mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 12:04:02 +00:00
refactor(time): migrate runtime time handling to Temporal
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, mock, test } from 'bun:test'
|
import { describe, expect, mock, test } from 'bun:test'
|
||||||
|
|
||||||
import type { AnonymousFeedbackService } from '@household/application'
|
import type { AnonymousFeedbackService } from '@household/application'
|
||||||
|
import { instantFromIso, nowInstant, Temporal, type Instant } from '@household/domain'
|
||||||
import type {
|
import type {
|
||||||
HouseholdConfigurationRepository,
|
HouseholdConfigurationRepository,
|
||||||
TelegramPendingActionRepository
|
TelegramPendingActionRepository
|
||||||
@@ -43,7 +44,7 @@ function anonUpdate(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createPromptRepository(): TelegramPendingActionRepository {
|
function createPromptRepository(): TelegramPendingActionRepository {
|
||||||
const store = new Map<string, { action: 'anonymous_feedback'; expiresAt: Date | null }>()
|
const store = new Map<string, { action: 'anonymous_feedback'; expiresAt: Instant | null }>()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
async upsertPendingAction(input) {
|
async upsertPendingAction(input) {
|
||||||
@@ -60,7 +61,7 @@ function createPromptRepository(): TelegramPendingActionRepository {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (record.expiresAt && record.expiresAt.getTime() <= Date.now()) {
|
if (record.expiresAt && Temporal.Instant.compare(record.expiresAt, nowInstant()) <= 0) {
|
||||||
store.delete(key)
|
store.delete(key)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -477,4 +478,68 @@ describe('registerAnonymousFeedback', () => {
|
|||||||
expect(calls[1]?.method).toBe('answerCallbackQuery')
|
expect(calls[1]?.method).toBe('answerCallbackQuery')
|
||||||
expect(calls[2]?.method).toBe('editMessageText')
|
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.'
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { AnonymousFeedbackService } from '@household/application'
|
import type { AnonymousFeedbackService } from '@household/application'
|
||||||
|
import { Temporal, nowInstant, type Instant } from '@household/domain'
|
||||||
import type { Logger } from '@household/observability'
|
import type { Logger } from '@household/observability'
|
||||||
import type {
|
import type {
|
||||||
HouseholdConfigurationRepository,
|
HouseholdConfigurationRepository,
|
||||||
@@ -43,7 +44,30 @@ function shouldKeepPrompt(reason: string): boolean {
|
|||||||
return reason === 'too_short' || reason === 'too_long' || reason === 'blocklisted'
|
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) {
|
switch (reason) {
|
||||||
case 'not_member':
|
case 'not_member':
|
||||||
return 'You are not a member of this household.'
|
return 'You are not a member of this household.'
|
||||||
@@ -52,9 +76,13 @@ function rejectionMessage(reason: string): string {
|
|||||||
case 'too_long':
|
case 'too_long':
|
||||||
return 'Anonymous feedback is too long. Keep it under 500 characters.'
|
return 'Anonymous feedback is too long. Keep it under 500 characters.'
|
||||||
case 'cooldown':
|
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':
|
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':
|
case 'blocklisted':
|
||||||
return 'Message rejected by moderation. Rewrite it in calmer, non-abusive language.'
|
return 'Message rejected by moderation. Rewrite it in calmer, non-abusive language.'
|
||||||
default:
|
default:
|
||||||
@@ -91,7 +119,7 @@ async function startPendingAnonymousFeedbackPrompt(
|
|||||||
telegramChatId,
|
telegramChatId,
|
||||||
action: ANONYMOUS_FEEDBACK_ACTION,
|
action: ANONYMOUS_FEEDBACK_ACTION,
|
||||||
payload: {},
|
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.', {
|
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)
|
await options.promptRepository.clearPendingAction(telegramChatId, telegramUserId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rejectionText = rejectionMessage(result.reason, result.nextAllowedAt, nowInstant())
|
||||||
|
|
||||||
await options.ctx.reply(
|
await options.ctx.reply(
|
||||||
shouldKeepPrompt(result.reason)
|
shouldKeepPrompt(result.reason)
|
||||||
? `${rejectionMessage(result.reason)} Send a revised message, or tap Cancel.`
|
? `${rejectionText} Send a revised message, or tap Cancel.`
|
||||||
: rejectionMessage(result.reason),
|
: rejectionText,
|
||||||
shouldKeepPrompt(result.reason)
|
shouldKeepPrompt(result.reason)
|
||||||
? {
|
? {
|
||||||
reply_markup: cancelReplyMarkup()
|
reply_markup: cancelReplyMarkup()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
createFinanceCommandService,
|
createFinanceCommandService,
|
||||||
createHouseholdOnboardingService
|
createHouseholdOnboardingService
|
||||||
} from '@household/application'
|
} from '@household/application'
|
||||||
|
import { instantFromIso } from '@household/domain'
|
||||||
import type {
|
import type {
|
||||||
FinanceRepository,
|
FinanceRepository,
|
||||||
HouseholdConfigurationRepository,
|
HouseholdConfigurationRepository,
|
||||||
@@ -53,7 +54,7 @@ function repository(
|
|||||||
amountMinor: 12000n,
|
amountMinor: 12000n,
|
||||||
currency: 'USD',
|
currency: 'USD',
|
||||||
createdByMemberId: member?.id ?? 'member-1',
|
createdByMemberId: member?.id ?? 'member-1',
|
||||||
createdAt: new Date('2026-03-12T12:00:00.000Z')
|
createdAt: instantFromIso('2026-03-12T12:00:00.000Z')
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
listParsedPurchasesForRange: async () => [
|
listParsedPurchasesForRange: async () => [
|
||||||
@@ -62,7 +63,7 @@ function repository(
|
|||||||
payerMemberId: member?.id ?? 'member-1',
|
payerMemberId: member?.id ?? 'member-1',
|
||||||
amountMinor: 3000n,
|
amountMinor: 3000n,
|
||||||
description: 'Soap',
|
description: 'Soap',
|
||||||
occurredAt: new Date('2026-03-12T11:00:00.000Z')
|
occurredAt: instantFromIso('2026-03-12T11:00:00.000Z')
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
replaceSettlementSnapshot: async () => {}
|
replaceSettlementSnapshot: async () => {}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, test } from 'bun:test'
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
|
||||||
|
import { instantFromIso } from '@household/domain'
|
||||||
import { createTelegramBot } from './bot'
|
import { createTelegramBot } from './bot'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -25,7 +26,7 @@ function candidate(overrides: Partial<PurchaseTopicCandidate> = {}): PurchaseTop
|
|||||||
threadId: '777',
|
threadId: '777',
|
||||||
senderTelegramUserId: '10002',
|
senderTelegramUserId: '10002',
|
||||||
rawText: 'Bought toilet paper 30 gel',
|
rawText: 'Bought toilet paper 30 gel',
|
||||||
messageSentAt: new Date('2026-03-05T00:00:00.000Z'),
|
messageSentAt: instantFromIso('2026-03-05T00:00:00.000Z'),
|
||||||
...overrides
|
...overrides
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { parsePurchaseMessage, type PurchaseParserLlmFallback } from '@household/application'
|
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 { and, eq } from 'drizzle-orm'
|
||||||
import type { Bot, Context } from 'grammy'
|
import type { Bot, Context } from 'grammy'
|
||||||
import type { Logger } from '@household/observability'
|
import type { Logger } from '@household/observability'
|
||||||
@@ -24,7 +24,7 @@ export interface PurchaseTopicCandidate {
|
|||||||
senderTelegramUserId: string
|
senderTelegramUserId: string
|
||||||
senderDisplayName?: string
|
senderDisplayName?: string
|
||||||
rawText: string
|
rawText: string
|
||||||
messageSentAt: Date
|
messageSentAt: Instant
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PurchaseTopicRecord extends PurchaseTopicCandidate {
|
export interface PurchaseTopicRecord extends PurchaseTopicCandidate {
|
||||||
@@ -170,7 +170,7 @@ export function createPurchaseMessageRepository(databaseUrl: string): {
|
|||||||
telegramMessageId: record.messageId,
|
telegramMessageId: record.messageId,
|
||||||
telegramThreadId: record.threadId,
|
telegramThreadId: record.threadId,
|
||||||
telegramUpdateId: String(record.updateId),
|
telegramUpdateId: String(record.updateId),
|
||||||
messageSentAt: record.messageSentAt,
|
messageSentAt: instantToDate(record.messageSentAt),
|
||||||
parsedAmountMinor: parsed?.amountMinor,
|
parsedAmountMinor: parsed?.amountMinor,
|
||||||
parsedCurrency: parsed?.currency,
|
parsedCurrency: parsed?.currency,
|
||||||
parsedItemDescription: parsed?.itemDescription,
|
parsedItemDescription: parsed?.itemDescription,
|
||||||
@@ -286,7 +286,7 @@ function toCandidateFromContext(ctx: Context): PurchaseTopicCandidate | null {
|
|||||||
threadId: message.message_thread_id.toString(),
|
threadId: message.message_thread_id.toString(),
|
||||||
senderTelegramUserId,
|
senderTelegramUserId,
|
||||||
rawText: message.text,
|
rawText: message.text,
|
||||||
messageSentAt: new Date(message.date * 1000)
|
messageSentAt: instantFromEpochSeconds(message.date)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (senderDisplayName.length > 0) {
|
if (senderDisplayName.length > 0) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ReminderJobService } from '@household/application'
|
import type { ReminderJobService } from '@household/application'
|
||||||
import { BillingPeriod } from '@household/domain'
|
import { BillingPeriod, nowInstant } from '@household/domain'
|
||||||
import type { Logger } from '@household/observability'
|
import type { Logger } from '@household/observability'
|
||||||
import { REMINDER_TYPES, type ReminderType } from '@household/ports'
|
import { REMINDER_TYPES, type ReminderType } from '@household/ports'
|
||||||
|
|
||||||
@@ -27,11 +27,7 @@ function parseReminderType(raw: string): ReminderType | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function currentPeriod(): string {
|
function currentPeriod(): string {
|
||||||
const now = new Date()
|
return BillingPeriod.fromInstant(nowInstant()).toString()
|
||||||
const year = now.getUTCFullYear()
|
|
||||||
const month = `${now.getUTCMonth() + 1}`.padStart(2, '0')
|
|
||||||
|
|
||||||
return `${year}-${month}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readBody(request: Request): Promise<ReminderJobRequestBody> {
|
async function readBody(request: Request): Promise<ReminderJobRequestBody> {
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { describe, expect, test } from 'bun:test'
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
|
||||||
|
import { instantFromIso, instantToEpochSeconds } from '@household/domain'
|
||||||
|
|
||||||
import { verifyTelegramMiniAppInitData } from './telegram-miniapp-auth'
|
import { verifyTelegramMiniAppInitData } from './telegram-miniapp-auth'
|
||||||
import { buildMiniAppInitData } from './telegram-miniapp-test-helpers'
|
import { buildMiniAppInitData } from './telegram-miniapp-test-helpers'
|
||||||
|
|
||||||
describe('verifyTelegramMiniAppInitData', () => {
|
describe('verifyTelegramMiniAppInitData', () => {
|
||||||
test('verifies valid init data and extracts user payload', () => {
|
test('verifies valid init data and extracts user payload', () => {
|
||||||
const now = new Date('2026-03-08T12:00:00.000Z')
|
const now = instantFromIso('2026-03-08T12:00:00.000Z')
|
||||||
const initData = buildMiniAppInitData('test-bot-token', Math.floor(now.getTime() / 1000), {
|
const initData = buildMiniAppInitData('test-bot-token', instantToEpochSeconds(now), {
|
||||||
id: 123456,
|
id: 123456,
|
||||||
first_name: 'Stan',
|
first_name: 'Stan',
|
||||||
username: 'stanislav'
|
username: 'stanislav'
|
||||||
@@ -24,9 +26,9 @@ describe('verifyTelegramMiniAppInitData', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('rejects invalid hash', () => {
|
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(
|
const params = new URLSearchParams(
|
||||||
buildMiniAppInitData('test-bot-token', Math.floor(now.getTime() / 1000), {
|
buildMiniAppInitData('test-bot-token', instantToEpochSeconds(now), {
|
||||||
id: 123456,
|
id: 123456,
|
||||||
first_name: 'Stan'
|
first_name: 'Stan'
|
||||||
})
|
})
|
||||||
@@ -39,15 +41,11 @@ describe('verifyTelegramMiniAppInitData', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('rejects expired init data', () => {
|
test('rejects expired init data', () => {
|
||||||
const now = new Date('2026-03-08T12:00:00.000Z')
|
const now = instantFromIso('2026-03-08T12:00:00.000Z')
|
||||||
const initData = buildMiniAppInitData(
|
const initData = buildMiniAppInitData('test-bot-token', instantToEpochSeconds(now) - 7200, {
|
||||||
'test-bot-token',
|
id: 123456,
|
||||||
Math.floor(now.getTime() / 1000) - 7200,
|
first_name: 'Stan'
|
||||||
{
|
})
|
||||||
id: 123456,
|
|
||||||
first_name: 'Stan'
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
const result = verifyTelegramMiniAppInitData(initData, 'test-bot-token', now, 3600)
|
const result = verifyTelegramMiniAppInitData(initData, 'test-bot-token', now, 3600)
|
||||||
|
|
||||||
@@ -55,8 +53,8 @@ describe('verifyTelegramMiniAppInitData', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('rejects init data timestamps from the future', () => {
|
test('rejects init data timestamps from the future', () => {
|
||||||
const now = new Date('2026-03-08T12:00:00.000Z')
|
const now = instantFromIso('2026-03-08T12:00:00.000Z')
|
||||||
const initData = buildMiniAppInitData('test-bot-token', Math.floor(now.getTime() / 1000) + 5, {
|
const initData = buildMiniAppInitData('test-bot-token', instantToEpochSeconds(now) + 5, {
|
||||||
id: 123456,
|
id: 123456,
|
||||||
first_name: 'Stan'
|
first_name: 'Stan'
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { createHmac, timingSafeEqual } from 'node:crypto'
|
import { createHmac, timingSafeEqual } from 'node:crypto'
|
||||||
|
|
||||||
|
import { instantToEpochSeconds, nowInstant, type Instant } from '@household/domain'
|
||||||
|
|
||||||
interface TelegramUserPayload {
|
interface TelegramUserPayload {
|
||||||
id: number
|
id: number
|
||||||
first_name?: string
|
first_name?: string
|
||||||
@@ -19,7 +21,7 @@ export interface VerifiedMiniAppUser {
|
|||||||
export function verifyTelegramMiniAppInitData(
|
export function verifyTelegramMiniAppInitData(
|
||||||
initData: string,
|
initData: string,
|
||||||
botToken: string,
|
botToken: string,
|
||||||
now = new Date(),
|
now: Instant = nowInstant(),
|
||||||
maxAgeSeconds = 3600
|
maxAgeSeconds = 3600
|
||||||
): VerifiedMiniAppUser | null {
|
): VerifiedMiniAppUser | null {
|
||||||
const params = new URLSearchParams(initData)
|
const params = new URLSearchParams(initData)
|
||||||
@@ -35,7 +37,7 @@ export function verifyTelegramMiniAppInitData(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const authDateSeconds = Number(authDateRaw)
|
const authDateSeconds = Number(authDateRaw)
|
||||||
const nowSeconds = Math.floor(now.getTime() / 1000)
|
const nowSeconds = instantToEpochSeconds(now)
|
||||||
if (authDateSeconds > nowSeconds) {
|
if (authDateSeconds > nowSeconds) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
7
bun.lock
7
bun.lock
@@ -76,6 +76,9 @@
|
|||||||
},
|
},
|
||||||
"packages/domain": {
|
"packages/domain": {
|
||||||
"name": "@household/domain",
|
"name": "@household/domain",
|
||||||
|
"dependencies": {
|
||||||
|
"@js-temporal/polyfill": "^0.5.1",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"packages/observability": {
|
"packages/observability": {
|
||||||
"name": "@household/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=="],
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
"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=="],
|
"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=="],
|
"json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="],
|
||||||
|
|||||||
45
docs/specs/HOUSEBOT-074-temporal-migration.md
Normal file
45
docs/specs/HOUSEBOT-074-temporal-migration.md
Normal file
@@ -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`
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { and, eq, inArray, sql } from 'drizzle-orm'
|
import { and, eq, inArray, sql } from 'drizzle-orm'
|
||||||
|
|
||||||
import { createDbClient, schema } from '@household/db'
|
import { createDbClient, schema } from '@household/db'
|
||||||
|
import { instantFromDatabaseValue, instantToDate } from '@household/domain'
|
||||||
import type {
|
import type {
|
||||||
AnonymousFeedbackModerationStatus,
|
AnonymousFeedbackModerationStatus,
|
||||||
AnonymousFeedbackRepository
|
AnonymousFeedbackRepository
|
||||||
@@ -49,11 +50,14 @@ export function createDbAnonymousFeedbackRepository(
|
|||||||
},
|
},
|
||||||
|
|
||||||
async getRateLimitSnapshot(memberId, acceptedSince) {
|
async getRateLimitSnapshot(memberId, acceptedSince) {
|
||||||
const acceptedSinceIso = acceptedSince.toISOString()
|
const acceptedSinceIso = acceptedSince.toString()
|
||||||
|
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({
|
.select({
|
||||||
acceptedCountSince: sql<string>`count(*) filter (where ${schema.anonymousMessages.createdAt} >= ${acceptedSinceIso}::timestamptz)`,
|
acceptedCountSince: sql<string>`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<string | Date | null>`max(${schema.anonymousMessages.createdAt})`
|
lastAcceptedAt: sql<string | Date | null>`max(${schema.anonymousMessages.createdAt})`
|
||||||
})
|
})
|
||||||
.from(schema.anonymousMessages)
|
.from(schema.anonymousMessages)
|
||||||
@@ -65,16 +69,13 @@ export function createDbAnonymousFeedbackRepository(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const earliestAcceptedAtSinceRaw = rows[0]?.earliestAcceptedAtSince ?? null
|
||||||
const lastAcceptedAtRaw = rows[0]?.lastAcceptedAt ?? null
|
const lastAcceptedAtRaw = rows[0]?.lastAcceptedAt ?? null
|
||||||
|
|
||||||
return {
|
return {
|
||||||
acceptedCountSince: Number(rows[0]?.acceptedCountSince ?? '0'),
|
acceptedCountSince: Number(rows[0]?.acceptedCountSince ?? '0'),
|
||||||
lastAcceptedAt:
|
earliestAcceptedAtSince: instantFromDatabaseValue(earliestAcceptedAtSinceRaw),
|
||||||
lastAcceptedAtRaw instanceof Date
|
lastAcceptedAt: instantFromDatabaseValue(lastAcceptedAtRaw)
|
||||||
? lastAcceptedAtRaw
|
|
||||||
: typeof lastAcceptedAtRaw === 'string'
|
|
||||||
? new Date(lastAcceptedAtRaw)
|
|
||||||
: null
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -146,7 +147,7 @@ export function createDbAnonymousFeedbackRepository(
|
|||||||
postedChatId: input.postedChatId,
|
postedChatId: input.postedChatId,
|
||||||
postedThreadId: input.postedThreadId,
|
postedThreadId: input.postedThreadId,
|
||||||
postedMessageId: input.postedMessageId,
|
postedMessageId: input.postedMessageId,
|
||||||
postedAt: input.postedAt,
|
postedAt: instantToDate(input.postedAt),
|
||||||
failureReason: null
|
failureReason: null
|
||||||
})
|
})
|
||||||
.where(eq(schema.anonymousMessages.id, input.submissionId))
|
.where(eq(schema.anonymousMessages.id, input.submissionId))
|
||||||
|
|||||||
@@ -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 { createDbClient, schema } from '@household/db'
|
||||||
import type { FinanceRepository } from '@household/ports'
|
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 {
|
function toCurrencyCode(raw: string): CurrencyCode {
|
||||||
const normalized = raw.trim().toUpperCase()
|
const normalized = raw.trim().toUpperCase()
|
||||||
@@ -171,7 +176,7 @@ export function createDbFinanceRepository(
|
|||||||
await db
|
await db
|
||||||
.update(schema.billingCycles)
|
.update(schema.billingCycles)
|
||||||
.set({
|
.set({
|
||||||
closedAt
|
closedAt: instantToDate(closedAt)
|
||||||
})
|
})
|
||||||
.where(eq(schema.billingCycles.id, cycleId))
|
.where(eq(schema.billingCycles.id, cycleId))
|
||||||
},
|
},
|
||||||
@@ -265,7 +270,8 @@ export function createDbFinanceRepository(
|
|||||||
|
|
||||||
return rows.map((row) => ({
|
return rows.map((row) => ({
|
||||||
...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),
|
eq(schema.purchaseMessages.householdId, householdId),
|
||||||
isNotNull(schema.purchaseMessages.senderMemberId),
|
isNotNull(schema.purchaseMessages.senderMemberId),
|
||||||
isNotNull(schema.purchaseMessages.parsedAmountMinor),
|
isNotNull(schema.purchaseMessages.parsedAmountMinor),
|
||||||
gte(schema.purchaseMessages.messageSentAt, start),
|
gte(schema.purchaseMessages.messageSentAt, instantToDate(start)),
|
||||||
lte(schema.purchaseMessages.messageSentAt, end)
|
lt(schema.purchaseMessages.messageSentAt, instantToDate(end))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -294,7 +300,7 @@ export function createDbFinanceRepository(
|
|||||||
payerMemberId: row.payerMemberId!,
|
payerMemberId: row.payerMemberId!,
|
||||||
amountMinor: row.amountMinor!,
|
amountMinor: row.amountMinor!,
|
||||||
description: row.description,
|
description: row.description,
|
||||||
occurredAt: row.occurredAt
|
occurredAt: instantFromDatabaseValue(row.occurredAt)
|
||||||
}))
|
}))
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -316,7 +322,7 @@ export function createDbFinanceRepository(
|
|||||||
inputHash: snapshot.inputHash,
|
inputHash: snapshot.inputHash,
|
||||||
totalDueMinor: snapshot.totalDueMinor,
|
totalDueMinor: snapshot.totalDueMinor,
|
||||||
currency: snapshot.currency,
|
currency: snapshot.currency,
|
||||||
computedAt: new Date(),
|
computedAt: instantToDate(nowInstant()),
|
||||||
metadata: snapshot.metadata
|
metadata: snapshot.metadata
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { and, eq } from 'drizzle-orm'
|
import { and, eq } from 'drizzle-orm'
|
||||||
|
|
||||||
import { createDbClient, schema } from '@household/db'
|
import { createDbClient, schema } from '@household/db'
|
||||||
|
import { instantToDate, nowInstant } from '@household/domain'
|
||||||
import {
|
import {
|
||||||
HOUSEHOLD_TOPIC_ROLES,
|
HOUSEHOLD_TOPIC_ROLES,
|
||||||
type HouseholdConfigurationRepository,
|
type HouseholdConfigurationRepository,
|
||||||
@@ -138,7 +139,7 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
|
|||||||
.set({
|
.set({
|
||||||
telegramChatType: input.telegramChatType,
|
telegramChatType: input.telegramChatType,
|
||||||
title: nextTitle,
|
title: nextTitle,
|
||||||
updatedAt: new Date()
|
updatedAt: instantToDate(nowInstant())
|
||||||
})
|
})
|
||||||
.where(eq(schema.householdTelegramChats.telegramChatId, input.telegramChatId))
|
.where(eq(schema.householdTelegramChats.telegramChatId, input.telegramChatId))
|
||||||
|
|
||||||
@@ -256,7 +257,7 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
|
|||||||
set: {
|
set: {
|
||||||
telegramThreadId: input.telegramThreadId,
|
telegramThreadId: input.telegramThreadId,
|
||||||
topicName: input.topicName?.trim() || null,
|
topicName: input.topicName?.trim() || null,
|
||||||
updatedAt: new Date()
|
updatedAt: instantToDate(nowInstant())
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.returning({
|
.returning({
|
||||||
@@ -348,7 +349,7 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
|
|||||||
set: {
|
set: {
|
||||||
token: input.token,
|
token: input.token,
|
||||||
createdByTelegramUserId: input.createdByTelegramUserId ?? null,
|
createdByTelegramUserId: input.createdByTelegramUserId ?? null,
|
||||||
updatedAt: new Date()
|
updatedAt: instantToDate(nowInstant())
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.returning({
|
.returning({
|
||||||
@@ -448,7 +449,7 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
|
|||||||
displayName: input.displayName,
|
displayName: input.displayName,
|
||||||
username: input.username?.trim() || null,
|
username: input.username?.trim() || null,
|
||||||
languageCode: input.languageCode?.trim() || null,
|
languageCode: input.languageCode?.trim() || null,
|
||||||
updatedAt: new Date()
|
updatedAt: instantToDate(nowInstant())
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.returning({
|
.returning({
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { and, eq } from 'drizzle-orm'
|
import { and, eq } from 'drizzle-orm'
|
||||||
|
|
||||||
import { createDbClient, schema } from '@household/db'
|
import { createDbClient, schema } from '@household/db'
|
||||||
|
import { instantFromDatabaseValue, instantToDate, nowInstant, Temporal } from '@household/domain'
|
||||||
import type {
|
import type {
|
||||||
TelegramPendingActionRecord,
|
TelegramPendingActionRecord,
|
||||||
TelegramPendingActionRepository,
|
TelegramPendingActionRepository,
|
||||||
@@ -20,7 +21,7 @@ function mapPendingAction(row: {
|
|||||||
telegramChatId: string
|
telegramChatId: string
|
||||||
action: string
|
action: string
|
||||||
payload: unknown
|
payload: unknown
|
||||||
expiresAt: Date | null
|
expiresAt: Date | string | null
|
||||||
}): TelegramPendingActionRecord {
|
}): TelegramPendingActionRecord {
|
||||||
return {
|
return {
|
||||||
telegramUserId: row.telegramUserId,
|
telegramUserId: row.telegramUserId,
|
||||||
@@ -30,7 +31,7 @@ function mapPendingAction(row: {
|
|||||||
row.payload && typeof row.payload === 'object' && !Array.isArray(row.payload)
|
row.payload && typeof row.payload === 'object' && !Array.isArray(row.payload)
|
||||||
? (row.payload as Record<string, unknown>)
|
? (row.payload as Record<string, unknown>)
|
||||||
: {},
|
: {},
|
||||||
expiresAt: row.expiresAt
|
expiresAt: instantFromDatabaseValue(row.expiresAt)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,8 +53,8 @@ export function createDbTelegramPendingActionRepository(databaseUrl: string): {
|
|||||||
telegramChatId: input.telegramChatId,
|
telegramChatId: input.telegramChatId,
|
||||||
action: input.action,
|
action: input.action,
|
||||||
payload: input.payload,
|
payload: input.payload,
|
||||||
expiresAt: input.expiresAt,
|
expiresAt: input.expiresAt ? instantToDate(input.expiresAt) : null,
|
||||||
updatedAt: new Date()
|
updatedAt: instantToDate(nowInstant())
|
||||||
})
|
})
|
||||||
.onConflictDoUpdate({
|
.onConflictDoUpdate({
|
||||||
target: [
|
target: [
|
||||||
@@ -63,8 +64,8 @@ export function createDbTelegramPendingActionRepository(databaseUrl: string): {
|
|||||||
set: {
|
set: {
|
||||||
action: input.action,
|
action: input.action,
|
||||||
payload: input.payload,
|
payload: input.payload,
|
||||||
expiresAt: input.expiresAt,
|
expiresAt: input.expiresAt ? instantToDate(input.expiresAt) : null,
|
||||||
updatedAt: new Date()
|
updatedAt: instantToDate(nowInstant())
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.returning({
|
.returning({
|
||||||
@@ -84,7 +85,7 @@ export function createDbTelegramPendingActionRepository(databaseUrl: string): {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async getPendingAction(telegramChatId, telegramUserId) {
|
async getPendingAction(telegramChatId, telegramUserId) {
|
||||||
const now = new Date()
|
const now = nowInstant()
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({
|
.select({
|
||||||
telegramUserId: schema.telegramPendingActions.telegramUserId,
|
telegramUserId: schema.telegramPendingActions.telegramUserId,
|
||||||
@@ -107,7 +108,8 @@ export function createDbTelegramPendingActionRepository(databaseUrl: string): {
|
|||||||
return null
|
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
|
await db
|
||||||
.delete(schema.telegramPendingActions)
|
.delete(schema.telegramPendingActions)
|
||||||
.where(
|
.where(
|
||||||
@@ -120,7 +122,16 @@ export function createDbTelegramPendingActionRepository(databaseUrl: string): {
|
|||||||
return null
|
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<string, unknown>)
|
||||||
|
: {},
|
||||||
|
expiresAt
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async clearPendingAction(telegramChatId, telegramUserId) {
|
async clearPendingAction(telegramChatId, telegramUserId) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, test } from 'bun:test'
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
|
||||||
|
import { instantFromIso, type Instant } from '@household/domain'
|
||||||
import type {
|
import type {
|
||||||
AnonymousFeedbackMemberRecord,
|
AnonymousFeedbackMemberRecord,
|
||||||
AnonymousFeedbackRepository,
|
AnonymousFeedbackRepository,
|
||||||
@@ -16,7 +17,8 @@ class AnonymousFeedbackRepositoryStub implements AnonymousFeedbackRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
acceptedCountSince = 0
|
acceptedCountSince = 0
|
||||||
lastAcceptedAt: Date | null = null
|
earliestAcceptedAtSince: Instant | null = null
|
||||||
|
lastAcceptedAt: Instant | null = null
|
||||||
duplicate = false
|
duplicate = false
|
||||||
created: Array<{
|
created: Array<{
|
||||||
rawText: string
|
rawText: string
|
||||||
@@ -34,6 +36,7 @@ class AnonymousFeedbackRepositoryStub implements AnonymousFeedbackRepository {
|
|||||||
async getRateLimitSnapshot() {
|
async getRateLimitSnapshot() {
|
||||||
return {
|
return {
|
||||||
acceptedCountSince: this.acceptedCountSince,
|
acceptedCountSince: this.acceptedCountSince,
|
||||||
|
earliestAcceptedAtSince: this.earliestAcceptedAtSince,
|
||||||
lastAcceptedAt: this.lastAcceptedAt
|
lastAcceptedAt: this.lastAcceptedAt
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -69,7 +72,7 @@ class AnonymousFeedbackRepositoryStub implements AnonymousFeedbackRepository {
|
|||||||
postedChatId: string
|
postedChatId: string
|
||||||
postedThreadId: string
|
postedThreadId: string
|
||||||
postedMessageId: string
|
postedMessageId: string
|
||||||
postedAt: Date
|
postedAt: Instant
|
||||||
}) {
|
}) {
|
||||||
this.posted.push({
|
this.posted.push({
|
||||||
submissionId: input.submissionId,
|
submissionId: input.submissionId,
|
||||||
@@ -94,7 +97,7 @@ describe('createAnonymousFeedbackService', () => {
|
|||||||
telegramChatId: 'chat-1',
|
telegramChatId: 'chat-1',
|
||||||
telegramMessageId: 'message-1',
|
telegramMessageId: 'message-1',
|
||||||
telegramUpdateId: 'update-1',
|
telegramUpdateId: 'update-1',
|
||||||
now: new Date('2026-03-08T12:00:00.000Z')
|
now: instantFromIso('2026-03-08T12:00:00.000Z')
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
@@ -154,7 +157,7 @@ describe('createAnonymousFeedbackService', () => {
|
|||||||
const repository = new AnonymousFeedbackRepositoryStub()
|
const repository = new AnonymousFeedbackRepositoryStub()
|
||||||
const service = createAnonymousFeedbackService(repository)
|
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({
|
const cooldownResult = await service.submit({
|
||||||
telegramUserId: '123',
|
telegramUserId: '123',
|
||||||
@@ -162,15 +165,17 @@ describe('createAnonymousFeedbackService', () => {
|
|||||||
telegramChatId: 'chat-1',
|
telegramChatId: 'chat-1',
|
||||||
telegramMessageId: 'message-1',
|
telegramMessageId: 'message-1',
|
||||||
telegramUpdateId: 'update-1',
|
telegramUpdateId: 'update-1',
|
||||||
now: new Date('2026-03-08T12:00:00.000Z')
|
now: instantFromIso('2026-03-08T12:00:00.000Z')
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(cooldownResult).toEqual({
|
expect(cooldownResult).toEqual({
|
||||||
status: 'rejected',
|
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
|
repository.acceptedCountSince = 3
|
||||||
|
|
||||||
const dailyCapResult = await service.submit({
|
const dailyCapResult = await service.submit({
|
||||||
@@ -179,12 +184,13 @@ describe('createAnonymousFeedbackService', () => {
|
|||||||
telegramChatId: 'chat-1',
|
telegramChatId: 'chat-1',
|
||||||
telegramMessageId: 'message-2',
|
telegramMessageId: 'message-2',
|
||||||
telegramUpdateId: 'update-2',
|
telegramUpdateId: 'update-2',
|
||||||
now: new Date('2026-03-08T12:00:00.000Z')
|
now: instantFromIso('2026-03-08T12:00:00.000Z')
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(dailyCapResult).toEqual({
|
expect(dailyCapResult).toEqual({
|
||||||
status: 'rejected',
|
status: 'rejected',
|
||||||
reason: 'daily_cap'
|
reason: 'daily_cap',
|
||||||
|
nextAllowedAt: instantFromIso('2026-03-08T18:00:00.000Z')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type {
|
|||||||
AnonymousFeedbackRejectionReason,
|
AnonymousFeedbackRejectionReason,
|
||||||
AnonymousFeedbackRepository
|
AnonymousFeedbackRepository
|
||||||
} from '@household/ports'
|
} from '@household/ports'
|
||||||
|
import { nowInstant, type Instant, Temporal } from '@household/domain'
|
||||||
|
|
||||||
const MIN_MESSAGE_LENGTH = 12
|
const MIN_MESSAGE_LENGTH = 12
|
||||||
const MAX_MESSAGE_LENGTH = 500
|
const MAX_MESSAGE_LENGTH = 500
|
||||||
@@ -46,6 +47,7 @@ export type AnonymousFeedbackSubmitResult =
|
|||||||
status: 'rejected'
|
status: 'rejected'
|
||||||
reason: AnonymousFeedbackRejectionReason
|
reason: AnonymousFeedbackRejectionReason
|
||||||
detail?: string
|
detail?: string
|
||||||
|
nextAllowedAt?: Instant
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AnonymousFeedbackService {
|
export interface AnonymousFeedbackService {
|
||||||
@@ -55,14 +57,14 @@ export interface AnonymousFeedbackService {
|
|||||||
telegramChatId: string
|
telegramChatId: string
|
||||||
telegramMessageId: string
|
telegramMessageId: string
|
||||||
telegramUpdateId: string
|
telegramUpdateId: string
|
||||||
now?: Date
|
now?: Instant
|
||||||
}): Promise<AnonymousFeedbackSubmitResult>
|
}): Promise<AnonymousFeedbackSubmitResult>
|
||||||
markPosted(input: {
|
markPosted(input: {
|
||||||
submissionId: string
|
submissionId: string
|
||||||
postedChatId: string
|
postedChatId: string
|
||||||
postedThreadId: string
|
postedThreadId: string
|
||||||
postedMessageId: string
|
postedMessageId: string
|
||||||
postedAt?: Date
|
postedAt?: Instant
|
||||||
}): Promise<void>
|
}): Promise<void>
|
||||||
markFailed(submissionId: string, failureReason: string): Promise<void>
|
markFailed(submissionId: string, failureReason: string): Promise<void>
|
||||||
}
|
}
|
||||||
@@ -74,6 +76,7 @@ async function rejectSubmission(
|
|||||||
rawText: string
|
rawText: string
|
||||||
reason: AnonymousFeedbackRejectionReason
|
reason: AnonymousFeedbackRejectionReason
|
||||||
detail?: string
|
detail?: string
|
||||||
|
nextAllowedAt?: Instant
|
||||||
telegramChatId: string
|
telegramChatId: string
|
||||||
telegramMessageId: string
|
telegramMessageId: string
|
||||||
telegramUpdateId: string
|
telegramUpdateId: string
|
||||||
@@ -100,6 +103,7 @@ async function rejectSubmission(
|
|||||||
return {
|
return {
|
||||||
status: 'rejected',
|
status: 'rejected',
|
||||||
reason: input.reason,
|
reason: input.reason,
|
||||||
|
...(input.nextAllowedAt ? { nextAllowedAt: input.nextAllowedAt } : {}),
|
||||||
...(input.detail ? { detail: input.detail } : {})
|
...(input.detail ? { detail: input.detail } : {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -153,14 +157,18 @@ export function createAnonymousFeedbackService(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = input.now ?? new Date()
|
const now = input.now ?? nowInstant()
|
||||||
const acceptedSince = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
const acceptedSince = now.subtract({ hours: 24 })
|
||||||
const rateLimit = await repository.getRateLimitSnapshot(member.id, acceptedSince)
|
const rateLimit = await repository.getRateLimitSnapshot(member.id, acceptedSince)
|
||||||
if (rateLimit.acceptedCountSince >= DAILY_CAP) {
|
if (rateLimit.acceptedCountSince >= DAILY_CAP) {
|
||||||
|
const nextAllowedAt =
|
||||||
|
rateLimit.earliestAcceptedAtSince?.add({ hours: 24 }) ?? now.add({ hours: 24 })
|
||||||
|
|
||||||
return rejectSubmission(repository, {
|
return rejectSubmission(repository, {
|
||||||
memberId: member.id,
|
memberId: member.id,
|
||||||
rawText: input.rawText,
|
rawText: input.rawText,
|
||||||
reason: 'daily_cap',
|
reason: 'daily_cap',
|
||||||
|
nextAllowedAt,
|
||||||
telegramChatId: input.telegramChatId,
|
telegramChatId: input.telegramChatId,
|
||||||
telegramMessageId: input.telegramMessageId,
|
telegramMessageId: input.telegramMessageId,
|
||||||
telegramUpdateId: input.telegramUpdateId
|
telegramUpdateId: input.telegramUpdateId
|
||||||
@@ -168,12 +176,13 @@ export function createAnonymousFeedbackService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (rateLimit.lastAcceptedAt) {
|
if (rateLimit.lastAcceptedAt) {
|
||||||
const cooldownBoundary = now.getTime() - COOLDOWN_HOURS * 60 * 60 * 1000
|
const cooldownBoundary = now.subtract({ hours: COOLDOWN_HOURS })
|
||||||
if (rateLimit.lastAcceptedAt.getTime() > cooldownBoundary) {
|
if (Temporal.Instant.compare(rateLimit.lastAcceptedAt, cooldownBoundary) > 0) {
|
||||||
return rejectSubmission(repository, {
|
return rejectSubmission(repository, {
|
||||||
memberId: member.id,
|
memberId: member.id,
|
||||||
rawText: input.rawText,
|
rawText: input.rawText,
|
||||||
reason: 'cooldown',
|
reason: 'cooldown',
|
||||||
|
nextAllowedAt: rateLimit.lastAcceptedAt.add({ hours: COOLDOWN_HOURS }),
|
||||||
telegramChatId: input.telegramChatId,
|
telegramChatId: input.telegramChatId,
|
||||||
telegramMessageId: input.telegramMessageId,
|
telegramMessageId: input.telegramMessageId,
|
||||||
telegramUpdateId: input.telegramUpdateId
|
telegramUpdateId: input.telegramUpdateId
|
||||||
@@ -212,7 +221,7 @@ export function createAnonymousFeedbackService(
|
|||||||
postedChatId: input.postedChatId,
|
postedChatId: input.postedChatId,
|
||||||
postedThreadId: input.postedThreadId,
|
postedThreadId: input.postedThreadId,
|
||||||
postedMessageId: input.postedMessageId,
|
postedMessageId: input.postedMessageId,
|
||||||
postedAt: input.postedAt ?? new Date()
|
postedAt: input.postedAt ?? nowInstant()
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, test } from 'bun:test'
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
|
||||||
|
import { instantFromIso, type Instant } from '@household/domain'
|
||||||
import type {
|
import type {
|
||||||
FinanceCycleRecord,
|
FinanceCycleRecord,
|
||||||
FinanceMemberRecord,
|
FinanceMemberRecord,
|
||||||
@@ -26,7 +27,7 @@ class FinanceRepositoryStub implements FinanceRepository {
|
|||||||
amountMinor: bigint
|
amountMinor: bigint
|
||||||
currency: 'USD' | 'GEL'
|
currency: 'USD' | 'GEL'
|
||||||
createdByMemberId: string | null
|
createdByMemberId: string | null
|
||||||
createdAt: Date
|
createdAt: Instant
|
||||||
}[] = []
|
}[] = []
|
||||||
|
|
||||||
lastSavedRentRule: {
|
lastSavedRentRule: {
|
||||||
@@ -180,7 +181,7 @@ describe('createFinanceCommandService', () => {
|
|||||||
amountMinor: 12000n,
|
amountMinor: 12000n,
|
||||||
currency: 'USD',
|
currency: 'USD',
|
||||||
createdByMemberId: 'alice',
|
createdByMemberId: 'alice',
|
||||||
createdAt: new Date('2026-03-12T12:00:00.000Z')
|
createdAt: instantFromIso('2026-03-12T12:00:00.000Z')
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
repository.purchases = [
|
repository.purchases = [
|
||||||
@@ -189,7 +190,7 @@ describe('createFinanceCommandService', () => {
|
|||||||
payerMemberId: 'alice',
|
payerMemberId: 'alice',
|
||||||
amountMinor: 3000n,
|
amountMinor: 3000n,
|
||||||
description: 'Soap',
|
description: 'Soap',
|
||||||
occurredAt: new Date('2026-03-12T11:00:00.000Z')
|
occurredAt: instantFromIso('2026-03-12T11:00:00.000Z')
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
MemberId,
|
MemberId,
|
||||||
Money,
|
Money,
|
||||||
PurchaseEntryId,
|
PurchaseEntryId,
|
||||||
|
Temporal,
|
||||||
|
nowInstant,
|
||||||
type CurrencyCode
|
type CurrencyCode
|
||||||
} from '@household/domain'
|
} from '@household/domain'
|
||||||
|
|
||||||
@@ -25,10 +27,13 @@ function parseCurrency(raw: string | undefined, fallback: CurrencyCode): Currenc
|
|||||||
return normalized
|
return normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
function monthRange(period: BillingPeriod): { start: Date; end: Date } {
|
function monthRange(period: BillingPeriod): {
|
||||||
|
start: Temporal.Instant
|
||||||
|
end: Temporal.Instant
|
||||||
|
} {
|
||||||
return {
|
return {
|
||||||
start: new Date(Date.UTC(period.year, period.month - 1, 1, 0, 0, 0)),
|
start: Temporal.Instant.from(`${period.toString()}-01T00:00:00Z`),
|
||||||
end: new Date(Date.UTC(period.year, period.month, 0, 23, 59, 59))
|
end: Temporal.Instant.from(`${period.next().toString()}-01T00:00:00Z`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,7 +166,7 @@ async function buildFinanceDashboard(
|
|||||||
actorDisplayName: bill.createdByMemberId
|
actorDisplayName: bill.createdByMemberId
|
||||||
? (memberNameById.get(bill.createdByMemberId) ?? null)
|
? (memberNameById.get(bill.createdByMemberId) ?? null)
|
||||||
: null,
|
: null,
|
||||||
occurredAt: bill.createdAt.toISOString()
|
occurredAt: bill.createdAt.toString()
|
||||||
})),
|
})),
|
||||||
...purchases.map((purchase) => ({
|
...purchases.map((purchase) => ({
|
||||||
id: purchase.id,
|
id: purchase.id,
|
||||||
@@ -169,7 +174,7 @@ async function buildFinanceDashboard(
|
|||||||
title: purchase.description ?? 'Shared purchase',
|
title: purchase.description ?? 'Shared purchase',
|
||||||
amount: Money.fromMinor(purchase.amountMinor, rentRule.currency),
|
amount: Money.fromMinor(purchase.amountMinor, rentRule.currency),
|
||||||
actorDisplayName: memberNameById.get(purchase.payerMemberId) ?? null,
|
actorDisplayName: memberNameById.get(purchase.payerMemberId) ?? null,
|
||||||
occurredAt: purchase.occurredAt?.toISOString() ?? null
|
occurredAt: purchase.occurredAt?.toString() ?? null
|
||||||
}))
|
}))
|
||||||
].sort((left, right) => {
|
].sort((left, right) => {
|
||||||
if (left.occurredAt === right.occurredAt) {
|
if (left.occurredAt === right.occurredAt) {
|
||||||
@@ -246,7 +251,7 @@ export function createFinanceCommandService(repository: FinanceRepository): Fina
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
await repository.closeCycle(cycle.id, new Date())
|
await repository.closeCycle(cycle.id, nowInstant())
|
||||||
return cycle
|
return cycle
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -10,5 +10,8 @@
|
|||||||
"typecheck": "tsgo --project tsconfig.json --noEmit",
|
"typecheck": "tsgo --project tsconfig.json --noEmit",
|
||||||
"test": "bun test --pass-with-no-tests",
|
"test": "bun test --pass-with-no-tests",
|
||||||
"lint": "oxlint \"src\""
|
"lint": "oxlint \"src\""
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@js-temporal/polyfill": "^0.5.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { Instant } from './time'
|
||||||
|
|
||||||
import { DOMAIN_ERROR_CODE, DomainError } from './errors'
|
import { DOMAIN_ERROR_CODE, DomainError } from './errors'
|
||||||
|
|
||||||
const BILLING_PERIOD_PATTERN = /^(\d{4})-(\d{2})$/
|
const BILLING_PERIOD_PATTERN = /^(\d{4})-(\d{2})$/
|
||||||
@@ -48,8 +50,10 @@ export class BillingPeriod {
|
|||||||
return BillingPeriod.from(Number(yearString), Number(monthString))
|
return BillingPeriod.from(Number(yearString), Number(monthString))
|
||||||
}
|
}
|
||||||
|
|
||||||
static fromDate(date: Date): BillingPeriod {
|
static fromInstant(instant: Instant): BillingPeriod {
|
||||||
return BillingPeriod.from(date.getUTCFullYear(), date.getUTCMonth() + 1)
|
const zoned = instant.toZonedDateTimeISO('UTC')
|
||||||
|
|
||||||
|
return BillingPeriod.from(zoned.year, zoned.month)
|
||||||
}
|
}
|
||||||
|
|
||||||
next(): BillingPeriod {
|
next(): BillingPeriod {
|
||||||
|
|||||||
@@ -2,7 +2,18 @@ export { BillingPeriod } from './billing-period'
|
|||||||
export { DOMAIN_ERROR_CODE, DomainError } from './errors'
|
export { DOMAIN_ERROR_CODE, DomainError } from './errors'
|
||||||
export { BillingCycleId, HouseholdId, MemberId, PurchaseEntryId } from './ids'
|
export { BillingCycleId, HouseholdId, MemberId, PurchaseEntryId } from './ids'
|
||||||
export { CURRENCIES, Money } from './money'
|
export { CURRENCIES, Money } from './money'
|
||||||
|
export {
|
||||||
|
Temporal,
|
||||||
|
instantFromDatabaseValue,
|
||||||
|
instantFromDate,
|
||||||
|
instantFromEpochSeconds,
|
||||||
|
instantFromIso,
|
||||||
|
instantToDate,
|
||||||
|
instantToEpochSeconds,
|
||||||
|
nowInstant
|
||||||
|
} from './time'
|
||||||
export type { CurrencyCode } from './money'
|
export type { CurrencyCode } from './money'
|
||||||
|
export type { Instant } from './time'
|
||||||
export type {
|
export type {
|
||||||
SettlementInput,
|
SettlementInput,
|
||||||
SettlementMemberInput,
|
SettlementMemberInput,
|
||||||
|
|||||||
41
packages/domain/src/time.ts
Normal file
41
packages/domain/src/time.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { Instant } from '@household/domain'
|
||||||
|
|
||||||
export type AnonymousFeedbackModerationStatus = 'accepted' | 'posted' | 'rejected' | 'failed'
|
export type AnonymousFeedbackModerationStatus = 'accepted' | 'posted' | 'rejected' | 'failed'
|
||||||
|
|
||||||
export type AnonymousFeedbackRejectionReason =
|
export type AnonymousFeedbackRejectionReason =
|
||||||
@@ -16,7 +18,8 @@ export interface AnonymousFeedbackMemberRecord {
|
|||||||
|
|
||||||
export interface AnonymousFeedbackRateLimitSnapshot {
|
export interface AnonymousFeedbackRateLimitSnapshot {
|
||||||
acceptedCountSince: number
|
acceptedCountSince: number
|
||||||
lastAcceptedAt: Date | null
|
earliestAcceptedAtSince: Instant | null
|
||||||
|
lastAcceptedAt: Instant | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AnonymousFeedbackSubmissionRecord {
|
export interface AnonymousFeedbackSubmissionRecord {
|
||||||
@@ -28,7 +31,7 @@ export interface AnonymousFeedbackRepository {
|
|||||||
getMemberByTelegramUserId(telegramUserId: string): Promise<AnonymousFeedbackMemberRecord | null>
|
getMemberByTelegramUserId(telegramUserId: string): Promise<AnonymousFeedbackMemberRecord | null>
|
||||||
getRateLimitSnapshot(
|
getRateLimitSnapshot(
|
||||||
memberId: string,
|
memberId: string,
|
||||||
acceptedSince: Date
|
acceptedSince: Instant
|
||||||
): Promise<AnonymousFeedbackRateLimitSnapshot>
|
): Promise<AnonymousFeedbackRateLimitSnapshot>
|
||||||
createSubmission(input: {
|
createSubmission(input: {
|
||||||
submittedByMemberId: string
|
submittedByMemberId: string
|
||||||
@@ -45,7 +48,7 @@ export interface AnonymousFeedbackRepository {
|
|||||||
postedChatId: string
|
postedChatId: string
|
||||||
postedThreadId: string
|
postedThreadId: string
|
||||||
postedMessageId: string
|
postedMessageId: string
|
||||||
postedAt: Date
|
postedAt: Instant
|
||||||
}): Promise<void>
|
}): Promise<void>
|
||||||
markFailed(submissionId: string, failureReason: string): Promise<void>
|
markFailed(submissionId: string, failureReason: string): Promise<void>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { CurrencyCode } from '@household/domain'
|
import type { CurrencyCode, Instant } from '@household/domain'
|
||||||
|
|
||||||
export interface FinanceMemberRecord {
|
export interface FinanceMemberRecord {
|
||||||
id: string
|
id: string
|
||||||
@@ -23,7 +23,7 @@ export interface FinanceParsedPurchaseRecord {
|
|||||||
payerMemberId: string
|
payerMemberId: string
|
||||||
amountMinor: bigint
|
amountMinor: bigint
|
||||||
description: string | null
|
description: string | null
|
||||||
occurredAt: Date | null
|
occurredAt: Instant | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FinanceUtilityBillRecord {
|
export interface FinanceUtilityBillRecord {
|
||||||
@@ -32,7 +32,7 @@ export interface FinanceUtilityBillRecord {
|
|||||||
amountMinor: bigint
|
amountMinor: bigint
|
||||||
currency: CurrencyCode
|
currency: CurrencyCode
|
||||||
createdByMemberId: string | null
|
createdByMemberId: string | null
|
||||||
createdAt: Date
|
createdAt: Instant
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SettlementSnapshotLineRecord {
|
export interface SettlementSnapshotLineRecord {
|
||||||
@@ -60,7 +60,7 @@ export interface FinanceRepository {
|
|||||||
getCycleByPeriod(period: string): Promise<FinanceCycleRecord | null>
|
getCycleByPeriod(period: string): Promise<FinanceCycleRecord | null>
|
||||||
getLatestCycle(): Promise<FinanceCycleRecord | null>
|
getLatestCycle(): Promise<FinanceCycleRecord | null>
|
||||||
openCycle(period: string, currency: CurrencyCode): Promise<void>
|
openCycle(period: string, currency: CurrencyCode): Promise<void>
|
||||||
closeCycle(cycleId: string, closedAt: Date): Promise<void>
|
closeCycle(cycleId: string, closedAt: Instant): Promise<void>
|
||||||
saveRentRule(period: string, amountMinor: bigint, currency: CurrencyCode): Promise<void>
|
saveRentRule(period: string, amountMinor: bigint, currency: CurrencyCode): Promise<void>
|
||||||
addUtilityBill(input: {
|
addUtilityBill(input: {
|
||||||
cycleId: string
|
cycleId: string
|
||||||
@@ -73,8 +73,8 @@ export interface FinanceRepository {
|
|||||||
getUtilityTotalForCycle(cycleId: string): Promise<bigint>
|
getUtilityTotalForCycle(cycleId: string): Promise<bigint>
|
||||||
listUtilityBillsForCycle(cycleId: string): Promise<readonly FinanceUtilityBillRecord[]>
|
listUtilityBillsForCycle(cycleId: string): Promise<readonly FinanceUtilityBillRecord[]>
|
||||||
listParsedPurchasesForRange(
|
listParsedPurchasesForRange(
|
||||||
start: Date,
|
start: Instant,
|
||||||
end: Date
|
end: Instant
|
||||||
): Promise<readonly FinanceParsedPurchaseRecord[]>
|
): Promise<readonly FinanceParsedPurchaseRecord[]>
|
||||||
replaceSettlementSnapshot(snapshot: SettlementSnapshotRecord): Promise<void>
|
replaceSettlementSnapshot(snapshot: SettlementSnapshotRecord): Promise<void>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { Instant } from '@household/domain'
|
||||||
|
|
||||||
export const TELEGRAM_PENDING_ACTION_TYPES = ['anonymous_feedback'] as const
|
export const TELEGRAM_PENDING_ACTION_TYPES = ['anonymous_feedback'] as const
|
||||||
|
|
||||||
export type TelegramPendingActionType = (typeof TELEGRAM_PENDING_ACTION_TYPES)[number]
|
export type TelegramPendingActionType = (typeof TELEGRAM_PENDING_ACTION_TYPES)[number]
|
||||||
@@ -7,7 +9,7 @@ export interface TelegramPendingActionRecord {
|
|||||||
telegramChatId: string
|
telegramChatId: string
|
||||||
action: TelegramPendingActionType
|
action: TelegramPendingActionType
|
||||||
payload: Record<string, unknown>
|
payload: Record<string, unknown>
|
||||||
expiresAt: Date | null
|
expiresAt: Instant | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TelegramPendingActionRepository {
|
export interface TelegramPendingActionRepository {
|
||||||
|
|||||||
Reference in New Issue
Block a user