diff --git a/.github/workflows/cd-aws.yml b/.github/workflows/cd-aws.yml index 88bef80..1271df2 100644 --- a/.github/workflows/cd-aws.yml +++ b/.github/workflows/cd-aws.yml @@ -1,23 +1,23 @@ name: CD / AWS -on: - workflow_run: - workflows: - - CI - types: - - completed - branches: - - main - workflow_dispatch: - inputs: - stack: - description: 'Pulumi stack' - required: true - default: 'dev' - ref: - description: 'Git ref to deploy (branch, tag, or SHA)' - required: true - default: 'main' +# on: +# workflow_run: +# workflows: +# - CI +# types: +# - completed +# branches: +# - main +# workflow_dispatch: +# inputs: +# stack: +# description: 'Pulumi stack' +# required: true +# default: 'dev' +# ref: +# description: 'Git ref to deploy (branch, tag, or SHA)' +# required: true +# default: 'main' permissions: contents: read diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 3d0ffac..8a3357f 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -1,4 +1,4 @@ -name: CD +name: CD / GCP on: workflow_run: 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/miniapp-dashboard.ts b/apps/bot/src/miniapp-dashboard.ts index 84890d5..6839438 100644 --- a/apps/bot/src/miniapp-dashboard.ts +++ b/apps/bot/src/miniapp-dashboard.ts @@ -124,6 +124,27 @@ export function createMiniAppDashboardHandler(options: { })), explanations: line.explanations })), + paymentPeriods: (dashboard.paymentPeriods ?? []).map((period) => ({ + period: period.period, + utilityTotalMajor: period.utilityTotal.toMajorString(), + hasOverdueBalance: period.hasOverdueBalance, + isCurrentPeriod: period.isCurrentPeriod, + kinds: period.kinds.map((kind) => ({ + kind: kind.kind, + totalDueMajor: kind.totalDue.toMajorString(), + totalPaidMajor: kind.totalPaid.toMajorString(), + totalRemainingMajor: kind.totalRemaining.toMajorString(), + unresolvedMembers: kind.unresolvedMembers.map((member) => ({ + memberId: member.memberId, + displayName: member.displayName, + suggestedAmountMajor: member.suggestedAmount.toMajorString(), + baseDueMajor: member.baseDue.toMajorString(), + paidMajor: member.paid.toMajorString(), + remainingMajor: member.remaining.toMajorString(), + effectivelySettled: member.effectivelySettled + })) + })) + })), ledger: dashboard.ledger.map((entry) => ({ id: entry.id, kind: entry.kind, 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/apps/miniapp/src/components/layout/shell.tsx b/apps/miniapp/src/components/layout/shell.tsx index c2f5a54..5675a9f 100644 --- a/apps/miniapp/src/components/layout/shell.tsx +++ b/apps/miniapp/src/components/layout/shell.tsx @@ -5,6 +5,7 @@ import { Settings } from 'lucide-solid' import { useSession } from '../../contexts/session-context' import { useI18n } from '../../contexts/i18n-context' import { useDashboard } from '../../contexts/dashboard-context' +import { formatCyclePeriod } from '../../lib/dates' import { NavigationTabs } from './navigation-tabs' import { Badge } from '../ui/badge' import { Button, IconButton } from '../ui/button' @@ -234,7 +235,9 @@ export function AppShell(props: ParentProps) {
{copy().testingPeriodCurrentLabel ?? ''} - {dashboard()?.period ?? '—'} + + {dashboard()?.period ? formatCyclePeriod(dashboard()!.period, locale()) : '—'} +
diff --git a/apps/miniapp/src/demo/miniapp-demo.ts b/apps/miniapp/src/demo/miniapp-demo.ts index 26acafb..2571336 100644 --- a/apps/miniapp/src/demo/miniapp-demo.ts +++ b/apps/miniapp/src/demo/miniapp-demo.ts @@ -377,6 +377,59 @@ function createDashboard(state: { members: MiniAppDashboard['members'] ledger?: MiniAppDashboard['ledger'] }): MiniAppDashboard { + const paymentPeriods: MiniAppDashboard['paymentPeriods'] = [ + { + period: '2026-03', + utilityTotalMajor: '286.00', + hasOverdueBalance: state.members.some((member) => member.overduePayments.length > 0), + isCurrentPeriod: true, + kinds: [ + { + kind: 'rent', + totalDueMajor: state.members + .reduce((sum, member) => sum + Number(member.rentShareMajor), 0) + .toFixed(2), + totalPaidMajor: '0.00', + totalRemainingMajor: state.members + .reduce((sum, member) => sum + Number(member.rentShareMajor), 0) + .toFixed(2), + unresolvedMembers: state.members + .filter((member) => Number(member.rentShareMajor) > 0) + .map((member) => ({ + memberId: member.memberId, + displayName: member.displayName, + suggestedAmountMajor: member.rentShareMajor, + baseDueMajor: member.rentShareMajor, + paidMajor: '0.00', + remainingMajor: member.rentShareMajor, + effectivelySettled: false + })) + }, + { + kind: 'utilities', + totalDueMajor: state.members + .reduce((sum, member) => sum + Number(member.utilityShareMajor), 0) + .toFixed(2), + totalPaidMajor: '0.00', + totalRemainingMajor: state.members + .reduce((sum, member) => sum + Number(member.utilityShareMajor), 0) + .toFixed(2), + unresolvedMembers: state.members + .filter((member) => Number(member.utilityShareMajor) > 0) + .map((member) => ({ + memberId: member.memberId, + displayName: member.displayName, + suggestedAmountMajor: member.utilityShareMajor, + baseDueMajor: member.utilityShareMajor, + paidMajor: '0.00', + remainingMajor: member.utilityShareMajor, + effectivelySettled: false + })) + } + ] + } + ] + return { period: '2026-03', currency: 'GEL', @@ -396,6 +449,7 @@ function createDashboard(state: { rentFxRateMicros: '2760000', rentFxEffectiveDate: '2026-03-17', members: state.members, + paymentPeriods, ledger: state.ledger ?? baseLedger() } } diff --git a/apps/miniapp/src/i18n.ts b/apps/miniapp/src/i18n.ts index 46d60c5..64764ad 100644 --- a/apps/miniapp/src/i18n.ts +++ b/apps/miniapp/src/i18n.ts @@ -133,6 +133,7 @@ export const dictionary = { purchasesTitle: 'Shared purchases', purchasesEmpty: 'No shared purchases recorded for this cycle yet.', utilityLedgerTitle: 'Utility bills', + utilityHistoryTitle: 'Utilities by period', utilityLedgerEmpty: 'No utility bills recorded for this cycle yet.', paymentsTitle: 'Payments', paymentsEmpty: 'No payment confirmations recorded for this cycle yet.', @@ -193,8 +194,17 @@ export const dictionary = { purchaseEditorBody: 'Review the purchase details and adjust the split only when needed.', purchasePayerLabel: 'Paid by', paymentsAdminTitle: 'Payments', - paymentsAdminBody: 'Add, fix, or remove payment records for the current cycle.', + paymentsAdminBody: + 'Resolve open rent and utility obligations period by period, or add a custom payment when needed.', paymentsAddAction: 'Add payment', + paymentsResolveAction: 'Resolve', + paymentsCustomAmountAction: 'Custom amount', + paymentsHistoryTitle: 'Payment history', + paymentsPeriodTitle: 'Period {period}', + paymentsPeriodCurrentBody: 'Current payment obligations for this billing period.', + paymentsPeriodOverdueBody: 'This period still has overdue base rent or utility payments.', + paymentsPeriodHistoryBody: 'Review and resolve older payment periods from here.', + paymentsBaseDueLabel: 'Base due {amount} · Remaining {remaining}', copiedToast: 'Copied!', quickPaymentTitle: 'Record payment', quickPaymentBody: 'Quickly record a {type} payment for the current cycle.', @@ -513,6 +523,7 @@ export const dictionary = { purchasesTitle: 'Общие покупки', purchasesEmpty: 'Пока нет общих покупок в этом цикле.', utilityLedgerTitle: 'Коммунальные платежи', + utilityHistoryTitle: 'Коммуналка по периодам', utilityLedgerEmpty: 'Пока нет коммунальных платежей в этом цикле.', paymentsTitle: 'Оплаты', paymentsEmpty: 'В этом цикле пока нет подтверждённых оплат.', @@ -575,8 +586,17 @@ export const dictionary = { 'Проверь покупку и меняй детали разделения только если это действительно нужно.', purchasePayerLabel: 'Оплатил', paymentsAdminTitle: 'Оплаты', - paymentsAdminBody: 'Добавляй, исправляй или удаляй оплаты за текущий цикл.', + paymentsAdminBody: + 'Закрывай открытые платежи по аренде и коммуналке по периодам или добавляй оплату с произвольной суммой.', paymentsAddAction: 'Добавить оплату', + paymentsResolveAction: 'Закрыть', + paymentsCustomAmountAction: 'Своя сумма', + paymentsHistoryTitle: 'История оплат', + paymentsPeriodTitle: 'Период {period}', + paymentsPeriodCurrentBody: 'Текущие обязательства по оплатам за этот биллинговый период.', + paymentsPeriodOverdueBody: 'В этом периоде остались просроченные базовые оплаты.', + paymentsPeriodHistoryBody: 'Здесь можно быстро проверить и закрыть старые периоды.', + paymentsBaseDueLabel: 'База {amount} · Осталось {remaining}', copiedToast: 'Скопировано!', quickPaymentTitle: 'Записать оплату', quickPaymentBody: 'Быстро запиши оплату {type} за текущий цикл.', diff --git a/apps/miniapp/src/index.css b/apps/miniapp/src/index.css index 4b83b10..c6488d4 100644 --- a/apps/miniapp/src/index.css +++ b/apps/miniapp/src/index.css @@ -589,11 +589,13 @@ a { .ui-badge { display: inline-flex; align-items: center; + justify-content: center; padding: 2px 8px; border-radius: var(--radius-full); font-size: var(--text-xs); font-weight: 600; letter-spacing: 0.02em; + white-space: nowrap; background: var(--accent-soft); color: var(--accent); border: none; @@ -739,7 +741,8 @@ a { transition: transform var(--transition-base); } -.ui-collapsible[data-expanded] .ui-collapsible__chevron { +.ui-collapsible__trigger[data-expanded] .ui-collapsible__chevron, +.ui-collapsible__trigger[aria-expanded='true'] .ui-collapsible__chevron { transform: rotate(180deg); } @@ -1532,6 +1535,15 @@ a { flex-direction: column; } +.editable-list-section-title { + font-size: var(--text-xs); + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--text-secondary); + margin-bottom: var(--spacing-sm); +} + .editable-list-row { display: flex; justify-content: space-between; @@ -1554,6 +1566,15 @@ a { background: var(--bg-input); } +.editable-list-row--static { + cursor: default; +} + +.editable-list-row--stacked { + align-items: flex-start; + gap: var(--spacing-sm); +} + .editable-list-row:disabled { cursor: default; } @@ -1591,6 +1612,13 @@ a { font-variant-numeric: tabular-nums; } +.editable-list-inline-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: var(--spacing-xs); +} + .editable-list-row__secondary { font-size: var(--text-xs); color: var(--text-muted); diff --git a/apps/miniapp/src/lib/ledger-helpers.ts b/apps/miniapp/src/lib/ledger-helpers.ts index e8d81c8..89cef5a 100644 --- a/apps/miniapp/src/lib/ledger-helpers.ts +++ b/apps/miniapp/src/lib/ledger-helpers.ts @@ -99,6 +99,25 @@ export function ledgerSecondaryAmount(entry: MiniAppDashboard['ledger'][number]) return `${entry.amountMajor} ${entry.currency}` } +export function localizedCurrencyLabel( + locale: 'en' | 'ru', + currency: MiniAppDashboard['currency'] +): string { + if (locale === 'ru' && currency === 'GEL') { + return 'Лари' + } + + return currency +} + +export function formatMoneyLabel( + amountMajor: string, + currency: MiniAppDashboard['currency'], + locale: 'en' | 'ru' +): string { + return `${amountMajor} ${localizedCurrencyLabel(locale, currency)}` +} + export function cycleUtilityBillDrafts( bills: MiniAppAdminCycleState['utilityBills'] ): Record { @@ -407,29 +426,30 @@ export function resolvedMemberAbsencePolicy( * Bug #5 fix: Prefill with the remaining amount for the selected payment kind. */ export function computePaymentPrefill( - member: MiniAppDashboard['members'][number] | null | undefined, - kind: 'rent' | 'utilities' + dashboard: MiniAppDashboard | null | undefined, + memberId: string, + kind: 'rent' | 'utilities', + period: string ): string { - if (!member) { + if (!dashboard) { return '' } - const rentMinor = majorStringToMinor(member.rentShareMajor) - const utilityMinor = majorStringToMinor(member.utilityShareMajor) - const remainingMinor = majorStringToMinor(member.remainingMajor) - - if (remainingMinor <= 0n) { + const periodSummary = (dashboard.paymentPeriods ?? []).find((entry) => entry.period === period) + const kindSummary = periodSummary?.kinds.find((entry) => entry.kind === kind) + const memberSummary = kindSummary?.unresolvedMembers.find((entry) => entry.memberId === memberId) + if (!memberSummary) { return '0.00' } - // Estimate unpaid per kind (simplified: if total due matches, - // use share for that kind as an approximation) - const dueMinor = kind === 'rent' ? rentMinor : utilityMinor - if (dueMinor <= 0n) { - return '0.00' + let prefillMinor = majorStringToMinor(memberSummary.remainingMajor) + if (periodSummary?.isCurrentPeriod && dashboard.paymentBalanceAdjustmentPolicy === kind) { + const member = dashboard.members.find((entry) => entry.memberId === memberId) + const purchaseOffsetMinor = majorStringToMinor(member?.purchaseOffsetMajor ?? '0.00') + if (purchaseOffsetMinor > 0n) { + prefillMinor += purchaseOffsetMinor + } } - // If remaining is less than due for this kind, use remaining - const prefillMinor = remainingMinor < dueMinor ? remainingMinor : dueMinor - return minorToMajorString(prefillMinor) + return minorToMajorString(prefillMinor > 0n ? prefillMinor : 0n) } diff --git a/apps/miniapp/src/miniapp-api.ts b/apps/miniapp/src/miniapp-api.ts index 368f8ba..03650c0 100644 --- a/apps/miniapp/src/miniapp-api.ts +++ b/apps/miniapp/src/miniapp-api.ts @@ -137,6 +137,27 @@ export interface MiniAppDashboard { }[] explanations: readonly string[] }[] + paymentPeriods?: { + period: string + utilityTotalMajor: string + hasOverdueBalance: boolean + isCurrentPeriod: boolean + kinds: { + kind: 'rent' | 'utilities' + totalDueMajor: string + totalPaidMajor: string + totalRemainingMajor: string + unresolvedMembers: { + memberId: string + displayName: string + suggestedAmountMajor: string + baseDueMajor: string + paidMajor: string + remainingMajor: string + effectivelySettled: boolean + }[] + }[] + }[] ledger: { id: string kind: 'purchase' | 'utility' | 'payment' diff --git a/apps/miniapp/src/routes/home.tsx b/apps/miniapp/src/routes/home.tsx index f99acbb..ad879c2 100644 --- a/apps/miniapp/src/routes/home.tsx +++ b/apps/miniapp/src/routes/home.tsx @@ -13,11 +13,12 @@ import { Input } from '../components/ui/input' import { Modal } from '../components/ui/dialog' import { Toast } from '../components/ui/toast' import { Skeleton } from '../components/ui/skeleton' -import { ledgerPrimaryAmount } from '../lib/ledger-helpers' +import { formatMoneyLabel, localizedCurrencyLabel } from '../lib/ledger-helpers' import { majorStringToMinor, minorToMajorString } from '../lib/money' import { compareTodayToPeriodDay, daysUntilPeriodDay, + formatCyclePeriod, formatPeriodDay, nextCyclePeriod, parseCalendarDate @@ -50,11 +51,17 @@ function paymentProposalMinor( ? majorStringToMinor(member.rentShareMajor) : majorStringToMinor(member.utilityShareMajor) - if (data.paymentBalanceAdjustmentPolicy === kind) { - return baseMinor + purchaseOffsetMinor + const proposalMinor = + data.paymentBalanceAdjustmentPolicy === kind ? baseMinor + purchaseOffsetMinor : baseMinor + + if (kind !== 'rent' || proposalMinor <= 0n) { + return proposalMinor } - return baseMinor + const wholeMinor = proposalMinor / 100n + const remainderMinor = proposalMinor % 100n + + return (remainderMinor >= 50n ? wholeMinor + 1n : wholeMinor) * 100n } function paymentRemainingMinor( @@ -380,7 +387,10 @@ export default function HomeRoute() { paymentRemainingMinor(data(), member(), 'utilities') const modes = () => currentPaymentModes() - const currency = () => data().currency + const formatMajorAmount = ( + amountMajor: string, + currencyCode: 'USD' | 'GEL' = data().currency + ) => formatMoneyLabel(amountMajor, currencyCode, locale()) const timezone = () => data().timezone const period = () => effectivePeriod() ?? data().period const today = () => todayOverride() @@ -470,15 +480,17 @@ export default function HomeRoute() {
{copy().finalDue} - - {overdue().amountMajor} {currency()} - + {formatMajorAmount(overdue().amountMajor)}
{copy().homeOverduePeriodsLabel.replace( '{periods}', - overdue().periods.join(', ') + overdue() + .periods.map((period) => + formatCyclePeriod(period, locale()) + ) + .join(', ') )}
@@ -513,15 +525,17 @@ export default function HomeRoute() {
{copy().finalDue} - - {overdue().amountMajor} {currency()} - + {formatMajorAmount(overdue().amountMajor)}
{copy().homeOverduePeriodsLabel.replace( '{periods}', - overdue().periods.join(', ') + overdue() + .periods.map((period) => + formatCyclePeriod(period, locale()) + ) + .join(', ') )}
@@ -552,7 +566,7 @@ export default function HomeRoute() {
{copy().finalDue} - {minorToMajorString(utilitiesRemainingMinor())} {currency()} + {formatMajorAmount(minorToMajorString(utilitiesRemainingMinor()))}
@@ -563,30 +577,30 @@ export default function HomeRoute() {
{copy().baseDue} - - {member().utilityShareMajor} {currency()} - + {formatMajorAmount(member().utilityShareMajor)}
{copy().balanceAdjustmentLabel} - - {member().purchaseOffsetMajor} {currency()} - + {formatMajorAmount(member().purchaseOffsetMajor)}
0}>
{copy().homeUtilitiesBillsTitle} - - {utilityTotalMajor()} {currency()} - + {formatMajorAmount(utilityTotalMajor())}
{(entry) => (
{entry.title} - {ledgerPrimaryAmount(entry)} + + {formatMoneyLabel( + entry.displayAmountMajor, + entry.displayCurrency, + locale() + )} +
)}
@@ -617,7 +631,7 @@ export default function HomeRoute() {
{copy().finalDue} - {minorToMajorString(rentRemainingMinor())} {currency()} + {formatMajorAmount(minorToMajorString(rentRemainingMinor()))}
@@ -626,16 +640,12 @@ export default function HomeRoute() {
{copy().baseDue} - - {member().rentShareMajor} {currency()} - + {formatMajorAmount(member().rentShareMajor)}
{copy().balanceAdjustmentLabel} - - {member().purchaseOffsetMajor} {currency()} - + {formatMajorAmount(member().purchaseOffsetMajor)}
@@ -924,7 +934,13 @@ export default function HomeRoute() { {(entry) => (
{entry.title} - {ledgerPrimaryAmount(entry)} + + {formatMoneyLabel( + entry.displayAmountMajor, + entry.displayCurrency, + locale() + )} +
)} @@ -997,7 +1013,14 @@ export default function HomeRoute() { /> - +
diff --git a/apps/miniapp/src/routes/ledger.tsx b/apps/miniapp/src/routes/ledger.tsx index e4eff55..dfd7004 100644 --- a/apps/miniapp/src/routes/ledger.tsx +++ b/apps/miniapp/src/routes/ledger.tsx @@ -15,17 +15,19 @@ import { Collapsible } from '../components/ui/collapsible' import { Toggle } from '../components/ui/toggle' import { Skeleton } from '../components/ui/skeleton' import { - ledgerPrimaryAmount, + formatMoneyLabel, ledgerSecondaryAmount, purchaseDraftForEntry, paymentDraftForEntry, computePaymentPrefill, + localizedCurrencyLabel, rebalancePurchaseSplit, validatePurchaseDraft, type PurchaseDraft, type PaymentDraft } from '../lib/ledger-helpers' import { minorToMajorString, majorStringToMinor } from '../lib/money' +import { formatCyclePeriod, formatFriendlyDate } from '../lib/dates' import { addMiniAppPurchase, updateMiniAppPurchase, @@ -39,6 +41,10 @@ import { type MiniAppDashboard } from '../miniapp-api' +function joinSubtitleParts(parts: readonly (string | null | undefined)[]): string { + return parts.filter(Boolean).join(' · ') +} + interface ParticipantSplitInputsProps { draft: PurchaseDraft updateDraft: (fn: (d: PurchaseDraft) => PurchaseDraft) => void @@ -203,7 +209,7 @@ function ParticipantSplitInputs(props: ParticipantSplitInputsProps) { export default function LedgerRoute() { const { initData, refreshHouseholdData, session } = useSession() - const { copy } = useI18n() + const { copy, locale } = useI18n() const { dashboard, loading, effectiveIsAdmin, purchaseLedger, utilityLedger, paymentLedger } = useDashboard() const unresolvedPurchaseLedger = createMemo(() => @@ -214,26 +220,15 @@ export default function LedgerRoute() { ) const paymentPeriodOptions = createMemo(() => { const periods = new Set() - if (dashboard()?.period) { - periods.add(dashboard()!.period) + for (const summary of dashboard()?.paymentPeriods ?? []) { + periods.add(summary.period) } - for (const entry of purchaseLedger()) { - if (entry.originPeriod) { - periods.add(entry.originPeriod) - } - } - - for (const member of dashboard()?.members ?? []) { - for (const overdue of member.overduePayments) { - for (const period of overdue.periods) { - periods.add(period) - } - } - } - - return [...periods].sort().map((period) => ({ value: period, label: period })) + return [...periods] + .sort() + .map((period) => ({ value: period, label: formatCyclePeriod(period, locale()) })) }) + const paymentPeriodSummaries = createMemo(() => dashboard()?.paymentPeriods ?? []) // ── Purchase editor ────────────────────────────── const [editingPurchase, setEditingPurchase] = createSignal< @@ -294,6 +289,7 @@ export default function LedgerRoute() { period: dashboard()?.period ?? '' }) const [addingPayment, setAddingPayment] = createSignal(false) + const [paymentActionError, setPaymentActionError] = createSignal(null) const addPurchaseButtonText = createMemo(() => { if (addingPurchase()) return copy().savingPurchase @@ -543,6 +539,7 @@ export default function LedgerRoute() { setAddingPayment(true) try { + setPaymentActionError(null) await addMiniAppPayment(data, { memberId: draft.memberId, kind: draft.kind, @@ -559,13 +556,58 @@ export default function LedgerRoute() { period: dashboard()?.period ?? '' }) await refreshHouseholdData(true, true) + } catch (error) { + setPaymentActionError(error instanceof Error ? error.message : copy().quickPaymentFailed) } finally { setAddingPayment(false) } } + async function handleResolveSuggestedPayment(input: { + memberId: string + kind: 'rent' | 'utilities' + period: string + amountMajor: string + }) { + const data = initData() + if (!data) return + + setAddingPayment(true) + try { + setPaymentActionError(null) + await addMiniAppPayment(data, { + memberId: input.memberId, + kind: input.kind, + period: input.period, + amountMajor: input.amountMajor, + currency: (dashboard()?.currency as 'USD' | 'GEL') ?? 'GEL' + }) + await refreshHouseholdData(true, true) + } catch (error) { + setPaymentActionError(error instanceof Error ? error.message : copy().quickPaymentFailed) + } finally { + setAddingPayment(false) + } + } + + function openCustomPayment(input: { + memberId: string + kind: 'rent' | 'utilities' + period: string + }) { + setPaymentActionError(null) + setNewPayment({ + memberId: input.memberId, + kind: input.kind, + amountMajor: computePaymentPrefill(dashboard(), input.memberId, input.kind, input.period), + currency: (dashboard()?.currency as 'USD' | 'GEL') ?? 'GEL', + period: input.period + }) + setAddPaymentOpen(true) + } + const currencyOptions = () => [ - { value: 'GEL', label: 'GEL' }, + { value: 'GEL', label: localizedCurrencyLabel(locale(), 'GEL') }, { value: 'USD', label: 'USD' } ] @@ -648,7 +690,9 @@ export default function LedgerRoute() { >
- {copy().unresolvedPurchasesTitle} +
+ {copy().unresolvedPurchasesTitle} +
0} fallback={

{copy().unresolvedPurchasesEmpty}

} @@ -664,13 +708,23 @@ export default function LedgerRoute() {
{entry.title} - {[entry.actorDisplayName, entry.originPeriod, 'Unresolved'] - .filter(Boolean) - .join(' · ')} + {joinSubtitleParts([ + entry.actorDisplayName, + entry.originPeriod + ? formatCyclePeriod(entry.originPeriod, locale()) + : null, + 'Unresolved' + ])}
- {ledgerPrimaryAmount(entry)} + + {formatMoneyLabel( + entry.displayAmountMajor, + entry.displayCurrency, + locale() + )} + {(secondary) => ( @@ -686,8 +740,7 @@ export default function LedgerRoute() {
-
- {copy().resolvedPurchasesTitle} + 0} fallback={

{copy().resolvedPurchasesEmpty}

} @@ -703,13 +756,25 @@ export default function LedgerRoute() {
{entry.title} - {[entry.actorDisplayName, entry.originPeriod, entry.resolvedAt] - .filter(Boolean) - .join(' · ')} + {joinSubtitleParts([ + entry.actorDisplayName, + entry.originPeriod + ? formatCyclePeriod(entry.originPeriod, locale()) + : null, + entry.resolvedAt + ? formatFriendlyDate(entry.resolvedAt, locale()) + : null + ])}
- {ledgerPrimaryAmount(entry)} + + {formatMoneyLabel( + entry.displayAmountMajor, + entry.displayCurrency, + locale() + )} + {(secondary) => ( @@ -723,47 +788,91 @@ export default function LedgerRoute() {
-
+
{/* ── Utility bills ──────────────────────── */} - -
- -
-
- 0} - fallback={

{copy().utilityLedgerEmpty}

} - > -
- - {(entry) => ( - - )} - -
-
+
+ +
+ +
+
+ 0} + fallback={

{copy().utilityLedgerEmpty}

} + > +
+ + {(entry) => ( + + )} + +
+
+ + 0} + fallback={

{copy().utilityLedgerEmpty}

} + > +
+ + {(summary) => ( +
+
+ + {formatCyclePeriod(summary.period, locale())} + + + {summary.isCurrentPeriod + ? copy().currentCycleLabel + : summary.hasOverdueBalance + ? copy().overdueLabel + : copy().homeSettledTitle} + +
+
+ + {formatMoneyLabel( + summary.utilityTotalMajor, + (dashboard()?.currency as 'USD' | 'GEL') ?? 'GEL', + locale() + )} + +
+
+ )} +
+
+
+
+
{/* ── Payments ───────────────────────────── */} @@ -776,7 +885,7 @@ export default function LedgerRoute() {
+ + {(error) =>

{error()}

} +
0} + when={paymentPeriodSummaries().length > 0} fallback={

{copy().paymentsEmpty}

} > -
- - {(entry) => ( - + +
+
+
+ )} + + + + + )} + -
- {ledgerPrimaryAmount(entry)} -
- + )} + + + 0} + fallback={

{copy().paymentsEmpty}

} + > +
+ + {(entry) => ( + + )} + +
+
+
@@ -1093,6 +1354,7 @@ export default function LedgerRoute() { } > + {(error) =>

{error()}

}