fix(feedback): normalize anonymous rate limit timestamps

This commit is contained in:
2026-03-13 06:16:21 +04:00
parent 94a5904f54
commit ba99460a34
3 changed files with 96 additions and 8 deletions

View File

@@ -194,6 +194,31 @@ describe('createAnonymousFeedbackService', () => {
})
})
test('normalizes legacy date-like rate limit values before cooldown checks', async () => {
const repository = new AnonymousFeedbackRepositoryStub()
const service = createAnonymousFeedbackService(repository)
;(repository.lastAcceptedAt as Instant | null | string) = '2026-03-08T09:00:00.000Z'
;(repository.earliestAcceptedAtSince as Instant | null | Date) = new Date(
'2026-03-08T09:00:00.000Z'
)
const result = await service.submit({
telegramUserId: '123',
rawText: 'Please take the trash out tonight',
telegramChatId: 'chat-1',
telegramMessageId: 'message-1',
telegramUpdateId: 'update-1',
now: instantFromIso('2026-03-08T12:00:00.000Z')
})
expect(result).toEqual({
status: 'rejected',
reason: 'cooldown',
nextAllowedAt: instantFromIso('2026-03-08T15:00:00.000Z')
})
})
test('marks posted and failed submissions', async () => {
const repository = new AnonymousFeedbackRepositoryStub()
const service = createAnonymousFeedbackService(repository)

View File

@@ -2,7 +2,13 @@ import type {
AnonymousFeedbackRejectionReason,
AnonymousFeedbackRepository
} from '@household/ports'
import { nowInstant, type Instant, Temporal } from '@household/domain'
import {
nowInstant,
instantFromDate,
instantFromIso,
type Instant,
Temporal
} from '@household/domain'
const MIN_MESSAGE_LENGTH = 12
const MAX_MESSAGE_LENGTH = 500
@@ -10,6 +16,37 @@ const COOLDOWN_HOURS = 6
const DAILY_CAP = 3
const BLOCKLIST = ['kill yourself', 'сука', 'тварь', 'идиот', 'idiot', 'hate you'] as const
function normalizeInstant(value: unknown): Instant | null {
if (!value) {
return null
}
if (value instanceof Temporal.Instant) {
return value
}
if (value instanceof Date) {
return instantFromDate(value)
}
if (typeof value === 'string') {
return instantFromIso(value)
}
if (
typeof value === 'object' &&
value !== null &&
'epochMilliseconds' in value &&
typeof (value as { epochMilliseconds?: unknown }).epochMilliseconds === 'number'
) {
return Temporal.Instant.fromEpochMilliseconds(
(value as { epochMilliseconds: number }).epochMilliseconds
)
}
return null
}
function collapseWhitespace(value: string): string {
return value.replace(/\s+/g, ' ').trim()
}
@@ -160,9 +197,11 @@ export function createAnonymousFeedbackService(
const now = input.now ?? nowInstant()
const acceptedSince = now.subtract({ hours: 24 })
const rateLimit = await repository.getRateLimitSnapshot(member.id, acceptedSince)
const earliestAcceptedAtSince = normalizeInstant(rateLimit.earliestAcceptedAtSince)
const lastAcceptedAt = normalizeInstant(rateLimit.lastAcceptedAt)
if (rateLimit.acceptedCountSince >= DAILY_CAP) {
const nextAllowedAt =
rateLimit.earliestAcceptedAtSince?.add({ hours: 24 }) ?? now.add({ hours: 24 })
const nextAllowedAt = earliestAcceptedAtSince?.add({ hours: 24 }) ?? now.add({ hours: 24 })
return rejectSubmission(repository, {
memberId: member.id,
@@ -175,14 +214,14 @@ export function createAnonymousFeedbackService(
})
}
if (rateLimit.lastAcceptedAt) {
if (lastAcceptedAt) {
const cooldownBoundary = now.subtract({ hours: COOLDOWN_HOURS })
if (Temporal.Instant.compare(rateLimit.lastAcceptedAt, cooldownBoundary) > 0) {
if (Temporal.Instant.compare(lastAcceptedAt, cooldownBoundary) > 0) {
return rejectSubmission(repository, {
memberId: member.id,
rawText: input.rawText,
reason: 'cooldown',
nextAllowedAt: rateLimit.lastAcceptedAt.add({ hours: COOLDOWN_HOURS }),
nextAllowedAt: lastAcceptedAt.add({ hours: COOLDOWN_HOURS }),
telegramChatId: input.telegramChatId,
telegramMessageId: input.telegramMessageId,
telegramUpdateId: input.telegramUpdateId