feat(gateway): recover orphaned Kakao account links

This commit is contained in:
2026-08-08 10:01:23 +00:00
parent 4374c490ac
commit 5c0aac5561
13 changed files with 634 additions and 27 deletions
@@ -161,24 +161,58 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createSimp
throw new Error('User not found.');
},
async linkKakao(userId, input): Promise<UserRecord> {
if (usersByOauthId.has(`KAKAO:${input.oauthId}`) || usersByEmail.has(input.email.toLowerCase())) {
const normalizedEmail = input.email.toLowerCase();
const oauthOwner = usersByOauthId.get(`KAKAO:${input.oauthId}`);
const emailOwner = usersByEmail.get(normalizedEmail);
if ((oauthOwner && oauthOwner.id !== userId) || (emailOwner && emailOwner.id !== userId)) {
throw new Error('Kakao account already linked.');
}
for (const user of usersByName.values()) {
if (user.id !== userId) {
continue;
}
if (user.oauthType === 'KAKAO' && user.oauthId) {
usersByOauthId.delete(`KAKAO:${user.oauthId}`);
}
if (user.email && user.email.toLowerCase() !== normalizedEmail) {
usersByEmail.delete(user.email.toLowerCase());
}
user.oauthType = 'KAKAO';
user.oauthId = input.oauthId;
user.email = input.email.toLowerCase();
user.email = normalizedEmail;
user.oauthInfo = input.oauthInfo;
user.kakaoVerifiedAt = input.verifiedAt.toISOString();
user.kakaoTalkVerifiedUntil = undefined;
usersByOauthId.set(`KAKAO:${input.oauthId}`, user);
usersByEmail.set(user.email, user);
return user;
}
throw new Error('User not found.');
},
async relinkKakaoByEmail(userId, input): Promise<UserRecord> {
const normalizedEmail = input.email.toLowerCase();
const oauthOwner = usersByOauthId.get(`KAKAO:${input.oauthId}`);
const emailOwner = usersByEmail.get(normalizedEmail);
if ((oauthOwner && oauthOwner.id !== userId) || emailOwner?.id !== userId) {
throw new Error('Kakao account recovery ownership changed.');
}
for (const user of usersByName.values()) {
if (user.id !== userId || user.email?.toLowerCase() !== normalizedEmail) {
continue;
}
if (user.oauthType === 'KAKAO' && user.oauthId) {
usersByOauthId.delete(`KAKAO:${user.oauthId}`);
}
user.oauthType = 'KAKAO';
user.oauthId = input.oauthId;
user.oauthInfo = input.oauthInfo;
user.kakaoVerifiedAt = input.verifiedAt.toISOString();
user.kakaoTalkVerifiedUntil = undefined;
usersByOauthId.set(`KAKAO:${input.oauthId}`, user);
return user;
}
throw new Error('Kakao account recovery ownership changed.');
},
async updateRoles(userId: string, roles: string[]): Promise<void> {
for (const user of usersByName.values()) {
if (user.id === userId) {
@@ -14,6 +14,8 @@ export interface OAuthPendingState {
export interface OAuthSession {
id: string;
mode: OAuthMode;
intent?: 'register' | 'link_existing' | 'rejoin';
targetUserId?: string;
kakaoId: string;
email: string;
accessToken: string;
@@ -251,10 +251,32 @@ export const createPostgresUserRepository = (
email: input.email.toLowerCase(),
oauthInfo: input.oauthInfo as GatewayPrisma.JsonObject,
kakaoVerifiedAt: input.verifiedAt,
kakaoTalkVerifiedUntil: null,
},
});
return mapUser(row);
},
async relinkKakaoByEmail(userId, input): Promise<UserRecord> {
const normalizedEmail = input.email.toLowerCase();
const updated = await prisma.appUser.updateMany({
where: {
id: userId,
email: normalizedEmail,
},
data: {
oauthType: 'KAKAO',
oauthId: input.oauthId,
oauthInfo: input.oauthInfo as GatewayPrisma.JsonObject,
kakaoVerifiedAt: input.verifiedAt,
kakaoTalkVerifiedUntil: null,
},
});
if (updated.count !== 1) {
throw new Error('Kakao account recovery ownership changed.');
}
const row = await prisma.appUser.findUniqueOrThrow({ where: { id: userId } });
return mapUser(row);
},
async updateRoles(userId: string, roles: string[]): Promise<void> {
await prisma.appUser.update({
where: { id: userId },
@@ -122,6 +122,15 @@ export interface UserRepository {
verifiedAt: Date;
}
): Promise<UserRecord>;
relinkKakaoByEmail(
userId: string,
input: {
oauthId: string;
email: string;
oauthInfo: UserOAuthInfo;
verifiedAt: Date;
}
): Promise<UserRecord>;
updateRoles(userId: string, roles: string[]): Promise<void>;
updateSanctions(userId: string, sanctions: UserSanctions): Promise<void>;
updateKakaoGraceUntil(userId: string, until: Date | null): Promise<void>;