feat: 계정 Web Push 전달 기반과 사건 판정을 추가한다
게임 상태 변경과 같은 트랜잭션에 내구성 outbox를 기록하고 Gateway가 계정 설정과 기기 구독으로 fan-out하도록 한다. 전역 기본값은 비활성으로 유지하며 migration과 회귀 테스트를 함께 추가한다.
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user