refactor(time): migrate runtime time handling to Temporal

This commit is contained in:
2026-03-09 07:18:09 +04:00
parent fa8fa7fe23
commit 29f6d788e7
25 changed files with 353 additions and 104 deletions

View File

@@ -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')
})
})

View File

@@ -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<AnonymousFeedbackSubmitResult>
markPosted(input: {
submissionId: string
postedChatId: string
postedThreadId: string
postedMessageId: string
postedAt?: Date
postedAt?: Instant
}): Promise<void>
markFailed(submissionId: string, failureReason: string): Promise<void>
}
@@ -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()
})
},

View File

@@ -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')
}
]

View File

@@ -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
},