mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 19:04:02 +00:00
feat(bot): add ad hoc reminder notifications
This commit is contained in:
268
packages/application/src/ad-hoc-notification-service.test.ts
Normal file
268
packages/application/src/ad-hoc-notification-service.test.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { Temporal } from '@household/domain'
|
||||
import type {
|
||||
AdHocNotificationRecord,
|
||||
AdHocNotificationRepository,
|
||||
CancelAdHocNotificationInput,
|
||||
ClaimAdHocNotificationDeliveryResult,
|
||||
CreateAdHocNotificationInput,
|
||||
HouseholdConfigurationRepository,
|
||||
HouseholdMemberRecord
|
||||
} from '@household/ports'
|
||||
|
||||
import { createAdHocNotificationService } from './ad-hoc-notification-service'
|
||||
|
||||
class NotificationRepositoryStub implements AdHocNotificationRepository {
|
||||
notifications = new Map<string, AdHocNotificationRecord>()
|
||||
nextId = 1
|
||||
|
||||
async createNotification(input: CreateAdHocNotificationInput): Promise<AdHocNotificationRecord> {
|
||||
const id = `notif-${this.nextId++}`
|
||||
const record: AdHocNotificationRecord = {
|
||||
id,
|
||||
householdId: input.householdId,
|
||||
creatorMemberId: input.creatorMemberId,
|
||||
assigneeMemberId: input.assigneeMemberId ?? null,
|
||||
originalRequestText: input.originalRequestText,
|
||||
notificationText: input.notificationText,
|
||||
timezone: input.timezone,
|
||||
scheduledFor: input.scheduledFor,
|
||||
timePrecision: input.timePrecision,
|
||||
deliveryMode: input.deliveryMode,
|
||||
dmRecipientMemberIds: input.dmRecipientMemberIds ?? [],
|
||||
friendlyTagAssignee: input.friendlyTagAssignee,
|
||||
status: 'scheduled',
|
||||
sourceTelegramChatId: input.sourceTelegramChatId ?? null,
|
||||
sourceTelegramThreadId: input.sourceTelegramThreadId ?? null,
|
||||
sentAt: null,
|
||||
cancelledAt: null,
|
||||
cancelledByMemberId: null,
|
||||
createdAt: Temporal.Instant.from('2026-03-23T09:00:00Z'),
|
||||
updatedAt: Temporal.Instant.from('2026-03-23T09:00:00Z')
|
||||
}
|
||||
this.notifications.set(id, record)
|
||||
return record
|
||||
}
|
||||
|
||||
async getNotificationById(notificationId: string): Promise<AdHocNotificationRecord | null> {
|
||||
return this.notifications.get(notificationId) ?? null
|
||||
}
|
||||
|
||||
async listUpcomingNotificationsForHousehold(
|
||||
householdId: string,
|
||||
asOf: Temporal.Instant
|
||||
): Promise<readonly AdHocNotificationRecord[]> {
|
||||
return [...this.notifications.values()].filter(
|
||||
(notification) =>
|
||||
notification.householdId === householdId &&
|
||||
notification.status === 'scheduled' &&
|
||||
notification.scheduledFor.epochMilliseconds > asOf.epochMilliseconds
|
||||
)
|
||||
}
|
||||
|
||||
async cancelNotification(
|
||||
input: CancelAdHocNotificationInput
|
||||
): Promise<AdHocNotificationRecord | null> {
|
||||
const record = this.notifications.get(input.notificationId)
|
||||
if (!record || record.status !== 'scheduled') {
|
||||
return null
|
||||
}
|
||||
|
||||
const next = {
|
||||
...record,
|
||||
status: 'cancelled' as const,
|
||||
cancelledAt: input.cancelledAt,
|
||||
cancelledByMemberId: input.cancelledByMemberId
|
||||
}
|
||||
this.notifications.set(input.notificationId, next)
|
||||
return next
|
||||
}
|
||||
|
||||
async listDueNotifications(asOf: Temporal.Instant): Promise<readonly AdHocNotificationRecord[]> {
|
||||
return [...this.notifications.values()].filter(
|
||||
(notification) =>
|
||||
notification.status === 'scheduled' &&
|
||||
notification.scheduledFor.epochMilliseconds <= asOf.epochMilliseconds
|
||||
)
|
||||
}
|
||||
|
||||
async markNotificationSent(
|
||||
notificationId: string,
|
||||
sentAt: Temporal.Instant
|
||||
): Promise<AdHocNotificationRecord | null> {
|
||||
const record = this.notifications.get(notificationId)
|
||||
if (!record || record.status !== 'scheduled') {
|
||||
return null
|
||||
}
|
||||
|
||||
const next = {
|
||||
...record,
|
||||
status: 'sent' as const,
|
||||
sentAt
|
||||
}
|
||||
this.notifications.set(notificationId, next)
|
||||
return next
|
||||
}
|
||||
|
||||
async claimNotificationDelivery(
|
||||
notificationId: string
|
||||
): Promise<ClaimAdHocNotificationDeliveryResult> {
|
||||
return {
|
||||
notificationId,
|
||||
claimed: true
|
||||
}
|
||||
}
|
||||
|
||||
async releaseNotificationDelivery(): Promise<void> {}
|
||||
}
|
||||
|
||||
function member(
|
||||
input: Partial<HouseholdMemberRecord> & Pick<HouseholdMemberRecord, 'id'>
|
||||
): HouseholdMemberRecord {
|
||||
return {
|
||||
id: input.id,
|
||||
householdId: input.householdId ?? 'household-1',
|
||||
telegramUserId: input.telegramUserId ?? `${input.id}-tg`,
|
||||
displayName: input.displayName ?? input.id,
|
||||
status: input.status ?? 'active',
|
||||
preferredLocale: input.preferredLocale ?? 'ru',
|
||||
householdDefaultLocale: input.householdDefaultLocale ?? 'ru',
|
||||
rentShareWeight: input.rentShareWeight ?? 1,
|
||||
isAdmin: input.isAdmin ?? false
|
||||
}
|
||||
}
|
||||
|
||||
function createHouseholdRepository(
|
||||
members: readonly HouseholdMemberRecord[]
|
||||
): Pick<HouseholdConfigurationRepository, 'getHouseholdMember' | 'listHouseholdMembers'> {
|
||||
return {
|
||||
async getHouseholdMember(householdId, telegramUserId) {
|
||||
return (
|
||||
members.find(
|
||||
(member) => member.householdId === householdId && member.telegramUserId === telegramUserId
|
||||
) ?? null
|
||||
)
|
||||
},
|
||||
async listHouseholdMembers(householdId) {
|
||||
return members.filter((member) => member.householdId === householdId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('createAdHocNotificationService', () => {
|
||||
test('defaults date-only reminder to scheduled notification with topic delivery', async () => {
|
||||
const repository = new NotificationRepositoryStub()
|
||||
const members = [member({ id: 'creator' }), member({ id: 'assignee', displayName: 'Georgiy' })]
|
||||
const service = createAdHocNotificationService({
|
||||
repository,
|
||||
householdConfigurationRepository: createHouseholdRepository(members)
|
||||
})
|
||||
|
||||
const result = await service.scheduleNotification({
|
||||
householdId: 'household-1',
|
||||
creatorMemberId: 'creator',
|
||||
assigneeMemberId: 'assignee',
|
||||
originalRequestText: 'Напомни Георгию завтра',
|
||||
notificationText: 'пошпынять Георгия о том, позвонил ли он',
|
||||
timezone: 'Asia/Tbilisi',
|
||||
scheduledFor: Temporal.Instant.from('2026-03-24T08:00:00Z'),
|
||||
timePrecision: 'date_only_defaulted',
|
||||
deliveryMode: 'topic'
|
||||
})
|
||||
|
||||
expect(result.status).toBe('scheduled')
|
||||
if (result.status === 'scheduled') {
|
||||
expect(result.notification.deliveryMode).toBe('topic')
|
||||
expect(result.notification.assigneeMemberId).toBe('assignee')
|
||||
}
|
||||
})
|
||||
|
||||
test('expands dm_all to all active members', async () => {
|
||||
const repository = new NotificationRepositoryStub()
|
||||
const members = [
|
||||
member({ id: 'creator' }),
|
||||
member({ id: 'alice' }),
|
||||
member({ id: 'bob', status: 'away' }),
|
||||
member({ id: 'carol' })
|
||||
]
|
||||
const service = createAdHocNotificationService({
|
||||
repository,
|
||||
householdConfigurationRepository: createHouseholdRepository(members)
|
||||
})
|
||||
|
||||
const result = await service.scheduleNotification({
|
||||
householdId: 'household-1',
|
||||
creatorMemberId: 'creator',
|
||||
originalRequestText: 'remind everyone tomorrow',
|
||||
notificationText: 'pay rent',
|
||||
timezone: 'Asia/Tbilisi',
|
||||
scheduledFor: Temporal.Instant.from('2026-03-24T08:00:00Z'),
|
||||
timePrecision: 'date_only_defaulted',
|
||||
deliveryMode: 'dm_all'
|
||||
})
|
||||
|
||||
expect(result.status).toBe('scheduled')
|
||||
if (result.status === 'scheduled') {
|
||||
expect(result.notification.dmRecipientMemberIds).toEqual(['creator', 'alice', 'carol'])
|
||||
}
|
||||
})
|
||||
|
||||
test('rejects friendly mode without assignee', async () => {
|
||||
const repository = new NotificationRepositoryStub()
|
||||
const service = createAdHocNotificationService({
|
||||
repository,
|
||||
householdConfigurationRepository: createHouseholdRepository([member({ id: 'creator' })])
|
||||
})
|
||||
|
||||
const result = await service.scheduleNotification({
|
||||
householdId: 'household-1',
|
||||
creatorMemberId: 'creator',
|
||||
originalRequestText: 'remind tomorrow',
|
||||
notificationText: 'check rent',
|
||||
timezone: 'Asia/Tbilisi',
|
||||
scheduledFor: Temporal.Instant.from('2026-03-24T08:00:00Z'),
|
||||
timePrecision: 'date_only_defaulted',
|
||||
deliveryMode: 'topic',
|
||||
friendlyTagAssignee: true
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'invalid',
|
||||
reason: 'friendly_assignee_missing'
|
||||
})
|
||||
})
|
||||
|
||||
test('allows admin to cancel someone else notification', async () => {
|
||||
const repository = new NotificationRepositoryStub()
|
||||
const creator = member({ id: 'creator', telegramUserId: 'creator-tg' })
|
||||
const admin = member({ id: 'admin', telegramUserId: 'admin-tg', isAdmin: true })
|
||||
const service = createAdHocNotificationService({
|
||||
repository,
|
||||
householdConfigurationRepository: createHouseholdRepository([creator, admin])
|
||||
})
|
||||
|
||||
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.cancelNotification({
|
||||
notificationId: created.id,
|
||||
viewerMemberId: 'admin',
|
||||
asOf: Temporal.Instant.from('2026-03-23T09:00:00Z')
|
||||
})
|
||||
|
||||
expect(result.status).toBe('cancelled')
|
||||
if (result.status === 'cancelled') {
|
||||
expect(result.notification.cancelledByMemberId).toBe('admin')
|
||||
}
|
||||
})
|
||||
})
|
||||
377
packages/application/src/ad-hoc-notification-service.ts
Normal file
377
packages/application/src/ad-hoc-notification-service.ts
Normal file
@@ -0,0 +1,377 @@
|
||||
import { nowInstant, type Instant } from '@household/domain'
|
||||
import type {
|
||||
AdHocNotificationDeliveryMode,
|
||||
AdHocNotificationRecord,
|
||||
AdHocNotificationRepository,
|
||||
AdHocNotificationTimePrecision,
|
||||
HouseholdConfigurationRepository,
|
||||
HouseholdMemberRecord
|
||||
} from '@household/ports'
|
||||
|
||||
interface NotificationActor {
|
||||
memberId: string
|
||||
householdId: string
|
||||
isAdmin: boolean
|
||||
}
|
||||
|
||||
export interface AdHocNotificationMemberSummary {
|
||||
memberId: string
|
||||
telegramUserId: string
|
||||
displayName: string
|
||||
}
|
||||
|
||||
export interface AdHocNotificationSummary {
|
||||
id: string
|
||||
notificationText: string
|
||||
scheduledFor: Instant
|
||||
deliveryMode: AdHocNotificationDeliveryMode
|
||||
friendlyTagAssignee: boolean
|
||||
creatorDisplayName: string
|
||||
assigneeDisplayName: string | null
|
||||
canCancel: boolean
|
||||
}
|
||||
|
||||
export interface DeliverableAdHocNotification {
|
||||
notification: AdHocNotificationRecord
|
||||
creator: AdHocNotificationMemberSummary
|
||||
assignee: AdHocNotificationMemberSummary | null
|
||||
dmRecipients: readonly AdHocNotificationMemberSummary[]
|
||||
}
|
||||
|
||||
export type ScheduleAdHocNotificationResult =
|
||||
| {
|
||||
status: 'scheduled'
|
||||
notification: AdHocNotificationRecord
|
||||
}
|
||||
| {
|
||||
status: 'invalid'
|
||||
reason:
|
||||
| 'creator_not_found'
|
||||
| 'assignee_not_found'
|
||||
| 'dm_recipients_missing'
|
||||
| 'delivery_mode_invalid'
|
||||
| 'friendly_assignee_missing'
|
||||
| 'scheduled_for_past'
|
||||
}
|
||||
|
||||
export type CancelAdHocNotificationResult =
|
||||
| {
|
||||
status: 'cancelled'
|
||||
notification: AdHocNotificationRecord
|
||||
}
|
||||
| {
|
||||
status: 'not_found' | 'forbidden' | 'already_handled' | 'past_due'
|
||||
}
|
||||
|
||||
export interface AdHocNotificationService {
|
||||
scheduleNotification(input: {
|
||||
householdId: string
|
||||
creatorMemberId: string
|
||||
originalRequestText: string
|
||||
notificationText: string
|
||||
timezone: string
|
||||
scheduledFor: Instant
|
||||
timePrecision: AdHocNotificationTimePrecision
|
||||
deliveryMode: AdHocNotificationDeliveryMode
|
||||
assigneeMemberId?: string | null
|
||||
dmRecipientMemberIds?: readonly string[]
|
||||
friendlyTagAssignee?: boolean
|
||||
sourceTelegramChatId?: string | null
|
||||
sourceTelegramThreadId?: string | null
|
||||
}): Promise<ScheduleAdHocNotificationResult>
|
||||
listUpcomingNotifications(input: {
|
||||
householdId: string
|
||||
viewerMemberId: string
|
||||
asOf?: Instant
|
||||
}): Promise<readonly AdHocNotificationSummary[]>
|
||||
cancelNotification(input: {
|
||||
notificationId: string
|
||||
viewerMemberId: string
|
||||
asOf?: Instant
|
||||
}): Promise<CancelAdHocNotificationResult>
|
||||
listDueNotifications(asOf?: Instant): Promise<readonly DeliverableAdHocNotification[]>
|
||||
claimDueNotification(notificationId: string): Promise<boolean>
|
||||
releaseDueNotification(notificationId: string): Promise<void>
|
||||
markNotificationSent(
|
||||
notificationId: string,
|
||||
sentAt?: Instant
|
||||
): Promise<AdHocNotificationRecord | null>
|
||||
}
|
||||
|
||||
function summarizeMember(member: HouseholdMemberRecord): AdHocNotificationMemberSummary {
|
||||
return {
|
||||
memberId: member.id,
|
||||
telegramUserId: member.telegramUserId,
|
||||
displayName: member.displayName
|
||||
}
|
||||
}
|
||||
|
||||
function isActiveMember(member: HouseholdMemberRecord): boolean {
|
||||
return member.status === 'active'
|
||||
}
|
||||
|
||||
async function listMemberMap(
|
||||
repository: Pick<HouseholdConfigurationRepository, 'listHouseholdMembers'>,
|
||||
householdId: string
|
||||
): Promise<Map<string, HouseholdMemberRecord>> {
|
||||
const members = await repository.listHouseholdMembers(householdId)
|
||||
return new Map(members.map((member) => [member.id, member]))
|
||||
}
|
||||
|
||||
function canCancelNotification(
|
||||
notification: AdHocNotificationRecord,
|
||||
actor: NotificationActor
|
||||
): boolean {
|
||||
return actor.isAdmin || notification.creatorMemberId === actor.memberId
|
||||
}
|
||||
|
||||
export function createAdHocNotificationService(input: {
|
||||
repository: AdHocNotificationRepository
|
||||
householdConfigurationRepository: Pick<
|
||||
HouseholdConfigurationRepository,
|
||||
'getHouseholdMember' | 'listHouseholdMembers'
|
||||
>
|
||||
}): AdHocNotificationService {
|
||||
async function resolveActor(
|
||||
householdId: string,
|
||||
memberId: string
|
||||
): Promise<NotificationActor | null> {
|
||||
const members = await input.householdConfigurationRepository.listHouseholdMembers(householdId)
|
||||
const member = members.find((entry) => entry.id === memberId)
|
||||
if (!member) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
memberId: member.id,
|
||||
householdId: member.householdId,
|
||||
isAdmin: member.isAdmin
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
async scheduleNotification(notificationInput) {
|
||||
const memberMap = await listMemberMap(
|
||||
input.householdConfigurationRepository,
|
||||
notificationInput.householdId
|
||||
)
|
||||
const creator = memberMap.get(notificationInput.creatorMemberId)
|
||||
if (!creator) {
|
||||
return {
|
||||
status: 'invalid',
|
||||
reason: 'creator_not_found'
|
||||
}
|
||||
}
|
||||
|
||||
const assignee = notificationInput.assigneeMemberId
|
||||
? memberMap.get(notificationInput.assigneeMemberId)
|
||||
: null
|
||||
if (notificationInput.assigneeMemberId && !assignee) {
|
||||
return {
|
||||
status: 'invalid',
|
||||
reason: 'assignee_not_found'
|
||||
}
|
||||
}
|
||||
|
||||
const effectiveNow = nowInstant()
|
||||
if (notificationInput.scheduledFor.epochMilliseconds <= effectiveNow.epochMilliseconds) {
|
||||
return {
|
||||
status: 'invalid',
|
||||
reason: 'scheduled_for_past'
|
||||
}
|
||||
}
|
||||
|
||||
const friendlyTagAssignee = notificationInput.friendlyTagAssignee === true
|
||||
if (friendlyTagAssignee && !assignee) {
|
||||
return {
|
||||
status: 'invalid',
|
||||
reason: 'friendly_assignee_missing'
|
||||
}
|
||||
}
|
||||
|
||||
let dmRecipientMemberIds: readonly string[] = []
|
||||
switch (notificationInput.deliveryMode) {
|
||||
case 'topic':
|
||||
dmRecipientMemberIds = []
|
||||
break
|
||||
case 'dm_all':
|
||||
dmRecipientMemberIds = [...memberMap.values()]
|
||||
.filter(isActiveMember)
|
||||
.map((member) => member.id)
|
||||
break
|
||||
case 'dm_selected': {
|
||||
const selected = (notificationInput.dmRecipientMemberIds ?? [])
|
||||
.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'
|
||||
}
|
||||
}
|
||||
|
||||
dmRecipientMemberIds = selected.map((member) => member.id)
|
||||
break
|
||||
}
|
||||
default:
|
||||
return {
|
||||
status: 'invalid',
|
||||
reason: 'delivery_mode_invalid'
|
||||
}
|
||||
}
|
||||
|
||||
const notification = await input.repository.createNotification({
|
||||
householdId: notificationInput.householdId,
|
||||
creatorMemberId: notificationInput.creatorMemberId,
|
||||
assigneeMemberId: assignee?.id ?? null,
|
||||
originalRequestText: notificationInput.originalRequestText.trim(),
|
||||
notificationText: notificationInput.notificationText.trim(),
|
||||
timezone: notificationInput.timezone,
|
||||
scheduledFor: notificationInput.scheduledFor,
|
||||
timePrecision: notificationInput.timePrecision,
|
||||
deliveryMode: notificationInput.deliveryMode,
|
||||
dmRecipientMemberIds,
|
||||
friendlyTagAssignee,
|
||||
sourceTelegramChatId: notificationInput.sourceTelegramChatId ?? null,
|
||||
sourceTelegramThreadId: notificationInput.sourceTelegramThreadId ?? null
|
||||
})
|
||||
|
||||
return {
|
||||
status: 'scheduled',
|
||||
notification
|
||||
}
|
||||
},
|
||||
|
||||
async listUpcomingNotifications({ householdId, viewerMemberId, asOf = nowInstant() }) {
|
||||
const actor = await resolveActor(householdId, viewerMemberId)
|
||||
if (!actor) {
|
||||
return []
|
||||
}
|
||||
|
||||
const memberMap = await listMemberMap(input.householdConfigurationRepository, householdId)
|
||||
const notifications = await input.repository.listUpcomingNotificationsForHousehold(
|
||||
householdId,
|
||||
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)
|
||||
}))
|
||||
},
|
||||
|
||||
async cancelNotification({ notificationId, viewerMemberId, 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 || !canCancelNotification(notification, actor)) {
|
||||
return {
|
||||
status: 'forbidden'
|
||||
}
|
||||
}
|
||||
|
||||
const cancelled = await input.repository.cancelNotification({
|
||||
notificationId,
|
||||
cancelledByMemberId: actor.memberId,
|
||||
cancelledAt: asOf
|
||||
})
|
||||
|
||||
if (!cancelled) {
|
||||
return {
|
||||
status: 'already_handled'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'cancelled',
|
||||
notification: cancelled
|
||||
}
|
||||
},
|
||||
|
||||
async listDueNotifications(asOf = nowInstant()) {
|
||||
const due = await input.repository.listDueNotifications(asOf)
|
||||
const groupedMembers = new Map<string, Map<string, HouseholdMemberRecord>>()
|
||||
|
||||
async function membersForHousehold(householdId: string) {
|
||||
const existing = groupedMembers.get(householdId)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
|
||||
const memberMap = await listMemberMap(input.householdConfigurationRepository, householdId)
|
||||
groupedMembers.set(householdId, memberMap)
|
||||
return memberMap
|
||||
}
|
||||
|
||||
const results: DeliverableAdHocNotification[] = []
|
||||
for (const notification of due) {
|
||||
const memberMap = await membersForHousehold(notification.householdId)
|
||||
const creator = memberMap.get(notification.creatorMemberId)
|
||||
if (!creator) {
|
||||
continue
|
||||
}
|
||||
|
||||
const assignee = notification.assigneeMemberId
|
||||
? (memberMap.get(notification.assigneeMemberId) ?? null)
|
||||
: null
|
||||
const dmRecipients = notification.dmRecipientMemberIds
|
||||
.map((memberId) => memberMap.get(memberId))
|
||||
.filter((member): member is HouseholdMemberRecord => Boolean(member))
|
||||
|
||||
results.push({
|
||||
notification,
|
||||
creator: summarizeMember(creator),
|
||||
assignee: assignee ? summarizeMember(assignee) : null,
|
||||
dmRecipients: dmRecipients.map(summarizeMember)
|
||||
})
|
||||
}
|
||||
|
||||
return results
|
||||
},
|
||||
|
||||
async claimDueNotification(notificationId) {
|
||||
const result = await input.repository.claimNotificationDelivery(notificationId)
|
||||
return result.claimed
|
||||
},
|
||||
|
||||
releaseDueNotification(notificationId) {
|
||||
return input.repository.releaseNotificationDelivery(notificationId)
|
||||
},
|
||||
|
||||
markNotificationSent(notificationId, sentAt = nowInstant()) {
|
||||
return input.repository.markNotificationSent(notificationId, sentAt)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,13 @@
|
||||
export { calculateMonthlySettlement } from './settlement-engine'
|
||||
export {
|
||||
createAdHocNotificationService,
|
||||
type AdHocNotificationMemberSummary,
|
||||
type AdHocNotificationService,
|
||||
type AdHocNotificationSummary,
|
||||
type CancelAdHocNotificationResult,
|
||||
type DeliverableAdHocNotification,
|
||||
type ScheduleAdHocNotificationResult
|
||||
} from './ad-hoc-notification-service'
|
||||
export {
|
||||
createAnonymousFeedbackService,
|
||||
type AnonymousFeedbackService,
|
||||
|
||||
Reference in New Issue
Block a user