feat: add user icon library and in-game selection

This commit is contained in:
2026-08-01 03:40:36 +00:00
parent 395b60cdbe
commit a275e6a234
26 changed files with 1214 additions and 69 deletions
@@ -1,13 +1,23 @@
import { randomUUID } from 'node:crypto';
import { createSimplePasswordHasher, type PasswordHasher } from './passwordHasher.js';
import type { CreateUserInput, UserRecord, UserRepository } from './userRepository.js';
import type { CreateUserInput, UserIconRecord, UserRecord, UserRepository } from './userRepository.js';
// 유저 데이터 저장소를 메모리로 대체한 임시 구현.
export const createInMemoryUserRepository = (hasher: PasswordHasher = createSimplePasswordHasher()): UserRepository => {
const usersByName = new Map<string, UserRecord>();
const usersByOauthId = new Map<string, UserRecord>();
const usersByEmail = new Map<string, UserRecord>();
const iconsById = new Map<string, UserIconRecord>();
const nextRevision = (user: UserRecord, now: Date): string =>
new Date(
Math.max(
now.getTime(),
new Date(user.createdAt).getTime() + 1,
(user.iconRevision ? new Date(user.iconRevision).getTime() : 0) + 1
)
).toISOString();
return {
async findById(id: string): Promise<UserRecord | null> {
@@ -165,14 +175,18 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createSimp
imageServer: number,
updatedAt: Date,
dayStart: Date,
consumeDailyQuota: boolean
consumeDailyQuota: boolean,
allowCutoffEquality = false
): Promise<string | null> {
for (const user of usersByName.values()) {
if (user.id !== userId) {
continue;
}
if (user.picture !== 'default.jpg' && user.iconUpdatedAt && new Date(user.iconUpdatedAt) >= dayStart) {
return null;
if (user.picture !== 'default.jpg' && user.iconUpdatedAt) {
const previousUpdate = new Date(user.iconUpdatedAt);
if (allowCutoffEquality ? previousUpdate > dayStart : previousUpdate >= dayStart) {
return null;
}
}
const previousRevision = Math.max(
new Date(user.createdAt).getTime(),
@@ -191,6 +205,67 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createSimp
}
throw new Error('User not found.');
},
async listIcons(userId: string, includeRetired = false): Promise<UserIconRecord[]> {
return [...iconsById.values()]
.filter((icon) => icon.userId === userId && (includeRetired || !icon.retiredAt))
.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
},
async addIconForWindow(userId, picture, imageServer, now, uploadCutoff, maxActive) {
const user = [...usersByName.values()].find((candidate) => candidate.id === userId);
if (!user) return { ok: false, reason: 'NOT_FOUND' };
if (user.iconUpdatedAt && new Date(user.iconUpdatedAt) > uploadCutoff) {
return { ok: false, reason: 'COOLDOWN' };
}
const active = [...iconsById.values()].filter((icon) => icon.userId === userId && !icon.retiredAt);
if (active.length >= maxActive) return { ok: false, reason: 'LIMIT' };
const revision = nextRevision(user, now);
const icon: UserIconRecord = {
id: randomUUID(),
userId,
picture,
imageServer,
createdAt: now.toISOString(),
};
iconsById.set(icon.id, icon);
user.picture = picture;
user.imageServer = imageServer;
user.iconUpdatedAt = now.toISOString();
user.iconRevision = revision;
return { ok: true, icon, revision };
},
async setPreferredIcon(userId, iconId, now) {
const user = [...usersByName.values()].find((candidate) => candidate.id === userId);
const icon = iconsById.get(iconId);
if (!user || !icon || icon.userId !== userId || icon.retiredAt) return null;
const revision = nextRevision(user, now);
user.picture = icon.picture;
user.imageServer = icon.imageServer;
user.iconRevision = revision;
return revision;
},
async retireIconForWindow(userId, iconId, now, retireCutoff) {
const user = [...usersByName.values()].find((candidate) => candidate.id === userId);
if (!user) return { ok: false, reason: 'NOT_FOUND' };
if (user.iconRetiredAt && new Date(user.iconRetiredAt) > retireCutoff) {
return { ok: false, reason: 'COOLDOWN' };
}
const icon = iconsById.get(iconId);
if (!icon || icon.userId !== userId) return { ok: false, reason: 'NOT_FOUND' };
if (icon.retiredAt) return { ok: false, reason: 'ALREADY_RETIRED' };
icon.retiredAt = now.toISOString();
const preferredChanged = user.picture === icon.picture;
if (preferredChanged) {
const fallback = [...iconsById.values()]
.filter((candidate) => candidate.userId === userId && !candidate.retiredAt)
.sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.id.localeCompare(a.id))[0];
user.picture = fallback?.picture ?? 'default.jpg';
user.imageServer = fallback?.imageServer ?? 0;
}
const revision = nextRevision(user, now);
user.iconRevision = revision;
user.iconRetiredAt = now.toISOString();
return { ok: true, icon, revision, preferredChanged };
},
async resetProfileIcon(userId: string, requestedAt: Date): Promise<string | null> {
for (const user of usersByName.values()) {
if (user.id !== userId) {
@@ -1,7 +1,14 @@
import { GatewayPrisma, type GatewayPrismaClient } from '@sammo-ts/infra';
import { createSimplePasswordHasher, type PasswordHasher } from './passwordHasher.js';
import type { CreateUserInput, UserOAuthInfo, UserRecord, UserRepository, UserSanctions } from './userRepository.js';
import type {
CreateUserInput,
UserIconRecord,
UserOAuthInfo,
UserRecord,
UserRepository,
UserSanctions,
} from './userRepository.js';
const readStringArray = (value: unknown): string[] => {
if (!Array.isArray(value)) {
@@ -46,6 +53,7 @@ const mapUser = (row: {
iconUpdatedAt: Date | null;
iconRevision: Date | null;
profileIconResetAt: Date | null;
iconRetiredAt: Date | null;
thirdPartyUse: boolean;
termsAcceptedAt: Date | null;
privacyAcceptedAt: Date | null;
@@ -69,6 +77,7 @@ const mapUser = (row: {
iconUpdatedAt: row.iconUpdatedAt?.toISOString(),
iconRevision: row.iconRevision?.toISOString(),
profileIconResetAt: row.profileIconResetAt?.toISOString(),
iconRetiredAt: row.iconRetiredAt?.toISOString(),
thirdPartyUse: row.thirdPartyUse,
termsAcceptedAt: row.termsAcceptedAt?.toISOString(),
privacyAcceptedAt: row.privacyAcceptedAt?.toISOString(),
@@ -82,6 +91,22 @@ const mapUser = (row: {
legacyGrade: readLegacyGrade(row.legacyData),
});
const mapIcon = (row: {
id: string;
userId: string;
picture: string;
imageServer: number;
createdAt: Date;
retiredAt: Date | null;
}): UserIconRecord => ({
id: row.id,
userId: row.userId,
picture: row.picture,
imageServer: row.imageServer,
createdAt: row.createdAt.toISOString(),
retiredAt: row.retiredAt?.toISOString(),
});
export const createPostgresUserRepository = (
prisma: GatewayPrismaClient,
hasher: PasswordHasher = createSimplePasswordHasher()
@@ -242,7 +267,8 @@ export const createPostgresUserRepository = (
imageServer: number,
updatedAt: Date,
dayStart: Date,
consumeDailyQuota: boolean
consumeDailyQuota: boolean,
allowCutoffEquality = false
): Promise<string | null> {
const rows = await prisma.$queryRaw<Array<{ iconRevision: Date }>>(GatewayPrisma.sql`
UPDATE "app_user"
@@ -262,11 +288,119 @@ export const createPostgresUserRepository = (
"picture" = 'default.jpg'
OR "icon_updated_at" IS NULL
OR "icon_updated_at" < ${dayStart}
OR (${allowCutoffEquality} AND "icon_updated_at" = ${dayStart})
)
RETURNING "icon_revision" AS "iconRevision"
`);
return rows[0]?.iconRevision.toISOString() ?? null;
},
async listIcons(userId: string, includeRetired = false): Promise<UserIconRecord[]> {
const rows = await prisma.userIcon.findMany({
where: { userId, ...(includeRetired ? {} : { retiredAt: null }) },
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
});
return rows.map(mapIcon);
},
async addIconForWindow(userId, picture, imageServer, now, uploadCutoff, maxActive) {
return prisma.$transaction(async (tx) => {
const users = await tx.$queryRaw<
Array<{ createdAt: Date; iconUpdatedAt: Date | null; iconRevision: Date | null }>
>(GatewayPrisma.sql`
SELECT "created_at" AS "createdAt", "icon_updated_at" AS "iconUpdatedAt",
"icon_revision" AS "iconRevision"
FROM "app_user" WHERE "id" = ${userId} FOR UPDATE
`);
const user = users[0];
if (!user) return { ok: false as const, reason: 'NOT_FOUND' as const };
if (user.iconUpdatedAt && user.iconUpdatedAt > uploadCutoff) {
return { ok: false as const, reason: 'COOLDOWN' as const };
}
const activeCount = await tx.userIcon.count({ where: { userId, retiredAt: null } });
if (activeCount >= maxActive) return { ok: false as const, reason: 'LIMIT' as const };
const revision = new Date(
Math.max(now.getTime(), user.iconRevision?.getTime() ?? 0, user.createdAt.getTime()) +
(now.getTime() <= (user.iconRevision?.getTime() ?? 0) ? 1 : 0)
);
const icon = await tx.userIcon.create({ data: { userId, picture, imageServer, createdAt: now } });
await tx.appUser.update({
where: { id: userId },
data: { picture, imageServer, iconUpdatedAt: now, iconRevision: revision },
});
return { ok: true as const, icon: mapIcon(icon), revision: revision.toISOString() };
});
},
async setPreferredIcon(userId, iconId, now) {
return prisma.$transaction(async (tx) => {
const users = await tx.$queryRaw<Array<{ createdAt: Date; iconRevision: Date | null }>>(
GatewayPrisma.sql`SELECT "created_at" AS "createdAt", "icon_revision" AS "iconRevision"
FROM "app_user" WHERE "id" = ${userId} FOR UPDATE`
);
const user = users[0];
if (!user) return null;
const icon = await tx.userIcon.findFirst({ where: { id: iconId, userId, retiredAt: null } });
if (!icon) return null;
const previous = Math.max(user.createdAt.getTime(), user.iconRevision?.getTime() ?? 0);
const revision = new Date(Math.max(now.getTime(), previous + 1));
await tx.appUser.update({
where: { id: userId },
data: { picture: icon.picture, imageServer: icon.imageServer, iconRevision: revision },
});
return revision.toISOString();
});
},
async retireIconForWindow(userId, iconId, now, retireCutoff) {
return prisma.$transaction(async (tx) => {
const users = await tx.$queryRaw<
Array<{
picture: string;
createdAt: Date;
iconRevision: Date | null;
iconRetiredAt: Date | null;
}>
>(GatewayPrisma.sql`
SELECT "picture", "created_at" AS "createdAt", "icon_revision" AS "iconRevision",
"icon_retired_at" AS "iconRetiredAt"
FROM "app_user" WHERE "id" = ${userId} FOR UPDATE
`);
const user = users[0];
if (!user) return { ok: false as const, reason: 'NOT_FOUND' as const };
if (user.iconRetiredAt && user.iconRetiredAt > retireCutoff) {
return { ok: false as const, reason: 'COOLDOWN' as const };
}
const icon = await tx.userIcon.findFirst({ where: { id: iconId, userId } });
if (!icon) return { ok: false as const, reason: 'NOT_FOUND' as const };
if (icon.retiredAt) return { ok: false as const, reason: 'ALREADY_RETIRED' as const };
const retired = await tx.userIcon.update({ where: { id: icon.id }, data: { retiredAt: now } });
const preferredChanged = user.picture === icon.picture;
const fallback = preferredChanged
? await tx.userIcon.findFirst({
where: { userId, retiredAt: null, id: { not: icon.id } },
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
})
: null;
const previous = Math.max(user.createdAt.getTime(), user.iconRevision?.getTime() ?? 0);
const revision = new Date(Math.max(now.getTime(), previous + 1));
await tx.appUser.update({
where: { id: userId },
data: {
iconRetiredAt: now,
iconRevision: revision,
...(preferredChanged
? {
picture: fallback?.picture ?? 'default.jpg',
imageServer: fallback?.imageServer ?? 0,
}
: {}),
},
});
return {
ok: true as const,
icon: mapIcon(retired),
revision: revision.toISOString(),
preferredChanged,
};
});
},
async resetProfileIcon(userId: string, requestedAt: Date): Promise<string | null> {
return prisma.$transaction(async (tx) => {
const rows = await tx.$queryRaw<
+30 -1
View File
@@ -13,6 +13,7 @@ export interface UserRecord {
iconUpdatedAt?: string;
iconRevision?: string;
profileIconResetAt?: string;
iconRetiredAt?: string;
thirdPartyUse: boolean;
termsAcceptedAt?: string;
privacyAcceptedAt?: string;
@@ -26,6 +27,22 @@ export interface UserRecord {
legacyGrade?: number;
}
export interface UserIconRecord {
id: string;
userId: string;
picture: string;
imageServer: number;
createdAt: string;
retiredAt?: string;
}
export type AddUserIconResult =
{ ok: true; icon: UserIconRecord; revision: string } | { ok: false; reason: 'COOLDOWN' | 'LIMIT' | 'NOT_FOUND' };
export type RetireUserIconResult =
| { ok: true; icon: UserIconRecord; revision: string; preferredChanged: boolean }
| { ok: false; reason: 'COOLDOWN' | 'NOT_FOUND' | 'ALREADY_RETIRED' };
export interface PublicUser {
id: string;
username: string;
@@ -110,8 +127,20 @@ export interface UserRepository {
imageServer: number,
updatedAt: Date,
dayStart: Date,
consumeDailyQuota: boolean
consumeDailyQuota: boolean,
allowCutoffEquality?: boolean
): Promise<string | null>;
listIcons(userId: string, includeRetired?: boolean): Promise<UserIconRecord[]>;
addIconForWindow(
userId: string,
picture: string,
imageServer: number,
now: Date,
uploadCutoff: Date,
maxActive: number
): Promise<AddUserIconResult>;
setPreferredIcon(userId: string, iconId: string, now: Date): Promise<string | null>;
retireIconForWindow(userId: string, iconId: string, now: Date, retireCutoff: Date): Promise<RetireUserIconResult>;
resetProfileIcon(userId: string, requestedAt: Date): Promise<string | null>;
setThirdPartyUse(userId: string, allowed: boolean): Promise<void>;
scheduleDeletion(userId: string, deleteAfter: Date): Promise<void>;