diff --git a/apps/bot/src/anonymous-feedback.test.ts b/apps/bot/src/anonymous-feedback.test.ts new file mode 100644 index 0000000..1be47a4 --- /dev/null +++ b/apps/bot/src/anonymous-feedback.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, mock, test } from 'bun:test' + +import type { AnonymousFeedbackService } from '@household/application' + +import { createTelegramBot } from './bot' +import { registerAnonymousFeedback } from './anonymous-feedback' + +function anonUpdate(params: { + updateId: number + chatType: 'private' | 'supergroup' + text: string +}) { + const commandToken = params.text.split(' ')[0] ?? params.text + + return { + update_id: params.updateId, + message: { + message_id: params.updateId, + date: Math.floor(Date.now() / 1000), + chat: { + id: params.chatType === 'private' ? 123456 : -100123456, + type: params.chatType + }, + from: { + id: 123456, + is_bot: false, + first_name: 'Stan' + }, + text: params.text, + entities: [ + { + offset: 0, + length: commandToken.length, + type: 'bot_command' + } + ] + } + } +} + +describe('registerAnonymousFeedback', () => { + test('posts accepted feedback into the configured topic', async () => { + const bot = createTelegramBot('000000:test-token') + const calls: Array<{ method: string; payload: unknown }> = [] + + bot.botInfo = { + id: 999000, + is_bot: true, + first_name: 'Household Test Bot', + username: 'household_test_bot', + can_join_groups: true, + can_read_all_group_messages: false, + supports_inline_queries: false, + can_connect_to_business: false, + has_main_web_app: false, + has_topics_enabled: true, + allows_users_to_create_topics: false + } + + bot.api.config.use(async (_prev, method, payload) => { + calls.push({ method, payload }) + + return { + ok: true, + result: { + message_id: calls.length, + date: Math.floor(Date.now() / 1000), + chat: { + id: 1, + type: 'private' + }, + text: 'ok' + } + } as never + }) + + const anonymousFeedbackService: AnonymousFeedbackService = { + submit: mock(async () => ({ + status: 'accepted' as const, + submissionId: 'submission-1', + sanitizedText: 'Please clean the kitchen tonight.' + })), + markPosted: mock(async () => {}), + markFailed: mock(async () => {}) + } + + registerAnonymousFeedback({ + bot, + anonymousFeedbackService, + householdChatId: '-100222333', + feedbackTopicId: 77 + }) + + await bot.handleUpdate( + anonUpdate({ + updateId: 1001, + chatType: 'private', + text: '/anon Please clean the kitchen tonight.' + }) as never + ) + + expect(calls).toHaveLength(2) + expect(calls[0]?.method).toBe('sendMessage') + expect(calls[0]?.payload).toMatchObject({ + chat_id: '-100222333', + message_thread_id: 77, + text: 'Anonymous household note\n\nPlease clean the kitchen tonight.' + }) + expect(calls[1]?.payload).toMatchObject({ + text: 'Anonymous feedback delivered.' + }) + }) + + test('rejects group usage and keeps feedback private', async () => { + const bot = createTelegramBot('000000:test-token') + const calls: Array<{ method: string; payload: unknown }> = [] + + bot.botInfo = { + id: 999000, + is_bot: true, + first_name: 'Household Test Bot', + username: 'household_test_bot', + can_join_groups: true, + can_read_all_group_messages: false, + supports_inline_queries: false, + can_connect_to_business: false, + has_main_web_app: false, + has_topics_enabled: true, + allows_users_to_create_topics: false + } + + bot.api.config.use(async (_prev, method, payload) => { + calls.push({ method, payload }) + + return { + ok: true, + result: { + message_id: calls.length, + date: Math.floor(Date.now() / 1000), + chat: { + id: -100123456, + type: 'supergroup' + }, + text: 'ok' + } + } as never + }) + + registerAnonymousFeedback({ + bot, + anonymousFeedbackService: { + submit: mock(async () => ({ + status: 'accepted' as const, + submissionId: 'submission-1', + sanitizedText: 'unused' + })), + markPosted: mock(async () => {}), + markFailed: mock(async () => {}) + }, + householdChatId: '-100222333', + feedbackTopicId: 77 + }) + + await bot.handleUpdate( + anonUpdate({ + updateId: 1002, + chatType: 'supergroup', + text: '/anon Please clean the kitchen tonight.' + }) as never + ) + + expect(calls).toHaveLength(1) + expect(calls[0]?.payload).toMatchObject({ + text: 'Use /anon in a private chat with the bot.' + }) + }) +}) diff --git a/apps/bot/src/anonymous-feedback.ts b/apps/bot/src/anonymous-feedback.ts new file mode 100644 index 0000000..fa5f298 --- /dev/null +++ b/apps/bot/src/anonymous-feedback.ts @@ -0,0 +1,101 @@ +import type { AnonymousFeedbackService } from '@household/application' +import type { Bot, Context } from 'grammy' + +function isPrivateChat(ctx: Context): boolean { + return ctx.chat?.type === 'private' +} + +function feedbackText(sanitizedText: string): string { + return ['Anonymous household note', '', sanitizedText].join('\n') +} + +function rejectionMessage(reason: string): string { + switch (reason) { + case 'not_member': + return 'You are not a member of this household.' + case 'too_short': + return 'Anonymous feedback is too short. Add a little more detail.' + case 'too_long': + return 'Anonymous feedback is too long. Keep it under 500 characters.' + case 'cooldown': + return 'Anonymous feedback cooldown is active. Try again later.' + case 'daily_cap': + return 'Daily anonymous feedback limit reached. Try again tomorrow.' + case 'blocklisted': + return 'Message rejected by moderation. Rewrite it in calmer, non-abusive language.' + default: + return 'Anonymous feedback could not be submitted.' + } +} + +export function registerAnonymousFeedback(options: { + bot: Bot + anonymousFeedbackService: AnonymousFeedbackService + householdChatId: string + feedbackTopicId: number +}): void { + options.bot.command('anon', async (ctx) => { + if (!isPrivateChat(ctx)) { + await ctx.reply('Use /anon in a private chat with the bot.') + return + } + + const rawText = typeof ctx.match === 'string' ? ctx.match.trim() : '' + if (rawText.length === 0) { + await ctx.reply('Usage: /anon ') + return + } + + const telegramUserId = ctx.from?.id?.toString() + const telegramChatId = ctx.chat?.id?.toString() + const telegramMessageId = ctx.msg?.message_id?.toString() + const telegramUpdateId = + 'update_id' in ctx.update ? ctx.update.update_id?.toString() : undefined + + if (!telegramUserId || !telegramChatId || !telegramMessageId || !telegramUpdateId) { + await ctx.reply('Unable to identify this message for anonymous feedback.') + return + } + + const result = await options.anonymousFeedbackService.submit({ + telegramUserId, + rawText, + telegramChatId, + telegramMessageId, + telegramUpdateId + }) + + if (result.status === 'duplicate') { + await ctx.reply('This anonymous feedback message was already processed.') + return + } + + if (result.status === 'rejected') { + await ctx.reply(rejectionMessage(result.reason)) + return + } + + try { + const posted = await ctx.api.sendMessage( + options.householdChatId, + feedbackText(result.sanitizedText), + { + message_thread_id: options.feedbackTopicId + } + ) + + await options.anonymousFeedbackService.markPosted({ + submissionId: result.submissionId, + postedChatId: options.householdChatId, + postedThreadId: options.feedbackTopicId.toString(), + postedMessageId: posted.message_id.toString() + }) + + await ctx.reply('Anonymous feedback delivered.') + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown Telegram send failure' + await options.anonymousFeedbackService.markFailed(result.submissionId, message) + await ctx.reply('Anonymous feedback was saved, but posting failed. Try again later.') + } + }) +} diff --git a/apps/bot/src/bot.ts b/apps/bot/src/bot.ts index 5b10144..8377090 100644 --- a/apps/bot/src/bot.ts +++ b/apps/bot/src/bot.ts @@ -9,7 +9,8 @@ export function createTelegramBot(token: string): Bot { 'Household bot scaffold is live.', 'Available commands:', '/help - Show command list', - '/household_status - Show placeholder household status' + '/household_status - Show placeholder household status', + '/anon - Send anonymous household feedback in a private chat' ].join('\n') ) }) diff --git a/apps/bot/src/config.ts b/apps/bot/src/config.ts index f399042..bed48f3 100644 --- a/apps/bot/src/config.ts +++ b/apps/bot/src/config.ts @@ -7,8 +7,10 @@ export interface BotRuntimeConfig { householdId?: string telegramHouseholdChatId?: string telegramPurchaseTopicId?: number + telegramFeedbackTopicId?: number purchaseTopicIngestionEnabled: boolean financeCommandsEnabled: boolean + anonymousFeedbackEnabled: boolean miniAppAllowedOrigins: readonly string[] miniAppAuthEnabled: boolean schedulerSharedSecret?: string @@ -46,7 +48,7 @@ function parseOptionalTopicId(raw: string | undefined): number | undefined { const parsed = Number(raw) if (!Number.isInteger(parsed) || parsed <= 0) { - throw new Error(`Invalid TELEGRAM_PURCHASE_TOPIC_ID value: ${raw}`) + throw new Error(`Invalid Telegram topic id value: ${raw}`) } return parsed @@ -75,6 +77,7 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu const householdId = parseOptionalValue(env.HOUSEHOLD_ID) const telegramHouseholdChatId = parseOptionalValue(env.TELEGRAM_HOUSEHOLD_CHAT_ID) const telegramPurchaseTopicId = parseOptionalTopicId(env.TELEGRAM_PURCHASE_TOPIC_ID) + const telegramFeedbackTopicId = parseOptionalTopicId(env.TELEGRAM_FEEDBACK_TOPIC_ID) const schedulerSharedSecret = parseOptionalValue(env.SCHEDULER_SHARED_SECRET) const schedulerOidcAllowedEmails = parseOptionalCsv(env.SCHEDULER_OIDC_ALLOWED_EMAILS) const miniAppAllowedOrigins = parseOptionalCsv(env.MINI_APP_ALLOWED_ORIGINS) @@ -86,6 +89,11 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu telegramPurchaseTopicId !== undefined const financeCommandsEnabled = databaseUrl !== undefined && householdId !== undefined + const anonymousFeedbackEnabled = + databaseUrl !== undefined && + householdId !== undefined && + telegramHouseholdChatId !== undefined && + telegramFeedbackTopicId !== undefined const miniAppAuthEnabled = databaseUrl !== undefined && householdId !== undefined const hasSchedulerOidcConfig = schedulerOidcAllowedEmails.length > 0 const reminderJobsEnabled = @@ -100,6 +108,7 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu telegramWebhookPath: env.TELEGRAM_WEBHOOK_PATH ?? '/webhook/telegram', purchaseTopicIngestionEnabled, financeCommandsEnabled, + anonymousFeedbackEnabled, miniAppAllowedOrigins, miniAppAuthEnabled, schedulerOidcAllowedEmails, @@ -119,6 +128,9 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu if (telegramPurchaseTopicId !== undefined) { runtime.telegramPurchaseTopicId = telegramPurchaseTopicId } + if (telegramFeedbackTopicId !== undefined) { + runtime.telegramFeedbackTopicId = telegramFeedbackTopicId + } if (schedulerSharedSecret !== undefined) { runtime.schedulerSharedSecret = schedulerSharedSecret } diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index 42702a1..334e184 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -1,11 +1,17 @@ import { webhookCallback } from 'grammy' -import { createFinanceCommandService, createReminderJobService } from '@household/application' import { + createAnonymousFeedbackService, + createFinanceCommandService, + createReminderJobService +} from '@household/application' +import { + createDbAnonymousFeedbackRepository, createDbFinanceRepository, createDbReminderDispatchRepository } from '@household/adapters-db' +import { registerAnonymousFeedback } from './anonymous-feedback' import { createFinanceCommandsService } from './finance-commands' import { createTelegramBot } from './bot' import { getBotRuntimeConfig } from './config' @@ -32,11 +38,21 @@ const financeRepositoryClient = const financeService = financeRepositoryClient ? createFinanceCommandService(financeRepositoryClient.repository) : null +const anonymousFeedbackRepositoryClient = runtime.anonymousFeedbackEnabled + ? createDbAnonymousFeedbackRepository(runtime.databaseUrl!, runtime.householdId!) + : null +const anonymousFeedbackService = anonymousFeedbackRepositoryClient + ? createAnonymousFeedbackService(anonymousFeedbackRepositoryClient.repository) + : null if (financeRepositoryClient) { shutdownTasks.push(financeRepositoryClient.close) } +if (anonymousFeedbackRepositoryClient) { + shutdownTasks.push(anonymousFeedbackRepositoryClient.close) +} + if (runtime.purchaseTopicIngestionEnabled) { const purchaseRepositoryClient = createPurchaseMessageRepository(runtime.databaseUrl!) shutdownTasks.push(purchaseRepositoryClient.close) @@ -90,6 +106,19 @@ if (!runtime.reminderJobsEnabled) { ) } +if (anonymousFeedbackService) { + registerAnonymousFeedback({ + bot, + anonymousFeedbackService, + householdChatId: runtime.telegramHouseholdChatId!, + feedbackTopicId: runtime.telegramFeedbackTopicId! + }) +} else { + console.warn( + 'Anonymous feedback is disabled. Set DATABASE_URL, HOUSEHOLD_ID, TELEGRAM_HOUSEHOLD_CHAT_ID, and TELEGRAM_FEEDBACK_TOPIC_ID to enable.' + ) +} + const server = createBotWebhookServer({ webhookPath: runtime.telegramWebhookPath, webhookSecret: runtime.telegramWebhookSecret, diff --git a/docs/specs/HOUSEBOT-050-anonymous-feedback-dm.md b/docs/specs/HOUSEBOT-050-anonymous-feedback-dm.md new file mode 100644 index 0000000..98e6d3e --- /dev/null +++ b/docs/specs/HOUSEBOT-050-anonymous-feedback-dm.md @@ -0,0 +1,80 @@ +# HOUSEBOT-050: Anonymous Feedback DM Flow + +## Summary + +Allow household members to send private `/anon` messages to the bot and have them reposted into a configured household topic without exposing the sender. + +## Goals + +- Keep sender identity hidden from the group. +- Enforce simple anti-abuse policy with cooldown, daily cap, and blocklist checks. +- Persist moderation and delivery metadata for audit without any reveal path. + +## Non-goals + +- Identity reveal tooling. +- LLM rewriting or sentiment analysis. +- Admin moderation UI. + +## Scope + +- In: DM command handling, persistence, reposting to topic, deterministic sanitization, policy enforcement. +- Out: anonymous reactions, editing or deleting previous posts. + +## Interfaces and Contracts + +- Telegram command: `/anon ` in private chat only +- Runtime config: + - `TELEGRAM_HOUSEHOLD_CHAT_ID` + - `TELEGRAM_FEEDBACK_TOPIC_ID` +- Persistence: + - `anonymous_messages` + +## Domain Rules + +- Sender identity is never included in the reposted group message. +- Cooldown is six hours between accepted submissions. +- Daily cap is three accepted submissions per member in a rolling 24-hour window. +- Blocklisted abusive phrases are rejected and recorded. +- Links, `@mentions`, and phone-like strings are sanitized before repost. + +## Data Model Changes + +- `anonymous_messages` + - household/member linkage + - raw text + - sanitized text + - moderation status and reason + - source Telegram message IDs + - posted Telegram message IDs + - failure reason and timestamps + +## Security and Privacy + +- Household membership is verified before accepting feedback. +- Group-facing text contains no sender identity or source metadata. +- Duplicate Telegram updates are deduplicated at persistence level. + +## Observability + +- Failed reposts are persisted with failure reasons. +- Moderation outcomes remain queryable in the database. + +## Edge Cases and Failure Modes + +- Command used outside DM is rejected. +- Duplicate webhook delivery does not repost. +- Telegram post failure marks the submission as failed without exposing the sender. + +## Test Plan + +- Unit: moderation, cooldown, and delivery state transitions. +- Bot tests: DM command path and private-chat enforcement. +- Integration: repo quality gates and migration generation. + +## Acceptance Criteria + +- [ ] DM to household topic repost works end-to-end. +- [ ] Sender identity is hidden from the reposted message. +- [ ] Cooldown, daily cap, and blocklist are enforced. +- [ ] Moderation and delivery metadata are persisted. diff --git a/infra/terraform/README.md b/infra/terraform/README.md index 0fa8bf3..d9ca677 100644 --- a/infra/terraform/README.md +++ b/infra/terraform/README.md @@ -72,6 +72,7 @@ Recommended approach: - `bot_household_id` - `bot_household_chat_id` - `bot_purchase_topic_id` + - optional `bot_feedback_topic_id` - optional `bot_parser_model` - optional `bot_mini_app_allowed_origins` diff --git a/infra/terraform/main.tf b/infra/terraform/main.tf index 61b932c..776689f 100644 --- a/infra/terraform/main.tf +++ b/infra/terraform/main.tf @@ -90,6 +90,9 @@ module "bot_api_service" { var.bot_purchase_topic_id == null ? {} : { TELEGRAM_PURCHASE_TOPIC_ID = tostring(var.bot_purchase_topic_id) }, + var.bot_feedback_topic_id == null ? {} : { + TELEGRAM_FEEDBACK_TOPIC_ID = tostring(var.bot_feedback_topic_id) + }, var.bot_parser_model == null ? {} : { PARSER_MODEL = var.bot_parser_model }, diff --git a/infra/terraform/terraform.tfvars.example b/infra/terraform/terraform.tfvars.example index 0ac34c4..5a7ae5b 100644 --- a/infra/terraform/terraform.tfvars.example +++ b/infra/terraform/terraform.tfvars.example @@ -11,6 +11,7 @@ mini_app_image = "europe-west1-docker.pkg.dev/my-gcp-project/household-bot/mini bot_household_id = "11111111-1111-4111-8111-111111111111" bot_household_chat_id = "-1001234567890" bot_purchase_topic_id = 777 +bot_feedback_topic_id = 778 bot_parser_model = "gpt-4.1-mini" bot_mini_app_allowed_origins = [ "https://household-dev-mini-app-abc123-ew.a.run.app" diff --git a/infra/terraform/variables.tf b/infra/terraform/variables.tf index c54b679..f531b65 100644 --- a/infra/terraform/variables.tf +++ b/infra/terraform/variables.tf @@ -104,6 +104,13 @@ variable "bot_purchase_topic_id" { nullable = true } +variable "bot_feedback_topic_id" { + description = "Optional TELEGRAM_FEEDBACK_TOPIC_ID value for bot runtime" + type = number + default = null + nullable = true +} + variable "bot_parser_model" { description = "Optional PARSER_MODEL override for bot runtime" type = string diff --git a/packages/adapters-db/src/anonymous-feedback-repository.ts b/packages/adapters-db/src/anonymous-feedback-repository.ts new file mode 100644 index 0000000..07e54d6 --- /dev/null +++ b/packages/adapters-db/src/anonymous-feedback-repository.ts @@ -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 +} { + 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`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 }) + } + } +} diff --git a/packages/adapters-db/src/index.ts b/packages/adapters-db/src/index.ts index bc08a9b..7bc3377 100644 --- a/packages/adapters-db/src/index.ts +++ b/packages/adapters-db/src/index.ts @@ -1,2 +1,3 @@ +export { createDbAnonymousFeedbackRepository } from './anonymous-feedback-repository' export { createDbFinanceRepository } from './finance-repository' export { createDbReminderDispatchRepository } from './reminder-dispatch-repository' diff --git a/packages/application/src/anonymous-feedback-service.test.ts b/packages/application/src/anonymous-feedback-service.test.ts new file mode 100644 index 0000000..c7aad24 --- /dev/null +++ b/packages/application/src/anonymous-feedback-service.test.ts @@ -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' + } + ]) + }) +}) diff --git a/packages/application/src/anonymous-feedback-service.ts b/packages/application/src/anonymous-feedback-service.ts new file mode 100644 index 0000000..8ba7ea1 --- /dev/null +++ b/packages/application/src/anonymous-feedback-service.ts @@ -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 + markPosted(input: { + submissionId: string + postedChatId: string + postedThreadId: string + postedMessageId: string + postedAt?: Date + }): Promise + markFailed(submissionId: string, failureReason: string): Promise +} + +async function rejectSubmission( + repository: AnonymousFeedbackRepository, + input: { + memberId: string + rawText: string + reason: AnonymousFeedbackRejectionReason + detail?: string + telegramChatId: string + telegramMessageId: string + telegramUpdateId: string + } +): Promise { + 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) + } + } +} diff --git a/packages/application/src/index.ts b/packages/application/src/index.ts index ffb204b..6430490 100644 --- a/packages/application/src/index.ts +++ b/packages/application/src/index.ts @@ -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, diff --git a/packages/db/drizzle/0004_big_ultimatum.sql b/packages/db/drizzle/0004_big_ultimatum.sql new file mode 100644 index 0000000..7cf7451 --- /dev/null +++ b/packages/db/drizzle/0004_big_ultimatum.sql @@ -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"); \ No newline at end of file diff --git a/packages/db/drizzle/meta/0004_snapshot.json b/packages/db/drizzle/meta/0004_snapshot.json new file mode 100644 index 0000000..d571b31 --- /dev/null +++ b/packages/db/drizzle/meta/0004_snapshot.json @@ -0,0 +1,1587 @@ +{ + "id": "49fee72d-1d0d-4f6e-a74f-bc4f0cc15270", + "prevId": "67e9eddc-9734-443e-a731-8a63cbf49145", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.anonymous_messages": { + "name": "anonymous_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "submitted_by_member_id": { + "name": "submitted_by_member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "raw_text": { + "name": "raw_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sanitized_text": { + "name": "sanitized_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "moderation_status": { + "name": "moderation_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "moderation_reason": { + "name": "moderation_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_message_id": { + "name": "telegram_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_update_id": { + "name": "telegram_update_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "posted_chat_id": { + "name": "posted_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "posted_thread_id": { + "name": "posted_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "posted_message_id": { + "name": "posted_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "posted_at": { + "name": "posted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "anonymous_messages_household_tg_update_unique": { + "name": "anonymous_messages_household_tg_update_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_update_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "anonymous_messages_member_created_idx": { + "name": "anonymous_messages_member_created_idx", + "columns": [ + { + "expression": "submitted_by_member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "anonymous_messages_status_created_idx": { + "name": "anonymous_messages_status_created_idx", + "columns": [ + { + "expression": "moderation_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "anonymous_messages_household_id_households_id_fk": { + "name": "anonymous_messages_household_id_households_id_fk", + "tableFrom": "anonymous_messages", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "anonymous_messages_submitted_by_member_id_members_id_fk": { + "name": "anonymous_messages_submitted_by_member_id_members_id_fk", + "tableFrom": "anonymous_messages", + "tableTo": "members", + "columnsFrom": ["submitted_by_member_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing_cycles": { + "name": "billing_cycles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "period": { + "name": "period", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "billing_cycles_household_period_unique": { + "name": "billing_cycles_household_period_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_cycles_household_period_idx": { + "name": "billing_cycles_household_period_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "billing_cycles_household_id_households_id_fk": { + "name": "billing_cycles_household_id_households_id_fk", + "tableFrom": "billing_cycles", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.households": { + "name": "households", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.members": { + "name": "members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "telegram_user_id": { + "name": "telegram_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_admin": { + "name": "is_admin", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "members_household_idx": { + "name": "members_household_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "members_household_tg_user_unique": { + "name": "members_household_tg_user_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "members_household_id_households_id_fk": { + "name": "members_household_id_households_id_fk", + "tableFrom": "members", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.presence_overrides": { + "name": "presence_overrides", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "cycle_id": { + "name": "cycle_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "member_id": { + "name": "member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "utility_days": { + "name": "utility_days", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "presence_overrides_cycle_member_unique": { + "name": "presence_overrides_cycle_member_unique", + "columns": [ + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "presence_overrides_cycle_idx": { + "name": "presence_overrides_cycle_idx", + "columns": [ + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "presence_overrides_cycle_id_billing_cycles_id_fk": { + "name": "presence_overrides_cycle_id_billing_cycles_id_fk", + "tableFrom": "presence_overrides", + "tableTo": "billing_cycles", + "columnsFrom": ["cycle_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "presence_overrides_member_id_members_id_fk": { + "name": "presence_overrides_member_id_members_id_fk", + "tableFrom": "presence_overrides", + "tableTo": "members", + "columnsFrom": ["member_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.processed_bot_messages": { + "name": "processed_bot_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_message_key": { + "name": "source_message_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload_hash": { + "name": "payload_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "processed_bot_messages_source_message_unique": { + "name": "processed_bot_messages_source_message_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_message_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "processed_bot_messages_household_id_households_id_fk": { + "name": "processed_bot_messages_household_id_households_id_fk", + "tableFrom": "processed_bot_messages", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.purchase_entries": { + "name": "purchase_entries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cycle_id": { + "name": "cycle_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "payer_member_id": { + "name": "payer_member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "amount_minor": { + "name": "amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "raw_text": { + "name": "raw_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_text": { + "name": "normalized_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parser_mode": { + "name": "parser_mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parser_confidence": { + "name": "parser_confidence", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telegram_message_id": { + "name": "telegram_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telegram_thread_id": { + "name": "telegram_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message_sent_at": { + "name": "message_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "purchase_entries_household_cycle_idx": { + "name": "purchase_entries_household_cycle_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "purchase_entries_payer_idx": { + "name": "purchase_entries_payer_idx", + "columns": [ + { + "expression": "payer_member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "purchase_entries_household_tg_message_unique": { + "name": "purchase_entries_household_tg_message_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "purchase_entries_household_id_households_id_fk": { + "name": "purchase_entries_household_id_households_id_fk", + "tableFrom": "purchase_entries", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "purchase_entries_cycle_id_billing_cycles_id_fk": { + "name": "purchase_entries_cycle_id_billing_cycles_id_fk", + "tableFrom": "purchase_entries", + "tableTo": "billing_cycles", + "columnsFrom": ["cycle_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "purchase_entries_payer_member_id_members_id_fk": { + "name": "purchase_entries_payer_member_id_members_id_fk", + "tableFrom": "purchase_entries", + "tableTo": "members", + "columnsFrom": ["payer_member_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.purchase_messages": { + "name": "purchase_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sender_member_id": { + "name": "sender_member_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "sender_telegram_user_id": { + "name": "sender_telegram_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sender_display_name": { + "name": "sender_display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw_text": { + "name": "raw_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_message_id": { + "name": "telegram_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_thread_id": { + "name": "telegram_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_update_id": { + "name": "telegram_update_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_sent_at": { + "name": "message_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "parsed_amount_minor": { + "name": "parsed_amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "parsed_currency": { + "name": "parsed_currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parsed_item_description": { + "name": "parsed_item_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parser_mode": { + "name": "parser_mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parser_confidence": { + "name": "parser_confidence", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "needs_review": { + "name": "needs_review", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "parser_error": { + "name": "parser_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "ingested_at": { + "name": "ingested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "purchase_messages_household_thread_idx": { + "name": "purchase_messages_household_thread_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "purchase_messages_sender_idx": { + "name": "purchase_messages_sender_idx", + "columns": [ + { + "expression": "sender_telegram_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "purchase_messages_household_tg_message_unique": { + "name": "purchase_messages_household_tg_message_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "purchase_messages_household_tg_update_unique": { + "name": "purchase_messages_household_tg_update_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_update_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "purchase_messages_household_id_households_id_fk": { + "name": "purchase_messages_household_id_households_id_fk", + "tableFrom": "purchase_messages", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "purchase_messages_sender_member_id_members_id_fk": { + "name": "purchase_messages_sender_member_id_members_id_fk", + "tableFrom": "purchase_messages", + "tableTo": "members", + "columnsFrom": ["sender_member_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rent_rules": { + "name": "rent_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "amount_minor": { + "name": "amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effective_from_period": { + "name": "effective_from_period", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effective_to_period": { + "name": "effective_to_period", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "rent_rules_household_from_period_unique": { + "name": "rent_rules_household_from_period_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "effective_from_period", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rent_rules_household_from_period_idx": { + "name": "rent_rules_household_from_period_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "effective_from_period", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rent_rules_household_id_households_id_fk": { + "name": "rent_rules_household_id_households_id_fk", + "tableFrom": "rent_rules", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settlement_lines": { + "name": "settlement_lines", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "settlement_id": { + "name": "settlement_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "member_id": { + "name": "member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "rent_share_minor": { + "name": "rent_share_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "utility_share_minor": { + "name": "utility_share_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "purchase_offset_minor": { + "name": "purchase_offset_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "net_due_minor": { + "name": "net_due_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "explanations": { + "name": "explanations", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "settlement_lines_settlement_member_unique": { + "name": "settlement_lines_settlement_member_unique", + "columns": [ + { + "expression": "settlement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "settlement_lines_settlement_idx": { + "name": "settlement_lines_settlement_idx", + "columns": [ + { + "expression": "settlement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "settlement_lines_settlement_id_settlements_id_fk": { + "name": "settlement_lines_settlement_id_settlements_id_fk", + "tableFrom": "settlement_lines", + "tableTo": "settlements", + "columnsFrom": ["settlement_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "settlement_lines_member_id_members_id_fk": { + "name": "settlement_lines_member_id_members_id_fk", + "tableFrom": "settlement_lines", + "tableTo": "members", + "columnsFrom": ["member_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settlements": { + "name": "settlements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cycle_id": { + "name": "cycle_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "input_hash": { + "name": "input_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_due_minor": { + "name": "total_due_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "computed_at": { + "name": "computed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "settlements_cycle_unique": { + "name": "settlements_cycle_unique", + "columns": [ + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "settlements_household_computed_idx": { + "name": "settlements_household_computed_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "computed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "settlements_household_id_households_id_fk": { + "name": "settlements_household_id_households_id_fk", + "tableFrom": "settlements", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "settlements_cycle_id_billing_cycles_id_fk": { + "name": "settlements_cycle_id_billing_cycles_id_fk", + "tableFrom": "settlements", + "tableTo": "billing_cycles", + "columnsFrom": ["cycle_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.utility_bills": { + "name": "utility_bills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cycle_id": { + "name": "cycle_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "bill_name": { + "name": "bill_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount_minor": { + "name": "amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "due_date": { + "name": "due_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "created_by_member_id": { + "name": "created_by_member_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "utility_bills_cycle_idx": { + "name": "utility_bills_cycle_idx", + "columns": [ + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "utility_bills_household_cycle_idx": { + "name": "utility_bills_household_cycle_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "utility_bills_household_id_households_id_fk": { + "name": "utility_bills_household_id_households_id_fk", + "tableFrom": "utility_bills", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "utility_bills_cycle_id_billing_cycles_id_fk": { + "name": "utility_bills_cycle_id_billing_cycles_id_fk", + "tableFrom": "utility_bills", + "tableTo": "billing_cycles", + "columnsFrom": ["cycle_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "utility_bills_created_by_member_id_members_id_fk": { + "name": "utility_bills_created_by_member_id_members_id_fk", + "tableFrom": "utility_bills", + "tableTo": "members", + "columnsFrom": ["created_by_member_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 7bbefc1..65b03ec 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1772671128084, "tag": "0003_mature_roulette", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1772995779819, + "tag": "0004_big_ultimatum", + "breakpoints": true } ] } diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 9439eb6..4f2d645 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -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 diff --git a/packages/ports/src/anonymous-feedback.ts b/packages/ports/src/anonymous-feedback.ts new file mode 100644 index 0000000..0805a2d --- /dev/null +++ b/packages/ports/src/anonymous-feedback.ts @@ -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 + getRateLimitSnapshot( + memberId: string, + acceptedSince: Date + ): Promise + 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 + markFailed(submissionId: string, failureReason: string): Promise +} diff --git a/packages/ports/src/index.ts b/packages/ports/src/index.ts index 6ec77f4..f793c4a 100644 --- a/packages/ports/src/index.ts +++ b/packages/ports/src/index.ts @@ -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,