229 lines
8.5 KiB
TypeScript
229 lines
8.5 KiB
TypeScript
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 = <T extends object>(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<UserSanctions>(row.sanctions, {}),
|
|
oauthType: row.oauthType,
|
|
oauthId: row.oauthId ?? undefined,
|
|
email: row.email ?? undefined,
|
|
oauthInfo: readObject<UserOAuthInfo>(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<UserRecord | null> {
|
|
const row = await prisma.appUser.findUnique({
|
|
where: {
|
|
id,
|
|
},
|
|
});
|
|
return row ? mapUser(row) : null;
|
|
},
|
|
async findByUsername(username: string): Promise<UserRecord | null> {
|
|
const row = await prisma.appUser.findUnique({
|
|
where: {
|
|
loginId: username,
|
|
},
|
|
});
|
|
return row ? mapUser(row) : null;
|
|
},
|
|
async findByDisplayName(displayName: string): Promise<UserRecord | null> {
|
|
const row = await prisma.appUser.findUnique({
|
|
where: {
|
|
displayName,
|
|
},
|
|
});
|
|
return row ? mapUser(row) : null;
|
|
},
|
|
async findByOauthId(type: 'KAKAO', oauthId: string): Promise<UserRecord | null> {
|
|
const row = await prisma.appUser.findFirst({
|
|
where: {
|
|
oauthType: type,
|
|
oauthId,
|
|
},
|
|
});
|
|
return row ? mapUser(row) : null;
|
|
},
|
|
async findByEmail(email: string): Promise<UserRecord | null> {
|
|
const row = await prisma.appUser.findUnique({
|
|
where: {
|
|
email: email.toLowerCase(),
|
|
},
|
|
});
|
|
return row ? mapUser(row) : null;
|
|
},
|
|
async createUser(input: CreateUserInput): Promise<UserRecord> {
|
|
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<boolean> {
|
|
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<void> {
|
|
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<void> {
|
|
await prisma.appUser.update({
|
|
where: { id: userId },
|
|
data: {
|
|
oauthInfo: oauthInfo as GatewayPrisma.JsonObject,
|
|
},
|
|
});
|
|
},
|
|
async linkKakao(userId, input): Promise<UserRecord> {
|
|
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<void> {
|
|
await prisma.appUser.update({
|
|
where: { id: userId },
|
|
data: {
|
|
roles: roles as GatewayPrisma.JsonArray,
|
|
},
|
|
});
|
|
},
|
|
async updateSanctions(userId: string, sanctions: UserSanctions): Promise<void> {
|
|
await prisma.appUser.update({
|
|
where: { id: userId },
|
|
data: {
|
|
sanctions: sanctions as GatewayPrisma.JsonObject,
|
|
},
|
|
});
|
|
},
|
|
async updateIcon(userId: string, picture: string, imageServer: number, updatedAt: Date): Promise<void> {
|
|
await prisma.appUser.update({
|
|
where: { id: userId },
|
|
data: {
|
|
picture,
|
|
imageServer,
|
|
iconUpdatedAt: updatedAt,
|
|
},
|
|
});
|
|
},
|
|
async setThirdPartyUse(userId: string, allowed: boolean): Promise<void> {
|
|
await prisma.appUser.update({
|
|
where: { id: userId },
|
|
data: { thirdPartyUse: allowed },
|
|
});
|
|
},
|
|
async scheduleDeletion(userId: string, deleteAfter: Date): Promise<void> {
|
|
await prisma.appUser.update({
|
|
where: { id: userId },
|
|
data: { deleteAfter },
|
|
});
|
|
},
|
|
async deleteUser(userId: string): Promise<void> {
|
|
await prisma.appUser.delete({
|
|
where: { id: userId },
|
|
});
|
|
},
|
|
};
|
|
};
|