feat(bot): add anonymous feedback flow

This commit is contained in:
2026-03-08 22:50:55 +04:00
parent c6a9ade586
commit 7ffd81bda9
21 changed files with 2750 additions and 3 deletions

View File

@@ -0,0 +1,171 @@
import { and, desc, eq, gte, inArray, sql } from 'drizzle-orm'
import { createDbClient, schema } from '@household/db'
import type { AnonymousFeedbackRepository } from '@household/ports'
const ACCEPTED_STATUSES = ['accepted', 'posted', 'failed'] as const
export function createDbAnonymousFeedbackRepository(
databaseUrl: string,
householdId: string
): {
repository: AnonymousFeedbackRepository
close: () => Promise<void>
} {
const { db, queryClient } = createDbClient(databaseUrl, {
max: 5,
prepare: false
})
const repository: AnonymousFeedbackRepository = {
async getMemberByTelegramUserId(telegramUserId) {
const rows = await db
.select({
id: schema.members.id,
telegramUserId: schema.members.telegramUserId,
displayName: schema.members.displayName
})
.from(schema.members)
.where(
and(
eq(schema.members.householdId, householdId),
eq(schema.members.telegramUserId, telegramUserId)
)
)
.limit(1)
return rows[0] ?? null
},
async getRateLimitSnapshot(memberId, acceptedSince) {
const countRows = 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
})
.from(schema.anonymousMessages)
.where(
and(
eq(schema.anonymousMessages.householdId, householdId),
eq(schema.anonymousMessages.submittedByMemberId, memberId),
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
}
},
async createSubmission(input) {
const inserted = await db
.insert(schema.anonymousMessages)
.values({
householdId,
submittedByMemberId: input.submittedByMemberId,
rawText: input.rawText,
sanitizedText: input.sanitizedText,
moderationStatus: input.moderationStatus,
moderationReason: input.moderationReason,
telegramChatId: input.telegramChatId,
telegramMessageId: input.telegramMessageId,
telegramUpdateId: input.telegramUpdateId
})
.onConflictDoNothing({
target: [schema.anonymousMessages.householdId, schema.anonymousMessages.telegramUpdateId]
})
.returning({
id: schema.anonymousMessages.id,
moderationStatus: schema.anonymousMessages.moderationStatus
})
if (inserted[0]) {
return {
submission: {
id: inserted[0].id,
moderationStatus: inserted[0].moderationStatus as
| 'accepted'
| 'posted'
| 'rejected'
| 'failed'
},
duplicate: false
}
}
const existing = await db
.select({
id: schema.anonymousMessages.id,
moderationStatus: schema.anonymousMessages.moderationStatus
})
.from(schema.anonymousMessages)
.where(
and(
eq(schema.anonymousMessages.householdId, householdId),
eq(schema.anonymousMessages.telegramUpdateId, input.telegramUpdateId)
)
)
.limit(1)
const row = existing[0]
if (!row) {
throw new Error('Anonymous feedback insert conflict without stored row')
}
return {
submission: {
id: row.id,
moderationStatus: row.moderationStatus as 'accepted' | 'posted' | 'rejected' | 'failed'
},
duplicate: true
}
},
async markPosted(input) {
await db
.update(schema.anonymousMessages)
.set({
moderationStatus: 'posted',
postedChatId: input.postedChatId,
postedThreadId: input.postedThreadId,
postedMessageId: input.postedMessageId,
postedAt: input.postedAt,
failureReason: null
})
.where(eq(schema.anonymousMessages.id, input.submissionId))
},
async markFailed(submissionId, failureReason) {
await db
.update(schema.anonymousMessages)
.set({
moderationStatus: 'failed',
failureReason
})
.where(eq(schema.anonymousMessages.id, submissionId))
}
}
return {
repository,
close: async () => {
await queryClient.end({ timeout: 5 })
}
}
}

View File

@@ -1,2 +1,3 @@
export { createDbAnonymousFeedbackRepository } from './anonymous-feedback-repository'
export { createDbFinanceRepository } from './finance-repository'
export { createDbReminderDispatchRepository } from './reminder-dispatch-repository'

View File

@@ -0,0 +1,217 @@
import { describe, expect, test } from 'bun:test'
import type {
AnonymousFeedbackMemberRecord,
AnonymousFeedbackRepository,
AnonymousFeedbackSubmissionRecord
} from '@household/ports'
import { createAnonymousFeedbackService } from './anonymous-feedback-service'
class AnonymousFeedbackRepositoryStub implements AnonymousFeedbackRepository {
member: AnonymousFeedbackMemberRecord | null = {
id: 'member-1',
telegramUserId: '123',
displayName: 'Stan'
}
acceptedCountSince = 0
lastAcceptedAt: Date | null = null
duplicate = false
created: Array<{
rawText: string
sanitizedText: string | null
moderationStatus: string
moderationReason: string | null
}> = []
posted: Array<{ submissionId: string; postedThreadId: string; postedMessageId: string }> = []
failed: Array<{ submissionId: string; failureReason: string }> = []
async getMemberByTelegramUserId() {
return this.member
}
async getRateLimitSnapshot() {
return {
acceptedCountSince: this.acceptedCountSince,
lastAcceptedAt: this.lastAcceptedAt
}
}
async createSubmission(input: {
submittedByMemberId: string
rawText: string
sanitizedText: string | null
moderationStatus: 'accepted' | 'posted' | 'rejected' | 'failed'
moderationReason: string | null
telegramChatId: string
telegramMessageId: string
telegramUpdateId: string
}): Promise<{ submission: AnonymousFeedbackSubmissionRecord; duplicate: boolean }> {
this.created.push({
rawText: input.rawText,
sanitizedText: input.sanitizedText,
moderationStatus: input.moderationStatus,
moderationReason: input.moderationReason
})
return {
submission: {
id: 'submission-1',
moderationStatus: input.moderationStatus
},
duplicate: this.duplicate
}
}
async markPosted(input: {
submissionId: string
postedChatId: string
postedThreadId: string
postedMessageId: string
postedAt: Date
}) {
this.posted.push({
submissionId: input.submissionId,
postedThreadId: input.postedThreadId,
postedMessageId: input.postedMessageId
})
}
async markFailed(submissionId: string, failureReason: string) {
this.failed.push({ submissionId, failureReason })
}
}
describe('createAnonymousFeedbackService', () => {
test('accepts and sanitizes a valid submission', async () => {
const repository = new AnonymousFeedbackRepositoryStub()
const service = createAnonymousFeedbackService(repository)
const result = await service.submit({
telegramUserId: '123',
rawText: 'Please clean the kitchen tonight @roommate https://example.com',
telegramChatId: 'chat-1',
telegramMessageId: 'message-1',
telegramUpdateId: 'update-1',
now: new Date('2026-03-08T12:00:00.000Z')
})
expect(result).toEqual({
status: 'accepted',
submissionId: 'submission-1',
sanitizedText: 'Please clean the kitchen tonight [mention removed] [link removed]'
})
expect(repository.created[0]).toMatchObject({
moderationStatus: 'accepted'
})
})
test('rejects non-members before persistence', async () => {
const repository = new AnonymousFeedbackRepositoryStub()
repository.member = null
const service = createAnonymousFeedbackService(repository)
const result = await service.submit({
telegramUserId: '404',
rawText: 'Please wash the dishes tonight',
telegramChatId: 'chat-1',
telegramMessageId: 'message-1',
telegramUpdateId: 'update-1'
})
expect(result).toEqual({
status: 'rejected',
reason: 'not_member'
})
expect(repository.created).toHaveLength(0)
})
test('rejects blocklisted content and persists moderation outcome', async () => {
const repository = new AnonymousFeedbackRepositoryStub()
const service = createAnonymousFeedbackService(repository)
const result = await service.submit({
telegramUserId: '123',
rawText: 'You are an idiot and this is disgusting',
telegramChatId: 'chat-1',
telegramMessageId: 'message-1',
telegramUpdateId: 'update-1'
})
expect(result).toEqual({
status: 'rejected',
reason: 'blocklisted',
detail: 'idiot'
})
expect(repository.created[0]).toMatchObject({
moderationStatus: 'rejected',
moderationReason: 'blocklisted:idiot'
})
})
test('enforces cooldown and daily cap', async () => {
const repository = new AnonymousFeedbackRepositoryStub()
const service = createAnonymousFeedbackService(repository)
repository.lastAcceptedAt = new Date('2026-03-08T09:00:00.000Z')
const cooldownResult = await service.submit({
telegramUserId: '123',
rawText: 'Please take the trash out tonight',
telegramChatId: 'chat-1',
telegramMessageId: 'message-1',
telegramUpdateId: 'update-1',
now: new Date('2026-03-08T12:00:00.000Z')
})
expect(cooldownResult).toEqual({
status: 'rejected',
reason: 'cooldown'
})
repository.lastAcceptedAt = new Date('2026-03-07T00:00:00.000Z')
repository.acceptedCountSince = 3
const dailyCapResult = await service.submit({
telegramUserId: '123',
rawText: 'Please ventilate the bathroom after showers',
telegramChatId: 'chat-1',
telegramMessageId: 'message-2',
telegramUpdateId: 'update-2',
now: new Date('2026-03-08T12:00:00.000Z')
})
expect(dailyCapResult).toEqual({
status: 'rejected',
reason: 'daily_cap'
})
})
test('marks posted and failed submissions', async () => {
const repository = new AnonymousFeedbackRepositoryStub()
const service = createAnonymousFeedbackService(repository)
await service.markPosted({
submissionId: 'submission-1',
postedChatId: 'group-1',
postedThreadId: 'thread-1',
postedMessageId: 'post-1'
})
await service.markFailed('submission-2', 'telegram send failed')
expect(repository.posted).toEqual([
{
submissionId: 'submission-1',
postedThreadId: 'thread-1',
postedMessageId: 'post-1'
}
])
expect(repository.failed).toEqual([
{
submissionId: 'submission-2',
failureReason: 'telegram send failed'
}
])
})
})

View File

@@ -0,0 +1,223 @@
import type {
AnonymousFeedbackRejectionReason,
AnonymousFeedbackRepository
} from '@household/ports'
const MIN_MESSAGE_LENGTH = 12
const MAX_MESSAGE_LENGTH = 500
const COOLDOWN_HOURS = 6
const DAILY_CAP = 3
const BLOCKLIST = ['kill yourself', 'сука', 'тварь', 'идиот', 'idiot', 'hate you'] as const
function collapseWhitespace(value: string): string {
return value.replace(/\s+/g, ' ').trim()
}
function sanitizeAnonymousText(rawText: string): string {
return collapseWhitespace(rawText)
.replace(/https?:\/\/\S+/gi, '[link removed]')
.replace(/@\w+/g, '[mention removed]')
.replace(/\+?\d[\d\s\-()]{8,}\d/g, '[contact removed]')
}
function findBlocklistedPhrase(value: string): string | null {
const normalized = value.toLowerCase()
for (const phrase of BLOCKLIST) {
if (normalized.includes(phrase)) {
return phrase
}
}
return null
}
export type AnonymousFeedbackSubmitResult =
| {
status: 'accepted'
submissionId: string
sanitizedText: string
}
| {
status: 'duplicate'
submissionId: string
}
| {
status: 'rejected'
reason: AnonymousFeedbackRejectionReason
detail?: string
}
export interface AnonymousFeedbackService {
submit(input: {
telegramUserId: string
rawText: string
telegramChatId: string
telegramMessageId: string
telegramUpdateId: string
now?: Date
}): Promise<AnonymousFeedbackSubmitResult>
markPosted(input: {
submissionId: string
postedChatId: string
postedThreadId: string
postedMessageId: string
postedAt?: Date
}): Promise<void>
markFailed(submissionId: string, failureReason: string): Promise<void>
}
async function rejectSubmission(
repository: AnonymousFeedbackRepository,
input: {
memberId: string
rawText: string
reason: AnonymousFeedbackRejectionReason
detail?: string
telegramChatId: string
telegramMessageId: string
telegramUpdateId: string
}
): Promise<AnonymousFeedbackSubmitResult> {
const created = await repository.createSubmission({
submittedByMemberId: input.memberId,
rawText: input.rawText,
sanitizedText: null,
moderationStatus: 'rejected',
moderationReason: input.detail ? `${input.reason}:${input.detail}` : input.reason,
telegramChatId: input.telegramChatId,
telegramMessageId: input.telegramMessageId,
telegramUpdateId: input.telegramUpdateId
})
if (created.duplicate) {
return {
status: 'duplicate',
submissionId: created.submission.id
}
}
return {
status: 'rejected',
reason: input.reason,
...(input.detail ? { detail: input.detail } : {})
}
}
export function createAnonymousFeedbackService(
repository: AnonymousFeedbackRepository
): AnonymousFeedbackService {
return {
async submit(input) {
const member = await repository.getMemberByTelegramUserId(input.telegramUserId)
if (!member) {
return {
status: 'rejected',
reason: 'not_member'
}
}
const sanitizedText = sanitizeAnonymousText(input.rawText)
if (sanitizedText.length < MIN_MESSAGE_LENGTH) {
return rejectSubmission(repository, {
memberId: member.id,
rawText: input.rawText,
reason: 'too_short',
telegramChatId: input.telegramChatId,
telegramMessageId: input.telegramMessageId,
telegramUpdateId: input.telegramUpdateId
})
}
if (sanitizedText.length > MAX_MESSAGE_LENGTH) {
return rejectSubmission(repository, {
memberId: member.id,
rawText: input.rawText,
reason: 'too_long',
telegramChatId: input.telegramChatId,
telegramMessageId: input.telegramMessageId,
telegramUpdateId: input.telegramUpdateId
})
}
const blockedPhrase = findBlocklistedPhrase(sanitizedText)
if (blockedPhrase) {
return rejectSubmission(repository, {
memberId: member.id,
rawText: input.rawText,
reason: 'blocklisted',
detail: blockedPhrase,
telegramChatId: input.telegramChatId,
telegramMessageId: input.telegramMessageId,
telegramUpdateId: input.telegramUpdateId
})
}
const now = input.now ?? new Date()
const acceptedSince = new Date(now.getTime() - 24 * 60 * 60 * 1000)
const rateLimit = await repository.getRateLimitSnapshot(member.id, acceptedSince)
if (rateLimit.acceptedCountSince >= DAILY_CAP) {
return rejectSubmission(repository, {
memberId: member.id,
rawText: input.rawText,
reason: 'daily_cap',
telegramChatId: input.telegramChatId,
telegramMessageId: input.telegramMessageId,
telegramUpdateId: input.telegramUpdateId
})
}
if (rateLimit.lastAcceptedAt) {
const cooldownBoundary = now.getTime() - COOLDOWN_HOURS * 60 * 60 * 1000
if (rateLimit.lastAcceptedAt.getTime() > cooldownBoundary) {
return rejectSubmission(repository, {
memberId: member.id,
rawText: input.rawText,
reason: 'cooldown',
telegramChatId: input.telegramChatId,
telegramMessageId: input.telegramMessageId,
telegramUpdateId: input.telegramUpdateId
})
}
}
const created = await repository.createSubmission({
submittedByMemberId: member.id,
rawText: input.rawText,
sanitizedText,
moderationStatus: 'accepted',
moderationReason: null,
telegramChatId: input.telegramChatId,
telegramMessageId: input.telegramMessageId,
telegramUpdateId: input.telegramUpdateId
})
if (created.duplicate) {
return {
status: 'duplicate',
submissionId: created.submission.id
}
}
return {
status: 'accepted',
submissionId: created.submission.id,
sanitizedText
}
},
markPosted(input) {
return repository.markPosted({
submissionId: input.submissionId,
postedChatId: input.postedChatId,
postedThreadId: input.postedThreadId,
postedMessageId: input.postedMessageId,
postedAt: input.postedAt ?? new Date()
})
},
markFailed(submissionId, failureReason) {
return repository.markFailed(submissionId, failureReason)
}
}
}

View File

@@ -1,4 +1,9 @@
export { calculateMonthlySettlement } from './settlement-engine'
export {
createAnonymousFeedbackService,
type AnonymousFeedbackService,
type AnonymousFeedbackSubmitResult
} from './anonymous-feedback-service'
export { createFinanceCommandService, type FinanceCommandService } from './finance-command-service'
export {
createReminderJobService,

View File

@@ -0,0 +1,24 @@
CREATE TABLE "anonymous_messages" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"household_id" uuid NOT NULL,
"submitted_by_member_id" uuid NOT NULL,
"raw_text" text NOT NULL,
"sanitized_text" text,
"moderation_status" text NOT NULL,
"moderation_reason" text,
"telegram_chat_id" text NOT NULL,
"telegram_message_id" text NOT NULL,
"telegram_update_id" text NOT NULL,
"posted_chat_id" text,
"posted_thread_id" text,
"posted_message_id" text,
"failure_reason" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"posted_at" timestamp with time zone
);
--> statement-breakpoint
ALTER TABLE "anonymous_messages" ADD CONSTRAINT "anonymous_messages_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "anonymous_messages" ADD CONSTRAINT "anonymous_messages_submitted_by_member_id_members_id_fk" FOREIGN KEY ("submitted_by_member_id") REFERENCES "public"."members"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "anonymous_messages_household_tg_update_unique" ON "anonymous_messages" USING btree ("household_id","telegram_update_id");--> statement-breakpoint
CREATE INDEX "anonymous_messages_member_created_idx" ON "anonymous_messages" USING btree ("submitted_by_member_id","created_at");--> statement-breakpoint
CREATE INDEX "anonymous_messages_status_created_idx" ON "anonymous_messages" USING btree ("moderation_status","created_at");

File diff suppressed because it is too large Load Diff

View File

@@ -29,6 +29,13 @@
"when": 1772671128084,
"tag": "0003_mature_roulette",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1772995779819,
"tag": "0004_big_ultimatum",
"breakpoints": true
}
]
}

View File

@@ -247,6 +247,46 @@ export const processedBotMessages = pgTable(
})
)
export const anonymousMessages = pgTable(
'anonymous_messages',
{
id: uuid('id').defaultRandom().primaryKey(),
householdId: uuid('household_id')
.notNull()
.references(() => households.id, { onDelete: 'cascade' }),
submittedByMemberId: uuid('submitted_by_member_id')
.notNull()
.references(() => members.id, { onDelete: 'restrict' }),
rawText: text('raw_text').notNull(),
sanitizedText: text('sanitized_text'),
moderationStatus: text('moderation_status').notNull(),
moderationReason: text('moderation_reason'),
telegramChatId: text('telegram_chat_id').notNull(),
telegramMessageId: text('telegram_message_id').notNull(),
telegramUpdateId: text('telegram_update_id').notNull(),
postedChatId: text('posted_chat_id'),
postedThreadId: text('posted_thread_id'),
postedMessageId: text('posted_message_id'),
failureReason: text('failure_reason'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
postedAt: timestamp('posted_at', { withTimezone: true })
},
(table) => ({
householdUpdateUnique: uniqueIndex('anonymous_messages_household_tg_update_unique').on(
table.householdId,
table.telegramUpdateId
),
memberCreatedIdx: index('anonymous_messages_member_created_idx').on(
table.submittedByMemberId,
table.createdAt
),
statusCreatedIdx: index('anonymous_messages_status_created_idx').on(
table.moderationStatus,
table.createdAt
)
})
)
export const settlements = pgTable(
'settlements',
{
@@ -308,4 +348,5 @@ export type BillingCycle = typeof billingCycles.$inferSelect
export type UtilityBill = typeof utilityBills.$inferSelect
export type PurchaseEntry = typeof purchaseEntries.$inferSelect
export type PurchaseMessage = typeof purchaseMessages.$inferSelect
export type AnonymousMessage = typeof anonymousMessages.$inferSelect
export type Settlement = typeof settlements.$inferSelect

View File

@@ -0,0 +1,51 @@
export type AnonymousFeedbackModerationStatus = 'accepted' | 'posted' | 'rejected' | 'failed'
export type AnonymousFeedbackRejectionReason =
| 'not_member'
| 'too_short'
| 'too_long'
| 'cooldown'
| 'daily_cap'
| 'blocklisted'
export interface AnonymousFeedbackMemberRecord {
id: string
telegramUserId: string
displayName: string
}
export interface AnonymousFeedbackRateLimitSnapshot {
acceptedCountSince: number
lastAcceptedAt: Date | null
}
export interface AnonymousFeedbackSubmissionRecord {
id: string
moderationStatus: AnonymousFeedbackModerationStatus
}
export interface AnonymousFeedbackRepository {
getMemberByTelegramUserId(telegramUserId: string): Promise<AnonymousFeedbackMemberRecord | null>
getRateLimitSnapshot(
memberId: string,
acceptedSince: Date
): Promise<AnonymousFeedbackRateLimitSnapshot>
createSubmission(input: {
submittedByMemberId: string
rawText: string
sanitizedText: string | null
moderationStatus: AnonymousFeedbackModerationStatus
moderationReason: string | null
telegramChatId: string
telegramMessageId: string
telegramUpdateId: string
}): Promise<{ submission: AnonymousFeedbackSubmissionRecord; duplicate: boolean }>
markPosted(input: {
submissionId: string
postedChatId: string
postedThreadId: string
postedMessageId: string
postedAt: Date
}): Promise<void>
markFailed(submissionId: string, failureReason: string): Promise<void>
}

View File

@@ -5,6 +5,14 @@ export {
type ReminderDispatchRepository,
type ReminderType
} from './reminders'
export type {
AnonymousFeedbackMemberRecord,
AnonymousFeedbackModerationStatus,
AnonymousFeedbackRateLimitSnapshot,
AnonymousFeedbackRejectionReason,
AnonymousFeedbackRepository,
AnonymousFeedbackSubmissionRecord
} from './anonymous-feedback'
export type {
FinanceCycleRecord,
FinanceMemberRecord,