mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 15:54:03 +00:00
feat(bot): add anonymous feedback flow
This commit is contained in:
171
packages/adapters-db/src/anonymous-feedback-repository.ts
Normal file
171
packages/adapters-db/src/anonymous-feedback-repository.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export { createDbAnonymousFeedbackRepository } from './anonymous-feedback-repository'
|
||||
export { createDbFinanceRepository } from './finance-repository'
|
||||
export { createDbReminderDispatchRepository } from './reminder-dispatch-repository'
|
||||
|
||||
217
packages/application/src/anonymous-feedback-service.test.ts
Normal file
217
packages/application/src/anonymous-feedback-service.test.ts
Normal 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'
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
223
packages/application/src/anonymous-feedback-service.ts
Normal file
223
packages/application/src/anonymous-feedback-service.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
24
packages/db/drizzle/0004_big_ultimatum.sql
Normal file
24
packages/db/drizzle/0004_big_ultimatum.sql
Normal 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");
|
||||
1587
packages/db/drizzle/meta/0004_snapshot.json
Normal file
1587
packages/db/drizzle/meta/0004_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,13 @@
|
||||
"when": 1772671128084,
|
||||
"tag": "0003_mature_roulette",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "7",
|
||||
"when": 1772995779819,
|
||||
"tag": "0004_big_ultimatum",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
51
packages/ports/src/anonymous-feedback.ts
Normal file
51
packages/ports/src/anonymous-feedback.ts
Normal 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>
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user