mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 08:44:02 +00:00
Merge pull request #20 from whekin/codex/ad-hoc-notifications
Codex/ad hoc notifications
This commit is contained in:
36
.github/workflows/cd-aws.yml
vendored
36
.github/workflows/cd-aws.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/cd.yml
vendored
2
.github/workflows/cd.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: CD
|
||||
name: CD / GCP
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
|
||||
114
apps/bot/src/ad-hoc-notification-jobs.test.ts
Normal file
114
apps/bot/src/ad-hoc-notification-jobs.test.ts
Normal file
@@ -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['notification']> = {}
|
||||
): 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'])
|
||||
})
|
||||
})
|
||||
194
apps/bot/src/ad-hoc-notification-jobs.ts
Normal file
194
apps/bot/src/ad-hoc-notification-jobs.ts
Normal file
@@ -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<DueNotificationJobRequestBody> {
|
||||
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<void>
|
||||
sendDirectMessage: (input: { telegramUserId: string; text: string }) => Promise<void>
|
||||
logger?: Logger
|
||||
}): {
|
||||
handle: (request: Request) => Promise<Response>
|
||||
} {
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
90
apps/bot/src/ad-hoc-notification-parser.test.ts
Normal file
90
apps/bot/src/ad-hoc-notification-parser.test.ts
Normal file
@@ -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<HouseholdMemberRecord> & Pick<HouseholdMemberRecord, 'id'>
|
||||
): 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')
|
||||
})
|
||||
})
|
||||
370
apps/bot/src/ad-hoc-notification-parser.ts
Normal file
370
apps/bot/src/ad-hoc-notification-parser.ts
Normal file
@@ -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<string>([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<string>([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
|
||||
}
|
||||
}
|
||||
888
apps/bot/src/ad-hoc-notifications.ts
Normal file
888
apps/bot/src/ad-hoc-notifications.ts
Normal file
@@ -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<NotificationDraftPayload, { stage: 'confirm' }>
|
||||
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<NotificationDraftPayload, { stage: 'confirm' }>,
|
||||
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<void> {
|
||||
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<ReminderTopicContext | null> {
|
||||
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<void> {
|
||||
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<NotificationDraftPayload | null> {
|
||||
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<NotificationDraftPayload, { stage: 'confirm' }>
|
||||
) {
|
||||
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<NotificationDraftPayload, { stage: 'confirm' }>
|
||||
) {
|
||||
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<NotificationDraftPayload, { stage: 'confirm' }> = {
|
||||
...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<NotificationDraftPayload, { stage: 'confirm' }> = {
|
||||
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<NotificationDraftPayload, { stage: 'confirm' }> = {
|
||||
...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: `<a href="tg://user?id=${escapeHtml(input.assignee.telegramUserId)}">${escapeHtml(input.assignee.displayName)}</a>, ${escapeHtml(input.notificationText)}`,
|
||||
parseMode: 'HTML'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
text: escapeHtml(input.notificationText),
|
||||
parseMode: 'HTML'
|
||||
}
|
||||
}
|
||||
@@ -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<BotRuntimeApp> {
|
||||
string,
|
||||
ReturnType<typeof createAnonymousFeedbackService>
|
||||
>()
|
||||
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<BotRuntimeApp> {
|
||||
shutdownTasks.push(topicMessageHistoryRepositoryClient.close)
|
||||
}
|
||||
|
||||
if (adHocNotificationRepositoryClient) {
|
||||
shutdownTasks.push(adHocNotificationRepositoryClient.close)
|
||||
}
|
||||
|
||||
if (purchaseRepositoryClient && householdConfigurationRepositoryClient) {
|
||||
registerConfiguredPurchaseTopicIngestion(
|
||||
bot,
|
||||
@@ -375,6 +393,20 @@ export async function createBotRuntimeApp(): Promise<BotRuntimeApp> {
|
||||
)
|
||||
}
|
||||
|
||||
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<BotRuntimeApp> {
|
||||
})
|
||||
})()
|
||||
: 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<BotRuntimeApp> {
|
||||
})
|
||||
: 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
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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) {
|
||||
</Show>
|
||||
<article class="testing-card__section">
|
||||
<span>{copy().testingPeriodCurrentLabel ?? ''}</span>
|
||||
<strong>{dashboard()?.period ?? '—'}</strong>
|
||||
<strong>
|
||||
{dashboard()?.period ? formatCyclePeriod(dashboard()!.period, locale()) : '—'}
|
||||
</strong>
|
||||
</article>
|
||||
<div class="testing-card__actions testing-card__actions--stack">
|
||||
<Field label={copy().testingPeriodOverrideLabel ?? ''} wide>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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} за текущий цикл.',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string, UtilityBillDraft> {
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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() {
|
||||
<div class="balance-card__amounts">
|
||||
<div class="balance-card__row balance-card__row--subtotal">
|
||||
<span>{copy().finalDue}</span>
|
||||
<strong>
|
||||
{overdue().amountMajor} {currency()}
|
||||
</strong>
|
||||
<strong>{formatMajorAmount(overdue().amountMajor)}</strong>
|
||||
</div>
|
||||
<div class="balance-card__row">
|
||||
<span>
|
||||
{copy().homeOverduePeriodsLabel.replace(
|
||||
'{periods}',
|
||||
overdue().periods.join(', ')
|
||||
overdue()
|
||||
.periods.map((period) =>
|
||||
formatCyclePeriod(period, locale())
|
||||
)
|
||||
.join(', ')
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -513,15 +525,17 @@ export default function HomeRoute() {
|
||||
<div class="balance-card__amounts">
|
||||
<div class="balance-card__row balance-card__row--subtotal">
|
||||
<span>{copy().finalDue}</span>
|
||||
<strong>
|
||||
{overdue().amountMajor} {currency()}
|
||||
</strong>
|
||||
<strong>{formatMajorAmount(overdue().amountMajor)}</strong>
|
||||
</div>
|
||||
<div class="balance-card__row">
|
||||
<span>
|
||||
{copy().homeOverduePeriodsLabel.replace(
|
||||
'{periods}',
|
||||
overdue().periods.join(', ')
|
||||
overdue()
|
||||
.periods.map((period) =>
|
||||
formatCyclePeriod(period, locale())
|
||||
)
|
||||
.join(', ')
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -552,7 +566,7 @@ export default function HomeRoute() {
|
||||
<div class="balance-card__row balance-card__row--subtotal">
|
||||
<span>{copy().finalDue}</span>
|
||||
<strong>
|
||||
{minorToMajorString(utilitiesRemainingMinor())} {currency()}
|
||||
{formatMajorAmount(minorToMajorString(utilitiesRemainingMinor()))}
|
||||
</strong>
|
||||
</div>
|
||||
<div class="balance-card__row">
|
||||
@@ -563,30 +577,30 @@ export default function HomeRoute() {
|
||||
</div>
|
||||
<div class="balance-card__row">
|
||||
<span>{copy().baseDue}</span>
|
||||
<strong>
|
||||
{member().utilityShareMajor} {currency()}
|
||||
</strong>
|
||||
<strong>{formatMajorAmount(member().utilityShareMajor)}</strong>
|
||||
</div>
|
||||
<Show when={policy() === 'utilities'}>
|
||||
<div class="balance-card__row">
|
||||
<span>{copy().balanceAdjustmentLabel}</span>
|
||||
<strong>
|
||||
{member().purchaseOffsetMajor} {currency()}
|
||||
</strong>
|
||||
<strong>{formatMajorAmount(member().purchaseOffsetMajor)}</strong>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={utilityLedger().length > 0}>
|
||||
<div class="balance-card__row balance-card__row--subtotal">
|
||||
<span>{copy().homeUtilitiesBillsTitle}</span>
|
||||
<strong>
|
||||
{utilityTotalMajor()} {currency()}
|
||||
</strong>
|
||||
<strong>{formatMajorAmount(utilityTotalMajor())}</strong>
|
||||
</div>
|
||||
<For each={utilityLedger()}>
|
||||
{(entry) => (
|
||||
<div class="balance-card__row">
|
||||
<span>{entry.title}</span>
|
||||
<strong>{ledgerPrimaryAmount(entry)}</strong>
|
||||
<strong>
|
||||
{formatMoneyLabel(
|
||||
entry.displayAmountMajor,
|
||||
entry.displayCurrency,
|
||||
locale()
|
||||
)}
|
||||
</strong>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
@@ -617,7 +631,7 @@ export default function HomeRoute() {
|
||||
<div class="balance-card__row balance-card__row--subtotal">
|
||||
<span>{copy().finalDue}</span>
|
||||
<strong>
|
||||
{minorToMajorString(rentRemainingMinor())} {currency()}
|
||||
{formatMajorAmount(minorToMajorString(rentRemainingMinor()))}
|
||||
</strong>
|
||||
</div>
|
||||
<div class="balance-card__row">
|
||||
@@ -626,16 +640,12 @@ export default function HomeRoute() {
|
||||
</div>
|
||||
<div class="balance-card__row">
|
||||
<span>{copy().baseDue}</span>
|
||||
<strong>
|
||||
{member().rentShareMajor} {currency()}
|
||||
</strong>
|
||||
<strong>{formatMajorAmount(member().rentShareMajor)}</strong>
|
||||
</div>
|
||||
<Show when={policy() === 'rent'}>
|
||||
<div class="balance-card__row">
|
||||
<span>{copy().balanceAdjustmentLabel}</span>
|
||||
<strong>
|
||||
{member().purchaseOffsetMajor} {currency()}
|
||||
</strong>
|
||||
<strong>{formatMajorAmount(member().purchaseOffsetMajor)}</strong>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
@@ -924,7 +934,13 @@ export default function HomeRoute() {
|
||||
{(entry) => (
|
||||
<div class="activity-card__item">
|
||||
<span class="activity-card__title">{entry.title}</span>
|
||||
<span class="activity-card__amount">{ledgerPrimaryAmount(entry)}</span>
|
||||
<span class="activity-card__amount">
|
||||
{formatMoneyLabel(
|
||||
entry.displayAmountMajor,
|
||||
entry.displayCurrency,
|
||||
locale()
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
@@ -997,7 +1013,14 @@ export default function HomeRoute() {
|
||||
/>
|
||||
</Field>
|
||||
<Field label={copy().quickPaymentCurrencyLabel}>
|
||||
<Input type="text" value={(dashboard()?.currency as 'USD' | 'GEL') ?? 'GEL'} disabled />
|
||||
<Input
|
||||
type="text"
|
||||
value={localizedCurrencyLabel(
|
||||
locale(),
|
||||
(dashboard()?.currency as 'USD' | 'GEL') ?? 'GEL'
|
||||
)}
|
||||
disabled
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -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<string>()
|
||||
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<string | null>(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() {
|
||||
>
|
||||
<div style={{ display: 'flex', 'flex-direction': 'column', gap: '12px' }}>
|
||||
<div>
|
||||
<strong>{copy().unresolvedPurchasesTitle}</strong>
|
||||
<div class="editable-list-section-title">
|
||||
{copy().unresolvedPurchasesTitle}
|
||||
</div>
|
||||
<Show
|
||||
when={unresolvedPurchaseLedger().length > 0}
|
||||
fallback={<p class="empty-state">{copy().unresolvedPurchasesEmpty}</p>}
|
||||
@@ -664,13 +708,23 @@ export default function LedgerRoute() {
|
||||
<div class="editable-list-row__main">
|
||||
<span class="editable-list-row__title">{entry.title}</span>
|
||||
<span class="editable-list-row__subtitle">
|
||||
{[entry.actorDisplayName, entry.originPeriod, 'Unresolved']
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
{joinSubtitleParts([
|
||||
entry.actorDisplayName,
|
||||
entry.originPeriod
|
||||
? formatCyclePeriod(entry.originPeriod, locale())
|
||||
: null,
|
||||
'Unresolved'
|
||||
])}
|
||||
</span>
|
||||
</div>
|
||||
<div class="editable-list-row__meta">
|
||||
<strong>{ledgerPrimaryAmount(entry)}</strong>
|
||||
<strong>
|
||||
{formatMoneyLabel(
|
||||
entry.displayAmountMajor,
|
||||
entry.displayCurrency,
|
||||
locale()
|
||||
)}
|
||||
</strong>
|
||||
<Show when={ledgerSecondaryAmount(entry)}>
|
||||
{(secondary) => (
|
||||
<span class="editable-list-row__secondary">
|
||||
@@ -686,8 +740,7 @@ export default function LedgerRoute() {
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<strong>{copy().resolvedPurchasesTitle}</strong>
|
||||
<Collapsible title={copy().resolvedPurchasesTitle} defaultOpen={false}>
|
||||
<Show
|
||||
when={resolvedPurchaseLedger().length > 0}
|
||||
fallback={<p class="empty-state">{copy().resolvedPurchasesEmpty}</p>}
|
||||
@@ -703,13 +756,25 @@ export default function LedgerRoute() {
|
||||
<div class="editable-list-row__main">
|
||||
<span class="editable-list-row__title">{entry.title}</span>
|
||||
<span class="editable-list-row__subtitle">
|
||||
{[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
|
||||
])}
|
||||
</span>
|
||||
</div>
|
||||
<div class="editable-list-row__meta">
|
||||
<strong>{ledgerPrimaryAmount(entry)}</strong>
|
||||
<strong>
|
||||
{formatMoneyLabel(
|
||||
entry.displayAmountMajor,
|
||||
entry.displayCurrency,
|
||||
locale()
|
||||
)}
|
||||
</strong>
|
||||
<Show when={ledgerSecondaryAmount(entry)}>
|
||||
{(secondary) => (
|
||||
<span class="editable-list-row__secondary">
|
||||
@@ -723,47 +788,91 @@ export default function LedgerRoute() {
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Collapsible>
|
||||
</div>
|
||||
</Show>
|
||||
</Collapsible>
|
||||
|
||||
{/* ── Utility bills ──────────────────────── */}
|
||||
<Collapsible title={copy().utilityLedgerTitle}>
|
||||
<Show when={effectiveIsAdmin()}>
|
||||
<div class="editable-list-actions">
|
||||
<Button variant="primary" size="sm" onClick={() => setAddUtilityOpen(true)}>
|
||||
<Plus size={14} />
|
||||
{copy().addUtilityBillAction}
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
<Show
|
||||
when={utilityLedger().length > 0}
|
||||
fallback={<p class="empty-state">{copy().utilityLedgerEmpty}</p>}
|
||||
>
|
||||
<div class="editable-list">
|
||||
<For each={utilityLedger()}>
|
||||
{(entry) => (
|
||||
<button
|
||||
class="editable-list-row"
|
||||
onClick={() => effectiveIsAdmin() && openUtilityEditor(entry)}
|
||||
disabled={!effectiveIsAdmin()}
|
||||
>
|
||||
<div class="editable-list-row__main">
|
||||
<span class="editable-list-row__title">{entry.title}</span>
|
||||
<span class="editable-list-row__subtitle">
|
||||
{entry.actorDisplayName}
|
||||
</span>
|
||||
</div>
|
||||
<div class="editable-list-row__meta">
|
||||
<strong>{ledgerPrimaryAmount(entry)}</strong>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
<div style={{ display: 'flex', 'flex-direction': 'column', gap: '12px' }}>
|
||||
<Show when={effectiveIsAdmin()}>
|
||||
<div class="editable-list-actions">
|
||||
<Button variant="primary" size="sm" onClick={() => setAddUtilityOpen(true)}>
|
||||
<Plus size={14} />
|
||||
{copy().addUtilityBillAction}
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
<Show
|
||||
when={utilityLedger().length > 0}
|
||||
fallback={<p class="empty-state">{copy().utilityLedgerEmpty}</p>}
|
||||
>
|
||||
<div class="editable-list">
|
||||
<For each={utilityLedger()}>
|
||||
{(entry) => (
|
||||
<button
|
||||
class="editable-list-row"
|
||||
onClick={() => effectiveIsAdmin() && openUtilityEditor(entry)}
|
||||
disabled={!effectiveIsAdmin()}
|
||||
>
|
||||
<div class="editable-list-row__main">
|
||||
<span class="editable-list-row__title">{entry.title}</span>
|
||||
<span class="editable-list-row__subtitle">
|
||||
{entry.actorDisplayName}
|
||||
</span>
|
||||
</div>
|
||||
<div class="editable-list-row__meta">
|
||||
<strong>
|
||||
{formatMoneyLabel(
|
||||
entry.displayAmountMajor,
|
||||
entry.displayCurrency,
|
||||
locale()
|
||||
)}
|
||||
</strong>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
<Collapsible title={copy().utilityHistoryTitle} defaultOpen={false}>
|
||||
<Show
|
||||
when={paymentPeriodSummaries().length > 0}
|
||||
fallback={<p class="empty-state">{copy().utilityLedgerEmpty}</p>}
|
||||
>
|
||||
<div class="editable-list">
|
||||
<For each={paymentPeriodSummaries()}>
|
||||
{(summary) => (
|
||||
<div class="editable-list-row editable-list-row--static">
|
||||
<div class="editable-list-row__main">
|
||||
<span class="editable-list-row__title">
|
||||
{formatCyclePeriod(summary.period, locale())}
|
||||
</span>
|
||||
<span class="editable-list-row__subtitle">
|
||||
{summary.isCurrentPeriod
|
||||
? copy().currentCycleLabel
|
||||
: summary.hasOverdueBalance
|
||||
? copy().overdueLabel
|
||||
: copy().homeSettledTitle}
|
||||
</span>
|
||||
</div>
|
||||
<div class="editable-list-row__meta">
|
||||
<strong>
|
||||
{formatMoneyLabel(
|
||||
summary.utilityTotalMajor,
|
||||
(dashboard()?.currency as 'USD' | 'GEL') ?? 'GEL',
|
||||
locale()
|
||||
)}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</Collapsible>
|
||||
</div>
|
||||
</Collapsible>
|
||||
|
||||
{/* ── Payments ───────────────────────────── */}
|
||||
@@ -776,7 +885,7 @@ export default function LedgerRoute() {
|
||||
<Show when={effectiveIsAdmin()}>
|
||||
<div class="editable-list-actions">
|
||||
<Button
|
||||
variant="primary"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setNewPayment((payment) => ({
|
||||
@@ -791,34 +900,186 @@ export default function LedgerRoute() {
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={paymentActionError()}>
|
||||
{(error) => <p class="empty-state">{error()}</p>}
|
||||
</Show>
|
||||
<Show
|
||||
when={paymentLedger().length > 0}
|
||||
when={paymentPeriodSummaries().length > 0}
|
||||
fallback={<p class="empty-state">{copy().paymentsEmpty}</p>}
|
||||
>
|
||||
<div class="editable-list">
|
||||
<For each={paymentLedger()}>
|
||||
{(entry) => (
|
||||
<button
|
||||
class="editable-list-row"
|
||||
onClick={() => effectiveIsAdmin() && openPaymentEditor(entry)}
|
||||
disabled={!effectiveIsAdmin()}
|
||||
<div style={{ display: 'flex', 'flex-direction': 'column', gap: '12px' }}>
|
||||
<For each={paymentPeriodSummaries()}>
|
||||
{(summary) => (
|
||||
<Collapsible
|
||||
title={copy().paymentsPeriodTitle.replace(
|
||||
'{period}',
|
||||
formatCyclePeriod(summary.period, locale())
|
||||
)}
|
||||
body={
|
||||
summary.hasOverdueBalance
|
||||
? copy().paymentsPeriodOverdueBody
|
||||
: summary.isCurrentPeriod
|
||||
? copy().paymentsPeriodCurrentBody
|
||||
: copy().paymentsPeriodHistoryBody
|
||||
}
|
||||
defaultOpen={summary.isCurrentPeriod || summary.hasOverdueBalance}
|
||||
>
|
||||
<div class="editable-list-row__main">
|
||||
<span class="editable-list-row__title">
|
||||
{entry.paymentKind === 'rent'
|
||||
? copy().paymentLedgerRent
|
||||
: copy().paymentLedgerUtilities}
|
||||
</span>
|
||||
<span class="editable-list-row__subtitle">
|
||||
{entry.actorDisplayName}
|
||||
</span>
|
||||
<div style={{ display: 'flex', 'flex-direction': 'column', gap: '12px' }}>
|
||||
<For each={summary.kinds}>
|
||||
{(kindSummary) => (
|
||||
<Show
|
||||
when={kindSummary.unresolvedMembers.length > 0}
|
||||
fallback={
|
||||
<div class="editable-list-row editable-list-row--static">
|
||||
<div class="editable-list-row__main">
|
||||
<span class="editable-list-row__title">
|
||||
{kindSummary.kind === 'rent'
|
||||
? copy().shareRent
|
||||
: copy().shareUtilities}
|
||||
</span>
|
||||
<span class="editable-list-row__subtitle">
|
||||
{copy().homeSettledTitle}
|
||||
</span>
|
||||
</div>
|
||||
<div class="editable-list-row__meta">
|
||||
<strong>
|
||||
{formatMoneyLabel(
|
||||
kindSummary.totalPaidMajor,
|
||||
(dashboard()?.currency as 'USD' | 'GEL') ?? 'GEL',
|
||||
locale()
|
||||
)}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<div class="editable-list-section-title">
|
||||
{kindSummary.kind === 'rent'
|
||||
? copy().shareRent
|
||||
: copy().shareUtilities}
|
||||
</div>
|
||||
<div class="editable-list">
|
||||
<For each={kindSummary.unresolvedMembers}>
|
||||
{(memberSummary) => (
|
||||
<div class="editable-list-row editable-list-row--stacked">
|
||||
<div class="editable-list-row__main">
|
||||
<span class="editable-list-row__title">
|
||||
{memberSummary.displayName}
|
||||
</span>
|
||||
<span class="editable-list-row__subtitle">
|
||||
{copy()
|
||||
.paymentsBaseDueLabel.replace(
|
||||
'{amount}',
|
||||
formatMoneyLabel(
|
||||
memberSummary.baseDueMajor,
|
||||
(dashboard()?.currency as 'USD' | 'GEL') ??
|
||||
'GEL',
|
||||
locale()
|
||||
)
|
||||
)
|
||||
.replace(
|
||||
'{remaining}',
|
||||
formatMoneyLabel(
|
||||
memberSummary.remainingMajor,
|
||||
(dashboard()?.currency as 'USD' | 'GEL') ??
|
||||
'GEL',
|
||||
locale()
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="editable-list-row__meta">
|
||||
<strong>
|
||||
{formatMoneyLabel(
|
||||
memberSummary.suggestedAmountMajor,
|
||||
(dashboard()?.currency as 'USD' | 'GEL') ?? 'GEL',
|
||||
locale()
|
||||
)}
|
||||
</strong>
|
||||
<div class="editable-list-inline-actions">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
loading={addingPayment()}
|
||||
onClick={() =>
|
||||
void handleResolveSuggestedPayment({
|
||||
memberId: memberSummary.memberId,
|
||||
kind: kindSummary.kind,
|
||||
period: summary.period,
|
||||
amountMajor:
|
||||
memberSummary.suggestedAmountMajor
|
||||
})
|
||||
}
|
||||
>
|
||||
{copy().paymentsResolveAction}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
openCustomPayment({
|
||||
memberId: memberSummary.memberId,
|
||||
kind: kindSummary.kind,
|
||||
period: summary.period
|
||||
})
|
||||
}
|
||||
>
|
||||
{copy().paymentsCustomAmountAction}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<div class="editable-list-row__meta">
|
||||
<strong>{ledgerPrimaryAmount(entry)}</strong>
|
||||
</div>
|
||||
</button>
|
||||
</Collapsible>
|
||||
)}
|
||||
</For>
|
||||
|
||||
<Collapsible title={copy().paymentsHistoryTitle} defaultOpen={false}>
|
||||
<Show
|
||||
when={paymentLedger().length > 0}
|
||||
fallback={<p class="empty-state">{copy().paymentsEmpty}</p>}
|
||||
>
|
||||
<div class="editable-list">
|
||||
<For each={paymentLedger()}>
|
||||
{(entry) => (
|
||||
<button
|
||||
class="editable-list-row"
|
||||
onClick={() => effectiveIsAdmin() && openPaymentEditor(entry)}
|
||||
disabled={!effectiveIsAdmin()}
|
||||
>
|
||||
<div class="editable-list-row__main">
|
||||
<span class="editable-list-row__title">
|
||||
{entry.paymentKind === 'rent'
|
||||
? copy().paymentLedgerRent
|
||||
: copy().paymentLedgerUtilities}
|
||||
</span>
|
||||
<span class="editable-list-row__subtitle">
|
||||
{entry.actorDisplayName}
|
||||
</span>
|
||||
</div>
|
||||
<div class="editable-list-row__meta">
|
||||
<strong>
|
||||
{formatMoneyLabel(
|
||||
entry.displayAmountMajor,
|
||||
entry.displayCurrency,
|
||||
locale()
|
||||
)}
|
||||
</strong>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</Collapsible>
|
||||
</div>
|
||||
</Show>
|
||||
</Collapsible>
|
||||
@@ -1093,6 +1354,7 @@ export default function LedgerRoute() {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show when={paymentActionError()}>{(error) => <p class="empty-state">{error()}</p>}</Show>
|
||||
<div class="editor-grid">
|
||||
<Field label={copy().paymentMember}>
|
||||
<Select
|
||||
@@ -1101,8 +1363,12 @@ export default function LedgerRoute() {
|
||||
placeholder="—"
|
||||
options={[{ value: '', label: '—' }, ...memberOptions()]}
|
||||
onChange={(memberId) => {
|
||||
const member = dashboard()?.members.find((m) => m.memberId === memberId)
|
||||
const prefill = computePaymentPrefill(member, newPayment().kind)
|
||||
const prefill = computePaymentPrefill(
|
||||
dashboard(),
|
||||
memberId,
|
||||
newPayment().kind,
|
||||
newPayment().period || dashboard()?.period || ''
|
||||
)
|
||||
setNewPayment((p) => ({ ...p, memberId, amountMajor: prefill }))
|
||||
}}
|
||||
/>
|
||||
@@ -1113,7 +1379,18 @@ export default function LedgerRoute() {
|
||||
ariaLabel={copy().paymentKind}
|
||||
options={kindOptions()}
|
||||
onChange={(value) =>
|
||||
setNewPayment((p) => ({ ...p, kind: value as 'rent' | 'utilities' }))
|
||||
setNewPayment((p) => ({
|
||||
...p,
|
||||
kind: value as 'rent' | 'utilities',
|
||||
amountMajor: p.memberId
|
||||
? computePaymentPrefill(
|
||||
dashboard(),
|
||||
p.memberId,
|
||||
value as 'rent' | 'utilities',
|
||||
p.period || dashboard()?.period || ''
|
||||
)
|
||||
: p.amountMajor
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
@@ -1123,7 +1400,20 @@ export default function LedgerRoute() {
|
||||
placeholder="—"
|
||||
ariaLabel="Billing period"
|
||||
options={[{ value: '', label: '—' }, ...paymentPeriodOptions()]}
|
||||
onChange={(value) => setNewPayment((p) => ({ ...p, period: value }))}
|
||||
onChange={(value) =>
|
||||
setNewPayment((p) => ({
|
||||
...p,
|
||||
period: value,
|
||||
amountMajor: p.memberId
|
||||
? computePaymentPrefill(
|
||||
dashboard(),
|
||||
p.memberId,
|
||||
p.kind,
|
||||
value || dashboard()?.period || ''
|
||||
)
|
||||
: p.amountMajor
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={copy().paymentAmount}>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useNavigate } from '@solidjs/router'
|
||||
import { useSession } from '../contexts/session-context'
|
||||
import { useI18n } from '../contexts/i18n-context'
|
||||
import { useDashboard } from '../contexts/dashboard-context'
|
||||
import { formatCyclePeriod } from '../lib/dates'
|
||||
import { Card } from '../components/ui/card'
|
||||
import { Button } from '../components/ui/button'
|
||||
import { Badge } from '../components/ui/badge'
|
||||
@@ -418,7 +419,7 @@ export default function SettingsRoute() {
|
||||
<div class="settings-billing-summary">
|
||||
<div class="settings-row">
|
||||
<span>{copy().billingCyclePeriod}</span>
|
||||
<Badge variant="accent">{cycle().period}</Badge>
|
||||
<Badge variant="accent">{formatCyclePeriod(cycle().period, locale())}</Badge>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<span>{copy().currencyLabel}</span>
|
||||
|
||||
276
packages/adapters-db/src/ad-hoc-notification-repository.ts
Normal file
276
packages/adapters-db/src/ad-hoc-notification-repository.ts
Normal file
@@ -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<void>
|
||||
} {
|
||||
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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
268
packages/application/src/ad-hoc-notification-service.test.ts
Normal file
268
packages/application/src/ad-hoc-notification-service.test.ts
Normal file
@@ -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<string, AdHocNotificationRecord>()
|
||||
nextId = 1
|
||||
|
||||
async createNotification(input: CreateAdHocNotificationInput): Promise<AdHocNotificationRecord> {
|
||||
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<AdHocNotificationRecord | null> {
|
||||
return this.notifications.get(notificationId) ?? null
|
||||
}
|
||||
|
||||
async listUpcomingNotificationsForHousehold(
|
||||
householdId: string,
|
||||
asOf: Temporal.Instant
|
||||
): Promise<readonly AdHocNotificationRecord[]> {
|
||||
return [...this.notifications.values()].filter(
|
||||
(notification) =>
|
||||
notification.householdId === householdId &&
|
||||
notification.status === 'scheduled' &&
|
||||
notification.scheduledFor.epochMilliseconds > asOf.epochMilliseconds
|
||||
)
|
||||
}
|
||||
|
||||
async cancelNotification(
|
||||
input: CancelAdHocNotificationInput
|
||||
): Promise<AdHocNotificationRecord | null> {
|
||||
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<readonly AdHocNotificationRecord[]> {
|
||||
return [...this.notifications.values()].filter(
|
||||
(notification) =>
|
||||
notification.status === 'scheduled' &&
|
||||
notification.scheduledFor.epochMilliseconds <= asOf.epochMilliseconds
|
||||
)
|
||||
}
|
||||
|
||||
async markNotificationSent(
|
||||
notificationId: string,
|
||||
sentAt: Temporal.Instant
|
||||
): Promise<AdHocNotificationRecord | null> {
|
||||
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<ClaimAdHocNotificationDeliveryResult> {
|
||||
return {
|
||||
notificationId,
|
||||
claimed: true
|
||||
}
|
||||
}
|
||||
|
||||
async releaseNotificationDelivery(): Promise<void> {}
|
||||
}
|
||||
|
||||
function member(
|
||||
input: Partial<HouseholdMemberRecord> & Pick<HouseholdMemberRecord, 'id'>
|
||||
): 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<HouseholdConfigurationRepository, 'getHouseholdMember' | 'listHouseholdMembers'> {
|
||||
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')
|
||||
}
|
||||
})
|
||||
})
|
||||
377
packages/application/src/ad-hoc-notification-service.ts
Normal file
377
packages/application/src/ad-hoc-notification-service.ts
Normal file
@@ -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<ScheduleAdHocNotificationResult>
|
||||
listUpcomingNotifications(input: {
|
||||
householdId: string
|
||||
viewerMemberId: string
|
||||
asOf?: Instant
|
||||
}): Promise<readonly AdHocNotificationSummary[]>
|
||||
cancelNotification(input: {
|
||||
notificationId: string
|
||||
viewerMemberId: string
|
||||
asOf?: Instant
|
||||
}): Promise<CancelAdHocNotificationResult>
|
||||
listDueNotifications(asOf?: Instant): Promise<readonly DeliverableAdHocNotification[]>
|
||||
claimDueNotification(notificationId: string): Promise<boolean>
|
||||
releaseDueNotification(notificationId: string): Promise<void>
|
||||
markNotificationSent(
|
||||
notificationId: string,
|
||||
sentAt?: Instant
|
||||
): Promise<AdHocNotificationRecord | null>
|
||||
}
|
||||
|
||||
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<HouseholdConfigurationRepository, 'listHouseholdMembers'>,
|
||||
householdId: string
|
||||
): Promise<Map<string, HouseholdMemberRecord>> {
|
||||
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<NotificationActor | null> {
|
||||
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<string, Map<string, HouseholdMemberRecord>>()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1269,4 +1269,76 @@ describe('createFinanceCommandService', () => {
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
test('generateDashboard rounds rent suggestions in payment period summaries', async () => {
|
||||
const repository = new FinanceRepositoryStub()
|
||||
repository.members = [
|
||||
{
|
||||
id: 'alice',
|
||||
telegramUserId: '1',
|
||||
displayName: 'Alice',
|
||||
rentShareWeight: 1,
|
||||
isAdmin: true
|
||||
}
|
||||
]
|
||||
repository.openCycleRecord = {
|
||||
id: 'cycle-2026-03',
|
||||
period: '2026-03',
|
||||
currency: 'GEL'
|
||||
}
|
||||
repository.latestCycleRecord = repository.openCycleRecord
|
||||
repository.cycles = [repository.openCycleRecord]
|
||||
repository.rentRule = {
|
||||
amountMinor: 47256n,
|
||||
currency: 'GEL'
|
||||
}
|
||||
|
||||
const service = createService(repository)
|
||||
const dashboard = await service.generateDashboard('2026-03')
|
||||
const rentSummary = dashboard?.paymentPeriods?.[0]?.kinds.find((kind) => kind.kind === 'rent')
|
||||
|
||||
expect(rentSummary?.unresolvedMembers[0]?.suggestedAmount.toMajorString()).toBe('473.00')
|
||||
})
|
||||
|
||||
test('addPayment rejects duplicate explicit payments when the period is already effectively settled', async () => {
|
||||
const repository = new FinanceRepositoryStub()
|
||||
repository.members = [
|
||||
{
|
||||
id: 'alice',
|
||||
telegramUserId: '1',
|
||||
displayName: 'Alice',
|
||||
rentShareWeight: 1,
|
||||
isAdmin: true
|
||||
}
|
||||
]
|
||||
repository.openCycleRecord = {
|
||||
id: 'cycle-2026-03',
|
||||
period: '2026-03',
|
||||
currency: 'GEL'
|
||||
}
|
||||
repository.latestCycleRecord = repository.openCycleRecord
|
||||
repository.cycles = [repository.openCycleRecord]
|
||||
repository.rentRule = {
|
||||
amountMinor: 47256n,
|
||||
currency: 'GEL'
|
||||
}
|
||||
repository.paymentRecords = [
|
||||
{
|
||||
id: 'payment-1',
|
||||
cycleId: 'cycle-2026-03',
|
||||
cyclePeriod: '2026-03',
|
||||
memberId: 'alice',
|
||||
kind: 'rent',
|
||||
amountMinor: 47200n,
|
||||
currency: 'GEL',
|
||||
recordedAt: instantFromIso('2026-03-18T12:00:00.000Z')
|
||||
}
|
||||
]
|
||||
|
||||
const service = createService(repository)
|
||||
|
||||
await expect(service.addPayment('alice', 'rent', '10.00', 'GEL', '2026-03')).rejects.toThrow(
|
||||
'Payment period is already settled'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -123,6 +123,32 @@ export interface FinanceDashboardMemberLine {
|
||||
explanations: readonly string[]
|
||||
}
|
||||
|
||||
export interface FinanceDashboardPaymentMemberSummary {
|
||||
memberId: string
|
||||
displayName: string
|
||||
suggestedAmount: Money
|
||||
baseDue: Money
|
||||
paid: Money
|
||||
remaining: Money
|
||||
effectivelySettled: boolean
|
||||
}
|
||||
|
||||
export interface FinanceDashboardPaymentKindSummary {
|
||||
kind: FinancePaymentKind
|
||||
totalDue: Money
|
||||
totalPaid: Money
|
||||
totalRemaining: Money
|
||||
unresolvedMembers: readonly FinanceDashboardPaymentMemberSummary[]
|
||||
}
|
||||
|
||||
export interface FinanceDashboardPaymentPeriodSummary {
|
||||
period: string
|
||||
utilityTotal: Money
|
||||
hasOverdueBalance: boolean
|
||||
isCurrentPeriod: boolean
|
||||
kinds: readonly FinanceDashboardPaymentKindSummary[]
|
||||
}
|
||||
|
||||
export interface FinanceDashboardLedgerEntry {
|
||||
id: string
|
||||
kind: 'purchase' | 'utility' | 'payment'
|
||||
@@ -171,6 +197,7 @@ export interface FinanceDashboard {
|
||||
rentFxRateMicros: bigint | null
|
||||
rentFxEffectiveDate: string | null
|
||||
members: readonly FinanceDashboardMemberLine[]
|
||||
paymentPeriods?: readonly FinanceDashboardPaymentPeriodSummary[]
|
||||
ledger: readonly FinanceDashboardLedgerEntry[]
|
||||
}
|
||||
|
||||
@@ -259,6 +286,33 @@ interface MutableOverdueSummary {
|
||||
utilities: { amountMinor: bigint; periods: string[] }
|
||||
}
|
||||
|
||||
const PAYMENT_SETTLEMENT_TOLERANCE_MINOR = 200n
|
||||
|
||||
function effectiveRemainingMinor(expectedMinor: bigint, paidMinor: bigint): bigint {
|
||||
const shortfallMinor = expectedMinor - paidMinor
|
||||
|
||||
if (shortfallMinor <= PAYMENT_SETTLEMENT_TOLERANCE_MINOR) {
|
||||
return 0n
|
||||
}
|
||||
|
||||
return shortfallMinor
|
||||
}
|
||||
|
||||
function roundSuggestedPaymentMinor(kind: FinancePaymentKind, amountMinor: bigint): bigint {
|
||||
if (kind !== 'rent') {
|
||||
return amountMinor
|
||||
}
|
||||
|
||||
if (amountMinor <= 0n) {
|
||||
return 0n
|
||||
}
|
||||
|
||||
const wholeMinor = amountMinor / 100n
|
||||
const remainderMinor = amountMinor % 100n
|
||||
|
||||
return (remainderMinor >= 50n ? wholeMinor + 1n : wholeMinor) * 100n
|
||||
}
|
||||
|
||||
function periodFromInstant(instant: Temporal.Instant | null | undefined): string | null {
|
||||
if (!instant) {
|
||||
return null
|
||||
@@ -516,13 +570,19 @@ async function computeMemberOverduePayments(input: {
|
||||
utilities: { amountMinor: 0n, periods: [] }
|
||||
}
|
||||
|
||||
const rentRemainingMinor = line.rentShare.subtract(line.rentPaid).amountMinor
|
||||
const rentRemainingMinor = effectiveRemainingMinor(
|
||||
line.rentShare.amountMinor,
|
||||
line.rentPaid.amountMinor
|
||||
)
|
||||
if (Temporal.PlainDate.compare(localDate, rentDueDate) > 0 && rentRemainingMinor > 0n) {
|
||||
current.rent.amountMinor += rentRemainingMinor
|
||||
current.rent.periods.push(cycle.period)
|
||||
}
|
||||
|
||||
const utilityRemainingMinor = line.utilityShare.subtract(line.utilityPaid).amountMinor
|
||||
const utilityRemainingMinor = effectiveRemainingMinor(
|
||||
line.utilityShare.amountMinor,
|
||||
line.utilityPaid.amountMinor
|
||||
)
|
||||
if (
|
||||
Temporal.PlainDate.compare(localDate, utilitiesDueDate) > 0 &&
|
||||
utilityRemainingMinor > 0n
|
||||
@@ -558,6 +618,161 @@ async function computeMemberOverduePayments(input: {
|
||||
)
|
||||
}
|
||||
|
||||
async function buildPaymentPeriodSummaries(input: {
|
||||
dependencies: FinanceCommandServiceDependencies
|
||||
currentCycle: FinanceCycleRecord
|
||||
members: readonly HouseholdMemberRecord[]
|
||||
memberAbsencePolicies: readonly HouseholdMemberAbsencePolicyRecord[]
|
||||
settings: HouseholdBillingSettingsRecord
|
||||
}): Promise<readonly FinanceDashboardPaymentPeriodSummary[]> {
|
||||
const localDate = localDateInTimezone(input.settings.timezone)
|
||||
const memberNameById = new Map(input.members.map((member) => [member.id, member.displayName]))
|
||||
const cycles = (await input.dependencies.repository.listCycles())
|
||||
.filter((cycle) => cycle.period.localeCompare(input.currentCycle.period) <= 0)
|
||||
.sort((left, right) => right.period.localeCompare(left.period))
|
||||
|
||||
const summaries: FinanceDashboardPaymentPeriodSummary[] = []
|
||||
|
||||
for (const cycle of cycles) {
|
||||
const [baseLines, utilityBills] = await Promise.all([
|
||||
buildCycleBaseMemberLines({
|
||||
dependencies: input.dependencies,
|
||||
cycle,
|
||||
members: input.members,
|
||||
memberAbsencePolicies: input.memberAbsencePolicies,
|
||||
settings: input.settings
|
||||
}),
|
||||
input.dependencies.repository.listUtilityBillsForCycle(cycle.id)
|
||||
])
|
||||
|
||||
const utilityTotal = utilityBills.reduce(
|
||||
(sum, bill) => sum.add(Money.fromMinor(bill.amountMinor, bill.currency)),
|
||||
Money.zero(cycle.currency)
|
||||
)
|
||||
const rentDueDate = billingPeriodLockDate(
|
||||
BillingPeriod.fromString(cycle.period),
|
||||
input.settings.rentDueDay
|
||||
)
|
||||
const utilitiesDueDate = billingPeriodLockDate(
|
||||
BillingPeriod.fromString(cycle.period),
|
||||
input.settings.utilitiesDueDay
|
||||
)
|
||||
|
||||
const rentMembers = baseLines.map((line) => {
|
||||
const remainingMinor = effectiveRemainingMinor(
|
||||
line.rentShare.amountMinor,
|
||||
line.rentPaid.amountMinor
|
||||
)
|
||||
const baseDue = line.rentShare
|
||||
return {
|
||||
memberId: line.memberId,
|
||||
displayName: memberNameById.get(line.memberId) ?? line.memberId,
|
||||
suggestedAmount: Money.fromMinor(
|
||||
roundSuggestedPaymentMinor('rent', remainingMinor),
|
||||
cycle.currency
|
||||
),
|
||||
baseDue,
|
||||
paid: line.rentPaid,
|
||||
remaining: Money.fromMinor(remainingMinor, cycle.currency),
|
||||
effectivelySettled: remainingMinor === 0n
|
||||
} satisfies FinanceDashboardPaymentMemberSummary
|
||||
})
|
||||
|
||||
const utilitiesMembers = baseLines.map((line) => {
|
||||
const remainingMinor = effectiveRemainingMinor(
|
||||
line.utilityShare.amountMinor,
|
||||
line.utilityPaid.amountMinor
|
||||
)
|
||||
return {
|
||||
memberId: line.memberId,
|
||||
displayName: memberNameById.get(line.memberId) ?? line.memberId,
|
||||
suggestedAmount: Money.fromMinor(remainingMinor, cycle.currency),
|
||||
baseDue: line.utilityShare,
|
||||
paid: line.utilityPaid,
|
||||
remaining: Money.fromMinor(remainingMinor, cycle.currency),
|
||||
effectivelySettled: remainingMinor === 0n
|
||||
} satisfies FinanceDashboardPaymentMemberSummary
|
||||
})
|
||||
|
||||
const hasOverdueBalance =
|
||||
(Temporal.PlainDate.compare(localDate, rentDueDate) > 0 &&
|
||||
rentMembers.some((member) => !member.effectivelySettled)) ||
|
||||
(Temporal.PlainDate.compare(localDate, utilitiesDueDate) > 0 &&
|
||||
utilitiesMembers.some((member) => !member.effectivelySettled))
|
||||
|
||||
summaries.push({
|
||||
period: cycle.period,
|
||||
utilityTotal,
|
||||
hasOverdueBalance,
|
||||
isCurrentPeriod: cycle.period === input.currentCycle.period,
|
||||
kinds: [
|
||||
{
|
||||
kind: 'rent',
|
||||
totalDue: rentMembers.reduce(
|
||||
(sum, member) => sum.add(member.baseDue),
|
||||
Money.zero(cycle.currency)
|
||||
),
|
||||
totalPaid: rentMembers.reduce(
|
||||
(sum, member) => sum.add(member.paid),
|
||||
Money.zero(cycle.currency)
|
||||
),
|
||||
totalRemaining: rentMembers.reduce(
|
||||
(sum, member) => sum.add(member.remaining),
|
||||
Money.zero(cycle.currency)
|
||||
),
|
||||
unresolvedMembers: rentMembers.filter((member) => !member.effectivelySettled)
|
||||
},
|
||||
{
|
||||
kind: 'utilities',
|
||||
totalDue: utilitiesMembers.reduce(
|
||||
(sum, member) => sum.add(member.baseDue),
|
||||
Money.zero(cycle.currency)
|
||||
),
|
||||
totalPaid: utilitiesMembers.reduce(
|
||||
(sum, member) => sum.add(member.paid),
|
||||
Money.zero(cycle.currency)
|
||||
),
|
||||
totalRemaining: utilitiesMembers.reduce(
|
||||
(sum, member) => sum.add(member.remaining),
|
||||
Money.zero(cycle.currency)
|
||||
),
|
||||
unresolvedMembers: utilitiesMembers.filter((member) => !member.effectivelySettled)
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
return summaries
|
||||
}
|
||||
|
||||
async function getCycleKindBaseRemaining(input: {
|
||||
dependencies: FinanceCommandServiceDependencies
|
||||
cycle: FinanceCycleRecord
|
||||
members: readonly HouseholdMemberRecord[]
|
||||
memberAbsencePolicies: readonly HouseholdMemberAbsencePolicyRecord[]
|
||||
settings: HouseholdBillingSettingsRecord
|
||||
memberId: string
|
||||
kind: FinancePaymentKind
|
||||
}): Promise<bigint> {
|
||||
const baseLine = (
|
||||
await buildCycleBaseMemberLines({
|
||||
dependencies: input.dependencies,
|
||||
cycle: input.cycle,
|
||||
members: input.members,
|
||||
memberAbsencePolicies: input.memberAbsencePolicies,
|
||||
settings: input.settings
|
||||
})
|
||||
).find((line) => line.memberId === input.memberId)
|
||||
|
||||
if (!baseLine) {
|
||||
return 0n
|
||||
}
|
||||
|
||||
return input.kind === 'rent'
|
||||
? effectiveRemainingMinor(baseLine.rentShare.amountMinor, baseLine.rentPaid.amountMinor)
|
||||
: effectiveRemainingMinor(baseLine.utilityShare.amountMinor, baseLine.utilityPaid.amountMinor)
|
||||
}
|
||||
|
||||
async function resolveAutomaticPaymentTargets(input: {
|
||||
dependencies: FinanceCommandServiceDependencies
|
||||
currentCycle: FinanceCycleRecord
|
||||
@@ -608,8 +823,11 @@ async function resolveAutomaticPaymentTargets(input: {
|
||||
|
||||
const remainingMinor =
|
||||
input.kind === 'rent'
|
||||
? baseLine.rentShare.subtract(baseLine.rentPaid).amountMinor
|
||||
: baseLine.utilityShare.subtract(baseLine.utilityPaid).amountMinor
|
||||
? effectiveRemainingMinor(baseLine.rentShare.amountMinor, baseLine.rentPaid.amountMinor)
|
||||
: effectiveRemainingMinor(
|
||||
baseLine.utilityShare.amountMinor,
|
||||
baseLine.utilityPaid.amountMinor
|
||||
)
|
||||
|
||||
if (remainingMinor <= 0n) {
|
||||
continue
|
||||
@@ -687,13 +905,22 @@ async function buildFinanceDashboard(
|
||||
const previousSnapshotLines = previousCycle
|
||||
? await dependencies.repository.getSettlementSnapshotLines(previousCycle.id)
|
||||
: []
|
||||
const overduePaymentsByMemberId = await computeMemberOverduePayments({
|
||||
dependencies,
|
||||
currentCycle: cycle,
|
||||
members,
|
||||
memberAbsencePolicies,
|
||||
settings
|
||||
})
|
||||
const [overduePaymentsByMemberId, paymentPeriods] = await Promise.all([
|
||||
computeMemberOverduePayments({
|
||||
dependencies,
|
||||
currentCycle: cycle,
|
||||
members,
|
||||
memberAbsencePolicies,
|
||||
settings
|
||||
}),
|
||||
buildPaymentPeriodSummaries({
|
||||
dependencies,
|
||||
currentCycle: cycle,
|
||||
members,
|
||||
memberAbsencePolicies,
|
||||
settings
|
||||
})
|
||||
])
|
||||
const previousUtilityShareByMemberId = new Map(
|
||||
previousSnapshotLines.map((line) => [
|
||||
line.memberId,
|
||||
@@ -1061,6 +1288,7 @@ async function buildFinanceDashboard(
|
||||
rentFxRateMicros: convertedRent.fxRateMicros,
|
||||
rentFxEffectiveDate: convertedRent.fxEffectiveDate,
|
||||
members: dashboardMembers,
|
||||
paymentPeriods,
|
||||
ledger
|
||||
}
|
||||
}
|
||||
@@ -1095,7 +1323,8 @@ async function allocatePaymentPurchaseOverage(input: {
|
||||
}
|
||||
|
||||
const baseAmount = input.kind === 'rent' ? memberLine.rentShare : memberLine.utilityShare
|
||||
let remainingMinor = input.paymentAmount.amountMinor - baseAmount.amountMinor
|
||||
const baseThresholdMinor = roundSuggestedPaymentMinor(input.kind, baseAmount.amountMinor)
|
||||
let remainingMinor = input.paymentAmount.amountMinor - baseThresholdMinor
|
||||
if (remainingMinor <= 0n) {
|
||||
return []
|
||||
}
|
||||
@@ -1588,6 +1817,22 @@ export function createFinanceCommandService(
|
||||
|
||||
const currency = parseCurrency(currencyArg, settings.settlementCurrency)
|
||||
const amount = Money.fromMajor(amountArg, currency)
|
||||
|
||||
if (periodArg) {
|
||||
const explicitRemainingMinor = await getCycleKindBaseRemaining({
|
||||
dependencies,
|
||||
cycle: currentCycle,
|
||||
members,
|
||||
memberAbsencePolicies,
|
||||
settings,
|
||||
memberId,
|
||||
kind
|
||||
})
|
||||
if (explicitRemainingMinor === 0n) {
|
||||
throw new Error('Payment period is already settled')
|
||||
}
|
||||
}
|
||||
|
||||
const paymentTargets = periodArg
|
||||
? [
|
||||
{
|
||||
@@ -1606,6 +1851,15 @@ export function createFinanceCommandService(
|
||||
kind
|
||||
})
|
||||
|
||||
if (
|
||||
!periodArg &&
|
||||
paymentTargets.every(
|
||||
(target) => target.baseRemainingMinor <= 0n && target.cycle.id === currentCycle.id
|
||||
)
|
||||
) {
|
||||
throw new Error('Payment period is already settled')
|
||||
}
|
||||
|
||||
let remainingMinor = amount.amountMinor
|
||||
let firstPayment: Awaited<ReturnType<FinanceRepository['addPaymentRecord']>> | null = null
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -37,6 +37,20 @@ function adjustmentApplies(
|
||||
return (policy === 'utilities' && kind === 'utilities') || (policy === 'rent' && kind === 'rent')
|
||||
}
|
||||
|
||||
function roundSuggestedPayment(kind: 'rent' | 'utilities', amount: Money): Money {
|
||||
if (kind !== 'rent' || amount.amountMinor <= 0n) {
|
||||
return amount
|
||||
}
|
||||
|
||||
const wholeMinor = amount.amountMinor / 100n
|
||||
const remainderMinor = amount.amountMinor % 100n
|
||||
|
||||
return Money.fromMinor(
|
||||
(remainderMinor >= 50n ? wholeMinor + 1n : wholeMinor) * 100n,
|
||||
amount.currency
|
||||
)
|
||||
}
|
||||
|
||||
export function buildMemberPaymentGuidance(input: {
|
||||
kind: 'rent' | 'utilities'
|
||||
period: string
|
||||
@@ -48,9 +62,10 @@ export function buildMemberPaymentGuidance(input: {
|
||||
const baseAmount =
|
||||
input.kind === 'rent' ? input.memberLine.rentShare : input.memberLine.utilityShare
|
||||
const purchaseOffset = input.memberLine.purchaseOffset
|
||||
const proposalAmount = adjustmentApplies(policy, input.kind)
|
||||
? baseAmount.add(purchaseOffset)
|
||||
: baseAmount
|
||||
const proposalAmount = roundSuggestedPayment(
|
||||
input.kind,
|
||||
adjustmentApplies(policy, input.kind) ? baseAmount.add(purchaseOffset) : baseAmount
|
||||
)
|
||||
|
||||
const reminderDay =
|
||||
input.kind === 'rent' ? input.settings.rentWarningDay : input.settings.utilitiesReminderDay
|
||||
|
||||
50
packages/db/drizzle/0023_huge_vision.sql
Normal file
50
packages/db/drizzle/0023_huge_vision.sql
Normal file
@@ -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");
|
||||
3863
packages/db/drizzle/meta/0023_snapshot.json
Normal file
3863
packages/db/drizzle/meta/0023_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
76
packages/ports/src/notifications.ts
Normal file
76
packages/ports/src/notifications.ts
Normal file
@@ -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<AdHocNotificationRecord>
|
||||
getNotificationById(notificationId: string): Promise<AdHocNotificationRecord | null>
|
||||
listUpcomingNotificationsForHousehold(
|
||||
householdId: string,
|
||||
asOf: Instant
|
||||
): Promise<readonly AdHocNotificationRecord[]>
|
||||
cancelNotification(input: CancelAdHocNotificationInput): Promise<AdHocNotificationRecord | null>
|
||||
listDueNotifications(asOf: Instant): Promise<readonly AdHocNotificationRecord[]>
|
||||
markNotificationSent(
|
||||
notificationId: string,
|
||||
sentAt: Instant
|
||||
): Promise<AdHocNotificationRecord | null>
|
||||
claimNotificationDelivery(notificationId: string): Promise<ClaimAdHocNotificationDeliveryResult>
|
||||
releaseNotificationDelivery(notificationId: string): Promise<void>
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user