diff --git a/apps/miniapp/src/index.css b/apps/miniapp/src/index.css index 22d477f..bcc88d7 100644 --- a/apps/miniapp/src/index.css +++ b/apps/miniapp/src/index.css @@ -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; diff --git a/packages/application/src/anonymous-feedback-service.test.ts b/packages/application/src/anonymous-feedback-service.test.ts index 118979c..4ea7b3e 100644 --- a/packages/application/src/anonymous-feedback-service.test.ts +++ b/packages/application/src/anonymous-feedback-service.test.ts @@ -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) diff --git a/packages/application/src/anonymous-feedback-service.ts b/packages/application/src/anonymous-feedback-service.ts index 24c3394..4de5b42 100644 --- a/packages/application/src/anonymous-feedback-service.ts +++ b/packages/application/src/anonymous-feedback-service.ts @@ -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