import type { GatewayPrisma, GatewayPrismaClient } from '@sammo-ts/infra'; import { createSimplePasswordHasher, type PasswordHasher } from './passwordHasher.js'; import type { CreateUserInput, UserOAuthInfo, UserRecord, UserRepository, UserSanctions } from './userRepository.js'; const readStringArray = (value: unknown): string[] => { if (!Array.isArray(value)) { return []; } return value.filter((item): item is string => typeof item === 'string'); }; const readObject = (value: unknown, fallback: T): T => { if (!value || typeof value !== 'object') { return fallback; } return value as T; }; const mapUser = (row: { id: string; loginId: string; displayName: string; passwordHash: string; passwordSalt: string; roles: GatewayPrisma.JsonValue; sanctions: GatewayPrisma.JsonValue; oauthType: 'NONE' | 'KAKAO'; oauthId: string | null; email: string | null; oauthInfo: GatewayPrisma.JsonValue; picture: string; imageServer: number; iconUpdatedAt: Date | null; thirdPartyUse: boolean; termsAcceptedAt: Date | null; privacyAcceptedAt: Date | null; kakaoVerifiedAt: Date | null; kakaoGraceStartedAt: Date; deleteAfter: Date | null; createdAt: Date; }): UserRecord => ({ id: row.id, username: row.loginId, displayName: row.displayName, roles: readStringArray(row.roles), sanctions: readObject(row.sanctions, {}), oauthType: row.oauthType, oauthId: row.oauthId ?? undefined, email: row.email ?? undefined, oauthInfo: readObject(row.oauthInfo, {}), picture: row.picture, imageServer: row.imageServer, iconUpdatedAt: row.iconUpdatedAt?.toISOString(), thirdPartyUse: row.thirdPartyUse, termsAcceptedAt: row.termsAcceptedAt?.toISOString(), privacyAcceptedAt: row.privacyAcceptedAt?.toISOString(), kakaoVerifiedAt: row.kakaoVerifiedAt?.toISOString(), kakaoGraceStartedAt: row.kakaoGraceStartedAt.toISOString(), deleteAfter: row.deleteAfter?.toISOString(), passwordHash: row.passwordHash, passwordSalt: row.passwordSalt, createdAt: row.createdAt.toISOString(), }); export const createPostgresUserRepository = ( prisma: GatewayPrismaClient, hasher: PasswordHasher = createSimplePasswordHasher() ): UserRepository => { return { async findById(id: string): Promise { const row = await prisma.appUser.findUnique({ where: { id, }, }); return row ? mapUser(row) : null; }, async findByUsername(username: string): Promise { const row = await prisma.appUser.findUnique({ where: { loginId: username, }, }); return row ? mapUser(row) : null; }, async findByDisplayName(displayName: string): Promise { const row = await prisma.appUser.findUnique({ where: { displayName, }, }); return row ? mapUser(row) : null; }, async findByOauthId(type: 'KAKAO', oauthId: string): Promise { const row = await prisma.appUser.findFirst({ where: { oauthType: type, oauthId, }, }); return row ? mapUser(row) : null; }, async findByEmail(email: string): Promise { const row = await prisma.appUser.findUnique({ where: { email: email.toLowerCase(), }, }); return row ? mapUser(row) : null; }, async createUser(input: CreateUserInput): Promise { const password = await hasher.hash(input.password); const oauthType = input.oauth?.type ?? 'NONE'; const now = new Date(); const row = await prisma.appUser.create({ data: { loginId: input.username, displayName: input.displayName ?? input.username, passwordHash: password.hash, passwordSalt: password.salt, roles: ['user'] satisfies GatewayPrisma.JsonArray, sanctions: {} satisfies GatewayPrisma.JsonObject, oauthType, oauthId: input.oauth?.id, email: input.oauth?.email?.toLowerCase(), oauthInfo: (input.oauth?.info ?? {}) as GatewayPrisma.JsonObject, termsAcceptedAt: input.termsAcceptedAt, privacyAcceptedAt: input.privacyAcceptedAt, thirdPartyUse: input.thirdPartyUse ?? false, kakaoVerifiedAt: input.oauth ? now : undefined, kakaoGraceStartedAt: now, }, }); return mapUser(row); }, async verifyPassword(user: UserRecord, password: string): Promise { const verified = await hasher.verify(password, user.passwordHash, user.passwordSalt); if (verified.ok && verified.needsUpgrade) { const upgraded = await hasher.hash(password); await prisma.appUser.update({ where: { id: user.id }, data: { passwordHash: upgraded.hash, passwordSalt: upgraded.salt, }, }); user.passwordHash = upgraded.hash; user.passwordSalt = upgraded.salt; } return verified.ok; }, async updatePassword(userId: string, password: string): Promise { const next = await hasher.hash(password); await prisma.appUser.update({ where: { id: userId }, data: { passwordHash: next.hash, passwordSalt: next.salt, }, }); }, async updateOAuthInfo(userId: string, oauthInfo: UserOAuthInfo): Promise { await prisma.appUser.update({ where: { id: userId }, data: { oauthInfo: oauthInfo as GatewayPrisma.JsonObject, }, }); }, async linkKakao(userId, input): Promise { const row = await prisma.appUser.update({ where: { id: userId }, data: { oauthType: 'KAKAO', oauthId: input.oauthId, email: input.email.toLowerCase(), oauthInfo: input.oauthInfo as GatewayPrisma.JsonObject, kakaoVerifiedAt: input.verifiedAt, }, }); return mapUser(row); }, async updateRoles(userId: string, roles: string[]): Promise { await prisma.appUser.update({ where: { id: userId }, data: { roles: roles as GatewayPrisma.JsonArray, }, }); }, async updateSanctions(userId: string, sanctions: UserSanctions): Promise { await prisma.appUser.update({ where: { id: userId }, data: { sanctions: sanctions as GatewayPrisma.JsonObject, }, }); }, async updateIcon(userId: string, picture: string, imageServer: number, updatedAt: Date): Promise { await prisma.appUser.update({ where: { id: userId }, data: { picture, imageServer, iconUpdatedAt: updatedAt, }, }); }, async setThirdPartyUse(userId: string, allowed: boolean): Promise { await prisma.appUser.update({ where: { id: userId }, data: { thirdPartyUse: allowed }, }); }, async scheduleDeletion(userId: string, deleteAfter: Date): Promise { await prisma.appUser.update({ where: { id: userId }, data: { deleteAfter }, }); }, async deleteUser(userId: string): Promise { await prisma.appUser.delete({ where: { id: userId }, }); }, }; };