feat: 계정 Web Push 전달 기반과 사건 판정을 추가한다

게임 상태 변경과 같은 트랜잭션에 내구성 outbox를 기록하고 Gateway가 계정 설정과 기기 구독으로 fan-out하도록 한다. 전역 기본값은 비활성으로 유지하며 migration과 회귀 테스트를 함께 추가한다.
This commit is contained in:
2026-08-23 13:16:07 +00:00
parent f858806295
commit 7bc3213b03
34 changed files with 1964 additions and 9 deletions
+79
View File
@@ -9,6 +9,7 @@ import { procedure, router } from '../trpc.js';
import type { UserRecord, UserSanctions } from '../auth/userRepository.js';
import { openPassword, zPasswordEnvelope } from '../auth/registrationInput.js';
import { resolveEffectiveAccountIcon } from '../auth/accountIconProjection.js';
import { WEB_PUSH_EVENT_TYPES } from '@sammo-ts/common';
const zSessionToken = z.string().min(1);
const MAX_ICON_BYTES = 50 * 1024;
@@ -117,6 +118,84 @@ const publishIconFlush = async (
};
export const accountRouter = router({
notifications: router({
get: procedure
.input(
z.object({
sessionToken: zSessionToken,
currentEndpoint: z.string().url().max(4096).optional(),
})
)
.query(async ({ ctx, input }) => {
const user = await requireSessionUser(ctx, input.sessionToken);
return ctx.webPush.getAccountState(user.id, input.currentEndpoint);
}),
setPreference: procedure
.input(
z.object({
sessionToken: zSessionToken,
profileName: z.string().min(1).max(128),
eventType: z.enum(WEB_PUSH_EVENT_TYPES),
enabled: z.boolean(),
targetYear: z.number().int().min(0).max(9999).nullable().optional(),
targetMonth: z.number().int().min(1).max(12).nullable().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const user = await requireSessionUser(ctx, input.sessionToken);
try {
await ctx.webPush.setPreference(user.id, input);
} catch (error) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: error instanceof Error ? error.message : '알림 설정을 저장하지 못했습니다.',
});
}
return { ok: true };
}),
subscribe: procedure
.input(
z.object({
sessionToken: zSessionToken,
subscription: z
.object({
endpoint: z.string().url().max(4096),
expirationTime: z.number().int().positive().nullable(),
keys: z.object({
p256dh: z.string().min(1).max(1024),
auth: z.string().min(1).max(1024),
}),
})
.strict(),
})
)
.mutation(async ({ ctx, input }) => {
const user = await requireSessionUser(ctx, input.sessionToken);
try {
const rawUserAgent = ctx.requestHeaders['user-agent'];
const userAgent = Array.isArray(rawUserAgent) ? rawUserAgent[0] : rawUserAgent;
await ctx.webPush.subscribe(user.id, input.subscription, userAgent);
} catch (error) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: error instanceof Error ? error.message : '이 기기의 알림 구독을 저장하지 못했습니다.',
});
}
return { ok: true };
}),
unsubscribe: procedure
.input(
z.object({
sessionToken: zSessionToken,
endpoint: z.string().url().max(4096),
})
)
.mutation(async ({ ctx, input }) => {
const user = await requireSessionUser(ctx, input.sessionToken);
await ctx.webPush.unsubscribe(user.id, input.endpoint);
return { ok: true };
}),
}),
get: procedure.input(z.object({ sessionToken: zSessionToken })).query(async ({ ctx, input }) => {
const user = await requireSessionUser(ctx, input.sessionToken);
const icons = await ctx.users.listIcons(user.id);