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

@@ -633,6 +633,7 @@ a {
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
transition: border-color var(--transition-fast);
overflow: hidden;
}
.ui-card--accent {
@@ -1402,7 +1403,8 @@ a {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-sm) 0;
margin: 0 calc(var(--spacing-lg) * -1);
padding: var(--spacing-sm) var(--spacing-lg);
border-bottom: 1px solid var(--border);
background: transparent;
border-left: 0;
@@ -1410,7 +1412,7 @@ a {
border-top: 0;
color: inherit;
text-align: left;
width: 100%;
width: calc(100% + var(--spacing-lg) * 2);
cursor: pointer;
transition: background var(--transition-fast);
}
@@ -1500,6 +1502,17 @@ a {
gap: var(--spacing-md);
}
.settings-profile__row.interactive {
margin: 0 calc(var(--spacing-lg) * -1);
padding: var(--spacing-sm) var(--spacing-lg);
cursor: pointer;
transition: background var(--transition-fast);
}
.settings-profile__row.interactive:hover {
background: var(--bg-input);
}
.settings-profile__row svg {
color: var(--text-muted);
flex-shrink: 0;
@@ -1552,6 +1565,17 @@ a {
align-items: center;
}
.member-row.interactive {
margin: 0 calc(var(--spacing-lg) * -1);
padding: var(--spacing-sm) var(--spacing-lg);
cursor: pointer;
transition: background var(--transition-fast);
}
.member-row.interactive:hover {
background: var(--bg-input);
}
.member-row__info {
display: flex;
flex-direction: column;

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