mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 19:04:02 +00:00
refactor(time): migrate runtime time handling to Temporal
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
},
|
||||
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user