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:
@@ -181,6 +181,38 @@ export function createDbAdHocNotificationRepository(databaseUrl: string): {
|
||||
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) {
|
||||
const rows = await db
|
||||
.select(notificationSelect())
|
||||
|
||||
@@ -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'])
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>>()
|
||||
|
||||
@@ -6,7 +6,8 @@ export {
|
||||
type AdHocNotificationSummary,
|
||||
type CancelAdHocNotificationResult,
|
||||
type DeliverableAdHocNotification,
|
||||
type ScheduleAdHocNotificationResult
|
||||
type ScheduleAdHocNotificationResult,
|
||||
type UpdateAdHocNotificationResult
|
||||
} from './ad-hoc-notification-service'
|
||||
export {
|
||||
createAnonymousFeedbackService,
|
||||
|
||||
@@ -17,7 +17,8 @@ export {
|
||||
type AdHocNotificationTimePrecision,
|
||||
type CancelAdHocNotificationInput,
|
||||
type ClaimAdHocNotificationDeliveryResult,
|
||||
type CreateAdHocNotificationInput
|
||||
type CreateAdHocNotificationInput,
|
||||
type UpdateAdHocNotificationInput
|
||||
} from './notifications'
|
||||
export type {
|
||||
ClaimProcessedBotMessageInput,
|
||||
|
||||
@@ -53,6 +53,15 @@ export interface CancelAdHocNotificationInput {
|
||||
cancelledAt: Instant
|
||||
}
|
||||
|
||||
export interface UpdateAdHocNotificationInput {
|
||||
notificationId: string
|
||||
scheduledFor?: Instant
|
||||
timePrecision?: AdHocNotificationTimePrecision
|
||||
deliveryMode?: AdHocNotificationDeliveryMode
|
||||
dmRecipientMemberIds?: readonly string[]
|
||||
updatedAt: Instant
|
||||
}
|
||||
|
||||
export interface ClaimAdHocNotificationDeliveryResult {
|
||||
notificationId: string
|
||||
claimed: boolean
|
||||
@@ -66,6 +75,7 @@ export interface AdHocNotificationRepository {
|
||||
asOf: Instant
|
||||
): Promise<readonly AdHocNotificationRecord[]>
|
||||
cancelNotification(input: CancelAdHocNotificationInput): Promise<AdHocNotificationRecord | null>
|
||||
updateNotification(input: UpdateAdHocNotificationInput): Promise<AdHocNotificationRecord | null>
|
||||
listDueNotifications(asOf: Instant): Promise<readonly AdHocNotificationRecord[]>
|
||||
markNotificationSent(
|
||||
notificationId: string,
|
||||
|
||||
Reference in New Issue
Block a user