feat: complete user-facing message and account APIs

This commit is contained in:
2026-07-25 11:39:47 +00:00
parent 579d0a8d4c
commit fbfaf8df81
26 changed files with 1272 additions and 525 deletions
+35
View File
@@ -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<StoredMessage | null> => {
const rows = await db.$queryRaw<MessageRow[]>`
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<void> => {
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() },
});
};
+145 -32
View File
@@ -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<MessageType, number>;
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<MessageType, MessageView[]> = {
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<number, Array<[number, string, number]>>();
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({
+169
View File
@@ -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<string, unknown> = {}) => {
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',
});
});
});