From 7218b55b1f5b74becfa9d381f5b4e6781a3bf55c Mon Sep 17 00:00:00 2001 From: whekin Date: Tue, 24 Mar 2026 01:28:26 +0400 Subject: [PATCH] feat(bot): add ad hoc reminder notifications --- apps/bot/src/ad-hoc-notification-jobs.test.ts | 114 + apps/bot/src/ad-hoc-notification-jobs.ts | 194 + .../src/ad-hoc-notification-parser.test.ts | 90 + apps/bot/src/ad-hoc-notification-parser.ts | 370 ++ apps/bot/src/ad-hoc-notifications.ts | 888 ++++ apps/bot/src/app.ts | 98 +- apps/bot/src/openai-chat-assistant.test.ts | 2 +- apps/bot/src/openai-chat-assistant.ts | 7 +- .../src/ad-hoc-notification-repository.ts | 276 ++ packages/adapters-db/src/index.ts | 1 + .../src/telegram-pending-action-repository.ts | 4 + .../src/ad-hoc-notification-service.test.ts | 268 ++ .../src/ad-hoc-notification-service.ts | 377 ++ packages/application/src/index.ts | 9 + packages/db/drizzle/0023_huge_vision.sql | 50 + packages/db/drizzle/meta/0023_snapshot.json | 3863 +++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/src/schema.ts | 46 + packages/ports/src/index.ts | 13 + packages/ports/src/notifications.ts | 76 + .../ports/src/telegram-pending-actions.ts | 1 + 21 files changed, 6746 insertions(+), 8 deletions(-) create mode 100644 apps/bot/src/ad-hoc-notification-jobs.test.ts create mode 100644 apps/bot/src/ad-hoc-notification-jobs.ts create mode 100644 apps/bot/src/ad-hoc-notification-parser.test.ts create mode 100644 apps/bot/src/ad-hoc-notification-parser.ts create mode 100644 apps/bot/src/ad-hoc-notifications.ts create mode 100644 packages/adapters-db/src/ad-hoc-notification-repository.ts create mode 100644 packages/application/src/ad-hoc-notification-service.test.ts create mode 100644 packages/application/src/ad-hoc-notification-service.ts create mode 100644 packages/db/drizzle/0023_huge_vision.sql create mode 100644 packages/db/drizzle/meta/0023_snapshot.json create mode 100644 packages/ports/src/notifications.ts diff --git a/apps/bot/src/ad-hoc-notification-jobs.test.ts b/apps/bot/src/ad-hoc-notification-jobs.test.ts new file mode 100644 index 0000000..328efc1 --- /dev/null +++ b/apps/bot/src/ad-hoc-notification-jobs.test.ts @@ -0,0 +1,114 @@ +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 ?? 'Ping Georgiy', + 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' }), + 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('tg://user?id=222') + 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 new file mode 100644 index 0000000..be8d77d --- /dev/null +++ b/apps/bot/src/ad-hoc-notification-jobs.ts @@ -0,0 +1,194 @@ +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, + assignee: notification.assignee, + friendlyTagAssignee: notification.notification.friendlyTagAssignee + }) + 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/ad-hoc-notification-parser.test.ts b/apps/bot/src/ad-hoc-notification-parser.test.ts new file mode 100644 index 0000000..8c099bd --- /dev/null +++ b/apps/bot/src/ad-hoc-notification-parser.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, test } from 'bun:test' + +import { Temporal } from '@household/domain' +import type { HouseholdMemberRecord } from '@household/ports' + +import { + parseAdHocNotificationRequest, + parseAdHocNotificationSchedule +} from './ad-hoc-notification-parser' + +function member( + input: Partial & Pick +): HouseholdMemberRecord { + return { + id: input.id, + householdId: input.householdId ?? 'household-1', + telegramUserId: input.telegramUserId ?? `${input.id}-tg`, + displayName: input.displayName ?? input.id, + status: input.status ?? 'active', + preferredLocale: input.preferredLocale ?? 'ru', + householdDefaultLocale: input.householdDefaultLocale ?? 'ru', + rentShareWeight: input.rentShareWeight ?? 1, + isAdmin: input.isAdmin ?? false + } +} + +describe('parseAdHocNotificationRequest', () => { + const members = [ + member({ id: 'dima', displayName: 'Дима' }), + member({ id: 'georgiy', displayName: 'Георгий' }) + ] + + test('parses exact datetime and assignee from russian request', () => { + const parsed = parseAdHocNotificationRequest({ + text: 'Железяка, напомни пошпынять Георгия завтра в 15:30', + timezone: 'Asia/Tbilisi', + locale: 'ru', + members, + senderMemberId: 'dima', + now: Temporal.Instant.from('2026-03-23T09:00:00Z') + }) + + expect(parsed.kind).toBe('parsed') + expect(parsed.notificationText).toContain('пошпынять Георгия') + expect(parsed.assigneeMemberId).toBe('georgiy') + expect(parsed.timePrecision).toBe('exact') + expect(parsed.scheduledFor?.toString()).toBe('2026-03-24T11:30:00Z') + }) + + test('defaults vague tomorrow to daytime slot', () => { + const parsed = parseAdHocNotificationRequest({ + text: 'напомни Георгию завтра про звонок', + timezone: 'Asia/Tbilisi', + locale: 'ru', + members, + senderMemberId: 'dima', + now: Temporal.Instant.from('2026-03-23T09:00:00Z') + }) + + expect(parsed.kind).toBe('parsed') + expect(parsed.timePrecision).toBe('date_only_defaulted') + expect(parsed.scheduledFor?.toString()).toBe('2026-03-24T08:00:00Z') + }) + + test('requests follow-up when schedule is missing', () => { + const parsed = parseAdHocNotificationRequest({ + text: 'напомни пошпынять Георгия', + timezone: 'Asia/Tbilisi', + locale: 'ru', + members, + senderMemberId: 'dima', + now: Temporal.Instant.from('2026-03-23T09:00:00Z') + }) + + expect(parsed.kind).toBe('missing_schedule') + expect(parsed.notificationText).toContain('пошпынять Георгия') + }) +}) + +describe('parseAdHocNotificationSchedule', () => { + test('rejects past schedule', () => { + const parsed = parseAdHocNotificationSchedule({ + text: 'сегодня в 10:00', + timezone: 'Asia/Tbilisi', + now: Temporal.Instant.from('2026-03-23T09:00:00Z') + }) + + expect(parsed.kind).toBe('invalid_past') + }) +}) diff --git a/apps/bot/src/ad-hoc-notification-parser.ts b/apps/bot/src/ad-hoc-notification-parser.ts new file mode 100644 index 0000000..949ead1 --- /dev/null +++ b/apps/bot/src/ad-hoc-notification-parser.ts @@ -0,0 +1,370 @@ +import { Temporal, nowInstant, type Instant } from '@household/domain' +import type { HouseholdMemberRecord } from '@household/ports' + +type SupportedLocale = 'en' | 'ru' + +export interface ParsedAdHocNotificationRequest { + kind: 'parsed' | 'missing_schedule' | 'invalid_past' | 'not_intent' + originalRequestText: string + notificationText: string | null + assigneeMemberId: string | null + scheduledFor: Instant | null + timePrecision: 'exact' | 'date_only_defaulted' | null +} + +export interface ParsedAdHocNotificationSchedule { + kind: 'parsed' | 'missing_schedule' | 'invalid_past' + scheduledFor: Instant | null + timePrecision: 'exact' | 'date_only_defaulted' | null +} + +const INTENT_PATTERNS = [ + /\bremind(?: me)?(?: to)?\b/i, + /\bping me\b/i, + /\bnotification\b/i, + /(?:^|[^\p{L}])напомни(?:ть)?(?=$|[^\p{L}])/iu, + /(?:^|[^\p{L}])напоминани[ея](?=$|[^\p{L}])/iu, + /(?:^|[^\p{L}])пни(?=$|[^\p{L}])/iu, + /(?:^|[^\p{L}])толкни(?=$|[^\p{L}])/iu +] as const + +const DAYTIME_DEFAULT_HOUR = 12 + +function normalizeWhitespace(value: string): string { + return value.replace(/\s+/gu, ' ').trim() +} + +function stripLeadingBotAddress(value: string): string { + const match = value.match(/^([^,\n:]{1,40})([:,])\s+/u) + if (!match) { + return value + } + + return value.slice(match[0].length) +} + +function hasIntent(value: string): boolean { + return INTENT_PATTERNS.some((pattern) => pattern.test(value)) +} + +function removeIntentPreamble(value: string): string { + const normalized = stripLeadingBotAddress(value) + const patterns = [ + /\bremind(?: me)?(?: to)?\b/iu, + /\bping me to\b/iu, + /(?:^|[^\p{L}])напомни(?:ть)?(?=$|[^\p{L}])/iu, + /(?:^|[^\p{L}])пни(?=$|[^\p{L}])/iu, + /(?:^|[^\p{L}])толкни(?=$|[^\p{L}])/iu + ] + + for (const pattern of patterns) { + const match = pattern.exec(normalized) + if (!match) { + continue + } + + return normalizeWhitespace(normalized.slice(match.index + match[0].length)) + } + + return normalizeWhitespace(normalized) +} + +function aliasVariants(token: string): string[] { + const aliases = new Set([token]) + + if (token.endsWith('а') && token.length > 2) { + aliases.add(`${token.slice(0, -1)}ы`) + aliases.add(`${token.slice(0, -1)}е`) + aliases.add(`${token.slice(0, -1)}у`) + aliases.add(`${token.slice(0, -1)}ой`) + } + + if (token.endsWith('я') && token.length > 2) { + aliases.add(`${token.slice(0, -1)}и`) + aliases.add(`${token.slice(0, -1)}ю`) + aliases.add(`${token.slice(0, -1)}ей`) + } + + if (token.endsWith('й') && token.length > 2) { + aliases.add(`${token.slice(0, -1)}я`) + aliases.add(`${token.slice(0, -1)}ю`) + } + + return [...aliases] +} + +function normalizeText(value: string): string { + return value + .toLowerCase() + .replace(/[^\p{L}\p{N}\s]/gu, ' ') + .replace(/\s+/gu, ' ') + .trim() +} + +function memberAliases(member: HouseholdMemberRecord): string[] { + const normalized = normalizeText(member.displayName) + const tokens = normalized.split(' ').filter((token) => token.length >= 2) + const aliases = new Set([normalized, ...tokens]) + + for (const token of tokens) { + for (const alias of aliasVariants(token)) { + aliases.add(alias) + } + } + + return [...aliases] +} + +function detectAssignee( + text: string, + members: readonly HouseholdMemberRecord[], + senderMemberId: string +): string | null { + const normalizedText = ` ${normalizeText(text)} ` + const candidates = members + .filter((member) => member.status === 'active' && member.id !== senderMemberId) + .map((member) => ({ + memberId: member.id, + score: memberAliases(member).reduce((best, alias) => { + const normalizedAlias = alias.trim() + if (normalizedAlias.length < 2) { + return best + } + + if (normalizedText.includes(` ${normalizedAlias} `)) { + return Math.max(best, normalizedAlias.length + 10) + } + + return best + }, 0) + })) + .filter((entry) => entry.score > 0) + .sort((left, right) => right.score - left.score) + + return candidates[0]?.memberId ?? null +} + +function parseTime(text: string): { + hour: number + minute: number + matchedText: string | null +} | null { + const explicit = /(?:^|\s)(?:at|в)\s*(\d{1,2})(?::(\d{2}))?(?=$|[^\d])/iu.exec(text) + const standalone = explicit ? explicit : /(?:^|\s)(\d{1,2}):(\d{2})(?=$|[^\d])/u.exec(text) + const match = standalone + if (!match) { + return null + } + + const hour = Number(match[1]) + const minute = Number(match[2] ?? '0') + if (!Number.isInteger(hour) || !Number.isInteger(minute) || hour > 23 || minute > 59) { + return null + } + + return { + hour, + minute, + matchedText: match[0] + } +} + +function parseDate( + text: string, + timezone: string, + referenceInstant: Instant +): { + date: Temporal.PlainDate + matchedText: string | null + precision: 'exact' | 'date_only_defaulted' +} | null { + const localNow = referenceInstant.toZonedDateTimeISO(timezone) + + const relativePatterns: Array<{ + pattern: RegExp + days: number + }> = [ + { pattern: /\bday after tomorrow\b/iu, days: 2 }, + { pattern: /(?:^|[^\p{L}])послезавтра(?=$|[^\p{L}])/iu, days: 2 }, + { pattern: /\btomorrow\b/iu, days: 1 }, + { pattern: /(?:^|[^\p{L}])завтра(?=$|[^\p{L}])/iu, days: 1 }, + { pattern: /\btoday\b/iu, days: 0 }, + { pattern: /(?:^|[^\p{L}])сегодня(?=$|[^\p{L}])/iu, days: 0 } + ] + + for (const entry of relativePatterns) { + const match = entry.pattern.exec(text) + if (!match) { + continue + } + + return { + date: localNow.toPlainDate().add({ days: entry.days }), + matchedText: match[0], + precision: 'date_only_defaulted' + } + } + + const isoMatch = /\b(\d{4})-(\d{2})-(\d{2})\b/u.exec(text) + if (isoMatch) { + return { + date: Temporal.PlainDate.from({ + year: Number(isoMatch[1]), + month: Number(isoMatch[2]), + day: Number(isoMatch[3]) + }), + matchedText: isoMatch[0], + precision: 'date_only_defaulted' + } + } + + const dottedMatch = /\b(\d{1,2})[./](\d{1,2})(?:[./](\d{4}))?\b/u.exec(text) + if (dottedMatch) { + return { + date: Temporal.PlainDate.from({ + year: Number(dottedMatch[3] ?? String(localNow.year)), + month: Number(dottedMatch[2]), + day: Number(dottedMatch[1]) + }), + matchedText: dottedMatch[0], + precision: 'date_only_defaulted' + } + } + + return null +} + +export function parseAdHocNotificationSchedule(input: { + text: string + timezone: string + now?: Instant +}): ParsedAdHocNotificationSchedule { + const rawText = normalizeWhitespace(input.text) + const referenceInstant = input.now ?? nowInstant() + const date = parseDate(rawText, input.timezone, referenceInstant) + const time = parseTime(rawText) + + if (!date) { + return { + kind: 'missing_schedule', + scheduledFor: null, + timePrecision: null + } + } + + const scheduledDateTime = Temporal.ZonedDateTime.from({ + timeZone: input.timezone, + year: date.date.year, + month: date.date.month, + day: date.date.day, + hour: time?.hour ?? DAYTIME_DEFAULT_HOUR, + minute: time?.minute ?? 0, + second: 0, + millisecond: 0 + }).toInstant() + + if (scheduledDateTime.epochMilliseconds <= referenceInstant.epochMilliseconds) { + return { + kind: 'invalid_past', + scheduledFor: scheduledDateTime, + timePrecision: time ? 'exact' : 'date_only_defaulted' + } + } + + return { + kind: 'parsed', + scheduledFor: scheduledDateTime, + timePrecision: time ? 'exact' : 'date_only_defaulted' + } +} + +function stripScheduleFragments(text: string, fragments: readonly (string | null)[]): string { + let next = text + + for (const fragment of fragments) { + if (!fragment || fragment.trim().length === 0) { + continue + } + + next = next.replace(fragment, ' ') + } + + next = next + .replace(/\b(?:on|at|в)\b/giu, ' ') + .replace(/\s+/gu, ' ') + .trim() + .replace(/^[,.\-:;]+/u, '') + .replace(/[,\-:;]+$/u, '') + .trim() + + return next +} + +export function parseAdHocNotificationRequest(input: { + text: string + timezone: string + locale: SupportedLocale + members: readonly HouseholdMemberRecord[] + senderMemberId: string + now?: Instant +}): ParsedAdHocNotificationRequest { + const rawText = normalizeWhitespace(input.text) + if (!hasIntent(rawText)) { + return { + kind: 'not_intent', + originalRequestText: rawText, + notificationText: null, + assigneeMemberId: null, + scheduledFor: null, + timePrecision: null + } + } + + const body = removeIntentPreamble(rawText) + const referenceInstant = input.now ?? nowInstant() + const date = parseDate(body, input.timezone, referenceInstant) + const time = parseTime(body) + + const notificationText = stripScheduleFragments(body, [ + date?.matchedText ?? null, + time?.matchedText ?? null + ]) + const assigneeMemberId = detectAssignee(notificationText, input.members, input.senderMemberId) + + if (!date) { + return { + kind: 'missing_schedule', + originalRequestText: rawText, + notificationText: notificationText.length > 0 ? notificationText : body, + assigneeMemberId, + scheduledFor: null, + timePrecision: null + } + } + + const schedule = parseAdHocNotificationSchedule({ + text: [date.matchedText, time?.matchedText].filter(Boolean).join(' '), + timezone: input.timezone, + now: referenceInstant + }) + + if (schedule.kind === 'invalid_past') { + return { + kind: 'invalid_past', + originalRequestText: rawText, + notificationText: notificationText.length > 0 ? notificationText : body, + assigneeMemberId, + scheduledFor: schedule.scheduledFor, + timePrecision: schedule.timePrecision + } + } + + return { + kind: 'parsed', + originalRequestText: rawText, + notificationText: notificationText.length > 0 ? notificationText : body, + assigneeMemberId, + scheduledFor: schedule.scheduledFor, + timePrecision: schedule.timePrecision + } +} diff --git a/apps/bot/src/ad-hoc-notifications.ts b/apps/bot/src/ad-hoc-notifications.ts new file mode 100644 index 0000000..f85d061 --- /dev/null +++ b/apps/bot/src/ad-hoc-notifications.ts @@ -0,0 +1,888 @@ +import type { AdHocNotificationService } from '@household/application' +import { Temporal, nowInstant } from '@household/domain' +import type { Logger } from '@household/observability' +import type { + AdHocNotificationDeliveryMode, + HouseholdConfigurationRepository, + HouseholdMemberRecord, + TelegramPendingActionRepository +} from '@household/ports' +import type { Bot, Context } from 'grammy' +import type { InlineKeyboardMarkup } from 'grammy/types' + +import { + parseAdHocNotificationRequest, + parseAdHocNotificationSchedule +} from './ad-hoc-notification-parser' +import { resolveReplyLocale } from './bot-locale' +import type { BotLocale } from './i18n' + +const AD_HOC_NOTIFICATION_ACTION = 'ad_hoc_notification' as const +const AD_HOC_NOTIFICATION_ACTION_TTL_MS = 30 * 60_000 +const AD_HOC_NOTIFICATION_CONFIRM_PREFIX = 'adhocnotif:confirm:' +const AD_HOC_NOTIFICATION_CANCEL_DRAFT_PREFIX = 'adhocnotif:canceldraft:' +const AD_HOC_NOTIFICATION_CANCEL_SAVED_PREFIX = 'adhocnotif:cancel:' +const AD_HOC_NOTIFICATION_MODE_PREFIX = 'adhocnotif:mode:' +const AD_HOC_NOTIFICATION_FRIENDLY_PREFIX = 'adhocnotif:friendly:' +const AD_HOC_NOTIFICATION_MEMBER_PREFIX = 'adhocnotif:member:' + +type NotificationDraftPayload = + | { + stage: 'await_schedule' + proposalId: string + householdId: string + threadId: string + creatorMemberId: string + timezone: string + originalRequestText: string + notificationText: string + assigneeMemberId: string | null + deliveryMode: AdHocNotificationDeliveryMode + dmRecipientMemberIds: readonly string[] + friendlyTagAssignee: boolean + } + | { + stage: 'confirm' + proposalId: string + householdId: string + threadId: string + creatorMemberId: string + timezone: string + originalRequestText: string + notificationText: string + assigneeMemberId: string | null + scheduledForIso: string + timePrecision: 'exact' | 'date_only_defaulted' + deliveryMode: AdHocNotificationDeliveryMode + dmRecipientMemberIds: readonly string[] + friendlyTagAssignee: boolean + } + +interface ReminderTopicContext { + locale: BotLocale + householdId: string + threadId: string + member: HouseholdMemberRecord + members: readonly HouseholdMemberRecord[] + timezone: string +} + +function createProposalId(): string { + return crypto.randomUUID().slice(0, 8) +} + +function getMessageThreadId(ctx: Context): string | null { + const message = + ctx.msg ?? + (ctx.callbackQuery && 'message' in ctx.callbackQuery ? ctx.callbackQuery.message : null) + if (!message || !('message_thread_id' in message) || message.message_thread_id === undefined) { + return null + } + + return message.message_thread_id.toString() +} + +function readMessageText(ctx: Context): string | null { + const message = ctx.message + if (!message) { + return null + } + + if ('text' in message && typeof message.text === 'string') { + return message.text.trim() + } + + if ('caption' in message && typeof message.caption === 'string') { + return message.caption.trim() + } + + return null +} + +function escapeHtml(raw: string): string { + return raw.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>') +} + +function formatScheduledFor(locale: BotLocale, scheduledForIso: string, timezone: string): string { + const zdt = Temporal.Instant.from(scheduledForIso).toZonedDateTimeISO(timezone) + const date = + locale === 'ru' + ? `${String(zdt.day).padStart(2, '0')}.${String(zdt.month).padStart(2, '0')}.${zdt.year}` + : `${zdt.year}-${String(zdt.month).padStart(2, '0')}-${String(zdt.day).padStart(2, '0')}` + const time = `${String(zdt.hour).padStart(2, '0')}:${String(zdt.minute).padStart(2, '0')}` + return `${date} ${time} (${timezone})` +} + +function deliveryModeLabel(locale: BotLocale, mode: AdHocNotificationDeliveryMode): string { + if (locale === 'ru') { + switch (mode) { + case 'topic': + return 'в этот топик' + case 'dm_all': + return 'всем в личку' + case 'dm_selected': + return 'выбранным в личку' + } + } + + switch (mode) { + case 'topic': + return 'this topic' + case 'dm_all': + return 'DM all members' + case 'dm_selected': + return 'DM selected members' + } +} + +function notificationSummaryText(input: { + locale: BotLocale + payload: Extract + members: readonly HouseholdMemberRecord[] +}): string { + const assignee = input.payload.assigneeMemberId + ? input.members.find((member) => member.id === input.payload.assigneeMemberId) + : null + const selectedRecipients = + input.payload.deliveryMode === 'dm_selected' + ? input.members.filter((member) => input.payload.dmRecipientMemberIds.includes(member.id)) + : [] + + if (input.locale === 'ru') { + return [ + 'Запланировать напоминание?', + '', + `Текст: ${input.payload.notificationText}`, + `Когда: ${formatScheduledFor(input.locale, input.payload.scheduledForIso, input.payload.timezone)}`, + `Точность: ${input.payload.timePrecision === 'date_only_defaulted' ? 'время по умолчанию 12:00' : 'точное время'}`, + `Куда: ${deliveryModeLabel(input.locale, input.payload.deliveryMode)}`, + assignee ? `Ответственный: ${assignee.displayName}` : null, + input.payload.deliveryMode === 'dm_selected' && selectedRecipients.length > 0 + ? `Получатели: ${selectedRecipients.map((member) => member.displayName).join(', ')}` + : null, + assignee ? `Дружелюбный тег: ${input.payload.friendlyTagAssignee ? 'вкл' : 'выкл'}` : null, + '', + 'Подтвердите или измените настройки ниже.' + ] + .filter(Boolean) + .join('\n') + } + + return [ + 'Schedule this notification?', + '', + `Text: ${input.payload.notificationText}`, + `When: ${formatScheduledFor(input.locale, input.payload.scheduledForIso, input.payload.timezone)}`, + `Precision: ${input.payload.timePrecision === 'date_only_defaulted' ? 'defaulted to 12:00' : 'exact time'}`, + `Delivery: ${deliveryModeLabel(input.locale, input.payload.deliveryMode)}`, + assignee ? `Assignee: ${assignee.displayName}` : null, + input.payload.deliveryMode === 'dm_selected' && selectedRecipients.length > 0 + ? `Recipients: ${selectedRecipients.map((member) => member.displayName).join(', ')}` + : null, + assignee ? `Friendly tag: ${input.payload.friendlyTagAssignee ? 'on' : 'off'}` : null, + '', + 'Confirm or adjust below.' + ] + .filter(Boolean) + .join('\n') +} + +function notificationDraftReplyMarkup( + locale: BotLocale, + payload: Extract, + members: readonly HouseholdMemberRecord[] +): InlineKeyboardMarkup { + const deliveryButtons = [ + { + text: `${payload.deliveryMode === 'topic' ? '• ' : ''}${locale === 'ru' ? 'В топик' : 'Topic'}`, + callback_data: `${AD_HOC_NOTIFICATION_MODE_PREFIX}${payload.proposalId}:topic` + }, + { + text: `${payload.deliveryMode === 'dm_all' ? '• ' : ''}${locale === 'ru' ? 'Всем ЛС' : 'DM all'}`, + callback_data: `${AD_HOC_NOTIFICATION_MODE_PREFIX}${payload.proposalId}:dm_all` + } + ] + + const rows: InlineKeyboardMarkup['inline_keyboard'] = [ + [ + { + text: locale === 'ru' ? 'Подтвердить' : 'Confirm', + callback_data: `${AD_HOC_NOTIFICATION_CONFIRM_PREFIX}${payload.proposalId}` + }, + { + text: locale === 'ru' ? 'Отменить' : 'Cancel', + callback_data: `${AD_HOC_NOTIFICATION_CANCEL_DRAFT_PREFIX}${payload.proposalId}` + } + ], + deliveryButtons, + [ + { + text: `${payload.deliveryMode === 'dm_selected' ? '• ' : ''}${locale === 'ru' ? 'Выбрать ЛС' : 'DM selected'}`, + callback_data: `${AD_HOC_NOTIFICATION_MODE_PREFIX}${payload.proposalId}:dm_selected` + } + ] + ] + + if (payload.assigneeMemberId) { + rows.push([ + { + text: `${payload.friendlyTagAssignee ? '✅ ' : ''}${locale === 'ru' ? 'Тегнуть ответственного' : 'Friendly tag assignee'}`, + callback_data: `${AD_HOC_NOTIFICATION_FRIENDLY_PREFIX}${payload.proposalId}` + } + ]) + } + + if (payload.deliveryMode === 'dm_selected') { + const eligibleMembers = members.filter((member) => member.status === 'active') + for (const member of eligibleMembers) { + rows.push([ + { + text: `${payload.dmRecipientMemberIds.includes(member.id) ? '✅ ' : ''}${member.displayName}`, + callback_data: `${AD_HOC_NOTIFICATION_MEMBER_PREFIX}${payload.proposalId}:${member.id}` + } + ]) + } + } + + return { + inline_keyboard: rows + } +} + +function buildSavedNotificationReplyMarkup( + locale: BotLocale, + notificationId: string +): InlineKeyboardMarkup { + return { + inline_keyboard: [ + [ + { + text: locale === 'ru' ? 'Отменить напоминание' : 'Cancel notification', + callback_data: `${AD_HOC_NOTIFICATION_CANCEL_SAVED_PREFIX}${notificationId}` + } + ] + ] + } +} + +async function replyInTopic( + ctx: Context, + text: string, + replyMarkup?: InlineKeyboardMarkup, + options?: { + parseMode?: 'HTML' + } +): Promise { + const message = ctx.msg + if (!ctx.chat || !message) { + return + } + + const threadId = + 'message_thread_id' in message && message.message_thread_id !== undefined + ? message.message_thread_id + : undefined + + await ctx.api.sendMessage(ctx.chat.id, text, { + ...(threadId !== undefined + ? { + message_thread_id: threadId + } + : {}), + reply_parameters: { + message_id: message.message_id + }, + ...(replyMarkup + ? { + reply_markup: replyMarkup as InlineKeyboardMarkup + } + : {}), + ...(options?.parseMode + ? { + parse_mode: options.parseMode + } + : {}) + }) +} + +async function resolveReminderTopicContext( + ctx: Context, + repository: HouseholdConfigurationRepository +): Promise { + if (ctx.chat?.type !== 'group' && ctx.chat?.type !== 'supergroup') { + return null + } + + const threadId = getMessageThreadId(ctx) + if (!ctx.chat || !threadId) { + return null + } + + const binding = await repository.findHouseholdTopicByTelegramContext({ + telegramChatId: ctx.chat.id.toString(), + telegramThreadId: threadId + }) + if (!binding || binding.role !== 'reminders') { + return null + } + + const telegramUserId = ctx.from?.id?.toString() + if (!telegramUserId) { + return null + } + + const [locale, member, members, settings] = await Promise.all([ + resolveReplyLocale({ + ctx, + repository, + householdId: binding.householdId + }), + repository.getHouseholdMember(binding.householdId, telegramUserId), + repository.listHouseholdMembers(binding.householdId), + repository.getHouseholdBillingSettings(binding.householdId) + ]) + + if (!member) { + return null + } + + return { + locale, + householdId: binding.householdId, + threadId, + member, + members, + timezone: settings.timezone + } +} + +async function saveDraft( + repository: TelegramPendingActionRepository, + ctx: Context, + payload: NotificationDraftPayload +): Promise { + const telegramUserId = ctx.from?.id?.toString() + const chatId = ctx.chat?.id?.toString() + if (!telegramUserId || !chatId) { + return + } + + await repository.upsertPendingAction({ + telegramUserId, + telegramChatId: chatId, + action: AD_HOC_NOTIFICATION_ACTION, + payload, + expiresAt: nowInstant().add({ milliseconds: AD_HOC_NOTIFICATION_ACTION_TTL_MS }) + }) +} + +async function loadDraft( + repository: TelegramPendingActionRepository, + ctx: Context +): Promise { + const telegramUserId = ctx.from?.id?.toString() + const chatId = ctx.chat?.id?.toString() + if (!telegramUserId || !chatId) { + return null + } + + const pending = await repository.getPendingAction(chatId, telegramUserId) + return pending?.action === AD_HOC_NOTIFICATION_ACTION + ? (pending.payload as NotificationDraftPayload) + : null +} + +export function registerAdHocNotifications(options: { + bot: Bot + householdConfigurationRepository: HouseholdConfigurationRepository + promptRepository: TelegramPendingActionRepository + notificationService: AdHocNotificationService + logger?: Logger +}): void { + async function showDraftConfirmation( + ctx: Context, + draft: Extract + ) { + const reminderContext = await resolveReminderTopicContext( + ctx, + options.householdConfigurationRepository + ) + if (!reminderContext) { + return + } + + await replyInTopic( + ctx, + notificationSummaryText({ + locale: reminderContext.locale, + payload: draft, + members: reminderContext.members + }), + notificationDraftReplyMarkup(reminderContext.locale, draft, reminderContext.members) + ) + } + + async function refreshConfirmationMessage( + ctx: Context, + payload: Extract + ) { + const reminderContext = await resolveReminderTopicContext( + ctx, + options.householdConfigurationRepository + ) + if (!reminderContext || !ctx.callbackQuery || !('message' in ctx.callbackQuery)) { + return + } + + await saveDraft(options.promptRepository, ctx, payload) + await ctx.editMessageText( + notificationSummaryText({ + locale: reminderContext.locale, + payload, + members: reminderContext.members + }), + { + reply_markup: notificationDraftReplyMarkup( + reminderContext.locale, + payload, + reminderContext.members + ) + } + ) + } + + options.bot.command('notifications', async (ctx, next) => { + const reminderContext = await resolveReminderTopicContext( + ctx, + options.householdConfigurationRepository + ) + if (!reminderContext) { + await next() + return + } + + const items = await options.notificationService.listUpcomingNotifications({ + householdId: reminderContext.householdId, + viewerMemberId: reminderContext.member.id + }) + const locale = reminderContext.locale + + if (items.length === 0) { + await replyInTopic( + ctx, + locale === 'ru' + ? 'Пока нет будущих напоминаний, которые вы можете отменить.' + : 'There are no upcoming notifications you can cancel yet.' + ) + return + } + + const lines = items.slice(0, 10).map((item, index) => { + const when = formatScheduledFor( + locale, + item.scheduledFor.toString(), + reminderContext.timezone + ) + return `${index + 1}. ${item.notificationText}\n${when}\n${deliveryModeLabel(locale, item.deliveryMode)}` + }) + + const keyboard: InlineKeyboardMarkup = { + inline_keyboard: items.slice(0, 10).map((item, index) => [ + { + text: locale === 'ru' ? `Отменить ${index + 1}` : `Cancel ${index + 1}`, + callback_data: `${AD_HOC_NOTIFICATION_CANCEL_SAVED_PREFIX}${item.id}` + } + ]) + } + + await replyInTopic( + ctx, + [locale === 'ru' ? 'Ближайшие напоминания:' : 'Upcoming notifications:', '', ...lines].join( + '\n' + ), + keyboard + ) + }) + + options.bot.on('message', async (ctx, next) => { + const messageText = readMessageText(ctx) + if (!messageText || messageText.startsWith('/')) { + await next() + return + } + + const reminderContext = await resolveReminderTopicContext( + ctx, + options.householdConfigurationRepository + ) + if (!reminderContext) { + await next() + return + } + + const existingDraft = await loadDraft(options.promptRepository, ctx) + if (existingDraft && existingDraft.threadId === reminderContext.threadId) { + if (existingDraft.stage === 'await_schedule') { + const schedule = parseAdHocNotificationSchedule({ + text: messageText, + timezone: existingDraft.timezone + }) + + if (schedule.kind === 'missing_schedule') { + await replyInTopic( + ctx, + reminderContext.locale === 'ru' + ? 'Нужны хотя бы день или дата. Например: «завтра», «24.03», «2026-03-24 18:30».' + : 'I still need at least a day or date. For example: "tomorrow", "2026-03-24", or "2026-03-24 18:30".' + ) + return + } + + if (schedule.kind === 'invalid_past') { + await replyInTopic( + ctx, + reminderContext.locale === 'ru' + ? 'Это время уже в прошлом. Пришлите будущую дату или время.' + : 'That time is already in the past. Send a future date or time.' + ) + return + } + + const confirmPayload: Extract = { + ...existingDraft, + stage: 'confirm', + scheduledForIso: schedule.scheduledFor!.toString(), + timePrecision: schedule.timePrecision! + } + await saveDraft(options.promptRepository, ctx, confirmPayload) + await showDraftConfirmation(ctx, confirmPayload) + return + } + + await next() + return + } + + const parsed = parseAdHocNotificationRequest({ + text: messageText, + timezone: reminderContext.timezone, + locale: reminderContext.locale, + members: reminderContext.members, + senderMemberId: reminderContext.member.id + }) + + if (parsed.kind === 'not_intent') { + await next() + return + } + + if (!parsed.notificationText || parsed.notificationText.length === 0) { + await replyInTopic( + ctx, + reminderContext.locale === 'ru' + ? 'Не понял текст напоминания. Сформулируйте, что именно нужно напомнить.' + : 'I could not extract the notification text. Please restate what should be reminded.' + ) + return + } + + if (parsed.kind === 'missing_schedule') { + await saveDraft(options.promptRepository, ctx, { + stage: 'await_schedule', + proposalId: createProposalId(), + householdId: reminderContext.householdId, + threadId: reminderContext.threadId, + creatorMemberId: reminderContext.member.id, + timezone: reminderContext.timezone, + originalRequestText: parsed.originalRequestText, + notificationText: parsed.notificationText, + assigneeMemberId: parsed.assigneeMemberId, + deliveryMode: 'topic', + dmRecipientMemberIds: [], + friendlyTagAssignee: false + }) + + await replyInTopic( + ctx, + reminderContext.locale === 'ru' + ? 'Когда напомнить? Подойдёт свободная форма, например: «завтра», «завтра в 15:00», «24.03 18:30».' + : 'When should I remind? Free-form is fine, for example: "tomorrow", "tomorrow 15:00", or "2026-03-24 18:30".' + ) + return + } + + if (parsed.kind === 'invalid_past') { + await replyInTopic( + ctx, + reminderContext.locale === 'ru' + ? 'Это время уже в прошлом. Пришлите будущую дату или время.' + : 'That time is already in the past. Send a future date or time.' + ) + return + } + + const draft: Extract = { + stage: 'confirm', + proposalId: createProposalId(), + householdId: reminderContext.householdId, + threadId: reminderContext.threadId, + creatorMemberId: reminderContext.member.id, + timezone: reminderContext.timezone, + originalRequestText: parsed.originalRequestText, + notificationText: parsed.notificationText, + assigneeMemberId: parsed.assigneeMemberId, + scheduledForIso: parsed.scheduledFor!.toString(), + timePrecision: parsed.timePrecision!, + deliveryMode: 'topic', + dmRecipientMemberIds: [], + friendlyTagAssignee: false + } + + await saveDraft(options.promptRepository, ctx, draft) + await showDraftConfirmation(ctx, draft) + }) + + options.bot.on('callback_query:data', async (ctx, next) => { + const data = typeof ctx.callbackQuery?.data === 'string' ? ctx.callbackQuery.data : null + if (!data) { + await next() + return + } + + if (data.startsWith(AD_HOC_NOTIFICATION_CONFIRM_PREFIX)) { + const proposalId = data.slice(AD_HOC_NOTIFICATION_CONFIRM_PREFIX.length) + const reminderContext = await resolveReminderTopicContext( + ctx, + options.householdConfigurationRepository + ) + const payload = await loadDraft(options.promptRepository, ctx) + if ( + !reminderContext || + !payload || + payload.stage !== 'confirm' || + payload.proposalId !== proposalId + ) { + await next() + return + } + + const result = await options.notificationService.scheduleNotification({ + householdId: payload.householdId, + creatorMemberId: payload.creatorMemberId, + originalRequestText: payload.originalRequestText, + notificationText: payload.notificationText, + timezone: payload.timezone, + scheduledFor: Temporal.Instant.from(payload.scheduledForIso), + timePrecision: payload.timePrecision, + deliveryMode: payload.deliveryMode, + assigneeMemberId: payload.assigneeMemberId, + dmRecipientMemberIds: payload.dmRecipientMemberIds, + friendlyTagAssignee: payload.friendlyTagAssignee, + sourceTelegramChatId: ctx.chat?.id?.toString() ?? null, + sourceTelegramThreadId: payload.threadId + }) + + if (result.status !== 'scheduled') { + await ctx.answerCallbackQuery({ + text: + reminderContext.locale === 'ru' + ? 'Не удалось сохранить напоминание.' + : 'Failed to save notification.', + show_alert: true + }) + return + } + + await options.promptRepository.clearPendingAction( + ctx.chat!.id.toString(), + ctx.from!.id.toString() + ) + + await ctx.answerCallbackQuery({ + text: + reminderContext.locale === 'ru' ? 'Напоминание запланировано.' : 'Notification scheduled.' + }) + await ctx.editMessageText( + [ + reminderContext.locale === 'ru' + ? `Напоминание запланировано: ${result.notification.notificationText}` + : `Notification scheduled: ${result.notification.notificationText}`, + formatScheduledFor( + reminderContext.locale, + result.notification.scheduledFor.toString(), + result.notification.timezone + ) + ].join('\n'), + { + reply_markup: buildSavedNotificationReplyMarkup( + reminderContext.locale, + result.notification.id + ) + } + ) + return + } + + if (data.startsWith(AD_HOC_NOTIFICATION_CANCEL_DRAFT_PREFIX)) { + const proposalId = data.slice(AD_HOC_NOTIFICATION_CANCEL_DRAFT_PREFIX.length) + const payload = await loadDraft(options.promptRepository, ctx) + if (!payload || payload.proposalId !== proposalId || !ctx.chat || !ctx.from) { + await next() + return + } + + await options.promptRepository.clearPendingAction( + ctx.chat.id.toString(), + ctx.from.id.toString() + ) + await ctx.answerCallbackQuery({ + text: 'Cancelled' + }) + await ctx.editMessageText('Cancelled', { + reply_markup: { + inline_keyboard: [] + } + }) + return + } + + if (data.startsWith(AD_HOC_NOTIFICATION_MODE_PREFIX)) { + const [proposalId, mode] = data.slice(AD_HOC_NOTIFICATION_MODE_PREFIX.length).split(':') + const payload = await loadDraft(options.promptRepository, ctx) + if ( + !payload || + payload.stage !== 'confirm' || + payload.proposalId !== proposalId || + (mode !== 'topic' && mode !== 'dm_all' && mode !== 'dm_selected') + ) { + await next() + return + } + + const nextPayload: Extract = { + ...payload, + deliveryMode: mode, + dmRecipientMemberIds: mode === 'dm_selected' ? payload.dmRecipientMemberIds : [] + } + await refreshConfirmationMessage(ctx, nextPayload) + await ctx.answerCallbackQuery() + return + } + + if (data.startsWith(AD_HOC_NOTIFICATION_FRIENDLY_PREFIX)) { + const proposalId = data.slice(AD_HOC_NOTIFICATION_FRIENDLY_PREFIX.length) + const payload = await loadDraft(options.promptRepository, ctx) + if (!payload || payload.stage !== 'confirm' || payload.proposalId !== proposalId) { + await next() + return + } + + await refreshConfirmationMessage(ctx, { + ...payload, + friendlyTagAssignee: !payload.friendlyTagAssignee + }) + await ctx.answerCallbackQuery() + return + } + + if (data.startsWith(AD_HOC_NOTIFICATION_MEMBER_PREFIX)) { + const rest = data.slice(AD_HOC_NOTIFICATION_MEMBER_PREFIX.length) + const separatorIndex = rest.indexOf(':') + const proposalId = separatorIndex >= 0 ? rest.slice(0, separatorIndex) : '' + const memberId = separatorIndex >= 0 ? rest.slice(separatorIndex + 1) : '' + const payload = await loadDraft(options.promptRepository, ctx) + if ( + !payload || + payload.stage !== 'confirm' || + payload.proposalId !== proposalId || + payload.deliveryMode !== 'dm_selected' + ) { + await next() + return + } + + const selected = new Set(payload.dmRecipientMemberIds) + if (selected.has(memberId)) { + selected.delete(memberId) + } else { + selected.add(memberId) + } + + await refreshConfirmationMessage(ctx, { + ...payload, + dmRecipientMemberIds: [...selected] + }) + await ctx.answerCallbackQuery() + return + } + + if (data.startsWith(AD_HOC_NOTIFICATION_CANCEL_SAVED_PREFIX)) { + const notificationId = data.slice(AD_HOC_NOTIFICATION_CANCEL_SAVED_PREFIX.length) + const reminderContext = await resolveReminderTopicContext( + ctx, + options.householdConfigurationRepository + ) + if (!reminderContext) { + await next() + return + } + + const result = await options.notificationService.cancelNotification({ + notificationId, + viewerMemberId: reminderContext.member.id + }) + + if (result.status !== 'cancelled') { + await ctx.answerCallbackQuery({ + text: + reminderContext.locale === 'ru' + ? 'Не удалось отменить напоминание.' + : 'Could not cancel this notification.', + show_alert: true + }) + return + } + + await ctx.answerCallbackQuery({ + text: reminderContext.locale === 'ru' ? 'Напоминание отменено.' : 'Notification cancelled.' + }) + await ctx.editMessageText( + reminderContext.locale === 'ru' + ? `Напоминание отменено: ${result.notification.notificationText}` + : `Notification cancelled: ${result.notification.notificationText}`, + { + reply_markup: { + inline_keyboard: [] + } + } + ) + return + } + + await next() + }) +} + +export function buildTopicNotificationText(input: { + notificationText: string + assignee?: { + displayName: string + telegramUserId: string + } | null + friendlyTagAssignee: boolean +}): { + text: string + parseMode: 'HTML' +} { + if (input.friendlyTagAssignee && input.assignee) { + return { + text: `${escapeHtml(input.assignee.displayName)}, ${escapeHtml(input.notificationText)}`, + parseMode: 'HTML' + } + } + + return { + text: escapeHtml(input.notificationText), + parseMode: 'HTML' + } +} diff --git a/apps/bot/src/app.ts b/apps/bot/src/app.ts index 60e390c..3e6955f 100644 --- a/apps/bot/src/app.ts +++ b/apps/bot/src/app.ts @@ -2,6 +2,7 @@ import { webhookCallback } from 'grammy' import type { InlineKeyboardMarkup } from 'grammy/types' import { + createAdHocNotificationService, createAnonymousFeedbackService, createFinanceCommandService, createHouseholdAdminService, @@ -13,6 +14,7 @@ import { createReminderJobService } from '@household/application' import { + createDbAdHocNotificationRepository, createDbAnonymousFeedbackRepository, createDbFinanceRepository, createDbHouseholdConfigurationRepository, @@ -23,6 +25,8 @@ import { } 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 { createInMemoryAssistantConversationMemoryStore, @@ -178,6 +182,16 @@ export async function createBotRuntimeApp(): Promise { string, ReturnType >() + const adHocNotificationRepositoryClient = runtime.databaseUrl + ? createDbAdHocNotificationRepository(runtime.databaseUrl) + : null + const adHocNotificationService = + adHocNotificationRepositoryClient && householdConfigurationRepositoryClient + ? createAdHocNotificationService({ + repository: adHocNotificationRepositoryClient.repository, + householdConfigurationRepository: householdConfigurationRepositoryClient.repository + }) + : null function financeServiceForHousehold(householdId: string) { const existing = financeServices.get(householdId) @@ -260,6 +274,10 @@ export async function createBotRuntimeApp(): Promise { shutdownTasks.push(topicMessageHistoryRepositoryClient.close) } + if (adHocNotificationRepositoryClient) { + shutdownTasks.push(adHocNotificationRepositoryClient.close) + } + if (purchaseRepositoryClient && householdConfigurationRepositoryClient) { registerConfiguredPurchaseTopicIngestion( bot, @@ -375,6 +393,20 @@ export async function createBotRuntimeApp(): Promise { ) } + if ( + householdConfigurationRepositoryClient && + telegramPendingActionRepositoryClient && + adHocNotificationService + ) { + registerAdHocNotifications({ + bot, + householdConfigurationRepository: householdConfigurationRepositoryClient.repository, + promptRepository: telegramPendingActionRepositoryClient.repository, + notificationService: adHocNotificationService, + logger: getLogger('ad-hoc-notifications') + }) + } + const reminderJobs = runtime.reminderJobsEnabled ? (() => { const reminderRepositoryClient = createDbReminderDispatchRepository(runtime.databaseUrl!) @@ -428,6 +460,34 @@ export async function createBotRuntimeApp(): Promise { }) })() : null + const adHocNotificationJobs = + runtime.reminderJobsEnabled && + adHocNotificationService && + householdConfigurationRepositoryClient + ? createAdHocNotificationJobsHandler({ + notificationService: adHocNotificationService, + householdConfigurationRepository: householdConfigurationRepositoryClient.repository, + sendTopicMessage: async (input) => { + const threadId = input.threadId ? Number(input.threadId) : undefined + await bot.api.sendMessage(input.chatId, input.text, { + ...(threadId && Number.isInteger(threadId) + ? { + message_thread_id: threadId + } + : {}), + ...(input.parseMode + ? { + parse_mode: input.parseMode + } + : {}) + }) + }, + sendDirectMessage: async (input) => { + await bot.api.sendMessage(input.telegramUserId, input.text) + }, + logger: getLogger('scheduler') + }) + : null if (!runtime.reminderJobsEnabled) { logger.warn( @@ -825,20 +885,50 @@ export async function createBotRuntimeApp(): Promise { }) : undefined, scheduler: - reminderJobs && runtime.schedulerSharedSecret + (reminderJobs || adHocNotificationJobs) && runtime.schedulerSharedSecret ? { + pathPrefix: '/jobs', authorize: createSchedulerRequestAuthorizer({ sharedSecret: runtime.schedulerSharedSecret, oidcAllowedEmails: runtime.schedulerOidcAllowedEmails }).authorize, - handler: reminderJobs.handle + 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) + : new Response('Not Found', { status: 404 }) + } + + return new Response('Not Found', { status: 404 }) + } } - : reminderJobs + : reminderJobs || adHocNotificationJobs ? { + pathPrefix: '/jobs', authorize: createSchedulerRequestAuthorizer({ oidcAllowedEmails: runtime.schedulerOidcAllowedEmails }).authorize, - handler: reminderJobs.handle + 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) + : new Response('Not Found', { status: 404 }) + } + + return new Response('Not Found', { status: 404 }) + } } : undefined }) diff --git a/apps/bot/src/openai-chat-assistant.test.ts b/apps/bot/src/openai-chat-assistant.test.ts index c44e378..af8e657 100644 --- a/apps/bot/src/openai-chat-assistant.test.ts +++ b/apps/bot/src/openai-chat-assistant.test.ts @@ -66,7 +66,7 @@ describe('createOpenAiChatAssistant', () => { expect(capturedBody!.input[1]?.role).toBe('system') expect(capturedBody!.input[1]?.content).toContain('Topic role: reminders') expect(capturedBody!.input[1]?.content).toContain( - 'You cannot create, schedule, snooze, or manage arbitrary personal reminders.' + 'Members can ask the bot to schedule a future notification in this topic.' ) } finally { globalThis.fetch = originalFetch diff --git a/apps/bot/src/openai-chat-assistant.ts b/apps/bot/src/openai-chat-assistant.ts index 5b4ee7e..11dae9e 100644 --- a/apps/bot/src/openai-chat-assistant.ts +++ b/apps/bot/src/openai-chat-assistant.ts @@ -61,9 +61,10 @@ function topicCapabilityNotes(topicRole: TopicMessageRole): string { case 'reminders': return [ 'Reminders topic capabilities:', - '- You can discuss existing household rent/utilities reminder timing and the supported utility-bill collection flow.', - '- You cannot create, schedule, snooze, or manage arbitrary personal reminders.', - '- You cannot promise future reminder setup. If asked, say that this feature is not supported.' + '- You can discuss existing household rent/utilities reminder timing, the supported utility-bill collection flow, and ad hoc household notifications.', + '- Members can ask the bot to schedule a future notification in this topic.', + '- If the date or time is missing, ask a concise follow-up instead of pretending it was scheduled.', + '- Do not claim a notification was saved unless the system explicitly confirmed it.' ].join('\n') case 'feedback': return [ diff --git a/packages/adapters-db/src/ad-hoc-notification-repository.ts b/packages/adapters-db/src/ad-hoc-notification-repository.ts new file mode 100644 index 0000000..c558468 --- /dev/null +++ b/packages/adapters-db/src/ad-hoc-notification-repository.ts @@ -0,0 +1,276 @@ +import { and, asc, eq, lte } from 'drizzle-orm' + +import { createDbClient, schema } from '@household/db' +import { instantFromDatabaseValue, instantToDate, nowInstant } from '@household/domain' +import type { + AdHocNotificationRecord, + AdHocNotificationRepository, + ClaimAdHocNotificationDeliveryResult +} from '@household/ports' + +const DELIVERY_CLAIM_SOURCE = 'ad-hoc-notification' + +function parseMemberIds(raw: unknown): readonly string[] { + if (!Array.isArray(raw)) { + return [] + } + + return raw.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0) +} + +function mapNotification(row: { + id: string + householdId: string + creatorMemberId: string + assigneeMemberId: string | null + originalRequestText: string + notificationText: string + timezone: string + scheduledFor: Date | string + timePrecision: string + deliveryMode: string + dmRecipientMemberIds: unknown + friendlyTagAssignee: number + status: string + sourceTelegramChatId: string | null + sourceTelegramThreadId: string | null + sentAt: Date | string | null + cancelledAt: Date | string | null + cancelledByMemberId: string | null + createdAt: Date | string + updatedAt: Date | string +}): AdHocNotificationRecord { + return { + id: row.id, + householdId: row.householdId, + creatorMemberId: row.creatorMemberId, + assigneeMemberId: row.assigneeMemberId, + originalRequestText: row.originalRequestText, + notificationText: row.notificationText, + timezone: row.timezone, + scheduledFor: instantFromDatabaseValue(row.scheduledFor)!, + timePrecision: row.timePrecision as AdHocNotificationRecord['timePrecision'], + deliveryMode: row.deliveryMode as AdHocNotificationRecord['deliveryMode'], + dmRecipientMemberIds: parseMemberIds(row.dmRecipientMemberIds), + friendlyTagAssignee: row.friendlyTagAssignee === 1, + status: row.status as AdHocNotificationRecord['status'], + sourceTelegramChatId: row.sourceTelegramChatId, + sourceTelegramThreadId: row.sourceTelegramThreadId, + sentAt: instantFromDatabaseValue(row.sentAt), + cancelledAt: instantFromDatabaseValue(row.cancelledAt), + cancelledByMemberId: row.cancelledByMemberId, + createdAt: instantFromDatabaseValue(row.createdAt)!, + updatedAt: instantFromDatabaseValue(row.updatedAt)! + } +} + +function notificationSelect() { + return { + id: schema.adHocNotifications.id, + householdId: schema.adHocNotifications.householdId, + creatorMemberId: schema.adHocNotifications.creatorMemberId, + assigneeMemberId: schema.adHocNotifications.assigneeMemberId, + originalRequestText: schema.adHocNotifications.originalRequestText, + notificationText: schema.adHocNotifications.notificationText, + timezone: schema.adHocNotifications.timezone, + scheduledFor: schema.adHocNotifications.scheduledFor, + timePrecision: schema.adHocNotifications.timePrecision, + deliveryMode: schema.adHocNotifications.deliveryMode, + dmRecipientMemberIds: schema.adHocNotifications.dmRecipientMemberIds, + friendlyTagAssignee: schema.adHocNotifications.friendlyTagAssignee, + status: schema.adHocNotifications.status, + sourceTelegramChatId: schema.adHocNotifications.sourceTelegramChatId, + sourceTelegramThreadId: schema.adHocNotifications.sourceTelegramThreadId, + sentAt: schema.adHocNotifications.sentAt, + cancelledAt: schema.adHocNotifications.cancelledAt, + cancelledByMemberId: schema.adHocNotifications.cancelledByMemberId, + createdAt: schema.adHocNotifications.createdAt, + updatedAt: schema.adHocNotifications.updatedAt + } +} + +export function createDbAdHocNotificationRepository(databaseUrl: string): { + repository: AdHocNotificationRepository + close: () => Promise +} { + const { db, queryClient } = createDbClient(databaseUrl, { + max: 3, + prepare: false + }) + + const repository: AdHocNotificationRepository = { + async createNotification(input) { + const timestamp = instantToDate(nowInstant()) + const rows = await db + .insert(schema.adHocNotifications) + .values({ + householdId: input.householdId, + creatorMemberId: input.creatorMemberId, + assigneeMemberId: input.assigneeMemberId ?? null, + originalRequestText: input.originalRequestText, + notificationText: input.notificationText, + timezone: input.timezone, + scheduledFor: instantToDate(input.scheduledFor), + timePrecision: input.timePrecision, + deliveryMode: input.deliveryMode, + dmRecipientMemberIds: input.dmRecipientMemberIds ?? [], + friendlyTagAssignee: input.friendlyTagAssignee ? 1 : 0, + status: 'scheduled', + sourceTelegramChatId: input.sourceTelegramChatId ?? null, + sourceTelegramThreadId: input.sourceTelegramThreadId ?? null, + updatedAt: timestamp + }) + .returning(notificationSelect()) + + const row = rows[0] + if (!row) { + throw new Error('Notification insert did not return a row') + } + + return mapNotification(row) + }, + + async getNotificationById(notificationId) { + const rows = await db + .select(notificationSelect()) + .from(schema.adHocNotifications) + .where(eq(schema.adHocNotifications.id, notificationId)) + .limit(1) + + return rows[0] ? mapNotification(rows[0]) : null + }, + + async listUpcomingNotificationsForHousehold(householdId, asOf) { + const rows = await db + .select(notificationSelect()) + .from(schema.adHocNotifications) + .where( + and( + eq(schema.adHocNotifications.householdId, householdId), + eq(schema.adHocNotifications.status, 'scheduled'), + lte(schema.adHocNotifications.createdAt, instantToDate(asOf)) + ) + ) + .orderBy( + asc(schema.adHocNotifications.scheduledFor), + asc(schema.adHocNotifications.createdAt) + ) + + return rows + .map(mapNotification) + .filter((record) => record.scheduledFor.epochMilliseconds >= asOf.epochMilliseconds) + }, + + async cancelNotification(input) { + const rows = await db + .update(schema.adHocNotifications) + .set({ + status: 'cancelled', + cancelledAt: instantToDate(input.cancelledAt), + cancelledByMemberId: input.cancelledByMemberId, + updatedAt: instantToDate(nowInstant()) + }) + .where( + and( + eq(schema.adHocNotifications.id, input.notificationId), + eq(schema.adHocNotifications.status, 'scheduled') + ) + ) + .returning(notificationSelect()) + + return rows[0] ? mapNotification(rows[0]) : null + }, + + async listDueNotifications(asOf) { + const rows = await db + .select(notificationSelect()) + .from(schema.adHocNotifications) + .where( + and( + eq(schema.adHocNotifications.status, 'scheduled'), + lte(schema.adHocNotifications.scheduledFor, instantToDate(asOf)) + ) + ) + .orderBy( + asc(schema.adHocNotifications.scheduledFor), + asc(schema.adHocNotifications.createdAt) + ) + + return rows.map(mapNotification) + }, + + async markNotificationSent(notificationId, sentAt) { + const rows = await db + .update(schema.adHocNotifications) + .set({ + status: 'sent', + sentAt: instantToDate(sentAt), + updatedAt: instantToDate(nowInstant()) + }) + .where( + and( + eq(schema.adHocNotifications.id, notificationId), + eq(schema.adHocNotifications.status, 'scheduled') + ) + ) + .returning(notificationSelect()) + + return rows[0] ? mapNotification(rows[0]) : null + }, + + async claimNotificationDelivery(notificationId) { + const notification = await repository.getNotificationById(notificationId) + if (!notification) { + return { + notificationId, + claimed: false + } satisfies ClaimAdHocNotificationDeliveryResult + } + + const rows = await db + .insert(schema.processedBotMessages) + .values({ + householdId: notification.householdId, + source: DELIVERY_CLAIM_SOURCE, + sourceMessageKey: notificationId + }) + .onConflictDoNothing({ + target: [ + schema.processedBotMessages.householdId, + schema.processedBotMessages.source, + schema.processedBotMessages.sourceMessageKey + ] + }) + .returning({ id: schema.processedBotMessages.id }) + + return { + notificationId, + claimed: rows.length > 0 + } + }, + + async releaseNotificationDelivery(notificationId) { + const notification = await repository.getNotificationById(notificationId) + if (!notification) { + return + } + + await db + .delete(schema.processedBotMessages) + .where( + and( + eq(schema.processedBotMessages.householdId, notification.householdId), + eq(schema.processedBotMessages.source, DELIVERY_CLAIM_SOURCE), + eq(schema.processedBotMessages.sourceMessageKey, notificationId) + ) + ) + } + } + + return { + repository, + close: async () => { + await queryClient.end({ timeout: 5 }) + } + } +} diff --git a/packages/adapters-db/src/index.ts b/packages/adapters-db/src/index.ts index 8ae64ba..02d6a77 100644 --- a/packages/adapters-db/src/index.ts +++ b/packages/adapters-db/src/index.ts @@ -1,3 +1,4 @@ +export { createDbAdHocNotificationRepository } from './ad-hoc-notification-repository' export { createDbAnonymousFeedbackRepository } from './anonymous-feedback-repository' export { createDbFinanceRepository } from './finance-repository' export { createDbHouseholdConfigurationRepository } from './household-config-repository' diff --git a/packages/adapters-db/src/telegram-pending-action-repository.ts b/packages/adapters-db/src/telegram-pending-action-repository.ts index 970e67e..e7599f4 100644 --- a/packages/adapters-db/src/telegram-pending-action-repository.ts +++ b/packages/adapters-db/src/telegram-pending-action-repository.ts @@ -9,6 +9,10 @@ import type { } from '@household/ports' function parsePendingActionType(raw: string): TelegramPendingActionType { + if (raw === 'ad_hoc_notification') { + return raw + } + if (raw === 'anonymous_feedback') { return raw } diff --git a/packages/application/src/ad-hoc-notification-service.test.ts b/packages/application/src/ad-hoc-notification-service.test.ts new file mode 100644 index 0000000..13d9832 --- /dev/null +++ b/packages/application/src/ad-hoc-notification-service.test.ts @@ -0,0 +1,268 @@ +import { describe, expect, test } from 'bun:test' + +import { Temporal } from '@household/domain' +import type { + AdHocNotificationRecord, + AdHocNotificationRepository, + CancelAdHocNotificationInput, + ClaimAdHocNotificationDeliveryResult, + CreateAdHocNotificationInput, + HouseholdConfigurationRepository, + HouseholdMemberRecord +} from '@household/ports' + +import { createAdHocNotificationService } from './ad-hoc-notification-service' + +class NotificationRepositoryStub implements AdHocNotificationRepository { + notifications = new Map() + nextId = 1 + + async createNotification(input: CreateAdHocNotificationInput): Promise { + const id = `notif-${this.nextId++}` + const record: AdHocNotificationRecord = { + id, + householdId: input.householdId, + creatorMemberId: input.creatorMemberId, + assigneeMemberId: input.assigneeMemberId ?? null, + originalRequestText: input.originalRequestText, + notificationText: input.notificationText, + timezone: input.timezone, + scheduledFor: input.scheduledFor, + timePrecision: input.timePrecision, + deliveryMode: input.deliveryMode, + dmRecipientMemberIds: input.dmRecipientMemberIds ?? [], + friendlyTagAssignee: input.friendlyTagAssignee, + status: 'scheduled', + sourceTelegramChatId: input.sourceTelegramChatId ?? null, + sourceTelegramThreadId: input.sourceTelegramThreadId ?? null, + sentAt: null, + cancelledAt: null, + cancelledByMemberId: null, + createdAt: Temporal.Instant.from('2026-03-23T09:00:00Z'), + updatedAt: Temporal.Instant.from('2026-03-23T09:00:00Z') + } + this.notifications.set(id, record) + return record + } + + async getNotificationById(notificationId: string): Promise { + return this.notifications.get(notificationId) ?? null + } + + async listUpcomingNotificationsForHousehold( + householdId: string, + asOf: Temporal.Instant + ): Promise { + return [...this.notifications.values()].filter( + (notification) => + notification.householdId === householdId && + notification.status === 'scheduled' && + notification.scheduledFor.epochMilliseconds > asOf.epochMilliseconds + ) + } + + async cancelNotification( + input: CancelAdHocNotificationInput + ): Promise { + const record = this.notifications.get(input.notificationId) + if (!record || record.status !== 'scheduled') { + return null + } + + const next = { + ...record, + status: 'cancelled' as const, + cancelledAt: input.cancelledAt, + cancelledByMemberId: input.cancelledByMemberId + } + this.notifications.set(input.notificationId, next) + return next + } + + async listDueNotifications(asOf: Temporal.Instant): Promise { + return [...this.notifications.values()].filter( + (notification) => + notification.status === 'scheduled' && + notification.scheduledFor.epochMilliseconds <= asOf.epochMilliseconds + ) + } + + async markNotificationSent( + notificationId: string, + sentAt: Temporal.Instant + ): Promise { + const record = this.notifications.get(notificationId) + if (!record || record.status !== 'scheduled') { + return null + } + + const next = { + ...record, + status: 'sent' as const, + sentAt + } + this.notifications.set(notificationId, next) + return next + } + + async claimNotificationDelivery( + notificationId: string + ): Promise { + return { + notificationId, + claimed: true + } + } + + async releaseNotificationDelivery(): Promise {} +} + +function member( + input: Partial & Pick +): HouseholdMemberRecord { + return { + id: input.id, + householdId: input.householdId ?? 'household-1', + telegramUserId: input.telegramUserId ?? `${input.id}-tg`, + displayName: input.displayName ?? input.id, + status: input.status ?? 'active', + preferredLocale: input.preferredLocale ?? 'ru', + householdDefaultLocale: input.householdDefaultLocale ?? 'ru', + rentShareWeight: input.rentShareWeight ?? 1, + isAdmin: input.isAdmin ?? false + } +} + +function createHouseholdRepository( + members: readonly HouseholdMemberRecord[] +): Pick { + return { + async getHouseholdMember(householdId, telegramUserId) { + return ( + members.find( + (member) => member.householdId === householdId && member.telegramUserId === telegramUserId + ) ?? null + ) + }, + async listHouseholdMembers(householdId) { + return members.filter((member) => member.householdId === householdId) + } + } +} + +describe('createAdHocNotificationService', () => { + test('defaults date-only reminder to scheduled notification with topic delivery', async () => { + const repository = new NotificationRepositoryStub() + const members = [member({ id: 'creator' }), member({ id: 'assignee', displayName: 'Georgiy' })] + const service = createAdHocNotificationService({ + repository, + householdConfigurationRepository: createHouseholdRepository(members) + }) + + const result = await service.scheduleNotification({ + householdId: 'household-1', + creatorMemberId: 'creator', + assigneeMemberId: 'assignee', + originalRequestText: 'Напомни Георгию завтра', + notificationText: 'пошпынять Георгия о том, позвонил ли он', + timezone: 'Asia/Tbilisi', + scheduledFor: Temporal.Instant.from('2026-03-24T08:00:00Z'), + timePrecision: 'date_only_defaulted', + deliveryMode: 'topic' + }) + + expect(result.status).toBe('scheduled') + if (result.status === 'scheduled') { + expect(result.notification.deliveryMode).toBe('topic') + expect(result.notification.assigneeMemberId).toBe('assignee') + } + }) + + test('expands dm_all to all active members', async () => { + const repository = new NotificationRepositoryStub() + const members = [ + member({ id: 'creator' }), + member({ id: 'alice' }), + member({ id: 'bob', status: 'away' }), + member({ id: 'carol' }) + ] + const service = createAdHocNotificationService({ + repository, + householdConfigurationRepository: createHouseholdRepository(members) + }) + + const result = await service.scheduleNotification({ + householdId: 'household-1', + creatorMemberId: 'creator', + originalRequestText: 'remind everyone tomorrow', + notificationText: 'pay rent', + timezone: 'Asia/Tbilisi', + scheduledFor: Temporal.Instant.from('2026-03-24T08:00:00Z'), + timePrecision: 'date_only_defaulted', + deliveryMode: 'dm_all' + }) + + expect(result.status).toBe('scheduled') + if (result.status === 'scheduled') { + expect(result.notification.dmRecipientMemberIds).toEqual(['creator', 'alice', 'carol']) + } + }) + + test('rejects friendly mode without assignee', async () => { + const repository = new NotificationRepositoryStub() + const service = createAdHocNotificationService({ + repository, + householdConfigurationRepository: createHouseholdRepository([member({ id: 'creator' })]) + }) + + const result = await service.scheduleNotification({ + householdId: 'household-1', + creatorMemberId: 'creator', + originalRequestText: 'remind tomorrow', + notificationText: 'check rent', + timezone: 'Asia/Tbilisi', + scheduledFor: Temporal.Instant.from('2026-03-24T08:00:00Z'), + timePrecision: 'date_only_defaulted', + deliveryMode: 'topic', + friendlyTagAssignee: true + }) + + expect(result).toEqual({ + status: 'invalid', + reason: 'friendly_assignee_missing' + }) + }) + + test('allows admin to cancel someone else notification', async () => { + const repository = new NotificationRepositoryStub() + const creator = member({ id: 'creator', telegramUserId: 'creator-tg' }) + const admin = member({ id: 'admin', telegramUserId: 'admin-tg', isAdmin: true }) + const service = createAdHocNotificationService({ + repository, + householdConfigurationRepository: createHouseholdRepository([creator, admin]) + }) + + const created = await repository.createNotification({ + householdId: 'household-1', + creatorMemberId: 'creator', + originalRequestText: 'remind tomorrow', + notificationText: 'call landlord', + timezone: 'Asia/Tbilisi', + scheduledFor: Temporal.Instant.from('2026-03-24T08:00:00Z'), + timePrecision: 'date_only_defaulted', + deliveryMode: 'topic', + friendlyTagAssignee: false + }) + + const result = await service.cancelNotification({ + notificationId: created.id, + viewerMemberId: 'admin', + asOf: Temporal.Instant.from('2026-03-23T09:00:00Z') + }) + + expect(result.status).toBe('cancelled') + if (result.status === 'cancelled') { + expect(result.notification.cancelledByMemberId).toBe('admin') + } + }) +}) diff --git a/packages/application/src/ad-hoc-notification-service.ts b/packages/application/src/ad-hoc-notification-service.ts new file mode 100644 index 0000000..42d6e27 --- /dev/null +++ b/packages/application/src/ad-hoc-notification-service.ts @@ -0,0 +1,377 @@ +import { nowInstant, type Instant } from '@household/domain' +import type { + AdHocNotificationDeliveryMode, + AdHocNotificationRecord, + AdHocNotificationRepository, + AdHocNotificationTimePrecision, + HouseholdConfigurationRepository, + HouseholdMemberRecord +} from '@household/ports' + +interface NotificationActor { + memberId: string + householdId: string + isAdmin: boolean +} + +export interface AdHocNotificationMemberSummary { + memberId: string + telegramUserId: string + displayName: string +} + +export interface AdHocNotificationSummary { + id: string + notificationText: string + scheduledFor: Instant + deliveryMode: AdHocNotificationDeliveryMode + friendlyTagAssignee: boolean + creatorDisplayName: string + assigneeDisplayName: string | null + canCancel: boolean +} + +export interface DeliverableAdHocNotification { + notification: AdHocNotificationRecord + creator: AdHocNotificationMemberSummary + assignee: AdHocNotificationMemberSummary | null + dmRecipients: readonly AdHocNotificationMemberSummary[] +} + +export type ScheduleAdHocNotificationResult = + | { + status: 'scheduled' + notification: AdHocNotificationRecord + } + | { + status: 'invalid' + reason: + | 'creator_not_found' + | 'assignee_not_found' + | 'dm_recipients_missing' + | 'delivery_mode_invalid' + | 'friendly_assignee_missing' + | 'scheduled_for_past' + } + +export type CancelAdHocNotificationResult = + | { + status: 'cancelled' + notification: AdHocNotificationRecord + } + | { + status: 'not_found' | 'forbidden' | 'already_handled' | 'past_due' + } + +export interface AdHocNotificationService { + scheduleNotification(input: { + householdId: string + creatorMemberId: string + originalRequestText: string + notificationText: string + timezone: string + scheduledFor: Instant + timePrecision: AdHocNotificationTimePrecision + deliveryMode: AdHocNotificationDeliveryMode + assigneeMemberId?: string | null + dmRecipientMemberIds?: readonly string[] + friendlyTagAssignee?: boolean + sourceTelegramChatId?: string | null + sourceTelegramThreadId?: string | null + }): Promise + listUpcomingNotifications(input: { + householdId: string + viewerMemberId: string + asOf?: Instant + }): Promise + cancelNotification(input: { + notificationId: string + viewerMemberId: string + asOf?: Instant + }): Promise + listDueNotifications(asOf?: Instant): Promise + claimDueNotification(notificationId: string): Promise + releaseDueNotification(notificationId: string): Promise + markNotificationSent( + notificationId: string, + sentAt?: Instant + ): Promise +} + +function summarizeMember(member: HouseholdMemberRecord): AdHocNotificationMemberSummary { + return { + memberId: member.id, + telegramUserId: member.telegramUserId, + displayName: member.displayName + } +} + +function isActiveMember(member: HouseholdMemberRecord): boolean { + return member.status === 'active' +} + +async function listMemberMap( + repository: Pick, + householdId: string +): Promise> { + const members = await repository.listHouseholdMembers(householdId) + return new Map(members.map((member) => [member.id, member])) +} + +function canCancelNotification( + notification: AdHocNotificationRecord, + actor: NotificationActor +): boolean { + return actor.isAdmin || notification.creatorMemberId === actor.memberId +} + +export function createAdHocNotificationService(input: { + repository: AdHocNotificationRepository + householdConfigurationRepository: Pick< + HouseholdConfigurationRepository, + 'getHouseholdMember' | 'listHouseholdMembers' + > +}): AdHocNotificationService { + async function resolveActor( + householdId: string, + memberId: string + ): Promise { + const members = await input.householdConfigurationRepository.listHouseholdMembers(householdId) + const member = members.find((entry) => entry.id === memberId) + if (!member) { + return null + } + + return { + memberId: member.id, + householdId: member.householdId, + isAdmin: member.isAdmin + } + } + + return { + async scheduleNotification(notificationInput) { + const memberMap = await listMemberMap( + input.householdConfigurationRepository, + notificationInput.householdId + ) + const creator = memberMap.get(notificationInput.creatorMemberId) + if (!creator) { + return { + status: 'invalid', + reason: 'creator_not_found' + } + } + + const assignee = notificationInput.assigneeMemberId + ? memberMap.get(notificationInput.assigneeMemberId) + : null + if (notificationInput.assigneeMemberId && !assignee) { + return { + status: 'invalid', + reason: 'assignee_not_found' + } + } + + const effectiveNow = nowInstant() + if (notificationInput.scheduledFor.epochMilliseconds <= effectiveNow.epochMilliseconds) { + return { + status: 'invalid', + reason: 'scheduled_for_past' + } + } + + const friendlyTagAssignee = notificationInput.friendlyTagAssignee === true + if (friendlyTagAssignee && !assignee) { + return { + status: 'invalid', + reason: 'friendly_assignee_missing' + } + } + + let dmRecipientMemberIds: readonly string[] = [] + switch (notificationInput.deliveryMode) { + case 'topic': + dmRecipientMemberIds = [] + break + case 'dm_all': + dmRecipientMemberIds = [...memberMap.values()] + .filter(isActiveMember) + .map((member) => member.id) + break + case 'dm_selected': { + const selected = (notificationInput.dmRecipientMemberIds ?? []) + .map((memberId) => memberMap.get(memberId)) + .filter((member): member is HouseholdMemberRecord => Boolean(member)) + .filter(isActiveMember) + + if (selected.length === 0) { + return { + status: 'invalid', + reason: 'dm_recipients_missing' + } + } + + dmRecipientMemberIds = selected.map((member) => member.id) + break + } + default: + return { + status: 'invalid', + reason: 'delivery_mode_invalid' + } + } + + const notification = await input.repository.createNotification({ + householdId: notificationInput.householdId, + creatorMemberId: notificationInput.creatorMemberId, + assigneeMemberId: assignee?.id ?? null, + originalRequestText: notificationInput.originalRequestText.trim(), + notificationText: notificationInput.notificationText.trim(), + timezone: notificationInput.timezone, + scheduledFor: notificationInput.scheduledFor, + timePrecision: notificationInput.timePrecision, + deliveryMode: notificationInput.deliveryMode, + dmRecipientMemberIds, + friendlyTagAssignee, + sourceTelegramChatId: notificationInput.sourceTelegramChatId ?? null, + sourceTelegramThreadId: notificationInput.sourceTelegramThreadId ?? null + }) + + return { + status: 'scheduled', + notification + } + }, + + async listUpcomingNotifications({ householdId, viewerMemberId, asOf = nowInstant() }) { + const actor = await resolveActor(householdId, viewerMemberId) + if (!actor) { + return [] + } + + const memberMap = await listMemberMap(input.householdConfigurationRepository, householdId) + const notifications = await input.repository.listUpcomingNotificationsForHousehold( + householdId, + asOf + ) + + return notifications + .filter((notification) => actor.isAdmin || notification.creatorMemberId === actor.memberId) + .map((notification) => ({ + id: notification.id, + notificationText: notification.notificationText, + scheduledFor: notification.scheduledFor, + deliveryMode: notification.deliveryMode, + friendlyTagAssignee: notification.friendlyTagAssignee, + creatorDisplayName: + memberMap.get(notification.creatorMemberId)?.displayName ?? + notification.creatorMemberId, + assigneeDisplayName: notification.assigneeMemberId + ? (memberMap.get(notification.assigneeMemberId)?.displayName ?? + notification.assigneeMemberId) + : null, + canCancel: canCancelNotification(notification, actor) + })) + }, + + async cancelNotification({ notificationId, viewerMemberId, asOf = nowInstant() }) { + const notification = await input.repository.getNotificationById(notificationId) + if (!notification) { + return { + status: 'not_found' + } + } + + if (notification.status !== 'scheduled') { + return { + status: 'already_handled' + } + } + + if (notification.scheduledFor.epochMilliseconds <= asOf.epochMilliseconds) { + return { + status: 'past_due' + } + } + + const actor = await resolveActor(notification.householdId, viewerMemberId) + if (!actor || !canCancelNotification(notification, actor)) { + return { + status: 'forbidden' + } + } + + const cancelled = await input.repository.cancelNotification({ + notificationId, + cancelledByMemberId: actor.memberId, + cancelledAt: asOf + }) + + if (!cancelled) { + return { + status: 'already_handled' + } + } + + return { + status: 'cancelled', + notification: cancelled + } + }, + + async listDueNotifications(asOf = nowInstant()) { + const due = await input.repository.listDueNotifications(asOf) + const groupedMembers = new Map>() + + async function membersForHousehold(householdId: string) { + const existing = groupedMembers.get(householdId) + if (existing) { + return existing + } + + const memberMap = await listMemberMap(input.householdConfigurationRepository, householdId) + groupedMembers.set(householdId, memberMap) + return memberMap + } + + const results: DeliverableAdHocNotification[] = [] + for (const notification of due) { + const memberMap = await membersForHousehold(notification.householdId) + const creator = memberMap.get(notification.creatorMemberId) + if (!creator) { + continue + } + + const assignee = notification.assigneeMemberId + ? (memberMap.get(notification.assigneeMemberId) ?? null) + : null + const dmRecipients = notification.dmRecipientMemberIds + .map((memberId) => memberMap.get(memberId)) + .filter((member): member is HouseholdMemberRecord => Boolean(member)) + + results.push({ + notification, + creator: summarizeMember(creator), + assignee: assignee ? summarizeMember(assignee) : null, + dmRecipients: dmRecipients.map(summarizeMember) + }) + } + + return results + }, + + async claimDueNotification(notificationId) { + const result = await input.repository.claimNotificationDelivery(notificationId) + return result.claimed + }, + + releaseDueNotification(notificationId) { + return input.repository.releaseNotificationDelivery(notificationId) + }, + + markNotificationSent(notificationId, sentAt = nowInstant()) { + return input.repository.markNotificationSent(notificationId, sentAt) + } + } +} diff --git a/packages/application/src/index.ts b/packages/application/src/index.ts index 87ae431..068f402 100644 --- a/packages/application/src/index.ts +++ b/packages/application/src/index.ts @@ -1,4 +1,13 @@ export { calculateMonthlySettlement } from './settlement-engine' +export { + createAdHocNotificationService, + type AdHocNotificationMemberSummary, + type AdHocNotificationService, + type AdHocNotificationSummary, + type CancelAdHocNotificationResult, + type DeliverableAdHocNotification, + type ScheduleAdHocNotificationResult +} from './ad-hoc-notification-service' export { createAnonymousFeedbackService, type AnonymousFeedbackService, diff --git a/packages/db/drizzle/0023_huge_vision.sql b/packages/db/drizzle/0023_huge_vision.sql new file mode 100644 index 0000000..1ae844a --- /dev/null +++ b/packages/db/drizzle/0023_huge_vision.sql @@ -0,0 +1,50 @@ +CREATE TABLE "ad_hoc_notifications" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "household_id" uuid NOT NULL, + "creator_member_id" uuid NOT NULL, + "assignee_member_id" uuid, + "original_request_text" text NOT NULL, + "notification_text" text NOT NULL, + "timezone" text NOT NULL, + "scheduled_for" timestamp with time zone NOT NULL, + "time_precision" text NOT NULL, + "delivery_mode" text NOT NULL, + "dm_recipient_member_ids" jsonb DEFAULT '[]'::jsonb NOT NULL, + "friendly_tag_assignee" integer DEFAULT 0 NOT NULL, + "status" text DEFAULT 'scheduled' NOT NULL, + "source_telegram_chat_id" text, + "source_telegram_thread_id" text, + "sent_at" timestamp with time zone, + "cancelled_at" timestamp with time zone, + "cancelled_by_member_id" uuid, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "payment_purchase_allocations" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "payment_record_id" uuid NOT NULL, + "purchase_id" uuid NOT NULL, + "member_id" uuid NOT NULL, + "amount_minor" bigint NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "purchase_messages" ADD COLUMN "cycle_id" uuid;--> statement-breakpoint +ALTER TABLE "purchase_messages" ADD COLUMN "payer_member_id" uuid;--> statement-breakpoint +ALTER TABLE "ad_hoc_notifications" ADD CONSTRAINT "ad_hoc_notifications_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "ad_hoc_notifications" ADD CONSTRAINT "ad_hoc_notifications_creator_member_id_members_id_fk" FOREIGN KEY ("creator_member_id") REFERENCES "public"."members"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "ad_hoc_notifications" ADD CONSTRAINT "ad_hoc_notifications_assignee_member_id_members_id_fk" FOREIGN KEY ("assignee_member_id") REFERENCES "public"."members"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "ad_hoc_notifications" ADD CONSTRAINT "ad_hoc_notifications_cancelled_by_member_id_members_id_fk" FOREIGN KEY ("cancelled_by_member_id") REFERENCES "public"."members"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "payment_purchase_allocations" ADD CONSTRAINT "payment_purchase_allocations_payment_record_id_payment_records_id_fk" FOREIGN KEY ("payment_record_id") REFERENCES "public"."payment_records"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "payment_purchase_allocations" ADD CONSTRAINT "payment_purchase_allocations_purchase_id_purchase_messages_id_fk" FOREIGN KEY ("purchase_id") REFERENCES "public"."purchase_messages"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "payment_purchase_allocations" ADD CONSTRAINT "payment_purchase_allocations_member_id_members_id_fk" FOREIGN KEY ("member_id") REFERENCES "public"."members"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "ad_hoc_notifications_due_idx" ON "ad_hoc_notifications" USING btree ("status","scheduled_for");--> statement-breakpoint +CREATE INDEX "ad_hoc_notifications_household_status_idx" ON "ad_hoc_notifications" USING btree ("household_id","status","scheduled_for");--> statement-breakpoint +CREATE INDEX "ad_hoc_notifications_creator_idx" ON "ad_hoc_notifications" USING btree ("creator_member_id");--> statement-breakpoint +CREATE INDEX "ad_hoc_notifications_assignee_idx" ON "ad_hoc_notifications" USING btree ("assignee_member_id");--> statement-breakpoint +CREATE INDEX "payment_purchase_allocations_payment_idx" ON "payment_purchase_allocations" USING btree ("payment_record_id");--> statement-breakpoint +CREATE INDEX "payment_purchase_allocations_purchase_member_idx" ON "payment_purchase_allocations" USING btree ("purchase_id","member_id");--> statement-breakpoint +ALTER TABLE "purchase_messages" ADD CONSTRAINT "purchase_messages_cycle_id_billing_cycles_id_fk" FOREIGN KEY ("cycle_id") REFERENCES "public"."billing_cycles"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "purchase_messages" ADD CONSTRAINT "purchase_messages_payer_member_id_members_id_fk" FOREIGN KEY ("payer_member_id") REFERENCES "public"."members"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "purchase_messages_cycle_idx" ON "purchase_messages" USING btree ("cycle_id"); \ No newline at end of file diff --git a/packages/db/drizzle/meta/0023_snapshot.json b/packages/db/drizzle/meta/0023_snapshot.json new file mode 100644 index 0000000..3332e66 --- /dev/null +++ b/packages/db/drizzle/meta/0023_snapshot.json @@ -0,0 +1,3863 @@ +{ + "id": "fbb59211-0980-4bdc-bc56-29d9f74ae1d4", + "prevId": "dda6c989-cce9-4dbc-91b4-5cd5cd2dd8f1", + "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.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 a1d0ab6..1b47e96 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -162,6 +162,13 @@ "when": 1774205000000, "tag": "0022_carry_purchase_history", "breakpoints": true + }, + { + "idx": 23, + "version": "7", + "when": 1774294611532, + "tag": "0023_huge_vision", + "breakpoints": true } ] } diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index a148bbe..005267e 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -507,6 +507,52 @@ export const processedBotMessages = pgTable( }) ) +export const adHocNotifications = pgTable( + 'ad_hoc_notifications', + { + id: uuid('id').defaultRandom().primaryKey(), + householdId: uuid('household_id') + .notNull() + .references(() => households.id, { onDelete: 'cascade' }), + creatorMemberId: uuid('creator_member_id') + .notNull() + .references(() => members.id, { onDelete: 'restrict' }), + assigneeMemberId: uuid('assignee_member_id').references(() => members.id, { + onDelete: 'set null' + }), + originalRequestText: text('original_request_text').notNull(), + notificationText: text('notification_text').notNull(), + timezone: text('timezone').notNull(), + scheduledFor: timestamp('scheduled_for', { withTimezone: true }).notNull(), + timePrecision: text('time_precision').notNull(), + deliveryMode: text('delivery_mode').notNull(), + dmRecipientMemberIds: jsonb('dm_recipient_member_ids') + .default(sql`'[]'::jsonb`) + .notNull(), + friendlyTagAssignee: integer('friendly_tag_assignee').default(0).notNull(), + status: text('status').default('scheduled').notNull(), + sourceTelegramChatId: text('source_telegram_chat_id'), + sourceTelegramThreadId: text('source_telegram_thread_id'), + sentAt: timestamp('sent_at', { withTimezone: true }), + cancelledAt: timestamp('cancelled_at', { withTimezone: true }), + cancelledByMemberId: uuid('cancelled_by_member_id').references(() => members.id, { + onDelete: 'set null' + }), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull() + }, + (table) => ({ + dueIdx: index('ad_hoc_notifications_due_idx').on(table.status, table.scheduledFor), + householdStatusIdx: index('ad_hoc_notifications_household_status_idx').on( + table.householdId, + table.status, + table.scheduledFor + ), + creatorIdx: index('ad_hoc_notifications_creator_idx').on(table.creatorMemberId), + assigneeIdx: index('ad_hoc_notifications_assignee_idx').on(table.assigneeMemberId) + }) +) + export const topicMessages = pgTable( 'topic_messages', { diff --git a/packages/ports/src/index.ts b/packages/ports/src/index.ts index ca9be8e..915d49d 100644 --- a/packages/ports/src/index.ts +++ b/packages/ports/src/index.ts @@ -6,6 +6,19 @@ export { type ReminderTarget, type ReminderType } from './reminders' +export { + AD_HOC_NOTIFICATION_DELIVERY_MODES, + AD_HOC_NOTIFICATION_STATUSES, + AD_HOC_NOTIFICATION_TIME_PRECISIONS, + type AdHocNotificationDeliveryMode, + type AdHocNotificationRecord, + type AdHocNotificationRepository, + type AdHocNotificationStatus, + type AdHocNotificationTimePrecision, + type CancelAdHocNotificationInput, + type ClaimAdHocNotificationDeliveryResult, + type CreateAdHocNotificationInput +} from './notifications' export type { ClaimProcessedBotMessageInput, ClaimProcessedBotMessageResult, diff --git a/packages/ports/src/notifications.ts b/packages/ports/src/notifications.ts new file mode 100644 index 0000000..712984d --- /dev/null +++ b/packages/ports/src/notifications.ts @@ -0,0 +1,76 @@ +import type { Instant } from '@household/domain' + +export const AD_HOC_NOTIFICATION_TIME_PRECISIONS = ['exact', 'date_only_defaulted'] as const +export const AD_HOC_NOTIFICATION_DELIVERY_MODES = ['topic', 'dm_all', 'dm_selected'] as const +export const AD_HOC_NOTIFICATION_STATUSES = ['scheduled', 'sent', 'cancelled'] as const + +export type AdHocNotificationTimePrecision = (typeof AD_HOC_NOTIFICATION_TIME_PRECISIONS)[number] +export type AdHocNotificationDeliveryMode = (typeof AD_HOC_NOTIFICATION_DELIVERY_MODES)[number] +export type AdHocNotificationStatus = (typeof AD_HOC_NOTIFICATION_STATUSES)[number] + +export interface AdHocNotificationRecord { + id: string + householdId: string + creatorMemberId: string + assigneeMemberId: string | null + originalRequestText: string + notificationText: string + timezone: string + scheduledFor: Instant + timePrecision: AdHocNotificationTimePrecision + deliveryMode: AdHocNotificationDeliveryMode + dmRecipientMemberIds: readonly string[] + friendlyTagAssignee: boolean + status: AdHocNotificationStatus + sourceTelegramChatId: string | null + sourceTelegramThreadId: string | null + sentAt: Instant | null + cancelledAt: Instant | null + cancelledByMemberId: string | null + createdAt: Instant + updatedAt: Instant +} + +export interface CreateAdHocNotificationInput { + householdId: string + creatorMemberId: string + assigneeMemberId?: string | null + originalRequestText: string + notificationText: string + timezone: string + scheduledFor: Instant + timePrecision: AdHocNotificationTimePrecision + deliveryMode: AdHocNotificationDeliveryMode + dmRecipientMemberIds?: readonly string[] + friendlyTagAssignee: boolean + sourceTelegramChatId?: string | null + sourceTelegramThreadId?: string | null +} + +export interface CancelAdHocNotificationInput { + notificationId: string + cancelledByMemberId: string + cancelledAt: Instant +} + +export interface ClaimAdHocNotificationDeliveryResult { + notificationId: string + claimed: boolean +} + +export interface AdHocNotificationRepository { + createNotification(input: CreateAdHocNotificationInput): Promise + getNotificationById(notificationId: string): Promise + listUpcomingNotificationsForHousehold( + householdId: string, + asOf: Instant + ): Promise + cancelNotification(input: CancelAdHocNotificationInput): Promise + listDueNotifications(asOf: Instant): Promise + markNotificationSent( + notificationId: string, + sentAt: Instant + ): Promise + claimNotificationDelivery(notificationId: string): Promise + releaseNotificationDelivery(notificationId: string): Promise +} diff --git a/packages/ports/src/telegram-pending-actions.ts b/packages/ports/src/telegram-pending-actions.ts index 5bd8571..3793985 100644 --- a/packages/ports/src/telegram-pending-actions.ts +++ b/packages/ports/src/telegram-pending-actions.ts @@ -1,6 +1,7 @@ import type { Instant } from '@household/domain' export const TELEGRAM_PENDING_ACTION_TYPES = [ + 'ad_hoc_notification', 'anonymous_feedback', 'assistant_payment_confirmation', 'household_group_invite',