diff --git a/apps/bot/src/anonymous-feedback.test.ts b/apps/bot/src/anonymous-feedback.test.ts index 1be47a4..3f7ada6 100644 --- a/apps/bot/src/anonymous-feedback.test.ts +++ b/apps/bot/src/anonymous-feedback.test.ts @@ -1,6 +1,7 @@ import { describe, expect, mock, test } from 'bun:test' import type { AnonymousFeedbackService } from '@household/application' +import type { TelegramPendingActionRepository } from '@household/ports' import { createTelegramBot } from './bot' import { registerAnonymousFeedback } from './anonymous-feedback' @@ -38,6 +39,43 @@ function anonUpdate(params: { } } +function createPromptRepository(): TelegramPendingActionRepository { + const store = new Map() + + return { + async upsertPendingAction(input) { + store.set(`${input.telegramChatId}:${input.telegramUserId}`, { + action: input.action, + expiresAt: input.expiresAt + }) + return input + }, + async getPendingAction(telegramChatId, telegramUserId) { + const key = `${telegramChatId}:${telegramUserId}` + const record = store.get(key) + if (!record) { + return null + } + + if (record.expiresAt && record.expiresAt.getTime() <= Date.now()) { + store.delete(key) + return null + } + + return { + telegramChatId, + telegramUserId, + action: record.action, + payload: {}, + expiresAt: record.expiresAt + } + }, + async clearPendingAction(telegramChatId, telegramUserId) { + store.delete(`${telegramChatId}:${telegramUserId}`) + } + } +} + describe('registerAnonymousFeedback', () => { test('posts accepted feedback into the configured topic', async () => { const bot = createTelegramBot('000000:test-token') @@ -87,6 +125,7 @@ describe('registerAnonymousFeedback', () => { registerAnonymousFeedback({ bot, anonymousFeedbackService, + promptRepository: createPromptRepository(), householdChatId: '-100222333', feedbackTopicId: 77 }) @@ -157,6 +196,7 @@ describe('registerAnonymousFeedback', () => { markPosted: mock(async () => {}), markFailed: mock(async () => {}) }, + promptRepository: createPromptRepository(), householdChatId: '-100222333', feedbackTopicId: 77 }) @@ -174,4 +214,184 @@ describe('registerAnonymousFeedback', () => { text: 'Use /anon in a private chat with the bot.' }) }) + + test('prompts for the next DM message when /anon has no body', 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 submit = mock(async () => ({ + status: 'accepted' as const, + submissionId: 'submission-1', + sanitizedText: 'Please clean the kitchen tonight.' + })) + + registerAnonymousFeedback({ + bot, + anonymousFeedbackService: { + submit, + markPosted: mock(async () => {}), + markFailed: mock(async () => {}) + }, + promptRepository: createPromptRepository(), + householdChatId: '-100222333', + feedbackTopicId: 77 + }) + + await bot.handleUpdate( + anonUpdate({ + updateId: 1003, + chatType: 'private', + text: '/anon' + }) as never + ) + + await bot.handleUpdate( + anonUpdate({ + updateId: 1004, + chatType: 'private', + text: 'Please clean the kitchen tonight.' + }) as never + ) + + expect(submit).toHaveBeenCalledTimes(1) + expect(calls[0]?.payload).toMatchObject({ + text: 'Send me the anonymous message in your next reply, or tap Cancel.' + }) + expect(calls[1]?.payload).toMatchObject({ + chat_id: '-100222333', + message_thread_id: 77, + text: 'Anonymous household note\n\nPlease clean the kitchen tonight.' + }) + expect(calls[2]?.payload).toMatchObject({ + text: 'Anonymous feedback delivered.' + }) + }) + + test('cancels the pending anonymous feedback prompt', 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 submit = mock(async () => ({ + status: 'accepted' as const, + submissionId: 'submission-1', + sanitizedText: 'Please clean the kitchen tonight.' + })) + + registerAnonymousFeedback({ + bot, + anonymousFeedbackService: { + submit, + markPosted: mock(async () => {}), + markFailed: mock(async () => {}) + }, + promptRepository: createPromptRepository(), + householdChatId: '-100222333', + feedbackTopicId: 77 + }) + + await bot.handleUpdate( + anonUpdate({ + updateId: 1005, + chatType: 'private', + text: '/anon' + }) as never + ) + + await bot.handleUpdate({ + update_id: 1006, + callback_query: { + id: 'callback-1', + from: { + id: 123456, + is_bot: false, + first_name: 'Stan' + }, + chat_instance: 'chat-instance', + message: { + message_id: 1005, + date: Math.floor(Date.now() / 1000), + chat: { + id: 123456, + type: 'private' + }, + text: 'Send me the anonymous message in your next reply, or tap Cancel.' + }, + data: 'cancel_prompt:anonymous_feedback' + } + } as never) + + await bot.handleUpdate( + anonUpdate({ + updateId: 1007, + chatType: 'private', + text: 'Please clean the kitchen tonight.' + }) as never + ) + + expect(submit).toHaveBeenCalledTimes(0) + expect(calls[1]?.method).toBe('answerCallbackQuery') + expect(calls[2]?.method).toBe('editMessageText') + }) }) diff --git a/apps/bot/src/anonymous-feedback.ts b/apps/bot/src/anonymous-feedback.ts index eb75dcb..c6e631c 100644 --- a/apps/bot/src/anonymous-feedback.ts +++ b/apps/bot/src/anonymous-feedback.ts @@ -1,15 +1,45 @@ import type { AnonymousFeedbackService } from '@household/application' import type { Logger } from '@household/observability' +import type { TelegramPendingActionRepository } from '@household/ports' import type { Bot, Context } from 'grammy' +const ANONYMOUS_FEEDBACK_ACTION = 'anonymous_feedback' as const +const CANCEL_ANONYMOUS_FEEDBACK_CALLBACK = 'cancel_prompt:anonymous_feedback' +const PENDING_ACTION_TTL_MS = 24 * 60 * 60 * 1000 + function isPrivateChat(ctx: Context): boolean { return ctx.chat?.type === 'private' } +function commandArgText(ctx: Context): string { + return typeof ctx.match === 'string' ? ctx.match.trim() : '' +} + function feedbackText(sanitizedText: string): string { return ['Anonymous household note', '', sanitizedText].join('\n') } +function cancelReplyMarkup() { + return { + inline_keyboard: [ + [ + { + text: 'Cancel', + callback_data: CANCEL_ANONYMOUS_FEEDBACK_CALLBACK + } + ] + ] + } +} + +function isCommandMessage(ctx: Context): boolean { + return typeof ctx.msg?.text === 'string' && ctx.msg.text.trim().startsWith('/') +} + +function shouldKeepPrompt(reason: string): boolean { + return reason === 'too_short' || reason === 'too_long' || reason === 'blocklisted' +} + function rejectionMessage(reason: string): string { switch (reason) { case 'not_member': @@ -29,85 +59,231 @@ function rejectionMessage(reason: string): string { } } +async function clearPendingAnonymousFeedbackPrompt( + repository: TelegramPendingActionRepository, + ctx: Context +): Promise { + const telegramUserId = ctx.from?.id?.toString() + const telegramChatId = ctx.chat?.id?.toString() + if (!telegramUserId || !telegramChatId) { + return + } + + await repository.clearPendingAction(telegramChatId, telegramUserId) +} + +async function startPendingAnonymousFeedbackPrompt( + repository: TelegramPendingActionRepository, + ctx: Context +): Promise { + const telegramUserId = ctx.from?.id?.toString() + const telegramChatId = ctx.chat?.id?.toString() + if (!telegramUserId || !telegramChatId) { + await ctx.reply('Unable to start anonymous feedback right now.') + return + } + + await repository.upsertPendingAction({ + telegramUserId, + telegramChatId, + action: ANONYMOUS_FEEDBACK_ACTION, + payload: {}, + expiresAt: new Date(Date.now() + PENDING_ACTION_TTL_MS) + }) + + await ctx.reply('Send me the anonymous message in your next reply, or tap Cancel.', { + reply_markup: cancelReplyMarkup() + }) +} + +async function submitAnonymousFeedback(options: { + ctx: Context + anonymousFeedbackService: AnonymousFeedbackService + promptRepository: TelegramPendingActionRepository + householdChatId: string + feedbackTopicId: number + logger?: Logger | undefined + rawText: string + keepPromptOnValidationFailure?: boolean +}): Promise { + const telegramUserId = options.ctx.from?.id?.toString() + const telegramChatId = options.ctx.chat?.id?.toString() + const telegramMessageId = options.ctx.msg?.message_id?.toString() + const telegramUpdateId = + 'update_id' in options.ctx.update ? options.ctx.update.update_id?.toString() : undefined + + if (!telegramUserId || !telegramChatId || !telegramMessageId || !telegramUpdateId) { + await options.ctx.reply('Unable to identify this message for anonymous feedback.') + return + } + + const result = await options.anonymousFeedbackService.submit({ + telegramUserId, + rawText: options.rawText, + telegramChatId, + telegramMessageId, + telegramUpdateId + }) + + if (result.status === 'duplicate') { + await options.promptRepository.clearPendingAction(telegramChatId, telegramUserId) + await options.ctx.reply('This anonymous feedback message was already processed.') + return + } + + if (result.status === 'rejected') { + if (!options.keepPromptOnValidationFailure || !shouldKeepPrompt(result.reason)) { + await options.promptRepository.clearPendingAction(telegramChatId, telegramUserId) + } + + await options.ctx.reply( + shouldKeepPrompt(result.reason) + ? `${rejectionMessage(result.reason)} Send a revised message, or tap Cancel.` + : rejectionMessage(result.reason), + shouldKeepPrompt(result.reason) + ? { + reply_markup: cancelReplyMarkup() + } + : {} + ) + return + } + + try { + const posted = await options.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 options.promptRepository.clearPendingAction(telegramChatId, telegramUserId) + await options.ctx.reply('Anonymous feedback delivered.') + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown Telegram send failure' + options.logger?.error( + { + event: 'anonymous_feedback.post_failed', + submissionId: result.submissionId, + householdChatId: options.householdChatId, + feedbackTopicId: options.feedbackTopicId, + error: message + }, + 'Anonymous feedback posting failed' + ) + await options.anonymousFeedbackService.markFailed(result.submissionId, message) + await options.ctx.reply('Anonymous feedback was saved, but posting failed. Try again later.') + } +} + export function registerAnonymousFeedback(options: { bot: Bot anonymousFeedbackService: AnonymousFeedbackService + promptRepository: TelegramPendingActionRepository householdChatId: string feedbackTopicId: number logger?: Logger }): void { + options.bot.command('cancel', async (ctx) => { + if (!isPrivateChat(ctx)) { + return + } + + const telegramUserId = ctx.from?.id?.toString() + const telegramChatId = ctx.chat?.id?.toString() + if (!telegramUserId || !telegramChatId) { + await ctx.reply('Nothing to cancel right now.') + return + } + + const pending = await options.promptRepository.getPendingAction(telegramChatId, telegramUserId) + if (!pending) { + await ctx.reply('Nothing to cancel right now.') + return + } + + await options.promptRepository.clearPendingAction(telegramChatId, telegramUserId) + await ctx.reply('Cancelled.') + }) + 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() : '' + const rawText = commandArgText(ctx) if (rawText.length === 0) { - await ctx.reply('Usage: /anon ') + await startPendingAnonymousFeedbackPrompt(options.promptRepository, ctx) + return + } + + await submitAnonymousFeedback({ + ctx, + anonymousFeedbackService: options.anonymousFeedbackService, + promptRepository: options.promptRepository, + householdChatId: options.householdChatId, + feedbackTopicId: options.feedbackTopicId, + logger: options.logger, + rawText + }) + }) + + options.bot.on('message:text', async (ctx, next) => { + if (!isPrivateChat(ctx) || isCommandMessage(ctx)) { + await next() 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.') + if (!telegramUserId || !telegramChatId) { + await next() return } - const result = await options.anonymousFeedbackService.submit({ - telegramUserId, - rawText, - telegramChatId, - telegramMessageId, - telegramUpdateId + const pending = await options.promptRepository.getPendingAction(telegramChatId, telegramUserId) + if (!pending || pending.action !== ANONYMOUS_FEEDBACK_ACTION) { + await next() + return + } + + await submitAnonymousFeedback({ + ctx, + anonymousFeedbackService: options.anonymousFeedbackService, + promptRepository: options.promptRepository, + householdChatId: options.householdChatId, + feedbackTopicId: options.feedbackTopicId, + logger: options.logger, + rawText: ctx.msg.text, + keepPromptOnValidationFailure: true + }) + }) + + options.bot.callbackQuery(CANCEL_ANONYMOUS_FEEDBACK_CALLBACK, async (ctx) => { + if (!isPrivateChat(ctx)) { + await ctx.answerCallbackQuery({ + text: 'Use this in a private chat with the bot.', + show_alert: true + }) + return + } + + await clearPendingAnonymousFeedbackPrompt(options.promptRepository, ctx) + await ctx.answerCallbackQuery({ + text: 'Cancelled.' }) - 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' - options.logger?.error( - { - event: 'anonymous_feedback.post_failed', - submissionId: result.submissionId, - householdChatId: options.householdChatId, - feedbackTopicId: options.feedbackTopicId, - error: message - }, - 'Anonymous feedback posting failed' - ) - await options.anonymousFeedbackService.markFailed(result.submissionId, message) - await ctx.reply('Anonymous feedback was saved, but posting failed. Try again later.') + if (ctx.msg) { + await ctx.editMessageText('Anonymous feedback cancelled.') } }) } diff --git a/apps/bot/src/household-setup.ts b/apps/bot/src/household-setup.ts index cd9ca1b..40dd246 100644 --- a/apps/bot/src/household-setup.ts +++ b/apps/bot/src/household-setup.ts @@ -6,6 +6,8 @@ import type { import type { Logger } from '@household/observability' import type { Bot, Context } from 'grammy' +const APPROVE_MEMBER_CALLBACK_PREFIX = 'approve_member:' + function commandArgText(ctx: Context): string { return typeof ctx.match === 'string' ? ctx.match.trim() : '' } @@ -73,6 +75,43 @@ function actorDisplayName(ctx: Context): string | undefined { return fullName || ctx.from?.username?.trim() || undefined } +function buildPendingMemberLabel(displayName: string): string { + const normalized = displayName.trim().replaceAll(/\s+/g, ' ') + if (normalized.length <= 32) { + return normalized + } + + return `${normalized.slice(0, 29)}...` +} + +function pendingMembersReply(result: { + householdName: string + members: readonly { + telegramUserId: string + displayName: string + username?: string | null + }[] +}) { + return { + text: [ + `Pending members for ${result.householdName}:`, + ...result.members.map( + (member, index) => + `${index + 1}. ${member.displayName} (${member.telegramUserId})${member.username ? ` @${member.username}` : ''}` + ), + 'Tap a button below to approve, or use /approve_member .' + ].join('\n'), + reply_markup: { + inline_keyboard: result.members.map((member) => [ + { + text: `Approve ${buildPendingMemberLabel(member.displayName)}`, + callback_data: `${APPROVE_MEMBER_CALLBACK_PREFIX}${member.telegramUserId}` + } + ]) + } + } as const +} + export function registerHouseholdSetupCommands(options: { bot: Bot householdSetupService: HouseholdSetupService @@ -335,16 +374,10 @@ export function registerHouseholdSetupCommands(options: { return } - await ctx.reply( - [ - `Pending members for ${result.householdName}:`, - ...result.members.map( - (member, index) => - `${index + 1}. ${member.displayName} (${member.telegramUserId})${member.username ? ` @${member.username}` : ''}` - ), - 'Approve with /approve_member .' - ].join('\n') - ) + const reply = pendingMembersReply(result) + await ctx.reply(reply.text, { + reply_markup: reply.reply_markup + }) }) options.bot.command('approve_member', async (ctx) => { @@ -380,4 +413,67 @@ export function registerHouseholdSetupCommands(options: { `Approved ${result.member.displayName} as an active member of ${result.householdName}.` ) }) + + options.bot.callbackQuery( + new RegExp(`^${APPROVE_MEMBER_CALLBACK_PREFIX}(\\d+)$`), + async (ctx) => { + if (!isGroupChat(ctx)) { + await ctx.answerCallbackQuery({ + text: 'Use this button in the household group.', + show_alert: true + }) + return + } + + const actorTelegramUserId = ctx.from?.id?.toString() + const pendingTelegramUserId = ctx.match[1] + if (!actorTelegramUserId || !pendingTelegramUserId) { + await ctx.answerCallbackQuery({ + text: 'Unable to identify the selected member.', + show_alert: true + }) + return + } + + const result = await options.householdAdminService.approvePendingMember({ + actorTelegramUserId, + telegramChatId: ctx.chat.id.toString(), + pendingTelegramUserId + }) + + if (result.status === 'rejected') { + await ctx.answerCallbackQuery({ + text: adminRejectionMessage(result.reason), + show_alert: true + }) + return + } + + await ctx.answerCallbackQuery({ + text: `Approved ${result.member.displayName}.` + }) + + if (ctx.msg) { + const refreshed = await options.householdAdminService.listPendingMembers({ + actorTelegramUserId, + telegramChatId: ctx.chat.id.toString() + }) + + if (refreshed.status === 'ok') { + if (refreshed.members.length === 0) { + await ctx.editMessageText(`No pending members for ${refreshed.householdName}.`) + } else { + const reply = pendingMembersReply(refreshed) + await ctx.editMessageText(reply.text, { + reply_markup: reply.reply_markup + }) + } + } + } + + await ctx.reply( + `Approved ${result.member.displayName} as an active member of ${result.householdName}.` + ) + } + ) } diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index aa63b69..fb18820 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -12,7 +12,8 @@ import { createDbAnonymousFeedbackRepository, createDbFinanceRepository, createDbHouseholdConfigurationRepository, - createDbReminderDispatchRepository + createDbReminderDispatchRepository, + createDbTelegramPendingActionRepository } from '@household/adapters-db' import { configureLogger, getLogger } from '@household/observability' @@ -66,6 +67,10 @@ const householdOnboardingService = householdConfigurationRepositoryClient const anonymousFeedbackRepositoryClient = runtime.anonymousFeedbackEnabled ? createDbAnonymousFeedbackRepository(runtime.databaseUrl!, runtime.householdId!) : null +const telegramPendingActionRepositoryClient = + runtime.databaseUrl && runtime.anonymousFeedbackEnabled + ? createDbTelegramPendingActionRepository(runtime.databaseUrl!) + : null const anonymousFeedbackService = anonymousFeedbackRepositoryClient ? createAnonymousFeedbackService(anonymousFeedbackRepositoryClient.repository) : null @@ -82,6 +87,10 @@ if (anonymousFeedbackRepositoryClient) { shutdownTasks.push(anonymousFeedbackRepositoryClient.close) } +if (telegramPendingActionRepositoryClient) { + shutdownTasks.push(telegramPendingActionRepositoryClient.close) +} + if (runtime.databaseUrl && householdConfigurationRepositoryClient) { const purchaseRepositoryClient = createPurchaseMessageRepository(runtime.databaseUrl!) shutdownTasks.push(purchaseRepositoryClient.close) @@ -175,6 +184,7 @@ if (anonymousFeedbackService) { registerAnonymousFeedback({ bot, anonymousFeedbackService, + promptRepository: telegramPendingActionRepositoryClient!.repository, householdChatId: runtime.telegramHouseholdChatId!, feedbackTopicId: runtime.telegramFeedbackTopicId!, logger: getLogger('anonymous-feedback') diff --git a/packages/adapters-db/src/index.ts b/packages/adapters-db/src/index.ts index 1c0f4fc..0ea022a 100644 --- a/packages/adapters-db/src/index.ts +++ b/packages/adapters-db/src/index.ts @@ -2,3 +2,4 @@ export { createDbAnonymousFeedbackRepository } from './anonymous-feedback-reposi export { createDbFinanceRepository } from './finance-repository' export { createDbHouseholdConfigurationRepository } from './household-config-repository' export { createDbReminderDispatchRepository } from './reminder-dispatch-repository' +export { createDbTelegramPendingActionRepository } from './telegram-pending-action-repository' diff --git a/packages/adapters-db/src/telegram-pending-action-repository.ts b/packages/adapters-db/src/telegram-pending-action-repository.ts new file mode 100644 index 0000000..e4f05a4 --- /dev/null +++ b/packages/adapters-db/src/telegram-pending-action-repository.ts @@ -0,0 +1,144 @@ +import { and, eq } from 'drizzle-orm' + +import { createDbClient, schema } from '@household/db' +import type { + TelegramPendingActionRecord, + TelegramPendingActionRepository, + TelegramPendingActionType +} from '@household/ports' + +function parsePendingActionType(raw: string): TelegramPendingActionType { + if (raw === 'anonymous_feedback') { + return raw + } + + throw new Error(`Unexpected telegram pending action type: ${raw}`) +} + +function mapPendingAction(row: { + telegramUserId: string + telegramChatId: string + action: string + payload: unknown + expiresAt: Date | null +}): TelegramPendingActionRecord { + return { + telegramUserId: row.telegramUserId, + telegramChatId: row.telegramChatId, + action: parsePendingActionType(row.action), + payload: + row.payload && typeof row.payload === 'object' && !Array.isArray(row.payload) + ? (row.payload as Record) + : {}, + expiresAt: row.expiresAt + } +} + +export function createDbTelegramPendingActionRepository(databaseUrl: string): { + repository: TelegramPendingActionRepository + close: () => Promise +} { + const { db, queryClient } = createDbClient(databaseUrl, { + max: 5, + prepare: false + }) + + const repository: TelegramPendingActionRepository = { + async upsertPendingAction(input) { + const rows = await db + .insert(schema.telegramPendingActions) + .values({ + telegramUserId: input.telegramUserId, + telegramChatId: input.telegramChatId, + action: input.action, + payload: input.payload, + expiresAt: input.expiresAt, + updatedAt: new Date() + }) + .onConflictDoUpdate({ + target: [ + schema.telegramPendingActions.telegramChatId, + schema.telegramPendingActions.telegramUserId + ], + set: { + action: input.action, + payload: input.payload, + expiresAt: input.expiresAt, + updatedAt: new Date() + } + }) + .returning({ + telegramUserId: schema.telegramPendingActions.telegramUserId, + telegramChatId: schema.telegramPendingActions.telegramChatId, + action: schema.telegramPendingActions.action, + payload: schema.telegramPendingActions.payload, + expiresAt: schema.telegramPendingActions.expiresAt + }) + + const row = rows[0] + if (!row) { + throw new Error('Pending action upsert did not return a row') + } + + return mapPendingAction(row) + }, + + async getPendingAction(telegramChatId, telegramUserId) { + const now = new Date() + const rows = await db + .select({ + telegramUserId: schema.telegramPendingActions.telegramUserId, + telegramChatId: schema.telegramPendingActions.telegramChatId, + action: schema.telegramPendingActions.action, + payload: schema.telegramPendingActions.payload, + expiresAt: schema.telegramPendingActions.expiresAt + }) + .from(schema.telegramPendingActions) + .where( + and( + eq(schema.telegramPendingActions.telegramChatId, telegramChatId), + eq(schema.telegramPendingActions.telegramUserId, telegramUserId) + ) + ) + .limit(1) + + const row = rows[0] + if (!row) { + return null + } + + if (row.expiresAt && row.expiresAt.getTime() <= now.getTime()) { + await db + .delete(schema.telegramPendingActions) + .where( + and( + eq(schema.telegramPendingActions.telegramChatId, telegramChatId), + eq(schema.telegramPendingActions.telegramUserId, telegramUserId) + ) + ) + + return null + } + + return mapPendingAction(row) + }, + + async clearPendingAction(telegramChatId, telegramUserId) { + await db + .delete(schema.telegramPendingActions) + .where( + and( + eq(schema.telegramPendingActions.telegramChatId, telegramChatId), + eq(schema.telegramPendingActions.telegramUserId, telegramUserId) + ) + ) + } + } + + return { + repository, + close: async () => { + await queryClient.end({ timeout: 5 }) + } + } +} diff --git a/packages/db/drizzle/0007_sudden_murmur.sql b/packages/db/drizzle/0007_sudden_murmur.sql new file mode 100644 index 0000000..96f310d --- /dev/null +++ b/packages/db/drizzle/0007_sudden_murmur.sql @@ -0,0 +1,13 @@ +CREATE TABLE "telegram_pending_actions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "telegram_user_id" text NOT NULL, + "telegram_chat_id" text NOT NULL, + "action" text NOT NULL, + "payload" jsonb DEFAULT '{}'::jsonb NOT NULL, + "expires_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX "telegram_pending_actions_chat_user_unique" ON "telegram_pending_actions" USING btree ("telegram_chat_id","telegram_user_id");--> statement-breakpoint +CREATE INDEX "telegram_pending_actions_user_action_idx" ON "telegram_pending_actions" USING btree ("telegram_user_id","action"); diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index e881bd9..e1ba2d1 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1773015092441, "tag": "0006_marvelous_nehzno", "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1773051000000, + "tag": "0007_sudden_murmur", + "breakpoints": true } ] } diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index d3eb7c2..1af08ee 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -107,6 +107,32 @@ export const householdPendingMembers = pgTable( }) ) +export const telegramPendingActions = pgTable( + 'telegram_pending_actions', + { + id: uuid('id').defaultRandom().primaryKey(), + telegramUserId: text('telegram_user_id').notNull(), + telegramChatId: text('telegram_chat_id').notNull(), + action: text('action').notNull(), + payload: jsonb('payload') + .default(sql`'{}'::jsonb`) + .notNull(), + expiresAt: timestamp('expires_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull() + }, + (table) => ({ + chatUserUnique: uniqueIndex('telegram_pending_actions_chat_user_unique').on( + table.telegramChatId, + table.telegramUserId + ), + userActionIdx: index('telegram_pending_actions_user_action_idx').on( + table.telegramUserId, + table.action + ) + }) +) + export const members = pgTable( 'members', { diff --git a/packages/ports/src/index.ts b/packages/ports/src/index.ts index e19bd74..af9af86 100644 --- a/packages/ports/src/index.ts +++ b/packages/ports/src/index.ts @@ -35,3 +35,9 @@ export type { SettlementSnapshotLineRecord, SettlementSnapshotRecord } from './finance' +export { + TELEGRAM_PENDING_ACTION_TYPES, + type TelegramPendingActionRecord, + type TelegramPendingActionRepository, + type TelegramPendingActionType +} from './telegram-pending-actions' diff --git a/packages/ports/src/telegram-pending-actions.ts b/packages/ports/src/telegram-pending-actions.ts new file mode 100644 index 0000000..7397316 --- /dev/null +++ b/packages/ports/src/telegram-pending-actions.ts @@ -0,0 +1,20 @@ +export const TELEGRAM_PENDING_ACTION_TYPES = ['anonymous_feedback'] as const + +export type TelegramPendingActionType = (typeof TELEGRAM_PENDING_ACTION_TYPES)[number] + +export interface TelegramPendingActionRecord { + telegramUserId: string + telegramChatId: string + action: TelegramPendingActionType + payload: Record + expiresAt: Date | null +} + +export interface TelegramPendingActionRepository { + upsertPendingAction(input: TelegramPendingActionRecord): Promise + getPendingAction( + telegramChatId: string, + telegramUserId: string + ): Promise + clearPendingAction(telegramChatId: string, telegramUserId: string): Promise +}