mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 10:24:02 +00:00
feat(bot): add observable notification management
This commit is contained in:
@@ -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 () => {},
|
||||||
|
|||||||
@@ -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 []
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,36 +662,42 @@ 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,
|
||||||
locale,
|
index
|
||||||
item.scheduledFor.toString(),
|
}))
|
||||||
reminderContext.timezone
|
const lines = listedItems.map(
|
||||||
)
|
({ item, index }) =>
|
||||||
return `${index + 1}. ${item.notificationText}\n${when}\n${deliveryModeLabel(locale, item.deliveryMode)}`
|
`${index + 1}. ${listedNotificationLine({
|
||||||
})
|
locale,
|
||||||
|
timezone: reminderContext.timezone,
|
||||||
|
item
|
||||||
|
})}`
|
||||||
|
)
|
||||||
|
|
||||||
const keyboard: InlineKeyboardMarkup = {
|
const keyboard: InlineKeyboardMarkup = {
|
||||||
inline_keyboard: items.slice(0, 10).map((item, index) => [
|
inline_keyboard: listedItems
|
||||||
{
|
.filter(({ item }) => item.canCancel)
|
||||||
text: locale === 'ru' ? `Отменить ${index + 1}` : `Cancel ${index + 1}`,
|
.map(({ item, index }) => [
|
||||||
callback_data: `${AD_HOC_NOTIFICATION_CANCEL_SAVED_PREFIX}${item.id}`
|
{
|
||||||
}
|
text: locale === 'ru' ? `Отменить ${index + 1}` : `Cancel ${index + 1}`,
|
||||||
])
|
callback_data: `${AD_HOC_NOTIFICATION_CANCEL_SAVED_PREFIX}${item.id}`
|
||||||
|
}
|
||||||
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
216
apps/bot/src/miniapp-notifications.ts
Normal file
216
apps/bot/src/miniapp-notifications.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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[]> {
|
||||||
|
|||||||
@@ -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()}
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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'])
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,23 +290,27 @@ export function createAdHocNotificationService(input: {
|
|||||||
asOf
|
asOf
|
||||||
)
|
)
|
||||||
|
|
||||||
return notifications
|
return notifications.map((notification) => ({
|
||||||
.filter((notification) => actor.isAdmin || notification.creatorMemberId === actor.memberId)
|
id: notification.id,
|
||||||
.map((notification) => ({
|
notificationText: notification.notificationText,
|
||||||
id: notification.id,
|
scheduledFor: notification.scheduledFor,
|
||||||
notificationText: notification.notificationText,
|
status: notification.status,
|
||||||
scheduledFor: notification.scheduledFor,
|
deliveryMode: notification.deliveryMode,
|
||||||
deliveryMode: notification.deliveryMode,
|
dmRecipientMemberIds: notification.dmRecipientMemberIds,
|
||||||
friendlyTagAssignee: notification.friendlyTagAssignee,
|
dmRecipientDisplayNames: notification.dmRecipientMemberIds.map(
|
||||||
creatorDisplayName:
|
(memberId) => memberMap.get(memberId)?.displayName ?? memberId
|
||||||
memberMap.get(notification.creatorMemberId)?.displayName ??
|
),
|
||||||
notification.creatorMemberId,
|
creatorDisplayName:
|
||||||
assigneeDisplayName: notification.assigneeMemberId
|
memberMap.get(notification.creatorMemberId)?.displayName ?? notification.creatorMemberId,
|
||||||
? (memberMap.get(notification.assigneeMemberId)?.displayName ??
|
creatorMemberId: notification.creatorMemberId,
|
||||||
notification.assigneeMemberId)
|
assigneeDisplayName: notification.assigneeMemberId
|
||||||
: null,
|
? (memberMap.get(notification.assigneeMemberId)?.displayName ??
|
||||||
canCancel: canCancelNotification(notification, actor)
|
notification.assigneeMemberId)
|
||||||
}))
|
: null,
|
||||||
|
assigneeMemberId: notification.assigneeMemberId,
|
||||||
|
canCancel: canCancelNotification(notification, actor),
|
||||||
|
canEdit: canEditNotification(notification, actor)
|
||||||
|
}))
|
||||||
},
|
},
|
||||||
|
|
||||||
async cancelNotification({ notificationId, viewerMemberId, asOf = nowInstant() }) {
|
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()) {
|
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>>()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user