🚧 wip: rewrite reprovision logic to match new user schema

This commit is contained in:
2026-05-28 21:01:54 +07:00
parent 8cebc0cd20
commit 57d19d4302
13 changed files with 120 additions and 110 deletions

View File

@ -15,8 +15,6 @@ Table users {
sex user_sex sex user_sex
phone_number String phone_number String
country countries country countries
auth_provider String
provider_token String
address user_addresses address user_addresses
preferences user_preferences preferences user_preferences
created_at DateTime [default: `now()`, not null] created_at DateTime [default: `now()`, not null]
@ -76,7 +74,6 @@ Table user_oauth_accounts {
provider_token String provider_token String
refresh_token String refresh_token String
expires_at DateTime expires_at DateTime
last_login DateTime [default: `now()`, not null]
created_at DateTime [default: `now()`, not null] created_at DateTime [default: `now()`, not null]
updated_at DateTime [not null] updated_at DateTime [not null]
user_id String [not null] user_id String [not null]
@ -98,7 +95,7 @@ Table user_sessions {
Table user_preferences { Table user_preferences {
user users [not null] user users [not null]
char_as_partner characters [not null] char_as_partner characters
comment_picture String comment_picture String
enable_watch_history Boolean [not null, default: true] enable_watch_history Boolean [not null, default: true]
enable_search_history Boolean [not null, default: false] enable_search_history Boolean [not null, default: false]
@ -115,7 +112,7 @@ Table user_preferences {
rating_preferences user_rating_preferences [not null] rating_preferences user_rating_preferences [not null]
country_preferences user_country_preferences [not null] country_preferences user_country_preferences [not null]
user_id String [pk] user_id String [pk]
char_as_partner_id String [not null] char_as_partner_id String
} }
Table user_genre_preferences { Table user_genre_preferences {

View File

@ -0,0 +1,10 @@
/*
Warnings:
- You are about to drop the column `auth_provider` on the `users` table. All the data in the column will be lost.
- You are about to drop the column `provider_token` on the `users` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "users" DROP COLUMN "auth_provider",
DROP COLUMN "provider_token";

View File

@ -0,0 +1,8 @@
/*
Warnings:
- You are about to drop the column `last_login` on the `user_oauth_accounts` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "user_oauth_accounts" DROP COLUMN "last_login";

View File

@ -178,7 +178,6 @@ model UserOauthAccounts {
provider_token String? @db.VarChar(255) provider_token String? @db.VarChar(255)
refresh_token String? @db.VarChar(255) refresh_token String? @db.VarChar(255)
expires_at DateTime? @db.Timestamptz() expires_at DateTime? @db.Timestamptz()
last_login DateTime @default(now()) @db.Timestamptz()
created_at DateTime @default(now()) @db.Timestamptz() created_at DateTime @default(now()) @db.Timestamptz()
updated_at DateTime @updatedAt @db.Timestamptz() updated_at DateTime @updatedAt @db.Timestamptz()
@ -204,7 +203,7 @@ model UserSession {
model UserPreference { model UserPreference {
user User @relation(fields: [user_id], references: [id]) user User @relation(fields: [user_id], references: [id])
char_as_partner Character @relation(fields: [char_as_partner_id], references: [id]) char_as_partner Character? @relation(fields: [char_as_partner_id], references: [id])
comment_picture String? @db.VarChar(255) comment_picture String? @db.VarChar(255)
enable_watch_history Boolean @default(true) enable_watch_history Boolean @default(true)
enable_search_history Boolean @default(false) enable_search_history Boolean @default(false)
@ -221,8 +220,8 @@ model UserPreference {
rating_preferences UserRatingPreference[] rating_preferences UserRatingPreference[]
country_preferences UserCountryPreference[] country_preferences UserCountryPreference[]
user_id String @id @db.Uuid user_id String @id @db.Uuid
char_as_partner_id String @db.Uuid char_as_partner_id String? @db.Uuid
@@map("user_preferences") @@map("user_preferences")
} }

View File

@ -13,10 +13,12 @@ export const getUserHeaderInformation = (clientInfo: string): UserHeaderInformat
const clientInfoHeader = (JSON.parse(clientInfo) as ClientInfoHeader) ?? ("unknown" as string); const clientInfoHeader = (JSON.parse(clientInfo) as ClientInfoHeader) ?? ("unknown" as string);
const userHeaderInformation = { const userHeaderInformation = {
ip: clientInfoHeader.ip ?? "unknown", ip: clientInfoHeader.ip,
deviceType: clientInfoHeader.deviceType ?? "unknown", deviceType: clientInfoHeader.deviceType ?? "desktop",
deviceOS: (clientInfoHeader.os ?? "unknown") + " " + (clientInfoHeader.osVersion ?? "unknown"), osType: clientInfoHeader.os ?? "unknown",
browser: (clientInfoHeader.browser ?? "unknown") + " " + (clientInfoHeader.browserVersion ?? "unknown"), osVersion: clientInfoHeader.osVersion ?? "unknown",
browserName: clientInfoHeader.browser ?? "unknown",
browserVersion: clientInfoHeader.browserVersion ?? "unknown",
}; };
return userHeaderInformation; return userHeaderInformation;

View File

@ -1,6 +1,8 @@
export interface UserHeaderInformation { export interface UserHeaderInformation {
ip: string; ip?: string;
deviceType: string; deviceType: string;
deviceOS: string; osType: string;
browser: string; osVersion: string;
browserName: string;
browserVersion: string;
} }

View File

@ -0,0 +1,23 @@
import { AppError } from "../../../../helpers/error/instances/app";
import { prisma } from "../../../../utils/databases/prisma/connection";
export const findAuthIdentityByEmailAndProviderRepository = async (email: string) => {
try {
return await prisma.user.findUnique({
where: {
email: email,
},
select: {
id: true,
oauth_accounts: {
select: {
provider_sub: true,
provider_name: true,
},
},
},
});
} catch (error) {
throw new AppError(500, "Error finding user by email", error);
}
};

View File

@ -12,14 +12,12 @@ export const googleCallbackService = async (
code: string; code: string;
callbackURI?: string; callbackURI?: string;
}, },
userHeaderInfo: UserHeaderInformation userHeaderInfo: UserHeaderInformation,
) => { ) => {
try { try {
// get code and state for validation from params and search for state in redis cache // get code and state for validation from params and search for state in redis cache
const state = query.state; const state = query.state;
const codeVerifier = await redis.get( const codeVerifier = await redis.get(`${process.env.APP_NAME}:pkce:${state}`);
`${process.env.APP_NAME}:pkce:${state}`
);
// return error if the state for validation is not found in redis, and delete if found // return error if the state for validation is not found in redis, and delete if found
if (!codeVerifier) throw new AppError(408, "Request timeout"); if (!codeVerifier) throw new AppError(408, "Request timeout");
@ -27,21 +25,15 @@ export const googleCallbackService = async (
// create access token with the result of validating the authorization code that compares access code with validator state // create access token with the result of validating the authorization code that compares access code with validator state
const google = googleProvider(query.callbackURI); const google = googleProvider(query.callbackURI);
const tokens = await google.validateAuthorizationCode( const tokens = await google.validateAuthorizationCode(query.code, codeVerifier);
query.code,
codeVerifier
);
// get user data from Google using the access token that has been created. // get user data from Google using the access token that has been created.
const accessToken = tokens.accessToken(); const accessToken = tokens.accessToken();
const response = await fetch( const response = await fetch("https://openidconnect.googleapis.com/v1/userinfo", {
"https://openidconnect.googleapis.com/v1/userinfo", headers: {
{ Authorization: `Bearer ${accessToken}`,
headers: { },
Authorization: `Bearer ${accessToken}`, });
},
}
);
// parse the user data response // parse the user data response
const userData = (await response.json()) as GoogleCallbackUserData; const userData = (await response.json()) as GoogleCallbackUserData;
@ -49,19 +41,17 @@ export const googleCallbackService = async (
// Provision or authenticate the user in the system // Provision or authenticate the user in the system
return await OAuthUserProvisionService( return await OAuthUserProvisionService(
{ {
provider: "google", fullname: userData.name,
providerId: userData.sub, username: `gle_${userData.sub}`,
providerToken: accessToken,
providerPayload: userData,
email: userData.email, email: userData.email,
username: `goo_${userData.sub}`,
name: userData.name,
avatar: userData.picture, avatar: userData.picture,
password: Math.random() oauthProvider: {
.toString(36) providerName: "google",
.slice(2, 16), sub: userData.sub,
token: accessToken,
},
}, },
userHeaderInfo userHeaderInfo,
); );
} catch (error) { } catch (error) {
ErrorForwarder(error, 500, "Authentication service error"); ErrorForwarder(error, 500, "Authentication service error");

View File

@ -1,39 +1,29 @@
import { User } from "@prisma/client";
import { UserHeaderInformation } from "../../../../helpers/http/userHeader/getUserHeaderInformation/types"; import { UserHeaderInformation } from "../../../../helpers/http/userHeader/getUserHeaderInformation/types";
import { findUserService } from "../../../user/services/internal/findUser.service";
import { createUserSessionService } from "../../../userSession/services/createUserSession.service"; import { createUserSessionService } from "../../../userSession/services/createUserSession.service";
import { ErrorForwarder } from "../../../../helpers/error/instances/forwarder"; import { ErrorForwarder } from "../../../../helpers/error/instances/forwarder";
import { createUserViaOauth } from "../../../user/user.types"; import { createUserViaOauth } from "../../../user/user.types";
import { createUserService } from "../../../user/services/internal/createUser.service"; import { createUserService } from "../../../user/services/internal/createUser.service";
import { AppError } from "../../../../helpers/error/instances/app"; import { AppError } from "../../../../helpers/error/instances/app";
import { findAuthIdentityByEmailAndProviderRepository } from "../../repositories/READ/findAuthIdentityByEmailAndProvider.repository";
export const OAuthUserProvisionService = async ( export const OAuthUserProvisionService = async (payload: createUserViaOauth, userHeaderInfo: UserHeaderInformation) => {
payload: createUserViaOauth,
userHeaderInfo: UserHeaderInformation
) => {
try { try {
const providerId = payload.providerId; const checkExistingUser = await findAuthIdentityByEmailAndProviderRepository(payload.email);
const findUserResult = (await findUserService({
identifier: providerId,
queryTarget: "providerId",
options: { verbosity: "full" },
})) as User;
if (findUserResult) {
return await createUserSessionService(findUserResult.id, userHeaderInfo);
} else {
const findUserByEmailOnly = await findUserService({
identifier: payload.email,
queryTarget: "email",
options: { verbosity: "exist" },
});
if (findUserByEmailOnly)
throw new AppError(409, "Email already in use with another account");
if (
checkExistingUser &&
checkExistingUser.oauth_accounts.some((account) => account.provider_sub === payload.oauthProvider.sub)
) {
// User already exists with this OAuth provider
return await createUserSessionService(checkExistingUser.id, userHeaderInfo);
} else if (!checkExistingUser) {
// No user with this email, create new user
const createdUser = await createUserService(payload); const createdUser = await createUserService(payload);
return await createUserSessionService(createdUser.id, userHeaderInfo); return await createUserSessionService(createdUser.id, userHeaderInfo);
} }
// User exists with this email but not with this OAuth provider
throw new AppError(409, "Email already in use with another account");
} catch (error) { } catch (error) {
ErrorForwarder(error); ErrorForwarder(error);
} }

View File

@ -3,18 +3,14 @@ import { ErrorForwarder } from "../../../../helpers/error/instances/forwarder";
import { userModel } from "../../user.model"; import { userModel } from "../../user.model";
import { createUserViaRegisterInput } from "../../user.types"; import { createUserViaRegisterInput } from "../../user.types";
export const createUserViaRegisterRepository = async ( export const createUserViaRegisterRepository = async (payload: createUserViaRegisterInput) => {
payload: createUserViaRegisterInput,
) => {
try { try {
return await userModel.create({ return await userModel.create({
data: { data: {
...payload, ...payload,
id: generateUUIDv7(), id: generateUUIDv7(),
preference: { preferences: {
create: { create: {},
id: generateUUIDv7(),
},
}, },
}, },
}); });

View File

@ -1,21 +1,17 @@
import { ErrorForwarder } from "../../../../helpers/error/instances/forwarder"; import { ErrorForwarder } from "../../../../helpers/error/instances/forwarder";
import { hashPassword } from "../../../../helpers/security/password/hash"; import { hashPassword } from "../../../../helpers/security/password/hash";
import { createUserViaRegisterRepository } from "../../repositories/create/createUserViaRegister.repository"; import { createUserViaRegisterRepository } from "../../repositories/create/createUserViaRegister.repository";
import { import { createUserViaOauth, createUserViaRegisterInput } from "../../user.types";
createUserViaOauth,
createUserViaRegisterInput,
} from "../../user.types";
export const createUserService = async ( export const createUserService = async (payload: createUserViaRegisterInput) => {
payload: createUserViaRegisterInput | createUserViaOauth
) => {
try { try {
const hashedPassword = await hashPassword(payload.password); if ("password" in payload && payload.password)
return await createUserViaRegisterRepository({
...payload,
password: await hashPassword(payload.password),
} as createUserViaRegisterInput);
return await createUserViaRegisterRepository({ return await createUserViaRegisterRepository(payload as Omit<createUserViaOauth, "oauthProvider">);
...payload,
password: hashedPassword,
});
} catch (error) { } catch (error) {
ErrorForwarder(error); ErrorForwarder(error);
} }

View File

@ -10,20 +10,20 @@ export interface getUserDataOptions {
export type getUserDataIncludeOptions = "preference" | "roles"; export type getUserDataIncludeOptions = "preference" | "roles";
export interface createUserViaRegisterInput { export interface createUserViaRegisterInput {
name: string; fullname: string;
username: string; username: string;
email: string; email: string;
password: string; password?: string;
}
export interface createUserViaOauth {
provider: string;
providerId: string;
providerToken?: string;
providerPayload?: unknown;
email: string;
username: string;
name: string;
avatar?: string; avatar?: string;
bio?: string; bio?: string;
password: string; datebirth?: Date;
}
export interface createUserViaOauth extends createUserViaRegisterInput {
oauthProvider: {
providerName: string;
sub: string;
token: string;
refreshToken?: string;
expiresAt?: Date;
};
} }

View File

@ -5,24 +5,21 @@ import { createUserSessionRepository } from "../repositories/createUserSession.r
import { jwtEncode } from "../../../helpers/http/jwt/encode"; import { jwtEncode } from "../../../helpers/http/jwt/encode";
import { createUserSessionInRedisService } from "./internal/createUserSessionInRedis.service"; import { createUserSessionInRedisService } from "./internal/createUserSessionInRedis.service";
export const createUserSessionService = async ( export const createUserSessionService = async (userId: string, userHeaderInfo: UserHeaderInformation) => {
userId: string,
userHeaderInfo: UserHeaderInformation,
) => {
try { try {
// set the date when the token will expire // set the date when the token will expire
const generateTokenExpirationDate = const generateTokenExpirationDate = Date.now() + Number(process.env.SESSION_EXPIRE) * 1000;
Date.now() + Number(process.env.SESSION_EXPIRE) * 1000;
// construct all data to fit the user session input query // construct all data to fit the user session input query
const constructData = { const constructData = {
userId, user_id: userId,
isAuthenticated: true, device_type: userHeaderInfo.deviceType,
deviceType: userHeaderInfo.deviceType, os_type: userHeaderInfo.osType,
deviceOs: userHeaderInfo.deviceOS, os_version: userHeaderInfo.osVersion,
deviceIp: userHeaderInfo.ip, browser_name: userHeaderInfo.browserName,
browser: userHeaderInfo.browser, browser_version: userHeaderInfo.browserVersion,
validUntil: new Date(generateTokenExpirationDate), ip_login: userHeaderInfo.ip,
valid_until: new Date(generateTokenExpirationDate),
} as Prisma.UserSessionUncheckedCreateInput; } as Prisma.UserSessionUncheckedCreateInput;
// insert user session into database // insert user session into database