mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 17:44:03 +00:00
fix(review): harden miniapp auth and finance flows
This commit is contained in:
@@ -1,10 +1,21 @@
|
||||
import { and, desc, eq, gte, inArray, sql } from 'drizzle-orm'
|
||||
import { and, eq, inArray, sql } from 'drizzle-orm'
|
||||
|
||||
import { createDbClient, schema } from '@household/db'
|
||||
import type { AnonymousFeedbackRepository } from '@household/ports'
|
||||
import type {
|
||||
AnonymousFeedbackModerationStatus,
|
||||
AnonymousFeedbackRepository
|
||||
} from '@household/ports'
|
||||
|
||||
const ACCEPTED_STATUSES = ['accepted', 'posted', 'failed'] as const
|
||||
|
||||
function parseModerationStatus(raw: string): AnonymousFeedbackModerationStatus {
|
||||
if (raw === 'accepted' || raw === 'posted' || raw === 'rejected' || raw === 'failed') {
|
||||
return raw
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected anonymous feedback moderation status: ${raw}`)
|
||||
}
|
||||
|
||||
export function createDbAnonymousFeedbackRepository(
|
||||
databaseUrl: string,
|
||||
householdId: string
|
||||
@@ -38,23 +49,10 @@ export function createDbAnonymousFeedbackRepository(
|
||||
},
|
||||
|
||||
async getRateLimitSnapshot(memberId, acceptedSince) {
|
||||
const countRows = await db
|
||||
const rows = await db
|
||||
.select({
|
||||
count: sql<string>`count(*)`
|
||||
})
|
||||
.from(schema.anonymousMessages)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.anonymousMessages.householdId, householdId),
|
||||
eq(schema.anonymousMessages.submittedByMemberId, memberId),
|
||||
inArray(schema.anonymousMessages.moderationStatus, ACCEPTED_STATUSES),
|
||||
gte(schema.anonymousMessages.createdAt, acceptedSince)
|
||||
)
|
||||
)
|
||||
|
||||
const lastRows = await db
|
||||
.select({
|
||||
createdAt: schema.anonymousMessages.createdAt
|
||||
acceptedCountSince: sql<string>`count(*) filter (where ${schema.anonymousMessages.createdAt} >= ${acceptedSince})`,
|
||||
lastAcceptedAt: sql<Date | null>`max(${schema.anonymousMessages.createdAt})`
|
||||
})
|
||||
.from(schema.anonymousMessages)
|
||||
.where(
|
||||
@@ -64,12 +62,10 @@ export function createDbAnonymousFeedbackRepository(
|
||||
inArray(schema.anonymousMessages.moderationStatus, ACCEPTED_STATUSES)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(schema.anonymousMessages.createdAt))
|
||||
.limit(1)
|
||||
|
||||
return {
|
||||
acceptedCountSince: Number(countRows[0]?.count ?? '0'),
|
||||
lastAcceptedAt: lastRows[0]?.createdAt ?? null
|
||||
acceptedCountSince: Number(rows[0]?.acceptedCountSince ?? '0'),
|
||||
lastAcceptedAt: rows[0]?.lastAcceptedAt ?? null
|
||||
}
|
||||
},
|
||||
|
||||
@@ -99,11 +95,7 @@ export function createDbAnonymousFeedbackRepository(
|
||||
return {
|
||||
submission: {
|
||||
id: inserted[0].id,
|
||||
moderationStatus: inserted[0].moderationStatus as
|
||||
| 'accepted'
|
||||
| 'posted'
|
||||
| 'rejected'
|
||||
| 'failed'
|
||||
moderationStatus: parseModerationStatus(inserted[0].moderationStatus)
|
||||
},
|
||||
duplicate: false
|
||||
}
|
||||
@@ -131,7 +123,7 @@ export function createDbAnonymousFeedbackRepository(
|
||||
return {
|
||||
submission: {
|
||||
id: row.id,
|
||||
moderationStatus: row.moderationStatus as 'accepted' | 'posted' | 'rejected' | 'failed'
|
||||
moderationStatus: parseModerationStatus(row.moderationStatus)
|
||||
},
|
||||
duplicate: true
|
||||
}
|
||||
|
||||
@@ -299,48 +299,54 @@ export function createDbFinanceRepository(
|
||||
},
|
||||
|
||||
async replaceSettlementSnapshot(snapshot) {
|
||||
const upserted = await db
|
||||
.insert(schema.settlements)
|
||||
.values({
|
||||
householdId,
|
||||
cycleId: snapshot.cycleId,
|
||||
inputHash: snapshot.inputHash,
|
||||
totalDueMinor: snapshot.totalDueMinor,
|
||||
currency: snapshot.currency,
|
||||
metadata: snapshot.metadata
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [schema.settlements.cycleId],
|
||||
set: {
|
||||
await db.transaction(async (tx) => {
|
||||
const upserted = await tx
|
||||
.insert(schema.settlements)
|
||||
.values({
|
||||
householdId,
|
||||
cycleId: snapshot.cycleId,
|
||||
inputHash: snapshot.inputHash,
|
||||
totalDueMinor: snapshot.totalDueMinor,
|
||||
currency: snapshot.currency,
|
||||
computedAt: new Date(),
|
||||
metadata: snapshot.metadata
|
||||
}
|
||||
})
|
||||
.returning({ id: schema.settlements.id })
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [schema.settlements.cycleId],
|
||||
set: {
|
||||
inputHash: snapshot.inputHash,
|
||||
totalDueMinor: snapshot.totalDueMinor,
|
||||
currency: snapshot.currency,
|
||||
computedAt: new Date(),
|
||||
metadata: snapshot.metadata
|
||||
}
|
||||
})
|
||||
.returning({ id: schema.settlements.id })
|
||||
|
||||
const settlementId = upserted[0]?.id
|
||||
if (!settlementId) {
|
||||
throw new Error('Failed to persist settlement snapshot')
|
||||
}
|
||||
const settlementId = upserted[0]?.id
|
||||
if (!settlementId) {
|
||||
throw new Error('Failed to persist settlement snapshot')
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(schema.settlementLines)
|
||||
.where(eq(schema.settlementLines.settlementId, settlementId))
|
||||
await tx
|
||||
.delete(schema.settlementLines)
|
||||
.where(eq(schema.settlementLines.settlementId, settlementId))
|
||||
|
||||
await db.insert(schema.settlementLines).values(
|
||||
snapshot.lines.map((line) => ({
|
||||
settlementId,
|
||||
memberId: line.memberId,
|
||||
rentShareMinor: line.rentShareMinor,
|
||||
utilityShareMinor: line.utilityShareMinor,
|
||||
purchaseOffsetMinor: line.purchaseOffsetMinor,
|
||||
netDueMinor: line.netDueMinor,
|
||||
explanations: line.explanations
|
||||
}))
|
||||
)
|
||||
if (snapshot.lines.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
await tx.insert(schema.settlementLines).values(
|
||||
snapshot.lines.map((line) => ({
|
||||
settlementId,
|
||||
memberId: line.memberId,
|
||||
rentShareMinor: line.rentShareMinor,
|
||||
utilityShareMinor: line.utilityShareMinor,
|
||||
purchaseOffsetMinor: line.purchaseOffsetMinor,
|
||||
netDueMinor: line.netDueMinor,
|
||||
explanations: line.explanations
|
||||
}))
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -232,11 +232,12 @@ export function createFinanceCommandService(repository: FinanceRepository): Fina
|
||||
|
||||
await repository.openCycle(period, currency)
|
||||
|
||||
return {
|
||||
id: '',
|
||||
period,
|
||||
currency
|
||||
const cycle = await repository.getCycleByPeriod(period)
|
||||
if (!cycle) {
|
||||
throw new Error(`Failed to load billing cycle for period ${period}`)
|
||||
}
|
||||
|
||||
return cycle
|
||||
},
|
||||
|
||||
async closeCycle(periodArg) {
|
||||
|
||||
@@ -44,6 +44,10 @@ describe('createReminderJobService', () => {
|
||||
|
||||
test('claims a dispatch once and returns the dedupe key', async () => {
|
||||
const repository = new ReminderDispatchRepositoryStub()
|
||||
repository.nextResult = {
|
||||
dedupeKey: '2026-03:rent-due',
|
||||
claimed: true
|
||||
}
|
||||
const service = createReminderJobService(repository)
|
||||
|
||||
const result = await service.handleJob({
|
||||
@@ -53,6 +57,7 @@ describe('createReminderJobService', () => {
|
||||
})
|
||||
|
||||
expect(result.status).toBe('claimed')
|
||||
expect(result.dedupeKey).toBe('2026-03:rent-due')
|
||||
expect(repository.lastClaim).toMatchObject({
|
||||
householdId: 'household-1',
|
||||
period: '2026-03',
|
||||
|
||||
@@ -11,6 +11,10 @@ function computePayloadHash(payload: object): string {
|
||||
return createHash('sha256').update(JSON.stringify(payload)).digest('hex')
|
||||
}
|
||||
|
||||
function buildReminderDedupeKey(period: string, reminderType: ReminderType): string {
|
||||
return `${period}:${reminderType}`
|
||||
}
|
||||
|
||||
function createReminderMessage(reminderType: ReminderType, period: string): string {
|
||||
switch (reminderType) {
|
||||
case 'utilities':
|
||||
@@ -56,7 +60,7 @@ export function createReminderJobService(
|
||||
if (input.dryRun === true) {
|
||||
return {
|
||||
status: 'dry-run',
|
||||
dedupeKey: `${period}:${input.reminderType}`,
|
||||
dedupeKey: buildReminderDedupeKey(period, input.reminderType),
|
||||
payloadHash,
|
||||
reminderType: input.reminderType,
|
||||
period,
|
||||
|
||||
Reference in New Issue
Block a user