628 lines
26 KiB
TypeScript
628 lines
26 KiB
TypeScript
import { TRPCError } from '@trpc/server';
|
|
import { z } from 'zod';
|
|
import { asRecord, ChangeJournal } from '@sammo-ts/common';
|
|
import { writeReadModelChangeJournal } from '@sammo-ts/infra';
|
|
import type { UserSanctions } from '@sammo-ts/common/auth/gameToken';
|
|
import { isMessageAccessBlocked } from '@sammo-ts/common/auth/sanctions';
|
|
|
|
import type { GameApiContext } from '../../context.js';
|
|
import {
|
|
accessLimitAuthedInputProcedure,
|
|
accessWallAuthedInputProcedure,
|
|
authedProcedure,
|
|
engineAuthedProcedure,
|
|
router,
|
|
wallAuthedProcedure,
|
|
} from '../../trpc.js';
|
|
import {
|
|
MESSAGE_MAILBOX_NATIONAL_BASE,
|
|
MESSAGE_MAILBOX_PUBLIC,
|
|
sendMessage,
|
|
type MessageDraft,
|
|
type MessageRecordDraft,
|
|
type MessageType,
|
|
} from '@sammo-ts/logic';
|
|
import { buildNationTarget, buildTargetFromGeneral, resolveNationInfo } from '../../messages/targets.js';
|
|
import {
|
|
fetchMessagesFromMailbox,
|
|
fetchOldMessagesFromMailbox,
|
|
fetchMessageById,
|
|
insertMessage,
|
|
tombstoneMessagesWithinDeleteWindow,
|
|
type MessageView,
|
|
} from '../../messages/store.js';
|
|
import { getOwnedGeneral } from '../shared/general.js';
|
|
import { resolveNationPermission } from '../nation/shared.js';
|
|
import { respondToDiplomaticMessage } from '../../messages/diplomaticResponse.js';
|
|
import { executeInputEvent } from '../../inputEventBoundary.js';
|
|
|
|
const zMessageType = z.enum(['private', 'public', 'national', 'diplomacy']);
|
|
|
|
const redactDiplomacyMessages = (messages: MessageView[], permission: number): MessageView[] => {
|
|
if (permission >= 3) {
|
|
return messages;
|
|
}
|
|
return messages.map((message) => {
|
|
if (!message.dest || message.dest.nationId === 0) {
|
|
return message;
|
|
}
|
|
return {
|
|
...message,
|
|
text: '조회 권한이 없는 외교 메시지입니다.',
|
|
};
|
|
});
|
|
};
|
|
|
|
const isMessageFeatureBlocked = (sanctions: UserSanctions, profileNames: string[]): boolean => {
|
|
return isMessageAccessBlocked(sanctions, profileNames);
|
|
};
|
|
|
|
const readPenaltyNumber = (penalty: unknown, key: string, fallback: number): number => {
|
|
const value = asRecord(penalty)[key];
|
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
return value;
|
|
}
|
|
if (typeof value === 'string') {
|
|
const parsed = Number(value);
|
|
if (Number.isFinite(parsed)) {
|
|
return parsed;
|
|
}
|
|
}
|
|
return fallback;
|
|
};
|
|
|
|
const hasPenalty = (penalty: unknown, key: string): boolean => {
|
|
const value = asRecord(penalty)[key];
|
|
return value === true || value === 1 || value === '1';
|
|
};
|
|
|
|
const markMessageMailboxes = (ctx: Pick<GameApiContext, 'changeJournal'>, mailboxes: Iterable<number>): void => {
|
|
for (const mailbox of mailboxes) {
|
|
ctx.changeJournal?.mark('messages.mailbox', mailbox);
|
|
}
|
|
};
|
|
|
|
export const messagesRouter = router({
|
|
getRecent: accessLimitAuthedInputProcedure(
|
|
z.object({
|
|
generalId: z.number().int().positive(),
|
|
sequence: z.number().int().optional(),
|
|
})
|
|
).query(async ({ ctx, input }) => {
|
|
const general = await getOwnedGeneral(ctx, input.generalId);
|
|
|
|
const sequence = input.sequence ?? -1;
|
|
const nationId = general.nationId;
|
|
const mailboxes = {
|
|
private: general.id,
|
|
public: MESSAGE_MAILBOX_PUBLIC,
|
|
national: MESSAGE_MAILBOX_NATIONAL_BASE + nationId,
|
|
diplomacy: MESSAGE_MAILBOX_NATIONAL_BASE + nationId,
|
|
} satisfies Record<MessageType, number>;
|
|
|
|
const [privateMessages, publicMessages, nationalMessages, diplomacyMessages, readState, nation] =
|
|
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 } }),
|
|
nationId > 0
|
|
? ctx.db.nation.findUnique({
|
|
where: { id: nationId },
|
|
select: { meta: true },
|
|
})
|
|
: null,
|
|
]);
|
|
|
|
const permission = nationId > 0 && nation ? resolveNationPermission(general, nation.meta, false) : -1;
|
|
const messageBuckets: Record<MessageType, MessageView[]> = {
|
|
private: privateMessages,
|
|
public: publicMessages,
|
|
national: nationalMessages,
|
|
diplomacy: redactDiplomacyMessages(diplomacyMessages, permission),
|
|
};
|
|
|
|
let nextSequence = sequence;
|
|
let minSequence = sequence;
|
|
let lastType: MessageType | null = null;
|
|
const updateSequence = (type: MessageType, messages: Array<{ id: number }>) => {
|
|
for (const message of messages) {
|
|
if (message.id > nextSequence) {
|
|
nextSequence = message.id;
|
|
}
|
|
if (message.id <= minSequence) {
|
|
minSequence = message.id;
|
|
lastType = type;
|
|
}
|
|
}
|
|
};
|
|
|
|
updateSequence('private', privateMessages);
|
|
updateSequence('public', publicMessages);
|
|
updateSequence('national', nationalMessages);
|
|
updateSequence('diplomacy', diplomacyMessages);
|
|
|
|
if (lastType === 'private' && messageBuckets.private.length > 0) {
|
|
messageBuckets.private.pop();
|
|
} else if (lastType === 'public' && messageBuckets.public.length > 0) {
|
|
messageBuckets.public.pop();
|
|
} else if (lastType === 'national' && messageBuckets.national.length > 0) {
|
|
messageBuckets.national.pop();
|
|
} else if (lastType === 'diplomacy' && messageBuckets.diplomacy.length > 0) {
|
|
messageBuckets.diplomacy.pop();
|
|
}
|
|
|
|
return {
|
|
result: true,
|
|
...messageBuckets,
|
|
sequence: nextSequence,
|
|
nationId: nationId,
|
|
generalName: general.name,
|
|
permission,
|
|
canRespondDiplomacy: permission >= 4 && general.officerLevel > 4,
|
|
latestRead: {
|
|
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) => ({
|
|
nationId: nation.id,
|
|
mailbox: MESSAGE_MAILBOX_NATIONAL_BASE + nation.id,
|
|
name: nation.name,
|
|
color: nation.color,
|
|
general: grouped.get(nation.id) ?? [],
|
|
})),
|
|
};
|
|
}),
|
|
readLatest: wallAuthedProcedure
|
|
.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: wallAuthedProcedure
|
|
.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?.action) {
|
|
throw new TRPCError({
|
|
code: 'BAD_REQUEST',
|
|
message: '시스템 외교 메시지는 삭제할 수 없습니다.',
|
|
});
|
|
}
|
|
if (message.payload.option?.deletable === false) {
|
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '삭제할 수 없는 메시지입니다.' });
|
|
}
|
|
const receiverMessageId = message.payload.option?.receiverMessageID;
|
|
const shouldDeleteReceiverCopy = message.msgType === 'private' || message.msgType === 'national';
|
|
const ids = [
|
|
message.id,
|
|
...(shouldDeleteReceiverCopy && typeof receiverMessageId === 'number' ? [receiverMessageId] : []),
|
|
];
|
|
const deletedIds = await tombstoneMessagesWithinDeleteWindow(ctx.db, message.id, ids);
|
|
if (!deletedIds.includes(message.id)) {
|
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '5분 이내의 메시지만 삭제할 수 있습니다.' });
|
|
}
|
|
const receiverMailbox =
|
|
shouldDeleteReceiverCopy && typeof receiverMessageId === 'number' && message.msgType === 'private'
|
|
? message.payload.dest.generalId
|
|
: shouldDeleteReceiverCopy &&
|
|
typeof receiverMessageId === 'number' &&
|
|
message.msgType === 'national'
|
|
? MESSAGE_MAILBOX_NATIONAL_BASE + message.payload.dest.nationId
|
|
: null;
|
|
markMessageMailboxes(ctx, [message.mailbox, ...(receiverMailbox === null ? [] : [receiverMailbox])]);
|
|
return { ok: true, deletedIds };
|
|
}),
|
|
respond: engineAuthedProcedure
|
|
.input(
|
|
z.object({
|
|
generalId: z.number().int().positive(),
|
|
messageId: z.number().int().positive(),
|
|
response: z.boolean(),
|
|
})
|
|
)
|
|
.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: '메시지가 없습니다.' });
|
|
}
|
|
const action = message.payload.option?.action;
|
|
if (action === 'scout' || action === 'raiseInvader') {
|
|
if (!ctx.auth) {
|
|
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
|
}
|
|
const commandResult = await ctx.turnDaemon.requestCommand({
|
|
type: 'messageRespond',
|
|
userId: ctx.auth.user.id,
|
|
generalId: general.id,
|
|
messageId: input.messageId,
|
|
response: input.response,
|
|
});
|
|
if (!commandResult || commandResult.type !== 'messageRespond') {
|
|
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: '메시지 응답 처리에 실패했습니다.' });
|
|
}
|
|
return { result: commandResult.ok, reason: commandResult.reason };
|
|
}
|
|
const ownsChangeJournal = !ctx.changeJournal;
|
|
const changeJournal = ctx.changeJournal ?? new ChangeJournal();
|
|
let journalPersisted = false;
|
|
const response = await executeInputEvent({
|
|
db: ctx.db,
|
|
requestId: `messages.respond.diplomatic:${input.messageId}`,
|
|
eventType: 'messages.respond.diplomatic',
|
|
payload: input,
|
|
actorUserId: ctx.auth?.user.id,
|
|
execute: async (transaction) => {
|
|
const transactionContext = { ...ctx, db: transaction, changeJournal };
|
|
const transactionGeneral = ownsChangeJournal
|
|
? await getOwnedGeneral(transactionContext, input.generalId)
|
|
: general;
|
|
const result = await respondToDiplomaticMessage({
|
|
db: transaction,
|
|
actor: transactionGeneral,
|
|
messageId: input.messageId,
|
|
response: input.response,
|
|
});
|
|
markMessageMailboxes(transactionContext, result.affectedMailboxes);
|
|
for (const generalId of result.affectedGeneralRecordIds) {
|
|
changeJournal.mark('records.general', generalId);
|
|
}
|
|
for (const nationId of result.affectedNationIds) {
|
|
changeJournal.mark('nation.content', nationId);
|
|
}
|
|
for (const cityId of result.affectedCityIds) {
|
|
changeJournal.mark('city.content', cityId);
|
|
}
|
|
if (result.affectedCityIds.length > 0) {
|
|
changeJournal.mark('map.world');
|
|
}
|
|
if (result.affectedNationIds.length > 0 || result.affectedCityIds.length > 0) {
|
|
changeJournal.mark('dashboard.global');
|
|
}
|
|
if (ownsChangeJournal) {
|
|
journalPersisted = Boolean(
|
|
await writeReadModelChangeJournal(transaction, changeJournal.snapshot())
|
|
);
|
|
}
|
|
return {
|
|
result: result.result,
|
|
reason: result.reason,
|
|
affectedNationIds: result.affectedNationIds,
|
|
affectedCityIds: result.affectedCityIds,
|
|
};
|
|
},
|
|
});
|
|
if (journalPersisted) {
|
|
ctx.readModelOutbox?.wake();
|
|
}
|
|
if (response.result && (response.affectedNationIds.length > 0 || response.affectedCityIds.length > 0)) {
|
|
if (!ctx.auth) {
|
|
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
|
}
|
|
const synchronized = await ctx.turnDaemon.requestCommand({
|
|
type: 'syncDiplomaticResponse',
|
|
userId: ctx.auth.user.id,
|
|
generalId: general.id,
|
|
messageId: input.messageId,
|
|
nationIds: response.affectedNationIds,
|
|
cityIds: response.affectedCityIds,
|
|
});
|
|
if (!synchronized || synchronized.type !== 'syncDiplomaticResponse' || !synchronized.ok) {
|
|
const synchronizationReason =
|
|
synchronized?.type === 'syncDiplomaticResponse' ? synchronized.reason : undefined;
|
|
throw new TRPCError({
|
|
code: 'INTERNAL_SERVER_ERROR',
|
|
message: synchronizationReason ?? '외교 상태를 게임 엔진에 동기화하지 못했습니다.',
|
|
});
|
|
}
|
|
}
|
|
return { result: response.result, reason: response.reason };
|
|
}),
|
|
getOld: authedProcedure
|
|
.input(
|
|
z.object({
|
|
generalId: z.number().int().positive(),
|
|
to: z.number().int().positive(),
|
|
type: zMessageType,
|
|
})
|
|
)
|
|
.query(async ({ ctx, input }) => {
|
|
const general = await getOwnedGeneral(ctx, input.generalId);
|
|
|
|
const nationId = general.nationId;
|
|
const nation =
|
|
nationId > 0
|
|
? await ctx.db.nation.findUnique({
|
|
where: { id: nationId },
|
|
select: { meta: true },
|
|
})
|
|
: null;
|
|
const permission = nationId > 0 && nation ? resolveNationPermission(general, nation.meta, false) : -1;
|
|
const mailboxes = {
|
|
private: general.id,
|
|
public: MESSAGE_MAILBOX_PUBLIC,
|
|
national: MESSAGE_MAILBOX_NATIONAL_BASE + nationId,
|
|
diplomacy: MESSAGE_MAILBOX_NATIONAL_BASE + nationId,
|
|
} satisfies Record<MessageType, number>;
|
|
|
|
const messageBuckets: Record<MessageType, MessageView[]> = {
|
|
private: [],
|
|
public: [],
|
|
national: [],
|
|
diplomacy: [],
|
|
};
|
|
|
|
const messages = await fetchOldMessagesFromMailbox({
|
|
db: ctx.db,
|
|
mailbox: mailboxes[input.type],
|
|
msgType: input.type,
|
|
toSeq: input.to,
|
|
limit: 15,
|
|
});
|
|
messageBuckets[input.type] =
|
|
input.type === 'diplomacy' ? redactDiplomacyMessages(messages, permission) : messages;
|
|
|
|
return {
|
|
result: true,
|
|
keepRecent: true,
|
|
sequence: 0,
|
|
nationId,
|
|
generalName: general.name,
|
|
permission,
|
|
...messageBuckets,
|
|
};
|
|
}),
|
|
send: accessWallAuthedInputProcedure(
|
|
z.object({
|
|
generalId: z.number().int().positive(),
|
|
mailbox: z.number().int(),
|
|
text: z.string().min(1),
|
|
})
|
|
).mutation(async ({ ctx, input }) => {
|
|
const general = await getOwnedGeneral(ctx, input.generalId);
|
|
if (!ctx.auth || isMessageFeatureBlocked(ctx.auth.sanctions, [ctx.profile.name, ctx.profile.id])) {
|
|
throw new TRPCError({
|
|
code: 'FORBIDDEN',
|
|
message: '메시지 전송이 제한된 계정입니다.',
|
|
});
|
|
}
|
|
|
|
const src = await buildTargetFromGeneral(ctx.db, general);
|
|
// Compatibility-only projection. persistMessageEnvelope records and
|
|
// displays the authoritative PostgreSQL wall instant.
|
|
const now = new Date();
|
|
const validUntil = new Date('9999-12-31T00:00:00Z');
|
|
|
|
let msgType: MessageType;
|
|
let dest = src;
|
|
let receiverMailbox = input.mailbox;
|
|
|
|
if (input.mailbox === MESSAGE_MAILBOX_PUBLIC) {
|
|
if (hasPenalty(general.penalty, 'noSendPublicMsg')) {
|
|
throw new TRPCError({
|
|
code: 'FORBIDDEN',
|
|
message: '공개 메세지를 보낼 수 없습니다.',
|
|
});
|
|
}
|
|
msgType = 'public';
|
|
} else if (input.mailbox >= MESSAGE_MAILBOX_NATIONAL_BASE) {
|
|
const sourceNation =
|
|
general.nationId > 0
|
|
? await ctx.db.nation.findUnique({
|
|
where: { id: general.nationId },
|
|
select: { meta: true },
|
|
})
|
|
: null;
|
|
const permission =
|
|
general.nationId > 0 && sourceNation ? resolveNationPermission(general, sourceNation.meta) : -1;
|
|
const destNationId = permission < 4 ? general.nationId : input.mailbox - MESSAGE_MAILBOX_NATIONAL_BASE;
|
|
const nationInfo = await resolveNationInfo(ctx.db, destNationId);
|
|
if (destNationId > 0) {
|
|
const destNation = await ctx.db.nation.findUnique({ where: { id: destNationId } });
|
|
if (!destNation) {
|
|
throw new TRPCError({
|
|
code: 'NOT_FOUND',
|
|
message: '존재하지 않는 국가입니다.',
|
|
});
|
|
}
|
|
}
|
|
dest = buildNationTarget(destNationId, nationInfo.name, nationInfo.color);
|
|
msgType = destNationId === general.nationId ? 'national' : 'diplomacy';
|
|
receiverMailbox = MESSAGE_MAILBOX_NATIONAL_BASE + destNationId;
|
|
} else if (input.mailbox > 0) {
|
|
if (hasPenalty(general.penalty, 'noSendPrivateMsg')) {
|
|
throw new TRPCError({
|
|
code: 'FORBIDDEN',
|
|
message: '개인 메세지를 보낼 수 없습니다.',
|
|
});
|
|
}
|
|
const intervalSeconds = Math.max(
|
|
0,
|
|
Math.ceil(readPenaltyNumber(general.penalty, 'sendPrivateMsgDelay', 2))
|
|
);
|
|
if (intervalSeconds > 0) {
|
|
const rateLimitKey = `game:${ctx.profile.name}:message:private:${ctx.auth.sessionId}`;
|
|
const acquired = await ctx.redis.set(rateLimitKey, '1', {
|
|
NX: true,
|
|
PX: intervalSeconds * 1000,
|
|
});
|
|
if (acquired === null) {
|
|
throw new TRPCError({
|
|
code: 'TOO_MANY_REQUESTS',
|
|
message: `개인메세지는 ${intervalSeconds}초당 1건만 보낼 수 있습니다!`,
|
|
});
|
|
}
|
|
}
|
|
const destGeneral = await ctx.db.general.findUnique({
|
|
where: { id: input.mailbox },
|
|
});
|
|
if (!destGeneral) {
|
|
throw new TRPCError({
|
|
code: 'NOT_FOUND',
|
|
message: '존재하지 않는 유저입니다.',
|
|
});
|
|
}
|
|
const [sourceNation, destNation] = await Promise.all([
|
|
general.nationId > 0
|
|
? ctx.db.nation.findUnique({ where: { id: general.nationId }, select: { meta: true } })
|
|
: null,
|
|
destGeneral.nationId > 0
|
|
? ctx.db.nation.findUnique({ where: { id: destGeneral.nationId }, select: { meta: true } })
|
|
: null,
|
|
]);
|
|
const sourcePermission =
|
|
sourceNation && general.nationId > 0 ? resolveNationPermission(general, sourceNation.meta, false) : -1;
|
|
const destPermission =
|
|
destNation && destGeneral.nationId > 0
|
|
? resolveNationPermission(destGeneral, destNation.meta, false)
|
|
: -1;
|
|
if (sourcePermission === 4 && destPermission === 4 && destGeneral.nationId !== general.nationId) {
|
|
throw new TRPCError({
|
|
code: 'FORBIDDEN',
|
|
message: '외교권자끼리는 메시지를 보낼 수 없습니다.',
|
|
});
|
|
}
|
|
dest = await buildTargetFromGeneral(ctx.db, destGeneral);
|
|
msgType = 'private';
|
|
} else {
|
|
throw new TRPCError({
|
|
code: 'BAD_REQUEST',
|
|
message: 'Invalid mailbox.',
|
|
});
|
|
}
|
|
|
|
const draft: MessageDraft = {
|
|
msgType,
|
|
src,
|
|
dest,
|
|
text: input.text,
|
|
time: now,
|
|
validUntil,
|
|
option: {},
|
|
};
|
|
|
|
const result = await sendMessage(
|
|
{
|
|
insertMessage: (draft: MessageRecordDraft) => insertMessage(ctx.db, draft),
|
|
},
|
|
draft
|
|
);
|
|
|
|
const senderMailbox =
|
|
result.senderId === undefined
|
|
? null
|
|
: msgType === 'private'
|
|
? general.id
|
|
: MESSAGE_MAILBOX_NATIONAL_BASE + general.nationId;
|
|
markMessageMailboxes(ctx, [receiverMailbox, ...(senderMailbox === null ? [] : [senderMailbox])]);
|
|
|
|
return { msgType, msgId: result.receiverId };
|
|
}),
|
|
});
|