From 57d19d43024807665e0dcb9706d637a7ebc75a91 Mon Sep 17 00:00:00 2001 From: Rafi Arrafif Date: Thu, 28 May 2026 21:01:54 +0700 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=A7=20wip:=20rewrite=20reprovision=20l?= =?UTF-8?q?ogic=20to=20match=20new=20user=20schema?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- prisma/dbml/schema.dbml | 7 +--- .../migration.sql | 10 +++++ .../migration.sql | 8 ++++ prisma/schema.prisma | 7 ++-- .../getUserHeaderInformation/index.ts | 10 +++-- .../getUserHeaderInformation/types.ts | 8 ++-- ...thIdentityByEmailAndProvider.repository.ts | 23 ++++++++++ .../services/http/googleCallback.service.ts | 42 +++++++------------ .../internal/OAuthUserProvision.service.ts | 38 +++++++---------- .../createUserViaRegister.repository.ts | 10 ++--- .../services/internal/createUser.service.ts | 20 ++++----- src/modules/user/user.types.ts | 24 +++++------ .../services/createUserSession.service.ts | 23 +++++----- 13 files changed, 120 insertions(+), 110 deletions(-) create mode 100644 prisma/migrations/20260528094152_remove_oauth_profile_from_user/migration.sql create mode 100644 prisma/migrations/20260528094932_remove_duplicate_oauth_activity_report/migration.sql create mode 100644 src/modules/auth/repositories/READ/findAuthIdentityByEmailAndProvider.repository.ts diff --git a/prisma/dbml/schema.dbml b/prisma/dbml/schema.dbml index 261e8d7..7742682 100644 --- a/prisma/dbml/schema.dbml +++ b/prisma/dbml/schema.dbml @@ -15,8 +15,6 @@ Table users { sex user_sex phone_number String country countries - auth_provider String - provider_token String address user_addresses preferences user_preferences created_at DateTime [default: `now()`, not null] @@ -76,7 +74,6 @@ Table user_oauth_accounts { provider_token String refresh_token String expires_at DateTime - last_login DateTime [default: `now()`, not null] created_at DateTime [default: `now()`, not null] updated_at DateTime [not null] user_id String [not null] @@ -98,7 +95,7 @@ Table user_sessions { Table user_preferences { user users [not null] - char_as_partner characters [not null] + char_as_partner characters comment_picture String enable_watch_history Boolean [not null, default: true] enable_search_history Boolean [not null, default: false] @@ -115,7 +112,7 @@ Table user_preferences { rating_preferences user_rating_preferences [not null] country_preferences user_country_preferences [not null] user_id String [pk] - char_as_partner_id String [not null] + char_as_partner_id String } Table user_genre_preferences { diff --git a/prisma/migrations/20260528094152_remove_oauth_profile_from_user/migration.sql b/prisma/migrations/20260528094152_remove_oauth_profile_from_user/migration.sql new file mode 100644 index 0000000..a491978 --- /dev/null +++ b/prisma/migrations/20260528094152_remove_oauth_profile_from_user/migration.sql @@ -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"; diff --git a/prisma/migrations/20260528094932_remove_duplicate_oauth_activity_report/migration.sql b/prisma/migrations/20260528094932_remove_duplicate_oauth_activity_report/migration.sql new file mode 100644 index 0000000..0ceb718 --- /dev/null +++ b/prisma/migrations/20260528094932_remove_duplicate_oauth_activity_report/migration.sql @@ -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"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e492eb2..46b08f1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -178,7 +178,6 @@ model UserOauthAccounts { provider_token String? @db.VarChar(255) refresh_token String? @db.VarChar(255) expires_at DateTime? @db.Timestamptz() - last_login DateTime @default(now()) @db.Timestamptz() created_at DateTime @default(now()) @db.Timestamptz() updated_at DateTime @updatedAt @db.Timestamptz() @@ -204,7 +203,7 @@ model UserSession { model UserPreference { 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) enable_watch_history Boolean @default(true) enable_search_history Boolean @default(false) @@ -221,8 +220,8 @@ model UserPreference { rating_preferences UserRatingPreference[] country_preferences UserCountryPreference[] - user_id String @id @db.Uuid - char_as_partner_id String @db.Uuid + user_id String @id @db.Uuid + char_as_partner_id String? @db.Uuid @@map("user_preferences") } diff --git a/src/helpers/http/userHeader/getUserHeaderInformation/index.ts b/src/helpers/http/userHeader/getUserHeaderInformation/index.ts index 8b18cbe..c7c0c0d 100644 --- a/src/helpers/http/userHeader/getUserHeaderInformation/index.ts +++ b/src/helpers/http/userHeader/getUserHeaderInformation/index.ts @@ -13,10 +13,12 @@ export const getUserHeaderInformation = (clientInfo: string): UserHeaderInformat const clientInfoHeader = (JSON.parse(clientInfo) as ClientInfoHeader) ?? ("unknown" as string); const userHeaderInformation = { - ip: clientInfoHeader.ip ?? "unknown", - deviceType: clientInfoHeader.deviceType ?? "unknown", - deviceOS: (clientInfoHeader.os ?? "unknown") + " " + (clientInfoHeader.osVersion ?? "unknown"), - browser: (clientInfoHeader.browser ?? "unknown") + " " + (clientInfoHeader.browserVersion ?? "unknown"), + ip: clientInfoHeader.ip, + deviceType: clientInfoHeader.deviceType ?? "desktop", + osType: clientInfoHeader.os ?? "unknown", + osVersion: clientInfoHeader.osVersion ?? "unknown", + browserName: clientInfoHeader.browser ?? "unknown", + browserVersion: clientInfoHeader.browserVersion ?? "unknown", }; return userHeaderInformation; diff --git a/src/helpers/http/userHeader/getUserHeaderInformation/types.ts b/src/helpers/http/userHeader/getUserHeaderInformation/types.ts index 9b4413b..86640db 100644 --- a/src/helpers/http/userHeader/getUserHeaderInformation/types.ts +++ b/src/helpers/http/userHeader/getUserHeaderInformation/types.ts @@ -1,6 +1,8 @@ export interface UserHeaderInformation { - ip: string; + ip?: string; deviceType: string; - deviceOS: string; - browser: string; + osType: string; + osVersion: string; + browserName: string; + browserVersion: string; } diff --git a/src/modules/auth/repositories/READ/findAuthIdentityByEmailAndProvider.repository.ts b/src/modules/auth/repositories/READ/findAuthIdentityByEmailAndProvider.repository.ts new file mode 100644 index 0000000..2705bb9 --- /dev/null +++ b/src/modules/auth/repositories/READ/findAuthIdentityByEmailAndProvider.repository.ts @@ -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); + } +}; diff --git a/src/modules/auth/services/http/googleCallback.service.ts b/src/modules/auth/services/http/googleCallback.service.ts index d018568..96136bf 100644 --- a/src/modules/auth/services/http/googleCallback.service.ts +++ b/src/modules/auth/services/http/googleCallback.service.ts @@ -12,14 +12,12 @@ export const googleCallbackService = async ( code: string; callbackURI?: string; }, - userHeaderInfo: UserHeaderInformation + userHeaderInfo: UserHeaderInformation, ) => { try { // get code and state for validation from params and search for state in redis cache const state = query.state; - const codeVerifier = await redis.get( - `${process.env.APP_NAME}:pkce:${state}` - ); + const codeVerifier = await redis.get(`${process.env.APP_NAME}:pkce:${state}`); // return error if the state for validation is not found in redis, and delete if found 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 const google = googleProvider(query.callbackURI); - const tokens = await google.validateAuthorizationCode( - query.code, - codeVerifier - ); + const tokens = await google.validateAuthorizationCode(query.code, codeVerifier); // get user data from Google using the access token that has been created. const accessToken = tokens.accessToken(); - const response = await fetch( - "https://openidconnect.googleapis.com/v1/userinfo", - { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - } - ); + const response = await fetch("https://openidconnect.googleapis.com/v1/userinfo", { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); // parse the user data response const userData = (await response.json()) as GoogleCallbackUserData; @@ -49,19 +41,17 @@ export const googleCallbackService = async ( // Provision or authenticate the user in the system return await OAuthUserProvisionService( { - provider: "google", - providerId: userData.sub, - providerToken: accessToken, - providerPayload: userData, + fullname: userData.name, + username: `gle_${userData.sub}`, email: userData.email, - username: `goo_${userData.sub}`, - name: userData.name, avatar: userData.picture, - password: Math.random() - .toString(36) - .slice(2, 16), + oauthProvider: { + providerName: "google", + sub: userData.sub, + token: accessToken, + }, }, - userHeaderInfo + userHeaderInfo, ); } catch (error) { ErrorForwarder(error, 500, "Authentication service error"); diff --git a/src/modules/auth/services/internal/OAuthUserProvision.service.ts b/src/modules/auth/services/internal/OAuthUserProvision.service.ts index 4aa65ad..6672b36 100644 --- a/src/modules/auth/services/internal/OAuthUserProvision.service.ts +++ b/src/modules/auth/services/internal/OAuthUserProvision.service.ts @@ -1,39 +1,29 @@ -import { User } from "@prisma/client"; import { UserHeaderInformation } from "../../../../helpers/http/userHeader/getUserHeaderInformation/types"; -import { findUserService } from "../../../user/services/internal/findUser.service"; import { createUserSessionService } from "../../../userSession/services/createUserSession.service"; import { ErrorForwarder } from "../../../../helpers/error/instances/forwarder"; import { createUserViaOauth } from "../../../user/user.types"; import { createUserService } from "../../../user/services/internal/createUser.service"; import { AppError } from "../../../../helpers/error/instances/app"; +import { findAuthIdentityByEmailAndProviderRepository } from "../../repositories/READ/findAuthIdentityByEmailAndProvider.repository"; -export const OAuthUserProvisionService = async ( - payload: createUserViaOauth, - userHeaderInfo: UserHeaderInformation -) => { +export const OAuthUserProvisionService = async (payload: createUserViaOauth, userHeaderInfo: UserHeaderInformation) => { try { - const providerId = payload.providerId; - 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"); + const checkExistingUser = await findAuthIdentityByEmailAndProviderRepository(payload.email); + 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); 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) { ErrorForwarder(error); } diff --git a/src/modules/user/repositories/create/createUserViaRegister.repository.ts b/src/modules/user/repositories/create/createUserViaRegister.repository.ts index 1c8c5b6..e387c96 100644 --- a/src/modules/user/repositories/create/createUserViaRegister.repository.ts +++ b/src/modules/user/repositories/create/createUserViaRegister.repository.ts @@ -3,18 +3,14 @@ import { ErrorForwarder } from "../../../../helpers/error/instances/forwarder"; import { userModel } from "../../user.model"; import { createUserViaRegisterInput } from "../../user.types"; -export const createUserViaRegisterRepository = async ( - payload: createUserViaRegisterInput, -) => { +export const createUserViaRegisterRepository = async (payload: createUserViaRegisterInput) => { try { return await userModel.create({ data: { ...payload, id: generateUUIDv7(), - preference: { - create: { - id: generateUUIDv7(), - }, + preferences: { + create: {}, }, }, }); diff --git a/src/modules/user/services/internal/createUser.service.ts b/src/modules/user/services/internal/createUser.service.ts index dab7a4a..b81bf5f 100644 --- a/src/modules/user/services/internal/createUser.service.ts +++ b/src/modules/user/services/internal/createUser.service.ts @@ -1,21 +1,17 @@ import { ErrorForwarder } from "../../../../helpers/error/instances/forwarder"; import { hashPassword } from "../../../../helpers/security/password/hash"; import { createUserViaRegisterRepository } from "../../repositories/create/createUserViaRegister.repository"; -import { - createUserViaOauth, - createUserViaRegisterInput, -} from "../../user.types"; +import { createUserViaOauth, createUserViaRegisterInput } from "../../user.types"; -export const createUserService = async ( - payload: createUserViaRegisterInput | createUserViaOauth -) => { +export const createUserService = async (payload: createUserViaRegisterInput) => { 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({ - ...payload, - password: hashedPassword, - }); + return await createUserViaRegisterRepository(payload as Omit); } catch (error) { ErrorForwarder(error); } diff --git a/src/modules/user/user.types.ts b/src/modules/user/user.types.ts index 20df9e8..74659fb 100644 --- a/src/modules/user/user.types.ts +++ b/src/modules/user/user.types.ts @@ -10,20 +10,20 @@ export interface getUserDataOptions { export type getUserDataIncludeOptions = "preference" | "roles"; export interface createUserViaRegisterInput { - name: string; + fullname: string; username: string; email: string; - password: string; -} -export interface createUserViaOauth { - provider: string; - providerId: string; - providerToken?: string; - providerPayload?: unknown; - email: string; - username: string; - name: string; + password?: string; avatar?: string; bio?: string; - password: string; + datebirth?: Date; +} +export interface createUserViaOauth extends createUserViaRegisterInput { + oauthProvider: { + providerName: string; + sub: string; + token: string; + refreshToken?: string; + expiresAt?: Date; + }; } diff --git a/src/modules/userSession/services/createUserSession.service.ts b/src/modules/userSession/services/createUserSession.service.ts index a59cc6d..b965c92 100644 --- a/src/modules/userSession/services/createUserSession.service.ts +++ b/src/modules/userSession/services/createUserSession.service.ts @@ -5,24 +5,21 @@ import { createUserSessionRepository } from "../repositories/createUserSession.r import { jwtEncode } from "../../../helpers/http/jwt/encode"; import { createUserSessionInRedisService } from "./internal/createUserSessionInRedis.service"; -export const createUserSessionService = async ( - userId: string, - userHeaderInfo: UserHeaderInformation, -) => { +export const createUserSessionService = async (userId: string, userHeaderInfo: UserHeaderInformation) => { try { // set the date when the token will expire - const generateTokenExpirationDate = - Date.now() + Number(process.env.SESSION_EXPIRE) * 1000; + const generateTokenExpirationDate = Date.now() + Number(process.env.SESSION_EXPIRE) * 1000; // construct all data to fit the user session input query const constructData = { - userId, - isAuthenticated: true, - deviceType: userHeaderInfo.deviceType, - deviceOs: userHeaderInfo.deviceOS, - deviceIp: userHeaderInfo.ip, - browser: userHeaderInfo.browser, - validUntil: new Date(generateTokenExpirationDate), + user_id: userId, + device_type: userHeaderInfo.deviceType, + os_type: userHeaderInfo.osType, + os_version: userHeaderInfo.osVersion, + browser_name: userHeaderInfo.browserName, + browser_version: userHeaderInfo.browserVersion, + ip_login: userHeaderInfo.ip, + valid_until: new Date(generateTokenExpirationDate), } as Prisma.UserSessionUncheckedCreateInput; // insert user session into database