From 29f6d788e749b522fa3677c07dd03f72ea70c378 Mon Sep 17 00:00:00 2001 From: whekin Date: Mon, 9 Mar 2026 07:18:09 +0400 Subject: [PATCH] refactor(time): migrate runtime time handling to Temporal --- apps/bot/src/anonymous-feedback.test.ts | 69 ++++++++++++++++++- apps/bot/src/anonymous-feedback.ts | 42 +++++++++-- apps/bot/src/miniapp-dashboard.test.ts | 5 +- apps/bot/src/purchase-topic-ingestion.test.ts | 3 +- apps/bot/src/purchase-topic-ingestion.ts | 8 +-- apps/bot/src/reminder-jobs.ts | 8 +-- apps/bot/src/telegram-miniapp-auth.test.ts | 28 ++++---- apps/bot/src/telegram-miniapp-auth.ts | 6 +- bun.lock | 7 ++ docs/specs/HOUSEBOT-074-temporal-migration.md | 45 ++++++++++++ .../src/anonymous-feedback-repository.ts | 17 ++--- .../adapters-db/src/finance-repository.ts | 22 +++--- .../src/household-config-repository.ts | 9 +-- .../src/telegram-pending-action-repository.ts | 29 +++++--- .../src/anonymous-feedback-service.test.ts | 24 ++++--- .../src/anonymous-feedback-service.ts | 23 +++++-- .../src/finance-command-service.test.ts | 7 +- .../src/finance-command-service.ts | 17 +++-- packages/domain/package.json | 3 + packages/domain/src/billing-period.ts | 8 ++- packages/domain/src/index.ts | 11 +++ packages/domain/src/time.ts | 41 +++++++++++ packages/ports/src/anonymous-feedback.ts | 9 ++- packages/ports/src/finance.ts | 12 ++-- .../ports/src/telegram-pending-actions.ts | 4 +- 25 files changed, 353 insertions(+), 104 deletions(-) create mode 100644 docs/specs/HOUSEBOT-074-temporal-migration.md create mode 100644 packages/domain/src/time.ts diff --git a/apps/bot/src/anonymous-feedback.test.ts b/apps/bot/src/anonymous-feedback.test.ts index 71ec8b5..e701a7f 100644 --- a/apps/bot/src/anonymous-feedback.test.ts +++ b/apps/bot/src/anonymous-feedback.test.ts @@ -1,6 +1,7 @@ import { describe, expect, mock, test } from 'bun:test' import type { AnonymousFeedbackService } from '@household/application' +import { instantFromIso, nowInstant, Temporal, type Instant } from '@household/domain' import type { HouseholdConfigurationRepository, TelegramPendingActionRepository @@ -43,7 +44,7 @@ function anonUpdate(params: { } function createPromptRepository(): TelegramPendingActionRepository { - const store = new Map() + const store = new Map() return { async upsertPendingAction(input) { @@ -60,7 +61,7 @@ function createPromptRepository(): TelegramPendingActionRepository { return null } - if (record.expiresAt && record.expiresAt.getTime() <= Date.now()) { + if (record.expiresAt && Temporal.Instant.compare(record.expiresAt, nowInstant()) <= 0) { store.delete(key) return null } @@ -477,4 +478,68 @@ describe('registerAnonymousFeedback', () => { expect(calls[1]?.method).toBe('answerCallbackQuery') expect(calls[2]?.method).toBe('editMessageText') }) + + test('includes the next allowed time in cooldown replies', async () => { + const bot = createTelegramBot('000000:test-token') + const calls: Array<{ method: string; payload: unknown }> = [] + + bot.botInfo = { + id: 999000, + is_bot: true, + first_name: 'Household Test Bot', + username: 'household_test_bot', + can_join_groups: true, + can_read_all_group_messages: false, + supports_inline_queries: false, + can_connect_to_business: false, + has_main_web_app: false, + has_topics_enabled: true, + allows_users_to_create_topics: false + } + + bot.api.config.use(async (_prev, method, payload) => { + calls.push({ method, payload }) + + return { + ok: true, + result: { + message_id: calls.length, + date: Math.floor(Date.now() / 1000), + chat: { + id: 1, + type: 'private' + }, + text: 'ok' + } + } as never + }) + + registerAnonymousFeedback({ + bot, + anonymousFeedbackServiceForHousehold: () => ({ + submit: mock(async () => ({ + status: 'rejected' as const, + reason: 'cooldown' as const, + nextAllowedAt: nowInstant().add({ hours: 6 }) + })), + markPosted: mock(async () => {}), + markFailed: mock(async () => {}) + }), + householdConfigurationRepository: createHouseholdConfigurationRepository(), + promptRepository: createPromptRepository() + }) + + await bot.handleUpdate( + anonUpdate({ + updateId: 1008, + chatType: 'private', + text: '/anon Please clean the kitchen tonight.' + }) as never + ) + + expect(calls).toHaveLength(1) + expect(calls[0]?.payload).toMatchObject({ + text: 'Anonymous feedback cooldown is active. You can send the next message in 6 hours.' + }) + }) }) diff --git a/apps/bot/src/anonymous-feedback.ts b/apps/bot/src/anonymous-feedback.ts index 779b39d..8293d34 100644 --- a/apps/bot/src/anonymous-feedback.ts +++ b/apps/bot/src/anonymous-feedback.ts @@ -1,4 +1,5 @@ import type { AnonymousFeedbackService } from '@household/application' +import { Temporal, nowInstant, type Instant } from '@household/domain' import type { Logger } from '@household/observability' import type { HouseholdConfigurationRepository, @@ -43,7 +44,30 @@ function shouldKeepPrompt(reason: string): boolean { return reason === 'too_short' || reason === 'too_long' || reason === 'blocklisted' } -function rejectionMessage(reason: string): string { +function formatRetryDelay(now: Instant, nextAllowedAt: Instant): string { + if (Temporal.Instant.compare(nextAllowedAt, now) <= 0) { + return 'now' + } + + const duration = now.until(nextAllowedAt, { + largestUnit: 'hour', + smallestUnit: 'minute', + roundingMode: 'ceil' + }) + + const days = Math.floor(duration.hours / 24) + const hours = duration.hours % 24 + + const parts = [ + days > 0 ? `${days} day${days === 1 ? '' : 's'}` : null, + hours > 0 ? `${hours} hour${hours === 1 ? '' : 's'}` : null, + duration.minutes > 0 ? `${duration.minutes} minute${duration.minutes === 1 ? '' : 's'}` : null + ].filter(Boolean) + + return parts.length > 0 ? `in ${parts.join(' ')}` : 'in less than a minute' +} + +function rejectionMessage(reason: string, nextAllowedAt?: Instant, now = nowInstant()): string { switch (reason) { case 'not_member': return 'You are not a member of this household.' @@ -52,9 +76,13 @@ function rejectionMessage(reason: string): string { case 'too_long': return 'Anonymous feedback is too long. Keep it under 500 characters.' case 'cooldown': - return 'Anonymous feedback cooldown is active. Try again later.' + return nextAllowedAt + ? `Anonymous feedback cooldown is active. You can send the next message ${formatRetryDelay(now, nextAllowedAt)}.` + : 'Anonymous feedback cooldown is active. Try again later.' case 'daily_cap': - return 'Daily anonymous feedback limit reached. Try again tomorrow.' + return nextAllowedAt + ? `Daily anonymous feedback limit reached. You can send the next message ${formatRetryDelay(now, nextAllowedAt)}.` + : 'Daily anonymous feedback limit reached. Try again tomorrow.' case 'blocklisted': return 'Message rejected by moderation. Rewrite it in calmer, non-abusive language.' default: @@ -91,7 +119,7 @@ async function startPendingAnonymousFeedbackPrompt( telegramChatId, action: ANONYMOUS_FEEDBACK_ACTION, payload: {}, - expiresAt: new Date(Date.now() + PENDING_ACTION_TTL_MS) + expiresAt: nowInstant().add({ milliseconds: PENDING_ACTION_TTL_MS }) }) await ctx.reply('Send me the anonymous message in your next reply, or tap Cancel.', { @@ -175,10 +203,12 @@ async function submitAnonymousFeedback(options: { await options.promptRepository.clearPendingAction(telegramChatId, telegramUserId) } + const rejectionText = rejectionMessage(result.reason, result.nextAllowedAt, nowInstant()) + await options.ctx.reply( shouldKeepPrompt(result.reason) - ? `${rejectionMessage(result.reason)} Send a revised message, or tap Cancel.` - : rejectionMessage(result.reason), + ? `${rejectionText} Send a revised message, or tap Cancel.` + : rejectionText, shouldKeepPrompt(result.reason) ? { reply_markup: cancelReplyMarkup() diff --git a/apps/bot/src/miniapp-dashboard.test.ts b/apps/bot/src/miniapp-dashboard.test.ts index ed2c8d7..f5658e5 100644 --- a/apps/bot/src/miniapp-dashboard.test.ts +++ b/apps/bot/src/miniapp-dashboard.test.ts @@ -4,6 +4,7 @@ import { createFinanceCommandService, createHouseholdOnboardingService } from '@household/application' +import { instantFromIso } from '@household/domain' import type { FinanceRepository, HouseholdConfigurationRepository, @@ -53,7 +54,7 @@ function repository( amountMinor: 12000n, currency: 'USD', createdByMemberId: member?.id ?? 'member-1', - createdAt: new Date('2026-03-12T12:00:00.000Z') + createdAt: instantFromIso('2026-03-12T12:00:00.000Z') } ], listParsedPurchasesForRange: async () => [ @@ -62,7 +63,7 @@ function repository( payerMemberId: member?.id ?? 'member-1', amountMinor: 3000n, description: 'Soap', - occurredAt: new Date('2026-03-12T11:00:00.000Z') + occurredAt: instantFromIso('2026-03-12T11:00:00.000Z') } ], replaceSettlementSnapshot: async () => {} diff --git a/apps/bot/src/purchase-topic-ingestion.test.ts b/apps/bot/src/purchase-topic-ingestion.test.ts index 9851316..aa1271f 100644 --- a/apps/bot/src/purchase-topic-ingestion.test.ts +++ b/apps/bot/src/purchase-topic-ingestion.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from 'bun:test' +import { instantFromIso } from '@household/domain' import { createTelegramBot } from './bot' import { @@ -25,7 +26,7 @@ function candidate(overrides: Partial = {}): PurchaseTop threadId: '777', senderTelegramUserId: '10002', rawText: 'Bought toilet paper 30 gel', - messageSentAt: new Date('2026-03-05T00:00:00.000Z'), + messageSentAt: instantFromIso('2026-03-05T00:00:00.000Z'), ...overrides } } diff --git a/apps/bot/src/purchase-topic-ingestion.ts b/apps/bot/src/purchase-topic-ingestion.ts index 4ec53a9..9c83c2e 100644 --- a/apps/bot/src/purchase-topic-ingestion.ts +++ b/apps/bot/src/purchase-topic-ingestion.ts @@ -1,5 +1,5 @@ import { parsePurchaseMessage, type PurchaseParserLlmFallback } from '@household/application' -import { Money } from '@household/domain' +import { instantFromEpochSeconds, instantToDate, Money, type Instant } from '@household/domain' import { and, eq } from 'drizzle-orm' import type { Bot, Context } from 'grammy' import type { Logger } from '@household/observability' @@ -24,7 +24,7 @@ export interface PurchaseTopicCandidate { senderTelegramUserId: string senderDisplayName?: string rawText: string - messageSentAt: Date + messageSentAt: Instant } export interface PurchaseTopicRecord extends PurchaseTopicCandidate { @@ -170,7 +170,7 @@ export function createPurchaseMessageRepository(databaseUrl: string): { telegramMessageId: record.messageId, telegramThreadId: record.threadId, telegramUpdateId: String(record.updateId), - messageSentAt: record.messageSentAt, + messageSentAt: instantToDate(record.messageSentAt), parsedAmountMinor: parsed?.amountMinor, parsedCurrency: parsed?.currency, parsedItemDescription: parsed?.itemDescription, @@ -286,7 +286,7 @@ function toCandidateFromContext(ctx: Context): PurchaseTopicCandidate | null { threadId: message.message_thread_id.toString(), senderTelegramUserId, rawText: message.text, - messageSentAt: new Date(message.date * 1000) + messageSentAt: instantFromEpochSeconds(message.date) } if (senderDisplayName.length > 0) { diff --git a/apps/bot/src/reminder-jobs.ts b/apps/bot/src/reminder-jobs.ts index 37db97e..ac7f700 100644 --- a/apps/bot/src/reminder-jobs.ts +++ b/apps/bot/src/reminder-jobs.ts @@ -1,5 +1,5 @@ import type { ReminderJobService } from '@household/application' -import { BillingPeriod } from '@household/domain' +import { BillingPeriod, nowInstant } from '@household/domain' import type { Logger } from '@household/observability' import { REMINDER_TYPES, type ReminderType } from '@household/ports' @@ -27,11 +27,7 @@ function parseReminderType(raw: string): ReminderType | null { } function currentPeriod(): string { - const now = new Date() - const year = now.getUTCFullYear() - const month = `${now.getUTCMonth() + 1}`.padStart(2, '0') - - return `${year}-${month}` + return BillingPeriod.fromInstant(nowInstant()).toString() } async function readBody(request: Request): Promise { diff --git a/apps/bot/src/telegram-miniapp-auth.test.ts b/apps/bot/src/telegram-miniapp-auth.test.ts index c5c3fb6..09fac9f 100644 --- a/apps/bot/src/telegram-miniapp-auth.test.ts +++ b/apps/bot/src/telegram-miniapp-auth.test.ts @@ -1,12 +1,14 @@ import { describe, expect, test } from 'bun:test' +import { instantFromIso, instantToEpochSeconds } from '@household/domain' + import { verifyTelegramMiniAppInitData } from './telegram-miniapp-auth' import { buildMiniAppInitData } from './telegram-miniapp-test-helpers' describe('verifyTelegramMiniAppInitData', () => { test('verifies valid init data and extracts user payload', () => { - const now = new Date('2026-03-08T12:00:00.000Z') - const initData = buildMiniAppInitData('test-bot-token', Math.floor(now.getTime() / 1000), { + const now = instantFromIso('2026-03-08T12:00:00.000Z') + const initData = buildMiniAppInitData('test-bot-token', instantToEpochSeconds(now), { id: 123456, first_name: 'Stan', username: 'stanislav' @@ -24,9 +26,9 @@ describe('verifyTelegramMiniAppInitData', () => { }) test('rejects invalid hash', () => { - const now = new Date('2026-03-08T12:00:00.000Z') + const now = instantFromIso('2026-03-08T12:00:00.000Z') const params = new URLSearchParams( - buildMiniAppInitData('test-bot-token', Math.floor(now.getTime() / 1000), { + buildMiniAppInitData('test-bot-token', instantToEpochSeconds(now), { id: 123456, first_name: 'Stan' }) @@ -39,15 +41,11 @@ describe('verifyTelegramMiniAppInitData', () => { }) test('rejects expired init data', () => { - const now = new Date('2026-03-08T12:00:00.000Z') - const initData = buildMiniAppInitData( - 'test-bot-token', - Math.floor(now.getTime() / 1000) - 7200, - { - id: 123456, - first_name: 'Stan' - } - ) + const now = instantFromIso('2026-03-08T12:00:00.000Z') + const initData = buildMiniAppInitData('test-bot-token', instantToEpochSeconds(now) - 7200, { + id: 123456, + first_name: 'Stan' + }) const result = verifyTelegramMiniAppInitData(initData, 'test-bot-token', now, 3600) @@ -55,8 +53,8 @@ describe('verifyTelegramMiniAppInitData', () => { }) test('rejects init data timestamps from the future', () => { - const now = new Date('2026-03-08T12:00:00.000Z') - const initData = buildMiniAppInitData('test-bot-token', Math.floor(now.getTime() / 1000) + 5, { + const now = instantFromIso('2026-03-08T12:00:00.000Z') + const initData = buildMiniAppInitData('test-bot-token', instantToEpochSeconds(now) + 5, { id: 123456, first_name: 'Stan' }) diff --git a/apps/bot/src/telegram-miniapp-auth.ts b/apps/bot/src/telegram-miniapp-auth.ts index 6582f82..1d224fc 100644 --- a/apps/bot/src/telegram-miniapp-auth.ts +++ b/apps/bot/src/telegram-miniapp-auth.ts @@ -1,5 +1,7 @@ import { createHmac, timingSafeEqual } from 'node:crypto' +import { instantToEpochSeconds, nowInstant, type Instant } from '@household/domain' + interface TelegramUserPayload { id: number first_name?: string @@ -19,7 +21,7 @@ export interface VerifiedMiniAppUser { export function verifyTelegramMiniAppInitData( initData: string, botToken: string, - now = new Date(), + now: Instant = nowInstant(), maxAgeSeconds = 3600 ): VerifiedMiniAppUser | null { const params = new URLSearchParams(initData) @@ -35,7 +37,7 @@ export function verifyTelegramMiniAppInitData( } const authDateSeconds = Number(authDateRaw) - const nowSeconds = Math.floor(now.getTime() / 1000) + const nowSeconds = instantToEpochSeconds(now) if (authDateSeconds > nowSeconds) { return null } diff --git a/bun.lock b/bun.lock index a64273e..94d8f48 100644 --- a/bun.lock +++ b/bun.lock @@ -76,6 +76,9 @@ }, "packages/domain": { "name": "@household/domain", + "dependencies": { + "@js-temporal/polyfill": "^0.5.1", + }, }, "packages/observability": { "name": "@household/observability", @@ -231,6 +234,8 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@js-temporal/polyfill": ["@js-temporal/polyfill@0.5.1", "", { "dependencies": { "jsbi": "^4.3.0" } }, "sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ=="], + "@nothing-but/utils": ["@nothing-but/utils@0.17.0", "", {}, "sha512-TuCHcHLOqDL0SnaAxACfuRHBNRgNJcNn9X0GiH5H3YSDBVquCr3qEIG3FOQAuMyZCbu9w8nk2CHhOsn7IvhIwQ=="], "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.51.0", "", { "os": "android", "cpu": "arm" }, "sha512-jJYIqbx4sX+suIxWstc4P7SzhEwb4ArWA2KVrmEuu9vH2i0qM6QIHz/ehmbGE4/2fZbpuMuBzTl7UkfNoqiSgw=="], @@ -533,6 +538,8 @@ "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + "jsbi": ["jsbi@4.3.2", "", {}, "sha512-9fqMSQbhJykSeii05nxKl4m6Eqn2P6rOlYiS+C5Dr/HPIU/7yZxu5qzbs40tgaFORiw2Amd0mirjxatXYMkIew=="], + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], "json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="], diff --git a/docs/specs/HOUSEBOT-074-temporal-migration.md b/docs/specs/HOUSEBOT-074-temporal-migration.md new file mode 100644 index 0000000..95b8398 --- /dev/null +++ b/docs/specs/HOUSEBOT-074-temporal-migration.md @@ -0,0 +1,45 @@ +# HOUSEBOT-074 Temporal Migration + +## Goal + +Move runtime time handling from ad-hoc `Date` usage to `Temporal` via `@js-temporal/polyfill`. + +## Why + +- Bun does not provide native `Temporal` yet, so the polyfill is the safe path. +- The bot has already had production failures caused by `Date` values crossing Bun/Postgres boundaries inconsistently. +- Time handling should be explicit and deterministic at adapter boundaries. + +## Slice 1 + +- Add shared Temporal helpers in `@household/domain` +- Migrate anonymous feedback service and repository +- Migrate Telegram pending-action expiry handling +- Migrate mini app auth timestamp verification + +## Boundary Rules + +- Business/application code uses `Temporal.Instant` instead of `Date` +- Database adapters convert `Temporal.Instant` to SQL-compatible values on write +- Database adapters normalize string/`Date` timestamp values back to `Temporal.Instant` on read +- Telegram epoch timestamps remain numeric seconds at the API edge + +## Non-goals + +- Full repo-wide `Date` removal in one change +- Database schema changes +- Billing-period model replacement + +## Follow-up Slices + +- Finance command service and finance repository date handling +- Purchase ingestion timestamps +- Reminder job scheduling timestamps +- Test helpers that still construct `Date` values for untouched paths + +## Acceptance + +- No raw `Date` values cross the anonymous-feedback, pending-action, or mini app auth application boundaries +- `bun run typecheck` +- `bun run test` +- `bun run build` diff --git a/packages/adapters-db/src/anonymous-feedback-repository.ts b/packages/adapters-db/src/anonymous-feedback-repository.ts index c9385bc..9d00cdf 100644 --- a/packages/adapters-db/src/anonymous-feedback-repository.ts +++ b/packages/adapters-db/src/anonymous-feedback-repository.ts @@ -1,6 +1,7 @@ import { and, eq, inArray, sql } from 'drizzle-orm' import { createDbClient, schema } from '@household/db' +import { instantFromDatabaseValue, instantToDate } from '@household/domain' import type { AnonymousFeedbackModerationStatus, AnonymousFeedbackRepository @@ -49,11 +50,14 @@ export function createDbAnonymousFeedbackRepository( }, async getRateLimitSnapshot(memberId, acceptedSince) { - const acceptedSinceIso = acceptedSince.toISOString() + const acceptedSinceIso = acceptedSince.toString() const rows = await db .select({ acceptedCountSince: sql`count(*) filter (where ${schema.anonymousMessages.createdAt} >= ${acceptedSinceIso}::timestamptz)`, + earliestAcceptedAtSince: sql< + string | Date | null + >`min(${schema.anonymousMessages.createdAt}) filter (where ${schema.anonymousMessages.createdAt} >= ${acceptedSinceIso}::timestamptz)`, lastAcceptedAt: sql`max(${schema.anonymousMessages.createdAt})` }) .from(schema.anonymousMessages) @@ -65,16 +69,13 @@ export function createDbAnonymousFeedbackRepository( ) ) + const earliestAcceptedAtSinceRaw = rows[0]?.earliestAcceptedAtSince ?? null const lastAcceptedAtRaw = rows[0]?.lastAcceptedAt ?? null return { acceptedCountSince: Number(rows[0]?.acceptedCountSince ?? '0'), - lastAcceptedAt: - lastAcceptedAtRaw instanceof Date - ? lastAcceptedAtRaw - : typeof lastAcceptedAtRaw === 'string' - ? new Date(lastAcceptedAtRaw) - : null + earliestAcceptedAtSince: instantFromDatabaseValue(earliestAcceptedAtSinceRaw), + lastAcceptedAt: instantFromDatabaseValue(lastAcceptedAtRaw) } }, @@ -146,7 +147,7 @@ export function createDbAnonymousFeedbackRepository( postedChatId: input.postedChatId, postedThreadId: input.postedThreadId, postedMessageId: input.postedMessageId, - postedAt: input.postedAt, + postedAt: instantToDate(input.postedAt), failureReason: null }) .where(eq(schema.anonymousMessages.id, input.submissionId)) diff --git a/packages/adapters-db/src/finance-repository.ts b/packages/adapters-db/src/finance-repository.ts index 05fae4f..b21f8a8 100644 --- a/packages/adapters-db/src/finance-repository.ts +++ b/packages/adapters-db/src/finance-repository.ts @@ -1,8 +1,13 @@ -import { and, desc, eq, gte, isNotNull, isNull, lte, or, sql } from 'drizzle-orm' +import { and, desc, eq, gte, isNotNull, isNull, lt, lte, or, sql } from 'drizzle-orm' import { createDbClient, schema } from '@household/db' import type { FinanceRepository } from '@household/ports' -import type { CurrencyCode } from '@household/domain' +import { + instantFromDatabaseValue, + instantToDate, + nowInstant, + type CurrencyCode +} from '@household/domain' function toCurrencyCode(raw: string): CurrencyCode { const normalized = raw.trim().toUpperCase() @@ -171,7 +176,7 @@ export function createDbFinanceRepository( await db .update(schema.billingCycles) .set({ - closedAt + closedAt: instantToDate(closedAt) }) .where(eq(schema.billingCycles.id, cycleId)) }, @@ -265,7 +270,8 @@ export function createDbFinanceRepository( return rows.map((row) => ({ ...row, - currency: toCurrencyCode(row.currency) + currency: toCurrencyCode(row.currency), + createdAt: instantFromDatabaseValue(row.createdAt)! })) }, @@ -284,8 +290,8 @@ export function createDbFinanceRepository( eq(schema.purchaseMessages.householdId, householdId), isNotNull(schema.purchaseMessages.senderMemberId), isNotNull(schema.purchaseMessages.parsedAmountMinor), - gte(schema.purchaseMessages.messageSentAt, start), - lte(schema.purchaseMessages.messageSentAt, end) + gte(schema.purchaseMessages.messageSentAt, instantToDate(start)), + lt(schema.purchaseMessages.messageSentAt, instantToDate(end)) ) ) @@ -294,7 +300,7 @@ export function createDbFinanceRepository( payerMemberId: row.payerMemberId!, amountMinor: row.amountMinor!, description: row.description, - occurredAt: row.occurredAt + occurredAt: instantFromDatabaseValue(row.occurredAt) })) }, @@ -316,7 +322,7 @@ export function createDbFinanceRepository( inputHash: snapshot.inputHash, totalDueMinor: snapshot.totalDueMinor, currency: snapshot.currency, - computedAt: new Date(), + computedAt: instantToDate(nowInstant()), metadata: snapshot.metadata } }) diff --git a/packages/adapters-db/src/household-config-repository.ts b/packages/adapters-db/src/household-config-repository.ts index 85dc20d..6ffc365 100644 --- a/packages/adapters-db/src/household-config-repository.ts +++ b/packages/adapters-db/src/household-config-repository.ts @@ -1,6 +1,7 @@ import { and, eq } from 'drizzle-orm' import { createDbClient, schema } from '@household/db' +import { instantToDate, nowInstant } from '@household/domain' import { HOUSEHOLD_TOPIC_ROLES, type HouseholdConfigurationRepository, @@ -138,7 +139,7 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): { .set({ telegramChatType: input.telegramChatType, title: nextTitle, - updatedAt: new Date() + updatedAt: instantToDate(nowInstant()) }) .where(eq(schema.householdTelegramChats.telegramChatId, input.telegramChatId)) @@ -256,7 +257,7 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): { set: { telegramThreadId: input.telegramThreadId, topicName: input.topicName?.trim() || null, - updatedAt: new Date() + updatedAt: instantToDate(nowInstant()) } }) .returning({ @@ -348,7 +349,7 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): { set: { token: input.token, createdByTelegramUserId: input.createdByTelegramUserId ?? null, - updatedAt: new Date() + updatedAt: instantToDate(nowInstant()) } }) .returning({ @@ -448,7 +449,7 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): { displayName: input.displayName, username: input.username?.trim() || null, languageCode: input.languageCode?.trim() || null, - updatedAt: new Date() + updatedAt: instantToDate(nowInstant()) } }) .returning({ diff --git a/packages/adapters-db/src/telegram-pending-action-repository.ts b/packages/adapters-db/src/telegram-pending-action-repository.ts index e4f05a4..9996cb8 100644 --- a/packages/adapters-db/src/telegram-pending-action-repository.ts +++ b/packages/adapters-db/src/telegram-pending-action-repository.ts @@ -1,6 +1,7 @@ import { and, eq } from 'drizzle-orm' import { createDbClient, schema } from '@household/db' +import { instantFromDatabaseValue, instantToDate, nowInstant, Temporal } from '@household/domain' import type { TelegramPendingActionRecord, TelegramPendingActionRepository, @@ -20,7 +21,7 @@ function mapPendingAction(row: { telegramChatId: string action: string payload: unknown - expiresAt: Date | null + expiresAt: Date | string | null }): TelegramPendingActionRecord { return { telegramUserId: row.telegramUserId, @@ -30,7 +31,7 @@ function mapPendingAction(row: { row.payload && typeof row.payload === 'object' && !Array.isArray(row.payload) ? (row.payload as Record) : {}, - expiresAt: row.expiresAt + expiresAt: instantFromDatabaseValue(row.expiresAt) } } @@ -52,8 +53,8 @@ export function createDbTelegramPendingActionRepository(databaseUrl: string): { telegramChatId: input.telegramChatId, action: input.action, payload: input.payload, - expiresAt: input.expiresAt, - updatedAt: new Date() + expiresAt: input.expiresAt ? instantToDate(input.expiresAt) : null, + updatedAt: instantToDate(nowInstant()) }) .onConflictDoUpdate({ target: [ @@ -63,8 +64,8 @@ export function createDbTelegramPendingActionRepository(databaseUrl: string): { set: { action: input.action, payload: input.payload, - expiresAt: input.expiresAt, - updatedAt: new Date() + expiresAt: input.expiresAt ? instantToDate(input.expiresAt) : null, + updatedAt: instantToDate(nowInstant()) } }) .returning({ @@ -84,7 +85,7 @@ export function createDbTelegramPendingActionRepository(databaseUrl: string): { }, async getPendingAction(telegramChatId, telegramUserId) { - const now = new Date() + const now = nowInstant() const rows = await db .select({ telegramUserId: schema.telegramPendingActions.telegramUserId, @@ -107,7 +108,8 @@ export function createDbTelegramPendingActionRepository(databaseUrl: string): { return null } - if (row.expiresAt && row.expiresAt.getTime() <= now.getTime()) { + const expiresAt = instantFromDatabaseValue(row.expiresAt) + if (expiresAt && Temporal.Instant.compare(expiresAt, now) <= 0) { await db .delete(schema.telegramPendingActions) .where( @@ -120,7 +122,16 @@ export function createDbTelegramPendingActionRepository(databaseUrl: string): { return null } - return mapPendingAction(row) + return { + telegramUserId: row.telegramUserId, + telegramChatId: row.telegramChatId, + action: parsePendingActionType(row.action), + payload: + row.payload && typeof row.payload === 'object' && !Array.isArray(row.payload) + ? (row.payload as Record) + : {}, + expiresAt + } }, async clearPendingAction(telegramChatId, telegramUserId) { diff --git a/packages/application/src/anonymous-feedback-service.test.ts b/packages/application/src/anonymous-feedback-service.test.ts index c7aad24..118979c 100644 --- a/packages/application/src/anonymous-feedback-service.test.ts +++ b/packages/application/src/anonymous-feedback-service.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from 'bun:test' +import { instantFromIso, type Instant } from '@household/domain' import type { AnonymousFeedbackMemberRecord, AnonymousFeedbackRepository, @@ -16,7 +17,8 @@ class AnonymousFeedbackRepositoryStub implements AnonymousFeedbackRepository { } acceptedCountSince = 0 - lastAcceptedAt: Date | null = null + earliestAcceptedAtSince: Instant | null = null + lastAcceptedAt: Instant | null = null duplicate = false created: Array<{ rawText: string @@ -34,6 +36,7 @@ class AnonymousFeedbackRepositoryStub implements AnonymousFeedbackRepository { async getRateLimitSnapshot() { return { acceptedCountSince: this.acceptedCountSince, + earliestAcceptedAtSince: this.earliestAcceptedAtSince, lastAcceptedAt: this.lastAcceptedAt } } @@ -69,7 +72,7 @@ class AnonymousFeedbackRepositoryStub implements AnonymousFeedbackRepository { postedChatId: string postedThreadId: string postedMessageId: string - postedAt: Date + postedAt: Instant }) { this.posted.push({ submissionId: input.submissionId, @@ -94,7 +97,7 @@ describe('createAnonymousFeedbackService', () => { telegramChatId: 'chat-1', telegramMessageId: 'message-1', telegramUpdateId: 'update-1', - now: new Date('2026-03-08T12:00:00.000Z') + now: instantFromIso('2026-03-08T12:00:00.000Z') }) expect(result).toEqual({ @@ -154,7 +157,7 @@ describe('createAnonymousFeedbackService', () => { const repository = new AnonymousFeedbackRepositoryStub() const service = createAnonymousFeedbackService(repository) - repository.lastAcceptedAt = new Date('2026-03-08T09:00:00.000Z') + repository.lastAcceptedAt = instantFromIso('2026-03-08T09:00:00.000Z') const cooldownResult = await service.submit({ telegramUserId: '123', @@ -162,15 +165,17 @@ describe('createAnonymousFeedbackService', () => { telegramChatId: 'chat-1', telegramMessageId: 'message-1', telegramUpdateId: 'update-1', - now: new Date('2026-03-08T12:00:00.000Z') + now: instantFromIso('2026-03-08T12:00:00.000Z') }) expect(cooldownResult).toEqual({ status: 'rejected', - reason: 'cooldown' + reason: 'cooldown', + nextAllowedAt: instantFromIso('2026-03-08T15:00:00.000Z') }) - repository.lastAcceptedAt = new Date('2026-03-07T00:00:00.000Z') + repository.earliestAcceptedAtSince = instantFromIso('2026-03-07T18:00:00.000Z') + repository.lastAcceptedAt = instantFromIso('2026-03-07T23:00:00.000Z') repository.acceptedCountSince = 3 const dailyCapResult = await service.submit({ @@ -179,12 +184,13 @@ describe('createAnonymousFeedbackService', () => { telegramChatId: 'chat-1', telegramMessageId: 'message-2', telegramUpdateId: 'update-2', - now: new Date('2026-03-08T12:00:00.000Z') + now: instantFromIso('2026-03-08T12:00:00.000Z') }) expect(dailyCapResult).toEqual({ status: 'rejected', - reason: 'daily_cap' + reason: 'daily_cap', + nextAllowedAt: instantFromIso('2026-03-08T18:00:00.000Z') }) }) diff --git a/packages/application/src/anonymous-feedback-service.ts b/packages/application/src/anonymous-feedback-service.ts index 8ba7ea1..24c3394 100644 --- a/packages/application/src/anonymous-feedback-service.ts +++ b/packages/application/src/anonymous-feedback-service.ts @@ -2,6 +2,7 @@ import type { AnonymousFeedbackRejectionReason, AnonymousFeedbackRepository } from '@household/ports' +import { nowInstant, type Instant, Temporal } from '@household/domain' const MIN_MESSAGE_LENGTH = 12 const MAX_MESSAGE_LENGTH = 500 @@ -46,6 +47,7 @@ export type AnonymousFeedbackSubmitResult = status: 'rejected' reason: AnonymousFeedbackRejectionReason detail?: string + nextAllowedAt?: Instant } export interface AnonymousFeedbackService { @@ -55,14 +57,14 @@ export interface AnonymousFeedbackService { telegramChatId: string telegramMessageId: string telegramUpdateId: string - now?: Date + now?: Instant }): Promise markPosted(input: { submissionId: string postedChatId: string postedThreadId: string postedMessageId: string - postedAt?: Date + postedAt?: Instant }): Promise markFailed(submissionId: string, failureReason: string): Promise } @@ -74,6 +76,7 @@ async function rejectSubmission( rawText: string reason: AnonymousFeedbackRejectionReason detail?: string + nextAllowedAt?: Instant telegramChatId: string telegramMessageId: string telegramUpdateId: string @@ -100,6 +103,7 @@ async function rejectSubmission( return { status: 'rejected', reason: input.reason, + ...(input.nextAllowedAt ? { nextAllowedAt: input.nextAllowedAt } : {}), ...(input.detail ? { detail: input.detail } : {}) } } @@ -153,14 +157,18 @@ export function createAnonymousFeedbackService( }) } - const now = input.now ?? new Date() - const acceptedSince = new Date(now.getTime() - 24 * 60 * 60 * 1000) + const now = input.now ?? nowInstant() + const acceptedSince = now.subtract({ hours: 24 }) const rateLimit = await repository.getRateLimitSnapshot(member.id, acceptedSince) if (rateLimit.acceptedCountSince >= DAILY_CAP) { + const nextAllowedAt = + rateLimit.earliestAcceptedAtSince?.add({ hours: 24 }) ?? now.add({ hours: 24 }) + return rejectSubmission(repository, { memberId: member.id, rawText: input.rawText, reason: 'daily_cap', + nextAllowedAt, telegramChatId: input.telegramChatId, telegramMessageId: input.telegramMessageId, telegramUpdateId: input.telegramUpdateId @@ -168,12 +176,13 @@ export function createAnonymousFeedbackService( } if (rateLimit.lastAcceptedAt) { - const cooldownBoundary = now.getTime() - COOLDOWN_HOURS * 60 * 60 * 1000 - if (rateLimit.lastAcceptedAt.getTime() > cooldownBoundary) { + const cooldownBoundary = now.subtract({ hours: COOLDOWN_HOURS }) + if (Temporal.Instant.compare(rateLimit.lastAcceptedAt, cooldownBoundary) > 0) { return rejectSubmission(repository, { memberId: member.id, rawText: input.rawText, reason: 'cooldown', + nextAllowedAt: rateLimit.lastAcceptedAt.add({ hours: COOLDOWN_HOURS }), telegramChatId: input.telegramChatId, telegramMessageId: input.telegramMessageId, telegramUpdateId: input.telegramUpdateId @@ -212,7 +221,7 @@ export function createAnonymousFeedbackService( postedChatId: input.postedChatId, postedThreadId: input.postedThreadId, postedMessageId: input.postedMessageId, - postedAt: input.postedAt ?? new Date() + postedAt: input.postedAt ?? nowInstant() }) }, diff --git a/packages/application/src/finance-command-service.test.ts b/packages/application/src/finance-command-service.test.ts index 2a71e2b..b77505e 100644 --- a/packages/application/src/finance-command-service.test.ts +++ b/packages/application/src/finance-command-service.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from 'bun:test' +import { instantFromIso, type Instant } from '@household/domain' import type { FinanceCycleRecord, FinanceMemberRecord, @@ -26,7 +27,7 @@ class FinanceRepositoryStub implements FinanceRepository { amountMinor: bigint currency: 'USD' | 'GEL' createdByMemberId: string | null - createdAt: Date + createdAt: Instant }[] = [] lastSavedRentRule: { @@ -180,7 +181,7 @@ describe('createFinanceCommandService', () => { amountMinor: 12000n, currency: 'USD', createdByMemberId: 'alice', - createdAt: new Date('2026-03-12T12:00:00.000Z') + createdAt: instantFromIso('2026-03-12T12:00:00.000Z') } ] repository.purchases = [ @@ -189,7 +190,7 @@ describe('createFinanceCommandService', () => { payerMemberId: 'alice', amountMinor: 3000n, description: 'Soap', - occurredAt: new Date('2026-03-12T11:00:00.000Z') + occurredAt: instantFromIso('2026-03-12T11:00:00.000Z') } ] diff --git a/packages/application/src/finance-command-service.ts b/packages/application/src/finance-command-service.ts index e59f9a9..cab1654 100644 --- a/packages/application/src/finance-command-service.ts +++ b/packages/application/src/finance-command-service.ts @@ -7,6 +7,8 @@ import { MemberId, Money, PurchaseEntryId, + Temporal, + nowInstant, type CurrencyCode } from '@household/domain' @@ -25,10 +27,13 @@ function parseCurrency(raw: string | undefined, fallback: CurrencyCode): Currenc return normalized } -function monthRange(period: BillingPeriod): { start: Date; end: Date } { +function monthRange(period: BillingPeriod): { + start: Temporal.Instant + end: Temporal.Instant +} { return { - start: new Date(Date.UTC(period.year, period.month - 1, 1, 0, 0, 0)), - end: new Date(Date.UTC(period.year, period.month, 0, 23, 59, 59)) + start: Temporal.Instant.from(`${period.toString()}-01T00:00:00Z`), + end: Temporal.Instant.from(`${period.next().toString()}-01T00:00:00Z`) } } @@ -161,7 +166,7 @@ async function buildFinanceDashboard( actorDisplayName: bill.createdByMemberId ? (memberNameById.get(bill.createdByMemberId) ?? null) : null, - occurredAt: bill.createdAt.toISOString() + occurredAt: bill.createdAt.toString() })), ...purchases.map((purchase) => ({ id: purchase.id, @@ -169,7 +174,7 @@ async function buildFinanceDashboard( title: purchase.description ?? 'Shared purchase', amount: Money.fromMinor(purchase.amountMinor, rentRule.currency), actorDisplayName: memberNameById.get(purchase.payerMemberId) ?? null, - occurredAt: purchase.occurredAt?.toISOString() ?? null + occurredAt: purchase.occurredAt?.toString() ?? null })) ].sort((left, right) => { if (left.occurredAt === right.occurredAt) { @@ -246,7 +251,7 @@ export function createFinanceCommandService(repository: FinanceRepository): Fina return null } - await repository.closeCycle(cycle.id, new Date()) + await repository.closeCycle(cycle.id, nowInstant()) return cycle }, diff --git a/packages/domain/package.json b/packages/domain/package.json index 1e0af89..c12da46 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -10,5 +10,8 @@ "typecheck": "tsgo --project tsconfig.json --noEmit", "test": "bun test --pass-with-no-tests", "lint": "oxlint \"src\"" + }, + "dependencies": { + "@js-temporal/polyfill": "^0.5.1" } } diff --git a/packages/domain/src/billing-period.ts b/packages/domain/src/billing-period.ts index 4ac5942..939397c 100644 --- a/packages/domain/src/billing-period.ts +++ b/packages/domain/src/billing-period.ts @@ -1,3 +1,5 @@ +import type { Instant } from './time' + import { DOMAIN_ERROR_CODE, DomainError } from './errors' const BILLING_PERIOD_PATTERN = /^(\d{4})-(\d{2})$/ @@ -48,8 +50,10 @@ export class BillingPeriod { return BillingPeriod.from(Number(yearString), Number(monthString)) } - static fromDate(date: Date): BillingPeriod { - return BillingPeriod.from(date.getUTCFullYear(), date.getUTCMonth() + 1) + static fromInstant(instant: Instant): BillingPeriod { + const zoned = instant.toZonedDateTimeISO('UTC') + + return BillingPeriod.from(zoned.year, zoned.month) } next(): BillingPeriod { diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index 9ba467e..d83f83a 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -2,7 +2,18 @@ export { BillingPeriod } from './billing-period' export { DOMAIN_ERROR_CODE, DomainError } from './errors' export { BillingCycleId, HouseholdId, MemberId, PurchaseEntryId } from './ids' export { CURRENCIES, Money } from './money' +export { + Temporal, + instantFromDatabaseValue, + instantFromDate, + instantFromEpochSeconds, + instantFromIso, + instantToDate, + instantToEpochSeconds, + nowInstant +} from './time' export type { CurrencyCode } from './money' +export type { Instant } from './time' export type { SettlementInput, SettlementMemberInput, diff --git a/packages/domain/src/time.ts b/packages/domain/src/time.ts new file mode 100644 index 0000000..83b9d25 --- /dev/null +++ b/packages/domain/src/time.ts @@ -0,0 +1,41 @@ +import { Temporal } from '@js-temporal/polyfill' + +export { Temporal } + +export type Instant = Temporal.Instant + +export function nowInstant(): Instant { + return Temporal.Now.instant() +} + +export function instantFromEpochSeconds(epochSeconds: number): Instant { + return Temporal.Instant.fromEpochMilliseconds(epochSeconds * 1000) +} + +export function instantToEpochSeconds(instant: Instant): number { + return Math.floor(instant.epochMilliseconds / 1000) +} + +export function instantFromDate(date: Date): Instant { + return Temporal.Instant.fromEpochMilliseconds(date.getTime()) +} + +export function instantToDate(instant: Instant): Date { + return new Date(instant.epochMilliseconds) +} + +export function instantFromIso(value: string): Instant { + return Temporal.Instant.from(value) +} + +export function instantFromDatabaseValue(value: Date | string | null): Instant | null { + if (value instanceof Date) { + return instantFromDate(value) + } + + if (typeof value === 'string') { + return instantFromIso(value) + } + + return null +} diff --git a/packages/ports/src/anonymous-feedback.ts b/packages/ports/src/anonymous-feedback.ts index 0805a2d..ddcae68 100644 --- a/packages/ports/src/anonymous-feedback.ts +++ b/packages/ports/src/anonymous-feedback.ts @@ -1,3 +1,5 @@ +import type { Instant } from '@household/domain' + export type AnonymousFeedbackModerationStatus = 'accepted' | 'posted' | 'rejected' | 'failed' export type AnonymousFeedbackRejectionReason = @@ -16,7 +18,8 @@ export interface AnonymousFeedbackMemberRecord { export interface AnonymousFeedbackRateLimitSnapshot { acceptedCountSince: number - lastAcceptedAt: Date | null + earliestAcceptedAtSince: Instant | null + lastAcceptedAt: Instant | null } export interface AnonymousFeedbackSubmissionRecord { @@ -28,7 +31,7 @@ export interface AnonymousFeedbackRepository { getMemberByTelegramUserId(telegramUserId: string): Promise getRateLimitSnapshot( memberId: string, - acceptedSince: Date + acceptedSince: Instant ): Promise createSubmission(input: { submittedByMemberId: string @@ -45,7 +48,7 @@ export interface AnonymousFeedbackRepository { postedChatId: string postedThreadId: string postedMessageId: string - postedAt: Date + postedAt: Instant }): Promise markFailed(submissionId: string, failureReason: string): Promise } diff --git a/packages/ports/src/finance.ts b/packages/ports/src/finance.ts index 2abb898..cae6d09 100644 --- a/packages/ports/src/finance.ts +++ b/packages/ports/src/finance.ts @@ -1,4 +1,4 @@ -import type { CurrencyCode } from '@household/domain' +import type { CurrencyCode, Instant } from '@household/domain' export interface FinanceMemberRecord { id: string @@ -23,7 +23,7 @@ export interface FinanceParsedPurchaseRecord { payerMemberId: string amountMinor: bigint description: string | null - occurredAt: Date | null + occurredAt: Instant | null } export interface FinanceUtilityBillRecord { @@ -32,7 +32,7 @@ export interface FinanceUtilityBillRecord { amountMinor: bigint currency: CurrencyCode createdByMemberId: string | null - createdAt: Date + createdAt: Instant } export interface SettlementSnapshotLineRecord { @@ -60,7 +60,7 @@ export interface FinanceRepository { getCycleByPeriod(period: string): Promise getLatestCycle(): Promise openCycle(period: string, currency: CurrencyCode): Promise - closeCycle(cycleId: string, closedAt: Date): Promise + closeCycle(cycleId: string, closedAt: Instant): Promise saveRentRule(period: string, amountMinor: bigint, currency: CurrencyCode): Promise addUtilityBill(input: { cycleId: string @@ -73,8 +73,8 @@ export interface FinanceRepository { getUtilityTotalForCycle(cycleId: string): Promise listUtilityBillsForCycle(cycleId: string): Promise listParsedPurchasesForRange( - start: Date, - end: Date + start: Instant, + end: Instant ): Promise replaceSettlementSnapshot(snapshot: SettlementSnapshotRecord): Promise } diff --git a/packages/ports/src/telegram-pending-actions.ts b/packages/ports/src/telegram-pending-actions.ts index 7397316..011c05d 100644 --- a/packages/ports/src/telegram-pending-actions.ts +++ b/packages/ports/src/telegram-pending-actions.ts @@ -1,3 +1,5 @@ +import type { Instant } from '@household/domain' + export const TELEGRAM_PENDING_ACTION_TYPES = ['anonymous_feedback'] as const export type TelegramPendingActionType = (typeof TELEGRAM_PENDING_ACTION_TYPES)[number] @@ -7,7 +9,7 @@ export interface TelegramPendingActionRecord { telegramChatId: string action: TelegramPendingActionType payload: Record - expiresAt: Date | null + expiresAt: Instant | null } export interface TelegramPendingActionRepository {