merge: 최신 main 변경을 전투 시뮬레이터 payload 작업에 반영한다
This commit is contained in:
@@ -29,3 +29,4 @@ export * from './legacyArchive/ArchivedGeneralSnapshot.js';
|
||||
export * from './gateway/profileStatus.js';
|
||||
export * from './game/accessPenalty.js';
|
||||
export * from './http/trpcTransport.js';
|
||||
export * from './webPush/types.js';
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
export const WEB_PUSH_EVENT_TYPES = [
|
||||
'TROOP_ANNIHILATED',
|
||||
'PRIVATE_MESSAGE_RECEIVED',
|
||||
'AUTONOMOUS_ACTION_ENDED',
|
||||
'RESERVED_TURNS_ENDED',
|
||||
'PROFILE_PREOPENED',
|
||||
'PROFILE_OPEN_SCHEDULED',
|
||||
'PROFILE_OPENED',
|
||||
'NATION_DESTROYED',
|
||||
'TARGET_DATE_REACHED',
|
||||
] as const;
|
||||
|
||||
export type WebPushEventType = (typeof WEB_PUSH_EVENT_TYPES)[number];
|
||||
|
||||
export const WEB_PUSH_TARGETED_EVENT_TYPES = [
|
||||
'TROOP_ANNIHILATED',
|
||||
'PRIVATE_MESSAGE_RECEIVED',
|
||||
'AUTONOMOUS_ACTION_ENDED',
|
||||
'RESERVED_TURNS_ENDED',
|
||||
'NATION_DESTROYED',
|
||||
] as const satisfies readonly WebPushEventType[];
|
||||
|
||||
export type WebPushTargetedEventType = (typeof WEB_PUSH_TARGETED_EVENT_TYPES)[number];
|
||||
|
||||
export interface WebPushEventEnvelopeV1 {
|
||||
version: 1;
|
||||
eventId: string;
|
||||
eventType: WebPushEventType;
|
||||
profileName: string;
|
||||
userIds: string[];
|
||||
year?: number;
|
||||
month?: number;
|
||||
occurredAt: string;
|
||||
}
|
||||
|
||||
export interface WebPushClientSubscription {
|
||||
endpoint: string;
|
||||
expirationTime: number | null;
|
||||
keys: {
|
||||
p256dh: string;
|
||||
auth: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const isWebPushEventType = (value: unknown): value is WebPushEventType =>
|
||||
typeof value === 'string' && (WEB_PUSH_EVENT_TYPES as readonly string[]).includes(value);
|
||||
@@ -108,6 +108,25 @@ model ReadModelOutbox {
|
||||
@@map("read_model_outbox")
|
||||
}
|
||||
|
||||
model WebPushOutbox {
|
||||
id BigInt @id @default(autoincrement())
|
||||
eventId String @unique @map("event_id")
|
||||
eventType String @map("event_type")
|
||||
userIds String[] @default([]) @map("user_ids")
|
||||
year Int?
|
||||
month Int?
|
||||
attempts Int @default(0)
|
||||
availableAt DateTime @default(now()) @map("available_at")
|
||||
lockedAt DateTime? @map("locked_at")
|
||||
lockOwner String? @map("lock_owner")
|
||||
deliveredAt DateTime? @map("delivered_at")
|
||||
lastError String? @map("last_error")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@index([deliveredAt, availableAt, id], map: "web_push_outbox_delivered_at_available_at_id_idx")
|
||||
@@map("web_push_outbox")
|
||||
}
|
||||
|
||||
model ReadModelRevisionMeta {
|
||||
id Int @id
|
||||
coverageVersion Int @default(0) @map("coverage_version")
|
||||
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
CREATE TABLE "web_push_subscription" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"user_id" TEXT NOT NULL,
|
||||
"endpoint" TEXT NOT NULL,
|
||||
"p256dh" TEXT NOT NULL,
|
||||
"auth" TEXT NOT NULL,
|
||||
"expiration_time" TIMESTAMP(3),
|
||||
"user_agent" TEXT,
|
||||
"disabled_at" TIMESTAMP(3),
|
||||
"last_seen_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "web_push_subscription_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "web_push_preference" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"user_id" TEXT NOT NULL,
|
||||
"profile_name" TEXT NOT NULL,
|
||||
"event_type" TEXT NOT NULL,
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT false,
|
||||
"target_year" INTEGER,
|
||||
"target_month" INTEGER,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "web_push_preference_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "web_push_event_receipt" (
|
||||
"event_id" TEXT NOT NULL,
|
||||
"profile_name" TEXT NOT NULL,
|
||||
"event_type" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "web_push_event_receipt_pkey" PRIMARY KEY ("event_id")
|
||||
);
|
||||
|
||||
CREATE TABLE "web_push_notification" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"dedupe_key" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"profile_name" TEXT NOT NULL,
|
||||
"event_type" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"body" TEXT NOT NULL,
|
||||
"url" TEXT NOT NULL,
|
||||
"tag" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "web_push_notification_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "web_push_delivery" (
|
||||
"id" BIGSERIAL NOT NULL,
|
||||
"notification_id" UUID NOT NULL,
|
||||
"subscription_id" UUID NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'PENDING',
|
||||
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||
"available_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"locked_at" TIMESTAMP(3),
|
||||
"lock_owner" TEXT,
|
||||
"delivered_at" TIMESTAMP(3),
|
||||
"last_error" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "web_push_delivery_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "web_push_profile_cursor" (
|
||||
"profile_name" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL,
|
||||
"preopen_at" TIMESTAMP(3),
|
||||
"open_at" TIMESTAMP(3),
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "web_push_profile_cursor_pkey" PRIMARY KEY ("profile_name")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "web_push_subscription_endpoint_key" ON "web_push_subscription"("endpoint");
|
||||
CREATE INDEX "web_push_subscription_user_id_disabled_at_updated_at_idx"
|
||||
ON "web_push_subscription"("user_id", "disabled_at", "updated_at");
|
||||
CREATE UNIQUE INDEX "web_push_preference_user_id_profile_name_event_type_key"
|
||||
ON "web_push_preference"("user_id", "profile_name", "event_type");
|
||||
CREATE INDEX "web_push_preference_profile_name_event_type_enabled_idx"
|
||||
ON "web_push_preference"("profile_name", "event_type", "enabled");
|
||||
CREATE INDEX "web_push_event_receipt_created_at_idx" ON "web_push_event_receipt"("created_at");
|
||||
CREATE UNIQUE INDEX "web_push_notification_dedupe_key_key" ON "web_push_notification"("dedupe_key");
|
||||
CREATE INDEX "web_push_notification_user_id_created_at_idx" ON "web_push_notification"("user_id", "created_at");
|
||||
CREATE UNIQUE INDEX "web_push_delivery_notification_id_subscription_id_key"
|
||||
ON "web_push_delivery"("notification_id", "subscription_id");
|
||||
CREATE INDEX "web_push_delivery_status_available_at_id_idx" ON "web_push_delivery"("status", "available_at", "id");
|
||||
|
||||
ALTER TABLE "web_push_subscription"
|
||||
ADD CONSTRAINT "web_push_subscription_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "app_user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "web_push_preference"
|
||||
ADD CONSTRAINT "web_push_preference_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "app_user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "web_push_notification"
|
||||
ADD CONSTRAINT "web_push_notification_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "app_user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "web_push_delivery"
|
||||
ADD CONSTRAINT "web_push_delivery_notification_id_fkey"
|
||||
FOREIGN KEY ("notification_id") REFERENCES "web_push_notification"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "web_push_delivery"
|
||||
ADD CONSTRAINT "web_push_delivery_subscription_id_fkey"
|
||||
FOREIGN KEY ("subscription_id") REFERENCES "web_push_subscription"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -111,6 +111,9 @@ model AppUser {
|
||||
legacyData Json @default(dbgenerated("'{}'::jsonb")) @map("legacy_data")
|
||||
icons UserIcon[]
|
||||
specialAccessGrants SpecialAccountAccessGrant[]
|
||||
webPushSubscriptions WebPushSubscription[]
|
||||
webPushPreferences WebPushPreference[]
|
||||
webPushNotifications WebPushNotification[]
|
||||
|
||||
@@map("app_user")
|
||||
}
|
||||
@@ -240,6 +243,100 @@ model GatewayProfile {
|
||||
@@map("gateway_profile")
|
||||
}
|
||||
|
||||
model WebPushSubscription {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
user AppUser @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
endpoint String @unique @db.Text
|
||||
p256dh String @db.Text
|
||||
auth String @db.Text
|
||||
expirationTime DateTime? @map("expiration_time")
|
||||
userAgent String? @map("user_agent") @db.Text
|
||||
disabledAt DateTime? @map("disabled_at")
|
||||
lastSeenAt DateTime @default(now()) @map("last_seen_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
deliveries WebPushDelivery[]
|
||||
|
||||
@@index([userId, disabledAt, updatedAt])
|
||||
@@map("web_push_subscription")
|
||||
}
|
||||
|
||||
model WebPushPreference {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
user AppUser @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
profileName String @map("profile_name")
|
||||
eventType String @map("event_type")
|
||||
enabled Boolean @default(false)
|
||||
targetYear Int? @map("target_year")
|
||||
targetMonth Int? @map("target_month")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@unique([userId, profileName, eventType])
|
||||
@@index([profileName, eventType, enabled])
|
||||
@@map("web_push_preference")
|
||||
}
|
||||
|
||||
model WebPushEventReceipt {
|
||||
eventId String @id @map("event_id")
|
||||
profileName String @map("profile_name")
|
||||
eventType String @map("event_type")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@index([createdAt])
|
||||
@@map("web_push_event_receipt")
|
||||
}
|
||||
|
||||
model WebPushNotification {
|
||||
id String @id @default(uuid())
|
||||
dedupeKey String @unique @map("dedupe_key")
|
||||
userId String @map("user_id")
|
||||
user AppUser @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
profileName String @map("profile_name")
|
||||
eventType String @map("event_type")
|
||||
title String
|
||||
body String
|
||||
url String
|
||||
tag String
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
deliveries WebPushDelivery[]
|
||||
|
||||
@@index([userId, createdAt])
|
||||
@@map("web_push_notification")
|
||||
}
|
||||
|
||||
model WebPushDelivery {
|
||||
id BigInt @id @default(autoincrement())
|
||||
notificationId String @map("notification_id")
|
||||
notification WebPushNotification @relation(fields: [notificationId], references: [id], onDelete: Cascade)
|
||||
subscriptionId String @map("subscription_id")
|
||||
subscription WebPushSubscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
|
||||
status String @default("PENDING")
|
||||
attempts Int @default(0)
|
||||
availableAt DateTime @default(now()) @map("available_at")
|
||||
lockedAt DateTime? @map("locked_at")
|
||||
lockOwner String? @map("lock_owner")
|
||||
deliveredAt DateTime? @map("delivered_at")
|
||||
lastError String? @map("last_error")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@unique([notificationId, subscriptionId])
|
||||
@@index([status, availableAt, id])
|
||||
@@map("web_push_delivery")
|
||||
}
|
||||
|
||||
model WebPushProfileCursor {
|
||||
profileName String @id @map("profile_name")
|
||||
status String
|
||||
preopenAt DateTime? @map("preopen_at")
|
||||
openAt DateTime? @map("open_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@map("web_push_profile_cursor")
|
||||
}
|
||||
|
||||
model GatewayRuntimeAction {
|
||||
id String @id @default(uuid())
|
||||
profileName String @map("profile_name")
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
CREATE TABLE "web_push_outbox" (
|
||||
"id" BIGSERIAL NOT NULL,
|
||||
"event_id" TEXT NOT NULL,
|
||||
"event_type" TEXT NOT NULL,
|
||||
"user_ids" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
|
||||
"year" INTEGER,
|
||||
"month" INTEGER,
|
||||
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||
"available_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"locked_at" TIMESTAMP(3),
|
||||
"lock_owner" TEXT,
|
||||
"delivered_at" TIMESTAMP(3),
|
||||
"last_error" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "web_push_outbox_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "web_push_outbox_event_id_key" ON "web_push_outbox"("event_id");
|
||||
CREATE INDEX "web_push_outbox_delivered_at_available_at_id_idx"
|
||||
ON "web_push_outbox"("delivered_at", "available_at", "id");
|
||||
@@ -47,4 +47,5 @@ export interface DatabaseClient {
|
||||
inputEvent: GamePrisma.InputEventDelegate;
|
||||
turnDaemonLease: GamePrisma.TurnDaemonLeaseDelegate;
|
||||
readModelOutbox: GamePrisma.ReadModelOutboxDelegate;
|
||||
webPushOutbox: GamePrisma.WebPushOutboxDelegate;
|
||||
}
|
||||
|
||||
@@ -10,3 +10,4 @@ export * from './readModelChangeJournal.js';
|
||||
export * from './readModelOutboxDispatcher.js';
|
||||
export * from './readModelCoverageActivation.js';
|
||||
export * from './gameSchemaAdvisoryLock.js';
|
||||
export * from './webPushOutbox.js';
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { WebPushEventType } from '@sammo-ts/common';
|
||||
|
||||
import type { GamePrisma } from './gamePrisma.js';
|
||||
|
||||
export type WebPushOutboxDatabase = Pick<GamePrisma.TransactionClient, 'general' | 'webPushOutbox'>;
|
||||
|
||||
export interface WebPushOutboxEventInput {
|
||||
eventId: string;
|
||||
eventType: WebPushEventType;
|
||||
userIds?: readonly string[];
|
||||
year?: number;
|
||||
month?: number;
|
||||
}
|
||||
|
||||
const uniqueUserIds = (values: readonly string[]): string[] => [...new Set(values.filter(Boolean))].sort();
|
||||
|
||||
export const enqueueWebPushOutboxEvents = async (
|
||||
db: WebPushOutboxDatabase,
|
||||
events: readonly WebPushOutboxEventInput[]
|
||||
): Promise<number> => {
|
||||
if (events.length === 0) return 0;
|
||||
const result = await db.webPushOutbox.createMany({
|
||||
data: events.map((event) => ({
|
||||
eventId: event.eventId,
|
||||
eventType: event.eventType,
|
||||
userIds: uniqueUserIds(event.userIds ?? []),
|
||||
...(event.year === undefined ? {} : { year: event.year }),
|
||||
...(event.month === undefined ? {} : { month: event.month }),
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
return result.count;
|
||||
};
|
||||
|
||||
export const enqueuePrivateMessageWebPush = async (
|
||||
db: WebPushOutboxDatabase,
|
||||
draft: { msgType: string; mailbox: number; destId: number },
|
||||
messageId: number
|
||||
): Promise<void> => {
|
||||
if (draft.msgType !== 'private' || draft.mailbox !== draft.destId) return;
|
||||
const recipient = await db.general.findUnique({
|
||||
where: { id: draft.destId },
|
||||
select: { userId: true },
|
||||
});
|
||||
if (!recipient?.userId) return;
|
||||
await enqueueWebPushOutboxEvents(db, [
|
||||
{
|
||||
eventId: `message:${messageId}`,
|
||||
eventType: 'PRIVATE_MESSAGE_RECEIVED',
|
||||
userIds: [recipient.userId],
|
||||
},
|
||||
]);
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { GamePrismaClient } from '../src/gamePrisma.js';
|
||||
import { enqueuePrivateMessageWebPush } from '../src/webPushOutbox.js';
|
||||
|
||||
describe('web push outbox event writer', () => {
|
||||
it('stores only a private receiver event without message content', async () => {
|
||||
const createMany = vi.fn().mockResolvedValue({ count: 1 });
|
||||
const db = {
|
||||
general: { findUnique: vi.fn().mockResolvedValue({ userId: '11111111-1111-4111-8111-111111111111' }) },
|
||||
webPushOutbox: { createMany },
|
||||
} as unknown as GamePrismaClient;
|
||||
|
||||
await enqueuePrivateMessageWebPush(db, { msgType: 'private', mailbox: 8, destId: 8 }, 42);
|
||||
|
||||
expect(createMany).toHaveBeenCalledWith({
|
||||
data: [
|
||||
{
|
||||
eventId: 'message:42',
|
||||
eventType: 'PRIVATE_MESSAGE_RECEIVED',
|
||||
userIds: ['11111111-1111-4111-8111-111111111111'],
|
||||
},
|
||||
],
|
||||
skipDuplicates: true,
|
||||
});
|
||||
expect(JSON.stringify(createMany.mock.calls)).not.toContain('message content');
|
||||
});
|
||||
|
||||
it('does not notify for the sender copy or a non-private message', async () => {
|
||||
const findUnique = vi.fn();
|
||||
const createMany = vi.fn();
|
||||
const db = {
|
||||
general: { findUnique },
|
||||
webPushOutbox: { createMany },
|
||||
} as unknown as GamePrismaClient;
|
||||
|
||||
await enqueuePrivateMessageWebPush(db, { msgType: 'private', mailbox: 3, destId: 8 }, 43);
|
||||
await enqueuePrivateMessageWebPush(db, { msgType: 'national', mailbox: 9001, destId: 9001 }, 44);
|
||||
|
||||
expect(findUnique).not.toHaveBeenCalled();
|
||||
expect(createMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user