diff --git a/apps/bot/src/ad-hoc-notification-jobs.test.ts b/apps/bot/src/ad-hoc-notification-jobs.test.ts index f4001bd..509be5f 100644 --- a/apps/bot/src/ad-hoc-notification-jobs.test.ts +++ b/apps/bot/src/ad-hoc-notification-jobs.test.ts @@ -63,6 +63,7 @@ describe('createAdHocNotificationJobsHandler', () => { }, listUpcomingNotifications: async () => [], cancelNotification: async () => ({ status: 'not_found' }), + updateNotification: async () => ({ status: 'not_found' }), listDueNotifications: async () => [dueNotification()], claimDueNotification: async () => true, releaseDueNotification: async () => {}, diff --git a/apps/bot/src/ad-hoc-notifications.test.ts b/apps/bot/src/ad-hoc-notifications.test.ts index 3827469..3c7f125 100644 --- a/apps/bot/src/ad-hoc-notifications.test.ts +++ b/apps/bot/src/ad-hoc-notifications.test.ts @@ -316,6 +316,9 @@ describe('registerAdHocNotifications', () => { async cancelNotification() { return { status: 'not_found' } }, + async updateNotification() { + return { status: 'not_found' } + }, async listDueNotifications() { return [] }, @@ -415,6 +418,9 @@ describe('registerAdHocNotifications', () => { async cancelNotification() { return { status: 'not_found' } }, + async updateNotification() { + return { status: 'not_found' } + }, async listDueNotifications() { return [] }, @@ -497,6 +503,9 @@ describe('registerAdHocNotifications', () => { async cancelNotification() { return { status: 'not_found' } }, + async updateNotification() { + return { status: 'not_found' } + }, async listDueNotifications() { return [] }, diff --git a/apps/bot/src/ad-hoc-notifications.ts b/apps/bot/src/ad-hoc-notifications.ts index 78b5698..6ed0263 100644 --- a/apps/bot/src/ad-hoc-notifications.ts +++ b/apps/bot/src/ad-hoc-notifications.ts @@ -217,26 +217,53 @@ export function formatReminderWhen(input: { : formatScheduledFor(input.locale, input.scheduledForIso, input.timezone) } -function deliveryModeLabel(locale: BotLocale, mode: AdHocNotificationDeliveryMode): string { - if (locale === 'ru') { - switch (mode) { - case 'topic': - return 'в этот топик' - case 'dm_all': - return 'всем в личку' - case 'dm_selected': - return 'выбранным в личку' +function listedNotificationLine(input: { + locale: BotLocale + timezone: string + item: Awaited>[number] +}): string { + const when = formatReminderWhen({ + locale: input.locale, + scheduledForIso: input.item.scheduledFor.toString(), + timezone: input.timezone + }) + const details: string[] = [] + + if (input.item.assigneeDisplayName) { + details.push( + input.locale === 'ru' + ? `для ${input.item.assigneeDisplayName}` + : `for ${input.item.assigneeDisplayName}` + ) + } + + if (input.item.deliveryMode !== 'topic') { + if (input.item.deliveryMode === 'dm_all') { + details.push(input.locale === 'ru' ? 'всем в личку' : 'DM to everyone') + } else { + const names = input.item.dmRecipientDisplayNames.join(', ') + details.push( + input.locale === 'ru' + ? names.length > 0 + ? `в личку: ${names}` + : 'в выбранные лички' + : names.length > 0 + ? `DM: ${names}` + : 'DM selected members' + ) } } - switch (mode) { - case 'topic': - return 'this topic' - case 'dm_all': - return 'DM all members' - case 'dm_selected': - return 'DM selected members' + if (input.item.creatorDisplayName !== input.item.assigneeDisplayName) { + details.push( + input.locale === 'ru' + ? `создал ${input.item.creatorDisplayName}` + : `created by ${input.item.creatorDisplayName}` + ) } + + const suffix = details.length > 0 ? `\n${details.join(' · ')}` : '' + return `${when}\n${input.item.notificationText}${suffix}` } function notificationSummaryText(input: { @@ -635,36 +662,42 @@ export function registerAdHocNotifications(options: { await replyInTopic( ctx, locale === 'ru' - ? 'Пока нет будущих напоминаний, которые вы можете отменить.' - : 'There are no upcoming notifications you can cancel yet.' + ? 'Пока будущих напоминаний нет.' + : 'There are no upcoming notifications 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 listedItems = items.slice(0, 10).map((item, index) => ({ + item, + index + })) + const lines = listedItems.map( + ({ item, index }) => + `${index + 1}. ${listedNotificationLine({ + locale, + timezone: reminderContext.timezone, + item + })}` + ) 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}` - } - ]) + inline_keyboard: listedItems + .filter(({ item }) => item.canCancel) + .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' + '\n\n' ), - keyboard + keyboard.inline_keyboard.length > 0 ? keyboard : undefined ) }) diff --git a/apps/bot/src/app.ts b/apps/bot/src/app.ts index 4bc702b..3f01b8e 100644 --- a/apps/bot/src/app.ts +++ b/apps/bot/src/app.ts @@ -73,6 +73,10 @@ import { createMiniAppUpdateUtilityBillHandler } from './miniapp-billing' import { createMiniAppLocalePreferenceHandler } from './miniapp-locale' +import { + createMiniAppCancelNotificationHandler, + createMiniAppUpdateNotificationHandler +} from './miniapp-notifications' import { createNbgExchangeRateProvider } from './nbg-exchange-rates' import { createOpenAiChatAssistant } from './openai-chat-assistant' import { createOpenAiAdHocNotificationInterpreter } from './openai-ad-hoc-notification-interpreter' @@ -635,10 +639,31 @@ export async function createBotRuntimeApp(): Promise { allowedOrigins: runtime.miniAppAllowedOrigins, botToken: runtime.telegramBotToken, financeServiceForHousehold, + adHocNotificationService: adHocNotificationService!, onboardingService: householdOnboardingService, logger: getLogger('miniapp-dashboard') }) : undefined, + miniAppUpdateNotification: + householdOnboardingService && adHocNotificationService + ? createMiniAppUpdateNotificationHandler({ + allowedOrigins: runtime.miniAppAllowedOrigins, + botToken: runtime.telegramBotToken, + onboardingService: householdOnboardingService, + adHocNotificationService, + logger: getLogger('miniapp-notifications') + }) + : undefined, + miniAppCancelNotification: + householdOnboardingService && adHocNotificationService + ? createMiniAppCancelNotificationHandler({ + allowedOrigins: runtime.miniAppAllowedOrigins, + botToken: runtime.telegramBotToken, + onboardingService: householdOnboardingService, + adHocNotificationService, + logger: getLogger('miniapp-notifications') + }) + : undefined, miniAppPendingMembers: householdOnboardingService ? createMiniAppPendingMembersHandler({ allowedOrigins: runtime.miniAppAllowedOrigins, diff --git a/apps/bot/src/miniapp-dashboard.test.ts b/apps/bot/src/miniapp-dashboard.test.ts index cf44f6f..1501254 100644 --- a/apps/bot/src/miniapp-dashboard.test.ts +++ b/apps/bot/src/miniapp-dashboard.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'bun:test' import { + type AdHocNotificationService, createFinanceCommandService, createHouseholdOnboardingService } from '@household/application' @@ -314,6 +315,23 @@ function onboardingRepository(): HouseholdConfigurationRepository { } } +function notificationService( + items: Awaited> = [] +): AdHocNotificationService { + return { + scheduleNotification: async () => { + throw new Error('not implemented') + }, + listUpcomingNotifications: async () => items, + cancelNotification: async () => ({ status: 'not_found' }), + updateNotification: async () => ({ status: 'not_found' }), + listDueNotifications: async () => [], + claimDueNotification: async () => false, + releaseDueNotification: async () => {}, + markNotificationSent: async () => null + } +} + describe('createMiniAppDashboardHandler', () => { test('returns a dashboard for an authenticated household member', async () => { const authDate = Math.floor(Date.now() / 1000) @@ -344,10 +362,28 @@ describe('createMiniAppDashboardHandler', () => { isAdmin: true } ] + const adHocNotificationService = notificationService([ + { + id: 'notification-1', + creatorMemberId: 'member-1', + creatorDisplayName: 'Stan', + assigneeMemberId: null, + assigneeDisplayName: null, + notificationText: 'Stan, breakfast time.', + scheduledFor: instantFromIso('2026-03-25T06:00:00.000Z'), + deliveryMode: 'topic', + dmRecipientMemberIds: [], + dmRecipientDisplayNames: [], + status: 'scheduled', + canCancel: true, + canEdit: true + } + ]) const dashboard = createMiniAppDashboardHandler({ allowedOrigins: ['http://localhost:5173'], botToken: 'test-bot-token', financeServiceForHousehold: () => financeService, + adHocNotificationService, onboardingService: createHouseholdOnboardingService({ repository: householdRepository }) @@ -415,6 +451,17 @@ describe('createMiniAppDashboardHandler', () => { currency: 'GEL', displayCurrency: 'GEL' } + ], + notifications: [ + { + id: 'notification-1', + summaryText: 'Stan, breakfast time.', + deliveryMode: 'topic', + dmRecipientMemberIds: [], + creatorDisplayName: 'Stan', + canCancel: true, + canEdit: true + } ] } }) @@ -550,6 +597,7 @@ describe('createMiniAppDashboardHandler', () => { allowedOrigins: ['http://localhost:5173'], botToken: 'test-bot-token', financeServiceForHousehold: () => financeService, + adHocNotificationService: notificationService(), onboardingService: createHouseholdOnboardingService({ repository: householdRepository }) @@ -644,6 +692,7 @@ describe('createMiniAppDashboardHandler', () => { allowedOrigins: ['http://localhost:5173'], botToken: 'test-bot-token', financeServiceForHousehold: () => financeService, + adHocNotificationService: notificationService(), onboardingService: createHouseholdOnboardingService({ repository: householdRepository }) @@ -706,6 +755,7 @@ describe('createMiniAppDashboardHandler', () => { allowedOrigins: ['http://localhost:5173'], botToken: 'test-bot-token', financeServiceForHousehold: () => financeService, + adHocNotificationService: notificationService(), onboardingService: createHouseholdOnboardingService({ repository: householdRepository }) diff --git a/apps/bot/src/miniapp-dashboard.ts b/apps/bot/src/miniapp-dashboard.ts index 6839438..6c72176 100644 --- a/apps/bot/src/miniapp-dashboard.ts +++ b/apps/bot/src/miniapp-dashboard.ts @@ -1,4 +1,8 @@ -import type { FinanceCommandService, HouseholdOnboardingService } from '@household/application' +import type { + AdHocNotificationService, + FinanceCommandService, + HouseholdOnboardingService +} from '@household/application' import { Money } from '@household/domain' import type { Logger } from '@household/observability' @@ -14,6 +18,7 @@ export function createMiniAppDashboardHandler(options: { allowedOrigins: readonly string[] botToken: string financeServiceForHousehold: (householdId: string) => FinanceCommandService + adHocNotificationService: AdHocNotificationService onboardingService: HouseholdOnboardingService logger?: Logger }): { @@ -74,6 +79,10 @@ export function createMiniAppDashboardHandler(options: { const dashboard = await options .financeServiceForHousehold(session.member.householdId) .generateDashboard() + const notifications = await options.adHocNotificationService.listUpcomingNotifications({ + householdId: session.member.householdId, + viewerMemberId: session.member.id + }) if (!dashboard) { return miniAppJsonResponse( { ok: false, error: 'No billing cycle available' }, @@ -178,6 +187,21 @@ export function createMiniAppDashboardHandler(options: { })) ?? [] } : {}) + })), + notifications: notifications.map((notification) => ({ + id: notification.id, + summaryText: notification.notificationText, + scheduledFor: notification.scheduledFor.toString(), + status: notification.status, + deliveryMode: notification.deliveryMode, + dmRecipientMemberIds: notification.dmRecipientMemberIds, + dmRecipientDisplayNames: notification.dmRecipientDisplayNames, + creatorMemberId: notification.creatorMemberId, + creatorDisplayName: notification.creatorDisplayName, + assigneeMemberId: notification.assigneeMemberId, + assigneeDisplayName: notification.assigneeDisplayName, + canCancel: notification.canCancel, + canEdit: notification.canEdit })) } }, diff --git a/apps/bot/src/miniapp-notifications.ts b/apps/bot/src/miniapp-notifications.ts new file mode 100644 index 0000000..f444930 --- /dev/null +++ b/apps/bot/src/miniapp-notifications.ts @@ -0,0 +1,216 @@ +import type { AdHocNotificationService, HouseholdOnboardingService } from '@household/application' +import { Temporal } from '@household/domain' +import type { Logger } from '@household/observability' +import type { AdHocNotificationDeliveryMode } from '@household/ports' + +import { + allowedMiniAppOrigin, + createMiniAppSessionService, + miniAppErrorResponse, + miniAppJsonResponse, + readMiniAppRequestPayload, + type MiniAppSessionResult +} from './miniapp-auth' + +async function authenticateMemberSession( + request: Request, + sessionService: ReturnType, + origin: string | undefined +): Promise< + | Response + | { + member: NonNullable + } +> { + const payload = await readMiniAppRequestPayload(request) + if (!payload.initData) { + return miniAppJsonResponse({ ok: false, error: 'Missing initData' }, 400, origin) + } + + const session = await sessionService.authenticate(payload) + if (!session) { + return miniAppJsonResponse({ ok: false, error: 'Invalid Telegram init data' }, 401, origin) + } + + if (!session.authorized || !session.member || session.member.status !== 'active') { + return miniAppJsonResponse( + { ok: false, error: 'Access limited to active household members' }, + 403, + origin + ) + } + + return { + member: session.member + } +} + +async function parseJsonBody(request: Request): Promise { + const text = await request.clone().text() + if (text.trim().length === 0) { + return {} as T + } + + try { + return JSON.parse(text) as T + } catch { + throw new Error('Invalid JSON body') + } +} + +function parseScheduledLocal(localValue: string, timezone: string): Temporal.Instant { + return Temporal.ZonedDateTime.from(`${localValue}[${timezone}]`).toInstant() +} + +function isDeliveryMode(value: string | undefined): value is AdHocNotificationDeliveryMode { + return value === 'topic' || value === 'dm_all' || value === 'dm_selected' +} + +export function createMiniAppUpdateNotificationHandler(options: { + allowedOrigins: readonly string[] + botToken: string + onboardingService: HouseholdOnboardingService + adHocNotificationService: AdHocNotificationService + logger?: Logger +}): { + handler: (request: Request) => Promise +} { + const sessionService = createMiniAppSessionService({ + botToken: options.botToken, + onboardingService: options.onboardingService + }) + + return { + handler: async (request) => { + const origin = allowedMiniAppOrigin(request, options.allowedOrigins) + + if (request.method === 'OPTIONS') { + return miniAppJsonResponse({ ok: true }, 204, origin) + } + + if (request.method !== 'POST') { + return miniAppJsonResponse({ ok: false, error: 'Method Not Allowed' }, 405, origin) + } + + try { + const auth = await authenticateMemberSession(request, sessionService, origin) + if (auth instanceof Response) { + return auth + } + + const parsed = await parseJsonBody<{ + notificationId?: string + scheduledLocal?: string + timezone?: string + deliveryMode?: string + dmRecipientMemberIds?: string[] + }>(request) + + const notificationId = parsed.notificationId?.trim() + if (!notificationId) { + return miniAppJsonResponse({ ok: false, error: 'Missing notificationId' }, 400, origin) + } + + const scheduledLocal = parsed.scheduledLocal?.trim() + const timezone = parsed.timezone?.trim() + const deliveryMode = parsed.deliveryMode?.trim() + + const result = await options.adHocNotificationService.updateNotification({ + notificationId, + viewerMemberId: auth.member.id, + ...(scheduledLocal && timezone + ? { + scheduledFor: parseScheduledLocal(scheduledLocal, timezone), + timePrecision: 'exact' as const + } + : {}), + ...(deliveryMode && isDeliveryMode(deliveryMode) + ? { + deliveryMode, + dmRecipientMemberIds: parsed.dmRecipientMemberIds ?? [] + } + : {}) + }) + + switch (result.status) { + case 'updated': + return miniAppJsonResponse({ ok: true, authorized: true }, 200, origin) + case 'invalid': + return miniAppJsonResponse({ ok: false, error: result.reason }, 400, origin) + case 'forbidden': + return miniAppJsonResponse({ ok: false, error: 'Forbidden' }, 403, origin) + case 'not_found': + return miniAppJsonResponse({ ok: false, error: 'Notification not found' }, 404, origin) + case 'already_handled': + case 'past_due': + return miniAppJsonResponse({ ok: false, error: result.status }, 409, origin) + } + } catch (error) { + return miniAppErrorResponse(error, origin, options.logger) + } + } + } +} + +export function createMiniAppCancelNotificationHandler(options: { + allowedOrigins: readonly string[] + botToken: string + onboardingService: HouseholdOnboardingService + adHocNotificationService: AdHocNotificationService + logger?: Logger +}): { + handler: (request: Request) => Promise +} { + const sessionService = createMiniAppSessionService({ + botToken: options.botToken, + onboardingService: options.onboardingService + }) + + return { + handler: async (request) => { + const origin = allowedMiniAppOrigin(request, options.allowedOrigins) + + if (request.method === 'OPTIONS') { + return miniAppJsonResponse({ ok: true }, 204, origin) + } + + if (request.method !== 'POST') { + return miniAppJsonResponse({ ok: false, error: 'Method Not Allowed' }, 405, origin) + } + + try { + const auth = await authenticateMemberSession(request, sessionService, origin) + if (auth instanceof Response) { + return auth + } + + const parsed = await parseJsonBody<{ + notificationId?: string + }>(request) + const notificationId = parsed.notificationId?.trim() + if (!notificationId) { + return miniAppJsonResponse({ ok: false, error: 'Missing notificationId' }, 400, origin) + } + + const result = await options.adHocNotificationService.cancelNotification({ + notificationId, + viewerMemberId: auth.member.id + }) + + switch (result.status) { + case 'cancelled': + return miniAppJsonResponse({ ok: true, authorized: true }, 200, origin) + case 'forbidden': + return miniAppJsonResponse({ ok: false, error: 'Forbidden' }, 403, origin) + case 'not_found': + return miniAppJsonResponse({ ok: false, error: 'Notification not found' }, 404, origin) + case 'already_handled': + case 'past_due': + return miniAppJsonResponse({ ok: false, error: result.status }, 409, origin) + } + } catch (error) { + return miniAppErrorResponse(error, origin, options.logger) + } + } + } +} diff --git a/apps/bot/src/server.ts b/apps/bot/src/server.ts index ebad101..4b31519 100644 --- a/apps/bot/src/server.ts +++ b/apps/bot/src/server.ts @@ -14,6 +14,18 @@ export interface BotWebhookServerOptions { handler: (request: Request) => Promise } | undefined + miniAppUpdateNotification?: + | { + path?: string + handler: (request: Request) => Promise + } + | undefined + miniAppCancelNotification?: + | { + path?: string + handler: (request: Request) => Promise + } + | undefined miniAppJoin?: | { path?: string @@ -226,6 +238,10 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): { : `/${options.webhookPath}` const miniAppAuthPath = options.miniAppAuth?.path ?? '/api/miniapp/session' const miniAppDashboardPath = options.miniAppDashboard?.path ?? '/api/miniapp/dashboard' + const miniAppUpdateNotificationPath = + options.miniAppUpdateNotification?.path ?? '/api/miniapp/notifications/update' + const miniAppCancelNotificationPath = + options.miniAppCancelNotification?.path ?? '/api/miniapp/notifications/cancel' const miniAppJoinPath = options.miniAppJoin?.path ?? '/api/miniapp/join' const miniAppPendingMembersPath = options.miniAppPendingMembers?.path ?? '/api/miniapp/admin/pending-members' @@ -301,6 +317,14 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): { return await options.miniAppDashboard.handler(request) } + if (options.miniAppUpdateNotification && url.pathname === miniAppUpdateNotificationPath) { + return await options.miniAppUpdateNotification.handler(request) + } + + if (options.miniAppCancelNotification && url.pathname === miniAppCancelNotificationPath) { + return await options.miniAppCancelNotification.handler(request) + } + if (options.miniAppJoin && url.pathname === miniAppJoinPath) { return await options.miniAppJoin.handler(request) } diff --git a/apps/miniapp/src/components/ui/input.tsx b/apps/miniapp/src/components/ui/input.tsx index e2a618a..cefd2b5 100644 --- a/apps/miniapp/src/components/ui/input.tsx +++ b/apps/miniapp/src/components/ui/input.tsx @@ -5,7 +5,7 @@ import { cn } from '../../lib/cn' type InputProps = { value?: string placeholder?: string - type?: 'text' | 'number' | 'email' + type?: 'text' | 'number' | 'email' | 'datetime-local' min?: string | number max?: string | number step?: string | number diff --git a/apps/miniapp/src/demo/miniapp-demo.ts b/apps/miniapp/src/demo/miniapp-demo.ts index 2571336..0a64332 100644 --- a/apps/miniapp/src/demo/miniapp-demo.ts +++ b/apps/miniapp/src/demo/miniapp-demo.ts @@ -450,7 +450,39 @@ function createDashboard(state: { rentFxEffectiveDate: '2026-03-17', members: state.members, paymentPeriods, - ledger: state.ledger ?? baseLedger() + ledger: state.ledger ?? baseLedger(), + notifications: [ + { + id: 'notification-breakfast', + summaryText: 'Stas, breakfast is waiting for your attention.', + scheduledFor: '2026-03-25T05:00:00.000Z', + status: 'scheduled', + deliveryMode: 'topic', + dmRecipientMemberIds: [], + dmRecipientDisplayNames: [], + creatorMemberId: 'demo-member', + creatorDisplayName: 'Stas', + assigneeMemberId: 'demo-member', + assigneeDisplayName: 'Stas', + canCancel: true, + canEdit: true + }, + { + id: 'notification-call-georgiy', + summaryText: 'Dima, time to check whether Georgiy has called back.', + scheduledFor: '2026-03-25T16:00:00.000Z', + status: 'scheduled', + deliveryMode: 'dm_selected', + dmRecipientMemberIds: ['member-chorb', 'demo-member'], + dmRecipientDisplayNames: ['Dima', 'Stas'], + creatorMemberId: 'member-chorb', + creatorDisplayName: 'Chorbanaut', + assigneeMemberId: null, + assigneeDisplayName: null, + canCancel: true, + canEdit: true + } + ] } } diff --git a/apps/miniapp/src/miniapp-api.ts b/apps/miniapp/src/miniapp-api.ts index 03650c0..c0a77ed 100644 --- a/apps/miniapp/src/miniapp-api.ts +++ b/apps/miniapp/src/miniapp-api.ts @@ -187,6 +187,21 @@ export interface MiniAppDashboard { }[] payerMemberId?: string }[] + notifications: { + id: string + summaryText: string + scheduledFor: string + status: 'scheduled' | 'sent' | 'cancelled' + deliveryMode: 'topic' | 'dm_all' | 'dm_selected' + dmRecipientMemberIds: readonly string[] + dmRecipientDisplayNames: readonly string[] + creatorMemberId: string + creatorDisplayName: string + assigneeMemberId: string | null + assigneeDisplayName: string | null + canCancel: boolean + canEdit: boolean + }[] } export interface MiniAppAdminSettingsPayload { @@ -339,6 +354,64 @@ export async function fetchMiniAppDashboard(initData: string): Promise { + const response = await fetch(`${apiBaseUrl()}/api/miniapp/notifications/update`, { + method: 'POST', + headers: { + 'content-type': 'application/json' + }, + body: JSON.stringify({ + initData, + ...input + }) + }) + + const payload = (await response.json()) as { + ok: boolean + authorized?: boolean + error?: string + } + + if (!response.ok || !payload.authorized) { + throw new Error(payload.error ?? 'Failed to update notification') + } +} + +export async function cancelMiniAppNotification( + initData: string, + notificationId: string +): Promise { + const response = await fetch(`${apiBaseUrl()}/api/miniapp/notifications/cancel`, { + method: 'POST', + headers: { + 'content-type': 'application/json' + }, + body: JSON.stringify({ + initData, + notificationId + }) + }) + + const payload = (await response.json()) as { + ok: boolean + authorized?: boolean + error?: string + } + + if (!response.ok || !payload.authorized) { + throw new Error(payload.error ?? 'Failed to cancel notification') + } +} + export async function fetchMiniAppPendingMembers( initData: string ): Promise { diff --git a/apps/miniapp/src/routes/home.tsx b/apps/miniapp/src/routes/home.tsx index ad879c2..6f074c4 100644 --- a/apps/miniapp/src/routes/home.tsx +++ b/apps/miniapp/src/routes/home.tsx @@ -23,7 +23,12 @@ import { nextCyclePeriod, parseCalendarDate } from '../lib/dates' -import { submitMiniAppUtilityBill, addMiniAppPayment } from '../miniapp-api' +import { + submitMiniAppUtilityBill, + addMiniAppPayment, + updateMiniAppNotification, + cancelMiniAppNotification +} from '../miniapp-api' import type { MiniAppDashboard } from '../miniapp-api' function sumMemberPaymentsByKind( @@ -76,6 +81,117 @@ function paymentRemainingMinor( return remainingMinor > 0n ? remainingMinor : 0n } +function zonedDateTimeParts(date: Date, timeZone: string) { + const parts = new Intl.DateTimeFormat('en-CA', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hourCycle: 'h23' + }).formatToParts(date) + + const read = (type: string) => Number(parts.find((part) => part.type === type)?.value ?? '0') + + return { + year: read('year'), + month: read('month'), + day: read('day'), + hour: read('hour'), + minute: read('minute') + } +} + +function dateKey(input: { year: number; month: number; day: number }) { + return [ + String(input.year).padStart(4, '0'), + String(input.month).padStart(2, '0'), + String(input.day).padStart(2, '0') + ].join('-') +} + +function shiftDateKey(currentKey: string, days: number): string { + const [yearText = '1970', monthText = '01', dayText = '01'] = currentKey.split('-') + const year = Number(yearText) + const month = Number(monthText) + const day = Number(dayText) + const shifted = new Date(Date.UTC(year, month - 1, day + days)) + return [ + shifted.getUTCFullYear(), + String(shifted.getUTCMonth() + 1).padStart(2, '0'), + String(shifted.getUTCDate()).padStart(2, '0') + ].join('-') +} + +function formatNotificationTimeOfDay(locale: 'en' | 'ru', hour: number, minute: number) { + const exact = `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}` + if (locale !== 'ru' || minute !== 0) { + return locale === 'ru' ? `в ${exact}` : `at ${exact}` + } + + if (hour >= 5 && hour <= 11) return `в ${hour} утра` + if (hour >= 12 && hour <= 16) return hour === 12 ? 'в 12 дня' : `в ${hour} дня` + if (hour >= 17 && hour <= 23) return `в ${hour > 12 ? hour - 12 : hour} вечера` + return `в ${hour} ночи` +} + +function formatNotificationWhen( + locale: 'en' | 'ru', + scheduledForIso: string, + timeZone: string +): string { + const now = zonedDateTimeParts(new Date(), timeZone) + const target = zonedDateTimeParts(new Date(scheduledForIso), timeZone) + const nowKey = dateKey(now) + const sleepAwareBaseKey = now.hour <= 4 ? shiftDateKey(nowKey, -1) : nowKey + const targetKey = dateKey(target) + const timeText = formatNotificationTimeOfDay(locale, target.hour, target.minute) + + if (targetKey === sleepAwareBaseKey) { + return locale === 'ru' ? `Сегодня ${timeText}` : `Today ${timeText}` + } + if (targetKey === shiftDateKey(sleepAwareBaseKey, 1)) { + return locale === 'ru' ? `Завтра ${timeText}` : `Tomorrow ${timeText}` + } + if (targetKey === shiftDateKey(sleepAwareBaseKey, 2)) { + return locale === 'ru' ? `Послезавтра ${timeText}` : `The day after tomorrow ${timeText}` + } + + const dateText = + locale === 'ru' + ? `${String(target.day).padStart(2, '0')}.${String(target.month).padStart(2, '0')}.${target.year}` + : `${target.year}-${String(target.month).padStart(2, '0')}-${String(target.day).padStart(2, '0')}` + + return `${dateText} ${timeText}` +} + +function formatNotificationDelivery( + locale: 'en' | 'ru', + notification: MiniAppDashboard['notifications'][number] +) { + if (notification.deliveryMode === 'topic') { + return locale === 'ru' ? 'В этот топик' : 'This topic' + } + + if (notification.deliveryMode === 'dm_all') { + return locale === 'ru' ? 'Всем в личку' : 'DM to everyone' + } + + return locale === 'ru' + ? notification.dmRecipientDisplayNames.length > 0 + ? `В личку: ${notification.dmRecipientDisplayNames.join(', ')}` + : 'В выбранные лички' + : notification.dmRecipientDisplayNames.length > 0 + ? `DM: ${notification.dmRecipientDisplayNames.join(', ')}` + : 'DM selected members' +} + +function notificationInputValue(iso: string, timeZone: string) { + const target = zonedDateTimeParts(new Date(iso), timeZone) + return `${dateKey(target)}T${String(target.hour).padStart(2, '0')}:${String(target.minute).padStart(2, '0')}` +} + export default function HomeRoute() { const navigate = useNavigate() const { readySession, initData, refreshHouseholdData } = useSession() @@ -104,12 +220,30 @@ export default function HomeRoute() { ) const [quickPaymentAmount, setQuickPaymentAmount] = createSignal('') const [submittingPayment, setSubmittingPayment] = createSignal(false) + const [notificationEditorOpen, setNotificationEditorOpen] = createSignal(false) + const [editingNotificationId, setEditingNotificationId] = createSignal(null) + const [notificationScheduleDraft, setNotificationScheduleDraft] = createSignal('') + const [notificationDeliveryModeDraft, setNotificationDeliveryModeDraft] = createSignal< + 'topic' | 'dm_all' | 'dm_selected' + >('topic') + const [notificationRecipientsDraft, setNotificationRecipientsDraft] = createSignal([]) + const [savingNotification, setSavingNotification] = createSignal(false) + const [cancellingNotificationId, setCancellingNotificationId] = createSignal(null) const [toastState, setToastState] = createSignal<{ visible: boolean message: string type: 'success' | 'info' | 'error' }>({ visible: false, message: '', type: 'info' }) + const selectedNotification = createMemo( + () => + dashboard()?.notifications.find( + (notification) => notification.id === editingNotificationId() + ) ?? null + ) + + const activeHouseholdMembers = createMemo(() => dashboard()?.members ?? []) + async function copyText(value: string): Promise { try { await navigator.clipboard.writeText(value) @@ -331,6 +465,94 @@ export default function HomeRoute() { } } + function openNotificationEditor(notification: MiniAppDashboard['notifications'][number]) { + const data = dashboard() + if (!data) return + + setEditingNotificationId(notification.id) + setNotificationScheduleDraft(notificationInputValue(notification.scheduledFor, data.timezone)) + setNotificationDeliveryModeDraft(notification.deliveryMode) + setNotificationRecipientsDraft( + notification.deliveryMode === 'dm_selected' ? [...notification.dmRecipientMemberIds] : [] + ) + setNotificationEditorOpen(true) + } + + function toggleNotificationRecipient(memberId: string) { + setNotificationRecipientsDraft((current) => + current.includes(memberId) + ? current.filter((value) => value !== memberId) + : [...current, memberId] + ) + } + + async function handleNotificationSave() { + const data = initData() + const current = dashboard() + const notification = selectedNotification() + if (!data || !current || !notification || !notification.canEdit || savingNotification()) return + + setSavingNotification(true) + try { + await updateMiniAppNotification(data, { + notificationId: notification.id, + scheduledLocal: notificationScheduleDraft(), + timezone: current.timezone, + deliveryMode: notificationDeliveryModeDraft(), + dmRecipientMemberIds: + notificationDeliveryModeDraft() === 'dm_selected' ? notificationRecipientsDraft() : [] + }) + setNotificationEditorOpen(false) + setToastState({ + visible: true, + message: locale() === 'ru' ? 'Напоминание обновлено.' : 'Notification updated.', + type: 'success' + }) + await refreshHouseholdData(true, true) + } catch { + setToastState({ + visible: true, + message: + locale() === 'ru' + ? 'Не получилось обновить напоминание.' + : 'Failed to update notification.', + type: 'error' + }) + } finally { + setSavingNotification(false) + } + } + + async function handleNotificationCancel(notificationId: string) { + const data = initData() + if (!data || cancellingNotificationId()) return + + setCancellingNotificationId(notificationId) + try { + await cancelMiniAppNotification(data, notificationId) + if (editingNotificationId() === notificationId) { + setNotificationEditorOpen(false) + } + setToastState({ + visible: true, + message: locale() === 'ru' ? 'Напоминание отменено.' : 'Notification cancelled.', + type: 'success' + }) + await refreshHouseholdData(true, true) + } catch { + setToastState({ + visible: true, + message: + locale() === 'ru' + ? 'Не получилось отменить напоминание.' + : 'Failed to cancel notification.', + type: 'error' + }) + } finally { + setCancellingNotificationId(null) + } + } + return (
{/* ── Welcome hero ────────────────────────────── */} @@ -918,6 +1140,105 @@ export default function HomeRoute() { + +
+
+ + {locale() === 'ru' ? 'Напоминания' : 'Notifications'} + + {data().notifications.length} +
+ 0} + fallback={ +

+ {locale() === 'ru' + ? 'Пока нет запланированных напоминаний.' + : 'There are no scheduled notifications yet.'} +

+ } + > +
+ + {(notification) => ( +
+
+
+ {notification.summaryText} + + {formatNotificationWhen( + locale(), + notification.scheduledFor, + data().timezone + )} + + {formatNotificationDelivery(locale(), notification)} + + + {(locale() === 'ru' ? 'Для: ' : 'For: ') + + notification.assigneeDisplayName} + + + + {(locale() === 'ru' ? 'Создал: ' : 'Created by: ') + + notification.creatorDisplayName} + +
+
+ + + + + + +
+
+
+ )} +
+
+
+
+
+ {/* Latest activity */}
@@ -972,6 +1293,122 @@ export default function HomeRoute() { + { + setNotificationEditorOpen(false) + }} + footer={ + <> + + + + + + + } + > +
+ + setNotificationScheduleDraft(event.currentTarget.value)} + /> + + + +
+ + + +
+
+ + + +
+ + {(member) => ( + + )} + +
+
+
+
+
+ {/* Quick Payment Modal */} = { + updatedAt: instantToDate(input.updatedAt) + } + + if (input.scheduledFor) { + updates.scheduledFor = instantToDate(input.scheduledFor) + } + if (input.timePrecision) { + updates.timePrecision = input.timePrecision + } + if (input.deliveryMode) { + updates.deliveryMode = input.deliveryMode + } + if (input.dmRecipientMemberIds) { + updates.dmRecipientMemberIds = input.dmRecipientMemberIds + } + + const rows = await db + .update(schema.adHocNotifications) + .set(updates) + .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()) diff --git a/packages/application/src/ad-hoc-notification-service.test.ts b/packages/application/src/ad-hoc-notification-service.test.ts index 13d9832..fcf26e8 100644 --- a/packages/application/src/ad-hoc-notification-service.test.ts +++ b/packages/application/src/ad-hoc-notification-service.test.ts @@ -79,6 +79,31 @@ class NotificationRepositoryStub implements AdHocNotificationRepository { return next } + async updateNotification(input: { + notificationId: string + scheduledFor?: Temporal.Instant + timePrecision?: AdHocNotificationRecord['timePrecision'] + deliveryMode?: AdHocNotificationRecord['deliveryMode'] + dmRecipientMemberIds?: readonly string[] + updatedAt: Temporal.Instant + }): Promise { + const record = this.notifications.get(input.notificationId) + if (!record || record.status !== 'scheduled') { + return null + } + + const next = { + ...record, + scheduledFor: input.scheduledFor ?? record.scheduledFor, + timePrecision: input.timePrecision ?? record.timePrecision, + deliveryMode: input.deliveryMode ?? record.deliveryMode, + dmRecipientMemberIds: input.dmRecipientMemberIds ?? record.dmRecipientMemberIds, + updatedAt: input.updatedAt + } + this.notifications.set(input.notificationId, next) + return next + } + async listDueNotifications(asOf: Temporal.Instant): Promise { return [...this.notifications.values()].filter( (notification) => @@ -265,4 +290,79 @@ describe('createAdHocNotificationService', () => { expect(result.notification.cancelledByMemberId).toBe('admin') } }) + + test('lists upcoming notifications for all household members with permission flags', async () => { + const repository = new NotificationRepositoryStub() + const creator = member({ id: 'creator' }) + const viewer = member({ id: 'viewer' }) + const service = createAdHocNotificationService({ + repository, + householdConfigurationRepository: createHouseholdRepository([creator, viewer]) + }) + + 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 items = await service.listUpcomingNotifications({ + householdId: 'household-1', + viewerMemberId: 'viewer', + asOf: Temporal.Instant.from('2026-03-23T09:00:00Z') + }) + + expect(items).toHaveLength(1) + expect(items[0]).toMatchObject({ + creatorDisplayName: 'creator', + canCancel: false, + canEdit: false + }) + }) + + test('allows creator to reschedule and update delivery', async () => { + const repository = new NotificationRepositoryStub() + const creator = member({ id: 'creator' }) + const alice = member({ id: 'alice' }) + const bob = member({ id: 'bob' }) + const service = createAdHocNotificationService({ + repository, + householdConfigurationRepository: createHouseholdRepository([creator, alice, bob]) + }) + + 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.updateNotification({ + notificationId: created.id, + viewerMemberId: 'creator', + scheduledFor: Temporal.Instant.from('2026-03-24T09:00:00Z'), + timePrecision: 'exact', + deliveryMode: 'dm_selected', + dmRecipientMemberIds: ['alice', 'bob'], + asOf: Temporal.Instant.from('2026-03-23T09:00:00Z') + }) + + expect(result.status).toBe('updated') + if (result.status === 'updated') { + expect(result.notification.scheduledFor.toString()).toBe('2026-03-24T09:00:00Z') + expect(result.notification.deliveryMode).toBe('dm_selected') + expect(result.notification.dmRecipientMemberIds).toEqual(['alice', 'bob']) + } + }) }) diff --git a/packages/application/src/ad-hoc-notification-service.ts b/packages/application/src/ad-hoc-notification-service.ts index 42d6e27..92b6f61 100644 --- a/packages/application/src/ad-hoc-notification-service.ts +++ b/packages/application/src/ad-hoc-notification-service.ts @@ -24,11 +24,16 @@ export interface AdHocNotificationSummary { id: string notificationText: string scheduledFor: Instant + status: 'scheduled' | 'sent' | 'cancelled' deliveryMode: AdHocNotificationDeliveryMode - friendlyTagAssignee: boolean + dmRecipientMemberIds: readonly string[] + dmRecipientDisplayNames: readonly string[] creatorDisplayName: string + creatorMemberId: string assigneeDisplayName: string | null + assigneeMemberId: string | null canCancel: boolean + canEdit: boolean } export interface DeliverableAdHocNotification { @@ -63,6 +68,19 @@ export type CancelAdHocNotificationResult = status: 'not_found' | 'forbidden' | 'already_handled' | 'past_due' } +export type UpdateAdHocNotificationResult = + | { + status: 'updated' + notification: AdHocNotificationRecord + } + | { + status: 'not_found' | 'forbidden' | 'already_handled' | 'past_due' + } + | { + status: 'invalid' + reason: 'delivery_mode_invalid' | 'dm_recipients_missing' | 'scheduled_for_past' + } + export interface AdHocNotificationService { scheduleNotification(input: { householdId: string @@ -89,6 +107,15 @@ export interface AdHocNotificationService { viewerMemberId: string asOf?: Instant }): Promise + updateNotification(input: { + notificationId: string + viewerMemberId: string + scheduledFor?: Instant + timePrecision?: AdHocNotificationTimePrecision + deliveryMode?: AdHocNotificationDeliveryMode + dmRecipientMemberIds?: readonly string[] + asOf?: Instant + }): Promise listDueNotifications(asOf?: Instant): Promise claimDueNotification(notificationId: string): Promise releaseDueNotification(notificationId: string): Promise @@ -125,6 +152,13 @@ function canCancelNotification( return actor.isAdmin || notification.creatorMemberId === actor.memberId } +function canEditNotification( + notification: AdHocNotificationRecord, + actor: NotificationActor +): boolean { + return canCancelNotification(notification, actor) +} + export function createAdHocNotificationService(input: { repository: AdHocNotificationRepository householdConfigurationRepository: Pick< @@ -256,23 +290,27 @@ export function createAdHocNotificationService(input: { 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) - })) + return notifications.map((notification) => ({ + id: notification.id, + notificationText: notification.notificationText, + scheduledFor: notification.scheduledFor, + status: notification.status, + deliveryMode: notification.deliveryMode, + dmRecipientMemberIds: notification.dmRecipientMemberIds, + dmRecipientDisplayNames: notification.dmRecipientMemberIds.map( + (memberId) => memberMap.get(memberId)?.displayName ?? memberId + ), + creatorDisplayName: + memberMap.get(notification.creatorMemberId)?.displayName ?? notification.creatorMemberId, + creatorMemberId: notification.creatorMemberId, + assigneeDisplayName: notification.assigneeMemberId + ? (memberMap.get(notification.assigneeMemberId)?.displayName ?? + notification.assigneeMemberId) + : null, + assigneeMemberId: notification.assigneeMemberId, + canCancel: canCancelNotification(notification, actor), + canEdit: canEditNotification(notification, actor) + })) }, async cancelNotification({ notificationId, viewerMemberId, asOf = nowInstant() }) { @@ -320,6 +358,109 @@ export function createAdHocNotificationService(input: { } }, + async updateNotification({ + notificationId, + viewerMemberId, + scheduledFor, + timePrecision, + deliveryMode, + dmRecipientMemberIds, + 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 || !canEditNotification(notification, actor)) { + return { + status: 'forbidden' + } + } + + const memberMap = await listMemberMap( + input.householdConfigurationRepository, + notification.householdId + ) + + if (scheduledFor && scheduledFor.epochMilliseconds <= asOf.epochMilliseconds) { + return { + status: 'invalid', + reason: 'scheduled_for_past' + } + } + + let nextDeliveryMode = deliveryMode ?? notification.deliveryMode + let nextDmRecipientMemberIds = dmRecipientMemberIds ?? notification.dmRecipientMemberIds + + switch (nextDeliveryMode) { + case 'topic': + nextDmRecipientMemberIds = [] + break + case 'dm_all': + nextDmRecipientMemberIds = [...memberMap.values()] + .filter(isActiveMember) + .map((member) => member.id) + break + case 'dm_selected': { + const selected = nextDmRecipientMemberIds + .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' + } + } + + nextDmRecipientMemberIds = selected.map((member) => member.id) + break + } + default: + return { + status: 'invalid', + reason: 'delivery_mode_invalid' + } + } + + const updated = await input.repository.updateNotification({ + notificationId, + ...(scheduledFor ? { scheduledFor } : {}), + ...(timePrecision ? { timePrecision } : {}), + deliveryMode: nextDeliveryMode, + dmRecipientMemberIds: nextDmRecipientMemberIds, + updatedAt: asOf + }) + + if (!updated) { + return { + status: 'already_handled' + } + } + + return { + status: 'updated', + notification: updated + } + }, + async listDueNotifications(asOf = nowInstant()) { const due = await input.repository.listDueNotifications(asOf) const groupedMembers = new Map>() diff --git a/packages/application/src/index.ts b/packages/application/src/index.ts index 068f402..e89a22d 100644 --- a/packages/application/src/index.ts +++ b/packages/application/src/index.ts @@ -6,7 +6,8 @@ export { type AdHocNotificationSummary, type CancelAdHocNotificationResult, type DeliverableAdHocNotification, - type ScheduleAdHocNotificationResult + type ScheduleAdHocNotificationResult, + type UpdateAdHocNotificationResult } from './ad-hoc-notification-service' export { createAnonymousFeedbackService, diff --git a/packages/ports/src/index.ts b/packages/ports/src/index.ts index 915d49d..6d468f0 100644 --- a/packages/ports/src/index.ts +++ b/packages/ports/src/index.ts @@ -17,7 +17,8 @@ export { type AdHocNotificationTimePrecision, type CancelAdHocNotificationInput, type ClaimAdHocNotificationDeliveryResult, - type CreateAdHocNotificationInput + type CreateAdHocNotificationInput, + type UpdateAdHocNotificationInput } from './notifications' export type { ClaimProcessedBotMessageInput, diff --git a/packages/ports/src/notifications.ts b/packages/ports/src/notifications.ts index 712984d..10248fa 100644 --- a/packages/ports/src/notifications.ts +++ b/packages/ports/src/notifications.ts @@ -53,6 +53,15 @@ export interface CancelAdHocNotificationInput { cancelledAt: Instant } +export interface UpdateAdHocNotificationInput { + notificationId: string + scheduledFor?: Instant + timePrecision?: AdHocNotificationTimePrecision + deliveryMode?: AdHocNotificationDeliveryMode + dmRecipientMemberIds?: readonly string[] + updatedAt: Instant +} + export interface ClaimAdHocNotificationDeliveryResult { notificationId: string claimed: boolean @@ -66,6 +75,7 @@ export interface AdHocNotificationRepository { asOf: Instant ): Promise cancelNotification(input: CancelAdHocNotificationInput): Promise + updateNotification(input: UpdateAdHocNotificationInput): Promise listDueNotifications(asOf: Instant): Promise markNotificationSent( notificationId: string,