diff --git a/.env.example b/.env.example index 79b1b00..88177fc 100644 --- a/.env.example +++ b/.env.example @@ -26,6 +26,8 @@ GATEWAY_REDIS_PREFIX=sammo:gateway GATEWAY_DB_SCHEMA=public GATEWAY_WORKSPACE_ROOT=/path/to/core2026 GATEWAY_WORKTREE_ROOT=/path/to/core2026/.worktrees +GATEWAY_USER_ICON_DIR=uploads/user-icons +GATEWAY_USER_ICON_PUBLIC_URL=http://localhost:13000/user-icons SESSION_TTL_SECONDS=604800 GAME_SESSION_TTL_SECONDS=21600 OAUTH_SESSION_TTL_SECONDS=600 diff --git a/.gitignore b/.gitignore index 3beadec..30ce65c 100644 --- a/.gitignore +++ b/.gitignore @@ -164,3 +164,4 @@ docker-compose.override.yml docs/image-storage.md playwright-report/ test-results/ +uploads/ diff --git a/app/game-api/src/messages/store.ts b/app/game-api/src/messages/store.ts index 52da8b7..171cb11 100644 --- a/app/game-api/src/messages/store.ts +++ b/app/game-api/src/messages/store.ts @@ -23,6 +23,14 @@ interface MessageRow { message: unknown; } +export interface StoredMessage { + id: number; + mailbox: number; + msgType: MessageType; + time: Date; + payload: MessagePayload; +} + const parsePayload = (value: unknown): MessagePayload => { if (typeof value === 'string') { return JSON.parse(value) as MessagePayload; @@ -113,3 +121,30 @@ export const fetchOldMessagesFromMailbox = async (params: { return rows.map(toMessageView); }; + +export const fetchMessageById = async (db: DatabaseClient, id: number): Promise => { + const rows = await db.$queryRaw` + SELECT id, mailbox, type, src, dest, time, valid_until, message + FROM message + WHERE id = ${id} AND valid_until > NOW() + LIMIT 1 + `; + const row = rows[0]; + if (!row) return null; + return { + id: row.id, + mailbox: row.mailbox, + msgType: row.type, + time: new Date(row.time), + payload: parsePayload(row.message), + }; +}; + +export const invalidateMessages = async (db: DatabaseClient, ids: number[]): Promise => { + const uniqueIds = Array.from(new Set(ids.filter((id) => Number.isInteger(id) && id > 0))); + if (uniqueIds.length === 0) return; + await db.message.updateMany({ + where: { id: { in: uniqueIds } }, + data: { validUntil: new Date() }, + }); +}; diff --git a/app/game-api/src/router/messages/index.ts b/app/game-api/src/router/messages/index.ts index 06d1b0c..a8d82b6 100644 --- a/app/game-api/src/router/messages/index.ts +++ b/app/game-api/src/router/messages/index.ts @@ -14,11 +14,14 @@ import { buildNationTarget, buildTargetFromGeneral, resolveNationInfo } from '.. import { fetchMessagesFromMailbox, fetchOldMessagesFromMailbox, + fetchMessageById, + invalidateMessages, insertMessage, type MessageView, } from '../../messages/store.js'; import { publishRealtimeEvent } from '../../realtime/publisher.js'; import { getOwnedGeneral } from '../shared/general.js'; +import { resolveNationPermission } from '../nation/shared.js'; const zMessageType = z.enum(['private', 'public', 'national', 'diplomacy']); @@ -42,36 +45,39 @@ export const messagesRouter = router({ diplomacy: MESSAGE_MAILBOX_NATIONAL_BASE + nationId, } satisfies Record; - const [privateMessages, publicMessages, nationalMessages, diplomacyMessages] = await Promise.all([ - fetchMessagesFromMailbox({ - db: ctx.db, - mailbox: mailboxes.private, - msgType: 'private', - limit: 15, - fromSeq: sequence, - }), - fetchMessagesFromMailbox({ - db: ctx.db, - mailbox: mailboxes.public, - msgType: 'public', - limit: 15, - fromSeq: sequence, - }), - fetchMessagesFromMailbox({ - db: ctx.db, - mailbox: mailboxes.national, - msgType: 'national', - limit: 15, - fromSeq: sequence, - }), - fetchMessagesFromMailbox({ - db: ctx.db, - mailbox: mailboxes.diplomacy, - msgType: 'diplomacy', - limit: 15, - fromSeq: sequence, - }), - ]); + const [privateMessages, publicMessages, nationalMessages, diplomacyMessages, readState] = await Promise.all( + [ + fetchMessagesFromMailbox({ + db: ctx.db, + mailbox: mailboxes.private, + msgType: 'private', + limit: 15, + fromSeq: sequence, + }), + fetchMessagesFromMailbox({ + db: ctx.db, + mailbox: mailboxes.public, + msgType: 'public', + limit: 15, + fromSeq: sequence, + }), + fetchMessagesFromMailbox({ + db: ctx.db, + mailbox: mailboxes.national, + msgType: 'national', + limit: 15, + fromSeq: sequence, + }), + fetchMessagesFromMailbox({ + db: ctx.db, + mailbox: mailboxes.diplomacy, + msgType: 'diplomacy', + limit: 15, + fromSeq: sequence, + }), + ctx.db.messageReadState.findUnique({ where: { generalId: general.id } }), + ] + ); const messageBuckets: Record = { private: privateMessages, @@ -117,11 +123,118 @@ export const messagesRouter = router({ nationId: nationId, generalName: general.name, latestRead: { - diplomacy: 0, - private: 0, + diplomacy: readState?.latestDiplomacyMessage ?? 0, + private: readState?.latestPrivateMessage ?? 0, }, }; }), + getContacts: authedProcedure + .input(z.object({ generalId: z.number().int().positive() })) + .query(async ({ ctx, input }) => { + await getOwnedGeneral(ctx, input.generalId); + const [nations, generals] = await Promise.all([ + ctx.db.nation.findMany({ + select: { id: true, name: true, color: true, meta: true }, + orderBy: { id: 'asc' }, + }), + ctx.db.general.findMany({ + where: { npcState: { lt: 2 } }, + select: { + id: true, + name: true, + nationId: true, + officerLevel: true, + npcState: true, + meta: true, + penalty: true, + }, + orderBy: { id: 'asc' }, + }), + ]); + const nationMeta = new Map(nations.map((nation) => [nation.id, nation.meta])); + const grouped = new Map>(); + for (const general of generals) { + let flags = 0; + if (general.officerLevel === 12) flags |= 1; + if (general.npcState === 1) flags |= 2; + if (resolveNationPermission(general, nationMeta.get(general.nationId) ?? {}, false) === 4) flags |= 4; + const list = grouped.get(general.nationId) ?? []; + list.push([general.id, general.name, flags]); + grouped.set(general.nationId, list); + } + const nationList = [ + { id: 0, name: '재야', color: '#000000', meta: {} }, + ...nations.filter((nation) => nation.id !== 0), + ]; + return { + nation: nationList.map((nation) => ({ + mailbox: MESSAGE_MAILBOX_NATIONAL_BASE + nation.id, + name: nation.name, + color: nation.color, + general: grouped.get(nation.id) ?? [], + })), + }; + }), + readLatest: authedProcedure + .input( + z.object({ + generalId: z.number().int().positive(), + type: z.enum(['private', 'diplomacy']), + messageId: z.number().int().positive(), + }) + ) + .mutation(async ({ ctx, input }) => { + const general = await getOwnedGeneral(ctx, input.generalId); + const privateValue = input.type === 'private' ? input.messageId : 0; + const diplomacyValue = input.type === 'diplomacy' ? input.messageId : 0; + await ctx.db.$executeRaw` + INSERT INTO message_read_state ( + general_id, + latest_private_message, + latest_diplomacy_message, + updated_at + ) + VALUES (${general.id}, ${privateValue}, ${diplomacyValue}, NOW()) + ON CONFLICT (general_id) DO UPDATE SET + latest_private_message = GREATEST( + message_read_state.latest_private_message, + EXCLUDED.latest_private_message + ), + latest_diplomacy_message = GREATEST( + message_read_state.latest_diplomacy_message, + EXCLUDED.latest_diplomacy_message + ), + updated_at = NOW() + `; + return { ok: true }; + }), + delete: authedProcedure + .input( + z.object({ + generalId: z.number().int().positive(), + messageId: z.number().int().positive(), + }) + ) + .mutation(async ({ ctx, input }) => { + const general = await getOwnedGeneral(ctx, input.generalId); + const message = await fetchMessageById(ctx.db, input.messageId); + if (!message) { + throw new TRPCError({ code: 'NOT_FOUND', message: '메시지가 없습니다.' }); + } + if (message.payload.src.generalId !== general.id) { + throw new TRPCError({ code: 'FORBIDDEN', message: '본인의 메시지만 삭제할 수 있습니다.' }); + } + if (message.msgType === 'diplomacy' || message.payload.option?.deletable === false) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '삭제할 수 없는 메시지입니다.' }); + } + if (Date.now() - message.time.getTime() > 5 * 60 * 1000) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '5분 이내의 메시지만 삭제할 수 있습니다.' }); + } + const receiverMessageId = message.payload.option?.receiverMessageID; + const ids = [message.id, ...(typeof receiverMessageId === 'number' ? [receiverMessageId] : [])]; + await invalidateMessages(ctx.db, ids); + return { ok: true, deletedIds: ids }; + }), getOld: authedProcedure .input( z.object({ diff --git a/app/game-api/test/messagesRouter.test.ts b/app/game-api/test/messagesRouter.test.ts new file mode 100644 index 0000000..55fda7c --- /dev/null +++ b/app/game-api/test/messagesRouter.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; +import { appRouter } from '../src/router.js'; +import type { GameApiContext, GeneralRow } from '../src/context.js'; + +const general = { + id: 7, + userId: 'user-7', + name: '보낸이', + nationId: 1, + officerLevel: 5, + npcState: 0, + meta: {}, + penalty: {}, +} as GeneralRow; + +const auth: GameSessionTokenPayload = { + version: 1, + profile: 'che:default', + issuedAt: '2026-01-01T00:00:00.000Z', + expiresAt: '2027-01-01T00:00:00.000Z', + sessionId: 'session-7', + user: { + id: 'user-7', + username: 'tester', + displayName: 'Tester', + roles: ['user'], + }, + sanctions: {}, +}; + +const buildContext = (overrides: Record = {}) => { + const executeRaw = vi.fn(async () => 1); + const updateMany = vi.fn(async () => ({ count: 1 })); + const db = { + general: { + findUnique: vi.fn(async () => general), + findMany: vi.fn(async () => []), + }, + nation: { + findMany: vi.fn(async () => []), + findUnique: vi.fn(async () => null), + }, + messageReadState: { + findUnique: vi.fn(async () => ({ + generalId: general.id, + latestPrivateMessage: 11, + latestDiplomacyMessage: 13, + updatedAt: new Date(), + })), + }, + message: { updateMany }, + $queryRaw: vi.fn(async () => []), + $executeRaw: executeRaw, + ...overrides, + }; + const context = { + db, + auth, + profile: { id: 'che', scenario: 'default', name: 'che:default' }, + redis: {}, + turnDaemon: {}, + battleSim: {}, + uploadDir: 'uploads', + uploadPath: '/uploads', + uploadPublicUrl: null, + accessTokenStore: {}, + flushStore: {}, + gameTokenSecret: 'test-secret', + } as unknown as GameApiContext; + return { caller: appRouter.createCaller(context), db, executeRaw, updateMany }; +}; + +describe('messages router missing-flow compatibility', () => { + it('returns persisted latest-read positions with recent messages', async () => { + const { caller } = buildContext(); + const result = await caller.messages.getRecent({ generalId: general.id }); + + expect(result.latestRead).toEqual({ private: 11, diplomacy: 13 }); + }); + + it('persists latest-read updates through the monotonic upsert', async () => { + const { caller, executeRaw } = buildContext(); + + await caller.messages.readLatest({ + generalId: general.id, + type: 'private', + messageId: 17, + }); + + expect(executeRaw).toHaveBeenCalledOnce(); + }); + + it('invalidates a recent owned message and its receiver copy', async () => { + const queryRaw = vi.fn(async () => [ + { + id: 21, + mailbox: general.id, + type: 'private', + src: general.id, + dest: 8, + time: new Date(), + valid_until: new Date('9999-12-31T00:00:00Z'), + message: { + src: { + generalId: general.id, + generalName: general.name, + nationId: 1, + nationName: '위', + color: '#fff', + icon: '', + }, + dest: { + generalId: 8, + generalName: '받는이', + nationId: 2, + nationName: '촉', + color: '#000', + icon: '', + }, + text: '삭제할 메시지', + option: { receiverMessageID: 22 }, + }, + }, + ]); + const { caller, updateMany } = buildContext({ $queryRaw: queryRaw }); + + const result = await caller.messages.delete({ generalId: general.id, messageId: 21 }); + + expect(result.deletedIds).toEqual([21, 22]); + expect(updateMany).toHaveBeenCalledWith({ + where: { id: { in: [21, 22] } }, + data: { validUntil: expect.any(Date) }, + }); + }); + + it('rejects deleting another general message', async () => { + const queryRaw = vi.fn(async () => [ + { + id: 23, + mailbox: general.id, + type: 'private', + src: 99, + dest: general.id, + time: new Date(), + valid_until: new Date('9999-12-31T00:00:00Z'), + message: { + src: { generalId: 99, generalName: '타인', nationId: 1, nationName: '위', color: '#fff', icon: '' }, + dest: { + generalId: general.id, + generalName: general.name, + nationId: 1, + nationName: '위', + color: '#fff', + icon: '', + }, + text: '타인 메시지', + option: {}, + }, + }, + ]); + const { caller } = buildContext({ $queryRaw: queryRaw }); + + await expect(caller.messages.delete({ generalId: general.id, messageId: 23 })).rejects.toMatchObject({ + code: 'FORBIDDEN', + }); + }); +}); diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index c288326..bb71425 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -492,6 +492,7 @@ export const createDatabaseTurnHooks = async ( name: nation.name, color: nation.color, capitalCityId: nation.capitalCityId, + chiefGeneralId: nation.chiefGeneralId, gold: nation.gold, rice: nation.rice, level: nation.level, diff --git a/app/game-engine/src/turn/worldLoader.ts b/app/game-engine/src/turn/worldLoader.ts index f69bffd..c3c554a 100644 --- a/app/game-engine/src/turn/worldLoader.ts +++ b/app/game-engine/src/turn/worldLoader.ts @@ -257,7 +257,7 @@ const mapNationRow = (row: TurnEngineNationRow): Nation => ({ name: row.name, color: row.color, capitalCityId: row.capitalCityId, - chiefGeneralId: null, + chiefGeneralId: row.chiefGeneralId, gold: row.gold, rice: row.rice, power: 0, diff --git a/app/gateway-api/package.json b/app/gateway-api/package.json index 4665b9d..ac34411 100644 --- a/app/gateway-api/package.json +++ b/app/gateway-api/package.json @@ -27,6 +27,7 @@ }, "dependencies": { "@fastify/cors": "^11.2.0", + "@fastify/static": "^9.0.0", "@prisma/client": "^7.2.0", "@sammo-ts/common": "workspace:*", "@sammo-ts/game-engine": "workspace:*", @@ -38,6 +39,7 @@ "fastify": "^5.6.2", "pm2": "^5.4.3", "redis": "^5.10.0", + "sharp": "^0.34.4", "zod": "^4.3.5" } } diff --git a/app/gateway-api/src/account/router.ts b/app/gateway-api/src/account/router.ts new file mode 100644 index 0000000..c3c0fa0 --- /dev/null +++ b/app/gateway-api/src/account/router.ts @@ -0,0 +1,167 @@ +import { randomBytes } from 'node:crypto'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { TRPCError } from '@trpc/server'; +import sharp from 'sharp'; +import { z } from 'zod'; + +import type { GatewayApiContext } from '../context.js'; +import { procedure, router } from '../trpc.js'; +import type { UserRecord, UserSanctions } from '../auth/userRepository.js'; + +const zSessionToken = z.string().min(1); +const zPassword = z.string().min(6).max(128); +const MAX_ICON_BYTES = 50 * 1024; +const ALLOWED_ICON_FORMATS = new Set(['avif', 'webp', 'jpeg', 'png', 'gif']); + +const requireSessionUser = async (ctx: GatewayApiContext, sessionToken: string): Promise => { + const session = await ctx.sessions.getSession(sessionToken); + if (!session) { + throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Session is not valid.' }); + } + const user = await ctx.users.findById(session.userId); + if (!user) { + throw new TRPCError({ code: 'UNAUTHORIZED', message: 'User no longer exists.' }); + } + return user; +}; + +const decodeImage = (input: string): Buffer => { + const match = input.match(/^data:[^;]+;base64,(.+)$/); + const encoded = match?.[1] ?? input; + const buffer = Buffer.from(encoded, 'base64'); + if (buffer.length === 0 || buffer.length > MAX_ICON_BYTES) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '아이콘은 50KB 이하여야 합니다.' }); + } + return buffer; +}; + +const sameUtcDate = (left: Date, right: Date): boolean => + left.getUTCFullYear() === right.getUTCFullYear() && + left.getUTCMonth() === right.getUTCMonth() && + left.getUTCDate() === right.getUTCDate(); + +const assertIconChangeAvailable = (user: UserRecord, now: Date): void => { + if (user.iconUpdatedAt && sameUtcDate(new Date(user.iconUpdatedAt), now)) { + throw new TRPCError({ code: 'TOO_MANY_REQUESTS', message: '아이콘은 하루에 한 번만 변경할 수 있습니다.' }); + } +}; + +const hasActiveSanction = (sanctions: UserSanctions, now: Date): boolean => { + const dates = [sanctions.bannedUntil, sanctions.mutedUntil, sanctions.suspendedUntil]; + for (const value of dates) { + if (value && new Date(value) > now) return true; + } + return Object.values(sanctions.serverRestrictions ?? {}).some((restriction) => + Boolean(restriction.until && new Date(restriction.until) > now) + ); +}; + +const buildIconUrl = (ctx: GatewayApiContext, user: UserRecord): string | null => { + if (user.imageServer !== 1 || user.picture === 'default.jpg') return null; + return `${ctx.userIconPublicUrl.replace(/\/$/, '')}/${encodeURIComponent(user.picture)}`; +}; + +export const accountRouter = router({ + get: procedure.input(z.object({ sessionToken: zSessionToken })).query(async ({ ctx, input }) => { + const user = await requireSessionUser(ctx, input.sessionToken); + return { + id: user.id, + username: user.username, + displayName: user.displayName, + roles: user.roles, + oauthType: user.oauthType, + createdAt: user.createdAt, + iconUrl: buildIconUrl(ctx, user), + thirdPartyUse: user.thirdPartyUse, + deleteAfter: user.deleteAfter ?? null, + }; + }), + changePassword: procedure + .input( + z.object({ + sessionToken: zSessionToken, + currentPassword: zPassword, + newPassword: zPassword, + }) + ) + .mutation(async ({ ctx, input }) => { + const user = await requireSessionUser(ctx, input.sessionToken); + if (!(await ctx.users.verifyPassword(user, input.currentPassword))) { + throw new TRPCError({ code: 'UNAUTHORIZED', message: '현재 비밀번호가 일치하지 않습니다.' }); + } + await ctx.users.updatePassword(user.id, input.newPassword); + await ctx.flushPublisher.publishUserFlush(user.id, 'password-changed'); + return { ok: true }; + }), + scheduleDeletion: procedure + .input(z.object({ sessionToken: zSessionToken, currentPassword: zPassword })) + .mutation(async ({ ctx, input }) => { + const user = await requireSessionUser(ctx, input.sessionToken); + if (!(await ctx.users.verifyPassword(user, input.currentPassword))) { + throw new TRPCError({ code: 'UNAUTHORIZED', message: '현재 비밀번호가 일치하지 않습니다.' }); + } + if (user.deleteAfter) { + throw new TRPCError({ code: 'CONFLICT', message: '이미 탈퇴 처리되어 있습니다.' }); + } + const now = new Date(); + if (hasActiveSanction(user.sanctions, now)) { + throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '징계가 남아 있어 탈퇴할 수 없습니다.' }); + } + const deleteAfter = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000); + await ctx.users.scheduleDeletion(user.id, deleteAfter); + await ctx.sessions.revokeSession(input.sessionToken, { revokeGames: true }); + await ctx.flushPublisher.publishUserFlush(user.id, 'account-deletion-scheduled'); + return { ok: true, deleteAfter: deleteAfter.toISOString() }; + }), + disallowThirdPartyUse: procedure + .input(z.object({ sessionToken: zSessionToken })) + .mutation(async ({ ctx, input }) => { + const user = await requireSessionUser(ctx, input.sessionToken); + await ctx.users.setThirdPartyUse(user.id, false); + return { ok: true }; + }), + changeIcon: procedure + .input( + z.object({ + sessionToken: zSessionToken, + imageData: z.string().min(1).max(100_000), + }) + ) + .mutation(async ({ ctx, input }) => { + const user = await requireSessionUser(ctx, input.sessionToken); + const now = new Date(); + assertIconChangeAvailable(user, now); + const buffer = decodeImage(input.imageData); + const metadata = await sharp(buffer, { animated: true }).metadata(); + if (!metadata.format || !ALLOWED_ICON_FORMATS.has(metadata.format)) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'avif, webp, jpg, gif, png 아이콘만 사용할 수 있습니다.', + }); + } + if (!metadata.width || metadata.width < 64 || metadata.width > 128 || metadata.height !== metadata.width) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: '아이콘은 64x64~128x128 범위의 정사각형이어야 합니다.', + }); + } + const extension = metadata.format === 'jpeg' ? 'jpg' : metadata.format; + const filename = `${randomBytes(8).toString('hex')}.${extension}`; + await fs.mkdir(ctx.userIconDir, { recursive: true }); + await fs.writeFile(path.join(ctx.userIconDir, filename), buffer, { flag: 'wx' }); + await ctx.users.updateIcon(user.id, filename, 1, now); + return { + ok: true, + iconUrl: `${ctx.userIconPublicUrl.replace(/\/$/, '')}/${filename}`, + }; + }), + deleteIcon: procedure.input(z.object({ sessionToken: zSessionToken })).mutation(async ({ ctx, input }) => { + const user = await requireSessionUser(ctx, input.sessionToken); + const now = new Date(); + assertIconChangeAvailable(user, now); + await ctx.users.updateIcon(user.id, 'default.jpg', 0, now); + return { ok: true, iconUrl: null }; + }), +}); diff --git a/app/gateway-api/src/auth/inMemoryUserRepository.ts b/app/gateway-api/src/auth/inMemoryUserRepository.ts index 26f9811..7515a83 100644 --- a/app/gateway-api/src/auth/inMemoryUserRepository.ts +++ b/app/gateway-api/src/auth/inMemoryUserRepository.ts @@ -43,6 +43,9 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createSimp oauthId: input.oauth?.id, email: input.oauth?.email, oauthInfo: input.oauth?.info, + picture: 'default.jpg', + imageServer: 0, + thirdPartyUse: true, passwordSalt: salt, passwordHash: hasher.hash(input.password, salt), createdAt: new Date().toISOString(), @@ -97,6 +100,35 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createSimp } throw new Error('User not found.'); }, + async updateIcon(userId: string, picture: string, imageServer: number, updatedAt: Date): Promise { + for (const user of usersByName.values()) { + if (user.id === userId) { + user.picture = picture; + user.imageServer = imageServer; + user.iconUpdatedAt = updatedAt.toISOString(); + return; + } + } + throw new Error('User not found.'); + }, + async setThirdPartyUse(userId: string, allowed: boolean): Promise { + for (const user of usersByName.values()) { + if (user.id === userId) { + user.thirdPartyUse = allowed; + return; + } + } + throw new Error('User not found.'); + }, + async scheduleDeletion(userId: string, deleteAfter: Date): Promise { + for (const user of usersByName.values()) { + if (user.id === userId) { + user.deleteAfter = deleteAfter.toISOString(); + return; + } + } + throw new Error('User not found.'); + }, async deleteUser(userId: string): Promise { for (const [username, user] of usersByName.entries()) { if (user.id === userId) { diff --git a/app/gateway-api/src/auth/postgresUserRepository.ts b/app/gateway-api/src/auth/postgresUserRepository.ts index 710f24a..7d56498 100644 --- a/app/gateway-api/src/auth/postgresUserRepository.ts +++ b/app/gateway-api/src/auth/postgresUserRepository.ts @@ -29,6 +29,11 @@ const mapUser = (row: { oauthId: string | null; email: string | null; oauthInfo: GatewayPrisma.JsonValue; + picture: string; + imageServer: number; + iconUpdatedAt: Date | null; + thirdPartyUse: boolean; + deleteAfter: Date | null; createdAt: Date; }): UserRecord => ({ id: row.id, @@ -40,6 +45,11 @@ const mapUser = (row: { oauthId: row.oauthId ?? undefined, email: row.email ?? undefined, oauthInfo: readObject(row.oauthInfo, {}), + picture: row.picture, + imageServer: row.imageServer, + iconUpdatedAt: row.iconUpdatedAt?.toISOString(), + thirdPartyUse: row.thirdPartyUse, + deleteAfter: row.deleteAfter?.toISOString(), passwordHash: row.passwordHash, passwordSalt: row.passwordSalt, createdAt: row.createdAt.toISOString(), @@ -139,6 +149,28 @@ export const createPostgresUserRepository = ( }, }); }, + async updateIcon(userId: string, picture: string, imageServer: number, updatedAt: Date): Promise { + await prisma.appUser.update({ + where: { id: userId }, + data: { + picture, + imageServer, + iconUpdatedAt: updatedAt, + }, + }); + }, + async setThirdPartyUse(userId: string, allowed: boolean): Promise { + await prisma.appUser.update({ + where: { id: userId }, + data: { thirdPartyUse: allowed }, + }); + }, + async scheduleDeletion(userId: string, deleteAfter: Date): Promise { + await prisma.appUser.update({ + where: { id: userId }, + data: { deleteAfter }, + }); + }, async deleteUser(userId: string): Promise { await prisma.appUser.delete({ where: { id: userId }, diff --git a/app/gateway-api/src/auth/userRepository.ts b/app/gateway-api/src/auth/userRepository.ts index b0cfa66..d8a9ff4 100644 --- a/app/gateway-api/src/auth/userRepository.ts +++ b/app/gateway-api/src/auth/userRepository.ts @@ -8,6 +8,11 @@ export interface UserRecord { oauthId?: string; email?: string; oauthInfo?: UserOAuthInfo; + picture: string; + imageServer: number; + iconUpdatedAt?: string; + thirdPartyUse: boolean; + deleteAfter?: string; passwordHash: string; passwordSalt: string; createdAt: string; @@ -18,6 +23,7 @@ export interface PublicUser { username: string; displayName: string; roles: string[]; + picture: string; createdAt: string; } @@ -44,6 +50,7 @@ export const toPublicUser = (user: UserRecord): PublicUser => ({ username: user.username, displayName: user.displayName, roles: user.roles, + picture: user.picture, createdAt: user.createdAt, }); @@ -70,6 +77,9 @@ export interface UserRepository { updateOAuthInfo(userId: string, oauthInfo: UserOAuthInfo): Promise; updateRoles(userId: string, roles: string[]): Promise; updateSanctions(userId: string, sanctions: UserSanctions): Promise; + updateIcon(userId: string, picture: string, imageServer: number, updatedAt: Date): Promise; + setThirdPartyUse(userId: string, allowed: boolean): Promise; + scheduleDeletion(userId: string, deleteAfter: Date): Promise; deleteUser(userId: string): Promise; } diff --git a/app/gateway-api/src/config.ts b/app/gateway-api/src/config.ts index 6a927ed..cd1f27e 100644 --- a/app/gateway-api/src/config.ts +++ b/app/gateway-api/src/config.ts @@ -16,6 +16,8 @@ export interface GatewayApiConfig { kakaoAdminKey?: string; kakaoRedirectUri: string; publicBaseUrl: string; + userIconDir: string; + userIconPublicUrl: string; adminLocalAccountEnabled: boolean; orchestratorEnabled: boolean; orchestratorReconcileIntervalMs: number; @@ -81,6 +83,8 @@ export const resolveGatewayApiConfigFromEnv = (env: NodeJS.ProcessEnv = process. kakaoAdminKey: env.KAKAO_ADMIN_KEY, kakaoRedirectUri, publicBaseUrl, + userIconDir: env.GATEWAY_USER_ICON_DIR ?? 'uploads/user-icons', + userIconPublicUrl: env.GATEWAY_USER_ICON_PUBLIC_URL ?? `${publicBaseUrl.replace(/\/$/, '')}/user-icons`, adminLocalAccountEnabled: parseBooleanWithFallback(env.GATEWAY_ADMIN_LOCAL_ACCOUNT_ENABLED, false), orchestratorEnabled: parseBooleanWithFallback(env.GATEWAY_ORCHESTRATOR_ENABLED, false), orchestratorReconcileIntervalMs: parseNumberWithFallback( diff --git a/app/gateway-api/src/context.ts b/app/gateway-api/src/context.ts index 5d600d1..d7fd982 100644 --- a/app/gateway-api/src/context.ts +++ b/app/gateway-api/src/context.ts @@ -18,6 +18,8 @@ export interface GatewayApiContext { kakaoClient: KakaoOAuthClient; oauthSessions: OAuthSessionStore; publicBaseUrl: string; + userIconDir: string; + userIconPublicUrl: string; adminLocalAccountEnabled: boolean; profiles: GatewayProfileRepository; orchestrator: GatewayOrchestratorHandle; @@ -36,6 +38,8 @@ export const createGatewayApiContext = (options: { kakaoClient: KakaoOAuthClient; oauthSessions: OAuthSessionStore; publicBaseUrl: string; + userIconDir?: string; + userIconPublicUrl?: string; adminLocalAccountEnabled: boolean; profiles: GatewayProfileRepository; orchestrator: GatewayOrchestratorHandle; @@ -51,6 +55,8 @@ export const createGatewayApiContext = (options: { kakaoClient: options.kakaoClient, oauthSessions: options.oauthSessions, publicBaseUrl: options.publicBaseUrl, + userIconDir: options.userIconDir ?? 'uploads/user-icons', + userIconPublicUrl: options.userIconPublicUrl ?? `${options.publicBaseUrl.replace(/\/$/, '')}/user-icons`, adminLocalAccountEnabled: options.adminLocalAccountEnabled, profiles: options.profiles, orchestrator: options.orchestrator, diff --git a/app/gateway-api/src/router.ts b/app/gateway-api/src/router.ts index f389a5c..f1eb691 100644 --- a/app/gateway-api/src/router.ts +++ b/app/gateway-api/src/router.ts @@ -10,6 +10,7 @@ import { procedure, router } from './trpc.js'; import { toPublicUser } from './auth/userRepository.js'; import type { UserOAuthInfo } from './auth/userRepository.js'; import { adminRouter } from './adminRouter.js'; +import { accountRouter } from './account/router.js'; const zUsername = z.string().min(2).max(32); const zPassword = z.string().min(6).max(128); @@ -61,6 +62,7 @@ export const appRouter = router({ }), }), admin: adminRouter, + account: accountRouter, auth: router({ bootstrapLocal: procedure .input( @@ -330,6 +332,12 @@ export const appRouter = router({ message: 'Invalid username or password.', }); } + if (user.deleteAfter) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'Account deletion is pending.', + }); + } const ok = await ctx.users.verifyPassword(user, input.password); if (!ok) { throw new TRPCError({ diff --git a/app/gateway-api/src/server.ts b/app/gateway-api/src/server.ts index 460edf9..1b36d66 100644 --- a/app/gateway-api/src/server.ts +++ b/app/gateway-api/src/server.ts @@ -1,5 +1,8 @@ import fastify, { type FastifyRequest } from 'fastify'; import cors from '@fastify/cors'; +import fastifyStatic from '@fastify/static'; +import fs from 'node:fs/promises'; +import path from 'node:path'; import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify'; import { createGatewayPostgresConnector, @@ -60,6 +63,12 @@ export const createGatewayApiServer = async () => { origin: true, credentials: true, }); + await fs.mkdir(path.resolve(process.cwd(), config.userIconDir), { recursive: true }); + await app.register(fastifyStatic, { + root: path.resolve(process.cwd(), config.userIconDir), + prefix: '/user-icons/', + decorateReply: false, + }); await app.register(fastifyTRPCPlugin, { prefix: config.trpcPath, @@ -75,6 +84,8 @@ export const createGatewayApiServer = async () => { kakaoClient, oauthSessions, publicBaseUrl: config.publicBaseUrl, + userIconDir: path.resolve(process.cwd(), config.userIconDir), + userIconPublicUrl: config.userIconPublicUrl, adminLocalAccountEnabled: config.adminLocalAccountEnabled, profiles, orchestrator, diff --git a/app/gateway-api/test/authFlow.test.ts b/app/gateway-api/test/authFlow.test.ts index c02bc38..a474eab 100644 --- a/app/gateway-api/test/authFlow.test.ts +++ b/app/gateway-api/test/authFlow.test.ts @@ -1,4 +1,8 @@ import { describe, expect, it } from 'vitest'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import sharp from 'sharp'; import { InMemoryGatewaySessionService } from '../src/auth/inMemorySessionService.js'; import { createInMemoryUserRepository } from '../src/auth/inMemoryUserRepository.js'; @@ -10,7 +14,7 @@ import { appRouter } from '../src/router.js'; import type { GatewayPrismaClient } from '@sammo-ts/infra'; import { decryptGameSessionToken } from '@sammo-ts/common/auth/gameToken'; -const buildCaller = () => { +const buildCaller = (options: { userIconDir?: string } = {}) => { const users = createInMemoryUserRepository(); const sessions = new InMemoryGatewaySessionService({ sessionTtlSeconds: 3600, @@ -83,6 +87,8 @@ const buildCaller = () => { kakaoClient: kakaoClient as unknown as KakaoOAuthClient, oauthSessions, publicBaseUrl: 'http://localhost', + userIconDir: options.userIconDir, + userIconPublicUrl: 'http://localhost/user-icons', adminLocalAccountEnabled: false, profiles, orchestrator, @@ -95,7 +101,7 @@ const buildCaller = () => { } as unknown as GatewayPrismaClient, }) ); - return { caller, oauthSessions }; + return { caller, oauthSessions, users, sessions }; }; describe('gateway auth flow', () => { @@ -167,3 +173,99 @@ describe('gateway auth flow', () => { expect(validated?.user.username).toBe('tester'); }); }); + +describe('account self service', () => { + it('changes only the authenticated user password after verifying the current password', async () => { + const { caller, users, sessions } = buildCaller(); + const user = await users.createUser({ + username: 'self-service', + password: 'current-password', + }); + const session = await sessions.createSession(user); + + await expect( + caller.account.changePassword({ + sessionToken: session.sessionToken, + currentPassword: 'wrong-password', + newPassword: 'next-password', + }) + ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + + await caller.account.changePassword({ + sessionToken: session.sessionToken, + currentPassword: 'current-password', + newPassword: 'next-password', + }); + + const refreshed = await users.findById(user.id); + expect(refreshed && (await users.verifyPassword(refreshed, 'next-password'))).toBe(true); + }); + + it('revokes the session and schedules deletion after 30 days', async () => { + const { caller, users, sessions } = buildCaller(); + const user = await users.createUser({ + username: 'delete-self', + password: 'current-password', + }); + const session = await sessions.createSession(user); + + const result = await caller.account.scheduleDeletion({ + sessionToken: session.sessionToken, + currentPassword: 'current-password', + }); + + expect(new Date(result.deleteAfter).getTime()).toBeGreaterThan(Date.now() + 29 * 24 * 60 * 60 * 1000); + expect((await users.findById(user.id))?.deleteAfter).toBe(result.deleteAfter); + expect(await sessions.getSession(session.sessionToken)).toBeNull(); + }); + + it('revokes third-party use consent without allowing it to be re-enabled', async () => { + const { caller, users, sessions } = buildCaller(); + const user = await users.createUser({ + username: 'privacy-self', + password: 'current-password', + }); + const session = await sessions.createSession(user); + + await caller.account.disallowThirdPartyUse({ sessionToken: session.sessionToken }); + + expect((await users.findById(user.id))?.thirdPartyUse).toBe(false); + }); + + it('validates and stores a legacy-sized account icon with a daily change limit', async () => { + const iconDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-account-icon-')); + try { + const { caller, users, sessions } = buildCaller({ userIconDir: iconDir }); + const user = await users.createUser({ + username: 'icon-self', + password: 'current-password', + }); + const session = await sessions.createSession(user); + const png = await sharp({ + create: { + width: 64, + height: 64, + channels: 4, + background: '#334455', + }, + }) + .png() + .toBuffer(); + + const result = await caller.account.changeIcon({ + sessionToken: session.sessionToken, + imageData: `data:image/png;base64,${png.toString('base64')}`, + }); + const updated = await users.findById(user.id); + + expect(result.iconUrl).toMatch(/^http:\/\/localhost\/user-icons\/[a-f0-9]{16}\.png$/); + expect(updated?.imageServer).toBe(1); + expect(await fs.stat(path.join(iconDir, updated?.picture ?? 'missing'))).toBeTruthy(); + await expect(caller.account.deleteIcon({ sessionToken: session.sessionToken })).rejects.toMatchObject({ + code: 'TOO_MANY_REQUESTS', + }); + } finally { + await fs.rm(iconDir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/infra/prisma/game.prisma b/packages/infra/prisma/game.prisma index e0d46d0..52da496 100644 --- a/packages/infra/prisma/game.prisma +++ b/packages/infra/prisma/game.prisma @@ -1,47 +1,47 @@ generator client { - provider = "prisma-client-js" - output = "./generated/game" - engineType = "binary" + provider = "prisma-client-js" + output = "./generated/game" + engineType = "binary" } datasource db { - provider = "postgresql" + provider = "postgresql" } enum LogScope { - SYSTEM - NATION - GENERAL - USER + SYSTEM + NATION + GENERAL + USER } enum LogCategory { - HISTORY - SUMMARY - ACTION - BATTLE_BRIEF - BATTLE_DETAIL - USER + HISTORY + SUMMARY + ACTION + BATTLE_BRIEF + BATTLE_DETAIL + USER } enum AuctionStatus { - OPEN - FINALIZING - FINISHED - CANCELED + OPEN + FINALIZING + FINISHED + CANCELED } enum AuctionType { - BUY_RICE - SELL_RICE - UNIQUE_ITEM + BUY_RICE + SELL_RICE + UNIQUE_ITEM } enum DiplomacyLetterState { - PROPOSED - ACTIVATED - CANCELLED - REPLACED + PROPOSED + ACTIVATED + CANCELLED + REPLACED } enum InputEventStatus { @@ -78,538 +78,561 @@ model InputEvent { } model WorldState { - id Int @id @default(autoincrement()) - scenarioCode String @map("scenario_code") - currentYear Int @map("current_year") - currentMonth Int @map("current_month") - tickSeconds Int @map("tick_seconds") - config Json @default(dbgenerated("'{}'::jsonb")) - meta Json @default(dbgenerated("'{}'::jsonb")) - updatedAt DateTime @updatedAt @map("updated_at") + id Int @id @default(autoincrement()) + scenarioCode String @map("scenario_code") + currentYear Int @map("current_year") + currentMonth Int @map("current_month") + tickSeconds Int @map("tick_seconds") + config Json @default(dbgenerated("'{}'::jsonb")) + meta Json @default(dbgenerated("'{}'::jsonb")) + updatedAt DateTime @updatedAt @map("updated_at") - @@map("world_state") + @@map("world_state") } model Nation { - id Int @id - name String - color String - capitalCityId Int? @map("capital_city_id") - gold Int @default(0) - rice Int @default(0) - tech Float @default(0) - level Int @default(0) - typeCode String @default("che_중립") @map("type_code") - meta Json @default(dbgenerated("'{}'::jsonb")) + id Int @id + name String + color String + capitalCityId Int? @map("capital_city_id") + chiefGeneralId Int? @map("chief_general_id") + gold Int @default(0) + rice Int @default(0) + tech Float @default(0) + level Int @default(0) + typeCode String @default("che_중립") @map("type_code") + meta Json @default(dbgenerated("'{}'::jsonb")) - @@map("nation") + @@map("nation") } model City { - id Int @id - name String - level Int - nationId Int @default(0) @map("nation_id") - supplyState Int @default(1) @map("supply_state") - frontState Int @default(0) @map("front_state") - population Int @map("pop") - populationMax Int @map("pop_max") - agriculture Int @map("agri") - agricultureMax Int @map("agri_max") - commerce Int @map("comm") - commerceMax Int @map("comm_max") - security Int @map("secu") - securityMax Int @map("secu_max") - trust Int @default(0) - trade Int @default(100) - defence Int @map("def") - defenceMax Int @map("def_max") - wall Int @map("wall") - wallMax Int @map("wall_max") - region Int - conflict Json @default(dbgenerated("'{}'::jsonb")) - meta Json @default(dbgenerated("'{}'::jsonb")) + id Int @id + name String + level Int + nationId Int @default(0) @map("nation_id") + supplyState Int @default(1) @map("supply_state") + frontState Int @default(0) @map("front_state") + population Int @map("pop") + populationMax Int @map("pop_max") + agriculture Int @map("agri") + agricultureMax Int @map("agri_max") + commerce Int @map("comm") + commerceMax Int @map("comm_max") + security Int @map("secu") + securityMax Int @map("secu_max") + trust Int @default(0) + trade Int @default(100) + defence Int @map("def") + defenceMax Int @map("def_max") + wall Int @map("wall") + wallMax Int @map("wall_max") + region Int + conflict Json @default(dbgenerated("'{}'::jsonb")) + meta Json @default(dbgenerated("'{}'::jsonb")) - @@map("city") + @@map("city") } model General { - id Int @id - userId String? @map("user_id") - name String - nationId Int @default(0) @map("nation_id") - cityId Int @default(0) @map("city_id") - troopId Int @default(0) @map("troop_id") - npcState Int @default(0) @map("npc_state") - affinity Int? - bornYear Int @default(180) @map("born_year") - deadYear Int @default(300) @map("dead_year") - picture String? - imageServer Int @default(0) @map("image_server") - leadership Int @default(50) - strength Int @default(50) - intel Int @default(50) - injury Int @default(0) - experience Int @default(0) - dedication Int @default(0) - officerLevel Int @default(0) @map("officer_level") - gold Int @default(1000) - rice Int @default(1000) - crew Int @default(0) - crewTypeId Int @default(0) @map("crew_type_id") - train Int @default(0) - atmos Int @default(0) - weaponCode String @default("None") @map("weapon_code") - bookCode String @default("None") @map("book_code") - horseCode String @default("None") @map("horse_code") - itemCode String @default("None") @map("item_code") - turnTime DateTime @map("turn_time") - recentWarTime DateTime? @map("recent_war_time") - age Int @default(20) - startAge Int @default(20) @map("start_age") - personalCode String @default("None") @map("personal_code") - specialCode String @default("None") @map("special_code") - special2Code String @default("None") @map("special2_code") - lastTurn Json @default(dbgenerated("'{}'::jsonb")) @map("last_turn") - meta Json @default(dbgenerated("'{}'::jsonb")) - penalty Json @default(dbgenerated("'{}'::jsonb")) - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @default(now()) @map("updated_at") + id Int @id + userId String? @map("user_id") + name String + nationId Int @default(0) @map("nation_id") + cityId Int @default(0) @map("city_id") + troopId Int @default(0) @map("troop_id") + npcState Int @default(0) @map("npc_state") + affinity Int? + bornYear Int @default(180) @map("born_year") + deadYear Int @default(300) @map("dead_year") + picture String? + imageServer Int @default(0) @map("image_server") + leadership Int @default(50) + strength Int @default(50) + intel Int @default(50) + injury Int @default(0) + experience Int @default(0) + dedication Int @default(0) + officerLevel Int @default(0) @map("officer_level") + gold Int @default(1000) + rice Int @default(1000) + crew Int @default(0) + crewTypeId Int @default(0) @map("crew_type_id") + train Int @default(0) + atmos Int @default(0) + weaponCode String @default("None") @map("weapon_code") + bookCode String @default("None") @map("book_code") + horseCode String @default("None") @map("horse_code") + itemCode String @default("None") @map("item_code") + turnTime DateTime @map("turn_time") + recentWarTime DateTime? @map("recent_war_time") + age Int @default(20) + startAge Int @default(20) @map("start_age") + personalCode String @default("None") @map("personal_code") + specialCode String @default("None") @map("special_code") + special2Code String @default("None") @map("special2_code") + lastTurn Json @default(dbgenerated("'{}'::jsonb")) @map("last_turn") + meta Json @default(dbgenerated("'{}'::jsonb")) + penalty Json @default(dbgenerated("'{}'::jsonb")) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @map("updated_at") - @@map("general") + @@map("general") } model GeneralAccessLog { - id Int @id @default(autoincrement()) - generalId Int @unique @map("general_id") - userId String? @map("user_id") - lastRefresh DateTime? @map("last_refresh") - refresh Int @default(0) - refreshTotal Int @default(0) @map("refresh_total") - refreshScore Int @default(0) @map("refresh_score") - refreshScoreTotal Int @default(0) @map("refresh_score_total") + id Int @id @default(autoincrement()) + generalId Int @unique @map("general_id") + userId String? @map("user_id") + lastRefresh DateTime? @map("last_refresh") + refresh Int @default(0) + refreshTotal Int @default(0) @map("refresh_total") + refreshScore Int @default(0) @map("refresh_score") + refreshScoreTotal Int @default(0) @map("refresh_score_total") - @@index([userId]) - @@map("general_access_log") + @@index([userId]) + @@map("general_access_log") +} + +model MessageReadState { + generalId Int @id @map("general_id") + latestPrivateMessage Int @default(0) @map("latest_private_message") + latestDiplomacyMessage Int @default(0) @map("latest_diplomacy_message") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("message_read_state") +} + +model Message { + id Int @id @default(autoincrement()) + mailbox Int + type String + src Int + dest Int + time DateTime + validUntil DateTime @map("valid_until") + message Json + + @@map("message") } model RankData { - id Int @id @default(autoincrement()) - nationId Int @default(0) @map("nation_id") - generalId Int @map("general_id") - type String @db.VarChar(20) - value Int @default(0) + id Int @id @default(autoincrement()) + nationId Int @default(0) @map("nation_id") + generalId Int @map("general_id") + type String @db.VarChar(20) + value Int @default(0) - @@unique([generalId, type]) - @@index([type, value], name: "by_type") - @@index([nationId, type, value], name: "by_nation") - @@map("rank_data") + @@unique([generalId, type]) + @@index([type, value], name: "by_type") + @@index([nationId, type, value], name: "by_nation") + @@map("rank_data") } model HallOfFame { - id Int @id @default(autoincrement()) - serverId String @map("server_id") - season Int - scenario Int - generalNo Int @map("general_no") - type String @db.VarChar(20) - value Float - owner String? @map("owner") - aux Json @default(dbgenerated("'{}'::jsonb")) + id Int @id @default(autoincrement()) + serverId String @map("server_id") + season Int + scenario Int + generalNo Int @map("general_no") + type String @db.VarChar(20) + value Float + owner String? @map("owner") + aux Json @default(dbgenerated("'{}'::jsonb")) - @@unique([serverId, type, generalNo]) - @@unique([owner, serverId, type], name: "hall_owner") - @@index([serverId, type, value], name: "server_show") - @@index([season, scenario, type, value], name: "scenario") - @@map("hall") + @@unique([serverId, type, generalNo]) + @@unique([owner, serverId, type], name: "hall_owner") + @@index([serverId, type, value], name: "server_show") + @@index([season, scenario, type, value], name: "scenario") + @@map("hall") } model GameHistory { - id Int @id @default(autoincrement()) - serverId String @map("server_id") - date DateTime - winnerNation Int? @map("winner_nation") - map String? @map("map") - season Int - scenario Int - scenarioName String @map("scenario_name") - env Json @default(dbgenerated("'{}'::jsonb")) + id Int @id @default(autoincrement()) + serverId String @map("server_id") + date DateTime + winnerNation Int? @map("winner_nation") + map String? @map("map") + season Int + scenario Int + scenarioName String @map("scenario_name") + env Json @default(dbgenerated("'{}'::jsonb")) - @@unique([serverId]) - @@index([date]) - @@map("ng_games") + @@unique([serverId]) + @@index([date]) + @@map("ng_games") } model OldNation { - id Int @id @default(autoincrement()) - serverId String @map("server_id") - nation Int @default(0) - data Json @default(dbgenerated("'{}'::jsonb")) - date DateTime @default(now()) + id Int @id @default(autoincrement()) + serverId String @map("server_id") + nation Int @default(0) + data Json @default(dbgenerated("'{}'::jsonb")) + date DateTime @default(now()) - @@unique([serverId, nation]) - @@index([serverId, nation], name: "by_server") - @@map("ng_old_nations") + @@unique([serverId, nation]) + @@index([serverId, nation], name: "by_server") + @@map("ng_old_nations") } model OldGeneral { - id Int @id @default(autoincrement()) - serverId String @map("server_id") - generalNo Int @map("general_no") - owner String? @map("owner") - name String - lastYearMonth Int @map("last_yearmonth") - turnTime DateTime @map("turntime") - data Json @default(dbgenerated("'{}'::jsonb")) + id Int @id @default(autoincrement()) + serverId String @map("server_id") + generalNo Int @map("general_no") + owner String? @map("owner") + name String + lastYearMonth Int @map("last_yearmonth") + turnTime DateTime @map("turntime") + data Json @default(dbgenerated("'{}'::jsonb")) - @@unique([serverId, generalNo], name: "by_no") - @@index([serverId, name], name: "by_name") - @@index([owner, serverId], name: "owner") - @@map("ng_old_generals") + @@unique([serverId, generalNo], name: "by_no") + @@index([serverId, name], name: "by_name") + @@index([owner, serverId], name: "owner") + @@map("ng_old_generals") } model Emperor { - id Int @id @default(autoincrement()) @map("no") - serverId String? @map("server_id") - phase String? @default("") - nationCount String? @map("nation_count") - nationName String? @map("nation_name") - nationHist String? @map("nation_hist") - genCount String? @map("gen_count") - personalHist String? @map("personal_hist") - specialHist String? @map("special_hist") - name String? @default("") - type String? @default("") - color String? @default("") - year Int? @default(0) - month Int? @default(0) - power Int? @default(0) - gennum Int? @default(0) - citynum Int? @default(0) - pop String? @default("0") - poprate String? @default("") - gold Int? @default(0) - rice Int? @default(0) - l12name String? @default("") - l12pic String? @default("") - l11name String? @default("") - l11pic String? @default("") - l10name String? @default("") - l10pic String? @default("") - l9name String? @default("") - l9pic String? @default("") - l8name String? @default("") - l8pic String? @default("") - l7name String? @default("") - l7pic String? @default("") - l6name String? @default("") - l6pic String? @default("") - l5name String? @default("") - l5pic String? @default("") - tiger String? @default("") - eagle String? @default("") - gen String? @default("") - history Json @default(dbgenerated("'{}'::jsonb")) - aux Json @default(dbgenerated("'{}'::jsonb")) + id Int @id @default(autoincrement()) @map("no") + serverId String? @map("server_id") + phase String? @default("") + nationCount String? @map("nation_count") + nationName String? @map("nation_name") + nationHist String? @map("nation_hist") + genCount String? @map("gen_count") + personalHist String? @map("personal_hist") + specialHist String? @map("special_hist") + name String? @default("") + type String? @default("") + color String? @default("") + year Int? @default(0) + month Int? @default(0) + power Int? @default(0) + gennum Int? @default(0) + citynum Int? @default(0) + pop String? @default("0") + poprate String? @default("") + gold Int? @default(0) + rice Int? @default(0) + l12name String? @default("") + l12pic String? @default("") + l11name String? @default("") + l11pic String? @default("") + l10name String? @default("") + l10pic String? @default("") + l9name String? @default("") + l9pic String? @default("") + l8name String? @default("") + l8pic String? @default("") + l7name String? @default("") + l7pic String? @default("") + l6name String? @default("") + l6pic String? @default("") + l5name String? @default("") + l5pic String? @default("") + tiger String? @default("") + eagle String? @default("") + gen String? @default("") + history Json @default(dbgenerated("'{}'::jsonb")) + aux Json @default(dbgenerated("'{}'::jsonb")) - @@map("emperior") + @@map("emperior") } model Troop { - troopLeaderId Int @id @map("troop_leader") - nationId Int @map("nation") - name String + troopLeaderId Int @id @map("troop_leader") + nationId Int @map("nation") + name String - @@map("troop") + @@map("troop") } model GeneralTurn { - id Int @id @default(autoincrement()) - generalId Int @map("general_id") - turnIdx Int @map("turn_idx") - actionCode String @map("action_code") - arg Json @default(dbgenerated("'{}'::jsonb")) - createdAt DateTime @default(now()) @map("created_at") + id Int @id @default(autoincrement()) + generalId Int @map("general_id") + turnIdx Int @map("turn_idx") + actionCode String @map("action_code") + arg Json @default(dbgenerated("'{}'::jsonb")) + createdAt DateTime @default(now()) @map("created_at") - @@unique([generalId, turnIdx]) - @@map("general_turn") + @@unique([generalId, turnIdx]) + @@map("general_turn") } model NationTurn { - id Int @id @default(autoincrement()) - nationId Int @map("nation_id") - officerLevel Int @map("officer_level") - turnIdx Int @map("turn_idx") - actionCode String @map("action_code") - arg Json @default(dbgenerated("'{}'::jsonb")) - createdAt DateTime @default(now()) @map("created_at") + id Int @id @default(autoincrement()) + nationId Int @map("nation_id") + officerLevel Int @map("officer_level") + turnIdx Int @map("turn_idx") + actionCode String @map("action_code") + arg Json @default(dbgenerated("'{}'::jsonb")) + createdAt DateTime @default(now()) @map("created_at") - @@unique([nationId, officerLevel, turnIdx]) - @@map("nation_turn") + @@unique([nationId, officerLevel, turnIdx]) + @@map("nation_turn") } model Diplomacy { - id Int @id @default(autoincrement()) - srcNationId Int @map("src_nation_id") - destNationId Int @map("dest_nation_id") - stateCode Int @map("state_code") - term Int @default(0) - isDead Boolean @default(false) @map("is_dead") - isShowing Boolean @default(true) @map("is_showing") - meta Json @default(dbgenerated("'{}'::jsonb")) - createdAt DateTime @default(now()) @map("created_at") + id Int @id @default(autoincrement()) + srcNationId Int @map("src_nation_id") + destNationId Int @map("dest_nation_id") + stateCode Int @map("state_code") + term Int @default(0) + isDead Boolean @default(false) @map("is_dead") + isShowing Boolean @default(true) @map("is_showing") + meta Json @default(dbgenerated("'{}'::jsonb")) + createdAt DateTime @default(now()) @map("created_at") - @@unique([srcNationId, destNationId]) - @@map("diplomacy") + @@unique([srcNationId, destNationId]) + @@map("diplomacy") } model DiplomacyLetter { - id Int @id @default(autoincrement()) - srcNationId Int @map("src_nation_id") - destNationId Int @map("dest_nation_id") - prevId Int? @map("prev_id") - state DiplomacyLetterState @default(PROPOSED) - textBrief String @map("text_brief") - textDetail String @map("text_detail") - date DateTime @default(now()) @map("date") - srcSignerId Int @map("src_signer") - destSignerId Int? @map("dest_signer") - aux Json @default(dbgenerated("'{}'::jsonb")) + id Int @id @default(autoincrement()) + srcNationId Int @map("src_nation_id") + destNationId Int @map("dest_nation_id") + prevId Int? @map("prev_id") + state DiplomacyLetterState @default(PROPOSED) + textBrief String @map("text_brief") + textDetail String @map("text_detail") + date DateTime @default(now()) @map("date") + srcSignerId Int @map("src_signer") + destSignerId Int? @map("dest_signer") + aux Json @default(dbgenerated("'{}'::jsonb")) - @@index([srcNationId, destNationId]) - @@index([destNationId, srcNationId]) - @@index([state, date]) - @@map("diplomacy_letter") + @@index([srcNationId, destNationId]) + @@index([destNationId, srcNationId]) + @@index([state, date]) + @@map("diplomacy_letter") } model YearbookHistory { - id Int @id @default(autoincrement()) - profileName String @map("profile_name") - year Int - month Int - map Json - nations Json - hash String @default("") - createdAt DateTime @default(now()) @map("created_at") + id Int @id @default(autoincrement()) + profileName String @map("profile_name") + year Int + month Int + map Json + nations Json + hash String @default("") + createdAt DateTime @default(now()) @map("created_at") - @@unique([profileName, year, month]) - @@index([profileName, year, month]) - @@map("yearbook_history") + @@unique([profileName, year, month]) + @@index([profileName, year, month]) + @@map("yearbook_history") } model Event { - id Int @id @default(autoincrement()) - targetCode String @map("target_code") - priority Int @default(0) - condition Json @default(dbgenerated("'{}'::jsonb")) - action Json @default(dbgenerated("'{}'::jsonb")) - meta Json @default(dbgenerated("'{}'::jsonb")) - createdAt DateTime @default(now()) @map("created_at") + id Int @id @default(autoincrement()) + targetCode String @map("target_code") + priority Int @default(0) + condition Json @default(dbgenerated("'{}'::jsonb")) + action Json @default(dbgenerated("'{}'::jsonb")) + meta Json @default(dbgenerated("'{}'::jsonb")) + createdAt DateTime @default(now()) @map("created_at") - @@map("event") + @@map("event") } model LogEntry { - id Int @id @default(autoincrement()) - scope LogScope - category LogCategory - subType String? @map("sub_type") - year Int - month Int - text String - generalId Int? @map("general_id") - nationId Int? @map("nation_id") - userId Int? @map("user_id") - meta Json @default(dbgenerated("'{}'::jsonb")) - createdAt DateTime @default(now()) @map("created_at") + id Int @id @default(autoincrement()) + scope LogScope + category LogCategory + subType String? @map("sub_type") + year Int + month Int + text String + generalId Int? @map("general_id") + nationId Int? @map("nation_id") + userId Int? @map("user_id") + meta Json @default(dbgenerated("'{}'::jsonb")) + createdAt DateTime @default(now()) @map("created_at") - @@index([scope, category, id]) - @@index([generalId, category, id]) - @@index([nationId, category, id]) - @@index([userId, category, id]) - @@map("log_entry") + @@index([scope, category, id]) + @@index([generalId, category, id]) + @@index([nationId, category, id]) + @@index([userId, category, id]) + @@map("log_entry") } model ErrorLog { - id Int @id @default(autoincrement()) - category String - source String? @map("source") - message String - trace String? - context Json @default(dbgenerated("'{}'::jsonb")) - createdAt DateTime @default(now()) @map("created_at") + id Int @id @default(autoincrement()) + category String + source String? @map("source") + message String + trace String? + context Json @default(dbgenerated("'{}'::jsonb")) + createdAt DateTime @default(now()) @map("created_at") - @@index([category, id]) - @@map("error_log") + @@index([category, id]) + @@map("error_log") } model InheritancePoint { - id Int @id @default(autoincrement()) - userId String @map("user_id") - key String - value Float @default(0) - aux Json @default(dbgenerated("'{}'::jsonb")) - updatedAt DateTime @updatedAt @map("updated_at") + id Int @id @default(autoincrement()) + userId String @map("user_id") + key String + value Float @default(0) + aux Json @default(dbgenerated("'{}'::jsonb")) + updatedAt DateTime @updatedAt @map("updated_at") - @@unique([userId, key]) - @@index([userId]) - @@map("inheritance_point") + @@unique([userId, key]) + @@index([userId]) + @@map("inheritance_point") } model InheritanceLog { - id Int @id @default(autoincrement()) - userId String @map("user_id") - year Int - month Int - text String - createdAt DateTime @default(now()) @map("created_at") + id Int @id @default(autoincrement()) + userId String @map("user_id") + year Int + month Int + text String + createdAt DateTime @default(now()) @map("created_at") - @@index([userId, id]) - @@map("inheritance_log") + @@index([userId, id]) + @@map("inheritance_log") } model InheritanceResult { - id Int @id @default(autoincrement()) - serverId String @map("server_id") - owner String @map("owner") - generalId Int @map("general_id") - year Int - month Int - value Json @default(dbgenerated("'{}'::jsonb")) - createdAt DateTime @default(now()) @map("created_at") + id Int @id @default(autoincrement()) + serverId String @map("server_id") + owner String @map("owner") + generalId Int @map("general_id") + year Int + month Int + value Json @default(dbgenerated("'{}'::jsonb")) + createdAt DateTime @default(now()) @map("created_at") - @@index([serverId, owner]) - @@map("inheritance_result") + @@index([serverId, owner]) + @@map("inheritance_result") } model Auction { - id Int @id @default(autoincrement()) - type AuctionType - targetCode String? @map("target_code") - hostGeneralId Int @map("host_general_id") - hostName String? @map("host_name") - detail Json @default(dbgenerated("'{}'::jsonb")) - status AuctionStatus @default(OPEN) - closeAt DateTime @map("close_at") - latestEventId String @default("") @map("latest_event_id") - latestEventAt DateTime @default(now()) @map("latest_event_at") - finalizingAt DateTime? @map("finalizing_at") - finishedAt DateTime? @map("finished_at") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + id Int @id @default(autoincrement()) + type AuctionType + targetCode String? @map("target_code") + hostGeneralId Int @map("host_general_id") + hostName String? @map("host_name") + detail Json @default(dbgenerated("'{}'::jsonb")) + status AuctionStatus @default(OPEN) + closeAt DateTime @map("close_at") + latestEventId String @default("") @map("latest_event_id") + latestEventAt DateTime @default(now()) @map("latest_event_at") + finalizingAt DateTime? @map("finalizing_at") + finishedAt DateTime? @map("finished_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") - bids AuctionBid[] + bids AuctionBid[] - @@index([status, closeAt]) - @@map("auction") + @@index([status, closeAt]) + @@map("auction") } model AuctionBid { - id Int @id @default(autoincrement()) - auctionId Int @map("auction_id") - generalId Int @map("general_id") - amount Int - eventId String @map("event_id") - eventAt DateTime @map("event_at") - meta Json @default(dbgenerated("'{}'::jsonb")) - createdAt DateTime @default(now()) @map("created_at") + id Int @id @default(autoincrement()) + auctionId Int @map("auction_id") + generalId Int @map("general_id") + amount Int + eventId String @map("event_id") + eventAt DateTime @map("event_at") + meta Json @default(dbgenerated("'{}'::jsonb")) + createdAt DateTime @default(now()) @map("created_at") - auction Auction @relation(fields: [auctionId], references: [id], onDelete: Cascade) + auction Auction @relation(fields: [auctionId], references: [id], onDelete: Cascade) - @@index([auctionId, amount]) - @@index([auctionId, eventAt]) - @@map("auction_bid") + @@index([auctionId, amount]) + @@index([auctionId, eventAt]) + @@map("auction_bid") } model InheritanceUserState { - userId String @id @map("user_id") - meta Json @default(dbgenerated("'{}'::jsonb")) - updatedAt DateTime @updatedAt @map("updated_at") + userId String @id @map("user_id") + meta Json @default(dbgenerated("'{}'::jsonb")) + updatedAt DateTime @updatedAt @map("updated_at") - @@map("inheritance_user_state") + @@map("inheritance_user_state") } model BoardPost { - id Int @id @default(autoincrement()) - nationId Int @map("nation_id") - isSecret Boolean @default(false) @map("is_secret") - authorGeneralId Int @map("author_general_id") - authorName String @map("author_name") - title String - contentHtml String @map("content_html") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + id Int @id @default(autoincrement()) + nationId Int @map("nation_id") + isSecret Boolean @default(false) @map("is_secret") + authorGeneralId Int @map("author_general_id") + authorName String @map("author_name") + title String + contentHtml String @map("content_html") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") - comments BoardComment[] + comments BoardComment[] - @@index([nationId, isSecret, createdAt]) - @@map("board_post") + @@index([nationId, isSecret, createdAt]) + @@map("board_post") } model BoardComment { - id Int @id @default(autoincrement()) - postId Int @map("post_id") - nationId Int @map("nation_id") - isSecret Boolean @default(false) @map("is_secret") - authorGeneralId Int @map("author_general_id") - authorName String @map("author_name") - contentText String @map("content_text") - createdAt DateTime @default(now()) @map("created_at") + id Int @id @default(autoincrement()) + postId Int @map("post_id") + nationId Int @map("nation_id") + isSecret Boolean @default(false) @map("is_secret") + authorGeneralId Int @map("author_general_id") + authorName String @map("author_name") + contentText String @map("content_text") + createdAt DateTime @default(now()) @map("created_at") - post BoardPost @relation(fields: [postId], references: [id], onDelete: Cascade) + post BoardPost @relation(fields: [postId], references: [id], onDelete: Cascade) - @@index([postId, createdAt]) - @@map("board_comment") + @@index([postId, createdAt]) + @@map("board_comment") } model VotePoll { - id Int @id @default(autoincrement()) - title String - body String @default("") - options Json - multipleOptions Int @default(1) @map("multiple_options") - revealMode String @map("reveal_mode") - openerGeneralId Int @map("opener_general_id") - openerName String @map("opener_name") - startAt DateTime @default(now()) @map("start_at") - endAt DateTime? @map("end_at") - closedAt DateTime? @map("closed_at") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + id Int @id @default(autoincrement()) + title String + body String @default("") + options Json + multipleOptions Int @default(1) @map("multiple_options") + revealMode String @map("reveal_mode") + openerGeneralId Int @map("opener_general_id") + openerName String @map("opener_name") + startAt DateTime @default(now()) @map("start_at") + endAt DateTime? @map("end_at") + closedAt DateTime? @map("closed_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") - votes Vote[] - comments VoteComment[] + votes Vote[] + comments VoteComment[] - @@map("vote_poll") + @@map("vote_poll") } model Vote { - id Int @id @default(autoincrement()) - voteId Int @map("vote_id") - generalId Int @map("general_id") - nationId Int @map("nation_id") - selection Json - createdAt DateTime @default(now()) @map("created_at") + id Int @id @default(autoincrement()) + voteId Int @map("vote_id") + generalId Int @map("general_id") + nationId Int @map("nation_id") + selection Json + createdAt DateTime @default(now()) @map("created_at") - poll VotePoll @relation(fields: [voteId], references: [id], onDelete: Cascade) + poll VotePoll @relation(fields: [voteId], references: [id], onDelete: Cascade) - @@unique([voteId, generalId]) - @@index([voteId]) - @@map("vote") + @@unique([voteId, generalId]) + @@index([voteId]) + @@map("vote") } model VoteComment { - id Int @id @default(autoincrement()) - voteId Int @map("vote_id") - generalId Int @map("general_id") - nationId Int @map("nation_id") - generalName String @map("general_name") - nationName String @map("nation_name") - text String - createdAt DateTime @default(now()) @map("created_at") + id Int @id @default(autoincrement()) + voteId Int @map("vote_id") + generalId Int @map("general_id") + nationId Int @map("nation_id") + generalName String @map("general_name") + nationName String @map("nation_name") + text String + createdAt DateTime @default(now()) @map("created_at") - poll VotePoll @relation(fields: [voteId], references: [id], onDelete: Cascade) + poll VotePoll @relation(fields: [voteId], references: [id], onDelete: Cascade) - @@index([voteId, createdAt]) - @@map("vote_comment") + @@index([voteId, createdAt]) + @@map("vote_comment") } diff --git a/packages/infra/prisma/gateway-migrations/20260725000000_add_account_self_service/migration.sql b/packages/infra/prisma/gateway-migrations/20260725000000_add_account_self_service/migration.sql new file mode 100644 index 0000000..1ac58c8 --- /dev/null +++ b/packages/infra/prisma/gateway-migrations/20260725000000_add_account_self_service/migration.sql @@ -0,0 +1,6 @@ +ALTER TABLE "app_user" + ADD COLUMN "picture" TEXT NOT NULL DEFAULT 'default.jpg', + ADD COLUMN "image_server" INTEGER NOT NULL DEFAULT 0, + ADD COLUMN "icon_updated_at" TIMESTAMP(3), + ADD COLUMN "third_party_use" BOOLEAN NOT NULL DEFAULT TRUE, + ADD COLUMN "delete_after" TIMESTAMP(3); diff --git a/packages/infra/prisma/gateway.prisma b/packages/infra/prisma/gateway.prisma index 364673c..7b9863f 100644 --- a/packages/infra/prisma/gateway.prisma +++ b/packages/infra/prisma/gateway.prisma @@ -1,84 +1,89 @@ generator client { - provider = "prisma-client-js" - output = "./generated/gateway" - engineType = "binary" + provider = "prisma-client-js" + output = "./generated/gateway" + engineType = "binary" } datasource db { - provider = "postgresql" + provider = "postgresql" } enum OAuthType { - NONE - KAKAO + NONE + KAKAO } enum GatewayProfileStatus { - RESERVED - PREOPEN - RUNNING - PAUSED - COMPLETED - STOPPED - DISABLED + RESERVED + PREOPEN + RUNNING + PAUSED + COMPLETED + STOPPED + DISABLED } enum GatewayBuildStatus { - IDLE - QUEUED - RUNNING - FAILED - SUCCEEDED + IDLE + QUEUED + RUNNING + FAILED + SUCCEEDED } model AppUser { - id String @id @default(uuid()) - loginId String @unique @map("login_id") - displayName String @map("display_name") - passwordHash String @map("password_hash") - passwordSalt String @map("password_salt") - roles Json @default(dbgenerated("'[]'::jsonb")) - sanctions Json @default(dbgenerated("'{}'::jsonb")) - oauthType OAuthType @default(NONE) @map("oauth_type") - oauthId String? @unique @map("oauth_id") - email String? @unique - oauthInfo Json @default(dbgenerated("'{}'::jsonb")) @map("oauth_info") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") - lastLoginAt DateTime? @map("last_login_at") + id String @id @default(uuid()) + loginId String @unique @map("login_id") + displayName String @map("display_name") + passwordHash String @map("password_hash") + passwordSalt String @map("password_salt") + roles Json @default(dbgenerated("'[]'::jsonb")) + sanctions Json @default(dbgenerated("'{}'::jsonb")) + oauthType OAuthType @default(NONE) @map("oauth_type") + oauthId String? @unique @map("oauth_id") + email String? @unique + oauthInfo Json @default(dbgenerated("'{}'::jsonb")) @map("oauth_info") + picture String @default("default.jpg") + imageServer Int @default(0) @map("image_server") + iconUpdatedAt DateTime? @map("icon_updated_at") + thirdPartyUse Boolean @default(true) @map("third_party_use") + deleteAfter DateTime? @map("delete_after") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + lastLoginAt DateTime? @map("last_login_at") - @@map("app_user") + @@map("app_user") } model GatewayProfile { - profileName String @id @map("profile_name") - profile String - scenario String - apiPort Int @map("api_port") - status GatewayProfileStatus - buildStatus GatewayBuildStatus @default(IDLE) @map("build_status") - buildCommitSha String? @map("build_commit_sha") - buildWorkspace String? @map("build_workspace") - buildLastUsedAt DateTime? @map("build_last_used_at") - preopenAt DateTime? @map("preopen_at") - openAt DateTime? @map("open_at") - scheduledStartAt DateTime? @map("scheduled_start_at") - buildRequestedAt DateTime? @map("build_requested_at") - buildStartedAt DateTime? @map("build_started_at") - buildCompletedAt DateTime? @map("build_completed_at") - buildError String? @map("build_error") - lastError String? @map("last_error") - meta Json @default(dbgenerated("'{}'::jsonb")) - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + profileName String @id @map("profile_name") + profile String + scenario String + apiPort Int @map("api_port") + status GatewayProfileStatus + buildStatus GatewayBuildStatus @default(IDLE) @map("build_status") + buildCommitSha String? @map("build_commit_sha") + buildWorkspace String? @map("build_workspace") + buildLastUsedAt DateTime? @map("build_last_used_at") + preopenAt DateTime? @map("preopen_at") + openAt DateTime? @map("open_at") + scheduledStartAt DateTime? @map("scheduled_start_at") + buildRequestedAt DateTime? @map("build_requested_at") + buildStartedAt DateTime? @map("build_started_at") + buildCompletedAt DateTime? @map("build_completed_at") + buildError String? @map("build_error") + lastError String? @map("last_error") + meta Json @default(dbgenerated("'{}'::jsonb")) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") - @@unique([profile, scenario]) - @@map("gateway_profile") + @@unique([profile, scenario]) + @@map("gateway_profile") } model SystemSetting { - id Int @id @default(1) @map("no") - notice String @default("") @map("notice") + id Int @id @default(1) @map("no") + notice String @default("") @map("notice") - @@map("system") + @@map("system") } diff --git a/packages/infra/prisma/migrations/20260725002000_add_message_read_state/migration.sql b/packages/infra/prisma/migrations/20260725002000_add_message_read_state/migration.sql new file mode 100644 index 0000000..5c2c9a6 --- /dev/null +++ b/packages/infra/prisma/migrations/20260725002000_add_message_read_state/migration.sql @@ -0,0 +1,6 @@ +CREATE TABLE "message_read_state" ( + "general_id" INTEGER PRIMARY KEY, + "latest_private_message" INTEGER NOT NULL DEFAULT 0, + "latest_diplomacy_message" INTEGER NOT NULL DEFAULT 0, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP +); diff --git a/packages/infra/prisma/migrations/20260725003000_add_nation_chief/migration.sql b/packages/infra/prisma/migrations/20260725003000_add_nation_chief/migration.sql new file mode 100644 index 0000000..b2159df --- /dev/null +++ b/packages/infra/prisma/migrations/20260725003000_add_nation_chief/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "nation" + ADD COLUMN "chief_general_id" INTEGER; diff --git a/packages/infra/src/db.ts b/packages/infra/src/db.ts index 321141d..e45ab82 100644 --- a/packages/infra/src/db.ts +++ b/packages/infra/src/db.ts @@ -7,6 +7,8 @@ export interface DatabaseClient { worldState: GamePrisma.WorldStateDelegate; general: GamePrisma.GeneralDelegate; generalAccessLog: GamePrisma.GeneralAccessLogDelegate; + messageReadState: GamePrisma.MessageReadStateDelegate; + message: GamePrisma.MessageDelegate; city: GamePrisma.CityDelegate; nation: GamePrisma.NationDelegate; diplomacy: GamePrisma.DiplomacyDelegate; diff --git a/packages/infra/src/turnEngineDb.ts b/packages/infra/src/turnEngineDb.ts index feceacc..4faf3e3 100644 --- a/packages/infra/src/turnEngineDb.ts +++ b/packages/infra/src/turnEngineDb.ts @@ -84,6 +84,7 @@ export interface TurnEngineNationRow { name: string; color: string; capitalCityId: number | null; + chiefGeneralId: number | null; gold: number; rice: number; tech: number; @@ -287,6 +288,7 @@ export interface TurnEngineNationCreateManyInput { name: string; color: string; capitalCityId: number | null; + chiefGeneralId: number | null; gold: number; rice: number; tech: number; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 857a47b..ac0e8ac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -233,6 +233,9 @@ importers: '@fastify/cors': specifier: ^11.2.0 version: 11.2.0 + '@fastify/static': + specifier: ^9.0.0 + version: 9.0.0 '@prisma/client': specifier: ^7.2.0 version: 7.2.0(prisma@7.2.0(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@6.0.2))(typescript@6.0.2) @@ -266,6 +269,9 @@ importers: redis: specifier: ^5.10.0 version: 5.10.0 + sharp: + specifier: ^0.34.4 + version: 0.34.5 zod: specifier: ^4.3.5 version: 4.3.5 diff --git a/tools/integration-tests/test/auctionFlow.test.ts b/tools/integration-tests/test/auctionFlow.test.ts index b05a7e1..763154e 100644 --- a/tools/integration-tests/test/auctionFlow.test.ts +++ b/tools/integration-tests/test/auctionFlow.test.ts @@ -295,7 +295,7 @@ describe('auction integration flow', () => { const adminGatewayToken = await gatewayClient.auth.issueGameSession.mutate({ sessionToken: adminSessionRef.value ?? '', - profile: 'che:2', + profile: 'che:908', }); const adminAccess = await gameClient.auth.exchangeGatewayToken.mutate({ gatewayToken: adminGatewayToken.gameToken,