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

@@ -79,6 +79,31 @@ class NotificationRepositoryStub implements AdHocNotificationRepository {
return next
}
async updateNotification(input: {
notificationId: string
scheduledFor?: Temporal.Instant
timePrecision?: AdHocNotificationRecord['timePrecision']
deliveryMode?: AdHocNotificationRecord['deliveryMode']
dmRecipientMemberIds?: readonly string[]
updatedAt: Temporal.Instant
}): Promise<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[]> {
return [...this.notifications.values()].filter(
(notification) =>
@@ -265,4 +290,79 @@ describe('createAdHocNotificationService', () => {
expect(result.notification.cancelledByMemberId).toBe('admin')
}
})
test('lists upcoming notifications for all household members with permission flags', async () => {
const repository = new NotificationRepositoryStub()
const creator = member({ id: 'creator' })
const viewer = member({ id: 'viewer' })
const service = createAdHocNotificationService({
repository,
householdConfigurationRepository: createHouseholdRepository([creator, viewer])
})
await repository.createNotification({
householdId: 'household-1',
creatorMemberId: 'creator',
originalRequestText: 'remind tomorrow',
notificationText: 'call landlord',
timezone: 'Asia/Tbilisi',
scheduledFor: Temporal.Instant.from('2026-03-24T08:00:00Z'),
timePrecision: 'date_only_defaulted',
deliveryMode: 'topic',
friendlyTagAssignee: false
})
const items = await service.listUpcomingNotifications({
householdId: 'household-1',
viewerMemberId: 'viewer',
asOf: Temporal.Instant.from('2026-03-23T09:00:00Z')
})
expect(items).toHaveLength(1)
expect(items[0]).toMatchObject({
creatorDisplayName: 'creator',
canCancel: false,
canEdit: false
})
})
test('allows creator to reschedule and update delivery', async () => {
const repository = new NotificationRepositoryStub()
const creator = member({ id: 'creator' })
const alice = member({ id: 'alice' })
const bob = member({ id: 'bob' })
const service = createAdHocNotificationService({
repository,
householdConfigurationRepository: createHouseholdRepository([creator, alice, bob])
})
const created = await repository.createNotification({
householdId: 'household-1',
creatorMemberId: 'creator',
originalRequestText: 'remind tomorrow',
notificationText: 'call landlord',
timezone: 'Asia/Tbilisi',
scheduledFor: Temporal.Instant.from('2026-03-24T08:00:00Z'),
timePrecision: 'date_only_defaulted',
deliveryMode: 'topic',
friendlyTagAssignee: false
})
const result = await service.updateNotification({
notificationId: created.id,
viewerMemberId: 'creator',
scheduledFor: Temporal.Instant.from('2026-03-24T09:00:00Z'),
timePrecision: 'exact',
deliveryMode: 'dm_selected',
dmRecipientMemberIds: ['alice', 'bob'],
asOf: Temporal.Instant.from('2026-03-23T09:00:00Z')
})
expect(result.status).toBe('updated')
if (result.status === 'updated') {
expect(result.notification.scheduledFor.toString()).toBe('2026-03-24T09:00:00Z')
expect(result.notification.deliveryMode).toBe('dm_selected')
expect(result.notification.dmRecipientMemberIds).toEqual(['alice', 'bob'])
}
})
})

View File

@@ -24,11 +24,16 @@ export interface AdHocNotificationSummary {
id: string
notificationText: string
scheduledFor: Instant
status: 'scheduled' | 'sent' | 'cancelled'
deliveryMode: AdHocNotificationDeliveryMode
friendlyTagAssignee: boolean
dmRecipientMemberIds: readonly string[]
dmRecipientDisplayNames: readonly string[]
creatorDisplayName: string
creatorMemberId: string
assigneeDisplayName: string | null
assigneeMemberId: string | null
canCancel: boolean
canEdit: boolean
}
export interface DeliverableAdHocNotification {
@@ -63,6 +68,19 @@ export type CancelAdHocNotificationResult =
status: 'not_found' | 'forbidden' | 'already_handled' | 'past_due'
}
export type UpdateAdHocNotificationResult =
| {
status: 'updated'
notification: AdHocNotificationRecord
}
| {
status: 'not_found' | 'forbidden' | 'already_handled' | 'past_due'
}
| {
status: 'invalid'
reason: 'delivery_mode_invalid' | 'dm_recipients_missing' | 'scheduled_for_past'
}
export interface AdHocNotificationService {
scheduleNotification(input: {
householdId: string
@@ -89,6 +107,15 @@ export interface AdHocNotificationService {
viewerMemberId: string
asOf?: Instant
}): Promise<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[]>
claimDueNotification(notificationId: string): Promise<boolean>
releaseDueNotification(notificationId: string): Promise<void>
@@ -125,6 +152,13 @@ function canCancelNotification(
return actor.isAdmin || notification.creatorMemberId === actor.memberId
}
function canEditNotification(
notification: AdHocNotificationRecord,
actor: NotificationActor
): boolean {
return canCancelNotification(notification, actor)
}
export function createAdHocNotificationService(input: {
repository: AdHocNotificationRepository
householdConfigurationRepository: Pick<
@@ -256,23 +290,27 @@ export function createAdHocNotificationService(input: {
asOf
)
return notifications
.filter((notification) => actor.isAdmin || notification.creatorMemberId === actor.memberId)
.map((notification) => ({
id: notification.id,
notificationText: notification.notificationText,
scheduledFor: notification.scheduledFor,
deliveryMode: notification.deliveryMode,
friendlyTagAssignee: notification.friendlyTagAssignee,
creatorDisplayName:
memberMap.get(notification.creatorMemberId)?.displayName ??
notification.creatorMemberId,
assigneeDisplayName: notification.assigneeMemberId
? (memberMap.get(notification.assigneeMemberId)?.displayName ??
notification.assigneeMemberId)
: null,
canCancel: canCancelNotification(notification, actor)
}))
return notifications.map((notification) => ({
id: notification.id,
notificationText: notification.notificationText,
scheduledFor: notification.scheduledFor,
status: notification.status,
deliveryMode: notification.deliveryMode,
dmRecipientMemberIds: notification.dmRecipientMemberIds,
dmRecipientDisplayNames: notification.dmRecipientMemberIds.map(
(memberId) => memberMap.get(memberId)?.displayName ?? memberId
),
creatorDisplayName:
memberMap.get(notification.creatorMemberId)?.displayName ?? notification.creatorMemberId,
creatorMemberId: notification.creatorMemberId,
assigneeDisplayName: notification.assigneeMemberId
? (memberMap.get(notification.assigneeMemberId)?.displayName ??
notification.assigneeMemberId)
: null,
assigneeMemberId: notification.assigneeMemberId,
canCancel: canCancelNotification(notification, actor),
canEdit: canEditNotification(notification, actor)
}))
},
async cancelNotification({ notificationId, viewerMemberId, asOf = nowInstant() }) {
@@ -320,6 +358,109 @@ export function createAdHocNotificationService(input: {
}
},
async updateNotification({
notificationId,
viewerMemberId,
scheduledFor,
timePrecision,
deliveryMode,
dmRecipientMemberIds,
asOf = nowInstant()
}) {
const notification = await input.repository.getNotificationById(notificationId)
if (!notification) {
return {
status: 'not_found'
}
}
if (notification.status !== 'scheduled') {
return {
status: 'already_handled'
}
}
if (notification.scheduledFor.epochMilliseconds <= asOf.epochMilliseconds) {
return {
status: 'past_due'
}
}
const actor = await resolveActor(notification.householdId, viewerMemberId)
if (!actor || !canEditNotification(notification, actor)) {
return {
status: 'forbidden'
}
}
const memberMap = await listMemberMap(
input.householdConfigurationRepository,
notification.householdId
)
if (scheduledFor && scheduledFor.epochMilliseconds <= asOf.epochMilliseconds) {
return {
status: 'invalid',
reason: 'scheduled_for_past'
}
}
let nextDeliveryMode = deliveryMode ?? notification.deliveryMode
let nextDmRecipientMemberIds = dmRecipientMemberIds ?? notification.dmRecipientMemberIds
switch (nextDeliveryMode) {
case 'topic':
nextDmRecipientMemberIds = []
break
case 'dm_all':
nextDmRecipientMemberIds = [...memberMap.values()]
.filter(isActiveMember)
.map((member) => member.id)
break
case 'dm_selected': {
const selected = nextDmRecipientMemberIds
.map((memberId) => memberMap.get(memberId))
.filter((member): member is HouseholdMemberRecord => Boolean(member))
.filter(isActiveMember)
if (selected.length === 0) {
return {
status: 'invalid',
reason: 'dm_recipients_missing'
}
}
nextDmRecipientMemberIds = selected.map((member) => member.id)
break
}
default:
return {
status: 'invalid',
reason: 'delivery_mode_invalid'
}
}
const updated = await input.repository.updateNotification({
notificationId,
...(scheduledFor ? { scheduledFor } : {}),
...(timePrecision ? { timePrecision } : {}),
deliveryMode: nextDeliveryMode,
dmRecipientMemberIds: nextDmRecipientMemberIds,
updatedAt: asOf
})
if (!updated) {
return {
status: 'already_handled'
}
}
return {
status: 'updated',
notification: updated
}
},
async listDueNotifications(asOf = nowInstant()) {
const due = await input.repository.listDueNotifications(asOf)
const groupedMembers = new Map<string, Map<string, HouseholdMemberRecord>>()

View File

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