시간 도메인과 정지 중 메시지·베팅 경계 정리

This commit is contained in:
2026-09-03 16:01:19 +00:00
parent 10cddbb565
commit abceee8315
108 changed files with 3504 additions and 1473 deletions
+4 -10
View File
@@ -60,10 +60,7 @@ export interface AuctionDetail {
export const hasAuctionClosePassed = (
auction: { closeAt: Date; closeTick: bigint | null },
time: { now: Date; tick: number | null }
): boolean =>
auction.closeTick !== null && time.tick !== null
? auction.closeTick < BigInt(time.tick)
: auction.closeAt.getTime() < time.now.getTime();
): boolean => auction.closeTick === null || time.tick === null || auction.closeTick < BigInt(time.tick);
interface AuctionBidRow {
id: number;
@@ -433,7 +430,6 @@ export const auctionRouter = router({
auctionId: auction.id,
generalId: general.id,
amount: input.amount,
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
tryExtendCloseDate: true,
});
throwIfCommandRejected(result);
@@ -448,7 +444,7 @@ export const auctionRouter = router({
const timerKeys = buildAuctionTimerKeys(ctx.profile.name);
const nextCloseAt = new Date(result.closeAt);
await ctx.redis.zAdd(timerKeys.timerKey, [
{ score: resolveAuctionTimerScore(gameTime, nextCloseAt), value: String(auction.id) },
{ score: resolveAuctionTimerScore(gameTime, nextCloseAt, BigInt(result.closeTick)), value: String(auction.id) },
]);
return { ok: true };
@@ -511,7 +507,6 @@ export const auctionRouter = router({
auctionId: auction.id,
generalId: general.id,
amount: input.amount,
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
tryExtendCloseDate: true,
});
throwIfCommandRejected(result);
@@ -526,7 +521,7 @@ export const auctionRouter = router({
const timerKeys = buildAuctionTimerKeys(ctx.profile.name);
const nextCloseAt = new Date(result.closeAt);
await ctx.redis.zAdd(timerKeys.timerKey, [
{ score: resolveAuctionTimerScore(gameTime, nextCloseAt), value: String(auction.id) },
{ score: resolveAuctionTimerScore(gameTime, nextCloseAt, BigInt(result.closeTick)), value: String(auction.id) },
]);
return { ok: true };
@@ -650,7 +645,6 @@ export const auctionRouter = router({
auctionId: auction.id,
generalId: general.id,
amount: input.amount,
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
tryExtendCloseDate: input.tryExtendCloseDate ?? false,
});
throwIfCommandRejected(result);
@@ -665,7 +659,7 @@ export const auctionRouter = router({
const timerKeys = buildAuctionTimerKeys(ctx.profile.name);
const nextCloseAt = new Date(result.closeAt);
await ctx.redis.zAdd(timerKeys.timerKey, [
{ score: resolveAuctionTimerScore(gameTime, nextCloseAt), value: String(auction.id) },
{ score: resolveAuctionTimerScore(gameTime, nextCloseAt, BigInt(result.closeTick)), value: String(auction.id) },
]);
return { ok: true };
+40 -5
View File
@@ -1,5 +1,5 @@
import { TRPCError } from '@trpc/server';
import { GamePrisma } from '@sammo-ts/infra';
import { CLOCK_OPERATION_PERSISTENCE_LOCK, GamePrisma, acquireGameSchemaAdvisoryXactLock } from '@sammo-ts/infra';
import { z } from 'zod';
import { accessAuthedInputProcedure, authedProcedure, router } from '../../trpc.js';
@@ -29,9 +29,43 @@ const loadWorldDate = async (db: Parameters<typeof getMyGeneral>[0]['db']) => {
return world;
};
interface BettingClockFenceRow {
currentYear: number;
currentMonth: number;
clockPhase: string;
clockRevision: bigint;
deadlineGeneration: bigint;
}
const lockBettingClockFence = async (db: Parameters<typeof getMyGeneral>[0]['db']): Promise<BettingClockFenceRow> => {
await acquireGameSchemaAdvisoryXactLock(db, CLOCK_OPERATION_PERSISTENCE_LOCK);
const rows = await db.$queryRaw<BettingClockFenceRow[]>(GamePrisma.sql`
SELECT current_year AS "currentYear",
current_month AS "currentMonth",
clock_phase AS "clockPhase",
clock_revision AS "clockRevision",
deadline_generation AS "deadlineGeneration"
FROM world_state
ORDER BY id ASC
LIMIT 1
FOR UPDATE
`);
const world = rows[0];
if (!world) {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'World state not found.' });
}
if (!['RUNNING', 'MANUAL', 'SUSPENDED'].includes(world.clockPhase)) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: `Nation betting is disabled while the game clock phase is ${world.clockPhase}.`,
});
}
return world;
};
export const bettingRouter = router({
getList: accessAuthedInputProcedure(z.object({ req: z.literal('bettingNation').optional() }).optional())
.query(async ({ ctx, input }) => {
getList: accessAuthedInputProcedure(z.object({ req: z.literal('bettingNation').optional() }).optional()).query(
async ({ ctx, input }) => {
requireUserId(ctx.auth);
await getMyGeneral(ctx);
const [world, rows] = await Promise.all([
@@ -66,7 +100,8 @@ export const bettingRouter = router({
year: world.currentYear,
month: world.currentMonth,
};
}),
}
),
getDetail: authedProcedure
.input(z.object({ bettingId: z.number().int().positive() }))
@@ -141,7 +176,7 @@ export const bettingRouter = router({
if (betting.finished) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 종료된 베팅입니다' });
}
const world = await loadWorldDate(ctx.db);
const world = await lockBettingClockFence(ctx.db);
const yearMonth = joinYearMonth(world.currentYear, world.currentMonth);
if (betting.closeYearMonth <= yearMonth) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 마감된 베팅입니다' });
+5 -8
View File
@@ -15,7 +15,7 @@ import {
import type { GameApiContext, GeneralRow, NationRow } from '../../context.js';
import { insertMessage } from '../../messages/store.js';
import { purifyDiplomacyHtml } from '../../security/diplomacyHtml.js';
import { loadCurrentGameTime } from '../../services/gameClock.js';
import { readDatabaseWallTime } from '../../services/wallClock.js';
import { accessAuthedInputProcedure, accessAuthedProcedure, router } from '../../trpc.js';
import { getMyGeneral } from '../shared/general.js';
import { assertNationAccess, resolveNationPermission } from '../nation/shared.js';
@@ -306,8 +306,6 @@ export const diplomacyRouter = router({
nationColor: destNation.color,
},
};
const letterDate = (await loadCurrentGameTime(ctx.db)).now;
const created = await ctx.db.diplomacyLetter.create({
data: {
srcNationId: srcNation.id,
@@ -316,7 +314,6 @@ export const diplomacyRouter = router({
state: 'PROPOSED',
textBrief: purifyDiplomacyHtml(input.brief),
textDetail: purifyDiplomacyHtml(input.detail),
date: letterDate,
srcSignerId: me.id,
aux: aux as GamePrisma.InputJsonValue,
},
@@ -332,7 +329,7 @@ export const diplomacyRouter = router({
src: srcTarget,
dest: destTarget,
text,
time: letterDate,
time: created.date,
});
return { id: created.id };
@@ -371,7 +368,7 @@ export const diplomacyRouter = router({
);
const messageSrc = buildActorTarget(me, destNation);
const messageDest = buildNationTarget(srcNation);
const messageTime = (await loadCurrentGameTime(ctx.db)).now;
const messageTime = await readDatabaseWallTime(ctx.db);
const aux = asRecord(letter.aux);
let messageText: string;
if (input.agree) {
@@ -458,7 +455,7 @@ export const diplomacyRouter = router({
);
const messageSrc = buildActorTarget(me, srcNation);
const messageDest = buildNationTarget(destNation);
const messageTime = (await loadCurrentGameTime(ctx.db)).now;
const messageTime = await readDatabaseWallTime(ctx.db);
const aux = asRecord(letter.aux);
aux.reason = {
who: me.id,
@@ -519,7 +516,7 @@ export const diplomacyRouter = router({
const otherNation = letter.srcNationId === me.nationId ? destNation : srcNation;
const messageSrc = buildActorTarget(me, actorNation);
const messageDest = buildNationTarget(otherNation);
const messageTime = (await loadCurrentGameTime(ctx.db)).now;
const messageTime = await readDatabaseWallTime(ctx.db);
let resultState: 'ACTIVATED' | 'CANCELLED';
let messageText: string;
+43 -31
View File
@@ -4,6 +4,11 @@ import { z } from 'zod';
import type { GameApiContext, WorldStateRow } from '../../context.js';
import { authedProcedure, engineAuthedProcedure, router } from '../../trpc.js';
import { asNumber, asRecord, asStringArray } from '@sammo-ts/common';
import {
CLOCK_OPERATION_PERSISTENCE_LOCK,
GamePrisma,
acquireGameSchemaAdvisoryXactLock,
} from '@sammo-ts/infra';
import {
isWarTraitKey,
JOIN_PERSONALITY_TRAIT_KEYS,
@@ -393,15 +398,12 @@ export const joinRouter = router({
if (!userId) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
const gameTime = await loadCurrentGameTime(ctx.db);
const commandRequestId = resolveSelectionReservationRequestId(ctx.requestId, userId);
const result = await ctx.turnDaemon.requestCommand({
type: 'selectPoolReserve',
...(commandRequestId ? { requestId: commandRequestId } : {}),
userId,
seedOwnerIdentity: ctx.auth?.user.legacyMemberNo ?? userId,
acceptedGameAt: gameTime.now.toISOString(),
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
});
return resolveSelectionReservationCommandResult(result);
}),
@@ -431,7 +433,6 @@ export const joinRouter = router({
});
}
const commandRequestId = resolveSelectionRequestId(ctx.requestId, userId, input.clientRequestId, 'create');
const gameTime = await loadCurrentGameTime(ctx.db);
const result = await ctx.turnDaemon.requestCommand({
type: 'selectPoolCreate',
...(commandRequestId ? { requestId: commandRequestId } : {}),
@@ -440,8 +441,6 @@ export const joinRouter = router({
uniqueName: input.uniqueName,
personality: input.personality,
seedOwnerIdentity: auth.user.legacyMemberNo ?? userId,
acceptedGameAt: gameTime.now.toISOString(),
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
...(selectedIcon
? {
ownerPicture: selectedIcon.picture,
@@ -471,15 +470,12 @@ export const joinRouter = router({
input.clientRequestId,
'reselect'
);
const gameTime = await loadCurrentGameTime(ctx.db);
const result = await ctx.turnDaemon.requestCommand({
type: 'selectPoolReselect',
...(commandRequestId ? { requestId: commandRequestId } : {}),
userId,
ownerDisplayName: auth.user.displayName,
uniqueName: input.uniqueName,
acceptedGameAt: gameTime.now.toISOString(),
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
});
return resolveSelectionCommandResult(result, 'selectPoolReselect');
}),
@@ -583,30 +579,46 @@ export const joinRouter = router({
message: '이 서버에서는 카카오 인증을 완료해야 장수를 생성할 수 있습니다.',
});
}
const worldState = await ctx.db.worldState.findFirst();
if (!worldState) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'World state is not initialized.',
});
}
try {
const gameTime = await loadCurrentGameTime(ctx.db);
if (gameTime.tick === null) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Game clock is not initialized.',
return await ctx.db.$transaction!(async (transaction) => {
await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK);
const clockRows = await transaction.$queryRaw<Array<{ clockPhase: string }>>(GamePrisma.sql`
SELECT clock_phase AS "clockPhase"
FROM world_state
ORDER BY id ASC
LIMIT 1
FOR UPDATE
`);
if (!clockRows[0]) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'World state is not initialized.',
});
}
if (!['PREOPEN', 'RUNNING', 'MANUAL'].includes(clockRows[0].clockPhase)) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: '게임 시계가 중단된 동안은 NPC 빙의 후보를 갱신할 수 없습니다.',
});
}
const worldState = await transaction.worldState.findFirst();
const gameTime = await loadCurrentGameTime(transaction);
if (!worldState || gameTime.tick === null) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Game clock is not initialized.',
});
}
return reserveNpcPossessionCandidates({
db: transaction,
worldState,
userId: auth.user.id,
ownerIdentity: auth.user.legacyMemberNo ?? auth.user.id,
refresh: input.refresh,
keepIds: input.keepIds,
now: gameTime.now,
createdGameTick: gameTime.tick,
});
}
return await reserveNpcPossessionCandidates({
db: ctx.db,
worldState,
userId: auth.user.id,
ownerIdentity: auth.user.legacyMemberNo ?? auth.user.id,
refresh: input.refresh,
keepIds: input.keepIds,
now: gameTime.now,
acceptedGameTick: gameTime.tick,
});
} catch (error) {
if (error instanceof NpcPossessionError) {
+19 -13
View File
@@ -5,7 +5,13 @@ import type { UserSanctions } from '@sammo-ts/common/auth/gameToken';
import { isMessageAccessBlocked } from '@sammo-ts/common/auth/sanctions';
import type { GameApiContext } from '../../context.js';
import { accessAuthedInputProcedure, accessLimitAuthedInputProcedure, authedProcedure, router } from '../../trpc.js';
import {
accessLimitAuthedInputProcedure,
accessWallAuthedInputProcedure,
authedProcedure,
router,
wallAuthedProcedure,
} from '../../trpc.js';
import {
MESSAGE_MAILBOX_NATIONAL_BASE,
MESSAGE_MAILBOX_PUBLIC,
@@ -20,13 +26,12 @@ import {
fetchOldMessagesFromMailbox,
fetchMessageById,
insertMessage,
tombstoneMessages,
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 { loadCurrentGameTime } from '../../services/gameClock.js';
const zMessageType = z.enum(['private', 'public', 'national', 'diplomacy']);
@@ -231,7 +236,7 @@ export const messagesRouter = router({
})),
};
}),
readLatest: authedProcedure
readLatest: wallAuthedProcedure
.input(
z.object({
generalId: z.number().int().positive(),
@@ -264,7 +269,7 @@ export const messagesRouter = router({
`;
return { ok: true };
}),
delete: authedProcedure
delete: wallAuthedProcedure
.input(
z.object({
generalId: z.number().int().positive(),
@@ -289,17 +294,16 @@ export const messagesRouter = router({
if (message.payload.option?.deletable === false) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '삭제할 수 없는 메시지입니다.' });
}
const { now } = await loadCurrentGameTime(ctx.db);
if (now.getTime() - message.time.getTime() > 5 * 60 * 1000) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '5분 이내의 메시지만 삭제할 수 있습니다.' });
}
const receiverMessageId = message.payload.option?.receiverMessageID;
const shouldDeleteReceiverCopy = message.msgType === 'private' || message.msgType === 'national';
const ids = [
message.id,
...(shouldDeleteReceiverCopy && typeof receiverMessageId === 'number' ? [receiverMessageId] : []),
];
await tombstoneMessages(ctx.db, ids);
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
@@ -309,7 +313,7 @@ export const messagesRouter = router({
? MESSAGE_MAILBOX_NATIONAL_BASE + message.payload.dest.nationId
: null;
markMessageMailboxes(ctx, [message.mailbox, ...(receiverMailbox === null ? [] : [receiverMailbox])]);
return { ok: true, deletedIds: ids };
return { ok: true, deletedIds };
}),
respond: authedProcedure
.input(
@@ -420,7 +424,7 @@ export const messagesRouter = router({
...messageBuckets,
};
}),
send: accessAuthedInputProcedure(
send: accessWallAuthedInputProcedure(
z.object({
generalId: z.number().int().positive(),
mailbox: z.number().int(),
@@ -436,7 +440,9 @@ export const messagesRouter = router({
}
const src = await buildTargetFromGeneral(ctx.db, general);
const { now } = await loadCurrentGameTime(ctx.db);
// 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;
+44 -5
View File
@@ -8,10 +8,10 @@ import type { TournamentState } from '../../tournament/types.js';
import { TournamentStore, type TournamentClockContext } from '../../tournament/store.js';
import { buildTournamentKeys } from '../../tournament/keys.js';
import { assignManualApplicantGroup } from '../../tournament/workerHelpers.js';
import { accessAuthedProcedure, authedProcedure, router } from '../../trpc.js';
import { accessAuthedProcedure, authedProcedure, engineAuthedProcedure, router } from '../../trpc.js';
import { getMyGeneral } from '../shared/general.js';
import { loadCurrentGameTime } from '../../services/gameClock.js';
import { ensureActiveRedisClockFence } from '../../services/redisClockFence.js';
import { ensureActiveRedisClockFence, ensureBettingRedisClockFence } from '../../services/redisClockFence.js';
import { loadClockAdminStatus } from '../../services/clockReadiness.js';
const hasAdminRole = (roles: string[], profileName: string): boolean => {
@@ -64,7 +64,7 @@ const withTournamentClockMutation = async <T>(
});
}
const clockContext: TournamentClockContext = {
phase: 'RUNNING',
phase: fence.phase,
revision: fence.revision,
deadlineGeneration: fence.generation,
dateToTick: gameTime.dateToTick,
@@ -72,6 +72,37 @@ const withTournamentClockMutation = async <T>(
return store.withClockContext(clockContext, () => store.withMutationLock(operation));
};
const withTournamentBetClockMutation = async <T>(
ctx: {
db: Parameters<typeof loadCurrentGameTime>[0];
redis: Parameters<typeof ensureBettingRedisClockFence>[0];
profile: { name: string };
},
store: TournamentStore,
operation: () => Promise<T>
): Promise<T> => {
const gameTime = await loadCurrentGameTime(ctx.db);
const fence = await ensureBettingRedisClockFence(ctx.redis, ctx.profile.name, gameTime);
if (!fence) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Clock reconciliation is incomplete; tournament betting is disabled.',
});
}
return store.withClockContext(
{
phase: fence.phase,
revision: fence.revision,
deadlineGeneration: fence.generation,
dateToTick: gameTime.dateToTick,
},
() => store.withMutationLock(operation)
);
};
const tournamentBetCommandRequestId = (requestId: string | undefined, step: string): string | undefined =>
requestId ? `${requestId}:tournamentBet:${step}` : undefined;
const zTournamentState = z.object({
stage: z.number().int().min(0),
phase: z.number().int().min(0),
@@ -544,7 +575,10 @@ export const tournamentRouter = router({
return { ok: true };
});
}),
placeBet: authedProcedure
// This route delegates its game mutations to durable ENGINE input events.
// Wrapping it in the API input-event transaction would hold the clock
// advisory lock while waiting for the daemon to claim the child event.
placeBet: engineAuthedProcedure
.input(
z.object({
targetId: z.number().int().positive(),
@@ -554,7 +588,7 @@ export const tournamentRouter = router({
.mutation(async ({ ctx, input }) => {
const general = await getMyGeneral(ctx);
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
return withTournamentClockMutation(ctx, store, async () => {
return withTournamentBetClockMutation(ctx, store, async () => {
const state = await store.getState();
if (!state || state.stage !== 6) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '베팅 기간이 아닙니다.' });
@@ -589,6 +623,7 @@ export const tournamentRouter = router({
const adjustResult = await ctx.turnDaemon.requestCommand({
type: 'adjustGeneralResources',
requestId: tournamentBetCommandRequestId(ctx.requestId, 'resources'),
reason: 'tournamentBet',
adjustments: [{ generalId: general.id, goldDelta: -input.amount, minGoldAfter: 500 }],
});
@@ -604,6 +639,7 @@ export const tournamentRouter = router({
const rankResult = await ctx.turnDaemon.requestCommand({
type: 'adjustGeneralMeta',
requestId: tournamentBetCommandRequestId(ctx.requestId, 'rank'),
reason: 'tournamentBet',
adjustments: [
{
@@ -615,6 +651,7 @@ export const tournamentRouter = router({
if (!rankResult || rankResult.type !== 'adjustGeneralMeta' || !rankResult.ok) {
await ctx.turnDaemon.requestCommand({
type: 'adjustGeneralResources',
requestId: tournamentBetCommandRequestId(ctx.requestId, 'rank-rollback-resources'),
reason: 'tournamentBetRollback',
adjustments: [{ generalId: general.id, goldDelta: input.amount }],
});
@@ -631,11 +668,13 @@ export const tournamentRouter = router({
await Promise.all([
ctx.turnDaemon.requestCommand({
type: 'adjustGeneralResources',
requestId: tournamentBetCommandRequestId(ctx.requestId, 'projection-rollback-resources'),
reason: 'tournamentBetRollback',
adjustments: [{ generalId: general.id, goldDelta: input.amount }],
}),
ctx.turnDaemon.requestCommand({
type: 'adjustGeneralMeta',
requestId: tournamentBetCommandRequestId(ctx.requestId, 'projection-rollback-rank'),
reason: 'tournamentBetRollback',
adjustments: [
{
+15 -21
View File
@@ -102,18 +102,16 @@ export const hasPollEnded = (
time: CurrentGameTime
): boolean =>
Boolean(poll.closed_at) ||
(poll.end_tick !== null && time.tick !== null
? poll.end_tick < BigInt(time.tick)
: Boolean(poll.end_at && poll.end_at.getTime() < time.now.getTime()));
Boolean(
poll.end_at &&
(poll.end_tick === null || time.tick === null || poll.end_tick < BigInt(time.tick))
);
const toGameTickOrNull = (time: CurrentGameTime, date: Date | null): bigint | null => {
if (!date) return null;
try {
const tick = time.dateToTick(date);
return tick === null ? null : BigInt(tick);
} catch {
return null;
}
const tick = time.dateToTick(date);
if (tick === null) throw new Error('Vote GAME_TIME deadline requires an initialized game clock.');
return BigInt(tick);
};
type VoteListRow = {
@@ -358,7 +356,6 @@ export const voteRouter = router({
voteId: input.voteId,
generalId: general.id,
selection: sortedSelection,
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
});
throwIfCommandRejected(rewardResult);
@@ -399,8 +396,6 @@ export const voteRouter = router({
? await ctx.db.nation.findFirst({ where: { id: general.nationId }, select: { name: true } })
: null;
const nationName = nation?.name ?? '재야';
const createdAt = new Date();
await ctx.db.$queryRaw(GamePrisma.sql`
INSERT INTO vote_comment (
vote_id,
@@ -418,7 +413,7 @@ export const voteRouter = router({
${general.name},
${nationName},
${input.text},
${createdAt}
CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
)
`);
@@ -451,7 +446,6 @@ export const voteRouter = router({
if (endAt && endAt < gameTime.now) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '종료일이 이미 지났습니다.' });
}
const operationalAt = new Date();
let multipleOptions = input.multipleOptions;
if (multipleOptions < 0) {
@@ -464,7 +458,8 @@ export const voteRouter = router({
if (input.closePrevious) {
await ctx.db.$queryRaw(GamePrisma.sql`
UPDATE vote_poll
SET closed_at = ${gameTime.now}, updated_at = ${operationalAt}
SET closed_at = ${gameTime.now},
updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
WHERE closed_at IS NULL
`);
}
@@ -497,8 +492,8 @@ export const voteRouter = router({
${gameTime.tick === null ? null : BigInt(gameTime.tick)},
${endAt},
${toGameTickOrNull(gameTime, endAt)},
${operationalAt},
${operationalAt}
CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
)
`);
@@ -573,7 +568,6 @@ export const voteRouter = router({
if (endAt && endAt < gameTime.now) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '종료일이 이미 지났습니다.' });
}
const updatedAt = new Date();
if (
input.title === undefined &&
@@ -596,7 +590,7 @@ export const voteRouter = router({
reveal_mode = COALESCE(${input.revealMode}, reveal_mode),
end_at = ${endAt ?? poll.end_at},
end_tick = ${endAt ? toGameTickOrNull(gameTime, endAt) : poll.end_tick},
updated_at = ${updatedAt}
updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
WHERE id = ${input.voteId}
`);
@@ -609,10 +603,10 @@ export const voteRouter = router({
.input(z.object({ voteId: z.number().int().positive() }))
.mutation(async ({ ctx, input }) => {
const gameTime = await loadCurrentGameTime(ctx.db);
const updatedAt = new Date();
const rows = await ctx.db.$queryRaw<Array<{ id: number }>>(GamePrisma.sql`
UPDATE vote_poll
SET closed_at = ${gameTime.now}, updated_at = ${updatedAt}
SET closed_at = ${gameTime.now},
updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
WHERE id = ${input.voteId}
RETURNING id
`);