mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 11:54:03 +00:00
refactor(time): migrate runtime time handling to Temporal
This commit is contained in:
@@ -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<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})`
|
||||
})
|
||||
.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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<string, unknown>)
|
||||
: {},
|
||||
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<string, unknown>)
|
||||
: {},
|
||||
expiresAt
|
||||
}
|
||||
},
|
||||
|
||||
async clearPendingAction(telegramChatId, telegramUserId) {
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
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 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<AnonymousFeedbackMemberRecord | null>
|
||||
getRateLimitSnapshot(
|
||||
memberId: string,
|
||||
acceptedSince: Date
|
||||
acceptedSince: Instant
|
||||
): Promise<AnonymousFeedbackRateLimitSnapshot>
|
||||
createSubmission(input: {
|
||||
submittedByMemberId: string
|
||||
@@ -45,7 +48,7 @@ export interface AnonymousFeedbackRepository {
|
||||
postedChatId: string
|
||||
postedThreadId: string
|
||||
postedMessageId: string
|
||||
postedAt: Date
|
||||
postedAt: Instant
|
||||
}): 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 {
|
||||
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<FinanceCycleRecord | null>
|
||||
getLatestCycle(): Promise<FinanceCycleRecord | null>
|
||||
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>
|
||||
addUtilityBill(input: {
|
||||
cycleId: string
|
||||
@@ -73,8 +73,8 @@ export interface FinanceRepository {
|
||||
getUtilityTotalForCycle(cycleId: string): Promise<bigint>
|
||||
listUtilityBillsForCycle(cycleId: string): Promise<readonly FinanceUtilityBillRecord[]>
|
||||
listParsedPurchasesForRange(
|
||||
start: Date,
|
||||
end: Date
|
||||
start: Instant,
|
||||
end: Instant
|
||||
): Promise<readonly FinanceParsedPurchaseRecord[]>
|
||||
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 type TelegramPendingActionType = (typeof TELEGRAM_PENDING_ACTION_TYPES)[number]
|
||||
@@ -7,7 +9,7 @@ export interface TelegramPendingActionRecord {
|
||||
telegramChatId: string
|
||||
action: TelegramPendingActionType
|
||||
payload: Record<string, unknown>
|
||||
expiresAt: Date | null
|
||||
expiresAt: Instant | null
|
||||
}
|
||||
|
||||
export interface TelegramPendingActionRepository {
|
||||
|
||||
Reference in New Issue
Block a user