diff --git a/apps/bot/package.json b/apps/bot/package.json index 010ce64..d1449db 100644 --- a/apps/bot/package.json +++ b/apps/bot/package.json @@ -10,6 +10,7 @@ "lint": "oxlint \"src\"" }, "dependencies": { + "@aws-sdk/client-scheduler": "^3.913.0", "@household/adapters-db": "workspace:*", "@household/application": "workspace:*", "@household/db": "workspace:*", diff --git a/apps/bot/src/ad-hoc-notification-jobs.test.ts b/apps/bot/src/ad-hoc-notification-jobs.test.ts deleted file mode 100644 index 509be5f..0000000 --- a/apps/bot/src/ad-hoc-notification-jobs.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { describe, expect, test } from 'bun:test' - -import { Temporal } from '@household/domain' -import type { AdHocNotificationService, DeliverableAdHocNotification } from '@household/application' - -import { createAdHocNotificationJobsHandler } from './ad-hoc-notification-jobs' - -function dueNotification( - input: Partial = {} -): DeliverableAdHocNotification { - return { - notification: { - id: input.id ?? 'notif-1', - householdId: input.householdId ?? 'household-1', - creatorMemberId: input.creatorMemberId ?? 'creator', - assigneeMemberId: input.assigneeMemberId ?? 'assignee', - originalRequestText: 'raw', - notificationText: - input.notificationText ?? 'Dima, time to check whether Georgiy has called already.', - timezone: input.timezone ?? 'Asia/Tbilisi', - scheduledFor: input.scheduledFor ?? Temporal.Instant.from('2026-03-23T09:00:00Z'), - timePrecision: input.timePrecision ?? 'exact', - deliveryMode: input.deliveryMode ?? 'topic', - dmRecipientMemberIds: input.dmRecipientMemberIds ?? [], - friendlyTagAssignee: input.friendlyTagAssignee ?? true, - status: input.status ?? 'scheduled', - sourceTelegramChatId: null, - sourceTelegramThreadId: null, - sentAt: null, - cancelledAt: null, - cancelledByMemberId: null, - createdAt: Temporal.Instant.from('2026-03-22T09:00:00Z'), - updatedAt: Temporal.Instant.from('2026-03-22T09:00:00Z') - }, - creator: { - memberId: 'creator', - telegramUserId: '111', - displayName: 'Dima' - }, - assignee: { - memberId: 'assignee', - telegramUserId: '222', - displayName: 'Georgiy' - }, - dmRecipients: [ - { - memberId: 'recipient', - telegramUserId: '333', - displayName: 'Alice' - } - ] - } -} - -describe('createAdHocNotificationJobsHandler', () => { - test('delivers topic notifications and marks them sent', async () => { - const sentTopicMessages: string[] = [] - const sentNotifications: string[] = [] - - const service: AdHocNotificationService = { - scheduleNotification: async () => { - throw new Error('not used') - }, - listUpcomingNotifications: async () => [], - cancelNotification: async () => ({ status: 'not_found' }), - updateNotification: async () => ({ status: 'not_found' }), - listDueNotifications: async () => [dueNotification()], - claimDueNotification: async () => true, - releaseDueNotification: async () => {}, - markNotificationSent: async (notificationId) => { - sentNotifications.push(notificationId) - return null - } - } - - const handler = createAdHocNotificationJobsHandler({ - notificationService: service, - householdConfigurationRepository: { - async getHouseholdChatByHouseholdId() { - return { - householdId: 'household-1', - householdName: 'Kojori', - telegramChatId: '777', - telegramChatType: 'supergroup', - title: 'Kojori', - defaultLocale: 'ru' - } - }, - async getHouseholdTopicBinding() { - return { - householdId: 'household-1', - role: 'reminders', - telegramThreadId: '103', - topicName: 'Reminders' - } - } - }, - sendTopicMessage: async (input) => { - sentTopicMessages.push(`${input.chatId}:${input.threadId}:${input.text}`) - }, - sendDirectMessage: async () => {} - }) - - const response = await handler.handle( - new Request('http://localhost/jobs/notifications/due', { - method: 'POST' - }) - ) - const payload = (await response.json()) as { ok: boolean; notifications: { outcome: string }[] } - - expect(payload.ok).toBe(true) - expect(payload.notifications[0]?.outcome).toBe('sent') - expect(sentTopicMessages[0]).toContain( - 'Dima, time to check whether Georgiy has called already.' - ) - expect(sentNotifications).toEqual(['notif-1']) - }) -}) diff --git a/apps/bot/src/ad-hoc-notification-jobs.ts b/apps/bot/src/ad-hoc-notification-jobs.ts deleted file mode 100644 index 0a19816..0000000 --- a/apps/bot/src/ad-hoc-notification-jobs.ts +++ /dev/null @@ -1,192 +0,0 @@ -import type { AdHocNotificationService, DeliverableAdHocNotification } from '@household/application' -import { nowInstant } from '@household/domain' -import type { Logger } from '@household/observability' -import type { HouseholdConfigurationRepository } from '@household/ports' - -import { buildTopicNotificationText } from './ad-hoc-notifications' - -interface DueNotificationJobRequestBody { - dryRun?: boolean - jobId?: string -} - -function json(body: object, status = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { - 'content-type': 'application/json; charset=utf-8' - } - }) -} - -async function readBody(request: Request): Promise { - const text = await request.text() - if (text.trim().length === 0) { - return {} - } - - try { - return JSON.parse(text) as DueNotificationJobRequestBody - } catch { - throw new Error('Invalid JSON body') - } -} - -export function createAdHocNotificationJobsHandler(options: { - notificationService: AdHocNotificationService - householdConfigurationRepository: Pick< - HouseholdConfigurationRepository, - 'getHouseholdChatByHouseholdId' | 'getHouseholdTopicBinding' - > - sendTopicMessage: (input: { - householdId: string - chatId: string - threadId: string | null - text: string - parseMode?: 'HTML' - }) => Promise - sendDirectMessage: (input: { telegramUserId: string; text: string }) => Promise - logger?: Logger -}): { - handle: (request: Request) => Promise -} { - async function deliver(notification: DeliverableAdHocNotification) { - switch (notification.notification.deliveryMode) { - case 'topic': { - const [chat, reminderTopic] = await Promise.all([ - options.householdConfigurationRepository.getHouseholdChatByHouseholdId( - notification.notification.householdId - ), - options.householdConfigurationRepository.getHouseholdTopicBinding( - notification.notification.householdId, - 'reminders' - ) - ]) - - if (!chat) { - throw new Error( - `Household chat not configured for ${notification.notification.householdId}` - ) - } - - const content = buildTopicNotificationText({ - notificationText: notification.notification.notificationText - }) - await options.sendTopicMessage({ - householdId: notification.notification.householdId, - chatId: chat.telegramChatId, - threadId: reminderTopic?.telegramThreadId ?? null, - text: content.text, - parseMode: content.parseMode - }) - return - } - case 'dm_all': - case 'dm_selected': { - for (const recipient of notification.dmRecipients) { - await options.sendDirectMessage({ - telegramUserId: recipient.telegramUserId, - text: notification.notification.notificationText - }) - } - return - } - } - } - - return { - handle: async (request) => { - if (request.method !== 'POST') { - return json({ ok: false, error: 'Method Not Allowed' }, 405) - } - - try { - const body = await readBody(request) - const now = nowInstant() - const due = await options.notificationService.listDueNotifications(now) - const dispatches: Array<{ - notificationId: string - householdId: string - outcome: 'dry-run' | 'sent' | 'duplicate' | 'failed' - error?: string - }> = [] - - for (const notification of due) { - if (body.dryRun === true) { - dispatches.push({ - notificationId: notification.notification.id, - householdId: notification.notification.householdId, - outcome: 'dry-run' - }) - continue - } - - const claimed = await options.notificationService.claimDueNotification( - notification.notification.id - ) - if (!claimed) { - dispatches.push({ - notificationId: notification.notification.id, - householdId: notification.notification.householdId, - outcome: 'duplicate' - }) - continue - } - - try { - await deliver(notification) - await options.notificationService.markNotificationSent( - notification.notification.id, - now - ) - dispatches.push({ - notificationId: notification.notification.id, - householdId: notification.notification.householdId, - outcome: 'sent' - }) - } catch (error) { - await options.notificationService.releaseDueNotification(notification.notification.id) - dispatches.push({ - notificationId: notification.notification.id, - householdId: notification.notification.householdId, - outcome: 'failed', - error: error instanceof Error ? error.message : 'Unknown delivery error' - }) - } - } - - options.logger?.info( - { - event: 'scheduler.ad_hoc_notifications.dispatch', - notificationCount: dispatches.length, - jobId: body.jobId ?? request.headers.get('x-cloudscheduler-jobname') ?? null, - dryRun: body.dryRun === true - }, - 'Ad hoc notification job completed' - ) - - return json({ - ok: true, - dryRun: body.dryRun === true, - notifications: dispatches - }) - } catch (error) { - options.logger?.error( - { - event: 'scheduler.ad_hoc_notifications.failed', - error: error instanceof Error ? error.message : String(error) - }, - 'Ad hoc notification job failed' - ) - - return json( - { - ok: false, - error: error instanceof Error ? error.message : 'Unknown error' - }, - 500 - ) - } - } - } -} diff --git a/apps/bot/src/app.ts b/apps/bot/src/app.ts index 3f01b8e..6399992 100644 --- a/apps/bot/src/app.ts +++ b/apps/bot/src/app.ts @@ -1,5 +1,4 @@ import { webhookCallback } from 'grammy' -import type { InlineKeyboardMarkup } from 'grammy/types' import { createAdHocNotificationService, @@ -11,7 +10,7 @@ import { createLocalePreferenceService, createMiniAppAdminService, createPaymentConfirmationService, - createReminderJobService + createScheduledDispatchService } from '@household/application' import { createDbAdHocNotificationRepository, @@ -19,13 +18,12 @@ import { createDbFinanceRepository, createDbHouseholdConfigurationRepository, createDbProcessedBotMessageRepository, - createDbReminderDispatchRepository, + createDbScheduledDispatchRepository, createDbTelegramPendingActionRepository, createDbTopicMessageHistoryRepository } from '@household/adapters-db' import { configureLogger, getLogger } from '@household/observability' -import { createAdHocNotificationJobsHandler } from './ad-hoc-notification-jobs' import { registerAdHocNotifications } from './ad-hoc-notifications' import { registerAnonymousFeedback } from './anonymous-feedback' import { @@ -39,6 +37,8 @@ import { createTelegramBot } from './bot' import { getBotRuntimeConfig, type BotRuntimeConfig } from './config' import { registerHouseholdSetupCommands } from './household-setup' import { HouseholdContextCache } from './household-context-cache' +import { createAwsScheduledDispatchScheduler } from './aws-scheduled-dispatch-scheduler' +import { createGcpScheduledDispatchScheduler } from './gcp-scheduled-dispatch-scheduler' import { createMiniAppAuthHandler, createMiniAppJoinHandler } from './miniapp-auth' import { createMiniAppApproveMemberHandler, @@ -86,9 +86,9 @@ import { registerConfiguredPurchaseTopicIngestion } from './purchase-topic-ingestion' import { registerConfiguredPaymentTopicIngestion } from './payment-topic-ingestion' -import { createReminderJobsHandler } from './reminder-jobs' import { registerReminderTopicUtilities } from './reminder-topic-utilities' import { createSchedulerRequestAuthorizer } from './scheduler-auth' +import { createScheduledDispatchHandler } from './scheduled-dispatch-handler' import { createBotWebhookServer } from './server' import { createTopicProcessor } from './topic-processor' @@ -134,8 +134,42 @@ export async function createBotRuntimeApp(): Promise { repository: householdConfigurationRepositoryClient.repository }) : null + const scheduledDispatchRepositoryClient = + runtime.databaseUrl && runtime.scheduledDispatch + ? createDbScheduledDispatchRepository(runtime.databaseUrl) + : null + const scheduledDispatchScheduler = + runtime.scheduledDispatch && runtime.schedulerSharedSecret + ? runtime.scheduledDispatch.provider === 'gcp-cloud-tasks' + ? createGcpScheduledDispatchScheduler({ + projectId: runtime.scheduledDispatch.projectId, + location: runtime.scheduledDispatch.location, + queue: runtime.scheduledDispatch.queue, + publicBaseUrl: runtime.scheduledDispatch.publicBaseUrl, + sharedSecret: runtime.schedulerSharedSecret + }) + : createAwsScheduledDispatchScheduler({ + region: runtime.scheduledDispatch.region, + targetLambdaArn: runtime.scheduledDispatch.targetLambdaArn, + roleArn: runtime.scheduledDispatch.roleArn, + groupName: runtime.scheduledDispatch.groupName + }) + : null + const scheduledDispatchService = + scheduledDispatchRepositoryClient && + scheduledDispatchScheduler && + householdConfigurationRepositoryClient + ? createScheduledDispatchService({ + repository: scheduledDispatchRepositoryClient.repository, + scheduler: scheduledDispatchScheduler, + householdConfigurationRepository: householdConfigurationRepositoryClient.repository + }) + : null const miniAppAdminService = householdConfigurationRepositoryClient - ? createMiniAppAdminService(householdConfigurationRepositoryClient.repository) + ? createMiniAppAdminService( + householdConfigurationRepositoryClient.repository, + scheduledDispatchService ?? undefined + ) : null const localePreferenceService = householdConfigurationRepositoryClient ? createLocalePreferenceService(householdConfigurationRepositoryClient.repository) @@ -200,7 +234,12 @@ export async function createBotRuntimeApp(): Promise { adHocNotificationRepositoryClient && householdConfigurationRepositoryClient ? createAdHocNotificationService({ repository: adHocNotificationRepositoryClient.repository, - householdConfigurationRepository: householdConfigurationRepositoryClient.repository + householdConfigurationRepository: householdConfigurationRepositoryClient.repository, + ...(scheduledDispatchService + ? { + scheduledDispatchService + } + : {}) }) : null @@ -289,6 +328,10 @@ export async function createBotRuntimeApp(): Promise { shutdownTasks.push(adHocNotificationRepositoryClient.close) } + if (scheduledDispatchRepositoryClient) { + shutdownTasks.push(scheduledDispatchRepositoryClient.close) + } + if (purchaseRepositoryClient && householdConfigurationRepositoryClient) { registerConfiguredPurchaseTopicIngestion( bot, @@ -375,7 +418,8 @@ export async function createBotRuntimeApp(): Promise { registerHouseholdSetupCommands({ bot, householdSetupService: createHouseholdSetupService( - householdConfigurationRepositoryClient.repository + householdConfigurationRepositoryClient.repository, + scheduledDispatchService ?? undefined ), householdAdminService: createHouseholdAdminService( householdConfigurationRepositoryClient.repository @@ -419,65 +463,13 @@ export async function createBotRuntimeApp(): Promise { }) } - const reminderJobs = runtime.reminderJobsEnabled - ? (() => { - const reminderRepositoryClient = createDbReminderDispatchRepository(runtime.databaseUrl!) - const reminderService = createReminderJobService(reminderRepositoryClient.repository) - - shutdownTasks.push(reminderRepositoryClient.close) - - return createReminderJobsHandler({ - listReminderTargets: () => - householdConfigurationRepositoryClient!.repository.listReminderTargets(), - ensureBillingCycle: async ({ householdId, at }) => { - await financeServiceForHousehold(householdId).ensureExpectedCycle(at) - }, - releaseReminderDispatch: (input) => - reminderRepositoryClient.repository.releaseReminderDispatch(input), - sendReminderMessage: async (target, content) => { - const threadId = - target.telegramThreadId !== null ? Number(target.telegramThreadId) : undefined - - if (target.telegramThreadId !== null && (!threadId || !Number.isInteger(threadId))) { - throw new Error( - `Invalid reminder thread id for household ${target.householdId}: ${target.telegramThreadId}` - ) - } - - await bot.api.sendMessage(target.telegramChatId, content.text, { - ...(threadId - ? { - message_thread_id: threadId - } - : {}), - ...(content.replyMarkup - ? { - reply_markup: content.replyMarkup as InlineKeyboardMarkup - } - : {}) - }) - }, - reminderService, - ...(runtime.miniAppUrl - ? { - miniAppUrl: runtime.miniAppUrl - } - : {}), - ...(bot.botInfo?.username - ? { - botUsername: bot.botInfo.username - } - : {}), - logger: getLogger('scheduler') - }) - })() - : null - const adHocNotificationJobs = - runtime.reminderJobsEnabled && - adHocNotificationService && + const scheduledDispatchHandler = + scheduledDispatchService && + adHocNotificationRepositoryClient && householdConfigurationRepositoryClient - ? createAdHocNotificationJobsHandler({ - notificationService: adHocNotificationService, + ? createScheduledDispatchHandler({ + scheduledDispatchService, + adHocNotificationRepository: adHocNotificationRepositoryClient.repository, householdConfigurationRepository: householdConfigurationRepositoryClient.repository, sendTopicMessage: async (input) => { const threadId = input.threadId ? Number(input.threadId) : undefined @@ -491,23 +483,38 @@ export async function createBotRuntimeApp(): Promise { ? { parse_mode: input.parseMode } + : {}), + ...(input.replyMarkup + ? { + reply_markup: input.replyMarkup + } : {}) }) }, sendDirectMessage: async (input) => { await bot.api.sendMessage(input.telegramUserId, input.text) }, + ...(runtime.miniAppUrl + ? { + miniAppUrl: runtime.miniAppUrl + } + : {}), + ...(bot.botInfo?.username + ? { + botUsername: bot.botInfo.username + } + : {}), logger: getLogger('scheduler') }) : null - if (!runtime.reminderJobsEnabled) { + if (!scheduledDispatchHandler) { logger.warn( { event: 'runtime.feature_disabled', - feature: 'reminder-jobs' + feature: 'scheduled-dispatch' }, - 'Reminder jobs are disabled. Set DATABASE_URL and either SCHEDULER_SHARED_SECRET or SCHEDULER_OIDC_ALLOWED_EMAILS to enable.' + 'Scheduled dispatch is disabled. Configure DATABASE_URL, SCHEDULED_DISPATCH_PROVIDER, and scheduler auth to enable reminder delivery.' ) } @@ -918,7 +925,7 @@ export async function createBotRuntimeApp(): Promise { }) : undefined, scheduler: - (reminderJobs || adHocNotificationJobs) && runtime.schedulerSharedSecret + scheduledDispatchHandler && runtime.schedulerSharedSecret ? { pathPrefix: '/jobs', authorize: createSchedulerRequestAuthorizer({ @@ -926,37 +933,25 @@ export async function createBotRuntimeApp(): Promise { oidcAllowedEmails: runtime.schedulerOidcAllowedEmails }).authorize, handler: async (request, jobPath) => { - if (jobPath.startsWith('reminder/')) { - return reminderJobs - ? reminderJobs.handle(request, jobPath.slice('reminder/'.length)) - : new Response('Not Found', { status: 404 }) - } - - if (jobPath === 'notifications/due') { - return adHocNotificationJobs - ? adHocNotificationJobs.handle(request) + if (jobPath.startsWith('dispatch/')) { + return scheduledDispatchHandler + ? scheduledDispatchHandler.handle(request, jobPath.slice('dispatch/'.length)) : new Response('Not Found', { status: 404 }) } return new Response('Not Found', { status: 404 }) } } - : reminderJobs || adHocNotificationJobs + : scheduledDispatchHandler ? { pathPrefix: '/jobs', authorize: createSchedulerRequestAuthorizer({ oidcAllowedEmails: runtime.schedulerOidcAllowedEmails }).authorize, handler: async (request, jobPath) => { - if (jobPath.startsWith('reminder/')) { - return reminderJobs - ? reminderJobs.handle(request, jobPath.slice('reminder/'.length)) - : new Response('Not Found', { status: 404 }) - } - - if (jobPath === 'notifications/due') { - return adHocNotificationJobs - ? adHocNotificationJobs.handle(request) + if (jobPath.startsWith('dispatch/')) { + return scheduledDispatchHandler + ? scheduledDispatchHandler.handle(request, jobPath.slice('dispatch/'.length)) : new Response('Not Found', { status: 404 }) } @@ -966,6 +961,10 @@ export async function createBotRuntimeApp(): Promise { : undefined }) + if (scheduledDispatchService) { + await scheduledDispatchService.reconcileAllBuiltInDispatches() + } + return { fetch: server.fetch, runtime, diff --git a/apps/bot/src/aws-scheduled-dispatch-scheduler.test.ts b/apps/bot/src/aws-scheduled-dispatch-scheduler.test.ts new file mode 100644 index 0000000..4c827b1 --- /dev/null +++ b/apps/bot/src/aws-scheduled-dispatch-scheduler.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from 'bun:test' + +import { Temporal } from '@household/domain' + +import { createAwsScheduledDispatchScheduler } from './aws-scheduled-dispatch-scheduler' + +describe('createAwsScheduledDispatchScheduler', () => { + test('creates one-shot EventBridge schedules targeting the bot lambda', async () => { + const calls: unknown[] = [] + const scheduler = createAwsScheduledDispatchScheduler({ + region: 'eu-central-1', + targetLambdaArn: 'arn:aws:lambda:eu-central-1:123:function:bot', + roleArn: 'arn:aws:iam::123:role/scheduler', + groupName: 'dispatches', + client: { + send: async (command) => { + calls.push(command.input) + return {} + } + } + }) + + const result = await scheduler.scheduleOneShotDispatch({ + dispatchId: 'dispatch-1', + dueAt: Temporal.Instant.from('2026-03-24T12:00:00Z') + }) + + expect(result.providerDispatchId).toContain('dispatch-dispatch-1-') + expect(calls[0]).toMatchObject({ + GroupName: 'dispatches', + ScheduleExpression: 'at(2026-03-24T12:00:00Z)', + ActionAfterCompletion: 'DELETE', + FlexibleTimeWindow: { + Mode: 'OFF' + }, + Target: { + Arn: 'arn:aws:lambda:eu-central-1:123:function:bot', + RoleArn: 'arn:aws:iam::123:role/scheduler', + Input: JSON.stringify({ + source: 'household.scheduled-dispatch', + dispatchId: 'dispatch-1' + }) + } + }) + }) +}) diff --git a/apps/bot/src/aws-scheduled-dispatch-scheduler.ts b/apps/bot/src/aws-scheduled-dispatch-scheduler.ts new file mode 100644 index 0000000..d92362e --- /dev/null +++ b/apps/bot/src/aws-scheduled-dispatch-scheduler.ts @@ -0,0 +1,79 @@ +import { + CreateScheduleCommand, + DeleteScheduleCommand, + SchedulerClient +} from '@aws-sdk/client-scheduler' + +import type { + ScheduleOneShotDispatchInput, + ScheduleOneShotDispatchResult, + ScheduledDispatchScheduler +} from '@household/ports' + +function scheduleName(dispatchId: string): string { + return `dispatch-${dispatchId}-${crypto.randomUUID().slice(0, 8)}` +} + +function atExpression(dueAtIso: string): string { + return `at(${dueAtIso.replace(/\.\d{3}Z$/, 'Z')})` +} + +export function createAwsScheduledDispatchScheduler(input: { + region: string + targetLambdaArn: string + roleArn: string + groupName: string + client?: Pick +}): ScheduledDispatchScheduler { + const client = input.client ?? new SchedulerClient({ region: input.region }) + + return { + provider: 'aws-eventbridge', + + async scheduleOneShotDispatch( + dispatchInput: ScheduleOneShotDispatchInput + ): Promise { + const name = scheduleName(dispatchInput.dispatchId) + await client.send( + new CreateScheduleCommand({ + Name: name, + GroupName: input.groupName, + ScheduleExpression: atExpression(dispatchInput.dueAt.toString()), + FlexibleTimeWindow: { + Mode: 'OFF' + }, + ActionAfterCompletion: 'DELETE', + Target: { + Arn: input.targetLambdaArn, + RoleArn: input.roleArn, + Input: JSON.stringify({ + source: 'household.scheduled-dispatch', + dispatchId: dispatchInput.dispatchId + }) + } + }) + ) + + return { + providerDispatchId: name + } + }, + + async cancelDispatch(providerDispatchId) { + try { + await client.send( + new DeleteScheduleCommand({ + Name: providerDispatchId, + GroupName: input.groupName + }) + ) + } catch (error) { + const code = (error as { name?: string }).name + if (code === 'ResourceNotFoundException') { + return + } + throw error + } + } + } +} diff --git a/apps/bot/src/config.ts b/apps/bot/src/config.ts index dedd885..e98eda5 100644 --- a/apps/bot/src/config.ts +++ b/apps/bot/src/config.ts @@ -13,7 +13,22 @@ export interface BotRuntimeConfig { miniAppAuthEnabled: boolean schedulerSharedSecret?: string schedulerOidcAllowedEmails: readonly string[] - reminderJobsEnabled: boolean + scheduledDispatch?: + | { + provider: 'gcp-cloud-tasks' + publicBaseUrl: string + projectId: string + location: string + queue: string + } + | { + provider: 'aws-eventbridge' + region: string + targetLambdaArn: string + roleArn: string + groupName: string + } + | undefined openaiApiKey?: string purchaseParserModel: string assistantModel: string @@ -86,6 +101,56 @@ function parseOptionalCsv(value: string | undefined): readonly string[] { .filter(Boolean) } +function parseScheduledDispatchConfig( + env: NodeJS.ProcessEnv +): BotRuntimeConfig['scheduledDispatch'] { + const provider = parseOptionalValue(env.SCHEDULED_DISPATCH_PROVIDER) + if (!provider) { + return undefined + } + + if (provider === 'gcp-cloud-tasks') { + const publicBaseUrl = parseOptionalValue(env.SCHEDULED_DISPATCH_PUBLIC_BASE_URL) + const projectId = parseOptionalValue(env.GCP_SCHEDULED_DISPATCH_PROJECT_ID) + const location = parseOptionalValue(env.GCP_SCHEDULED_DISPATCH_LOCATION) + const queue = parseOptionalValue(env.GCP_SCHEDULED_DISPATCH_QUEUE) + if (!publicBaseUrl || !projectId || !location || !queue) { + throw new Error( + 'GCP scheduled dispatch requires SCHEDULED_DISPATCH_PUBLIC_BASE_URL, GCP_SCHEDULED_DISPATCH_PROJECT_ID, GCP_SCHEDULED_DISPATCH_LOCATION, and GCP_SCHEDULED_DISPATCH_QUEUE' + ) + } + + return { + provider, + publicBaseUrl, + projectId, + location, + queue + } + } + + if (provider === 'aws-eventbridge') { + const region = parseOptionalValue(env.AWS_SCHEDULED_DISPATCH_REGION) + const targetLambdaArn = parseOptionalValue(env.AWS_SCHEDULED_DISPATCH_TARGET_LAMBDA_ARN) + const roleArn = parseOptionalValue(env.AWS_SCHEDULED_DISPATCH_ROLE_ARN) + if (!region || !targetLambdaArn || !roleArn) { + throw new Error( + 'AWS scheduled dispatch requires AWS_SCHEDULED_DISPATCH_REGION, AWS_SCHEDULED_DISPATCH_TARGET_LAMBDA_ARN, and AWS_SCHEDULED_DISPATCH_ROLE_ARN' + ) + } + + return { + provider, + region, + targetLambdaArn, + roleArn, + groupName: parseOptionalValue(env.AWS_SCHEDULED_DISPATCH_GROUP_NAME) ?? 'default' + } + } + + throw new Error(`Invalid SCHEDULED_DISPATCH_PROVIDER value: ${provider}`) +} + function parsePositiveInteger(raw: string | undefined, fallback: number, key: string): number { if (raw === undefined) { return fallback @@ -105,6 +170,7 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu const schedulerOidcAllowedEmails = parseOptionalCsv(env.SCHEDULER_OIDC_ALLOWED_EMAILS) const miniAppAllowedOrigins = parseOptionalCsv(env.MINI_APP_ALLOWED_ORIGINS) const miniAppUrl = parseOptionalValue(env.MINI_APP_URL) + const scheduledDispatch = parseScheduledDispatchConfig(env) const purchaseTopicIngestionEnabled = databaseUrl !== undefined @@ -112,9 +178,6 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu const anonymousFeedbackEnabled = databaseUrl !== undefined const assistantEnabled = databaseUrl !== undefined const miniAppAuthEnabled = databaseUrl !== undefined - const hasSchedulerOidcConfig = schedulerOidcAllowedEmails.length > 0 - const reminderJobsEnabled = - databaseUrl !== undefined && (schedulerSharedSecret !== undefined || hasSchedulerOidcConfig) const runtime: BotRuntimeConfig = { port: parsePort(env.PORT), @@ -129,7 +192,6 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu miniAppAllowedOrigins, miniAppAuthEnabled, schedulerOidcAllowedEmails, - reminderJobsEnabled, purchaseParserModel: env.PURCHASE_PARSER_MODEL?.trim() || 'gpt-4o-mini', assistantModel: env.ASSISTANT_MODEL?.trim() || 'gpt-4o-mini', topicProcessorModel: env.TOPIC_PROCESSOR_MODEL?.trim() || 'gpt-4o-mini', @@ -176,6 +238,9 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu if (schedulerSharedSecret !== undefined) { runtime.schedulerSharedSecret = schedulerSharedSecret } + if (scheduledDispatch !== undefined) { + runtime.scheduledDispatch = scheduledDispatch + } if (miniAppUrl !== undefined) { runtime.miniAppUrl = miniAppUrl } diff --git a/apps/bot/src/gcp-scheduled-dispatch-scheduler.test.ts b/apps/bot/src/gcp-scheduled-dispatch-scheduler.test.ts new file mode 100644 index 0000000..4428104 --- /dev/null +++ b/apps/bot/src/gcp-scheduled-dispatch-scheduler.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from 'bun:test' + +import { Temporal } from '@household/domain' + +import { createGcpScheduledDispatchScheduler } from './gcp-scheduled-dispatch-scheduler' + +describe('createGcpScheduledDispatchScheduler', () => { + test('creates Cloud Tasks HTTP tasks for one-shot dispatches', async () => { + const requests: Array<{ url: string; init: RequestInit | undefined }> = [] + const scheduler = createGcpScheduledDispatchScheduler({ + projectId: 'project-1', + location: 'europe-west1', + queue: 'dispatches', + publicBaseUrl: 'https://bot.example.com', + sharedSecret: 'secret-1', + auth: { + getAccessToken: async () => 'access-token' + }, + fetchImpl: (async (url, init) => { + requests.push({ + url: String(url), + init + }) + return new Response(JSON.stringify({ name: 'tasks/dispatch-1' }), { + status: 200, + headers: { + 'content-type': 'application/json' + } + }) + }) as typeof fetch + }) + + const result = await scheduler.scheduleOneShotDispatch({ + dispatchId: 'dispatch-1', + dueAt: Temporal.Instant.from('2026-03-24T12:00:00Z') + }) + + expect(result.providerDispatchId).toBe('tasks/dispatch-1') + expect(requests[0]?.url).toBe( + 'https://cloudtasks.googleapis.com/v2/projects/project-1/locations/europe-west1/queues/dispatches/tasks' + ) + const payload = JSON.parse(String(requests[0]?.init?.body)) as { + task: { + httpRequest: { url: string; headers: Record } + scheduleTime: { seconds: string } + } + } + expect(payload.task.httpRequest.url).toBe('https://bot.example.com/jobs/dispatch/dispatch-1') + expect(payload.task.httpRequest.headers['x-household-scheduler-secret']).toBe('secret-1') + expect(payload.task.scheduleTime.seconds).toBe('1774353600') + }) +}) diff --git a/apps/bot/src/gcp-scheduled-dispatch-scheduler.ts b/apps/bot/src/gcp-scheduled-dispatch-scheduler.ts new file mode 100644 index 0000000..d39ba91 --- /dev/null +++ b/apps/bot/src/gcp-scheduled-dispatch-scheduler.ts @@ -0,0 +1,122 @@ +import { GoogleAuth } from 'google-auth-library' + +import type { + ScheduleOneShotDispatchInput, + ScheduleOneShotDispatchResult, + ScheduledDispatchScheduler +} from '@household/ports' + +function scheduleTimestamp(input: ScheduleOneShotDispatchInput): { + seconds: string + nanos: number +} { + const milliseconds = input.dueAt.epochMilliseconds + const seconds = Math.floor(milliseconds / 1000) + return { + seconds: String(seconds), + nanos: (milliseconds - seconds * 1000) * 1_000_000 + } +} + +function callbackUrl(baseUrl: string, dispatchId: string): string { + return `${baseUrl.replace(/\/$/, '')}/jobs/dispatch/${dispatchId}` +} + +export function createGcpScheduledDispatchScheduler(input: { + projectId: string + location: string + queue: string + publicBaseUrl: string + sharedSecret: string + auth?: Pick + fetchImpl?: typeof fetch +}): ScheduledDispatchScheduler { + const auth = + input.auth ?? + new GoogleAuth({ + scopes: ['https://www.googleapis.com/auth/cloud-platform'] + }) + const fetchImpl = input.fetchImpl ?? fetch + + async function authorizedHeaders() { + const accessToken = await auth.getAccessToken() + if (!accessToken) { + throw new Error('Failed to acquire Google Cloud access token for scheduled dispatch') + } + const token = + typeof accessToken === 'string' + ? accessToken + : ((accessToken as { token?: string }).token ?? null) + if (!token) { + throw new Error('Failed to read Google Cloud access token for scheduled dispatch') + } + + return { + authorization: `Bearer ${token}`, + 'content-type': 'application/json' + } + } + + return { + provider: 'gcp-cloud-tasks', + + async scheduleOneShotDispatch(dispatchInput): Promise { + const response = await fetchImpl( + `https://cloudtasks.googleapis.com/v2/projects/${input.projectId}/locations/${input.location}/queues/${input.queue}/tasks`, + { + method: 'POST', + headers: await authorizedHeaders(), + body: JSON.stringify({ + task: { + scheduleTime: scheduleTimestamp(dispatchInput), + httpRequest: { + httpMethod: 'POST', + url: callbackUrl(input.publicBaseUrl, dispatchInput.dispatchId), + headers: { + 'content-type': 'application/json', + 'x-household-scheduler-secret': input.sharedSecret + }, + body: Buffer.from( + JSON.stringify({ + dispatchId: dispatchInput.dispatchId + }) + ).toString('base64') + } + } + }) + } + ) + + if (!response.ok) { + throw new Error(`Cloud Tasks create task failed with status ${response.status}`) + } + + const payload = (await response.json()) as { name?: string } + if (!payload.name) { + throw new Error('Cloud Tasks create task response did not include a task name') + } + + return { + providerDispatchId: payload.name + } + }, + + async cancelDispatch(providerDispatchId) { + const response = await fetchImpl( + `https://cloudtasks.googleapis.com/v2/${providerDispatchId}`, + { + method: 'DELETE', + headers: await authorizedHeaders() + } + ) + + if (response.status === 404) { + return + } + + if (!response.ok) { + throw new Error(`Cloud Tasks delete task failed with status ${response.status}`) + } + } + } +} diff --git a/apps/bot/src/lambda.ts b/apps/bot/src/lambda.ts index 22222b7..2594e86 100644 --- a/apps/bot/src/lambda.ts +++ b/apps/bot/src/lambda.ts @@ -10,7 +10,58 @@ import { const appPromise = createBotRuntimeApp() const logger = getLogger('lambda') -export async function handler(event: LambdaFunctionUrlRequest): Promise { +interface ScheduledDispatchLambdaEvent { + source: 'household.scheduled-dispatch' + dispatchId: string +} + +function isScheduledDispatchLambdaEvent(value: unknown): value is ScheduledDispatchLambdaEvent { + if (!value || typeof value !== 'object') { + return false + } + + const candidate = value as Record + return ( + candidate.source === 'household.scheduled-dispatch' && typeof candidate.dispatchId === 'string' + ) +} + +async function handleScheduledDispatchEvent( + event: ScheduledDispatchLambdaEvent +): Promise { + const app = await appPromise + const secret = process.env.SCHEDULER_SHARED_SECRET + + const response = await app.fetch( + new Request(`https://lambda.internal/jobs/dispatch/${event.dispatchId}`, { + method: 'POST', + headers: secret + ? { + 'x-household-scheduler-secret': secret + } + : undefined, + body: JSON.stringify({ + dispatchId: event.dispatchId + }) + }) + ) + + return { + statusCode: response.status, + headers: { + 'content-type': response.headers.get('content-type') ?? 'application/json; charset=utf-8' + }, + body: await response.text() + } +} + +export async function handler( + event: LambdaFunctionUrlRequest | ScheduledDispatchLambdaEvent +): Promise { + if (isScheduledDispatchLambdaEvent(event)) { + return handleScheduledDispatchEvent(event) + } + const app = await appPromise return handleLambdaFunctionUrlEvent(event, app.fetch) } @@ -76,7 +127,9 @@ async function runtimeLoop(): Promise { } try { - const event = (await invocation.json()) as LambdaFunctionUrlRequest + const event = (await invocation.json()) as + | LambdaFunctionUrlRequest + | ScheduledDispatchLambdaEvent const response = await handler(event) await postRuntimeResponse(requestId, response) } catch (error) { diff --git a/apps/bot/src/reminder-jobs.test.ts b/apps/bot/src/reminder-jobs.test.ts deleted file mode 100644 index e52baeb..0000000 --- a/apps/bot/src/reminder-jobs.test.ts +++ /dev/null @@ -1,331 +0,0 @@ -import { describe, expect, mock, test } from 'bun:test' - -import type { ReminderJobResult, ReminderJobService } from '@household/application' -import { Temporal } from '@household/domain' -import type { ReminderTarget } from '@household/ports' - -import { createReminderJobsHandler } from './reminder-jobs' - -const target: ReminderTarget = { - householdId: 'household-1', - householdName: 'Kojori House', - telegramChatId: '-1001', - telegramThreadId: '12', - locale: 'ru', - timezone: 'Asia/Tbilisi', - rentDueDay: 20, - rentWarningDay: 17, - utilitiesDueDay: 4, - utilitiesReminderDay: 3 -} - -const fixedNow = Temporal.Instant.from('2026-03-03T09:00:00Z') - -describe('createReminderJobsHandler', () => { - test('returns per-household dispatch outcome with Telegram delivery metadata', async () => { - const claimedResult: ReminderJobResult = { - status: 'claimed', - dedupeKey: '2026-03:utilities', - payloadHash: 'hash', - reminderType: 'utilities', - period: '2026-03', - messageText: 'Utilities reminder for 2026-03' - } - - const reminderService: ReminderJobService = { - handleJob: mock(async () => claimedResult) - } - const sendReminderMessage = mock(async () => {}) - - const handler = createReminderJobsHandler({ - listReminderTargets: async () => [target], - releaseReminderDispatch: mock(async () => {}), - sendReminderMessage, - reminderService, - now: () => fixedNow, - botUsername: 'household_test_bot' - }) - - const response = await handler.handle( - new Request('http://localhost/jobs/reminder/utilities', { - method: 'POST', - body: JSON.stringify({ - period: '2026-03', - jobId: 'job-1' - }) - }), - 'utilities' - ) - - expect(sendReminderMessage).toHaveBeenCalledTimes(1) - expect(sendReminderMessage).toHaveBeenCalledWith( - target, - expect.objectContaining({ - text: 'Напоминание по коммунальным платежам за 2026-03', - replyMarkup: { - inline_keyboard: [ - [ - { - text: 'Ввести по шагам', - callback_data: 'reminder_util:guided' - }, - { - text: 'Шаблон', - callback_data: 'reminder_util:template' - } - ], - [ - { - text: 'Открыть дашборд', - url: 'https://t.me/household_test_bot?start=dashboard' - } - ] - ] - } - }) - ) - - expect(response.status).toBe(200) - expect(await response.json()).toEqual({ - ok: true, - jobId: 'job-1', - reminderType: 'utilities', - period: '2026-03', - dryRun: false, - totals: { - targets: 1, - claimed: 1, - duplicate: 0, - 'dry-run': 0, - failed: 0 - }, - dispatches: [ - { - householdId: 'household-1', - householdName: 'Kojori House', - telegramChatId: '-1001', - telegramThreadId: '12', - period: '2026-03', - dedupeKey: '2026-03:utilities', - outcome: 'claimed', - messageText: 'Напоминание по коммунальным платежам за 2026-03' - } - ] - }) - }) - - test('supports forced dry-run mode without posting to Telegram', async () => { - const dryRunResult: ReminderJobResult = { - status: 'dry-run', - dedupeKey: '2026-03:rent-warning', - payloadHash: 'hash', - reminderType: 'rent-warning', - period: '2026-03', - messageText: 'Rent reminder for 2026-03: payment is coming up soon.' - } - - const reminderService: ReminderJobService = { - handleJob: mock(async () => dryRunResult) - } - const sendReminderMessage = mock(async () => {}) - - const handler = createReminderJobsHandler({ - listReminderTargets: async () => [target], - releaseReminderDispatch: mock(async () => {}), - sendReminderMessage, - reminderService, - forceDryRun: true, - now: () => fixedNow - }) - - const response = await handler.handle( - new Request('http://localhost/jobs/reminder/rent-warning', { - method: 'POST', - body: JSON.stringify({ period: '2026-03', jobId: 'job-2' }) - }), - 'rent-warning' - ) - - expect(sendReminderMessage).toHaveBeenCalledTimes(0) - expect(response.status).toBe(200) - expect(await response.json()).toMatchObject({ - dryRun: true, - totals: { - targets: 1, - claimed: 0, - duplicate: 0, - 'dry-run': 1, - failed: 0 - } - }) - }) - - test('releases a dispatch claim when Telegram delivery fails', async () => { - const failedResult: ReminderJobResult = { - status: 'claimed', - dedupeKey: '2026-03:rent-due', - payloadHash: 'hash', - reminderType: 'rent-due', - period: '2026-03', - messageText: 'Rent due reminder for 2026-03: please settle payment today.' - } - const reminderService: ReminderJobService = { - handleJob: mock(async () => failedResult) - } - const releaseReminderDispatch = mock(async () => {}) - - const handler = createReminderJobsHandler({ - listReminderTargets: async () => [target], - releaseReminderDispatch, - sendReminderMessage: mock(async () => { - throw new Error('Telegram unavailable') - }), - reminderService, - now: () => fixedNow - }) - - const response = await handler.handle( - new Request('http://localhost/jobs/reminder/rent-due', { - method: 'POST', - body: JSON.stringify({ period: '2026-03' }) - }), - 'rent-due' - ) - - expect(releaseReminderDispatch).toHaveBeenCalledWith({ - householdId: 'household-1', - period: '2026-03', - reminderType: 'rent-due' - }) - expect(response.status).toBe(200) - expect(await response.json()).toMatchObject({ - totals: { - failed: 1 - }, - dispatches: [ - expect.objectContaining({ - outcome: 'failed', - error: 'Telegram unavailable' - }) - ] - }) - }) - - test('rejects unsupported reminder type', async () => { - const handler = createReminderJobsHandler({ - listReminderTargets: async () => [target], - releaseReminderDispatch: mock(async () => {}), - sendReminderMessage: mock(async () => {}), - reminderService: { - handleJob: mock(async () => { - throw new Error('should not be called') - }) - }, - now: () => fixedNow - }) - - const response = await handler.handle( - new Request('http://localhost/jobs/reminder/unknown', { - method: 'POST', - body: JSON.stringify({ period: '2026-03' }) - }), - 'unknown' - ) - - expect(response.status).toBe(400) - expect(await response.json()).toEqual({ - ok: false, - error: 'Invalid reminder type' - }) - }) - - test('skips households whose configured reminder day does not match today when no period override is supplied', async () => { - const reminderService: ReminderJobService = { - handleJob: mock(async () => { - throw new Error('should not be called') - }) - } - - const handler = createReminderJobsHandler({ - listReminderTargets: async () => [ - { - ...target, - utilitiesReminderDay: 31 - } - ], - releaseReminderDispatch: mock(async () => {}), - sendReminderMessage: mock(async () => {}), - reminderService, - now: () => fixedNow - }) - - const response = await handler.handle( - new Request('http://localhost/jobs/reminder/utilities', { - method: 'POST', - body: JSON.stringify({ jobId: 'job-3' }) - }), - 'utilities' - ) - - expect(response.status).toBe(200) - expect(await response.json()).toMatchObject({ - ok: true, - totals: { - targets: 0 - }, - dispatches: [] - }) - }) - - test('honors explicit period overrides even when today is not the configured reminder day', async () => { - const dryRunResult: ReminderJobResult = { - status: 'dry-run', - dedupeKey: '2026-03:rent-due', - payloadHash: 'hash', - reminderType: 'rent-due', - period: '2026-03', - messageText: 'Rent due reminder for 2026-03: please settle payment today.' - } - - const reminderService: ReminderJobService = { - handleJob: mock(async () => dryRunResult) - } - - const handler = createReminderJobsHandler({ - listReminderTargets: async () => [ - { - ...target, - rentDueDay: 20 - } - ], - releaseReminderDispatch: mock(async () => {}), - sendReminderMessage: mock(async () => {}), - reminderService, - now: () => fixedNow - }) - - const response = await handler.handle( - new Request('http://localhost/jobs/reminder/rent-due', { - method: 'POST', - body: JSON.stringify({ period: '2026-03', dryRun: true }) - }), - 'rent-due' - ) - - expect(response.status).toBe(200) - expect(await response.json()).toMatchObject({ - ok: true, - totals: { - targets: 1, - 'dry-run': 1 - }, - dispatches: [ - expect.objectContaining({ - householdId: 'household-1', - period: '2026-03', - outcome: 'dry-run' - }) - ] - }) - }) -}) diff --git a/apps/bot/src/reminder-jobs.ts b/apps/bot/src/reminder-jobs.ts deleted file mode 100644 index 78d50cd..0000000 --- a/apps/bot/src/reminder-jobs.ts +++ /dev/null @@ -1,299 +0,0 @@ -import type { ReminderJobService } from '@household/application' -import { BillingPeriod, Temporal, nowInstant } from '@household/domain' -import type { Logger } from '@household/observability' -import { REMINDER_TYPES, type ReminderTarget, type ReminderType } from '@household/ports' -import type { InlineKeyboardMarkup } from 'grammy/types' - -import { getBotTranslations } from './i18n' -import { buildUtilitiesReminderReplyMarkup } from './reminder-topic-utilities' - -interface ReminderJobRequestBody { - period?: string - jobId?: string - dryRun?: boolean -} - -export interface ReminderMessageContent { - text: string - replyMarkup?: InlineKeyboardMarkup -} - -function json(body: object, status = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { - 'content-type': 'application/json; charset=utf-8' - } - }) -} - -function parseReminderType(raw: string): ReminderType | null { - if ((REMINDER_TYPES as readonly string[]).includes(raw)) { - return raw as ReminderType - } - - return null -} - -function currentPeriod(): string { - return BillingPeriod.fromInstant(nowInstant()).toString() -} - -function targetLocalDate(target: ReminderTarget, instant: Temporal.Instant) { - return instant.toZonedDateTimeISO(target.timezone) -} - -function isReminderDueToday( - target: ReminderTarget, - reminderType: ReminderType, - instant: Temporal.Instant -): boolean { - const currentDay = targetLocalDate(target, instant).day - - switch (reminderType) { - case 'utilities': - return currentDay === target.utilitiesReminderDay - case 'rent-warning': - return currentDay === target.rentWarningDay - case 'rent-due': - return currentDay === target.rentDueDay - } -} - -function targetPeriod(target: ReminderTarget, instant: Temporal.Instant): string { - const localDate = targetLocalDate(target, instant) - return BillingPeriod.fromString( - `${localDate.year}-${String(localDate.month).padStart(2, '0')}` - ).toString() -} - -async function readBody(request: Request): Promise { - const text = await request.text() - - if (text.trim().length === 0) { - return {} - } - - try { - return JSON.parse(text) as ReminderJobRequestBody - } catch { - throw new Error('Invalid JSON body') - } -} - -export function createReminderJobsHandler(options: { - listReminderTargets: () => Promise - ensureBillingCycle?: (input: { householdId: string; at: Temporal.Instant }) => Promise - releaseReminderDispatch: (input: { - householdId: string - period: string - reminderType: ReminderType - }) => Promise - sendReminderMessage: (target: ReminderTarget, content: ReminderMessageContent) => Promise - reminderService: ReminderJobService - forceDryRun?: boolean - now?: () => Temporal.Instant - miniAppUrl?: string - botUsername?: string - logger?: Logger -}): { - handle: (request: Request, rawReminderType: string) => Promise -} { - function messageContent( - target: ReminderTarget, - reminderType: ReminderType, - period: string - ): ReminderMessageContent { - const t = getBotTranslations(target.locale).reminders - - switch (reminderType) { - case 'utilities': - return { - text: t.utilities(period), - replyMarkup: buildUtilitiesReminderReplyMarkup(target.locale, { - ...(options.miniAppUrl - ? { - miniAppUrl: options.miniAppUrl - } - : {}), - ...(options.botUsername - ? { - botUsername: options.botUsername - } - : {}) - }) - } - case 'rent-warning': - return { - text: t.rentWarning(period), - replyMarkup: { - inline_keyboard: [ - [ - { - text: t.openDashboardButton, - url: options.botUsername - ? `https://t.me/${options.botUsername}?start=dashboard` - : '#' - } - ] - ] - } - } - case 'rent-due': - return { - text: t.rentDue(period), - replyMarkup: { - inline_keyboard: [ - [ - { - text: t.openDashboardButton, - url: options.botUsername - ? `https://t.me/${options.botUsername}?start=dashboard` - : '#' - } - ] - ] - } - } - } - } - - return { - handle: async (request, rawReminderType) => { - const reminderType = parseReminderType(rawReminderType) - if (!reminderType) { - return json({ ok: false, error: 'Invalid reminder type' }, 400) - } - - try { - const body = await readBody(request) - const schedulerJobName = request.headers.get('x-cloudscheduler-jobname') - const requestedPeriod = body.period - ? BillingPeriod.fromString(body.period).toString() - : null - const defaultPeriod = requestedPeriod ?? currentPeriod() - const dryRun = options.forceDryRun === true || body.dryRun === true - const currentInstant = options.now?.() ?? nowInstant() - const targets = await options.listReminderTargets() - const dispatches: Array<{ - householdId: string - householdName: string - telegramChatId: string - telegramThreadId: string | null - period: string - dedupeKey: string - outcome: 'dry-run' | 'claimed' | 'duplicate' | 'failed' - messageText: string - error?: string - }> = [] - - for (const target of targets) { - await options.ensureBillingCycle?.({ - householdId: target.householdId, - at: currentInstant - }) - - if (!requestedPeriod && !isReminderDueToday(target, reminderType, currentInstant)) { - continue - } - - const period = requestedPeriod ?? targetPeriod(target, currentInstant) - const result = await options.reminderService.handleJob({ - householdId: target.householdId, - period, - reminderType, - dryRun - }) - const content = messageContent(target, reminderType, period) - - let outcome: 'dry-run' | 'claimed' | 'duplicate' | 'failed' = result.status - let error: string | undefined - - if (result.status === 'claimed') { - try { - await options.sendReminderMessage(target, content) - } catch (dispatchError) { - await options.releaseReminderDispatch({ - householdId: target.householdId, - period, - reminderType - }) - - outcome = 'failed' - error = - dispatchError instanceof Error - ? dispatchError.message - : 'Unknown reminder delivery error' - } - } - - options.logger?.info( - { - event: 'scheduler.reminder.dispatch', - reminderType, - period, - jobId: body.jobId ?? schedulerJobName ?? null, - householdId: target.householdId, - householdName: target.householdName, - dedupeKey: result.dedupeKey, - outcome, - dryRun, - ...(error ? { error } : {}) - }, - 'Reminder job processed' - ) - - dispatches.push({ - householdId: target.householdId, - householdName: target.householdName, - telegramChatId: target.telegramChatId, - telegramThreadId: target.telegramThreadId, - period, - dedupeKey: result.dedupeKey, - outcome, - messageText: content.text, - ...(error ? { error } : {}) - }) - } - - const totals = dispatches.reduce( - (summary, dispatch) => { - summary.targets += 1 - summary[dispatch.outcome] += 1 - return summary - }, - { - targets: 0, - claimed: 0, - duplicate: 0, - 'dry-run': 0, - failed: 0 - } - ) - - return json({ - ok: true, - jobId: body.jobId ?? schedulerJobName ?? null, - reminderType, - period: defaultPeriod, - dryRun, - totals, - dispatches - }) - } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown reminder job error' - - options.logger?.error( - { - event: 'scheduler.reminder.dispatch_failed', - reminderType: rawReminderType, - error: message - }, - 'Reminder job failed' - ) - - return json({ ok: false, error: message }, 400) - } - } - } -} diff --git a/apps/bot/src/scheduled-dispatch-handler.test.ts b/apps/bot/src/scheduled-dispatch-handler.test.ts new file mode 100644 index 0000000..68ba6d7 --- /dev/null +++ b/apps/bot/src/scheduled-dispatch-handler.test.ts @@ -0,0 +1,218 @@ +import { describe, expect, test } from 'bun:test' + +import type { ScheduledDispatchService } from '@household/application' +import { Temporal } from '@household/domain' +import type { + AdHocNotificationRecord, + HouseholdMemberRecord, + HouseholdTelegramChatRecord, + HouseholdTopicBindingRecord, + ScheduledDispatchRecord +} from '@household/ports' + +import { createScheduledDispatchHandler } from './scheduled-dispatch-handler' + +function scheduledDispatch( + input: Partial & + Pick +): ScheduledDispatchRecord { + return { + id: input.id, + householdId: input.householdId, + kind: input.kind, + dueAt: input.dueAt ?? Temporal.Now.instant().subtract({ minutes: 1 }), + timezone: input.timezone ?? 'Asia/Tbilisi', + status: input.status ?? 'scheduled', + provider: input.provider ?? 'gcp-cloud-tasks', + providerDispatchId: input.providerDispatchId ?? 'provider-1', + adHocNotificationId: input.adHocNotificationId ?? null, + period: input.period ?? null, + sentAt: input.sentAt ?? null, + cancelledAt: input.cancelledAt ?? null, + createdAt: input.createdAt ?? Temporal.Instant.from('2026-03-24T00:00:00Z'), + updatedAt: input.updatedAt ?? Temporal.Instant.from('2026-03-24T00:00:00Z') + } +} + +function notification(input: Partial = {}): AdHocNotificationRecord { + return { + id: input.id ?? 'notif-1', + householdId: input.householdId ?? 'household-1', + creatorMemberId: input.creatorMemberId ?? 'creator', + assigneeMemberId: input.assigneeMemberId ?? null, + originalRequestText: 'raw', + notificationText: input.notificationText ?? 'Reminder text', + timezone: input.timezone ?? 'Asia/Tbilisi', + scheduledFor: input.scheduledFor ?? Temporal.Now.instant().subtract({ minutes: 1 }), + timePrecision: input.timePrecision ?? 'exact', + deliveryMode: input.deliveryMode ?? 'topic', + dmRecipientMemberIds: input.dmRecipientMemberIds ?? [], + friendlyTagAssignee: input.friendlyTagAssignee ?? false, + status: input.status ?? 'scheduled', + sourceTelegramChatId: input.sourceTelegramChatId ?? null, + sourceTelegramThreadId: input.sourceTelegramThreadId ?? null, + sentAt: input.sentAt ?? null, + cancelledAt: input.cancelledAt ?? null, + cancelledByMemberId: input.cancelledByMemberId ?? null, + createdAt: input.createdAt ?? Temporal.Instant.from('2026-03-24T00:00:00Z'), + updatedAt: input.updatedAt ?? Temporal.Instant.from('2026-03-24T00:00:00Z') + } +} + +describe('createScheduledDispatchHandler', () => { + test('delivers ad hoc topic notifications exactly once and marks them sent', async () => { + const dispatch = scheduledDispatch({ + id: 'dispatch-1', + householdId: 'household-1', + kind: 'ad_hoc_notification', + adHocNotificationId: 'notif-1' + }) + const sentTopicMessages: string[] = [] + const markedNotifications: string[] = [] + const markedDispatches: string[] = [] + + const service: ScheduledDispatchService = { + scheduleAdHocNotification: async () => dispatch, + cancelAdHocNotification: async () => {}, + reconcileHouseholdBuiltInDispatches: async () => {}, + reconcileAllBuiltInDispatches: async () => {}, + getDispatchById: async () => dispatch, + claimDispatch: async () => true, + releaseDispatch: async () => {}, + markDispatchSent: async (dispatchId) => { + markedDispatches.push(dispatchId) + return dispatch + } + } + + const handler = createScheduledDispatchHandler({ + scheduledDispatchService: service, + adHocNotificationRepository: { + async getNotificationById() { + return notification({ + id: 'notif-1', + scheduledFor: dispatch.dueAt, + notificationText: 'Dima, reminder landed.' + }) + }, + async markNotificationSent(notificationId) { + markedNotifications.push(notificationId) + return null + } + }, + householdConfigurationRepository: { + async getHouseholdChatByHouseholdId(): Promise { + return { + householdId: 'household-1', + householdName: 'Kojori', + telegramChatId: 'chat-1', + telegramChatType: 'supergroup', + title: 'Kojori', + defaultLocale: 'ru' + } + }, + async getHouseholdTopicBinding(): Promise { + return { + householdId: 'household-1', + role: 'reminders', + telegramThreadId: '103', + topicName: 'Reminders' + } + }, + async getHouseholdBillingSettings() { + throw new Error('not used') + }, + async listHouseholdMembers(): Promise { + return [] + } + }, + sendTopicMessage: async (input) => { + sentTopicMessages.push(`${input.chatId}:${input.threadId}:${input.text}`) + }, + sendDirectMessage: async () => { + throw new Error('not used') + } + }) + + const response = await handler.handle( + new Request('http://localhost/jobs/dispatch/dispatch-1', { method: 'POST' }), + 'dispatch-1' + ) + const payload = (await response.json()) as { ok: boolean; outcome: string } + + expect(payload.ok).toBe(true) + expect(payload.outcome).toBe('sent') + expect(sentTopicMessages).toEqual(['chat-1:103:Dima, reminder landed.']) + expect(markedNotifications).toEqual(['notif-1']) + expect(markedDispatches).toEqual(['dispatch-1']) + }) + + test('ignores stale ad hoc dispatch callbacks after a reschedule', async () => { + const dispatch = scheduledDispatch({ + id: 'dispatch-1', + householdId: 'household-1', + kind: 'ad_hoc_notification', + adHocNotificationId: 'notif-1', + dueAt: Temporal.Instant.from('2026-03-24T08:00:00Z') + }) + let released = false + + const service: ScheduledDispatchService = { + scheduleAdHocNotification: async () => dispatch, + cancelAdHocNotification: async () => {}, + reconcileHouseholdBuiltInDispatches: async () => {}, + reconcileAllBuiltInDispatches: async () => {}, + getDispatchById: async () => dispatch, + claimDispatch: async () => true, + releaseDispatch: async () => { + released = true + }, + markDispatchSent: async () => dispatch + } + + const handler = createScheduledDispatchHandler({ + scheduledDispatchService: service, + adHocNotificationRepository: { + async getNotificationById() { + return notification({ + id: 'notif-1', + scheduledFor: Temporal.Instant.from('2026-03-24T09:00:00Z') + }) + }, + async markNotificationSent() { + throw new Error('not used') + } + }, + householdConfigurationRepository: { + async getHouseholdChatByHouseholdId() { + return null + }, + async getHouseholdTopicBinding() { + return null + }, + async getHouseholdBillingSettings() { + throw new Error('not used') + }, + async listHouseholdMembers(): Promise { + return [] + } + }, + sendTopicMessage: async () => { + throw new Error('should not send') + }, + sendDirectMessage: async () => { + throw new Error('should not send') + } + }) + + const response = await handler.handle( + new Request('http://localhost/jobs/dispatch/dispatch-1', { method: 'POST' }), + 'dispatch-1' + ) + const payload = (await response.json()) as { ok: boolean; outcome: string } + + expect(payload.ok).toBe(true) + expect(payload.outcome).toBe('stale') + expect(released).toBe(true) + }) +}) diff --git a/apps/bot/src/scheduled-dispatch-handler.ts b/apps/bot/src/scheduled-dispatch-handler.ts new file mode 100644 index 0000000..36f1f7e --- /dev/null +++ b/apps/bot/src/scheduled-dispatch-handler.ts @@ -0,0 +1,292 @@ +import type { ScheduledDispatchService } from '@household/application' +import { BillingPeriod, nowInstant } from '@household/domain' +import type { Logger } from '@household/observability' +import type { + AdHocNotificationRepository, + HouseholdConfigurationRepository, + HouseholdMemberRecord, + ReminderType +} from '@household/ports' +import type { InlineKeyboardMarkup } from 'grammy/types' + +import { buildTopicNotificationText } from './ad-hoc-notifications' +import { buildScheduledReminderMessageContent } from './scheduled-reminder-content' + +function json(body: object, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { + 'content-type': 'application/json; charset=utf-8' + } + }) +} + +function builtInReminderType(kind: 'utilities' | 'rent_warning' | 'rent_due'): ReminderType { + switch (kind) { + case 'utilities': + return 'utilities' + case 'rent_warning': + return 'rent-warning' + case 'rent_due': + return 'rent-due' + } +} + +export function createScheduledDispatchHandler(options: { + scheduledDispatchService: ScheduledDispatchService + adHocNotificationRepository: Pick< + AdHocNotificationRepository, + 'getNotificationById' | 'markNotificationSent' + > + householdConfigurationRepository: Pick< + HouseholdConfigurationRepository, + | 'getHouseholdChatByHouseholdId' + | 'getHouseholdTopicBinding' + | 'getHouseholdBillingSettings' + | 'listHouseholdMembers' + > + sendTopicMessage: (input: { + householdId: string + chatId: string + threadId: string | null + text: string + parseMode?: 'HTML' + replyMarkup?: InlineKeyboardMarkup + }) => Promise + sendDirectMessage: (input: { telegramUserId: string; text: string }) => Promise + miniAppUrl?: string + botUsername?: string + logger?: Logger +}): { + handle: (request: Request, dispatchId: string) => Promise +} { + async function sendAdHocNotification(dispatchId: string) { + const dispatch = await options.scheduledDispatchService.getDispatchById(dispatchId) + if ( + !dispatch || + dispatch.kind !== 'ad_hoc_notification' || + !dispatch.adHocNotificationId || + dispatch.status !== 'scheduled' + ) { + return { outcome: 'noop' as const } + } + + const currentNow = nowInstant() + if (dispatch.dueAt.epochMilliseconds > currentNow.epochMilliseconds) { + return { outcome: 'not_due' as const } + } + + const claimed = await options.scheduledDispatchService.claimDispatch(dispatch.id) + if (!claimed) { + return { outcome: 'duplicate' as const } + } + + try { + const notification = await options.adHocNotificationRepository.getNotificationById( + dispatch.adHocNotificationId + ) + if (!notification || notification.status !== 'scheduled') { + await options.scheduledDispatchService.markDispatchSent(dispatch.id, currentNow) + return { outcome: 'noop' as const } + } + + if (notification.scheduledFor.epochMilliseconds !== dispatch.dueAt.epochMilliseconds) { + await options.scheduledDispatchService.releaseDispatch(dispatch.id) + return { outcome: 'stale' as const } + } + + if (notification.deliveryMode === 'topic') { + const householdChat = + notification.sourceTelegramChatId ?? + ( + await options.householdConfigurationRepository.getHouseholdChatByHouseholdId( + notification.householdId + ) + )?.telegramChatId + const threadId = + notification.sourceTelegramThreadId ?? + ( + await options.householdConfigurationRepository.getHouseholdTopicBinding( + notification.householdId, + 'reminders' + ) + )?.telegramThreadId ?? + null + + if (!householdChat) { + throw new Error(`Household chat not configured for ${notification.householdId}`) + } + + const content = buildTopicNotificationText({ + notificationText: notification.notificationText + }) + await options.sendTopicMessage({ + householdId: notification.householdId, + chatId: householdChat, + threadId, + text: content.text, + parseMode: content.parseMode + }) + } else { + const members = await options.householdConfigurationRepository.listHouseholdMembers( + notification.householdId + ) + const dmRecipients = notification.dmRecipientMemberIds + .map((memberId) => members.find((member) => member.id === memberId)) + .filter((member): member is HouseholdMemberRecord => Boolean(member)) + + for (const recipient of dmRecipients) { + await options.sendDirectMessage({ + telegramUserId: recipient.telegramUserId, + text: notification.notificationText + }) + } + } + + await options.adHocNotificationRepository.markNotificationSent(notification.id, currentNow) + await options.scheduledDispatchService.markDispatchSent(dispatch.id, currentNow) + return { outcome: 'sent' as const } + } catch (error) { + await options.scheduledDispatchService.releaseDispatch(dispatch.id) + throw error + } + } + + async function sendBuiltInReminder(dispatchId: string) { + const dispatch = await options.scheduledDispatchService.getDispatchById(dispatchId) + if ( + !dispatch || + dispatch.status !== 'scheduled' || + (dispatch.kind !== 'utilities' && + dispatch.kind !== 'rent_warning' && + dispatch.kind !== 'rent_due') + ) { + return { outcome: 'noop' as const } + } + + const currentNow = nowInstant() + if (dispatch.dueAt.epochMilliseconds > currentNow.epochMilliseconds) { + return { outcome: 'not_due' as const } + } + + const claimed = await options.scheduledDispatchService.claimDispatch(dispatch.id) + if (!claimed) { + return { outcome: 'duplicate' as const } + } + + try { + const [chat, reminderTopic] = await Promise.all([ + options.householdConfigurationRepository.getHouseholdChatByHouseholdId( + dispatch.householdId + ), + options.householdConfigurationRepository.getHouseholdTopicBinding( + dispatch.householdId, + 'reminders' + ) + ]) + + if (!chat) { + await options.scheduledDispatchService.markDispatchSent(dispatch.id, currentNow) + return { outcome: 'noop' as const } + } + + const content = buildScheduledReminderMessageContent({ + locale: chat.defaultLocale, + reminderType: builtInReminderType(dispatch.kind), + period: + dispatch.period ?? + BillingPeriod.fromInstant( + dispatch.dueAt.toZonedDateTimeISO(dispatch.timezone).toInstant() + ).toString(), + ...(options.miniAppUrl + ? { + miniAppUrl: options.miniAppUrl + } + : {}), + ...(options.botUsername + ? { + botUsername: options.botUsername + } + : {}) + }) + + await options.sendTopicMessage({ + householdId: dispatch.householdId, + chatId: chat.telegramChatId, + threadId: reminderTopic?.telegramThreadId ?? null, + text: content.text, + ...(content.replyMarkup + ? { + replyMarkup: content.replyMarkup + } + : {}) + }) + + await options.scheduledDispatchService.markDispatchSent(dispatch.id, currentNow) + await options.scheduledDispatchService.reconcileHouseholdBuiltInDispatches( + dispatch.householdId, + currentNow.add({ seconds: 1 }) + ) + return { + outcome: 'sent' as const + } + } catch (error) { + await options.scheduledDispatchService.releaseDispatch(dispatch.id) + throw error + } + } + + return { + handle: async (_request, dispatchId) => { + try { + const dispatch = await options.scheduledDispatchService.getDispatchById(dispatchId) + if (!dispatch) { + return json({ + ok: true, + dispatchId, + outcome: 'noop' + }) + } + + const result = + dispatch.kind === 'ad_hoc_notification' + ? await sendAdHocNotification(dispatchId) + : await sendBuiltInReminder(dispatchId) + + options.logger?.info( + { + event: 'scheduler.scheduled_dispatch.handle', + dispatchId, + householdId: dispatch.householdId, + kind: dispatch.kind, + outcome: result.outcome + }, + 'Scheduled dispatch handled' + ) + + return json({ + ok: true, + dispatchId, + outcome: result.outcome + }) + } catch (error) { + options.logger?.error( + { + event: 'scheduler.scheduled_dispatch.failed', + dispatchId, + error: error instanceof Error ? error.message : String(error) + }, + 'Scheduled dispatch failed' + ) + return json( + { + ok: false, + dispatchId, + error: error instanceof Error ? error.message : 'Unknown error' + }, + 500 + ) + } + } + } +} diff --git a/apps/bot/src/scheduled-reminder-content.ts b/apps/bot/src/scheduled-reminder-content.ts new file mode 100644 index 0000000..ffafcb5 --- /dev/null +++ b/apps/bot/src/scheduled-reminder-content.ts @@ -0,0 +1,70 @@ +import type { ReminderType } from '@household/ports' +import type { InlineKeyboardMarkup } from 'grammy/types' + +import { getBotTranslations } from './i18n' +import type { BotLocale } from './i18n' +import { buildUtilitiesReminderReplyMarkup } from './reminder-topic-utilities' + +export interface ScheduledReminderMessageContent { + text: string + replyMarkup?: InlineKeyboardMarkup +} + +export function buildScheduledReminderMessageContent(input: { + locale: BotLocale + reminderType: ReminderType + period: string + miniAppUrl?: string + botUsername?: string +}): ScheduledReminderMessageContent { + const t = getBotTranslations(input.locale).reminders + const dashboardReplyMarkup = input.botUsername + ? ({ + inline_keyboard: [ + [ + { + text: t.openDashboardButton, + url: `https://t.me/${input.botUsername}?start=dashboard` + } + ] + ] + } satisfies InlineKeyboardMarkup) + : null + + switch (input.reminderType) { + case 'utilities': + return { + text: t.utilities(input.period), + replyMarkup: buildUtilitiesReminderReplyMarkup(input.locale, { + ...(input.miniAppUrl + ? { + miniAppUrl: input.miniAppUrl + } + : {}), + ...(input.botUsername + ? { + botUsername: input.botUsername + } + : {}) + }) + } + case 'rent-warning': + return { + text: t.rentWarning(input.period), + ...(dashboardReplyMarkup + ? { + replyMarkup: dashboardReplyMarkup + } + : {}) + } + case 'rent-due': + return { + text: t.rentDue(input.period), + ...(dashboardReplyMarkup + ? { + replyMarkup: dashboardReplyMarkup + } + : {}) + } + } +} diff --git a/apps/bot/src/scheduler-auth.test.ts b/apps/bot/src/scheduler-auth.test.ts index f63120d..dbf4f7d 100644 --- a/apps/bot/src/scheduler-auth.test.ts +++ b/apps/bot/src/scheduler-auth.test.ts @@ -9,7 +9,7 @@ describe('createSchedulerRequestAuthorizer', () => { }) const authorized = await authorizer.authorize( - new Request('http://localhost/jobs/reminder/utilities', { + new Request('http://localhost/jobs/dispatch/test-dispatch', { headers: { 'x-household-scheduler-secret': 'secret' } @@ -36,7 +36,7 @@ describe('createSchedulerRequestAuthorizer', () => { }) const authorized = await authorizer.authorize( - new Request('http://localhost/jobs/reminder/utilities', { + new Request('http://localhost/jobs/dispatch/test-dispatch', { headers: { authorization: 'Bearer signed-id-token' } @@ -63,7 +63,7 @@ describe('createSchedulerRequestAuthorizer', () => { }) const authorized = await authorizer.authorize( - new Request('http://localhost/jobs/reminder/utilities', { + new Request('http://localhost/jobs/dispatch/test-dispatch', { headers: { authorization: 'Bearer signed-id-token' } diff --git a/apps/bot/src/server.test.ts b/apps/bot/src/server.test.ts index 8ab2ee1..51b5392 100644 --- a/apps/bot/src/server.test.ts +++ b/apps/bot/src/server.test.ts @@ -564,7 +564,7 @@ describe('createBotWebhookServer', () => { test('rejects scheduler request with missing secret', async () => { const response = await server.fetch( - new Request('http://localhost/jobs/reminder/utilities', { + new Request('http://localhost/jobs/dispatch/test-dispatch', { method: 'POST', body: JSON.stringify({ period: '2026-03' }) }) @@ -575,7 +575,7 @@ describe('createBotWebhookServer', () => { test('rejects non-post method for scheduler endpoint', async () => { const response = await server.fetch( - new Request('http://localhost/jobs/reminder/utilities', { + new Request('http://localhost/jobs/dispatch/test-dispatch', { method: 'GET', headers: { 'x-household-scheduler-secret': 'scheduler-secret' @@ -588,7 +588,7 @@ describe('createBotWebhookServer', () => { test('accepts authorized scheduler request', async () => { const response = await server.fetch( - new Request('http://localhost/jobs/reminder/rent-due', { + new Request('http://localhost/jobs/dispatch/test-dispatch', { method: 'POST', headers: { 'x-household-scheduler-secret': 'scheduler-secret' @@ -600,7 +600,7 @@ describe('createBotWebhookServer', () => { expect(response.status).toBe(200) expect(await response.json()).toEqual({ ok: true, - reminderType: 'rent-due' + reminderType: 'dispatch/test-dispatch' }) }) }) diff --git a/apps/bot/src/server.ts b/apps/bot/src/server.ts index 4b31519..87da9e5 100644 --- a/apps/bot/src/server.ts +++ b/apps/bot/src/server.ts @@ -297,9 +297,7 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): { options.miniAppDeletePayment?.path ?? '/api/miniapp/admin/payments/delete' const miniAppLocalePreferencePath = options.miniAppLocalePreference?.path ?? '/api/miniapp/preferences/locale' - const schedulerPathPrefix = options.scheduler - ? (options.scheduler.pathPrefix ?? '/jobs/reminder') - : null + const schedulerPathPrefix = options.scheduler ? (options.scheduler.pathPrefix ?? '/jobs') : null return { fetch: async (request: Request) => { diff --git a/bun.lock b/bun.lock index 857c92c..6685cf5 100644 --- a/bun.lock +++ b/bun.lock @@ -16,6 +16,7 @@ "apps/bot": { "name": "@household/bot", "dependencies": { + "@aws-sdk/client-scheduler": "^3.913.0", "@household/adapters-db": "workspace:*", "@household/application": "workspace:*", "@household/db": "workspace:*", @@ -114,6 +115,7 @@ "scripts": { "name": "@household/scripts", "devDependencies": { + "@aws-sdk/client-scheduler": "^3.913.0", "@household/config": "workspace:*", "@household/db": "workspace:*", "drizzle-orm": "^0.45.1", @@ -131,23 +133,25 @@ "@aws-sdk/client-ecs": ["@aws-sdk/client-ecs@3.1014.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.23", "@aws-sdk/credential-provider-node": "^3.972.24", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.8", "@aws-sdk/middleware-user-agent": "^3.972.24", "@aws-sdk/region-config-resolver": "^3.972.9", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", "@aws-sdk/util-user-agent-node": "^3.973.10", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.12", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/hash-node": "^4.2.12", "@smithy/invalid-dependency": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-retry": "^4.4.44", "@smithy/middleware-serde": "^4.2.15", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/node-http-handler": "^4.5.0", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.43", "@smithy/util-defaults-mode-node": "^4.2.47", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "@smithy/util-waiter": "^4.2.13", "tslib": "^2.6.2" } }, "sha512-wCyy58TBSXzgYkkvrH69VJU7kGd6/XW39p7fJkoGLOvLnWQMFWYVoDHkmp5rId4hsAA1sXVjix1DhXpzyINIDQ=="], - "@aws-sdk/core": ["@aws-sdk/core@3.973.23", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@aws-sdk/xml-builder": "^3.972.15", "@smithy/core": "^3.23.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/signature-v4": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-aoJncvD1XvloZ9JLnKqTRL9dBy+Szkryoag9VT+V1TqsuUgIxV9cnBVM/hrDi2vE8bDqLiDR8nirdRcCdtJu0w=="], + "@aws-sdk/client-scheduler": ["@aws-sdk/client-scheduler@3.1015.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.24", "@aws-sdk/credential-provider-node": "^3.972.25", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.8", "@aws-sdk/middleware-user-agent": "^3.972.25", "@aws-sdk/region-config-resolver": "^3.972.9", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", "@aws-sdk/util-user-agent-node": "^3.973.11", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.12", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/hash-node": "^4.2.12", "@smithy/invalid-dependency": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-retry": "^4.4.44", "@smithy/middleware-serde": "^4.2.15", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/node-http-handler": "^4.5.0", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.43", "@smithy/util-defaults-mode-node": "^4.2.47", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-RZH9AxG62ICguhtgzJ33QTY+GbFr3la3YGPwnSABwJcgpEGY8NkNEWDSY6e4/rs9jdTGwZ7iAflr0BVzhXP8eQ=="], - "@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.21", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-BkAfKq8Bd4shCtec1usNz//urPJF/SZy14qJyxkSaRJQ/Vv1gVh0VZSTmS7aE6aLMELkFV5wHHrS9ZcdG8Kxsg=="], + "@aws-sdk/core": ["@aws-sdk/core@3.973.24", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@aws-sdk/xml-builder": "^3.972.15", "@smithy/core": "^3.23.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/signature-v4": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-vvf82RYQu2GidWAuQq+uIzaPz9V0gSCXVqdVzRosgl5rXcspXOpSD3wFreGGW6AYymPr97Z69kjVnLePBxloDw=="], - "@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/types": "^3.973.6", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/node-http-handler": "^4.5.0", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/util-stream": "^4.5.20", "tslib": "^2.6.2" } }, "sha512-4XZ3+Gu5DY8/n8zQFHBgcKTF7hWQl42G6CY9xfXVo2d25FM/lYkpmuzhYopYoPL1ITWkJ2OSBQfYEu5JRfHOhA=="], + "@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.22", "", { "dependencies": { "@aws-sdk/core": "^3.973.24", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-cXp0VTDWT76p3hyK5D51yIKEfpf6/zsUvMfaB8CkyqadJxMQ8SbEeVroregmDlZbtG31wkj9ei0WnftmieggLg=="], - "@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/credential-provider-env": "^3.972.21", "@aws-sdk/credential-provider-http": "^3.972.23", "@aws-sdk/credential-provider-login": "^3.972.23", "@aws-sdk/credential-provider-process": "^3.972.21", "@aws-sdk/credential-provider-sso": "^3.972.23", "@aws-sdk/credential-provider-web-identity": "^3.972.23", "@aws-sdk/nested-clients": "^3.996.13", "@aws-sdk/types": "^3.973.6", "@smithy/credential-provider-imds": "^4.2.12", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-PZLSmU0JFpNCDFReidBezsgL5ji9jOBry8CnZdw4Jj6d0K2z3Ftnp44NXgADqYx5BLMu/ZHujfeJReaDoV+IwQ=="], + "@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.24", "", { "dependencies": { "@aws-sdk/core": "^3.973.24", "@aws-sdk/types": "^3.973.6", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/node-http-handler": "^4.5.0", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/util-stream": "^4.5.20", "tslib": "^2.6.2" } }, "sha512-h694K7+tRuepSRJr09wTvQfaEnjzsKZ5s7fbESrVds02GT/QzViJ94/HCNwM7bUfFxqpPXHxulZfL6Cou0dwPg=="], - "@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/nested-clients": "^3.996.13", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-OmE/pSkbMM3dCj1HdOnZ5kXnKK+R/Yz+kbBugraBecp0pGAs21eEURfQRz+1N2gzIHLVyGIP1MEjk/uSrFsngg=="], + "@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.24", "", { "dependencies": { "@aws-sdk/core": "^3.973.24", "@aws-sdk/credential-provider-env": "^3.972.22", "@aws-sdk/credential-provider-http": "^3.972.24", "@aws-sdk/credential-provider-login": "^3.972.24", "@aws-sdk/credential-provider-process": "^3.972.22", "@aws-sdk/credential-provider-sso": "^3.972.24", "@aws-sdk/credential-provider-web-identity": "^3.972.24", "@aws-sdk/nested-clients": "^3.996.14", "@aws-sdk/types": "^3.973.6", "@smithy/credential-provider-imds": "^4.2.12", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-O46fFmv0RDFWiWEA9/e6oW92BnsyAXuEgTTasxHligjn2RCr9L/DK773m/NoFaL3ZdNAUz8WxgxunleMnHAkeQ=="], - "@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.24", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.21", "@aws-sdk/credential-provider-http": "^3.972.23", "@aws-sdk/credential-provider-ini": "^3.972.23", "@aws-sdk/credential-provider-process": "^3.972.21", "@aws-sdk/credential-provider-sso": "^3.972.23", "@aws-sdk/credential-provider-web-identity": "^3.972.23", "@aws-sdk/types": "^3.973.6", "@smithy/credential-provider-imds": "^4.2.12", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-9Jwi7aps3AfUicJyF5udYadPypPpCwUZ6BSKr/QjRbVCpRVS1wc+1Q6AEZ/qz8J4JraeRd247pSzyMQSIHVebw=="], + "@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.24", "", { "dependencies": { "@aws-sdk/core": "^3.973.24", "@aws-sdk/nested-clients": "^3.996.14", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-sIk8oa6AzDoUhxsR11svZESqvzGuXesw62Rl2oW6wguZx8i9cdGCvkFg+h5K7iucUZP8wyWibUbJMc+J66cu5g=="], - "@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.21", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-nRxbeOJ1E1gVA0lNQezuMVndx+ZcuyaW/RB05pUsznN5BxykSlH6KkZ/7Ca/ubJf3i5N3p0gwNO5zgPSCzj+ww=="], + "@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.25", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.22", "@aws-sdk/credential-provider-http": "^3.972.24", "@aws-sdk/credential-provider-ini": "^3.972.24", "@aws-sdk/credential-provider-process": "^3.972.22", "@aws-sdk/credential-provider-sso": "^3.972.24", "@aws-sdk/credential-provider-web-identity": "^3.972.24", "@aws-sdk/types": "^3.973.6", "@smithy/credential-provider-imds": "^4.2.12", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-m7dR0Dsva2P+VUpL+VkC0WwiDby5pgmWXkRVDB5rlwv0jXJrQJf7YMtCoM8Wjk0H9jPeCYOxOXXcIgp/qp5Alg=="], - "@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/nested-clients": "^3.996.13", "@aws-sdk/token-providers": "3.1014.0", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-APUccADuYPLL0f2htpM8Z4czabSmHOdo4r41W6lKEZdy++cNJ42Radqy6x4TopENzr3hR6WYMyhiuiqtbf/nAA=="], + "@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.22", "", { "dependencies": { "@aws-sdk/core": "^3.973.24", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-Os32s8/4gTZjBk5BtoS/cuTILaj+K72d0dVG7TCJX/fC4598cxwLDmf1AEHEpER5oL3K//yETjvFaz0V8oO5Xw=="], - "@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/nested-clients": "^3.996.13", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-H5JNqtIwOu/feInmMMWcK0dL5r897ReEn7n2m16Dd0DPD9gA2Hg8Cq4UDzZ/9OzaLh/uqBM6seixz0U6Fi2Eag=="], + "@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.24", "", { "dependencies": { "@aws-sdk/core": "^3.973.24", "@aws-sdk/nested-clients": "^3.996.14", "@aws-sdk/token-providers": "3.1015.0", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-PaFv7snEfypU2yXkpvfyWgddEbDLtgVe51wdZlinhc2doubBjUzJZZpgwuF2Jenl1FBydMhNpMjD6SBUM3qdSA=="], + + "@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.24", "", { "dependencies": { "@aws-sdk/core": "^3.973.24", "@aws-sdk/nested-clients": "^3.996.14", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-J6H4R1nvr3uBTqD/EeIPAskrBtET4WFfNhpFySr2xW7bVZOXpQfPjrLSIx65jcNjBmLXzWq8QFLdVoGxiGG/SA=="], "@aws-sdk/middleware-host-header": ["@aws-sdk/middleware-host-header@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-wAr2REfKsqoKQ+OkNqvOShnBoh+nkPurDKW7uAeVSu6kUECnWlSJiPvnoqxGlfousEY/v9LfS9sNc46hjSYDIQ=="], @@ -155,13 +159,13 @@ "@aws-sdk/middleware-recursion-detection": ["@aws-sdk/middleware-recursion-detection@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-BnnvYs2ZEpdlmZ2PNlV2ZyQ8j8AEkMTjN79y/YA475ER1ByFYrkVR85qmhni8oeTaJcDqbx364wDpitDAA/wCA=="], - "@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.972.24", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@smithy/core": "^3.23.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-retry": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-dLTWy6IfAMhNiSEvMr07g/qZ54be6pLqlxVblbF6AzafmmGAzMMj8qMoY9B4+YgT+gY9IcuxZslNh03L6PyMCQ=="], + "@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.972.25", "", { "dependencies": { "@aws-sdk/core": "^3.973.24", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@smithy/core": "^3.23.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-retry": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-QxiMPofvOt8SwSynTOmuZfvvPM1S9QfkESBxB22NMHTRXCJhR5BygLl8IXfC4jELiisQgwsgUby21GtXfX3f/g=="], - "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.996.13", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.23", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.8", "@aws-sdk/middleware-user-agent": "^3.972.24", "@aws-sdk/region-config-resolver": "^3.972.9", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", "@aws-sdk/util-user-agent-node": "^3.973.10", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.12", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/hash-node": "^4.2.12", "@smithy/invalid-dependency": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-retry": "^4.4.44", "@smithy/middleware-serde": "^4.2.15", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/node-http-handler": "^4.5.0", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.43", "@smithy/util-defaults-mode-node": "^4.2.47", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-ptZ1HF4yYHNJX8cgFF+8NdYO69XJKZn7ft0/ynV3c0hCbN+89fAbrLS+fqniU2tW8o9Kfqhj8FUh+IPXb2Qsuw=="], + "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.996.14", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.24", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.8", "@aws-sdk/middleware-user-agent": "^3.972.25", "@aws-sdk/region-config-resolver": "^3.972.9", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", "@aws-sdk/util-user-agent-node": "^3.973.11", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.12", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/hash-node": "^4.2.12", "@smithy/invalid-dependency": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-retry": "^4.4.44", "@smithy/middleware-serde": "^4.2.15", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/node-http-handler": "^4.5.0", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.43", "@smithy/util-defaults-mode-node": "^4.2.47", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-fSESKvh1VbfjtV3QMnRkCPZWkUbQof6T/DOpiLp33yP2wA+rbwwnZeG3XT3Ekljgw2I8X4XaQPnw+zSR8yxJ5Q=="], "@aws-sdk/region-config-resolver": ["@aws-sdk/region-config-resolver@3.972.9", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/config-resolver": "^4.4.13", "@smithy/node-config-provider": "^4.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-eQ+dFU05ZRC/lC2XpYlYSPlXtX3VT8sn5toxN2Fv7EXlMoA2p9V7vUBKqHunfD4TRLpxUq8Y8Ol/nCqiv327Ng=="], - "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1014.0", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/nested-clients": "^3.996.13", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-gHTHNUoaOGNrSWkl32A7wFsU78jlNTlqMccLu0byUk5CysYYXaxNMIonIVr4YcykC7vgtDS5ABuz83giy6fzJA=="], + "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1015.0", "", { "dependencies": { "@aws-sdk/core": "^3.973.24", "@aws-sdk/nested-clients": "^3.996.14", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-3OSD4y110nisRhHzFOjoEeHU4GQL4KpzkX9PxzWaiZe0Yg2+thZKM0Pn9DjYwezH5JYfh/K++xK/SE0IHGrmCQ=="], "@aws-sdk/types": ["@aws-sdk/types@3.973.6", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-Atfcy4E++beKtwJHiDln2Nby8W/mam64opFPTiHEqgsthqeydFS1pY+OUlN1ouNOmf8ArPU/6cDS65anOP3KQw=="], @@ -171,7 +175,7 @@ "@aws-sdk/util-user-agent-browser": ["@aws-sdk/util-user-agent-browser@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-B3KGXJviV2u6Cdw2SDY2aDhoJkVfY/Q/Trwk2CMSkikE1Oi6gRzxhvhIfiRpHfmIsAhV4EA54TVEX8K6CbHbkA=="], - "@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.973.10", "", { "dependencies": { "@aws-sdk/middleware-user-agent": "^3.972.24", "@aws-sdk/types": "^3.973.6", "@smithy/node-config-provider": "^4.3.12", "@smithy/types": "^4.13.1", "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" }, "peerDependencies": { "aws-crt": ">=1.0.0" }, "optionalPeers": ["aws-crt"] }, "sha512-E99zeTscCc+pTMfsvnfi6foPpKmdD1cZfOC7/P8UUrjsoQdg9VEWPRD+xdFduKnfPXwcvby58AlO9jwwF6U96g=="], + "@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.973.11", "", { "dependencies": { "@aws-sdk/middleware-user-agent": "^3.972.25", "@aws-sdk/types": "^3.973.6", "@smithy/node-config-provider": "^4.3.12", "@smithy/types": "^4.13.1", "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" }, "peerDependencies": { "aws-crt": ">=1.0.0" }, "optionalPeers": ["aws-crt"] }, "sha512-1qdXbXo2s5MMLpUvw00284LsbhtlQ4ul7Zzdn5n+7p4WVgCMLqhxImpHIrjSoc72E/fyc4Wq8dLtUld2Gsh+lA=="], "@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.15", "", { "dependencies": { "@smithy/types": "^4.13.1", "fast-xml-parser": "5.5.8", "tslib": "^2.6.2" } }, "sha512-PxMRlCFNiQnke9YR29vjFQwz4jq+6Q04rOVFeTDR2K7Qpv9h9FOWOxG+zJjageimYbWqE3bTuLjmryWHAWbvaA=="], @@ -1365,6 +1369,14 @@ "@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], + "@aws-sdk/client-ecs/@aws-sdk/core": ["@aws-sdk/core@3.973.23", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@aws-sdk/xml-builder": "^3.972.15", "@smithy/core": "^3.23.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/signature-v4": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-aoJncvD1XvloZ9JLnKqTRL9dBy+Szkryoag9VT+V1TqsuUgIxV9cnBVM/hrDi2vE8bDqLiDR8nirdRcCdtJu0w=="], + + "@aws-sdk/client-ecs/@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.24", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.21", "@aws-sdk/credential-provider-http": "^3.972.23", "@aws-sdk/credential-provider-ini": "^3.972.23", "@aws-sdk/credential-provider-process": "^3.972.21", "@aws-sdk/credential-provider-sso": "^3.972.23", "@aws-sdk/credential-provider-web-identity": "^3.972.23", "@aws-sdk/types": "^3.973.6", "@smithy/credential-provider-imds": "^4.2.12", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-9Jwi7aps3AfUicJyF5udYadPypPpCwUZ6BSKr/QjRbVCpRVS1wc+1Q6AEZ/qz8J4JraeRd247pSzyMQSIHVebw=="], + + "@aws-sdk/client-ecs/@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.972.24", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@smithy/core": "^3.23.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-retry": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-dLTWy6IfAMhNiSEvMr07g/qZ54be6pLqlxVblbF6AzafmmGAzMMj8qMoY9B4+YgT+gY9IcuxZslNh03L6PyMCQ=="], + + "@aws-sdk/client-ecs/@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.973.10", "", { "dependencies": { "@aws-sdk/middleware-user-agent": "^3.972.24", "@aws-sdk/types": "^3.973.6", "@smithy/node-config-provider": "^4.3.12", "@smithy/types": "^4.13.1", "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" }, "peerDependencies": { "aws-crt": ">=1.0.0" }, "optionalPeers": ["aws-crt"] }, "sha512-E99zeTscCc+pTMfsvnfi6foPpKmdD1cZfOC7/P8UUrjsoQdg9VEWPRD+xdFduKnfPXwcvby58AlO9jwwF6U96g=="], + "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], @@ -1453,6 +1465,18 @@ "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], + "@aws-sdk/client-ecs/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.21", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-BkAfKq8Bd4shCtec1usNz//urPJF/SZy14qJyxkSaRJQ/Vv1gVh0VZSTmS7aE6aLMELkFV5wHHrS9ZcdG8Kxsg=="], + + "@aws-sdk/client-ecs/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/types": "^3.973.6", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/node-http-handler": "^4.5.0", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/util-stream": "^4.5.20", "tslib": "^2.6.2" } }, "sha512-4XZ3+Gu5DY8/n8zQFHBgcKTF7hWQl42G6CY9xfXVo2d25FM/lYkpmuzhYopYoPL1ITWkJ2OSBQfYEu5JRfHOhA=="], + + "@aws-sdk/client-ecs/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/credential-provider-env": "^3.972.21", "@aws-sdk/credential-provider-http": "^3.972.23", "@aws-sdk/credential-provider-login": "^3.972.23", "@aws-sdk/credential-provider-process": "^3.972.21", "@aws-sdk/credential-provider-sso": "^3.972.23", "@aws-sdk/credential-provider-web-identity": "^3.972.23", "@aws-sdk/nested-clients": "^3.996.13", "@aws-sdk/types": "^3.973.6", "@smithy/credential-provider-imds": "^4.2.12", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-PZLSmU0JFpNCDFReidBezsgL5ji9jOBry8CnZdw4Jj6d0K2z3Ftnp44NXgADqYx5BLMu/ZHujfeJReaDoV+IwQ=="], + + "@aws-sdk/client-ecs/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.21", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-nRxbeOJ1E1gVA0lNQezuMVndx+ZcuyaW/RB05pUsznN5BxykSlH6KkZ/7Ca/ubJf3i5N3p0gwNO5zgPSCzj+ww=="], + + "@aws-sdk/client-ecs/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/nested-clients": "^3.996.13", "@aws-sdk/token-providers": "3.1014.0", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-APUccADuYPLL0f2htpM8Z4czabSmHOdo4r41W6lKEZdy++cNJ42Radqy6x4TopENzr3hR6WYMyhiuiqtbf/nAA=="], + + "@aws-sdk/client-ecs/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/nested-clients": "^3.996.13", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-H5JNqtIwOu/feInmMMWcK0dL5r897ReEn7n2m16Dd0DPD9gA2Hg8Cq4UDzZ/9OzaLh/uqBM6seixz0U6Fi2Eag=="], + "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], @@ -1641,6 +1665,16 @@ "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], + "@aws-sdk/client-ecs/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/nested-clients": "^3.996.13", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-OmE/pSkbMM3dCj1HdOnZ5kXnKK+R/Yz+kbBugraBecp0pGAs21eEURfQRz+1N2gzIHLVyGIP1MEjk/uSrFsngg=="], + + "@aws-sdk/client-ecs/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.996.13", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.23", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.8", "@aws-sdk/middleware-user-agent": "^3.972.24", "@aws-sdk/region-config-resolver": "^3.972.9", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", "@aws-sdk/util-user-agent-node": "^3.973.10", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.12", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/hash-node": "^4.2.12", "@smithy/invalid-dependency": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-retry": "^4.4.44", "@smithy/middleware-serde": "^4.2.15", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/node-http-handler": "^4.5.0", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.43", "@smithy/util-defaults-mode-node": "^4.2.47", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-ptZ1HF4yYHNJX8cgFF+8NdYO69XJKZn7ft0/ynV3c0hCbN+89fAbrLS+fqniU2tW8o9Kfqhj8FUh+IPXb2Qsuw=="], + + "@aws-sdk/client-ecs/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.996.13", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.23", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.8", "@aws-sdk/middleware-user-agent": "^3.972.24", "@aws-sdk/region-config-resolver": "^3.972.9", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", "@aws-sdk/util-user-agent-node": "^3.973.10", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.12", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/hash-node": "^4.2.12", "@smithy/invalid-dependency": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-retry": "^4.4.44", "@smithy/middleware-serde": "^4.2.15", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/node-http-handler": "^4.5.0", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.43", "@smithy/util-defaults-mode-node": "^4.2.47", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-ptZ1HF4yYHNJX8cgFF+8NdYO69XJKZn7ft0/ynV3c0hCbN+89fAbrLS+fqniU2tW8o9Kfqhj8FUh+IPXb2Qsuw=="], + + "@aws-sdk/client-ecs/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1014.0", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/nested-clients": "^3.996.13", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-gHTHNUoaOGNrSWkl32A7wFsU78jlNTlqMccLu0byUk5CysYYXaxNMIonIVr4YcykC7vgtDS5ABuz83giy6fzJA=="], + + "@aws-sdk/client-ecs/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.996.13", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.23", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.8", "@aws-sdk/middleware-user-agent": "^3.972.24", "@aws-sdk/region-config-resolver": "^3.972.9", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", "@aws-sdk/util-user-agent-node": "^3.973.10", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.12", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/hash-node": "^4.2.12", "@smithy/invalid-dependency": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-retry": "^4.4.44", "@smithy/middleware-serde": "^4.2.15", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/node-http-handler": "^4.5.0", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.43", "@smithy/util-defaults-mode-node": "^4.2.47", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-ptZ1HF4yYHNJX8cgFF+8NdYO69XJKZn7ft0/ynV3c0hCbN+89fAbrLS+fqniU2tW8o9Kfqhj8FUh+IPXb2Qsuw=="], + "rimraf/glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], "rimraf/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], diff --git a/infra/pulumi/aws/index.ts b/infra/pulumi/aws/index.ts index cd71b1d..519e4e6 100644 --- a/infra/pulumi/aws/index.ts +++ b/infra/pulumi/aws/index.ts @@ -4,6 +4,8 @@ import * as pulumi from '@pulumi/pulumi' const config = new pulumi.Config() const awsConfig = new pulumi.Config('aws') +const region = awsConfig.get('region') || aws.getRegionOutput().name +const accountId = aws.getCallerIdentityOutput().accountId const appName = config.get('appName') ?? 'household' const environment = config.get('environment') ?? pulumi.getStack() @@ -23,6 +25,10 @@ const logLevel = config.get('logLevel') ?? 'info' const purchaseParserModel = config.get('purchaseParserModel') ?? 'gpt-4o-mini' const assistantModel = config.get('assistantModel') ?? 'gpt-4o-mini' const topicProcessorModel = config.get('topicProcessorModel') ?? 'gpt-4o-mini' +const scheduledDispatchGroupName = + config.get('scheduledDispatchGroupName') ?? 'scheduled-dispatches' +const lambdaFunctionName = `${appName}-${environment}-bot` +const scheduledDispatchTargetLambdaArn = pulumi.interpolate`arn:aws:lambda:${region}:${accountId}:function:${lambdaFunctionName}` const telegramBotToken = config.requireSecret('telegramBotToken') const telegramWebhookSecret = config.requireSecret('telegramWebhookSecret') @@ -58,6 +64,18 @@ new aws.iam.RolePolicyAttachment(`${appName}-${environment}-lambda-basic-exec`, policyArn: 'arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole' }) +const schedulerGroup = new aws.scheduler.ScheduleGroup(`${appName}-${environment}-dispatches`, { + name: scheduledDispatchGroupName, + tags +}) + +const schedulerInvokeRole = new aws.iam.Role(`${appName}-${environment}-scheduler-invoke-role`, { + assumeRolePolicy: aws.iam.assumeRolePolicyForPrincipal({ + Service: 'scheduler.amazonaws.com' + }), + tags +}) + const secretSpecs = [ { key: 'telegramBotToken', @@ -160,6 +178,7 @@ new aws.s3.BucketPolicy(`${appName}-${environment}-miniapp-policy`, { }) const lambda = new aws.lambda.Function(`${appName}-${environment}-bot`, { + name: lambdaFunctionName, packageType: 'Image', imageUri: botImage.imageUri, role: lambdaRole.arn, @@ -175,6 +194,11 @@ const lambda = new aws.lambda.Function(`${appName}-${environment}-bot`, { TELEGRAM_WEBHOOK_PATH: config.get('telegramWebhookPath') ?? '/webhook/telegram', DATABASE_URL: databaseUrl ?? '', SCHEDULER_SHARED_SECRET: schedulerSharedSecret ?? '', + SCHEDULED_DISPATCH_PROVIDER: 'aws-eventbridge', + AWS_SCHEDULED_DISPATCH_REGION: region, + AWS_SCHEDULED_DISPATCH_TARGET_LAMBDA_ARN: scheduledDispatchTargetLambdaArn, + AWS_SCHEDULED_DISPATCH_ROLE_ARN: schedulerInvokeRole.arn, + AWS_SCHEDULED_DISPATCH_GROUP_NAME: schedulerGroup.name, OPENAI_API_KEY: openaiApiKey ?? '', MINI_APP_URL: miniAppUrl, MINI_APP_ALLOWED_ORIGINS: miniAppAllowedOrigins.join(','), @@ -186,6 +210,43 @@ const lambda = new aws.lambda.Function(`${appName}-${environment}-bot`, { tags }) +new aws.iam.RolePolicy(`${appName}-${environment}-lambda-scheduler-policy`, { + role: lambdaRole.id, + policy: schedulerInvokeRole.arn.apply((schedulerInvokeRoleArn) => + JSON.stringify({ + Version: '2012-10-17', + Statement: [ + { + Effect: 'Allow', + Action: ['scheduler:CreateSchedule', 'scheduler:DeleteSchedule', 'scheduler:GetSchedule'], + Resource: '*' + }, + { + Effect: 'Allow', + Action: ['iam:PassRole'], + Resource: schedulerInvokeRoleArn + } + ] + }) + ) +}) + +new aws.iam.RolePolicy(`${appName}-${environment}-scheduler-invoke-policy`, { + role: schedulerInvokeRole.id, + policy: lambda.arn.apply((lambdaArn) => + JSON.stringify({ + Version: '2012-10-17', + Statement: [ + { + Effect: 'Allow', + Action: ['lambda:InvokeFunction'], + Resource: lambdaArn + } + ] + }) + ) +}) + const functionUrl = new aws.lambda.FunctionUrl(`${appName}-${environment}-bot-url`, { functionName: lambda.name, authorizationType: 'NONE', @@ -199,8 +260,6 @@ const functionUrl = new aws.lambda.FunctionUrl(`${appName}-${environment}-bot-ur } }) -const region = awsConfig.get('region') || aws.getRegionOutput().name - export const botOriginUrl = functionUrl.functionUrl export const miniAppBucketName = bucket.bucket export const miniAppWebsiteUrl = pulumi.interpolate`http://${bucket.websiteEndpoint}` diff --git a/infra/terraform/locals.tf b/infra/terraform/locals.tf index 67f9142..0a87469 100644 --- a/infra/terraform/locals.tf +++ b/infra/terraform/locals.tf @@ -12,21 +12,6 @@ locals { artifact_location = coalesce(var.artifact_repository_location, var.region) - reminder_jobs = { - utilities = { - schedule = var.scheduler_utilities_cron - path = "/jobs/reminder/utilities" - } - rent-warning = { - schedule = var.scheduler_rent_warning_cron - path = "/jobs/reminder/rent-warning" - } - rent-due = { - schedule = var.scheduler_rent_due_cron - path = "/jobs/reminder/rent-due" - } - } - runtime_secret_ids = toset(compact([ var.telegram_webhook_secret_id, var.scheduler_shared_secret_id, @@ -37,6 +22,7 @@ locals { api_services = toset([ "artifactregistry.googleapis.com", + "cloudtasks.googleapis.com", "cloudscheduler.googleapis.com", "iam.googleapis.com", "iamcredentials.googleapis.com", diff --git a/infra/terraform/main.tf b/infra/terraform/main.tf index 96cd4f2..595056d 100644 --- a/infra/terraform/main.tf +++ b/infra/terraform/main.tf @@ -58,10 +58,18 @@ resource "google_service_account" "mini_runtime" { display_name = "${local.name_prefix} mini runtime" } -resource "google_service_account" "scheduler_invoker" { - project = var.project_id - account_id = "${var.environment}-scheduler" - display_name = "${local.name_prefix} scheduler invoker" +resource "google_cloud_tasks_queue" "scheduled_dispatches" { + project = var.project_id + location = var.region + name = var.scheduled_dispatch_queue_name + + depends_on = [google_project_service.enabled] +} + +resource "google_project_iam_member" "bot_runtime_cloud_tasks_enqueuer" { + project = var.project_id + role = "roles/cloudtasks.enqueuer" + member = "serviceAccount:${google_service_account.bot_runtime.email}" } resource "google_secret_manager_secret" "runtime" { @@ -169,8 +177,12 @@ module "bot_api_service" { var.bot_mini_app_url == null ? {} : { MINI_APP_URL = var.bot_mini_app_url }, - { - SCHEDULER_OIDC_ALLOWED_EMAILS = google_service_account.scheduler_invoker.email + var.scheduled_dispatch_public_base_url == null ? {} : { + SCHEDULED_DISPATCH_PROVIDER = "gcp-cloud-tasks" + SCHEDULED_DISPATCH_PUBLIC_BASE_URL = var.scheduled_dispatch_public_base_url + GCP_SCHEDULED_DISPATCH_PROJECT_ID = var.project_id + GCP_SCHEDULED_DISPATCH_LOCATION = var.region + GCP_SCHEDULED_DISPATCH_QUEUE = google_cloud_tasks_queue.scheduled_dispatches.name } ) @@ -192,6 +204,8 @@ module "bot_api_service" { depends_on = [ google_project_service.enabled, + google_cloud_tasks_queue.scheduled_dispatches, + google_project_iam_member.bot_runtime_cloud_tasks_enqueuer, google_secret_manager_secret.runtime, google_secret_manager_secret_iam_member.bot_runtime_access ] @@ -218,54 +232,6 @@ module "mini_app_service" { depends_on = [google_project_service.enabled] } -resource "google_cloud_run_v2_service_iam_member" "scheduler_invoker" { - project = var.project_id - location = var.region - name = module.bot_api_service.name - role = "roles/run.invoker" - member = "serviceAccount:${google_service_account.scheduler_invoker.email}" -} - -resource "google_service_account_iam_member" "scheduler_token_creator" { - service_account_id = google_service_account.scheduler_invoker.name - role = "roles/iam.serviceAccountTokenCreator" - member = "serviceAccount:service-${data.google_project.current.number}@gcp-sa-cloudscheduler.iam.gserviceaccount.com" -} - -resource "google_cloud_scheduler_job" "reminders" { - for_each = local.reminder_jobs - - project = var.project_id - region = var.region - name = "${local.name_prefix}-${each.key}" - schedule = each.value.schedule - time_zone = var.scheduler_timezone - paused = var.scheduler_paused - - http_target { - uri = "${module.bot_api_service.uri}${each.value.path}" - http_method = "POST" - - headers = { - "Content-Type" = "application/json" - } - - body = base64encode(jsonencode({ - dryRun = var.scheduler_dry_run - jobId = "${local.name_prefix}-${each.key}" - })) - - oidc_token { - service_account_email = google_service_account.scheduler_invoker.email - audience = module.bot_api_service.uri - } - } - - depends_on = [ - module.bot_api_service, - google_service_account_iam_member.scheduler_token_creator - ] -} resource "google_service_account" "github_deployer" { count = var.create_workload_identity ? 1 : 0 diff --git a/infra/terraform/outputs.tf b/infra/terraform/outputs.tf index 985fab2..0e2b95f 100644 --- a/infra/terraform/outputs.tf +++ b/infra/terraform/outputs.tf @@ -23,11 +23,6 @@ output "mini_app_service_url" { value = module.mini_app_service.uri } -output "scheduler_job_names" { - description = "Cloud Scheduler jobs for reminders" - value = { for name, job in google_cloud_scheduler_job.reminders : name => job.name } -} - output "runtime_secret_ids" { description = "Secret Manager IDs expected by runtime" value = sort([for secret in google_secret_manager_secret.runtime : secret.secret_id]) diff --git a/infra/terraform/terraform.tfvars.example b/infra/terraform/terraform.tfvars.example index 925d8d2..0648769 100644 --- a/infra/terraform/terraform.tfvars.example +++ b/infra/terraform/terraform.tfvars.example @@ -28,12 +28,8 @@ alert_notification_emails = [ "alerts@example.com" ] -scheduler_utilities_cron = "0 9 * * *" -scheduler_rent_warning_cron = "0 9 * * *" -scheduler_rent_due_cron = "0 9 * * *" -scheduler_timezone = "Asia/Tbilisi" -scheduler_paused = true -scheduler_dry_run = true +scheduled_dispatch_queue_name = "scheduled-dispatches" +scheduled_dispatch_public_base_url = "https://api.example.com" create_workload_identity = true github_repository = "whekin/household-bot" diff --git a/infra/terraform/variables.tf b/infra/terraform/variables.tf index bf685a5..9224522 100644 --- a/infra/terraform/variables.tf +++ b/infra/terraform/variables.tf @@ -165,40 +165,17 @@ variable "openai_api_key_secret_id" { nullable = true } -variable "scheduler_timezone" { - description = "Scheduler timezone" +variable "scheduled_dispatch_queue_name" { + description = "Cloud Tasks queue name for one-shot reminder dispatches" type = string - default = "Asia/Tbilisi" + default = "scheduled-dispatches" } -variable "scheduler_utilities_cron" { - description = "Cron expression for the utilities reminder scheduler job. Daily cadence is recommended because the app filters per household." +variable "scheduled_dispatch_public_base_url" { + description = "Public bot base URL used by Cloud Tasks callbacks for scheduled dispatches" type = string - default = "0 9 * * *" -} - -variable "scheduler_rent_warning_cron" { - description = "Cron expression for the rent warning scheduler job. Daily cadence is recommended because the app filters per household." - type = string - default = "0 9 * * *" -} - -variable "scheduler_rent_due_cron" { - description = "Cron expression for the rent due scheduler job. Daily cadence is recommended because the app filters per household." - type = string - default = "0 9 * * *" -} - -variable "scheduler_dry_run" { - description = "Whether scheduler jobs should invoke the bot in dry-run mode" - type = bool - default = true -} - -variable "scheduler_paused" { - description = "Whether scheduler should be paused initially" - type = bool - default = true + default = null + nullable = true } variable "bot_min_instances" { diff --git a/package.json b/package.json index d6cb6a4..dbd5ce0 100644 --- a/package.json +++ b/package.json @@ -49,8 +49,7 @@ "ops:deploy:smoke": "bun run scripts/ops/deploy-smoke.ts", "ops:aws:miniapp:publish": "bun run scripts/ops/publish-miniapp-aws.ts", "ops:telegram:webhook": "bun run scripts/ops/telegram-webhook.ts", - "ops:telegram:commands": "bun run scripts/ops/telegram-commands.ts", - "ops:reminder": "bun run scripts/ops/trigger-reminder.ts" + "ops:telegram:commands": "bun run scripts/ops/telegram-commands.ts" }, "devDependencies": { "@types/bun": "1.3.11", diff --git a/packages/adapters-db/src/index.ts b/packages/adapters-db/src/index.ts index 02d6a77..4b02f1a 100644 --- a/packages/adapters-db/src/index.ts +++ b/packages/adapters-db/src/index.ts @@ -3,6 +3,6 @@ export { createDbAnonymousFeedbackRepository } from './anonymous-feedback-reposi export { createDbFinanceRepository } from './finance-repository' export { createDbHouseholdConfigurationRepository } from './household-config-repository' export { createDbProcessedBotMessageRepository } from './processed-bot-message-repository' -export { createDbReminderDispatchRepository } from './reminder-dispatch-repository' +export { createDbScheduledDispatchRepository } from './scheduled-dispatch-repository' export { createDbTelegramPendingActionRepository } from './telegram-pending-action-repository' export { createDbTopicMessageHistoryRepository } from './topic-message-history-repository' diff --git a/packages/adapters-db/src/reminder-dispatch-repository.ts b/packages/adapters-db/src/reminder-dispatch-repository.ts deleted file mode 100644 index c74d764..0000000 --- a/packages/adapters-db/src/reminder-dispatch-repository.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { and, eq } from 'drizzle-orm' - -import { createDbClient, schema } from '@household/db' -import type { ReminderDispatchRepository } from '@household/ports' - -export function createDbReminderDispatchRepository(databaseUrl: string): { - repository: ReminderDispatchRepository - close: () => Promise -} { - const { db, queryClient } = createDbClient(databaseUrl, { - max: 3, - prepare: false - }) - - const repository: ReminderDispatchRepository = { - async claimReminderDispatch(input) { - const dedupeKey = `${input.period}:${input.reminderType}` - const rows = await db - .insert(schema.processedBotMessages) - .values({ - householdId: input.householdId, - source: 'scheduler-reminder', - sourceMessageKey: dedupeKey, - payloadHash: input.payloadHash - }) - .onConflictDoNothing({ - target: [ - schema.processedBotMessages.householdId, - schema.processedBotMessages.source, - schema.processedBotMessages.sourceMessageKey - ] - }) - .returning({ id: schema.processedBotMessages.id }) - - return { - dedupeKey, - claimed: rows.length > 0 - } - }, - - async releaseReminderDispatch(input) { - const dedupeKey = `${input.period}:${input.reminderType}` - - await db - .delete(schema.processedBotMessages) - .where( - and( - eq(schema.processedBotMessages.householdId, input.householdId), - eq(schema.processedBotMessages.source, 'scheduler-reminder'), - eq(schema.processedBotMessages.sourceMessageKey, dedupeKey) - ) - ) - } - } - - return { - repository, - close: async () => { - await queryClient.end({ timeout: 5 }) - } - } -} diff --git a/packages/adapters-db/src/scheduled-dispatch-repository.ts b/packages/adapters-db/src/scheduled-dispatch-repository.ts new file mode 100644 index 0000000..fa4761d --- /dev/null +++ b/packages/adapters-db/src/scheduled-dispatch-repository.ts @@ -0,0 +1,252 @@ +import { and, asc, eq } from 'drizzle-orm' + +import { createDbClient, schema } from '@household/db' +import { instantFromDatabaseValue, instantToDate, nowInstant } from '@household/domain' +import type { + ClaimScheduledDispatchDeliveryResult, + ScheduledDispatchRecord, + ScheduledDispatchRepository +} from '@household/ports' + +const DELIVERY_CLAIM_SOURCE = 'scheduled-dispatch' + +function scheduledDispatchSelect() { + return { + id: schema.scheduledDispatches.id, + householdId: schema.scheduledDispatches.householdId, + kind: schema.scheduledDispatches.kind, + dueAt: schema.scheduledDispatches.dueAt, + timezone: schema.scheduledDispatches.timezone, + status: schema.scheduledDispatches.status, + provider: schema.scheduledDispatches.provider, + providerDispatchId: schema.scheduledDispatches.providerDispatchId, + adHocNotificationId: schema.scheduledDispatches.adHocNotificationId, + period: schema.scheduledDispatches.period, + sentAt: schema.scheduledDispatches.sentAt, + cancelledAt: schema.scheduledDispatches.cancelledAt, + createdAt: schema.scheduledDispatches.createdAt, + updatedAt: schema.scheduledDispatches.updatedAt + } +} + +function mapScheduledDispatch(row: { + id: string + householdId: string + kind: string + dueAt: Date | string + timezone: string + status: string + provider: string + providerDispatchId: string | null + adHocNotificationId: string | null + period: string | null + sentAt: Date | string | null + cancelledAt: Date | string | null + createdAt: Date | string + updatedAt: Date | string +}): ScheduledDispatchRecord { + return { + id: row.id, + householdId: row.householdId, + kind: row.kind as ScheduledDispatchRecord['kind'], + dueAt: instantFromDatabaseValue(row.dueAt)!, + timezone: row.timezone, + status: row.status as ScheduledDispatchRecord['status'], + provider: row.provider as ScheduledDispatchRecord['provider'], + providerDispatchId: row.providerDispatchId, + adHocNotificationId: row.adHocNotificationId, + period: row.period, + sentAt: instantFromDatabaseValue(row.sentAt), + cancelledAt: instantFromDatabaseValue(row.cancelledAt), + createdAt: instantFromDatabaseValue(row.createdAt)!, + updatedAt: instantFromDatabaseValue(row.updatedAt)! + } +} + +export function createDbScheduledDispatchRepository(databaseUrl: string): { + repository: ScheduledDispatchRepository + close: () => Promise +} { + const { db, queryClient } = createDbClient(databaseUrl, { + max: 3, + prepare: false + }) + + const repository: ScheduledDispatchRepository = { + async createScheduledDispatch(input) { + const timestamp = instantToDate(nowInstant()) + const rows = await db + .insert(schema.scheduledDispatches) + .values({ + householdId: input.householdId, + kind: input.kind, + dueAt: instantToDate(input.dueAt), + timezone: input.timezone, + status: 'scheduled', + provider: input.provider, + providerDispatchId: input.providerDispatchId ?? null, + adHocNotificationId: input.adHocNotificationId ?? null, + period: input.period ?? null, + updatedAt: timestamp + }) + .returning(scheduledDispatchSelect()) + + const row = rows[0] + if (!row) { + throw new Error('Scheduled dispatch insert did not return a row') + } + + return mapScheduledDispatch(row) + }, + + async getScheduledDispatchById(dispatchId) { + const rows = await db + .select(scheduledDispatchSelect()) + .from(schema.scheduledDispatches) + .where(eq(schema.scheduledDispatches.id, dispatchId)) + .limit(1) + + return rows[0] ? mapScheduledDispatch(rows[0]) : null + }, + + async getScheduledDispatchByAdHocNotificationId(notificationId) { + const rows = await db + .select(scheduledDispatchSelect()) + .from(schema.scheduledDispatches) + .where(eq(schema.scheduledDispatches.adHocNotificationId, notificationId)) + .limit(1) + + return rows[0] ? mapScheduledDispatch(rows[0]) : null + }, + + async listScheduledDispatchesForHousehold(householdId) { + const rows = await db + .select(scheduledDispatchSelect()) + .from(schema.scheduledDispatches) + .where(eq(schema.scheduledDispatches.householdId, householdId)) + .orderBy(asc(schema.scheduledDispatches.dueAt), asc(schema.scheduledDispatches.createdAt)) + + return rows.map(mapScheduledDispatch) + }, + + async updateScheduledDispatch(input) { + const updates: Record = { + updatedAt: instantToDate(input.updatedAt) + } + + if (input.dueAt) { + updates.dueAt = instantToDate(input.dueAt) + } + if (input.timezone) { + updates.timezone = input.timezone + } + if (input.providerDispatchId !== undefined) { + updates.providerDispatchId = input.providerDispatchId + } + if (input.period !== undefined) { + updates.period = input.period + } + + const rows = await db + .update(schema.scheduledDispatches) + .set(updates) + .where(eq(schema.scheduledDispatches.id, input.dispatchId)) + .returning(scheduledDispatchSelect()) + + return rows[0] ? mapScheduledDispatch(rows[0]) : null + }, + + async cancelScheduledDispatch(dispatchId, cancelledAt) { + const rows = await db + .update(schema.scheduledDispatches) + .set({ + status: 'cancelled', + cancelledAt: instantToDate(cancelledAt), + updatedAt: instantToDate(nowInstant()) + }) + .where( + and( + eq(schema.scheduledDispatches.id, dispatchId), + eq(schema.scheduledDispatches.status, 'scheduled') + ) + ) + .returning(scheduledDispatchSelect()) + + return rows[0] ? mapScheduledDispatch(rows[0]) : null + }, + + async markScheduledDispatchSent(dispatchId, sentAt) { + const rows = await db + .update(schema.scheduledDispatches) + .set({ + status: 'sent', + sentAt: instantToDate(sentAt), + updatedAt: instantToDate(nowInstant()) + }) + .where( + and( + eq(schema.scheduledDispatches.id, dispatchId), + eq(schema.scheduledDispatches.status, 'scheduled') + ) + ) + .returning(scheduledDispatchSelect()) + + return rows[0] ? mapScheduledDispatch(rows[0]) : null + }, + + async claimScheduledDispatchDelivery(dispatchId) { + const dispatch = await repository.getScheduledDispatchById(dispatchId) + if (!dispatch) { + return { + dispatchId, + claimed: false + } satisfies ClaimScheduledDispatchDeliveryResult + } + + const rows = await db + .insert(schema.processedBotMessages) + .values({ + householdId: dispatch.householdId, + source: DELIVERY_CLAIM_SOURCE, + sourceMessageKey: dispatchId + }) + .onConflictDoNothing({ + target: [ + schema.processedBotMessages.householdId, + schema.processedBotMessages.source, + schema.processedBotMessages.sourceMessageKey + ] + }) + .returning({ id: schema.processedBotMessages.id }) + + return { + dispatchId, + claimed: rows.length > 0 + } + }, + + async releaseScheduledDispatchDelivery(dispatchId) { + const dispatch = await repository.getScheduledDispatchById(dispatchId) + if (!dispatch) { + return + } + + await db + .delete(schema.processedBotMessages) + .where( + and( + eq(schema.processedBotMessages.householdId, dispatch.householdId), + eq(schema.processedBotMessages.source, DELIVERY_CLAIM_SOURCE), + eq(schema.processedBotMessages.sourceMessageKey, dispatchId) + ) + ) + } + } + + return { + repository, + close: async () => { + await queryClient.end({ timeout: 5 }) + } + } +} diff --git a/packages/application/src/ad-hoc-notification-service.ts b/packages/application/src/ad-hoc-notification-service.ts index 92b6f61..02b42fa 100644 --- a/packages/application/src/ad-hoc-notification-service.ts +++ b/packages/application/src/ad-hoc-notification-service.ts @@ -7,6 +7,7 @@ import type { HouseholdConfigurationRepository, HouseholdMemberRecord } from '@household/ports' +import type { ScheduledDispatchService } from './scheduled-dispatch-service' interface NotificationActor { memberId: string @@ -57,6 +58,7 @@ export type ScheduleAdHocNotificationResult = | 'delivery_mode_invalid' | 'friendly_assignee_missing' | 'scheduled_for_past' + | 'dispatch_schedule_failed' } export type CancelAdHocNotificationResult = @@ -78,7 +80,11 @@ export type UpdateAdHocNotificationResult = } | { status: 'invalid' - reason: 'delivery_mode_invalid' | 'dm_recipients_missing' | 'scheduled_for_past' + reason: + | 'delivery_mode_invalid' + | 'dm_recipients_missing' + | 'scheduled_for_past' + | 'dispatch_schedule_failed' } export interface AdHocNotificationService { @@ -165,6 +171,7 @@ export function createAdHocNotificationService(input: { HouseholdConfigurationRepository, 'getHouseholdMember' | 'listHouseholdMembers' > + scheduledDispatchService?: ScheduledDispatchService }): AdHocNotificationService { async function resolveActor( householdId: string, @@ -272,6 +279,28 @@ export function createAdHocNotificationService(input: { sourceTelegramThreadId: notificationInput.sourceTelegramThreadId ?? null }) + if (input.scheduledDispatchService) { + try { + await input.scheduledDispatchService.scheduleAdHocNotification({ + householdId: notification.householdId, + notificationId: notification.id, + dueAt: notification.scheduledFor, + timezone: notification.timezone + }) + } catch { + await input.repository.cancelNotification({ + notificationId: notification.id, + cancelledByMemberId: notification.creatorMemberId, + cancelledAt: nowInstant() + }) + + return { + status: 'invalid', + reason: 'dispatch_schedule_failed' + } + } + } + return { status: 'scheduled', notification @@ -352,6 +381,10 @@ export function createAdHocNotificationService(input: { } } + if (input.scheduledDispatchService) { + await input.scheduledDispatchService.cancelAdHocNotification(notificationId, asOf) + } + return { status: 'cancelled', notification: cancelled @@ -397,6 +430,10 @@ export function createAdHocNotificationService(input: { input.householdConfigurationRepository, notification.householdId ) + const previousScheduledFor = notification.scheduledFor + const previousTimePrecision = notification.timePrecision + const previousDeliveryMode = notification.deliveryMode + const previousDmRecipientMemberIds = notification.dmRecipientMemberIds if (scheduledFor && scheduledFor.epochMilliseconds <= asOf.epochMilliseconds) { return { @@ -455,6 +492,31 @@ export function createAdHocNotificationService(input: { } } + if (input.scheduledDispatchService) { + try { + await input.scheduledDispatchService.scheduleAdHocNotification({ + householdId: updated.householdId, + notificationId: updated.id, + dueAt: updated.scheduledFor, + timezone: updated.timezone + }) + } catch { + await input.repository.updateNotification({ + notificationId, + scheduledFor: previousScheduledFor, + timePrecision: previousTimePrecision, + deliveryMode: previousDeliveryMode, + dmRecipientMemberIds: previousDmRecipientMemberIds, + updatedAt: nowInstant() + }) + + return { + status: 'invalid', + reason: 'dispatch_schedule_failed' + } + } + } + return { status: 'updated', notification: updated diff --git a/packages/application/src/household-setup-service.ts b/packages/application/src/household-setup-service.ts index 51be09c..b362116 100644 --- a/packages/application/src/household-setup-service.ts +++ b/packages/application/src/household-setup-service.ts @@ -4,6 +4,7 @@ import type { HouseholdTopicBindingRecord, HouseholdTopicRole } from '@household/ports' +import type { ScheduledDispatchService } from './scheduled-dispatch-service' export interface HouseholdSetupService { setupGroupChat(input: { @@ -72,7 +73,8 @@ function defaultHouseholdName(title: string | undefined, telegramChatId: string) } export function createHouseholdSetupService( - repository: HouseholdConfigurationRepository + repository: HouseholdConfigurationRepository, + scheduledDispatchService?: ScheduledDispatchService ): HouseholdSetupService { return { async setupGroupChat(input) { @@ -118,6 +120,12 @@ export function createHouseholdSetupService( } } + if (scheduledDispatchService) { + await scheduledDispatchService.reconcileHouseholdBuiltInDispatches( + registered.household.householdId + ) + } + return { status: registered.status, household: registered.household diff --git a/packages/application/src/index.ts b/packages/application/src/index.ts index e89a22d..48fd29c 100644 --- a/packages/application/src/index.ts +++ b/packages/application/src/index.ts @@ -25,10 +25,9 @@ export { type HouseholdOnboardingService } from './household-onboarding-service' export { - createReminderJobService, - type ReminderJobResult, - type ReminderJobService -} from './reminder-job-service' + createScheduledDispatchService, + type ScheduledDispatchService +} from './scheduled-dispatch-service' export { createLocalePreferenceService, type LocalePreferenceService diff --git a/packages/application/src/miniapp-admin-service.ts b/packages/application/src/miniapp-admin-service.ts index d2f44f9..0537c21 100644 --- a/packages/application/src/miniapp-admin-service.ts +++ b/packages/application/src/miniapp-admin-service.ts @@ -12,6 +12,7 @@ import type { HouseholdUtilityCategoryRecord } from '@household/ports' import { Money, Temporal, type CurrencyCode } from '@household/domain' +import type { ScheduledDispatchService } from './scheduled-dispatch-service' function isValidDay(value: number): boolean { return Number.isInteger(value) && value >= 1 && value <= 31 @@ -339,7 +340,8 @@ function normalizeAssistantText( } export function createMiniAppAdminService( - repository: HouseholdConfigurationRepository + repository: HouseholdConfigurationRepository, + scheduledDispatchService?: ScheduledDispatchService ): MiniAppAdminService { return { async getSettings(input) { @@ -531,6 +533,10 @@ export function createMiniAppAdminService( throw new Error('Failed to resolve household chat after settings update') } + if (scheduledDispatchService) { + await scheduledDispatchService.reconcileHouseholdBuiltInDispatches(input.householdId) + } + return { status: 'ok', householdName: household.householdName, diff --git a/packages/application/src/reminder-job-service.test.ts b/packages/application/src/reminder-job-service.test.ts deleted file mode 100644 index f9e34eb..0000000 --- a/packages/application/src/reminder-job-service.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { describe, expect, test } from 'bun:test' - -import type { - ClaimReminderDispatchInput, - ClaimReminderDispatchResult, - ReminderDispatchRepository -} from '@household/ports' - -import { createReminderJobService } from './reminder-job-service' - -class ReminderDispatchRepositoryStub implements ReminderDispatchRepository { - nextResult: ClaimReminderDispatchResult = { - dedupeKey: '2026-03:utilities', - claimed: true - } - - lastClaim: ClaimReminderDispatchInput | null = null - - async claimReminderDispatch( - input: ClaimReminderDispatchInput - ): Promise { - this.lastClaim = input - return this.nextResult - } - - async releaseReminderDispatch(): Promise {} -} - -describe('createReminderJobService', () => { - test('returns dry-run result without touching the repository', async () => { - const repository = new ReminderDispatchRepositoryStub() - const service = createReminderJobService(repository) - - const result = await service.handleJob({ - householdId: 'household-1', - period: '2026-03', - reminderType: 'utilities', - dryRun: true - }) - - expect(result.status).toBe('dry-run') - expect(result.dedupeKey).toBe('2026-03:utilities') - expect(result.messageText).toBe('Utilities reminder for 2026-03') - expect(repository.lastClaim).toBeNull() - }) - - test('claims a dispatch once and returns the dedupe key', async () => { - const repository = new ReminderDispatchRepositoryStub() - repository.nextResult = { - dedupeKey: '2026-03:rent-due', - claimed: true - } - const service = createReminderJobService(repository) - - const result = await service.handleJob({ - householdId: 'household-1', - period: '2026-03', - reminderType: 'rent-due' - }) - - expect(result.status).toBe('claimed') - expect(result.dedupeKey).toBe('2026-03:rent-due') - expect(repository.lastClaim).toMatchObject({ - householdId: 'household-1', - period: '2026-03', - reminderType: 'rent-due' - }) - }) - - test('returns duplicate when the repository rejects a replay', async () => { - const repository = new ReminderDispatchRepositoryStub() - repository.nextResult = { - dedupeKey: '2026-03:rent-warning', - claimed: false - } - - const service = createReminderJobService(repository) - const result = await service.handleJob({ - householdId: 'household-1', - period: '2026-03', - reminderType: 'rent-warning' - }) - - expect(result.status).toBe('duplicate') - expect(result.dedupeKey).toBe('2026-03:rent-warning') - }) -}) diff --git a/packages/application/src/reminder-job-service.ts b/packages/application/src/reminder-job-service.ts deleted file mode 100644 index b86b5f4..0000000 --- a/packages/application/src/reminder-job-service.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { createHash } from 'node:crypto' - -import { BillingPeriod } from '@household/domain' -import type { - ClaimReminderDispatchResult, - ReminderDispatchRepository, - ReminderType -} from '@household/ports' - -function computePayloadHash(payload: object): string { - return createHash('sha256').update(JSON.stringify(payload)).digest('hex') -} - -function buildReminderDedupeKey(period: string, reminderType: ReminderType): string { - return `${period}:${reminderType}` -} - -function createReminderMessage(reminderType: ReminderType, period: string): string { - switch (reminderType) { - case 'utilities': - return `Utilities reminder for ${period}` - case 'rent-warning': - return `Rent reminder for ${period}: payment is coming up soon.` - case 'rent-due': - return `Rent due reminder for ${period}: please settle payment today.` - } -} - -export interface ReminderJobResult { - status: 'dry-run' | 'claimed' | 'duplicate' - dedupeKey: string - payloadHash: string - reminderType: ReminderType - period: string - messageText: string -} - -export interface ReminderJobService { - handleJob(input: { - householdId: string - period: string - reminderType: ReminderType - dryRun?: boolean - }): Promise -} - -export function createReminderJobService( - repository: ReminderDispatchRepository -): ReminderJobService { - return { - async handleJob(input) { - const period = BillingPeriod.fromString(input.period).toString() - const payloadHash = computePayloadHash({ - householdId: input.householdId, - period, - reminderType: input.reminderType - }) - const messageText = createReminderMessage(input.reminderType, period) - - if (input.dryRun === true) { - return { - status: 'dry-run', - dedupeKey: buildReminderDedupeKey(period, input.reminderType), - payloadHash, - reminderType: input.reminderType, - period, - messageText - } - } - - const result: ClaimReminderDispatchResult = await repository.claimReminderDispatch({ - householdId: input.householdId, - period, - reminderType: input.reminderType, - payloadHash - }) - - return { - status: result.claimed ? 'claimed' : 'duplicate', - dedupeKey: result.dedupeKey, - payloadHash, - reminderType: input.reminderType, - period, - messageText - } - } - } -} diff --git a/packages/application/src/scheduled-dispatch-service.test.ts b/packages/application/src/scheduled-dispatch-service.test.ts new file mode 100644 index 0000000..452d1b4 --- /dev/null +++ b/packages/application/src/scheduled-dispatch-service.test.ts @@ -0,0 +1,294 @@ +import { describe, expect, test } from 'bun:test' + +import { Temporal } from '@household/domain' +import type { + HouseholdBillingSettingsRecord, + HouseholdTelegramChatRecord, + ReminderTarget, + ScheduledDispatchRecord, + ScheduledDispatchRepository, + ScheduledDispatchScheduler +} from '@household/ports' + +import { createScheduledDispatchService } from './scheduled-dispatch-service' + +class ScheduledDispatchRepositoryStub implements ScheduledDispatchRepository { + dispatches = new Map() + nextId = 1 + claims = new Set() + + async createScheduledDispatch(input: { + householdId: string + kind: ScheduledDispatchRecord['kind'] + dueAt: Temporal.Instant + timezone: string + provider: ScheduledDispatchRecord['provider'] + providerDispatchId?: string | null + adHocNotificationId?: string | null + period?: string | null + }): Promise { + const id = `dispatch-${this.nextId++}` + const record: ScheduledDispatchRecord = { + id, + householdId: input.householdId, + kind: input.kind, + dueAt: input.dueAt, + timezone: input.timezone, + status: 'scheduled', + provider: input.provider, + providerDispatchId: input.providerDispatchId ?? null, + adHocNotificationId: input.adHocNotificationId ?? null, + period: input.period ?? null, + sentAt: null, + cancelledAt: null, + createdAt: Temporal.Instant.from('2026-03-24T00:00:00Z'), + updatedAt: Temporal.Instant.from('2026-03-24T00:00:00Z') + } + this.dispatches.set(id, record) + return record + } + + async getScheduledDispatchById(dispatchId: string): Promise { + return this.dispatches.get(dispatchId) ?? null + } + + async getScheduledDispatchByAdHocNotificationId( + notificationId: string + ): Promise { + return ( + [...this.dispatches.values()].find( + (dispatch) => dispatch.adHocNotificationId === notificationId + ) ?? null + ) + } + + async listScheduledDispatchesForHousehold( + householdId: string + ): Promise { + return [...this.dispatches.values()].filter((dispatch) => dispatch.householdId === householdId) + } + + async updateScheduledDispatch(input: { + dispatchId: string + dueAt?: Temporal.Instant + timezone?: string + providerDispatchId?: string | null + period?: string | null + updatedAt: Temporal.Instant + }): Promise { + const current = this.dispatches.get(input.dispatchId) + if (!current) { + return null + } + + const next: ScheduledDispatchRecord = { + ...current, + dueAt: input.dueAt ?? current.dueAt, + timezone: input.timezone ?? current.timezone, + providerDispatchId: + input.providerDispatchId === undefined + ? current.providerDispatchId + : input.providerDispatchId, + period: input.period === undefined ? current.period : input.period, + updatedAt: input.updatedAt + } + this.dispatches.set(input.dispatchId, next) + return next + } + + async cancelScheduledDispatch( + dispatchId: string, + cancelledAt: Temporal.Instant + ): Promise { + const current = this.dispatches.get(dispatchId) + if (!current || current.status !== 'scheduled') { + return null + } + + const next: ScheduledDispatchRecord = { + ...current, + status: 'cancelled', + cancelledAt + } + this.dispatches.set(dispatchId, next) + return next + } + + async markScheduledDispatchSent( + dispatchId: string, + sentAt: Temporal.Instant + ): Promise { + const current = this.dispatches.get(dispatchId) + if (!current || current.status !== 'scheduled') { + return null + } + + const next: ScheduledDispatchRecord = { + ...current, + status: 'sent', + sentAt + } + this.dispatches.set(dispatchId, next) + return next + } + + async claimScheduledDispatchDelivery(dispatchId: string) { + if (this.claims.has(dispatchId)) { + return { dispatchId, claimed: false } + } + this.claims.add(dispatchId) + return { dispatchId, claimed: true } + } + + async releaseScheduledDispatchDelivery(dispatchId: string) { + this.claims.delete(dispatchId) + } +} + +function createSchedulerStub(): ScheduledDispatchScheduler & { + scheduled: Array<{ dispatchId: string; dueAt: string }> + cancelled: string[] +} { + let nextId = 1 + + return { + provider: 'gcp-cloud-tasks', + scheduled: [], + cancelled: [], + async scheduleOneShotDispatch(input) { + this.scheduled.push({ + dispatchId: input.dispatchId, + dueAt: input.dueAt.toString() + }) + return { + providerDispatchId: `provider-${nextId++}` + } + }, + async cancelDispatch(providerDispatchId) { + this.cancelled.push(providerDispatchId) + } + } +} + +function billingSettings( + timezone = 'Asia/Tbilisi' +): HouseholdBillingSettingsRecord & { householdId: string } { + return { + householdId: 'household-1', + settlementCurrency: 'GEL', + timezone, + rentDueDay: 5, + rentWarningDay: 3, + utilitiesReminderDay: 12, + utilitiesDueDay: 15, + rentAmountMinor: null, + rentCurrency: 'GEL', + rentPaymentDestinations: null + } +} + +function householdChat(): HouseholdTelegramChatRecord { + return { + householdId: 'household-1', + householdName: 'Kojori', + telegramChatId: 'chat-1', + telegramChatType: 'supergroup', + title: 'Kojori', + defaultLocale: 'ru' + } +} + +describe('createScheduledDispatchService', () => { + test('schedules and reschedules ad hoc notifications via provider task', async () => { + const repository = new ScheduledDispatchRepositoryStub() + const scheduler = createSchedulerStub() + const service = createScheduledDispatchService({ + repository, + scheduler, + householdConfigurationRepository: { + async getHouseholdBillingSettings() { + return billingSettings() + }, + async getHouseholdChatByHouseholdId() { + return householdChat() + }, + async listReminderTargets(): Promise { + return [] + } + } + }) + + const firstDueAt = Temporal.Instant.from('2026-03-25T08:00:00Z') + const secondDueAt = Temporal.Instant.from('2026-03-25T09:00:00Z') + + const first = await service.scheduleAdHocNotification({ + householdId: 'household-1', + notificationId: 'notif-1', + dueAt: firstDueAt, + timezone: 'Asia/Tbilisi' + }) + const second = await service.scheduleAdHocNotification({ + householdId: 'household-1', + notificationId: 'notif-1', + dueAt: secondDueAt, + timezone: 'Asia/Tbilisi' + }) + + expect(first.providerDispatchId).toBe('provider-1') + expect(second.providerDispatchId).toBe('provider-2') + expect(scheduler.cancelled).toEqual(['provider-1']) + + await service.cancelAdHocNotification('notif-1', Temporal.Instant.from('2026-03-24T11:00:00Z')) + + expect(scheduler.cancelled).toEqual(['provider-1', 'provider-2']) + expect((await repository.getScheduledDispatchByAdHocNotificationId('notif-1'))?.status).toBe( + 'cancelled' + ) + }) + + test('reconciles one future built-in dispatch per reminder kind', async () => { + const repository = new ScheduledDispatchRepositoryStub() + const scheduler = createSchedulerStub() + const service = createScheduledDispatchService({ + repository, + scheduler, + householdConfigurationRepository: { + async getHouseholdBillingSettings() { + return billingSettings() + }, + async getHouseholdChatByHouseholdId() { + return householdChat() + }, + async listReminderTargets(): Promise { + return [ + { + householdId: 'household-1', + householdName: 'Kojori', + telegramChatId: 'chat-1', + telegramThreadId: '103', + locale: 'ru', + timezone: 'Asia/Tbilisi', + utilitiesReminderDay: 12, + utilitiesDueDay: 15, + rentWarningDay: 3, + rentDueDay: 5 + } + ] + } + } + }) + + await service.reconcileAllBuiltInDispatches(Temporal.Instant.from('2026-03-24T00:00:00Z')) + + const scheduled = [...repository.dispatches.values()].filter( + (dispatch) => dispatch.status === 'scheduled' + ) + expect(scheduled.map((dispatch) => dispatch.kind).sort()).toEqual([ + 'rent_due', + 'rent_warning', + 'utilities' + ]) + expect(scheduler.scheduled).toHaveLength(3) + expect(scheduled.every((dispatch) => dispatch.period === '2026-04')).toBe(true) + }) +}) diff --git a/packages/application/src/scheduled-dispatch-service.ts b/packages/application/src/scheduled-dispatch-service.ts new file mode 100644 index 0000000..538ec55 --- /dev/null +++ b/packages/application/src/scheduled-dispatch-service.ts @@ -0,0 +1,327 @@ +import { BillingPeriod, Temporal, nowInstant, type Instant } from '@household/domain' +import type { + HouseholdConfigurationRepository, + ScheduledDispatchKind, + ScheduledDispatchRecord, + ScheduledDispatchRepository, + ScheduledDispatchScheduler +} from '@household/ports' + +const BUILT_IN_DISPATCH_KINDS = ['utilities', 'rent_warning', 'rent_due'] as const + +function builtInDispatchDay( + kind: (typeof BUILT_IN_DISPATCH_KINDS)[number], + settings: Awaited> +): number { + switch (kind) { + case 'utilities': + return settings.utilitiesReminderDay + case 'rent_warning': + return settings.rentWarningDay + case 'rent_due': + return settings.rentDueDay + } +} + +function builtInDispatchHour(): number { + return 9 +} + +function clampDay(year: number, month: number, day: number): number { + const yearMonth = new Temporal.PlainYearMonth(year, month) + return Math.min(day, yearMonth.daysInMonth) +} + +function nextBuiltInDispatch(input: { + kind: (typeof BUILT_IN_DISPATCH_KINDS)[number] + timezone: string + day: number + asOf: Instant +}): { + dueAt: Instant + period: string +} { + const localNow = input.asOf.toZonedDateTimeISO(input.timezone) + let year = localNow.year + let month = localNow.month + + const buildCandidate = (candidateYear: number, candidateMonth: number) => { + const candidateDay = clampDay(candidateYear, candidateMonth, input.day) + return new Temporal.PlainDateTime( + candidateYear, + candidateMonth, + candidateDay, + builtInDispatchHour(), + 0, + 0, + 0 + ).toZonedDateTime(input.timezone) + } + + let candidate = buildCandidate(year, month) + if (candidate.epochMilliseconds <= localNow.epochMilliseconds) { + const nextMonth = new Temporal.PlainYearMonth(localNow.year, localNow.month).add({ + months: 1 + }) + year = nextMonth.year + month = nextMonth.month + candidate = buildCandidate(year, month) + } + + return { + dueAt: candidate.toInstant(), + period: BillingPeriod.fromString( + `${candidate.year}-${String(candidate.month).padStart(2, '0')}` + ).toString() + } +} + +export interface ScheduledDispatchService { + scheduleAdHocNotification(input: { + householdId: string + notificationId: string + dueAt: Instant + timezone: string + }): Promise + cancelAdHocNotification(notificationId: string, cancelledAt?: Instant): Promise + reconcileHouseholdBuiltInDispatches(householdId: string, asOf?: Instant): Promise + reconcileAllBuiltInDispatches(asOf?: Instant): Promise + getDispatchById(dispatchId: string): Promise + claimDispatch(dispatchId: string): Promise + releaseDispatch(dispatchId: string): Promise + markDispatchSent(dispatchId: string, sentAt?: Instant): Promise +} + +export function createScheduledDispatchService(input: { + repository: ScheduledDispatchRepository + scheduler: ScheduledDispatchScheduler + householdConfigurationRepository: Pick< + HouseholdConfigurationRepository, + 'getHouseholdBillingSettings' | 'getHouseholdChatByHouseholdId' | 'listReminderTargets' + > +}): ScheduledDispatchService { + async function createDispatchRecord(record: { + householdId: string + kind: ScheduledDispatchKind + dueAt: Instant + timezone: string + adHocNotificationId?: string | null + period?: string | null + }) { + return input.repository.createScheduledDispatch({ + householdId: record.householdId, + kind: record.kind, + dueAt: record.dueAt, + timezone: record.timezone, + provider: input.scheduler.provider, + providerDispatchId: null, + adHocNotificationId: record.adHocNotificationId ?? null, + period: record.period ?? null + }) + } + + async function activateDispatch( + dispatch: ScheduledDispatchRecord, + dueAt: Instant, + timezone: string, + period?: string | null + ) { + const result = await input.scheduler.scheduleOneShotDispatch({ + dispatchId: dispatch.id, + dueAt + }) + + const updated = await input.repository.updateScheduledDispatch({ + dispatchId: dispatch.id, + dueAt, + timezone, + providerDispatchId: result.providerDispatchId, + period: period ?? null, + updatedAt: nowInstant() + }) + if (!updated) { + await input.scheduler.cancelDispatch(result.providerDispatchId) + throw new Error(`Failed to update scheduled dispatch ${dispatch.id}`) + } + return updated + } + + async function ensureBuiltInDispatch(inputValue: { + householdId: string + kind: (typeof BUILT_IN_DISPATCH_KINDS)[number] + dueAt: Instant + timezone: string + period: string + existing: ScheduledDispatchRecord | null + }) { + if ( + inputValue.existing && + inputValue.existing.status === 'scheduled' && + inputValue.existing.dueAt.epochMilliseconds === inputValue.dueAt.epochMilliseconds && + inputValue.existing.period === inputValue.period && + inputValue.existing.provider === input.scheduler.provider && + inputValue.existing.providerDispatchId + ) { + return + } + + if (!inputValue.existing) { + const created = await createDispatchRecord({ + householdId: inputValue.householdId, + kind: inputValue.kind, + dueAt: inputValue.dueAt, + timezone: inputValue.timezone, + period: inputValue.period + }) + + try { + await activateDispatch(created, inputValue.dueAt, inputValue.timezone, inputValue.period) + } catch (error) { + await input.repository.cancelScheduledDispatch(created.id, nowInstant()) + throw error + } + return + } + + const previousProviderDispatchId = inputValue.existing.providerDispatchId + const updated = await activateDispatch( + inputValue.existing, + inputValue.dueAt, + inputValue.timezone, + inputValue.period + ) + + if (previousProviderDispatchId && previousProviderDispatchId !== updated.providerDispatchId) { + await input.scheduler.cancelDispatch(previousProviderDispatchId) + } + } + + async function reconcileHouseholdBuiltInDispatches(householdId: string, asOf = nowInstant()) { + const [chat, settings, existingDispatches] = await Promise.all([ + input.householdConfigurationRepository.getHouseholdChatByHouseholdId(householdId), + input.householdConfigurationRepository.getHouseholdBillingSettings(householdId), + input.repository.listScheduledDispatchesForHousehold(householdId) + ]) + + const existingByKind = new Map( + existingDispatches + .filter((dispatch) => + BUILT_IN_DISPATCH_KINDS.includes( + dispatch.kind as (typeof BUILT_IN_DISPATCH_KINDS)[number] + ) + ) + .map((dispatch) => [dispatch.kind, dispatch]) + ) + + if (!chat) { + for (const dispatch of existingByKind.values()) { + if (dispatch.status !== 'scheduled') { + continue + } + + if (dispatch.providerDispatchId) { + await input.scheduler.cancelDispatch(dispatch.providerDispatchId) + } + await input.repository.cancelScheduledDispatch(dispatch.id, asOf) + } + return + } + + for (const kind of BUILT_IN_DISPATCH_KINDS) { + const next = nextBuiltInDispatch({ + kind, + timezone: settings.timezone, + day: builtInDispatchDay(kind, settings), + asOf + }) + + await ensureBuiltInDispatch({ + householdId, + kind, + dueAt: next.dueAt, + timezone: settings.timezone, + period: next.period, + existing: existingByKind.get(kind) ?? null + }) + } + } + + return { + async scheduleAdHocNotification(dispatchInput) { + const existing = await input.repository.getScheduledDispatchByAdHocNotificationId( + dispatchInput.notificationId + ) + if (!existing) { + const created = await createDispatchRecord({ + householdId: dispatchInput.householdId, + kind: 'ad_hoc_notification', + dueAt: dispatchInput.dueAt, + timezone: dispatchInput.timezone, + adHocNotificationId: dispatchInput.notificationId + }) + + try { + return await activateDispatch(created, dispatchInput.dueAt, dispatchInput.timezone, null) + } catch (error) { + await input.repository.cancelScheduledDispatch(created.id, nowInstant()) + throw error + } + } + + const previousProviderDispatchId = existing.providerDispatchId + const updated = await activateDispatch( + existing, + dispatchInput.dueAt, + dispatchInput.timezone, + null + ) + + if (previousProviderDispatchId && previousProviderDispatchId !== updated.providerDispatchId) { + await input.scheduler.cancelDispatch(previousProviderDispatchId) + } + + return updated + }, + + async cancelAdHocNotification(notificationId, cancelledAt = nowInstant()) { + const existing = + await input.repository.getScheduledDispatchByAdHocNotificationId(notificationId) + if (!existing || existing.status !== 'scheduled') { + return + } + + if (existing.providerDispatchId) { + await input.scheduler.cancelDispatch(existing.providerDispatchId) + } + await input.repository.cancelScheduledDispatch(existing.id, cancelledAt) + }, + + reconcileHouseholdBuiltInDispatches, + + async reconcileAllBuiltInDispatches(asOf = nowInstant()) { + const targets = await input.householdConfigurationRepository.listReminderTargets() + const householdIds = [...new Set(targets.map((target) => target.householdId))] + + for (const householdId of householdIds) { + await reconcileHouseholdBuiltInDispatches(householdId, asOf) + } + }, + + getDispatchById(dispatchId) { + return input.repository.getScheduledDispatchById(dispatchId) + }, + + async claimDispatch(dispatchId) { + const result = await input.repository.claimScheduledDispatchDelivery(dispatchId) + return result.claimed + }, + + releaseDispatch(dispatchId) { + return input.repository.releaseScheduledDispatchDelivery(dispatchId) + }, + + markDispatchSent(dispatchId, sentAt = nowInstant()) { + return input.repository.markScheduledDispatchSent(dispatchId, sentAt) + } + } +} diff --git a/packages/db/drizzle-checksums.json b/packages/db/drizzle-checksums.json index dd2751d..8d73219 100644 --- a/packages/db/drizzle-checksums.json +++ b/packages/db/drizzle-checksums.json @@ -25,6 +25,7 @@ "0020_silver_payments.sql": "9686235c75453f1eaa016f2f4ab7fce8fe964c76a4e3515987a2b9f90bd7b1ad", "0021_sharp_payer.sql": "973596e154382984ba7769979ea58298b6d93c5139540854be01e8b283ddb4f1", "0022_carry_purchase_history.sql": "f031c9736e43e71eec3263a323332c29de9324c6409db034b0760051c8a9f074", - "0023_huge_vision.sql": "9a682e8b62fc6c54711ccd7bb912dd7192e278f546d5853670bea6a0a4585c1c" + "0023_huge_vision.sql": "9a682e8b62fc6c54711ccd7bb912dd7192e278f546d5853670bea6a0a4585c1c", + "0024_lush_lucky_pierre.sql": "35d111486df774fde5add5cc98f2bf8bcb16d5bae8c4dd4df01fedb661a297d6" } } diff --git a/packages/db/drizzle/0024_lush_lucky_pierre.sql b/packages/db/drizzle/0024_lush_lucky_pierre.sql new file mode 100644 index 0000000..2f1f16a --- /dev/null +++ b/packages/db/drizzle/0024_lush_lucky_pierre.sql @@ -0,0 +1,22 @@ +CREATE TABLE "scheduled_dispatches" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "household_id" uuid NOT NULL, + "kind" text NOT NULL, + "due_at" timestamp with time zone NOT NULL, + "timezone" text NOT NULL, + "status" text DEFAULT 'scheduled' NOT NULL, + "provider" text NOT NULL, + "provider_dispatch_id" text, + "ad_hoc_notification_id" uuid, + "period" text, + "sent_at" timestamp with time zone, + "cancelled_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 +ALTER TABLE "scheduled_dispatches" ADD CONSTRAINT "scheduled_dispatches_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "scheduled_dispatches" ADD CONSTRAINT "scheduled_dispatches_ad_hoc_notification_id_ad_hoc_notifications_id_fk" FOREIGN KEY ("ad_hoc_notification_id") REFERENCES "public"."ad_hoc_notifications"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "scheduled_dispatches_due_idx" ON "scheduled_dispatches" USING btree ("status","due_at");--> statement-breakpoint +CREATE INDEX "scheduled_dispatches_household_kind_idx" ON "scheduled_dispatches" USING btree ("household_id","kind","status");--> statement-breakpoint +CREATE UNIQUE INDEX "scheduled_dispatches_ad_hoc_notification_unique" ON "scheduled_dispatches" USING btree ("ad_hoc_notification_id"); \ No newline at end of file diff --git a/packages/db/drizzle/meta/0024_snapshot.json b/packages/db/drizzle/meta/0024_snapshot.json new file mode 100644 index 0000000..3a07246 --- /dev/null +++ b/packages/db/drizzle/meta/0024_snapshot.json @@ -0,0 +1,4047 @@ +{ + "id": "c3d78147-f479-4d64-bf6c-bdf1bb1dbfa5", + "prevId": "fbb59211-0980-4bdc-bc56-29d9f74ae1d4", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.ad_hoc_notifications": { + "name": "ad_hoc_notifications", + "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 + }, + "creator_member_id": { + "name": "creator_member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "assignee_member_id": { + "name": "assignee_member_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "original_request_text": { + "name": "original_request_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "notification_text": { + "name": "notification_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scheduled_for": { + "name": "scheduled_for", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "time_precision": { + "name": "time_precision", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "delivery_mode": { + "name": "delivery_mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dm_recipient_member_ids": { + "name": "dm_recipient_member_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "friendly_tag_assignee": { + "name": "friendly_tag_assignee", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'scheduled'" + }, + "source_telegram_chat_id": { + "name": "source_telegram_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_telegram_thread_id": { + "name": "source_telegram_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancelled_by_member_id": { + "name": "cancelled_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()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ad_hoc_notifications_due_idx": { + "name": "ad_hoc_notifications_due_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scheduled_for", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ad_hoc_notifications_household_status_idx": { + "name": "ad_hoc_notifications_household_status_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scheduled_for", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ad_hoc_notifications_creator_idx": { + "name": "ad_hoc_notifications_creator_idx", + "columns": [ + { + "expression": "creator_member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ad_hoc_notifications_assignee_idx": { + "name": "ad_hoc_notifications_assignee_idx", + "columns": [ + { + "expression": "assignee_member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ad_hoc_notifications_household_id_households_id_fk": { + "name": "ad_hoc_notifications_household_id_households_id_fk", + "tableFrom": "ad_hoc_notifications", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ad_hoc_notifications_creator_member_id_members_id_fk": { + "name": "ad_hoc_notifications_creator_member_id_members_id_fk", + "tableFrom": "ad_hoc_notifications", + "tableTo": "members", + "columnsFrom": ["creator_member_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "ad_hoc_notifications_assignee_member_id_members_id_fk": { + "name": "ad_hoc_notifications_assignee_member_id_members_id_fk", + "tableFrom": "ad_hoc_notifications", + "tableTo": "members", + "columnsFrom": ["assignee_member_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ad_hoc_notifications_cancelled_by_member_id_members_id_fk": { + "name": "ad_hoc_notifications_cancelled_by_member_id_members_id_fk", + "tableFrom": "ad_hoc_notifications", + "tableTo": "members", + "columnsFrom": ["cancelled_by_member_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "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_cycle_exchange_rates": { + "name": "billing_cycle_exchange_rates", + "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 + }, + "source_currency": { + "name": "source_currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_currency": { + "name": "target_currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rate_micros": { + "name": "rate_micros", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "effective_date": { + "name": "effective_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'nbg'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "billing_cycle_exchange_rates_cycle_pair_unique": { + "name": "billing_cycle_exchange_rates_cycle_pair_unique", + "columns": [ + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_currency", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_currency", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_cycle_exchange_rates_cycle_idx": { + "name": "billing_cycle_exchange_rates_cycle_idx", + "columns": [ + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "billing_cycle_exchange_rates_cycle_id_billing_cycles_id_fk": { + "name": "billing_cycle_exchange_rates_cycle_id_billing_cycles_id_fk", + "tableFrom": "billing_cycle_exchange_rates", + "tableTo": "billing_cycles", + "columnsFrom": ["cycle_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "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.household_billing_settings": { + "name": "household_billing_settings", + "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 + }, + "settlement_currency": { + "name": "settlement_currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'GEL'" + }, + "payment_balance_adjustment_policy": { + "name": "payment_balance_adjustment_policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'utilities'" + }, + "rent_amount_minor": { + "name": "rent_amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "rent_currency": { + "name": "rent_currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "rent_due_day": { + "name": "rent_due_day", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 20 + }, + "rent_warning_day": { + "name": "rent_warning_day", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 17 + }, + "utilities_due_day": { + "name": "utilities_due_day", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 4 + }, + "utilities_reminder_day": { + "name": "utilities_reminder_day", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Asia/Tbilisi'" + }, + "rent_payment_destinations": { + "name": "rent_payment_destinations", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "household_billing_settings_household_unique": { + "name": "household_billing_settings_household_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "household_billing_settings_household_id_households_id_fk": { + "name": "household_billing_settings_household_id_households_id_fk", + "tableFrom": "household_billing_settings", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.household_join_tokens": { + "name": "household_join_tokens", + "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 + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_telegram_user_id": { + "name": "created_by_telegram_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "household_join_tokens_household_unique": { + "name": "household_join_tokens_household_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "household_join_tokens_token_unique": { + "name": "household_join_tokens_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "household_join_tokens_household_id_households_id_fk": { + "name": "household_join_tokens_household_id_households_id_fk", + "tableFrom": "household_join_tokens", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.household_pending_members": { + "name": "household_pending_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 + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "language_code": { + "name": "language_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "household_pending_members_household_user_unique": { + "name": "household_pending_members_household_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": {} + }, + "household_pending_members_telegram_user_idx": { + "name": "household_pending_members_telegram_user_idx", + "columns": [ + { + "expression": "telegram_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "household_pending_members_household_id_households_id_fk": { + "name": "household_pending_members_household_id_households_id_fk", + "tableFrom": "household_pending_members", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.household_telegram_chats": { + "name": "household_telegram_chats", + "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_chat_id": { + "name": "telegram_chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_chat_type": { + "name": "telegram_chat_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "household_telegram_chats_household_unique": { + "name": "household_telegram_chats_household_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "household_telegram_chats_chat_unique": { + "name": "household_telegram_chats_chat_unique", + "columns": [ + { + "expression": "telegram_chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "household_telegram_chats_household_id_households_id_fk": { + "name": "household_telegram_chats_household_id_households_id_fk", + "tableFrom": "household_telegram_chats", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.household_topic_bindings": { + "name": "household_topic_bindings", + "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 + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_thread_id": { + "name": "telegram_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "topic_name": { + "name": "topic_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "household_topic_bindings_household_role_unique": { + "name": "household_topic_bindings_household_role_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "household_topic_bindings_household_thread_unique": { + "name": "household_topic_bindings_household_thread_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "household_topic_bindings_household_role_idx": { + "name": "household_topic_bindings_household_role_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "household_topic_bindings_household_id_households_id_fk": { + "name": "household_topic_bindings_household_id_households_id_fk", + "tableFrom": "household_topic_bindings", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.household_utility_categories": { + "name": "household_utility_categories", + "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 + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "household_utility_categories_household_slug_unique": { + "name": "household_utility_categories_household_slug_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "household_utility_categories_household_sort_idx": { + "name": "household_utility_categories_household_sort_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "household_utility_categories_household_id_households_id_fk": { + "name": "household_utility_categories_household_id_households_id_fk", + "tableFrom": "household_utility_categories", + "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 + }, + "default_locale": { + "name": "default_locale", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ru'" + }, + "assistant_context": { + "name": "assistant_context", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assistant_tone": { + "name": "assistant_tone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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.member_absence_policies": { + "name": "member_absence_policies", + "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 + }, + "member_id": { + "name": "member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "effective_from_period": { + "name": "effective_from_period", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_absence_policies_household_member_period_unique": { + "name": "member_absence_policies_household_member_period_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "effective_from_period", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_absence_policies_household_member_idx": { + "name": "member_absence_policies_household_member_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_absence_policies_household_id_households_id_fk": { + "name": "member_absence_policies_household_id_households_id_fk", + "tableFrom": "member_absence_policies", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_absence_policies_member_id_members_id_fk": { + "name": "member_absence_policies_member_id_members_id_fk", + "tableFrom": "member_absence_policies", + "tableTo": "members", + "columnsFrom": ["member_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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 + }, + "lifecycle_status": { + "name": "lifecycle_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "preferred_locale": { + "name": "preferred_locale", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rent_share_weight": { + "name": "rent_share_weight", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "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.payment_confirmations": { + "name": "payment_confirmations", + "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 + }, + "member_id": { + "name": "member_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "sender_telegram_user_id": { + "name": "sender_telegram_user_id", + "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": true + }, + "detected_kind": { + "name": "detected_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "explicit_amount_minor": { + "name": "explicit_amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "explicit_currency": { + "name": "explicit_currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resolved_amount_minor": { + "name": "resolved_amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "resolved_currency": { + "name": "resolved_currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "review_reason": { + "name": "review_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attachment_count": { + "name": "attachment_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "payment_confirmations_household_tg_message_unique": { + "name": "payment_confirmations_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": {} + }, + "payment_confirmations_household_tg_update_unique": { + "name": "payment_confirmations_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": {} + }, + "payment_confirmations_household_status_idx": { + "name": "payment_confirmations_household_status_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_confirmations_member_created_idx": { + "name": "payment_confirmations_member_created_idx", + "columns": [ + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payment_confirmations_household_id_households_id_fk": { + "name": "payment_confirmations_household_id_households_id_fk", + "tableFrom": "payment_confirmations", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payment_confirmations_cycle_id_billing_cycles_id_fk": { + "name": "payment_confirmations_cycle_id_billing_cycles_id_fk", + "tableFrom": "payment_confirmations", + "tableTo": "billing_cycles", + "columnsFrom": ["cycle_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "payment_confirmations_member_id_members_id_fk": { + "name": "payment_confirmations_member_id_members_id_fk", + "tableFrom": "payment_confirmations", + "tableTo": "members", + "columnsFrom": ["member_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payment_purchase_allocations": { + "name": "payment_purchase_allocations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "payment_record_id": { + "name": "payment_record_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "purchase_id": { + "name": "purchase_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "member_id": { + "name": "member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "amount_minor": { + "name": "amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "payment_purchase_allocations_payment_idx": { + "name": "payment_purchase_allocations_payment_idx", + "columns": [ + { + "expression": "payment_record_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_purchase_allocations_purchase_member_idx": { + "name": "payment_purchase_allocations_purchase_member_idx", + "columns": [ + { + "expression": "purchase_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payment_purchase_allocations_payment_record_id_payment_records_id_fk": { + "name": "payment_purchase_allocations_payment_record_id_payment_records_id_fk", + "tableFrom": "payment_purchase_allocations", + "tableTo": "payment_records", + "columnsFrom": ["payment_record_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payment_purchase_allocations_purchase_id_purchase_messages_id_fk": { + "name": "payment_purchase_allocations_purchase_id_purchase_messages_id_fk", + "tableFrom": "payment_purchase_allocations", + "tableTo": "purchase_messages", + "columnsFrom": ["purchase_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payment_purchase_allocations_member_id_members_id_fk": { + "name": "payment_purchase_allocations_member_id_members_id_fk", + "tableFrom": "payment_purchase_allocations", + "tableTo": "members", + "columnsFrom": ["member_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payment_records": { + "name": "payment_records", + "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 + }, + "member_id": { + "name": "member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "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 + }, + "confirmation_id": { + "name": "confirmation_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "recorded_at": { + "name": "recorded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "payment_records_cycle_member_idx": { + "name": "payment_records_cycle_member_idx", + "columns": [ + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_records_cycle_kind_idx": { + "name": "payment_records_cycle_kind_idx", + "columns": [ + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_records_confirmation_unique": { + "name": "payment_records_confirmation_unique", + "columns": [ + { + "expression": "confirmation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payment_records_household_id_households_id_fk": { + "name": "payment_records_household_id_households_id_fk", + "tableFrom": "payment_records", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payment_records_cycle_id_billing_cycles_id_fk": { + "name": "payment_records_cycle_id_billing_cycles_id_fk", + "tableFrom": "payment_records", + "tableTo": "billing_cycles", + "columnsFrom": ["cycle_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payment_records_member_id_members_id_fk": { + "name": "payment_records_member_id_members_id_fk", + "tableFrom": "payment_records", + "tableTo": "members", + "columnsFrom": ["member_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "payment_records_confirmation_id_payment_confirmations_id_fk": { + "name": "payment_records_confirmation_id_payment_confirmations_id_fk", + "tableFrom": "payment_records", + "tableTo": "payment_confirmations", + "columnsFrom": ["confirmation_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "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_message_participants": { + "name": "purchase_message_participants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "purchase_message_id": { + "name": "purchase_message_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "member_id": { + "name": "member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "included": { + "name": "included", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "share_amount_minor": { + "name": "share_amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "purchase_message_participants_purchase_member_unique": { + "name": "purchase_message_participants_purchase_member_unique", + "columns": [ + { + "expression": "purchase_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "purchase_message_participants_purchase_idx": { + "name": "purchase_message_participants_purchase_idx", + "columns": [ + { + "expression": "purchase_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "purchase_message_participants_member_idx": { + "name": "purchase_message_participants_member_idx", + "columns": [ + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "purchase_message_participants_purchase_message_id_purchase_messages_id_fk": { + "name": "purchase_message_participants_purchase_message_id_purchase_messages_id_fk", + "tableFrom": "purchase_message_participants", + "tableTo": "purchase_messages", + "columnsFrom": ["purchase_message_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "purchase_message_participants_member_id_members_id_fk": { + "name": "purchase_message_participants_member_id_members_id_fk", + "tableFrom": "purchase_message_participants", + "tableTo": "members", + "columnsFrom": ["member_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "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 + }, + "cycle_id": { + "name": "cycle_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "sender_member_id": { + "name": "sender_member_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "payer_member_id": { + "name": "payer_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 + }, + "participant_split_mode": { + "name": "participant_split_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'equal'" + }, + "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_cycle_idx": { + "name": "purchase_messages_cycle_idx", + "columns": [ + { + "expression": "cycle_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_cycle_id_billing_cycles_id_fk": { + "name": "purchase_messages_cycle_id_billing_cycles_id_fk", + "tableFrom": "purchase_messages", + "tableTo": "billing_cycles", + "columnsFrom": ["cycle_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "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" + }, + "purchase_messages_payer_member_id_members_id_fk": { + "name": "purchase_messages_payer_member_id_members_id_fk", + "tableFrom": "purchase_messages", + "tableTo": "members", + "columnsFrom": ["payer_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.scheduled_dispatches": { + "name": "scheduled_dispatches", + "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 + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "due_at": { + "name": "due_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'scheduled'" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_dispatch_id": { + "name": "provider_dispatch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ad_hoc_notification_id": { + "name": "ad_hoc_notification_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "period": { + "name": "period", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_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()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scheduled_dispatches_due_idx": { + "name": "scheduled_dispatches_due_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "due_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scheduled_dispatches_household_kind_idx": { + "name": "scheduled_dispatches_household_kind_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scheduled_dispatches_ad_hoc_notification_unique": { + "name": "scheduled_dispatches_ad_hoc_notification_unique", + "columns": [ + { + "expression": "ad_hoc_notification_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scheduled_dispatches_household_id_households_id_fk": { + "name": "scheduled_dispatches_household_id_households_id_fk", + "tableFrom": "scheduled_dispatches", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scheduled_dispatches_ad_hoc_notification_id_ad_hoc_notifications_id_fk": { + "name": "scheduled_dispatches_ad_hoc_notification_id_ad_hoc_notifications_id_fk", + "tableFrom": "scheduled_dispatches", + "tableTo": "ad_hoc_notifications", + "columnsFrom": ["ad_hoc_notification_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.telegram_pending_actions": { + "name": "telegram_pending_actions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "telegram_user_id": { + "name": "telegram_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "expires_at": { + "name": "expires_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()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "telegram_pending_actions_chat_user_unique": { + "name": "telegram_pending_actions_chat_user_unique", + "columns": [ + { + "expression": "telegram_chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "telegram_pending_actions_user_action_idx": { + "name": "telegram_pending_actions_user_action_idx", + "columns": [ + { + "expression": "telegram_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.topic_messages": { + "name": "topic_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 + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_thread_id": { + "name": "telegram_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telegram_message_id": { + "name": "telegram_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telegram_update_id": { + "name": "telegram_update_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sender_telegram_user_id": { + "name": "sender_telegram_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sender_display_name": { + "name": "sender_display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_bot": { + "name": "is_bot", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "raw_text": { + "name": "raw_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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": { + "topic_messages_household_thread_sent_idx": { + "name": "topic_messages_household_thread_sent_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_sent_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "topic_messages_household_chat_sent_idx": { + "name": "topic_messages_household_chat_sent_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_sent_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "topic_messages_household_tg_message_unique": { + "name": "topic_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": {} + }, + "topic_messages_household_tg_update_unique": { + "name": "topic_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": { + "topic_messages_household_id_households_id_fk": { + "name": "topic_messages_household_id_households_id_fk", + "tableFrom": "topic_messages", + "tableTo": "households", + "columnsFrom": ["household_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 1b47e96..5537825 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -169,6 +169,13 @@ "when": 1774294611532, "tag": "0023_huge_vision", "breakpoints": true + }, + { + "idx": 24, + "version": "7", + "when": 1774367260609, + "tag": "0024_lush_lucky_pierre", + "breakpoints": true } ] } diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 005267e..b0585c2 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -553,6 +553,41 @@ export const adHocNotifications = pgTable( }) ) +export const scheduledDispatches = pgTable( + 'scheduled_dispatches', + { + id: uuid('id').defaultRandom().primaryKey(), + householdId: uuid('household_id') + .notNull() + .references(() => households.id, { onDelete: 'cascade' }), + kind: text('kind').notNull(), + dueAt: timestamp('due_at', { withTimezone: true }).notNull(), + timezone: text('timezone').notNull(), + status: text('status').default('scheduled').notNull(), + provider: text('provider').notNull(), + providerDispatchId: text('provider_dispatch_id'), + adHocNotificationId: uuid('ad_hoc_notification_id').references(() => adHocNotifications.id, { + onDelete: 'cascade' + }), + period: text('period'), + sentAt: timestamp('sent_at', { withTimezone: true }), + cancelledAt: timestamp('cancelled_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull() + }, + (table) => ({ + dueIdx: index('scheduled_dispatches_due_idx').on(table.status, table.dueAt), + householdKindIdx: index('scheduled_dispatches_household_kind_idx').on( + table.householdId, + table.kind, + table.status + ), + adHocNotificationUnique: uniqueIndex('scheduled_dispatches_ad_hoc_notification_unique').on( + table.adHocNotificationId + ) + }) +) + export const topicMessages = pgTable( 'topic_messages', { diff --git a/packages/ports/src/index.ts b/packages/ports/src/index.ts index 6d468f0..c10aa6f 100644 --- a/packages/ports/src/index.ts +++ b/packages/ports/src/index.ts @@ -1,11 +1,4 @@ -export { - REMINDER_TYPES, - type ClaimReminderDispatchInput, - type ClaimReminderDispatchResult, - type ReminderDispatchRepository, - type ReminderTarget, - type ReminderType -} from './reminders' +export { REMINDER_TYPES, type ReminderTarget, type ReminderType } from './reminders' export { AD_HOC_NOTIFICATION_DELIVERY_MODES, AD_HOC_NOTIFICATION_STATUSES, @@ -20,6 +13,22 @@ export { type CreateAdHocNotificationInput, type UpdateAdHocNotificationInput } from './notifications' +export { + SCHEDULED_DISPATCH_KINDS, + SCHEDULED_DISPATCH_PROVIDERS, + SCHEDULED_DISPATCH_STATUSES, + type ClaimScheduledDispatchDeliveryResult, + type CreateScheduledDispatchInput, + type ScheduleOneShotDispatchInput, + type ScheduleOneShotDispatchResult, + type ScheduledDispatchKind, + type ScheduledDispatchProvider, + type ScheduledDispatchRecord, + type ScheduledDispatchRepository, + type ScheduledDispatchScheduler, + type ScheduledDispatchStatus, + type UpdateScheduledDispatchInput +} from './scheduled-dispatches' export type { ClaimProcessedBotMessageInput, ClaimProcessedBotMessageResult, diff --git a/packages/ports/src/reminders.ts b/packages/ports/src/reminders.ts index 5dd2ec3..c9a6cda 100644 --- a/packages/ports/src/reminders.ts +++ b/packages/ports/src/reminders.ts @@ -16,24 +16,3 @@ export interface ReminderTarget { utilitiesDueDay: number utilitiesReminderDay: number } - -export interface ClaimReminderDispatchInput { - householdId: string - period: string - reminderType: ReminderType - payloadHash: string -} - -export interface ClaimReminderDispatchResult { - dedupeKey: string - claimed: boolean -} - -export interface ReminderDispatchRepository { - claimReminderDispatch(input: ClaimReminderDispatchInput): Promise - releaseReminderDispatch(input: { - householdId: string - period: string - reminderType: ReminderType - }): Promise -} diff --git a/packages/ports/src/scheduled-dispatches.ts b/packages/ports/src/scheduled-dispatches.ts new file mode 100644 index 0000000..719e3b6 --- /dev/null +++ b/packages/ports/src/scheduled-dispatches.ts @@ -0,0 +1,97 @@ +import type { Instant } from '@household/domain' + +export const SCHEDULED_DISPATCH_KINDS = [ + 'ad_hoc_notification', + 'utilities', + 'rent_warning', + 'rent_due' +] as const +export const SCHEDULED_DISPATCH_STATUSES = ['scheduled', 'sent', 'cancelled'] as const +export const SCHEDULED_DISPATCH_PROVIDERS = ['gcp-cloud-tasks', 'aws-eventbridge'] as const + +export type ScheduledDispatchKind = (typeof SCHEDULED_DISPATCH_KINDS)[number] +export type ScheduledDispatchStatus = (typeof SCHEDULED_DISPATCH_STATUSES)[number] +export type ScheduledDispatchProvider = (typeof SCHEDULED_DISPATCH_PROVIDERS)[number] + +export interface ScheduledDispatchRecord { + id: string + householdId: string + kind: ScheduledDispatchKind + dueAt: Instant + timezone: string + status: ScheduledDispatchStatus + provider: ScheduledDispatchProvider + providerDispatchId: string | null + adHocNotificationId: string | null + period: string | null + sentAt: Instant | null + cancelledAt: Instant | null + createdAt: Instant + updatedAt: Instant +} + +export interface CreateScheduledDispatchInput { + householdId: string + kind: ScheduledDispatchKind + dueAt: Instant + timezone: string + provider: ScheduledDispatchProvider + providerDispatchId?: string | null + adHocNotificationId?: string | null + period?: string | null +} + +export interface UpdateScheduledDispatchInput { + dispatchId: string + dueAt?: Instant + timezone?: string + providerDispatchId?: string | null + period?: string | null + updatedAt: Instant +} + +export interface ClaimScheduledDispatchDeliveryResult { + dispatchId: string + claimed: boolean +} + +export interface ScheduledDispatchRepository { + createScheduledDispatch(input: CreateScheduledDispatchInput): Promise + getScheduledDispatchById(dispatchId: string): Promise + getScheduledDispatchByAdHocNotificationId( + notificationId: string + ): Promise + listScheduledDispatchesForHousehold( + householdId: string + ): Promise + updateScheduledDispatch( + input: UpdateScheduledDispatchInput + ): Promise + cancelScheduledDispatch( + dispatchId: string, + cancelledAt: Instant + ): Promise + markScheduledDispatchSent( + dispatchId: string, + sentAt: Instant + ): Promise + claimScheduledDispatchDelivery(dispatchId: string): Promise + releaseScheduledDispatchDelivery(dispatchId: string): Promise +} + +export interface ScheduleOneShotDispatchInput { + dispatchId: string + dueAt: Instant +} + +export interface ScheduleOneShotDispatchResult { + providerDispatchId: string +} + +export interface ScheduledDispatchScheduler { + readonly provider: ScheduledDispatchProvider + scheduleOneShotDispatch( + input: ScheduleOneShotDispatchInput + ): Promise + cancelDispatch(providerDispatchId: string): Promise +} diff --git a/scripts/ops/deploy-smoke.ts b/scripts/ops/deploy-smoke.ts index 01ca854..f19bda9 100644 --- a/scripts/ops/deploy-smoke.ts +++ b/scripts/ops/deploy-smoke.ts @@ -70,7 +70,7 @@ async function run(): Promise { ) await expectJson( - toUrl(botApiUrl, '/jobs/reminder/utilities'), + toUrl(botApiUrl, '/jobs/dispatch/test-dispatch'), { method: 'POST', headers: { diff --git a/scripts/ops/trigger-reminder.ts b/scripts/ops/trigger-reminder.ts deleted file mode 100644 index ebd844b..0000000 --- a/scripts/ops/trigger-reminder.ts +++ /dev/null @@ -1,108 +0,0 @@ -type ReminderType = 'utilities' | 'rent-warning' | 'rent-due' - -function parseReminderType(raw: string | undefined): ReminderType { - const value = raw?.trim() - - if (value === 'utilities' || value === 'rent-warning' || value === 'rent-due') { - return value - } - - throw new Error( - 'Usage: bun run ops:reminder [period] [--dry-run]' - ) -} - -function parseArgs(argv: readonly string[]) { - const reminderType = parseReminderType(argv[2]) - const rawPeriod = argv[3]?.trim() - const dryRun = argv.includes('--dry-run') - - return { - reminderType, - period: rawPeriod && rawPeriod.length > 0 ? rawPeriod : undefined, - dryRun - } -} - -function readText(command: string[], name: string): string { - const result = Bun.spawnSync(command, { - stdout: 'pipe', - stderr: 'pipe' - }) - - if (result.exitCode !== 0) { - const stderr = result.stderr.toString().trim() - throw new Error(`${name} failed: ${stderr || `exit code ${result.exitCode}`}`) - } - - const value = result.stdout.toString().trim() - if (!value) { - throw new Error(`${name} returned an empty value`) - } - - return value -} - -function resolveBotApiUrl(): string { - const envValue = process.env.BOT_API_URL?.trim() - if (envValue) { - return envValue - } - - return readText( - ['terraform', '-chdir=infra/terraform', 'output', '-raw', 'bot_api_service_url'], - 'terraform output bot_api_service_url' - ) -} - -function resolveSchedulerSecret(): string { - const envValue = process.env.SCHEDULER_SHARED_SECRET?.trim() - if (envValue) { - return envValue - } - - const projectId = process.env.GCP_PROJECT_ID?.trim() || 'gen-lang-client-0200379851' - return readText( - [ - 'gcloud', - 'secrets', - 'versions', - 'access', - 'latest', - '--secret=scheduler-shared-secret', - '--project', - projectId - ], - 'gcloud secrets versions access' - ) -} - -async function run() { - const { reminderType, period, dryRun } = parseArgs(process.argv) - const botApiUrl = resolveBotApiUrl().replace(/\/$/, '') - const schedulerSecret = resolveSchedulerSecret() - - const response = await fetch(`${botApiUrl}/jobs/reminder/${reminderType}`, { - method: 'POST', - headers: { - 'content-type': 'application/json', - 'x-household-scheduler-secret': schedulerSecret - }, - body: JSON.stringify({ - ...(period ? { period } : {}), - ...(dryRun ? { dryRun: true } : {}) - }) - }) - - const text = await response.text() - if (!response.ok) { - throw new Error(`${response.status} ${response.statusText}: ${text}`) - } - - console.log(text) -} - -run().catch((error) => { - console.error(error instanceof Error ? error.message : String(error)) - process.exitCode = 1 -}) diff --git a/scripts/package.json b/scripts/package.json index b2516eb..93e6364 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -6,6 +6,7 @@ "typecheck": "tsgo --project tsconfig.json --noEmit" }, "devDependencies": { + "@aws-sdk/client-scheduler": "^3.913.0", "drizzle-orm": "^0.45.1", "@household/config": "workspace:*", "@household/db": "workspace:*"