mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 17:44:03 +00:00
fix(feedback): normalize anonymous rate limit timestamps
This commit is contained in:
@@ -633,6 +633,7 @@ a {
|
|||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
padding: var(--spacing-lg);
|
padding: var(--spacing-lg);
|
||||||
transition: border-color var(--transition-fast);
|
transition: border-color var(--transition-fast);
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-card--accent {
|
.ui-card--accent {
|
||||||
@@ -1402,7 +1403,8 @@ a {
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
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);
|
border-bottom: 1px solid var(--border);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border-left: 0;
|
border-left: 0;
|
||||||
@@ -1410,7 +1412,7 @@ a {
|
|||||||
border-top: 0;
|
border-top: 0;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
width: 100%;
|
width: calc(100% + var(--spacing-lg) * 2);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background var(--transition-fast);
|
transition: background var(--transition-fast);
|
||||||
}
|
}
|
||||||
@@ -1500,6 +1502,17 @@ a {
|
|||||||
gap: var(--spacing-md);
|
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 {
|
.settings-profile__row svg {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -1552,6 +1565,17 @@ a {
|
|||||||
align-items: center;
|
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 {
|
.member-row__info {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -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 () => {
|
test('marks posted and failed submissions', async () => {
|
||||||
const repository = new AnonymousFeedbackRepositoryStub()
|
const repository = new AnonymousFeedbackRepositoryStub()
|
||||||
const service = createAnonymousFeedbackService(repository)
|
const service = createAnonymousFeedbackService(repository)
|
||||||
|
|||||||
@@ -2,7 +2,13 @@ import type {
|
|||||||
AnonymousFeedbackRejectionReason,
|
AnonymousFeedbackRejectionReason,
|
||||||
AnonymousFeedbackRepository
|
AnonymousFeedbackRepository
|
||||||
} from '@household/ports'
|
} 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 MIN_MESSAGE_LENGTH = 12
|
||||||
const MAX_MESSAGE_LENGTH = 500
|
const MAX_MESSAGE_LENGTH = 500
|
||||||
@@ -10,6 +16,37 @@ const COOLDOWN_HOURS = 6
|
|||||||
const DAILY_CAP = 3
|
const DAILY_CAP = 3
|
||||||
const BLOCKLIST = ['kill yourself', 'сука', 'тварь', 'идиот', 'idiot', 'hate you'] as const
|
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 {
|
function collapseWhitespace(value: string): string {
|
||||||
return value.replace(/\s+/g, ' ').trim()
|
return value.replace(/\s+/g, ' ').trim()
|
||||||
}
|
}
|
||||||
@@ -160,9 +197,11 @@ export function createAnonymousFeedbackService(
|
|||||||
const now = input.now ?? nowInstant()
|
const now = input.now ?? nowInstant()
|
||||||
const acceptedSince = now.subtract({ hours: 24 })
|
const acceptedSince = now.subtract({ hours: 24 })
|
||||||
const rateLimit = await repository.getRateLimitSnapshot(member.id, acceptedSince)
|
const rateLimit = await repository.getRateLimitSnapshot(member.id, acceptedSince)
|
||||||
|
const earliestAcceptedAtSince = normalizeInstant(rateLimit.earliestAcceptedAtSince)
|
||||||
|
const lastAcceptedAt = normalizeInstant(rateLimit.lastAcceptedAt)
|
||||||
|
|
||||||
if (rateLimit.acceptedCountSince >= DAILY_CAP) {
|
if (rateLimit.acceptedCountSince >= DAILY_CAP) {
|
||||||
const nextAllowedAt =
|
const nextAllowedAt = earliestAcceptedAtSince?.add({ hours: 24 }) ?? now.add({ hours: 24 })
|
||||||
rateLimit.earliestAcceptedAtSince?.add({ hours: 24 }) ?? now.add({ hours: 24 })
|
|
||||||
|
|
||||||
return rejectSubmission(repository, {
|
return rejectSubmission(repository, {
|
||||||
memberId: member.id,
|
memberId: member.id,
|
||||||
@@ -175,14 +214,14 @@ export function createAnonymousFeedbackService(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rateLimit.lastAcceptedAt) {
|
if (lastAcceptedAt) {
|
||||||
const cooldownBoundary = now.subtract({ hours: COOLDOWN_HOURS })
|
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, {
|
return rejectSubmission(repository, {
|
||||||
memberId: member.id,
|
memberId: member.id,
|
||||||
rawText: input.rawText,
|
rawText: input.rawText,
|
||||||
reason: 'cooldown',
|
reason: 'cooldown',
|
||||||
nextAllowedAt: rateLimit.lastAcceptedAt.add({ hours: COOLDOWN_HOURS }),
|
nextAllowedAt: lastAcceptedAt.add({ hours: COOLDOWN_HOURS }),
|
||||||
telegramChatId: input.telegramChatId,
|
telegramChatId: input.telegramChatId,
|
||||||
telegramMessageId: input.telegramMessageId,
|
telegramMessageId: input.telegramMessageId,
|
||||||
telegramUpdateId: input.telegramUpdateId
|
telegramUpdateId: input.telegramUpdateId
|
||||||
|
|||||||
Reference in New Issue
Block a user