feat: synchronize account icons across game profiles

This commit is contained in:
2026-07-31 11:21:08 +00:00
parent c8adeeb47b
commit 5f20413552
87 changed files with 5755 additions and 280 deletions
@@ -1,4 +1,4 @@
import type { GatewayPrisma, GatewayPrismaClient } from '@sammo-ts/infra';
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';
@@ -44,6 +44,8 @@ const mapUser = (row: {
picture: string;
imageServer: number;
iconUpdatedAt: Date | null;
iconRevision: Date | null;
profileIconResetAt: Date | null;
thirdPartyUse: boolean;
termsAcceptedAt: Date | null;
privacyAcceptedAt: Date | null;
@@ -65,6 +67,8 @@ const mapUser = (row: {
picture: row.picture,
imageServer: row.imageServer,
iconUpdatedAt: row.iconUpdatedAt?.toISOString(),
iconRevision: row.iconRevision?.toISOString(),
profileIconResetAt: row.profileIconResetAt?.toISOString(),
thirdPartyUse: row.thirdPartyUse,
termsAcceptedAt: row.termsAcceptedAt?.toISOString(),
privacyAcceptedAt: row.privacyAcceptedAt?.toISOString(),
@@ -91,6 +95,15 @@ export const createPostgresUserRepository = (
});
return row ? mapUser(row) : null;
},
async findByIds(ids: string[]): Promise<UserRecord[]> {
if (ids.length === 0) {
return [];
}
const rows = await prisma.appUser.findMany({
where: { id: { in: ids } },
});
return rows.map(mapUser);
},
async findByUsername(username: string): Promise<UserRecord | null> {
const row = await prisma.appUser.findUnique({
where: {
@@ -219,9 +232,81 @@ export const createPostgresUserRepository = (
picture,
imageServer,
iconUpdatedAt: updatedAt,
iconRevision: updatedAt,
},
});
},
async updateIconForDay(
userId: string,
picture: string,
imageServer: number,
updatedAt: Date,
dayStart: Date,
consumeDailyQuota: boolean
): Promise<string | null> {
const rows = await prisma.$queryRaw<Array<{ iconRevision: Date }>>(GatewayPrisma.sql`
UPDATE "app_user"
SET
"picture" = ${picture},
"image_server" = ${imageServer},
"icon_updated_at" = CASE
WHEN ${consumeDailyQuota} THEN ${updatedAt}
ELSE "icon_updated_at"
END,
"icon_revision" = GREATEST(
${updatedAt},
COALESCE("icon_revision", "icon_updated_at", "created_at") + INTERVAL '1 millisecond'
)
WHERE "id" = ${userId}
AND (
"picture" = 'default.jpg'
OR "icon_updated_at" IS NULL
OR "icon_updated_at" < ${dayStart}
)
RETURNING "icon_revision" AS "iconRevision"
`);
return rows[0]?.iconRevision.toISOString() ?? null;
},
async resetProfileIcon(userId: string, requestedAt: Date): Promise<string | null> {
return prisma.$transaction(async (tx) => {
const rows = await tx.$queryRaw<
Array<{
iconRevision: Date | null;
iconUpdatedAt: Date | null;
createdAt: Date;
profileIconResetAt: Date | null;
}>
>(GatewayPrisma.sql`
SELECT
"icon_revision" AS "iconRevision",
"icon_updated_at" AS "iconUpdatedAt",
"created_at" AS "createdAt",
"profile_icon_reset_at" AS "profileIconResetAt"
FROM "app_user"
WHERE "id" = ${userId}
FOR UPDATE
`);
const row = rows[0];
if (!row) {
return null;
}
const previous = Math.max(
row.createdAt.getTime(),
row.iconUpdatedAt?.getTime() ?? 0,
row.iconRevision?.getTime() ?? 0,
row.profileIconResetAt?.getTime() ?? 0
);
const revision = new Date(Math.max(requestedAt.getTime(), previous + 1));
await tx.appUser.update({
where: { id: userId },
data: {
iconRevision: revision,
profileIconResetAt: revision,
},
});
return revision.toISOString();
});
},
async setThirdPartyUse(userId: string, allowed: boolean): Promise<void> {
await prisma.appUser.update({
where: { id: userId },