feat(bot): add observable notification management

This commit is contained in:
2026-03-24 03:58:00 +04:00
parent 7e9ae75a41
commit 83ffd7df72
18 changed files with 1267 additions and 58 deletions

View File

@@ -63,6 +63,7 @@ describe('createAdHocNotificationJobsHandler', () => {
}, },
listUpcomingNotifications: async () => [], listUpcomingNotifications: async () => [],
cancelNotification: async () => ({ status: 'not_found' }), cancelNotification: async () => ({ status: 'not_found' }),
updateNotification: async () => ({ status: 'not_found' }),
listDueNotifications: async () => [dueNotification()], listDueNotifications: async () => [dueNotification()],
claimDueNotification: async () => true, claimDueNotification: async () => true,
releaseDueNotification: async () => {}, releaseDueNotification: async () => {},

View File

@@ -316,6 +316,9 @@ describe('registerAdHocNotifications', () => {
async cancelNotification() { async cancelNotification() {
return { status: 'not_found' } return { status: 'not_found' }
}, },
async updateNotification() {
return { status: 'not_found' }
},
async listDueNotifications() { async listDueNotifications() {
return [] return []
}, },
@@ -415,6 +418,9 @@ describe('registerAdHocNotifications', () => {
async cancelNotification() { async cancelNotification() {
return { status: 'not_found' } return { status: 'not_found' }
}, },
async updateNotification() {
return { status: 'not_found' }
},
async listDueNotifications() { async listDueNotifications() {
return [] return []
}, },
@@ -497,6 +503,9 @@ describe('registerAdHocNotifications', () => {
async cancelNotification() { async cancelNotification() {
return { status: 'not_found' } return { status: 'not_found' }
}, },
async updateNotification() {
return { status: 'not_found' }
},
async listDueNotifications() { async listDueNotifications() {
return [] return []
}, },

View File

@@ -217,26 +217,53 @@ export function formatReminderWhen(input: {
: formatScheduledFor(input.locale, input.scheduledForIso, input.timezone) : formatScheduledFor(input.locale, input.scheduledForIso, input.timezone)
} }
function deliveryModeLabel(locale: BotLocale, mode: AdHocNotificationDeliveryMode): string { function listedNotificationLine(input: {
if (locale === 'ru') { locale: BotLocale
switch (mode) { timezone: string
case 'topic': item: Awaited<ReturnType<AdHocNotificationService['listUpcomingNotifications']>>[number]
return 'в этот топик' }): string {
case 'dm_all': const when = formatReminderWhen({
return 'всем в личку' locale: input.locale,
case 'dm_selected': scheduledForIso: input.item.scheduledFor.toString(),
return 'выбранным в личку' 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) { if (input.item.creatorDisplayName !== input.item.assigneeDisplayName) {
case 'topic': details.push(
return 'this topic' input.locale === 'ru'
case 'dm_all': ? `создал ${input.item.creatorDisplayName}`
return 'DM all members' : `created by ${input.item.creatorDisplayName}`
case 'dm_selected': )
return 'DM selected members'
} }
const suffix = details.length > 0 ? `\n${details.join(' · ')}` : ''
return `${when}\n${input.item.notificationText}${suffix}`
} }
function notificationSummaryText(input: { function notificationSummaryText(input: {
@@ -635,23 +662,29 @@ export function registerAdHocNotifications(options: {
await replyInTopic( await replyInTopic(
ctx, ctx,
locale === 'ru' locale === 'ru'
? 'Пока нет будущих напоминаний, которые вы можете отменить.' ? 'Пока будущих напоминаний нет.'
: 'There are no upcoming notifications you can cancel yet.' : 'There are no upcoming notifications yet.'
) )
return return
} }
const lines = items.slice(0, 10).map((item, index) => { const listedItems = items.slice(0, 10).map((item, index) => ({
const when = formatScheduledFor( item,
index
}))
const lines = listedItems.map(
({ item, index }) =>
`${index + 1}. ${listedNotificationLine({
locale, locale,
item.scheduledFor.toString(), timezone: reminderContext.timezone,
reminderContext.timezone item
})}`
) )
return `${index + 1}. ${item.notificationText}\n${when}\n${deliveryModeLabel(locale, item.deliveryMode)}`
})
const keyboard: InlineKeyboardMarkup = { const keyboard: InlineKeyboardMarkup = {
inline_keyboard: items.slice(0, 10).map((item, index) => [ inline_keyboard: listedItems
.filter(({ item }) => item.canCancel)
.map(({ item, index }) => [
{ {
text: locale === 'ru' ? `Отменить ${index + 1}` : `Cancel ${index + 1}`, text: locale === 'ru' ? `Отменить ${index + 1}` : `Cancel ${index + 1}`,
callback_data: `${AD_HOC_NOTIFICATION_CANCEL_SAVED_PREFIX}${item.id}` callback_data: `${AD_HOC_NOTIFICATION_CANCEL_SAVED_PREFIX}${item.id}`
@@ -662,9 +695,9 @@ export function registerAdHocNotifications(options: {
await replyInTopic( await replyInTopic(
ctx, ctx,
[locale === 'ru' ? 'Ближайшие напоминания:' : 'Upcoming notifications:', '', ...lines].join( [locale === 'ru' ? 'Ближайшие напоминания:' : 'Upcoming notifications:', '', ...lines].join(
'\n' '\n\n'
), ),
keyboard keyboard.inline_keyboard.length > 0 ? keyboard : undefined
) )
}) })

View File

@@ -73,6 +73,10 @@ import {
createMiniAppUpdateUtilityBillHandler createMiniAppUpdateUtilityBillHandler
} from './miniapp-billing' } from './miniapp-billing'
import { createMiniAppLocalePreferenceHandler } from './miniapp-locale' import { createMiniAppLocalePreferenceHandler } from './miniapp-locale'
import {
createMiniAppCancelNotificationHandler,
createMiniAppUpdateNotificationHandler
} from './miniapp-notifications'
import { createNbgExchangeRateProvider } from './nbg-exchange-rates' import { createNbgExchangeRateProvider } from './nbg-exchange-rates'
import { createOpenAiChatAssistant } from './openai-chat-assistant' import { createOpenAiChatAssistant } from './openai-chat-assistant'
import { createOpenAiAdHocNotificationInterpreter } from './openai-ad-hoc-notification-interpreter' import { createOpenAiAdHocNotificationInterpreter } from './openai-ad-hoc-notification-interpreter'
@@ -635,10 +639,31 @@ export async function createBotRuntimeApp(): Promise<BotRuntimeApp> {
allowedOrigins: runtime.miniAppAllowedOrigins, allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken, botToken: runtime.telegramBotToken,
financeServiceForHousehold, financeServiceForHousehold,
adHocNotificationService: adHocNotificationService!,
onboardingService: householdOnboardingService, onboardingService: householdOnboardingService,
logger: getLogger('miniapp-dashboard') logger: getLogger('miniapp-dashboard')
}) })
: undefined, : 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 miniAppPendingMembers: householdOnboardingService
? createMiniAppPendingMembersHandler({ ? createMiniAppPendingMembersHandler({
allowedOrigins: runtime.miniAppAllowedOrigins, allowedOrigins: runtime.miniAppAllowedOrigins,

View File

@@ -1,6 +1,7 @@
import { describe, expect, test } from 'bun:test' import { describe, expect, test } from 'bun:test'
import { import {
type AdHocNotificationService,
createFinanceCommandService, createFinanceCommandService,
createHouseholdOnboardingService createHouseholdOnboardingService
} from '@household/application' } from '@household/application'
@@ -314,6 +315,23 @@ function onboardingRepository(): HouseholdConfigurationRepository {
} }
} }
function notificationService(
items: Awaited<ReturnType<AdHocNotificationService['listUpcomingNotifications']>> = []
): 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', () => { describe('createMiniAppDashboardHandler', () => {
test('returns a dashboard for an authenticated household member', async () => { test('returns a dashboard for an authenticated household member', async () => {
const authDate = Math.floor(Date.now() / 1000) const authDate = Math.floor(Date.now() / 1000)
@@ -344,10 +362,28 @@ describe('createMiniAppDashboardHandler', () => {
isAdmin: true 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({ const dashboard = createMiniAppDashboardHandler({
allowedOrigins: ['http://localhost:5173'], allowedOrigins: ['http://localhost:5173'],
botToken: 'test-bot-token', botToken: 'test-bot-token',
financeServiceForHousehold: () => financeService, financeServiceForHousehold: () => financeService,
adHocNotificationService,
onboardingService: createHouseholdOnboardingService({ onboardingService: createHouseholdOnboardingService({
repository: householdRepository repository: householdRepository
}) })
@@ -415,6 +451,17 @@ describe('createMiniAppDashboardHandler', () => {
currency: 'GEL', currency: 'GEL',
displayCurrency: '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'], allowedOrigins: ['http://localhost:5173'],
botToken: 'test-bot-token', botToken: 'test-bot-token',
financeServiceForHousehold: () => financeService, financeServiceForHousehold: () => financeService,
adHocNotificationService: notificationService(),
onboardingService: createHouseholdOnboardingService({ onboardingService: createHouseholdOnboardingService({
repository: householdRepository repository: householdRepository
}) })
@@ -644,6 +692,7 @@ describe('createMiniAppDashboardHandler', () => {
allowedOrigins: ['http://localhost:5173'], allowedOrigins: ['http://localhost:5173'],
botToken: 'test-bot-token', botToken: 'test-bot-token',
financeServiceForHousehold: () => financeService, financeServiceForHousehold: () => financeService,
adHocNotificationService: notificationService(),
onboardingService: createHouseholdOnboardingService({ onboardingService: createHouseholdOnboardingService({
repository: householdRepository repository: householdRepository
}) })
@@ -706,6 +755,7 @@ describe('createMiniAppDashboardHandler', () => {
allowedOrigins: ['http://localhost:5173'], allowedOrigins: ['http://localhost:5173'],
botToken: 'test-bot-token', botToken: 'test-bot-token',
financeServiceForHousehold: () => financeService, financeServiceForHousehold: () => financeService,
adHocNotificationService: notificationService(),
onboardingService: createHouseholdOnboardingService({ onboardingService: createHouseholdOnboardingService({
repository: householdRepository repository: householdRepository
}) })

View File

@@ -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 { Money } from '@household/domain'
import type { Logger } from '@household/observability' import type { Logger } from '@household/observability'
@@ -14,6 +18,7 @@ export function createMiniAppDashboardHandler(options: {
allowedOrigins: readonly string[] allowedOrigins: readonly string[]
botToken: string botToken: string
financeServiceForHousehold: (householdId: string) => FinanceCommandService financeServiceForHousehold: (householdId: string) => FinanceCommandService
adHocNotificationService: AdHocNotificationService
onboardingService: HouseholdOnboardingService onboardingService: HouseholdOnboardingService
logger?: Logger logger?: Logger
}): { }): {
@@ -74,6 +79,10 @@ export function createMiniAppDashboardHandler(options: {
const dashboard = await options const dashboard = await options
.financeServiceForHousehold(session.member.householdId) .financeServiceForHousehold(session.member.householdId)
.generateDashboard() .generateDashboard()
const notifications = await options.adHocNotificationService.listUpcomingNotifications({
householdId: session.member.householdId,
viewerMemberId: session.member.id
})
if (!dashboard) { if (!dashboard) {
return miniAppJsonResponse( return miniAppJsonResponse(
{ ok: false, error: 'No billing cycle available' }, { 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
})) }))
} }
}, },

View File

@@ -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<typeof createMiniAppSessionService>,
origin: string | undefined
): Promise<
| Response
| {
member: NonNullable<MiniAppSessionResult['member']>
}
> {
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<T>(request: Request): Promise<T> {
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<Response>
} {
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<Response>
} {
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)
}
}
}
}

View File

@@ -14,6 +14,18 @@ export interface BotWebhookServerOptions {
handler: (request: Request) => Promise<Response> handler: (request: Request) => Promise<Response>
} }
| undefined | undefined
miniAppUpdateNotification?:
| {
path?: string
handler: (request: Request) => Promise<Response>
}
| undefined
miniAppCancelNotification?:
| {
path?: string
handler: (request: Request) => Promise<Response>
}
| undefined
miniAppJoin?: miniAppJoin?:
| { | {
path?: string path?: string
@@ -226,6 +238,10 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): {
: `/${options.webhookPath}` : `/${options.webhookPath}`
const miniAppAuthPath = options.miniAppAuth?.path ?? '/api/miniapp/session' const miniAppAuthPath = options.miniAppAuth?.path ?? '/api/miniapp/session'
const miniAppDashboardPath = options.miniAppDashboard?.path ?? '/api/miniapp/dashboard' 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 miniAppJoinPath = options.miniAppJoin?.path ?? '/api/miniapp/join'
const miniAppPendingMembersPath = const miniAppPendingMembersPath =
options.miniAppPendingMembers?.path ?? '/api/miniapp/admin/pending-members' options.miniAppPendingMembers?.path ?? '/api/miniapp/admin/pending-members'
@@ -301,6 +317,14 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): {
return await options.miniAppDashboard.handler(request) 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) { if (options.miniAppJoin && url.pathname === miniAppJoinPath) {
return await options.miniAppJoin.handler(request) return await options.miniAppJoin.handler(request)
} }

View File

@@ -5,7 +5,7 @@ import { cn } from '../../lib/cn'
type InputProps = { type InputProps = {
value?: string value?: string
placeholder?: string placeholder?: string
type?: 'text' | 'number' | 'email' type?: 'text' | 'number' | 'email' | 'datetime-local'
min?: string | number min?: string | number
max?: string | number max?: string | number
step?: string | number step?: string | number

View File

@@ -450,7 +450,39 @@ function createDashboard(state: {
rentFxEffectiveDate: '2026-03-17', rentFxEffectiveDate: '2026-03-17',
members: state.members, members: state.members,
paymentPeriods, 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
}
]
} }
} }

View File

@@ -187,6 +187,21 @@ export interface MiniAppDashboard {
}[] }[]
payerMemberId?: string 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 { export interface MiniAppAdminSettingsPayload {
@@ -339,6 +354,64 @@ export async function fetchMiniAppDashboard(initData: string): Promise<MiniAppDa
return payload.dashboard return payload.dashboard
} }
export async function updateMiniAppNotification(
initData: string,
input: {
notificationId: string
scheduledLocal?: string
timezone?: string
deliveryMode?: 'topic' | 'dm_all' | 'dm_selected'
dmRecipientMemberIds?: readonly string[]
}
): Promise<void> {
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<void> {
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( export async function fetchMiniAppPendingMembers(
initData: string initData: string
): Promise<readonly MiniAppPendingMember[]> { ): Promise<readonly MiniAppPendingMember[]> {

View File

@@ -23,7 +23,12 @@ import {
nextCyclePeriod, nextCyclePeriod,
parseCalendarDate parseCalendarDate
} from '../lib/dates' } from '../lib/dates'
import { submitMiniAppUtilityBill, addMiniAppPayment } from '../miniapp-api' import {
submitMiniAppUtilityBill,
addMiniAppPayment,
updateMiniAppNotification,
cancelMiniAppNotification
} from '../miniapp-api'
import type { MiniAppDashboard } from '../miniapp-api' import type { MiniAppDashboard } from '../miniapp-api'
function sumMemberPaymentsByKind( function sumMemberPaymentsByKind(
@@ -76,6 +81,117 @@ function paymentRemainingMinor(
return remainingMinor > 0n ? remainingMinor : 0n 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() { export default function HomeRoute() {
const navigate = useNavigate() const navigate = useNavigate()
const { readySession, initData, refreshHouseholdData } = useSession() const { readySession, initData, refreshHouseholdData } = useSession()
@@ -104,12 +220,30 @@ export default function HomeRoute() {
) )
const [quickPaymentAmount, setQuickPaymentAmount] = createSignal('') const [quickPaymentAmount, setQuickPaymentAmount] = createSignal('')
const [submittingPayment, setSubmittingPayment] = createSignal(false) const [submittingPayment, setSubmittingPayment] = createSignal(false)
const [notificationEditorOpen, setNotificationEditorOpen] = createSignal(false)
const [editingNotificationId, setEditingNotificationId] = createSignal<string | null>(null)
const [notificationScheduleDraft, setNotificationScheduleDraft] = createSignal('')
const [notificationDeliveryModeDraft, setNotificationDeliveryModeDraft] = createSignal<
'topic' | 'dm_all' | 'dm_selected'
>('topic')
const [notificationRecipientsDraft, setNotificationRecipientsDraft] = createSignal<string[]>([])
const [savingNotification, setSavingNotification] = createSignal(false)
const [cancellingNotificationId, setCancellingNotificationId] = createSignal<string | null>(null)
const [toastState, setToastState] = createSignal<{ const [toastState, setToastState] = createSignal<{
visible: boolean visible: boolean
message: string message: string
type: 'success' | 'info' | 'error' type: 'success' | 'info' | 'error'
}>({ visible: false, message: '', type: 'info' }) }>({ 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<boolean> { async function copyText(value: string): Promise<boolean> {
try { try {
await navigator.clipboard.writeText(value) 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 ( return (
<div class="route route--home"> <div class="route route--home">
{/* ── Welcome hero ────────────────────────────── */} {/* ── Welcome hero ────────────────────────────── */}
@@ -918,6 +1140,105 @@ export default function HomeRoute() {
</Card> </Card>
</Show> </Show>
<Card>
<div class="balance-card">
<div class="balance-card__header">
<span class="balance-card__label">
{locale() === 'ru' ? 'Напоминания' : 'Notifications'}
</span>
<Badge variant="muted">{data().notifications.length}</Badge>
</div>
<Show
when={data().notifications.length > 0}
fallback={
<p class="empty-state">
{locale() === 'ru'
? 'Пока нет запланированных напоминаний.'
: 'There are no scheduled notifications yet.'}
</p>
}
>
<div class="balance-card__amounts">
<For each={data().notifications}>
{(notification) => (
<div
class="balance-card__row"
style={{
'align-items': 'flex-start',
'flex-direction': 'column',
gap: '10px',
padding: '12px 0'
}}
>
<div
style={{
display: 'flex',
width: '100%',
'justify-content': 'space-between',
gap: '12px',
'align-items': 'flex-start'
}}
>
<div style={{ display: 'grid', gap: '6px' }}>
<strong>{notification.summaryText}</strong>
<span>
{formatNotificationWhen(
locale(),
notification.scheduledFor,
data().timezone
)}
</span>
<span>{formatNotificationDelivery(locale(), notification)}</span>
<Show when={notification.assigneeDisplayName}>
<span>
{(locale() === 'ru' ? 'Для: ' : 'For: ') +
notification.assigneeDisplayName}
</span>
</Show>
<span>
{(locale() === 'ru' ? 'Создал: ' : 'Created by: ') +
notification.creatorDisplayName}
</span>
</div>
<div
style={{
display: 'flex',
gap: '8px',
'flex-wrap': 'nowrap',
'justify-content': 'flex-end',
'align-items': 'center',
'flex-shrink': '0'
}}
>
<Show when={notification.canEdit}>
<Button
variant="secondary"
size="sm"
onClick={() => openNotificationEditor(notification)}
>
{locale() === 'ru' ? 'Управлять' : 'Manage'}
</Button>
</Show>
<Show when={notification.canCancel}>
<Button
variant="ghost"
size="sm"
loading={cancellingNotificationId() === notification.id}
onClick={() => void handleNotificationCancel(notification.id)}
>
{locale() === 'ru' ? 'Отменить' : 'Cancel'}
</Button>
</Show>
</div>
</div>
</div>
)}
</For>
</div>
</Show>
</div>
</Card>
{/* Latest activity */} {/* Latest activity */}
<Card> <Card>
<div class="activity-card"> <div class="activity-card">
@@ -972,6 +1293,122 @@ export default function HomeRoute() {
</Match> </Match>
</Switch> </Switch>
<Modal
open={notificationEditorOpen()}
title={locale() === 'ru' ? 'Управление напоминанием' : 'Manage notification'}
{...(selectedNotification()
? {
description: formatNotificationWhen(
locale(),
selectedNotification()!.scheduledFor,
dashboard()?.timezone ?? 'UTC'
)
}
: {})}
closeLabel={copy().showLessAction}
onClose={() => {
setNotificationEditorOpen(false)
}}
footer={
<>
<Show when={selectedNotification()?.canCancel}>
<Button
variant="ghost"
loading={cancellingNotificationId() === selectedNotification()?.id}
onClick={() =>
selectedNotification() &&
void handleNotificationCancel(selectedNotification()!.id)
}
>
{locale() === 'ru' ? 'Отменить напоминание' : 'Cancel notification'}
</Button>
</Show>
<Button variant="ghost" onClick={() => setNotificationEditorOpen(false)}>
{copy().showLessAction}
</Button>
<Button
variant="primary"
loading={savingNotification()}
disabled={
!notificationScheduleDraft().trim() ||
(notificationDeliveryModeDraft() === 'dm_selected' &&
notificationRecipientsDraft().length === 0)
}
onClick={() => void handleNotificationSave()}
>
{locale() === 'ru' ? 'Сохранить' : 'Save'}
</Button>
</>
}
>
<div style={{ display: 'grid', gap: '16px' }}>
<Field label={locale() === 'ru' ? 'Когда' : 'When'}>
<Input
type="datetime-local"
value={notificationScheduleDraft()}
onInput={(event) => setNotificationScheduleDraft(event.currentTarget.value)}
/>
</Field>
<Field label={locale() === 'ru' ? 'Куда отправлять' : 'Delivery'}>
<div style={{ display: 'flex', gap: '8px', 'flex-wrap': 'wrap' }}>
<Button
variant={notificationDeliveryModeDraft() === 'topic' ? 'primary' : 'secondary'}
size="sm"
onClick={() => setNotificationDeliveryModeDraft('topic')}
>
{locale() === 'ru' ? 'В топик' : 'Topic'}
</Button>
<Button
variant={notificationDeliveryModeDraft() === 'dm_all' ? 'primary' : 'secondary'}
size="sm"
onClick={() => setNotificationDeliveryModeDraft('dm_all')}
>
{locale() === 'ru' ? 'Всем в личку' : 'DM all'}
</Button>
<Button
variant={
notificationDeliveryModeDraft() === 'dm_selected' ? 'primary' : 'secondary'
}
size="sm"
onClick={() => setNotificationDeliveryModeDraft('dm_selected')}
>
{locale() === 'ru' ? 'Выбрать получателей' : 'Select recipients'}
</Button>
</div>
</Field>
<Show when={notificationDeliveryModeDraft() === 'dm_selected'}>
<Field
label={locale() === 'ru' ? 'Получатели' : 'Recipients'}
hint={
locale() === 'ru'
? 'Выберите, кому отправить в личку.'
: 'Choose who should receive the DM.'
}
>
<div style={{ display: 'flex', gap: '8px', 'flex-wrap': 'wrap' }}>
<For each={activeHouseholdMembers()}>
{(member) => (
<Button
variant={
notificationRecipientsDraft().includes(member.memberId)
? 'primary'
: 'secondary'
}
size="sm"
onClick={() => toggleNotificationRecipient(member.memberId)}
>
{member.displayName}
</Button>
)}
</For>
</div>
</Field>
</Show>
</div>
</Modal>
{/* Quick Payment Modal */} {/* Quick Payment Modal */}
<Modal <Modal
open={quickPaymentOpen()} open={quickPaymentOpen()}

View File

@@ -181,6 +181,38 @@ export function createDbAdHocNotificationRepository(databaseUrl: string): {
return rows[0] ? mapNotification(rows[0]) : null return rows[0] ? mapNotification(rows[0]) : null
}, },
async updateNotification(input) {
const updates: Record<string, unknown> = {
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) { async listDueNotifications(asOf) {
const rows = await db const rows = await db
.select(notificationSelect()) .select(notificationSelect())

View File

@@ -79,6 +79,31 @@ class NotificationRepositoryStub implements AdHocNotificationRepository {
return next return next
} }
async updateNotification(input: {
notificationId: string
scheduledFor?: Temporal.Instant
timePrecision?: AdHocNotificationRecord['timePrecision']
deliveryMode?: AdHocNotificationRecord['deliveryMode']
dmRecipientMemberIds?: readonly string[]
updatedAt: Temporal.Instant
}): Promise<AdHocNotificationRecord | null> {
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<readonly AdHocNotificationRecord[]> { async listDueNotifications(asOf: Temporal.Instant): Promise<readonly AdHocNotificationRecord[]> {
return [...this.notifications.values()].filter( return [...this.notifications.values()].filter(
(notification) => (notification) =>
@@ -265,4 +290,79 @@ describe('createAdHocNotificationService', () => {
expect(result.notification.cancelledByMemberId).toBe('admin') 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'])
}
})
}) })

View File

@@ -24,11 +24,16 @@ export interface AdHocNotificationSummary {
id: string id: string
notificationText: string notificationText: string
scheduledFor: Instant scheduledFor: Instant
status: 'scheduled' | 'sent' | 'cancelled'
deliveryMode: AdHocNotificationDeliveryMode deliveryMode: AdHocNotificationDeliveryMode
friendlyTagAssignee: boolean dmRecipientMemberIds: readonly string[]
dmRecipientDisplayNames: readonly string[]
creatorDisplayName: string creatorDisplayName: string
creatorMemberId: string
assigneeDisplayName: string | null assigneeDisplayName: string | null
assigneeMemberId: string | null
canCancel: boolean canCancel: boolean
canEdit: boolean
} }
export interface DeliverableAdHocNotification { export interface DeliverableAdHocNotification {
@@ -63,6 +68,19 @@ export type CancelAdHocNotificationResult =
status: 'not_found' | 'forbidden' | 'already_handled' | 'past_due' 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 { export interface AdHocNotificationService {
scheduleNotification(input: { scheduleNotification(input: {
householdId: string householdId: string
@@ -89,6 +107,15 @@ export interface AdHocNotificationService {
viewerMemberId: string viewerMemberId: string
asOf?: Instant asOf?: Instant
}): Promise<CancelAdHocNotificationResult> }): Promise<CancelAdHocNotificationResult>
updateNotification(input: {
notificationId: string
viewerMemberId: string
scheduledFor?: Instant
timePrecision?: AdHocNotificationTimePrecision
deliveryMode?: AdHocNotificationDeliveryMode
dmRecipientMemberIds?: readonly string[]
asOf?: Instant
}): Promise<UpdateAdHocNotificationResult>
listDueNotifications(asOf?: Instant): Promise<readonly DeliverableAdHocNotification[]> listDueNotifications(asOf?: Instant): Promise<readonly DeliverableAdHocNotification[]>
claimDueNotification(notificationId: string): Promise<boolean> claimDueNotification(notificationId: string): Promise<boolean>
releaseDueNotification(notificationId: string): Promise<void> releaseDueNotification(notificationId: string): Promise<void>
@@ -125,6 +152,13 @@ function canCancelNotification(
return actor.isAdmin || notification.creatorMemberId === actor.memberId return actor.isAdmin || notification.creatorMemberId === actor.memberId
} }
function canEditNotification(
notification: AdHocNotificationRecord,
actor: NotificationActor
): boolean {
return canCancelNotification(notification, actor)
}
export function createAdHocNotificationService(input: { export function createAdHocNotificationService(input: {
repository: AdHocNotificationRepository repository: AdHocNotificationRepository
householdConfigurationRepository: Pick< householdConfigurationRepository: Pick<
@@ -256,22 +290,26 @@ export function createAdHocNotificationService(input: {
asOf asOf
) )
return notifications return notifications.map((notification) => ({
.filter((notification) => actor.isAdmin || notification.creatorMemberId === actor.memberId)
.map((notification) => ({
id: notification.id, id: notification.id,
notificationText: notification.notificationText, notificationText: notification.notificationText,
scheduledFor: notification.scheduledFor, scheduledFor: notification.scheduledFor,
status: notification.status,
deliveryMode: notification.deliveryMode, deliveryMode: notification.deliveryMode,
friendlyTagAssignee: notification.friendlyTagAssignee, dmRecipientMemberIds: notification.dmRecipientMemberIds,
dmRecipientDisplayNames: notification.dmRecipientMemberIds.map(
(memberId) => memberMap.get(memberId)?.displayName ?? memberId
),
creatorDisplayName: creatorDisplayName:
memberMap.get(notification.creatorMemberId)?.displayName ?? memberMap.get(notification.creatorMemberId)?.displayName ?? notification.creatorMemberId,
notification.creatorMemberId, creatorMemberId: notification.creatorMemberId,
assigneeDisplayName: notification.assigneeMemberId assigneeDisplayName: notification.assigneeMemberId
? (memberMap.get(notification.assigneeMemberId)?.displayName ?? ? (memberMap.get(notification.assigneeMemberId)?.displayName ??
notification.assigneeMemberId) notification.assigneeMemberId)
: null, : null,
canCancel: canCancelNotification(notification, actor) assigneeMemberId: notification.assigneeMemberId,
canCancel: canCancelNotification(notification, actor),
canEdit: canEditNotification(notification, actor)
})) }))
}, },
@@ -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()) { async listDueNotifications(asOf = nowInstant()) {
const due = await input.repository.listDueNotifications(asOf) const due = await input.repository.listDueNotifications(asOf)
const groupedMembers = new Map<string, Map<string, HouseholdMemberRecord>>() const groupedMembers = new Map<string, Map<string, HouseholdMemberRecord>>()

View File

@@ -6,7 +6,8 @@ export {
type AdHocNotificationSummary, type AdHocNotificationSummary,
type CancelAdHocNotificationResult, type CancelAdHocNotificationResult,
type DeliverableAdHocNotification, type DeliverableAdHocNotification,
type ScheduleAdHocNotificationResult type ScheduleAdHocNotificationResult,
type UpdateAdHocNotificationResult
} from './ad-hoc-notification-service' } from './ad-hoc-notification-service'
export { export {
createAnonymousFeedbackService, createAnonymousFeedbackService,

View File

@@ -17,7 +17,8 @@ export {
type AdHocNotificationTimePrecision, type AdHocNotificationTimePrecision,
type CancelAdHocNotificationInput, type CancelAdHocNotificationInput,
type ClaimAdHocNotificationDeliveryResult, type ClaimAdHocNotificationDeliveryResult,
type CreateAdHocNotificationInput type CreateAdHocNotificationInput,
type UpdateAdHocNotificationInput
} from './notifications' } from './notifications'
export type { export type {
ClaimProcessedBotMessageInput, ClaimProcessedBotMessageInput,

View File

@@ -53,6 +53,15 @@ export interface CancelAdHocNotificationInput {
cancelledAt: Instant cancelledAt: Instant
} }
export interface UpdateAdHocNotificationInput {
notificationId: string
scheduledFor?: Instant
timePrecision?: AdHocNotificationTimePrecision
deliveryMode?: AdHocNotificationDeliveryMode
dmRecipientMemberIds?: readonly string[]
updatedAt: Instant
}
export interface ClaimAdHocNotificationDeliveryResult { export interface ClaimAdHocNotificationDeliveryResult {
notificationId: string notificationId: string
claimed: boolean claimed: boolean
@@ -66,6 +75,7 @@ export interface AdHocNotificationRepository {
asOf: Instant asOf: Instant
): Promise<readonly AdHocNotificationRecord[]> ): Promise<readonly AdHocNotificationRecord[]>
cancelNotification(input: CancelAdHocNotificationInput): Promise<AdHocNotificationRecord | null> cancelNotification(input: CancelAdHocNotificationInput): Promise<AdHocNotificationRecord | null>
updateNotification(input: UpdateAdHocNotificationInput): Promise<AdHocNotificationRecord | null>
listDueNotifications(asOf: Instant): Promise<readonly AdHocNotificationRecord[]> listDueNotifications(asOf: Instant): Promise<readonly AdHocNotificationRecord[]>
markNotificationSent( markNotificationSent(
notificationId: string, notificationId: string,