mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 08:54:04 +00:00
refactor(bot): replace reminder polling with scheduled dispatches
This commit is contained in:
@@ -10,6 +10,7 @@
|
|||||||
"lint": "oxlint \"src\""
|
"lint": "oxlint \"src\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-scheduler": "^3.913.0",
|
||||||
"@household/adapters-db": "workspace:*",
|
"@household/adapters-db": "workspace:*",
|
||||||
"@household/application": "workspace:*",
|
"@household/application": "workspace:*",
|
||||||
"@household/db": "workspace:*",
|
"@household/db": "workspace:*",
|
||||||
|
|||||||
@@ -1,118 +0,0 @@
|
|||||||
import { describe, expect, test } from 'bun:test'
|
|
||||||
|
|
||||||
import { Temporal } from '@household/domain'
|
|
||||||
import type { AdHocNotificationService, DeliverableAdHocNotification } from '@household/application'
|
|
||||||
|
|
||||||
import { createAdHocNotificationJobsHandler } from './ad-hoc-notification-jobs'
|
|
||||||
|
|
||||||
function dueNotification(
|
|
||||||
input: Partial<DeliverableAdHocNotification['notification']> = {}
|
|
||||||
): DeliverableAdHocNotification {
|
|
||||||
return {
|
|
||||||
notification: {
|
|
||||||
id: input.id ?? 'notif-1',
|
|
||||||
householdId: input.householdId ?? 'household-1',
|
|
||||||
creatorMemberId: input.creatorMemberId ?? 'creator',
|
|
||||||
assigneeMemberId: input.assigneeMemberId ?? 'assignee',
|
|
||||||
originalRequestText: 'raw',
|
|
||||||
notificationText:
|
|
||||||
input.notificationText ?? 'Dima, time to check whether Georgiy has called already.',
|
|
||||||
timezone: input.timezone ?? 'Asia/Tbilisi',
|
|
||||||
scheduledFor: input.scheduledFor ?? Temporal.Instant.from('2026-03-23T09:00:00Z'),
|
|
||||||
timePrecision: input.timePrecision ?? 'exact',
|
|
||||||
deliveryMode: input.deliveryMode ?? 'topic',
|
|
||||||
dmRecipientMemberIds: input.dmRecipientMemberIds ?? [],
|
|
||||||
friendlyTagAssignee: input.friendlyTagAssignee ?? true,
|
|
||||||
status: input.status ?? 'scheduled',
|
|
||||||
sourceTelegramChatId: null,
|
|
||||||
sourceTelegramThreadId: null,
|
|
||||||
sentAt: null,
|
|
||||||
cancelledAt: null,
|
|
||||||
cancelledByMemberId: null,
|
|
||||||
createdAt: Temporal.Instant.from('2026-03-22T09:00:00Z'),
|
|
||||||
updatedAt: Temporal.Instant.from('2026-03-22T09:00:00Z')
|
|
||||||
},
|
|
||||||
creator: {
|
|
||||||
memberId: 'creator',
|
|
||||||
telegramUserId: '111',
|
|
||||||
displayName: 'Dima'
|
|
||||||
},
|
|
||||||
assignee: {
|
|
||||||
memberId: 'assignee',
|
|
||||||
telegramUserId: '222',
|
|
||||||
displayName: 'Georgiy'
|
|
||||||
},
|
|
||||||
dmRecipients: [
|
|
||||||
{
|
|
||||||
memberId: 'recipient',
|
|
||||||
telegramUserId: '333',
|
|
||||||
displayName: 'Alice'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('createAdHocNotificationJobsHandler', () => {
|
|
||||||
test('delivers topic notifications and marks them sent', async () => {
|
|
||||||
const sentTopicMessages: string[] = []
|
|
||||||
const sentNotifications: string[] = []
|
|
||||||
|
|
||||||
const service: AdHocNotificationService = {
|
|
||||||
scheduleNotification: async () => {
|
|
||||||
throw new Error('not used')
|
|
||||||
},
|
|
||||||
listUpcomingNotifications: async () => [],
|
|
||||||
cancelNotification: async () => ({ status: 'not_found' }),
|
|
||||||
updateNotification: async () => ({ status: 'not_found' }),
|
|
||||||
listDueNotifications: async () => [dueNotification()],
|
|
||||||
claimDueNotification: async () => true,
|
|
||||||
releaseDueNotification: async () => {},
|
|
||||||
markNotificationSent: async (notificationId) => {
|
|
||||||
sentNotifications.push(notificationId)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handler = createAdHocNotificationJobsHandler({
|
|
||||||
notificationService: service,
|
|
||||||
householdConfigurationRepository: {
|
|
||||||
async getHouseholdChatByHouseholdId() {
|
|
||||||
return {
|
|
||||||
householdId: 'household-1',
|
|
||||||
householdName: 'Kojori',
|
|
||||||
telegramChatId: '777',
|
|
||||||
telegramChatType: 'supergroup',
|
|
||||||
title: 'Kojori',
|
|
||||||
defaultLocale: 'ru'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async getHouseholdTopicBinding() {
|
|
||||||
return {
|
|
||||||
householdId: 'household-1',
|
|
||||||
role: 'reminders',
|
|
||||||
telegramThreadId: '103',
|
|
||||||
topicName: 'Reminders'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
sendTopicMessage: async (input) => {
|
|
||||||
sentTopicMessages.push(`${input.chatId}:${input.threadId}:${input.text}`)
|
|
||||||
},
|
|
||||||
sendDirectMessage: async () => {}
|
|
||||||
})
|
|
||||||
|
|
||||||
const response = await handler.handle(
|
|
||||||
new Request('http://localhost/jobs/notifications/due', {
|
|
||||||
method: 'POST'
|
|
||||||
})
|
|
||||||
)
|
|
||||||
const payload = (await response.json()) as { ok: boolean; notifications: { outcome: string }[] }
|
|
||||||
|
|
||||||
expect(payload.ok).toBe(true)
|
|
||||||
expect(payload.notifications[0]?.outcome).toBe('sent')
|
|
||||||
expect(sentTopicMessages[0]).toContain(
|
|
||||||
'Dima, time to check whether Georgiy has called already.'
|
|
||||||
)
|
|
||||||
expect(sentNotifications).toEqual(['notif-1'])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
import type { AdHocNotificationService, DeliverableAdHocNotification } from '@household/application'
|
|
||||||
import { nowInstant } from '@household/domain'
|
|
||||||
import type { Logger } from '@household/observability'
|
|
||||||
import type { HouseholdConfigurationRepository } from '@household/ports'
|
|
||||||
|
|
||||||
import { buildTopicNotificationText } from './ad-hoc-notifications'
|
|
||||||
|
|
||||||
interface DueNotificationJobRequestBody {
|
|
||||||
dryRun?: boolean
|
|
||||||
jobId?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function json(body: object, status = 200): Response {
|
|
||||||
return new Response(JSON.stringify(body), {
|
|
||||||
status,
|
|
||||||
headers: {
|
|
||||||
'content-type': 'application/json; charset=utf-8'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readBody(request: Request): Promise<DueNotificationJobRequestBody> {
|
|
||||||
const text = await request.text()
|
|
||||||
if (text.trim().length === 0) {
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return JSON.parse(text) as DueNotificationJobRequestBody
|
|
||||||
} catch {
|
|
||||||
throw new Error('Invalid JSON body')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createAdHocNotificationJobsHandler(options: {
|
|
||||||
notificationService: AdHocNotificationService
|
|
||||||
householdConfigurationRepository: Pick<
|
|
||||||
HouseholdConfigurationRepository,
|
|
||||||
'getHouseholdChatByHouseholdId' | 'getHouseholdTopicBinding'
|
|
||||||
>
|
|
||||||
sendTopicMessage: (input: {
|
|
||||||
householdId: string
|
|
||||||
chatId: string
|
|
||||||
threadId: string | null
|
|
||||||
text: string
|
|
||||||
parseMode?: 'HTML'
|
|
||||||
}) => Promise<void>
|
|
||||||
sendDirectMessage: (input: { telegramUserId: string; text: string }) => Promise<void>
|
|
||||||
logger?: Logger
|
|
||||||
}): {
|
|
||||||
handle: (request: Request) => Promise<Response>
|
|
||||||
} {
|
|
||||||
async function deliver(notification: DeliverableAdHocNotification) {
|
|
||||||
switch (notification.notification.deliveryMode) {
|
|
||||||
case 'topic': {
|
|
||||||
const [chat, reminderTopic] = await Promise.all([
|
|
||||||
options.householdConfigurationRepository.getHouseholdChatByHouseholdId(
|
|
||||||
notification.notification.householdId
|
|
||||||
),
|
|
||||||
options.householdConfigurationRepository.getHouseholdTopicBinding(
|
|
||||||
notification.notification.householdId,
|
|
||||||
'reminders'
|
|
||||||
)
|
|
||||||
])
|
|
||||||
|
|
||||||
if (!chat) {
|
|
||||||
throw new Error(
|
|
||||||
`Household chat not configured for ${notification.notification.householdId}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const content = buildTopicNotificationText({
|
|
||||||
notificationText: notification.notification.notificationText
|
|
||||||
})
|
|
||||||
await options.sendTopicMessage({
|
|
||||||
householdId: notification.notification.householdId,
|
|
||||||
chatId: chat.telegramChatId,
|
|
||||||
threadId: reminderTopic?.telegramThreadId ?? null,
|
|
||||||
text: content.text,
|
|
||||||
parseMode: content.parseMode
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
case 'dm_all':
|
|
||||||
case 'dm_selected': {
|
|
||||||
for (const recipient of notification.dmRecipients) {
|
|
||||||
await options.sendDirectMessage({
|
|
||||||
telegramUserId: recipient.telegramUserId,
|
|
||||||
text: notification.notification.notificationText
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
handle: async (request) => {
|
|
||||||
if (request.method !== 'POST') {
|
|
||||||
return json({ ok: false, error: 'Method Not Allowed' }, 405)
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const body = await readBody(request)
|
|
||||||
const now = nowInstant()
|
|
||||||
const due = await options.notificationService.listDueNotifications(now)
|
|
||||||
const dispatches: Array<{
|
|
||||||
notificationId: string
|
|
||||||
householdId: string
|
|
||||||
outcome: 'dry-run' | 'sent' | 'duplicate' | 'failed'
|
|
||||||
error?: string
|
|
||||||
}> = []
|
|
||||||
|
|
||||||
for (const notification of due) {
|
|
||||||
if (body.dryRun === true) {
|
|
||||||
dispatches.push({
|
|
||||||
notificationId: notification.notification.id,
|
|
||||||
householdId: notification.notification.householdId,
|
|
||||||
outcome: 'dry-run'
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const claimed = await options.notificationService.claimDueNotification(
|
|
||||||
notification.notification.id
|
|
||||||
)
|
|
||||||
if (!claimed) {
|
|
||||||
dispatches.push({
|
|
||||||
notificationId: notification.notification.id,
|
|
||||||
householdId: notification.notification.householdId,
|
|
||||||
outcome: 'duplicate'
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await deliver(notification)
|
|
||||||
await options.notificationService.markNotificationSent(
|
|
||||||
notification.notification.id,
|
|
||||||
now
|
|
||||||
)
|
|
||||||
dispatches.push({
|
|
||||||
notificationId: notification.notification.id,
|
|
||||||
householdId: notification.notification.householdId,
|
|
||||||
outcome: 'sent'
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
await options.notificationService.releaseDueNotification(notification.notification.id)
|
|
||||||
dispatches.push({
|
|
||||||
notificationId: notification.notification.id,
|
|
||||||
householdId: notification.notification.householdId,
|
|
||||||
outcome: 'failed',
|
|
||||||
error: error instanceof Error ? error.message : 'Unknown delivery error'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
options.logger?.info(
|
|
||||||
{
|
|
||||||
event: 'scheduler.ad_hoc_notifications.dispatch',
|
|
||||||
notificationCount: dispatches.length,
|
|
||||||
jobId: body.jobId ?? request.headers.get('x-cloudscheduler-jobname') ?? null,
|
|
||||||
dryRun: body.dryRun === true
|
|
||||||
},
|
|
||||||
'Ad hoc notification job completed'
|
|
||||||
)
|
|
||||||
|
|
||||||
return json({
|
|
||||||
ok: true,
|
|
||||||
dryRun: body.dryRun === true,
|
|
||||||
notifications: dispatches
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
options.logger?.error(
|
|
||||||
{
|
|
||||||
event: 'scheduler.ad_hoc_notifications.failed',
|
|
||||||
error: error instanceof Error ? error.message : String(error)
|
|
||||||
},
|
|
||||||
'Ad hoc notification job failed'
|
|
||||||
)
|
|
||||||
|
|
||||||
return json(
|
|
||||||
{
|
|
||||||
ok: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Unknown error'
|
|
||||||
},
|
|
||||||
500
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { webhookCallback } from 'grammy'
|
import { webhookCallback } from 'grammy'
|
||||||
import type { InlineKeyboardMarkup } from 'grammy/types'
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createAdHocNotificationService,
|
createAdHocNotificationService,
|
||||||
@@ -11,7 +10,7 @@ import {
|
|||||||
createLocalePreferenceService,
|
createLocalePreferenceService,
|
||||||
createMiniAppAdminService,
|
createMiniAppAdminService,
|
||||||
createPaymentConfirmationService,
|
createPaymentConfirmationService,
|
||||||
createReminderJobService
|
createScheduledDispatchService
|
||||||
} from '@household/application'
|
} from '@household/application'
|
||||||
import {
|
import {
|
||||||
createDbAdHocNotificationRepository,
|
createDbAdHocNotificationRepository,
|
||||||
@@ -19,13 +18,12 @@ import {
|
|||||||
createDbFinanceRepository,
|
createDbFinanceRepository,
|
||||||
createDbHouseholdConfigurationRepository,
|
createDbHouseholdConfigurationRepository,
|
||||||
createDbProcessedBotMessageRepository,
|
createDbProcessedBotMessageRepository,
|
||||||
createDbReminderDispatchRepository,
|
createDbScheduledDispatchRepository,
|
||||||
createDbTelegramPendingActionRepository,
|
createDbTelegramPendingActionRepository,
|
||||||
createDbTopicMessageHistoryRepository
|
createDbTopicMessageHistoryRepository
|
||||||
} from '@household/adapters-db'
|
} from '@household/adapters-db'
|
||||||
import { configureLogger, getLogger } from '@household/observability'
|
import { configureLogger, getLogger } from '@household/observability'
|
||||||
|
|
||||||
import { createAdHocNotificationJobsHandler } from './ad-hoc-notification-jobs'
|
|
||||||
import { registerAdHocNotifications } from './ad-hoc-notifications'
|
import { registerAdHocNotifications } from './ad-hoc-notifications'
|
||||||
import { registerAnonymousFeedback } from './anonymous-feedback'
|
import { registerAnonymousFeedback } from './anonymous-feedback'
|
||||||
import {
|
import {
|
||||||
@@ -39,6 +37,8 @@ import { createTelegramBot } from './bot'
|
|||||||
import { getBotRuntimeConfig, type BotRuntimeConfig } from './config'
|
import { getBotRuntimeConfig, type BotRuntimeConfig } from './config'
|
||||||
import { registerHouseholdSetupCommands } from './household-setup'
|
import { registerHouseholdSetupCommands } from './household-setup'
|
||||||
import { HouseholdContextCache } from './household-context-cache'
|
import { HouseholdContextCache } from './household-context-cache'
|
||||||
|
import { createAwsScheduledDispatchScheduler } from './aws-scheduled-dispatch-scheduler'
|
||||||
|
import { createGcpScheduledDispatchScheduler } from './gcp-scheduled-dispatch-scheduler'
|
||||||
import { createMiniAppAuthHandler, createMiniAppJoinHandler } from './miniapp-auth'
|
import { createMiniAppAuthHandler, createMiniAppJoinHandler } from './miniapp-auth'
|
||||||
import {
|
import {
|
||||||
createMiniAppApproveMemberHandler,
|
createMiniAppApproveMemberHandler,
|
||||||
@@ -86,9 +86,9 @@ import {
|
|||||||
registerConfiguredPurchaseTopicIngestion
|
registerConfiguredPurchaseTopicIngestion
|
||||||
} from './purchase-topic-ingestion'
|
} from './purchase-topic-ingestion'
|
||||||
import { registerConfiguredPaymentTopicIngestion } from './payment-topic-ingestion'
|
import { registerConfiguredPaymentTopicIngestion } from './payment-topic-ingestion'
|
||||||
import { createReminderJobsHandler } from './reminder-jobs'
|
|
||||||
import { registerReminderTopicUtilities } from './reminder-topic-utilities'
|
import { registerReminderTopicUtilities } from './reminder-topic-utilities'
|
||||||
import { createSchedulerRequestAuthorizer } from './scheduler-auth'
|
import { createSchedulerRequestAuthorizer } from './scheduler-auth'
|
||||||
|
import { createScheduledDispatchHandler } from './scheduled-dispatch-handler'
|
||||||
import { createBotWebhookServer } from './server'
|
import { createBotWebhookServer } from './server'
|
||||||
import { createTopicProcessor } from './topic-processor'
|
import { createTopicProcessor } from './topic-processor'
|
||||||
|
|
||||||
@@ -134,8 +134,42 @@ export async function createBotRuntimeApp(): Promise<BotRuntimeApp> {
|
|||||||
repository: householdConfigurationRepositoryClient.repository
|
repository: householdConfigurationRepositoryClient.repository
|
||||||
})
|
})
|
||||||
: null
|
: null
|
||||||
|
const scheduledDispatchRepositoryClient =
|
||||||
|
runtime.databaseUrl && runtime.scheduledDispatch
|
||||||
|
? createDbScheduledDispatchRepository(runtime.databaseUrl)
|
||||||
|
: null
|
||||||
|
const scheduledDispatchScheduler =
|
||||||
|
runtime.scheduledDispatch && runtime.schedulerSharedSecret
|
||||||
|
? runtime.scheduledDispatch.provider === 'gcp-cloud-tasks'
|
||||||
|
? createGcpScheduledDispatchScheduler({
|
||||||
|
projectId: runtime.scheduledDispatch.projectId,
|
||||||
|
location: runtime.scheduledDispatch.location,
|
||||||
|
queue: runtime.scheduledDispatch.queue,
|
||||||
|
publicBaseUrl: runtime.scheduledDispatch.publicBaseUrl,
|
||||||
|
sharedSecret: runtime.schedulerSharedSecret
|
||||||
|
})
|
||||||
|
: createAwsScheduledDispatchScheduler({
|
||||||
|
region: runtime.scheduledDispatch.region,
|
||||||
|
targetLambdaArn: runtime.scheduledDispatch.targetLambdaArn,
|
||||||
|
roleArn: runtime.scheduledDispatch.roleArn,
|
||||||
|
groupName: runtime.scheduledDispatch.groupName
|
||||||
|
})
|
||||||
|
: null
|
||||||
|
const scheduledDispatchService =
|
||||||
|
scheduledDispatchRepositoryClient &&
|
||||||
|
scheduledDispatchScheduler &&
|
||||||
|
householdConfigurationRepositoryClient
|
||||||
|
? createScheduledDispatchService({
|
||||||
|
repository: scheduledDispatchRepositoryClient.repository,
|
||||||
|
scheduler: scheduledDispatchScheduler,
|
||||||
|
householdConfigurationRepository: householdConfigurationRepositoryClient.repository
|
||||||
|
})
|
||||||
|
: null
|
||||||
const miniAppAdminService = householdConfigurationRepositoryClient
|
const miniAppAdminService = householdConfigurationRepositoryClient
|
||||||
? createMiniAppAdminService(householdConfigurationRepositoryClient.repository)
|
? createMiniAppAdminService(
|
||||||
|
householdConfigurationRepositoryClient.repository,
|
||||||
|
scheduledDispatchService ?? undefined
|
||||||
|
)
|
||||||
: null
|
: null
|
||||||
const localePreferenceService = householdConfigurationRepositoryClient
|
const localePreferenceService = householdConfigurationRepositoryClient
|
||||||
? createLocalePreferenceService(householdConfigurationRepositoryClient.repository)
|
? createLocalePreferenceService(householdConfigurationRepositoryClient.repository)
|
||||||
@@ -200,7 +234,12 @@ export async function createBotRuntimeApp(): Promise<BotRuntimeApp> {
|
|||||||
adHocNotificationRepositoryClient && householdConfigurationRepositoryClient
|
adHocNotificationRepositoryClient && householdConfigurationRepositoryClient
|
||||||
? createAdHocNotificationService({
|
? createAdHocNotificationService({
|
||||||
repository: adHocNotificationRepositoryClient.repository,
|
repository: adHocNotificationRepositoryClient.repository,
|
||||||
householdConfigurationRepository: householdConfigurationRepositoryClient.repository
|
householdConfigurationRepository: householdConfigurationRepositoryClient.repository,
|
||||||
|
...(scheduledDispatchService
|
||||||
|
? {
|
||||||
|
scheduledDispatchService
|
||||||
|
}
|
||||||
|
: {})
|
||||||
})
|
})
|
||||||
: null
|
: null
|
||||||
|
|
||||||
@@ -289,6 +328,10 @@ export async function createBotRuntimeApp(): Promise<BotRuntimeApp> {
|
|||||||
shutdownTasks.push(adHocNotificationRepositoryClient.close)
|
shutdownTasks.push(adHocNotificationRepositoryClient.close)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (scheduledDispatchRepositoryClient) {
|
||||||
|
shutdownTasks.push(scheduledDispatchRepositoryClient.close)
|
||||||
|
}
|
||||||
|
|
||||||
if (purchaseRepositoryClient && householdConfigurationRepositoryClient) {
|
if (purchaseRepositoryClient && householdConfigurationRepositoryClient) {
|
||||||
registerConfiguredPurchaseTopicIngestion(
|
registerConfiguredPurchaseTopicIngestion(
|
||||||
bot,
|
bot,
|
||||||
@@ -375,7 +418,8 @@ export async function createBotRuntimeApp(): Promise<BotRuntimeApp> {
|
|||||||
registerHouseholdSetupCommands({
|
registerHouseholdSetupCommands({
|
||||||
bot,
|
bot,
|
||||||
householdSetupService: createHouseholdSetupService(
|
householdSetupService: createHouseholdSetupService(
|
||||||
householdConfigurationRepositoryClient.repository
|
householdConfigurationRepositoryClient.repository,
|
||||||
|
scheduledDispatchService ?? undefined
|
||||||
),
|
),
|
||||||
householdAdminService: createHouseholdAdminService(
|
householdAdminService: createHouseholdAdminService(
|
||||||
householdConfigurationRepositoryClient.repository
|
householdConfigurationRepositoryClient.repository
|
||||||
@@ -419,65 +463,13 @@ export async function createBotRuntimeApp(): Promise<BotRuntimeApp> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const reminderJobs = runtime.reminderJobsEnabled
|
const scheduledDispatchHandler =
|
||||||
? (() => {
|
scheduledDispatchService &&
|
||||||
const reminderRepositoryClient = createDbReminderDispatchRepository(runtime.databaseUrl!)
|
adHocNotificationRepositoryClient &&
|
||||||
const reminderService = createReminderJobService(reminderRepositoryClient.repository)
|
|
||||||
|
|
||||||
shutdownTasks.push(reminderRepositoryClient.close)
|
|
||||||
|
|
||||||
return createReminderJobsHandler({
|
|
||||||
listReminderTargets: () =>
|
|
||||||
householdConfigurationRepositoryClient!.repository.listReminderTargets(),
|
|
||||||
ensureBillingCycle: async ({ householdId, at }) => {
|
|
||||||
await financeServiceForHousehold(householdId).ensureExpectedCycle(at)
|
|
||||||
},
|
|
||||||
releaseReminderDispatch: (input) =>
|
|
||||||
reminderRepositoryClient.repository.releaseReminderDispatch(input),
|
|
||||||
sendReminderMessage: async (target, content) => {
|
|
||||||
const threadId =
|
|
||||||
target.telegramThreadId !== null ? Number(target.telegramThreadId) : undefined
|
|
||||||
|
|
||||||
if (target.telegramThreadId !== null && (!threadId || !Number.isInteger(threadId))) {
|
|
||||||
throw new Error(
|
|
||||||
`Invalid reminder thread id for household ${target.householdId}: ${target.telegramThreadId}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
await bot.api.sendMessage(target.telegramChatId, content.text, {
|
|
||||||
...(threadId
|
|
||||||
? {
|
|
||||||
message_thread_id: threadId
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
...(content.replyMarkup
|
|
||||||
? {
|
|
||||||
reply_markup: content.replyMarkup as InlineKeyboardMarkup
|
|
||||||
}
|
|
||||||
: {})
|
|
||||||
})
|
|
||||||
},
|
|
||||||
reminderService,
|
|
||||||
...(runtime.miniAppUrl
|
|
||||||
? {
|
|
||||||
miniAppUrl: runtime.miniAppUrl
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
...(bot.botInfo?.username
|
|
||||||
? {
|
|
||||||
botUsername: bot.botInfo.username
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
logger: getLogger('scheduler')
|
|
||||||
})
|
|
||||||
})()
|
|
||||||
: null
|
|
||||||
const adHocNotificationJobs =
|
|
||||||
runtime.reminderJobsEnabled &&
|
|
||||||
adHocNotificationService &&
|
|
||||||
householdConfigurationRepositoryClient
|
householdConfigurationRepositoryClient
|
||||||
? createAdHocNotificationJobsHandler({
|
? createScheduledDispatchHandler({
|
||||||
notificationService: adHocNotificationService,
|
scheduledDispatchService,
|
||||||
|
adHocNotificationRepository: adHocNotificationRepositoryClient.repository,
|
||||||
householdConfigurationRepository: householdConfigurationRepositoryClient.repository,
|
householdConfigurationRepository: householdConfigurationRepositoryClient.repository,
|
||||||
sendTopicMessage: async (input) => {
|
sendTopicMessage: async (input) => {
|
||||||
const threadId = input.threadId ? Number(input.threadId) : undefined
|
const threadId = input.threadId ? Number(input.threadId) : undefined
|
||||||
@@ -491,23 +483,38 @@ export async function createBotRuntimeApp(): Promise<BotRuntimeApp> {
|
|||||||
? {
|
? {
|
||||||
parse_mode: input.parseMode
|
parse_mode: input.parseMode
|
||||||
}
|
}
|
||||||
|
: {}),
|
||||||
|
...(input.replyMarkup
|
||||||
|
? {
|
||||||
|
reply_markup: input.replyMarkup
|
||||||
|
}
|
||||||
: {})
|
: {})
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
sendDirectMessage: async (input) => {
|
sendDirectMessage: async (input) => {
|
||||||
await bot.api.sendMessage(input.telegramUserId, input.text)
|
await bot.api.sendMessage(input.telegramUserId, input.text)
|
||||||
},
|
},
|
||||||
|
...(runtime.miniAppUrl
|
||||||
|
? {
|
||||||
|
miniAppUrl: runtime.miniAppUrl
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(bot.botInfo?.username
|
||||||
|
? {
|
||||||
|
botUsername: bot.botInfo.username
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
logger: getLogger('scheduler')
|
logger: getLogger('scheduler')
|
||||||
})
|
})
|
||||||
: null
|
: null
|
||||||
|
|
||||||
if (!runtime.reminderJobsEnabled) {
|
if (!scheduledDispatchHandler) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
{
|
{
|
||||||
event: 'runtime.feature_disabled',
|
event: 'runtime.feature_disabled',
|
||||||
feature: 'reminder-jobs'
|
feature: 'scheduled-dispatch'
|
||||||
},
|
},
|
||||||
'Reminder jobs are disabled. Set DATABASE_URL and either SCHEDULER_SHARED_SECRET or SCHEDULER_OIDC_ALLOWED_EMAILS to enable.'
|
'Scheduled dispatch is disabled. Configure DATABASE_URL, SCHEDULED_DISPATCH_PROVIDER, and scheduler auth to enable reminder delivery.'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -918,7 +925,7 @@ export async function createBotRuntimeApp(): Promise<BotRuntimeApp> {
|
|||||||
})
|
})
|
||||||
: undefined,
|
: undefined,
|
||||||
scheduler:
|
scheduler:
|
||||||
(reminderJobs || adHocNotificationJobs) && runtime.schedulerSharedSecret
|
scheduledDispatchHandler && runtime.schedulerSharedSecret
|
||||||
? {
|
? {
|
||||||
pathPrefix: '/jobs',
|
pathPrefix: '/jobs',
|
||||||
authorize: createSchedulerRequestAuthorizer({
|
authorize: createSchedulerRequestAuthorizer({
|
||||||
@@ -926,37 +933,25 @@ export async function createBotRuntimeApp(): Promise<BotRuntimeApp> {
|
|||||||
oidcAllowedEmails: runtime.schedulerOidcAllowedEmails
|
oidcAllowedEmails: runtime.schedulerOidcAllowedEmails
|
||||||
}).authorize,
|
}).authorize,
|
||||||
handler: async (request, jobPath) => {
|
handler: async (request, jobPath) => {
|
||||||
if (jobPath.startsWith('reminder/')) {
|
if (jobPath.startsWith('dispatch/')) {
|
||||||
return reminderJobs
|
return scheduledDispatchHandler
|
||||||
? reminderJobs.handle(request, jobPath.slice('reminder/'.length))
|
? scheduledDispatchHandler.handle(request, jobPath.slice('dispatch/'.length))
|
||||||
: new Response('Not Found', { status: 404 })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (jobPath === 'notifications/due') {
|
|
||||||
return adHocNotificationJobs
|
|
||||||
? adHocNotificationJobs.handle(request)
|
|
||||||
: new Response('Not Found', { status: 404 })
|
: new Response('Not Found', { status: 404 })
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response('Not Found', { status: 404 })
|
return new Response('Not Found', { status: 404 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
: reminderJobs || adHocNotificationJobs
|
: scheduledDispatchHandler
|
||||||
? {
|
? {
|
||||||
pathPrefix: '/jobs',
|
pathPrefix: '/jobs',
|
||||||
authorize: createSchedulerRequestAuthorizer({
|
authorize: createSchedulerRequestAuthorizer({
|
||||||
oidcAllowedEmails: runtime.schedulerOidcAllowedEmails
|
oidcAllowedEmails: runtime.schedulerOidcAllowedEmails
|
||||||
}).authorize,
|
}).authorize,
|
||||||
handler: async (request, jobPath) => {
|
handler: async (request, jobPath) => {
|
||||||
if (jobPath.startsWith('reminder/')) {
|
if (jobPath.startsWith('dispatch/')) {
|
||||||
return reminderJobs
|
return scheduledDispatchHandler
|
||||||
? reminderJobs.handle(request, jobPath.slice('reminder/'.length))
|
? scheduledDispatchHandler.handle(request, jobPath.slice('dispatch/'.length))
|
||||||
: new Response('Not Found', { status: 404 })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (jobPath === 'notifications/due') {
|
|
||||||
return adHocNotificationJobs
|
|
||||||
? adHocNotificationJobs.handle(request)
|
|
||||||
: new Response('Not Found', { status: 404 })
|
: new Response('Not Found', { status: 404 })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -966,6 +961,10 @@ export async function createBotRuntimeApp(): Promise<BotRuntimeApp> {
|
|||||||
: undefined
|
: undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (scheduledDispatchService) {
|
||||||
|
await scheduledDispatchService.reconcileAllBuiltInDispatches()
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
fetch: server.fetch,
|
fetch: server.fetch,
|
||||||
runtime,
|
runtime,
|
||||||
|
|||||||
46
apps/bot/src/aws-scheduled-dispatch-scheduler.test.ts
Normal file
46
apps/bot/src/aws-scheduled-dispatch-scheduler.test.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
|
||||||
|
import { Temporal } from '@household/domain'
|
||||||
|
|
||||||
|
import { createAwsScheduledDispatchScheduler } from './aws-scheduled-dispatch-scheduler'
|
||||||
|
|
||||||
|
describe('createAwsScheduledDispatchScheduler', () => {
|
||||||
|
test('creates one-shot EventBridge schedules targeting the bot lambda', async () => {
|
||||||
|
const calls: unknown[] = []
|
||||||
|
const scheduler = createAwsScheduledDispatchScheduler({
|
||||||
|
region: 'eu-central-1',
|
||||||
|
targetLambdaArn: 'arn:aws:lambda:eu-central-1:123:function:bot',
|
||||||
|
roleArn: 'arn:aws:iam::123:role/scheduler',
|
||||||
|
groupName: 'dispatches',
|
||||||
|
client: {
|
||||||
|
send: async (command) => {
|
||||||
|
calls.push(command.input)
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await scheduler.scheduleOneShotDispatch({
|
||||||
|
dispatchId: 'dispatch-1',
|
||||||
|
dueAt: Temporal.Instant.from('2026-03-24T12:00:00Z')
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.providerDispatchId).toContain('dispatch-dispatch-1-')
|
||||||
|
expect(calls[0]).toMatchObject({
|
||||||
|
GroupName: 'dispatches',
|
||||||
|
ScheduleExpression: 'at(2026-03-24T12:00:00Z)',
|
||||||
|
ActionAfterCompletion: 'DELETE',
|
||||||
|
FlexibleTimeWindow: {
|
||||||
|
Mode: 'OFF'
|
||||||
|
},
|
||||||
|
Target: {
|
||||||
|
Arn: 'arn:aws:lambda:eu-central-1:123:function:bot',
|
||||||
|
RoleArn: 'arn:aws:iam::123:role/scheduler',
|
||||||
|
Input: JSON.stringify({
|
||||||
|
source: 'household.scheduled-dispatch',
|
||||||
|
dispatchId: 'dispatch-1'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
79
apps/bot/src/aws-scheduled-dispatch-scheduler.ts
Normal file
79
apps/bot/src/aws-scheduled-dispatch-scheduler.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import {
|
||||||
|
CreateScheduleCommand,
|
||||||
|
DeleteScheduleCommand,
|
||||||
|
SchedulerClient
|
||||||
|
} from '@aws-sdk/client-scheduler'
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ScheduleOneShotDispatchInput,
|
||||||
|
ScheduleOneShotDispatchResult,
|
||||||
|
ScheduledDispatchScheduler
|
||||||
|
} from '@household/ports'
|
||||||
|
|
||||||
|
function scheduleName(dispatchId: string): string {
|
||||||
|
return `dispatch-${dispatchId}-${crypto.randomUUID().slice(0, 8)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function atExpression(dueAtIso: string): string {
|
||||||
|
return `at(${dueAtIso.replace(/\.\d{3}Z$/, 'Z')})`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAwsScheduledDispatchScheduler(input: {
|
||||||
|
region: string
|
||||||
|
targetLambdaArn: string
|
||||||
|
roleArn: string
|
||||||
|
groupName: string
|
||||||
|
client?: Pick<SchedulerClient, 'send'>
|
||||||
|
}): ScheduledDispatchScheduler {
|
||||||
|
const client = input.client ?? new SchedulerClient({ region: input.region })
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider: 'aws-eventbridge',
|
||||||
|
|
||||||
|
async scheduleOneShotDispatch(
|
||||||
|
dispatchInput: ScheduleOneShotDispatchInput
|
||||||
|
): Promise<ScheduleOneShotDispatchResult> {
|
||||||
|
const name = scheduleName(dispatchInput.dispatchId)
|
||||||
|
await client.send(
|
||||||
|
new CreateScheduleCommand({
|
||||||
|
Name: name,
|
||||||
|
GroupName: input.groupName,
|
||||||
|
ScheduleExpression: atExpression(dispatchInput.dueAt.toString()),
|
||||||
|
FlexibleTimeWindow: {
|
||||||
|
Mode: 'OFF'
|
||||||
|
},
|
||||||
|
ActionAfterCompletion: 'DELETE',
|
||||||
|
Target: {
|
||||||
|
Arn: input.targetLambdaArn,
|
||||||
|
RoleArn: input.roleArn,
|
||||||
|
Input: JSON.stringify({
|
||||||
|
source: 'household.scheduled-dispatch',
|
||||||
|
dispatchId: dispatchInput.dispatchId
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
providerDispatchId: name
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async cancelDispatch(providerDispatchId) {
|
||||||
|
try {
|
||||||
|
await client.send(
|
||||||
|
new DeleteScheduleCommand({
|
||||||
|
Name: providerDispatchId,
|
||||||
|
GroupName: input.groupName
|
||||||
|
})
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
const code = (error as { name?: string }).name
|
||||||
|
if (code === 'ResourceNotFoundException') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,22 @@ export interface BotRuntimeConfig {
|
|||||||
miniAppAuthEnabled: boolean
|
miniAppAuthEnabled: boolean
|
||||||
schedulerSharedSecret?: string
|
schedulerSharedSecret?: string
|
||||||
schedulerOidcAllowedEmails: readonly string[]
|
schedulerOidcAllowedEmails: readonly string[]
|
||||||
reminderJobsEnabled: boolean
|
scheduledDispatch?:
|
||||||
|
| {
|
||||||
|
provider: 'gcp-cloud-tasks'
|
||||||
|
publicBaseUrl: string
|
||||||
|
projectId: string
|
||||||
|
location: string
|
||||||
|
queue: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
provider: 'aws-eventbridge'
|
||||||
|
region: string
|
||||||
|
targetLambdaArn: string
|
||||||
|
roleArn: string
|
||||||
|
groupName: string
|
||||||
|
}
|
||||||
|
| undefined
|
||||||
openaiApiKey?: string
|
openaiApiKey?: string
|
||||||
purchaseParserModel: string
|
purchaseParserModel: string
|
||||||
assistantModel: string
|
assistantModel: string
|
||||||
@@ -86,6 +101,56 @@ function parseOptionalCsv(value: string | undefined): readonly string[] {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseScheduledDispatchConfig(
|
||||||
|
env: NodeJS.ProcessEnv
|
||||||
|
): BotRuntimeConfig['scheduledDispatch'] {
|
||||||
|
const provider = parseOptionalValue(env.SCHEDULED_DISPATCH_PROVIDER)
|
||||||
|
if (!provider) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider === 'gcp-cloud-tasks') {
|
||||||
|
const publicBaseUrl = parseOptionalValue(env.SCHEDULED_DISPATCH_PUBLIC_BASE_URL)
|
||||||
|
const projectId = parseOptionalValue(env.GCP_SCHEDULED_DISPATCH_PROJECT_ID)
|
||||||
|
const location = parseOptionalValue(env.GCP_SCHEDULED_DISPATCH_LOCATION)
|
||||||
|
const queue = parseOptionalValue(env.GCP_SCHEDULED_DISPATCH_QUEUE)
|
||||||
|
if (!publicBaseUrl || !projectId || !location || !queue) {
|
||||||
|
throw new Error(
|
||||||
|
'GCP scheduled dispatch requires SCHEDULED_DISPATCH_PUBLIC_BASE_URL, GCP_SCHEDULED_DISPATCH_PROJECT_ID, GCP_SCHEDULED_DISPATCH_LOCATION, and GCP_SCHEDULED_DISPATCH_QUEUE'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider,
|
||||||
|
publicBaseUrl,
|
||||||
|
projectId,
|
||||||
|
location,
|
||||||
|
queue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider === 'aws-eventbridge') {
|
||||||
|
const region = parseOptionalValue(env.AWS_SCHEDULED_DISPATCH_REGION)
|
||||||
|
const targetLambdaArn = parseOptionalValue(env.AWS_SCHEDULED_DISPATCH_TARGET_LAMBDA_ARN)
|
||||||
|
const roleArn = parseOptionalValue(env.AWS_SCHEDULED_DISPATCH_ROLE_ARN)
|
||||||
|
if (!region || !targetLambdaArn || !roleArn) {
|
||||||
|
throw new Error(
|
||||||
|
'AWS scheduled dispatch requires AWS_SCHEDULED_DISPATCH_REGION, AWS_SCHEDULED_DISPATCH_TARGET_LAMBDA_ARN, and AWS_SCHEDULED_DISPATCH_ROLE_ARN'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider,
|
||||||
|
region,
|
||||||
|
targetLambdaArn,
|
||||||
|
roleArn,
|
||||||
|
groupName: parseOptionalValue(env.AWS_SCHEDULED_DISPATCH_GROUP_NAME) ?? 'default'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Invalid SCHEDULED_DISPATCH_PROVIDER value: ${provider}`)
|
||||||
|
}
|
||||||
|
|
||||||
function parsePositiveInteger(raw: string | undefined, fallback: number, key: string): number {
|
function parsePositiveInteger(raw: string | undefined, fallback: number, key: string): number {
|
||||||
if (raw === undefined) {
|
if (raw === undefined) {
|
||||||
return fallback
|
return fallback
|
||||||
@@ -105,6 +170,7 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu
|
|||||||
const schedulerOidcAllowedEmails = parseOptionalCsv(env.SCHEDULER_OIDC_ALLOWED_EMAILS)
|
const schedulerOidcAllowedEmails = parseOptionalCsv(env.SCHEDULER_OIDC_ALLOWED_EMAILS)
|
||||||
const miniAppAllowedOrigins = parseOptionalCsv(env.MINI_APP_ALLOWED_ORIGINS)
|
const miniAppAllowedOrigins = parseOptionalCsv(env.MINI_APP_ALLOWED_ORIGINS)
|
||||||
const miniAppUrl = parseOptionalValue(env.MINI_APP_URL)
|
const miniAppUrl = parseOptionalValue(env.MINI_APP_URL)
|
||||||
|
const scheduledDispatch = parseScheduledDispatchConfig(env)
|
||||||
|
|
||||||
const purchaseTopicIngestionEnabled = databaseUrl !== undefined
|
const purchaseTopicIngestionEnabled = databaseUrl !== undefined
|
||||||
|
|
||||||
@@ -112,9 +178,6 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu
|
|||||||
const anonymousFeedbackEnabled = databaseUrl !== undefined
|
const anonymousFeedbackEnabled = databaseUrl !== undefined
|
||||||
const assistantEnabled = databaseUrl !== undefined
|
const assistantEnabled = databaseUrl !== undefined
|
||||||
const miniAppAuthEnabled = databaseUrl !== undefined
|
const miniAppAuthEnabled = databaseUrl !== undefined
|
||||||
const hasSchedulerOidcConfig = schedulerOidcAllowedEmails.length > 0
|
|
||||||
const reminderJobsEnabled =
|
|
||||||
databaseUrl !== undefined && (schedulerSharedSecret !== undefined || hasSchedulerOidcConfig)
|
|
||||||
|
|
||||||
const runtime: BotRuntimeConfig = {
|
const runtime: BotRuntimeConfig = {
|
||||||
port: parsePort(env.PORT),
|
port: parsePort(env.PORT),
|
||||||
@@ -129,7 +192,6 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu
|
|||||||
miniAppAllowedOrigins,
|
miniAppAllowedOrigins,
|
||||||
miniAppAuthEnabled,
|
miniAppAuthEnabled,
|
||||||
schedulerOidcAllowedEmails,
|
schedulerOidcAllowedEmails,
|
||||||
reminderJobsEnabled,
|
|
||||||
purchaseParserModel: env.PURCHASE_PARSER_MODEL?.trim() || 'gpt-4o-mini',
|
purchaseParserModel: env.PURCHASE_PARSER_MODEL?.trim() || 'gpt-4o-mini',
|
||||||
assistantModel: env.ASSISTANT_MODEL?.trim() || 'gpt-4o-mini',
|
assistantModel: env.ASSISTANT_MODEL?.trim() || 'gpt-4o-mini',
|
||||||
topicProcessorModel: env.TOPIC_PROCESSOR_MODEL?.trim() || 'gpt-4o-mini',
|
topicProcessorModel: env.TOPIC_PROCESSOR_MODEL?.trim() || 'gpt-4o-mini',
|
||||||
@@ -176,6 +238,9 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu
|
|||||||
if (schedulerSharedSecret !== undefined) {
|
if (schedulerSharedSecret !== undefined) {
|
||||||
runtime.schedulerSharedSecret = schedulerSharedSecret
|
runtime.schedulerSharedSecret = schedulerSharedSecret
|
||||||
}
|
}
|
||||||
|
if (scheduledDispatch !== undefined) {
|
||||||
|
runtime.scheduledDispatch = scheduledDispatch
|
||||||
|
}
|
||||||
if (miniAppUrl !== undefined) {
|
if (miniAppUrl !== undefined) {
|
||||||
runtime.miniAppUrl = miniAppUrl
|
runtime.miniAppUrl = miniAppUrl
|
||||||
}
|
}
|
||||||
|
|||||||
52
apps/bot/src/gcp-scheduled-dispatch-scheduler.test.ts
Normal file
52
apps/bot/src/gcp-scheduled-dispatch-scheduler.test.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
|
||||||
|
import { Temporal } from '@household/domain'
|
||||||
|
|
||||||
|
import { createGcpScheduledDispatchScheduler } from './gcp-scheduled-dispatch-scheduler'
|
||||||
|
|
||||||
|
describe('createGcpScheduledDispatchScheduler', () => {
|
||||||
|
test('creates Cloud Tasks HTTP tasks for one-shot dispatches', async () => {
|
||||||
|
const requests: Array<{ url: string; init: RequestInit | undefined }> = []
|
||||||
|
const scheduler = createGcpScheduledDispatchScheduler({
|
||||||
|
projectId: 'project-1',
|
||||||
|
location: 'europe-west1',
|
||||||
|
queue: 'dispatches',
|
||||||
|
publicBaseUrl: 'https://bot.example.com',
|
||||||
|
sharedSecret: 'secret-1',
|
||||||
|
auth: {
|
||||||
|
getAccessToken: async () => 'access-token'
|
||||||
|
},
|
||||||
|
fetchImpl: (async (url, init) => {
|
||||||
|
requests.push({
|
||||||
|
url: String(url),
|
||||||
|
init
|
||||||
|
})
|
||||||
|
return new Response(JSON.stringify({ name: 'tasks/dispatch-1' }), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}) as typeof fetch
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await scheduler.scheduleOneShotDispatch({
|
||||||
|
dispatchId: 'dispatch-1',
|
||||||
|
dueAt: Temporal.Instant.from('2026-03-24T12:00:00Z')
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.providerDispatchId).toBe('tasks/dispatch-1')
|
||||||
|
expect(requests[0]?.url).toBe(
|
||||||
|
'https://cloudtasks.googleapis.com/v2/projects/project-1/locations/europe-west1/queues/dispatches/tasks'
|
||||||
|
)
|
||||||
|
const payload = JSON.parse(String(requests[0]?.init?.body)) as {
|
||||||
|
task: {
|
||||||
|
httpRequest: { url: string; headers: Record<string, string> }
|
||||||
|
scheduleTime: { seconds: string }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(payload.task.httpRequest.url).toBe('https://bot.example.com/jobs/dispatch/dispatch-1')
|
||||||
|
expect(payload.task.httpRequest.headers['x-household-scheduler-secret']).toBe('secret-1')
|
||||||
|
expect(payload.task.scheduleTime.seconds).toBe('1774353600')
|
||||||
|
})
|
||||||
|
})
|
||||||
122
apps/bot/src/gcp-scheduled-dispatch-scheduler.ts
Normal file
122
apps/bot/src/gcp-scheduled-dispatch-scheduler.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { GoogleAuth } from 'google-auth-library'
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ScheduleOneShotDispatchInput,
|
||||||
|
ScheduleOneShotDispatchResult,
|
||||||
|
ScheduledDispatchScheduler
|
||||||
|
} from '@household/ports'
|
||||||
|
|
||||||
|
function scheduleTimestamp(input: ScheduleOneShotDispatchInput): {
|
||||||
|
seconds: string
|
||||||
|
nanos: number
|
||||||
|
} {
|
||||||
|
const milliseconds = input.dueAt.epochMilliseconds
|
||||||
|
const seconds = Math.floor(milliseconds / 1000)
|
||||||
|
return {
|
||||||
|
seconds: String(seconds),
|
||||||
|
nanos: (milliseconds - seconds * 1000) * 1_000_000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function callbackUrl(baseUrl: string, dispatchId: string): string {
|
||||||
|
return `${baseUrl.replace(/\/$/, '')}/jobs/dispatch/${dispatchId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createGcpScheduledDispatchScheduler(input: {
|
||||||
|
projectId: string
|
||||||
|
location: string
|
||||||
|
queue: string
|
||||||
|
publicBaseUrl: string
|
||||||
|
sharedSecret: string
|
||||||
|
auth?: Pick<GoogleAuth, 'getAccessToken'>
|
||||||
|
fetchImpl?: typeof fetch
|
||||||
|
}): ScheduledDispatchScheduler {
|
||||||
|
const auth =
|
||||||
|
input.auth ??
|
||||||
|
new GoogleAuth({
|
||||||
|
scopes: ['https://www.googleapis.com/auth/cloud-platform']
|
||||||
|
})
|
||||||
|
const fetchImpl = input.fetchImpl ?? fetch
|
||||||
|
|
||||||
|
async function authorizedHeaders() {
|
||||||
|
const accessToken = await auth.getAccessToken()
|
||||||
|
if (!accessToken) {
|
||||||
|
throw new Error('Failed to acquire Google Cloud access token for scheduled dispatch')
|
||||||
|
}
|
||||||
|
const token =
|
||||||
|
typeof accessToken === 'string'
|
||||||
|
? accessToken
|
||||||
|
: ((accessToken as { token?: string }).token ?? null)
|
||||||
|
if (!token) {
|
||||||
|
throw new Error('Failed to read Google Cloud access token for scheduled dispatch')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
authorization: `Bearer ${token}`,
|
||||||
|
'content-type': 'application/json'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider: 'gcp-cloud-tasks',
|
||||||
|
|
||||||
|
async scheduleOneShotDispatch(dispatchInput): Promise<ScheduleOneShotDispatchResult> {
|
||||||
|
const response = await fetchImpl(
|
||||||
|
`https://cloudtasks.googleapis.com/v2/projects/${input.projectId}/locations/${input.location}/queues/${input.queue}/tasks`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: await authorizedHeaders(),
|
||||||
|
body: JSON.stringify({
|
||||||
|
task: {
|
||||||
|
scheduleTime: scheduleTimestamp(dispatchInput),
|
||||||
|
httpRequest: {
|
||||||
|
httpMethod: 'POST',
|
||||||
|
url: callbackUrl(input.publicBaseUrl, dispatchInput.dispatchId),
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
'x-household-scheduler-secret': input.sharedSecret
|
||||||
|
},
|
||||||
|
body: Buffer.from(
|
||||||
|
JSON.stringify({
|
||||||
|
dispatchId: dispatchInput.dispatchId
|
||||||
|
})
|
||||||
|
).toString('base64')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Cloud Tasks create task failed with status ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = (await response.json()) as { name?: string }
|
||||||
|
if (!payload.name) {
|
||||||
|
throw new Error('Cloud Tasks create task response did not include a task name')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
providerDispatchId: payload.name
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async cancelDispatch(providerDispatchId) {
|
||||||
|
const response = await fetchImpl(
|
||||||
|
`https://cloudtasks.googleapis.com/v2/${providerDispatchId}`,
|
||||||
|
{
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: await authorizedHeaders()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (response.status === 404) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Cloud Tasks delete task failed with status ${response.status}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,58 @@ import {
|
|||||||
const appPromise = createBotRuntimeApp()
|
const appPromise = createBotRuntimeApp()
|
||||||
const logger = getLogger('lambda')
|
const logger = getLogger('lambda')
|
||||||
|
|
||||||
export async function handler(event: LambdaFunctionUrlRequest): Promise<LambdaFunctionUrlResponse> {
|
interface ScheduledDispatchLambdaEvent {
|
||||||
|
source: 'household.scheduled-dispatch'
|
||||||
|
dispatchId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function isScheduledDispatchLambdaEvent(value: unknown): value is ScheduledDispatchLambdaEvent {
|
||||||
|
if (!value || typeof value !== 'object') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = value as Record<string, unknown>
|
||||||
|
return (
|
||||||
|
candidate.source === 'household.scheduled-dispatch' && typeof candidate.dispatchId === 'string'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleScheduledDispatchEvent(
|
||||||
|
event: ScheduledDispatchLambdaEvent
|
||||||
|
): Promise<LambdaFunctionUrlResponse> {
|
||||||
|
const app = await appPromise
|
||||||
|
const secret = process.env.SCHEDULER_SHARED_SECRET
|
||||||
|
|
||||||
|
const response = await app.fetch(
|
||||||
|
new Request(`https://lambda.internal/jobs/dispatch/${event.dispatchId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: secret
|
||||||
|
? {
|
||||||
|
'x-household-scheduler-secret': secret
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
body: JSON.stringify({
|
||||||
|
dispatchId: event.dispatchId
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
statusCode: response.status,
|
||||||
|
headers: {
|
||||||
|
'content-type': response.headers.get('content-type') ?? 'application/json; charset=utf-8'
|
||||||
|
},
|
||||||
|
body: await response.text()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handler(
|
||||||
|
event: LambdaFunctionUrlRequest | ScheduledDispatchLambdaEvent
|
||||||
|
): Promise<LambdaFunctionUrlResponse> {
|
||||||
|
if (isScheduledDispatchLambdaEvent(event)) {
|
||||||
|
return handleScheduledDispatchEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
const app = await appPromise
|
const app = await appPromise
|
||||||
return handleLambdaFunctionUrlEvent(event, app.fetch)
|
return handleLambdaFunctionUrlEvent(event, app.fetch)
|
||||||
}
|
}
|
||||||
@@ -76,7 +127,9 @@ async function runtimeLoop(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const event = (await invocation.json()) as LambdaFunctionUrlRequest
|
const event = (await invocation.json()) as
|
||||||
|
| LambdaFunctionUrlRequest
|
||||||
|
| ScheduledDispatchLambdaEvent
|
||||||
const response = await handler(event)
|
const response = await handler(event)
|
||||||
await postRuntimeResponse(requestId, response)
|
await postRuntimeResponse(requestId, response)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,331 +0,0 @@
|
|||||||
import { describe, expect, mock, test } from 'bun:test'
|
|
||||||
|
|
||||||
import type { ReminderJobResult, ReminderJobService } from '@household/application'
|
|
||||||
import { Temporal } from '@household/domain'
|
|
||||||
import type { ReminderTarget } from '@household/ports'
|
|
||||||
|
|
||||||
import { createReminderJobsHandler } from './reminder-jobs'
|
|
||||||
|
|
||||||
const target: ReminderTarget = {
|
|
||||||
householdId: 'household-1',
|
|
||||||
householdName: 'Kojori House',
|
|
||||||
telegramChatId: '-1001',
|
|
||||||
telegramThreadId: '12',
|
|
||||||
locale: 'ru',
|
|
||||||
timezone: 'Asia/Tbilisi',
|
|
||||||
rentDueDay: 20,
|
|
||||||
rentWarningDay: 17,
|
|
||||||
utilitiesDueDay: 4,
|
|
||||||
utilitiesReminderDay: 3
|
|
||||||
}
|
|
||||||
|
|
||||||
const fixedNow = Temporal.Instant.from('2026-03-03T09:00:00Z')
|
|
||||||
|
|
||||||
describe('createReminderJobsHandler', () => {
|
|
||||||
test('returns per-household dispatch outcome with Telegram delivery metadata', async () => {
|
|
||||||
const claimedResult: ReminderJobResult = {
|
|
||||||
status: 'claimed',
|
|
||||||
dedupeKey: '2026-03:utilities',
|
|
||||||
payloadHash: 'hash',
|
|
||||||
reminderType: 'utilities',
|
|
||||||
period: '2026-03',
|
|
||||||
messageText: 'Utilities reminder for 2026-03'
|
|
||||||
}
|
|
||||||
|
|
||||||
const reminderService: ReminderJobService = {
|
|
||||||
handleJob: mock(async () => claimedResult)
|
|
||||||
}
|
|
||||||
const sendReminderMessage = mock(async () => {})
|
|
||||||
|
|
||||||
const handler = createReminderJobsHandler({
|
|
||||||
listReminderTargets: async () => [target],
|
|
||||||
releaseReminderDispatch: mock(async () => {}),
|
|
||||||
sendReminderMessage,
|
|
||||||
reminderService,
|
|
||||||
now: () => fixedNow,
|
|
||||||
botUsername: 'household_test_bot'
|
|
||||||
})
|
|
||||||
|
|
||||||
const response = await handler.handle(
|
|
||||||
new Request('http://localhost/jobs/reminder/utilities', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
period: '2026-03',
|
|
||||||
jobId: 'job-1'
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
'utilities'
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(sendReminderMessage).toHaveBeenCalledTimes(1)
|
|
||||||
expect(sendReminderMessage).toHaveBeenCalledWith(
|
|
||||||
target,
|
|
||||||
expect.objectContaining({
|
|
||||||
text: 'Напоминание по коммунальным платежам за 2026-03',
|
|
||||||
replyMarkup: {
|
|
||||||
inline_keyboard: [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
text: 'Ввести по шагам',
|
|
||||||
callback_data: 'reminder_util:guided'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Шаблон',
|
|
||||||
callback_data: 'reminder_util:template'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{
|
|
||||||
text: 'Открыть дашборд',
|
|
||||||
url: 'https://t.me/household_test_bot?start=dashboard'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(response.status).toBe(200)
|
|
||||||
expect(await response.json()).toEqual({
|
|
||||||
ok: true,
|
|
||||||
jobId: 'job-1',
|
|
||||||
reminderType: 'utilities',
|
|
||||||
period: '2026-03',
|
|
||||||
dryRun: false,
|
|
||||||
totals: {
|
|
||||||
targets: 1,
|
|
||||||
claimed: 1,
|
|
||||||
duplicate: 0,
|
|
||||||
'dry-run': 0,
|
|
||||||
failed: 0
|
|
||||||
},
|
|
||||||
dispatches: [
|
|
||||||
{
|
|
||||||
householdId: 'household-1',
|
|
||||||
householdName: 'Kojori House',
|
|
||||||
telegramChatId: '-1001',
|
|
||||||
telegramThreadId: '12',
|
|
||||||
period: '2026-03',
|
|
||||||
dedupeKey: '2026-03:utilities',
|
|
||||||
outcome: 'claimed',
|
|
||||||
messageText: 'Напоминание по коммунальным платежам за 2026-03'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test('supports forced dry-run mode without posting to Telegram', async () => {
|
|
||||||
const dryRunResult: ReminderJobResult = {
|
|
||||||
status: 'dry-run',
|
|
||||||
dedupeKey: '2026-03:rent-warning',
|
|
||||||
payloadHash: 'hash',
|
|
||||||
reminderType: 'rent-warning',
|
|
||||||
period: '2026-03',
|
|
||||||
messageText: 'Rent reminder for 2026-03: payment is coming up soon.'
|
|
||||||
}
|
|
||||||
|
|
||||||
const reminderService: ReminderJobService = {
|
|
||||||
handleJob: mock(async () => dryRunResult)
|
|
||||||
}
|
|
||||||
const sendReminderMessage = mock(async () => {})
|
|
||||||
|
|
||||||
const handler = createReminderJobsHandler({
|
|
||||||
listReminderTargets: async () => [target],
|
|
||||||
releaseReminderDispatch: mock(async () => {}),
|
|
||||||
sendReminderMessage,
|
|
||||||
reminderService,
|
|
||||||
forceDryRun: true,
|
|
||||||
now: () => fixedNow
|
|
||||||
})
|
|
||||||
|
|
||||||
const response = await handler.handle(
|
|
||||||
new Request('http://localhost/jobs/reminder/rent-warning', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ period: '2026-03', jobId: 'job-2' })
|
|
||||||
}),
|
|
||||||
'rent-warning'
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(sendReminderMessage).toHaveBeenCalledTimes(0)
|
|
||||||
expect(response.status).toBe(200)
|
|
||||||
expect(await response.json()).toMatchObject({
|
|
||||||
dryRun: true,
|
|
||||||
totals: {
|
|
||||||
targets: 1,
|
|
||||||
claimed: 0,
|
|
||||||
duplicate: 0,
|
|
||||||
'dry-run': 1,
|
|
||||||
failed: 0
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test('releases a dispatch claim when Telegram delivery fails', async () => {
|
|
||||||
const failedResult: ReminderJobResult = {
|
|
||||||
status: 'claimed',
|
|
||||||
dedupeKey: '2026-03:rent-due',
|
|
||||||
payloadHash: 'hash',
|
|
||||||
reminderType: 'rent-due',
|
|
||||||
period: '2026-03',
|
|
||||||
messageText: 'Rent due reminder for 2026-03: please settle payment today.'
|
|
||||||
}
|
|
||||||
const reminderService: ReminderJobService = {
|
|
||||||
handleJob: mock(async () => failedResult)
|
|
||||||
}
|
|
||||||
const releaseReminderDispatch = mock(async () => {})
|
|
||||||
|
|
||||||
const handler = createReminderJobsHandler({
|
|
||||||
listReminderTargets: async () => [target],
|
|
||||||
releaseReminderDispatch,
|
|
||||||
sendReminderMessage: mock(async () => {
|
|
||||||
throw new Error('Telegram unavailable')
|
|
||||||
}),
|
|
||||||
reminderService,
|
|
||||||
now: () => fixedNow
|
|
||||||
})
|
|
||||||
|
|
||||||
const response = await handler.handle(
|
|
||||||
new Request('http://localhost/jobs/reminder/rent-due', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ period: '2026-03' })
|
|
||||||
}),
|
|
||||||
'rent-due'
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(releaseReminderDispatch).toHaveBeenCalledWith({
|
|
||||||
householdId: 'household-1',
|
|
||||||
period: '2026-03',
|
|
||||||
reminderType: 'rent-due'
|
|
||||||
})
|
|
||||||
expect(response.status).toBe(200)
|
|
||||||
expect(await response.json()).toMatchObject({
|
|
||||||
totals: {
|
|
||||||
failed: 1
|
|
||||||
},
|
|
||||||
dispatches: [
|
|
||||||
expect.objectContaining({
|
|
||||||
outcome: 'failed',
|
|
||||||
error: 'Telegram unavailable'
|
|
||||||
})
|
|
||||||
]
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test('rejects unsupported reminder type', async () => {
|
|
||||||
const handler = createReminderJobsHandler({
|
|
||||||
listReminderTargets: async () => [target],
|
|
||||||
releaseReminderDispatch: mock(async () => {}),
|
|
||||||
sendReminderMessage: mock(async () => {}),
|
|
||||||
reminderService: {
|
|
||||||
handleJob: mock(async () => {
|
|
||||||
throw new Error('should not be called')
|
|
||||||
})
|
|
||||||
},
|
|
||||||
now: () => fixedNow
|
|
||||||
})
|
|
||||||
|
|
||||||
const response = await handler.handle(
|
|
||||||
new Request('http://localhost/jobs/reminder/unknown', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ period: '2026-03' })
|
|
||||||
}),
|
|
||||||
'unknown'
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(response.status).toBe(400)
|
|
||||||
expect(await response.json()).toEqual({
|
|
||||||
ok: false,
|
|
||||||
error: 'Invalid reminder type'
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test('skips households whose configured reminder day does not match today when no period override is supplied', async () => {
|
|
||||||
const reminderService: ReminderJobService = {
|
|
||||||
handleJob: mock(async () => {
|
|
||||||
throw new Error('should not be called')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handler = createReminderJobsHandler({
|
|
||||||
listReminderTargets: async () => [
|
|
||||||
{
|
|
||||||
...target,
|
|
||||||
utilitiesReminderDay: 31
|
|
||||||
}
|
|
||||||
],
|
|
||||||
releaseReminderDispatch: mock(async () => {}),
|
|
||||||
sendReminderMessage: mock(async () => {}),
|
|
||||||
reminderService,
|
|
||||||
now: () => fixedNow
|
|
||||||
})
|
|
||||||
|
|
||||||
const response = await handler.handle(
|
|
||||||
new Request('http://localhost/jobs/reminder/utilities', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ jobId: 'job-3' })
|
|
||||||
}),
|
|
||||||
'utilities'
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(response.status).toBe(200)
|
|
||||||
expect(await response.json()).toMatchObject({
|
|
||||||
ok: true,
|
|
||||||
totals: {
|
|
||||||
targets: 0
|
|
||||||
},
|
|
||||||
dispatches: []
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test('honors explicit period overrides even when today is not the configured reminder day', async () => {
|
|
||||||
const dryRunResult: ReminderJobResult = {
|
|
||||||
status: 'dry-run',
|
|
||||||
dedupeKey: '2026-03:rent-due',
|
|
||||||
payloadHash: 'hash',
|
|
||||||
reminderType: 'rent-due',
|
|
||||||
period: '2026-03',
|
|
||||||
messageText: 'Rent due reminder for 2026-03: please settle payment today.'
|
|
||||||
}
|
|
||||||
|
|
||||||
const reminderService: ReminderJobService = {
|
|
||||||
handleJob: mock(async () => dryRunResult)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handler = createReminderJobsHandler({
|
|
||||||
listReminderTargets: async () => [
|
|
||||||
{
|
|
||||||
...target,
|
|
||||||
rentDueDay: 20
|
|
||||||
}
|
|
||||||
],
|
|
||||||
releaseReminderDispatch: mock(async () => {}),
|
|
||||||
sendReminderMessage: mock(async () => {}),
|
|
||||||
reminderService,
|
|
||||||
now: () => fixedNow
|
|
||||||
})
|
|
||||||
|
|
||||||
const response = await handler.handle(
|
|
||||||
new Request('http://localhost/jobs/reminder/rent-due', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ period: '2026-03', dryRun: true })
|
|
||||||
}),
|
|
||||||
'rent-due'
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(response.status).toBe(200)
|
|
||||||
expect(await response.json()).toMatchObject({
|
|
||||||
ok: true,
|
|
||||||
totals: {
|
|
||||||
targets: 1,
|
|
||||||
'dry-run': 1
|
|
||||||
},
|
|
||||||
dispatches: [
|
|
||||||
expect.objectContaining({
|
|
||||||
householdId: 'household-1',
|
|
||||||
period: '2026-03',
|
|
||||||
outcome: 'dry-run'
|
|
||||||
})
|
|
||||||
]
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,299 +0,0 @@
|
|||||||
import type { ReminderJobService } from '@household/application'
|
|
||||||
import { BillingPeriod, Temporal, nowInstant } from '@household/domain'
|
|
||||||
import type { Logger } from '@household/observability'
|
|
||||||
import { REMINDER_TYPES, type ReminderTarget, type ReminderType } from '@household/ports'
|
|
||||||
import type { InlineKeyboardMarkup } from 'grammy/types'
|
|
||||||
|
|
||||||
import { getBotTranslations } from './i18n'
|
|
||||||
import { buildUtilitiesReminderReplyMarkup } from './reminder-topic-utilities'
|
|
||||||
|
|
||||||
interface ReminderJobRequestBody {
|
|
||||||
period?: string
|
|
||||||
jobId?: string
|
|
||||||
dryRun?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ReminderMessageContent {
|
|
||||||
text: string
|
|
||||||
replyMarkup?: InlineKeyboardMarkup
|
|
||||||
}
|
|
||||||
|
|
||||||
function json(body: object, status = 200): Response {
|
|
||||||
return new Response(JSON.stringify(body), {
|
|
||||||
status,
|
|
||||||
headers: {
|
|
||||||
'content-type': 'application/json; charset=utf-8'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseReminderType(raw: string): ReminderType | null {
|
|
||||||
if ((REMINDER_TYPES as readonly string[]).includes(raw)) {
|
|
||||||
return raw as ReminderType
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentPeriod(): string {
|
|
||||||
return BillingPeriod.fromInstant(nowInstant()).toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
function targetLocalDate(target: ReminderTarget, instant: Temporal.Instant) {
|
|
||||||
return instant.toZonedDateTimeISO(target.timezone)
|
|
||||||
}
|
|
||||||
|
|
||||||
function isReminderDueToday(
|
|
||||||
target: ReminderTarget,
|
|
||||||
reminderType: ReminderType,
|
|
||||||
instant: Temporal.Instant
|
|
||||||
): boolean {
|
|
||||||
const currentDay = targetLocalDate(target, instant).day
|
|
||||||
|
|
||||||
switch (reminderType) {
|
|
||||||
case 'utilities':
|
|
||||||
return currentDay === target.utilitiesReminderDay
|
|
||||||
case 'rent-warning':
|
|
||||||
return currentDay === target.rentWarningDay
|
|
||||||
case 'rent-due':
|
|
||||||
return currentDay === target.rentDueDay
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function targetPeriod(target: ReminderTarget, instant: Temporal.Instant): string {
|
|
||||||
const localDate = targetLocalDate(target, instant)
|
|
||||||
return BillingPeriod.fromString(
|
|
||||||
`${localDate.year}-${String(localDate.month).padStart(2, '0')}`
|
|
||||||
).toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readBody(request: Request): Promise<ReminderJobRequestBody> {
|
|
||||||
const text = await request.text()
|
|
||||||
|
|
||||||
if (text.trim().length === 0) {
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return JSON.parse(text) as ReminderJobRequestBody
|
|
||||||
} catch {
|
|
||||||
throw new Error('Invalid JSON body')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createReminderJobsHandler(options: {
|
|
||||||
listReminderTargets: () => Promise<readonly ReminderTarget[]>
|
|
||||||
ensureBillingCycle?: (input: { householdId: string; at: Temporal.Instant }) => Promise<void>
|
|
||||||
releaseReminderDispatch: (input: {
|
|
||||||
householdId: string
|
|
||||||
period: string
|
|
||||||
reminderType: ReminderType
|
|
||||||
}) => Promise<void>
|
|
||||||
sendReminderMessage: (target: ReminderTarget, content: ReminderMessageContent) => Promise<void>
|
|
||||||
reminderService: ReminderJobService
|
|
||||||
forceDryRun?: boolean
|
|
||||||
now?: () => Temporal.Instant
|
|
||||||
miniAppUrl?: string
|
|
||||||
botUsername?: string
|
|
||||||
logger?: Logger
|
|
||||||
}): {
|
|
||||||
handle: (request: Request, rawReminderType: string) => Promise<Response>
|
|
||||||
} {
|
|
||||||
function messageContent(
|
|
||||||
target: ReminderTarget,
|
|
||||||
reminderType: ReminderType,
|
|
||||||
period: string
|
|
||||||
): ReminderMessageContent {
|
|
||||||
const t = getBotTranslations(target.locale).reminders
|
|
||||||
|
|
||||||
switch (reminderType) {
|
|
||||||
case 'utilities':
|
|
||||||
return {
|
|
||||||
text: t.utilities(period),
|
|
||||||
replyMarkup: buildUtilitiesReminderReplyMarkup(target.locale, {
|
|
||||||
...(options.miniAppUrl
|
|
||||||
? {
|
|
||||||
miniAppUrl: options.miniAppUrl
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
...(options.botUsername
|
|
||||||
? {
|
|
||||||
botUsername: options.botUsername
|
|
||||||
}
|
|
||||||
: {})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
case 'rent-warning':
|
|
||||||
return {
|
|
||||||
text: t.rentWarning(period),
|
|
||||||
replyMarkup: {
|
|
||||||
inline_keyboard: [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
text: t.openDashboardButton,
|
|
||||||
url: options.botUsername
|
|
||||||
? `https://t.me/${options.botUsername}?start=dashboard`
|
|
||||||
: '#'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case 'rent-due':
|
|
||||||
return {
|
|
||||||
text: t.rentDue(period),
|
|
||||||
replyMarkup: {
|
|
||||||
inline_keyboard: [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
text: t.openDashboardButton,
|
|
||||||
url: options.botUsername
|
|
||||||
? `https://t.me/${options.botUsername}?start=dashboard`
|
|
||||||
: '#'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
handle: async (request, rawReminderType) => {
|
|
||||||
const reminderType = parseReminderType(rawReminderType)
|
|
||||||
if (!reminderType) {
|
|
||||||
return json({ ok: false, error: 'Invalid reminder type' }, 400)
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const body = await readBody(request)
|
|
||||||
const schedulerJobName = request.headers.get('x-cloudscheduler-jobname')
|
|
||||||
const requestedPeriod = body.period
|
|
||||||
? BillingPeriod.fromString(body.period).toString()
|
|
||||||
: null
|
|
||||||
const defaultPeriod = requestedPeriod ?? currentPeriod()
|
|
||||||
const dryRun = options.forceDryRun === true || body.dryRun === true
|
|
||||||
const currentInstant = options.now?.() ?? nowInstant()
|
|
||||||
const targets = await options.listReminderTargets()
|
|
||||||
const dispatches: Array<{
|
|
||||||
householdId: string
|
|
||||||
householdName: string
|
|
||||||
telegramChatId: string
|
|
||||||
telegramThreadId: string | null
|
|
||||||
period: string
|
|
||||||
dedupeKey: string
|
|
||||||
outcome: 'dry-run' | 'claimed' | 'duplicate' | 'failed'
|
|
||||||
messageText: string
|
|
||||||
error?: string
|
|
||||||
}> = []
|
|
||||||
|
|
||||||
for (const target of targets) {
|
|
||||||
await options.ensureBillingCycle?.({
|
|
||||||
householdId: target.householdId,
|
|
||||||
at: currentInstant
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!requestedPeriod && !isReminderDueToday(target, reminderType, currentInstant)) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const period = requestedPeriod ?? targetPeriod(target, currentInstant)
|
|
||||||
const result = await options.reminderService.handleJob({
|
|
||||||
householdId: target.householdId,
|
|
||||||
period,
|
|
||||||
reminderType,
|
|
||||||
dryRun
|
|
||||||
})
|
|
||||||
const content = messageContent(target, reminderType, period)
|
|
||||||
|
|
||||||
let outcome: 'dry-run' | 'claimed' | 'duplicate' | 'failed' = result.status
|
|
||||||
let error: string | undefined
|
|
||||||
|
|
||||||
if (result.status === 'claimed') {
|
|
||||||
try {
|
|
||||||
await options.sendReminderMessage(target, content)
|
|
||||||
} catch (dispatchError) {
|
|
||||||
await options.releaseReminderDispatch({
|
|
||||||
householdId: target.householdId,
|
|
||||||
period,
|
|
||||||
reminderType
|
|
||||||
})
|
|
||||||
|
|
||||||
outcome = 'failed'
|
|
||||||
error =
|
|
||||||
dispatchError instanceof Error
|
|
||||||
? dispatchError.message
|
|
||||||
: 'Unknown reminder delivery error'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
options.logger?.info(
|
|
||||||
{
|
|
||||||
event: 'scheduler.reminder.dispatch',
|
|
||||||
reminderType,
|
|
||||||
period,
|
|
||||||
jobId: body.jobId ?? schedulerJobName ?? null,
|
|
||||||
householdId: target.householdId,
|
|
||||||
householdName: target.householdName,
|
|
||||||
dedupeKey: result.dedupeKey,
|
|
||||||
outcome,
|
|
||||||
dryRun,
|
|
||||||
...(error ? { error } : {})
|
|
||||||
},
|
|
||||||
'Reminder job processed'
|
|
||||||
)
|
|
||||||
|
|
||||||
dispatches.push({
|
|
||||||
householdId: target.householdId,
|
|
||||||
householdName: target.householdName,
|
|
||||||
telegramChatId: target.telegramChatId,
|
|
||||||
telegramThreadId: target.telegramThreadId,
|
|
||||||
period,
|
|
||||||
dedupeKey: result.dedupeKey,
|
|
||||||
outcome,
|
|
||||||
messageText: content.text,
|
|
||||||
...(error ? { error } : {})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const totals = dispatches.reduce(
|
|
||||||
(summary, dispatch) => {
|
|
||||||
summary.targets += 1
|
|
||||||
summary[dispatch.outcome] += 1
|
|
||||||
return summary
|
|
||||||
},
|
|
||||||
{
|
|
||||||
targets: 0,
|
|
||||||
claimed: 0,
|
|
||||||
duplicate: 0,
|
|
||||||
'dry-run': 0,
|
|
||||||
failed: 0
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return json({
|
|
||||||
ok: true,
|
|
||||||
jobId: body.jobId ?? schedulerJobName ?? null,
|
|
||||||
reminderType,
|
|
||||||
period: defaultPeriod,
|
|
||||||
dryRun,
|
|
||||||
totals,
|
|
||||||
dispatches
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Unknown reminder job error'
|
|
||||||
|
|
||||||
options.logger?.error(
|
|
||||||
{
|
|
||||||
event: 'scheduler.reminder.dispatch_failed',
|
|
||||||
reminderType: rawReminderType,
|
|
||||||
error: message
|
|
||||||
},
|
|
||||||
'Reminder job failed'
|
|
||||||
)
|
|
||||||
|
|
||||||
return json({ ok: false, error: message }, 400)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
218
apps/bot/src/scheduled-dispatch-handler.test.ts
Normal file
218
apps/bot/src/scheduled-dispatch-handler.test.ts
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
|
||||||
|
import type { ScheduledDispatchService } from '@household/application'
|
||||||
|
import { Temporal } from '@household/domain'
|
||||||
|
import type {
|
||||||
|
AdHocNotificationRecord,
|
||||||
|
HouseholdMemberRecord,
|
||||||
|
HouseholdTelegramChatRecord,
|
||||||
|
HouseholdTopicBindingRecord,
|
||||||
|
ScheduledDispatchRecord
|
||||||
|
} from '@household/ports'
|
||||||
|
|
||||||
|
import { createScheduledDispatchHandler } from './scheduled-dispatch-handler'
|
||||||
|
|
||||||
|
function scheduledDispatch(
|
||||||
|
input: Partial<ScheduledDispatchRecord> &
|
||||||
|
Pick<ScheduledDispatchRecord, 'id' | 'householdId' | 'kind'>
|
||||||
|
): ScheduledDispatchRecord {
|
||||||
|
return {
|
||||||
|
id: input.id,
|
||||||
|
householdId: input.householdId,
|
||||||
|
kind: input.kind,
|
||||||
|
dueAt: input.dueAt ?? Temporal.Now.instant().subtract({ minutes: 1 }),
|
||||||
|
timezone: input.timezone ?? 'Asia/Tbilisi',
|
||||||
|
status: input.status ?? 'scheduled',
|
||||||
|
provider: input.provider ?? 'gcp-cloud-tasks',
|
||||||
|
providerDispatchId: input.providerDispatchId ?? 'provider-1',
|
||||||
|
adHocNotificationId: input.adHocNotificationId ?? null,
|
||||||
|
period: input.period ?? null,
|
||||||
|
sentAt: input.sentAt ?? null,
|
||||||
|
cancelledAt: input.cancelledAt ?? null,
|
||||||
|
createdAt: input.createdAt ?? Temporal.Instant.from('2026-03-24T00:00:00Z'),
|
||||||
|
updatedAt: input.updatedAt ?? Temporal.Instant.from('2026-03-24T00:00:00Z')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function notification(input: Partial<AdHocNotificationRecord> = {}): AdHocNotificationRecord {
|
||||||
|
return {
|
||||||
|
id: input.id ?? 'notif-1',
|
||||||
|
householdId: input.householdId ?? 'household-1',
|
||||||
|
creatorMemberId: input.creatorMemberId ?? 'creator',
|
||||||
|
assigneeMemberId: input.assigneeMemberId ?? null,
|
||||||
|
originalRequestText: 'raw',
|
||||||
|
notificationText: input.notificationText ?? 'Reminder text',
|
||||||
|
timezone: input.timezone ?? 'Asia/Tbilisi',
|
||||||
|
scheduledFor: input.scheduledFor ?? Temporal.Now.instant().subtract({ minutes: 1 }),
|
||||||
|
timePrecision: input.timePrecision ?? 'exact',
|
||||||
|
deliveryMode: input.deliveryMode ?? 'topic',
|
||||||
|
dmRecipientMemberIds: input.dmRecipientMemberIds ?? [],
|
||||||
|
friendlyTagAssignee: input.friendlyTagAssignee ?? false,
|
||||||
|
status: input.status ?? 'scheduled',
|
||||||
|
sourceTelegramChatId: input.sourceTelegramChatId ?? null,
|
||||||
|
sourceTelegramThreadId: input.sourceTelegramThreadId ?? null,
|
||||||
|
sentAt: input.sentAt ?? null,
|
||||||
|
cancelledAt: input.cancelledAt ?? null,
|
||||||
|
cancelledByMemberId: input.cancelledByMemberId ?? null,
|
||||||
|
createdAt: input.createdAt ?? Temporal.Instant.from('2026-03-24T00:00:00Z'),
|
||||||
|
updatedAt: input.updatedAt ?? Temporal.Instant.from('2026-03-24T00:00:00Z')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('createScheduledDispatchHandler', () => {
|
||||||
|
test('delivers ad hoc topic notifications exactly once and marks them sent', async () => {
|
||||||
|
const dispatch = scheduledDispatch({
|
||||||
|
id: 'dispatch-1',
|
||||||
|
householdId: 'household-1',
|
||||||
|
kind: 'ad_hoc_notification',
|
||||||
|
adHocNotificationId: 'notif-1'
|
||||||
|
})
|
||||||
|
const sentTopicMessages: string[] = []
|
||||||
|
const markedNotifications: string[] = []
|
||||||
|
const markedDispatches: string[] = []
|
||||||
|
|
||||||
|
const service: ScheduledDispatchService = {
|
||||||
|
scheduleAdHocNotification: async () => dispatch,
|
||||||
|
cancelAdHocNotification: async () => {},
|
||||||
|
reconcileHouseholdBuiltInDispatches: async () => {},
|
||||||
|
reconcileAllBuiltInDispatches: async () => {},
|
||||||
|
getDispatchById: async () => dispatch,
|
||||||
|
claimDispatch: async () => true,
|
||||||
|
releaseDispatch: async () => {},
|
||||||
|
markDispatchSent: async (dispatchId) => {
|
||||||
|
markedDispatches.push(dispatchId)
|
||||||
|
return dispatch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handler = createScheduledDispatchHandler({
|
||||||
|
scheduledDispatchService: service,
|
||||||
|
adHocNotificationRepository: {
|
||||||
|
async getNotificationById() {
|
||||||
|
return notification({
|
||||||
|
id: 'notif-1',
|
||||||
|
scheduledFor: dispatch.dueAt,
|
||||||
|
notificationText: 'Dima, reminder landed.'
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async markNotificationSent(notificationId) {
|
||||||
|
markedNotifications.push(notificationId)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
householdConfigurationRepository: {
|
||||||
|
async getHouseholdChatByHouseholdId(): Promise<HouseholdTelegramChatRecord | null> {
|
||||||
|
return {
|
||||||
|
householdId: 'household-1',
|
||||||
|
householdName: 'Kojori',
|
||||||
|
telegramChatId: 'chat-1',
|
||||||
|
telegramChatType: 'supergroup',
|
||||||
|
title: 'Kojori',
|
||||||
|
defaultLocale: 'ru'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async getHouseholdTopicBinding(): Promise<HouseholdTopicBindingRecord | null> {
|
||||||
|
return {
|
||||||
|
householdId: 'household-1',
|
||||||
|
role: 'reminders',
|
||||||
|
telegramThreadId: '103',
|
||||||
|
topicName: 'Reminders'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async getHouseholdBillingSettings() {
|
||||||
|
throw new Error('not used')
|
||||||
|
},
|
||||||
|
async listHouseholdMembers(): Promise<readonly HouseholdMemberRecord[]> {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
sendTopicMessage: async (input) => {
|
||||||
|
sentTopicMessages.push(`${input.chatId}:${input.threadId}:${input.text}`)
|
||||||
|
},
|
||||||
|
sendDirectMessage: async () => {
|
||||||
|
throw new Error('not used')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await handler.handle(
|
||||||
|
new Request('http://localhost/jobs/dispatch/dispatch-1', { method: 'POST' }),
|
||||||
|
'dispatch-1'
|
||||||
|
)
|
||||||
|
const payload = (await response.json()) as { ok: boolean; outcome: string }
|
||||||
|
|
||||||
|
expect(payload.ok).toBe(true)
|
||||||
|
expect(payload.outcome).toBe('sent')
|
||||||
|
expect(sentTopicMessages).toEqual(['chat-1:103:Dima, reminder landed.'])
|
||||||
|
expect(markedNotifications).toEqual(['notif-1'])
|
||||||
|
expect(markedDispatches).toEqual(['dispatch-1'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('ignores stale ad hoc dispatch callbacks after a reschedule', async () => {
|
||||||
|
const dispatch = scheduledDispatch({
|
||||||
|
id: 'dispatch-1',
|
||||||
|
householdId: 'household-1',
|
||||||
|
kind: 'ad_hoc_notification',
|
||||||
|
adHocNotificationId: 'notif-1',
|
||||||
|
dueAt: Temporal.Instant.from('2026-03-24T08:00:00Z')
|
||||||
|
})
|
||||||
|
let released = false
|
||||||
|
|
||||||
|
const service: ScheduledDispatchService = {
|
||||||
|
scheduleAdHocNotification: async () => dispatch,
|
||||||
|
cancelAdHocNotification: async () => {},
|
||||||
|
reconcileHouseholdBuiltInDispatches: async () => {},
|
||||||
|
reconcileAllBuiltInDispatches: async () => {},
|
||||||
|
getDispatchById: async () => dispatch,
|
||||||
|
claimDispatch: async () => true,
|
||||||
|
releaseDispatch: async () => {
|
||||||
|
released = true
|
||||||
|
},
|
||||||
|
markDispatchSent: async () => dispatch
|
||||||
|
}
|
||||||
|
|
||||||
|
const handler = createScheduledDispatchHandler({
|
||||||
|
scheduledDispatchService: service,
|
||||||
|
adHocNotificationRepository: {
|
||||||
|
async getNotificationById() {
|
||||||
|
return notification({
|
||||||
|
id: 'notif-1',
|
||||||
|
scheduledFor: Temporal.Instant.from('2026-03-24T09:00:00Z')
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async markNotificationSent() {
|
||||||
|
throw new Error('not used')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
householdConfigurationRepository: {
|
||||||
|
async getHouseholdChatByHouseholdId() {
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
async getHouseholdTopicBinding() {
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
async getHouseholdBillingSettings() {
|
||||||
|
throw new Error('not used')
|
||||||
|
},
|
||||||
|
async listHouseholdMembers(): Promise<readonly HouseholdMemberRecord[]> {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
sendTopicMessage: async () => {
|
||||||
|
throw new Error('should not send')
|
||||||
|
},
|
||||||
|
sendDirectMessage: async () => {
|
||||||
|
throw new Error('should not send')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await handler.handle(
|
||||||
|
new Request('http://localhost/jobs/dispatch/dispatch-1', { method: 'POST' }),
|
||||||
|
'dispatch-1'
|
||||||
|
)
|
||||||
|
const payload = (await response.json()) as { ok: boolean; outcome: string }
|
||||||
|
|
||||||
|
expect(payload.ok).toBe(true)
|
||||||
|
expect(payload.outcome).toBe('stale')
|
||||||
|
expect(released).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
292
apps/bot/src/scheduled-dispatch-handler.ts
Normal file
292
apps/bot/src/scheduled-dispatch-handler.ts
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
import type { ScheduledDispatchService } from '@household/application'
|
||||||
|
import { BillingPeriod, nowInstant } from '@household/domain'
|
||||||
|
import type { Logger } from '@household/observability'
|
||||||
|
import type {
|
||||||
|
AdHocNotificationRepository,
|
||||||
|
HouseholdConfigurationRepository,
|
||||||
|
HouseholdMemberRecord,
|
||||||
|
ReminderType
|
||||||
|
} from '@household/ports'
|
||||||
|
import type { InlineKeyboardMarkup } from 'grammy/types'
|
||||||
|
|
||||||
|
import { buildTopicNotificationText } from './ad-hoc-notifications'
|
||||||
|
import { buildScheduledReminderMessageContent } from './scheduled-reminder-content'
|
||||||
|
|
||||||
|
function json(body: object, status = 200): Response {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json; charset=utf-8'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function builtInReminderType(kind: 'utilities' | 'rent_warning' | 'rent_due'): ReminderType {
|
||||||
|
switch (kind) {
|
||||||
|
case 'utilities':
|
||||||
|
return 'utilities'
|
||||||
|
case 'rent_warning':
|
||||||
|
return 'rent-warning'
|
||||||
|
case 'rent_due':
|
||||||
|
return 'rent-due'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createScheduledDispatchHandler(options: {
|
||||||
|
scheduledDispatchService: ScheduledDispatchService
|
||||||
|
adHocNotificationRepository: Pick<
|
||||||
|
AdHocNotificationRepository,
|
||||||
|
'getNotificationById' | 'markNotificationSent'
|
||||||
|
>
|
||||||
|
householdConfigurationRepository: Pick<
|
||||||
|
HouseholdConfigurationRepository,
|
||||||
|
| 'getHouseholdChatByHouseholdId'
|
||||||
|
| 'getHouseholdTopicBinding'
|
||||||
|
| 'getHouseholdBillingSettings'
|
||||||
|
| 'listHouseholdMembers'
|
||||||
|
>
|
||||||
|
sendTopicMessage: (input: {
|
||||||
|
householdId: string
|
||||||
|
chatId: string
|
||||||
|
threadId: string | null
|
||||||
|
text: string
|
||||||
|
parseMode?: 'HTML'
|
||||||
|
replyMarkup?: InlineKeyboardMarkup
|
||||||
|
}) => Promise<void>
|
||||||
|
sendDirectMessage: (input: { telegramUserId: string; text: string }) => Promise<void>
|
||||||
|
miniAppUrl?: string
|
||||||
|
botUsername?: string
|
||||||
|
logger?: Logger
|
||||||
|
}): {
|
||||||
|
handle: (request: Request, dispatchId: string) => Promise<Response>
|
||||||
|
} {
|
||||||
|
async function sendAdHocNotification(dispatchId: string) {
|
||||||
|
const dispatch = await options.scheduledDispatchService.getDispatchById(dispatchId)
|
||||||
|
if (
|
||||||
|
!dispatch ||
|
||||||
|
dispatch.kind !== 'ad_hoc_notification' ||
|
||||||
|
!dispatch.adHocNotificationId ||
|
||||||
|
dispatch.status !== 'scheduled'
|
||||||
|
) {
|
||||||
|
return { outcome: 'noop' as const }
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentNow = nowInstant()
|
||||||
|
if (dispatch.dueAt.epochMilliseconds > currentNow.epochMilliseconds) {
|
||||||
|
return { outcome: 'not_due' as const }
|
||||||
|
}
|
||||||
|
|
||||||
|
const claimed = await options.scheduledDispatchService.claimDispatch(dispatch.id)
|
||||||
|
if (!claimed) {
|
||||||
|
return { outcome: 'duplicate' as const }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const notification = await options.adHocNotificationRepository.getNotificationById(
|
||||||
|
dispatch.adHocNotificationId
|
||||||
|
)
|
||||||
|
if (!notification || notification.status !== 'scheduled') {
|
||||||
|
await options.scheduledDispatchService.markDispatchSent(dispatch.id, currentNow)
|
||||||
|
return { outcome: 'noop' as const }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notification.scheduledFor.epochMilliseconds !== dispatch.dueAt.epochMilliseconds) {
|
||||||
|
await options.scheduledDispatchService.releaseDispatch(dispatch.id)
|
||||||
|
return { outcome: 'stale' as const }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notification.deliveryMode === 'topic') {
|
||||||
|
const householdChat =
|
||||||
|
notification.sourceTelegramChatId ??
|
||||||
|
(
|
||||||
|
await options.householdConfigurationRepository.getHouseholdChatByHouseholdId(
|
||||||
|
notification.householdId
|
||||||
|
)
|
||||||
|
)?.telegramChatId
|
||||||
|
const threadId =
|
||||||
|
notification.sourceTelegramThreadId ??
|
||||||
|
(
|
||||||
|
await options.householdConfigurationRepository.getHouseholdTopicBinding(
|
||||||
|
notification.householdId,
|
||||||
|
'reminders'
|
||||||
|
)
|
||||||
|
)?.telegramThreadId ??
|
||||||
|
null
|
||||||
|
|
||||||
|
if (!householdChat) {
|
||||||
|
throw new Error(`Household chat not configured for ${notification.householdId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = buildTopicNotificationText({
|
||||||
|
notificationText: notification.notificationText
|
||||||
|
})
|
||||||
|
await options.sendTopicMessage({
|
||||||
|
householdId: notification.householdId,
|
||||||
|
chatId: householdChat,
|
||||||
|
threadId,
|
||||||
|
text: content.text,
|
||||||
|
parseMode: content.parseMode
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const members = await options.householdConfigurationRepository.listHouseholdMembers(
|
||||||
|
notification.householdId
|
||||||
|
)
|
||||||
|
const dmRecipients = notification.dmRecipientMemberIds
|
||||||
|
.map((memberId) => members.find((member) => member.id === memberId))
|
||||||
|
.filter((member): member is HouseholdMemberRecord => Boolean(member))
|
||||||
|
|
||||||
|
for (const recipient of dmRecipients) {
|
||||||
|
await options.sendDirectMessage({
|
||||||
|
telegramUserId: recipient.telegramUserId,
|
||||||
|
text: notification.notificationText
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await options.adHocNotificationRepository.markNotificationSent(notification.id, currentNow)
|
||||||
|
await options.scheduledDispatchService.markDispatchSent(dispatch.id, currentNow)
|
||||||
|
return { outcome: 'sent' as const }
|
||||||
|
} catch (error) {
|
||||||
|
await options.scheduledDispatchService.releaseDispatch(dispatch.id)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendBuiltInReminder(dispatchId: string) {
|
||||||
|
const dispatch = await options.scheduledDispatchService.getDispatchById(dispatchId)
|
||||||
|
if (
|
||||||
|
!dispatch ||
|
||||||
|
dispatch.status !== 'scheduled' ||
|
||||||
|
(dispatch.kind !== 'utilities' &&
|
||||||
|
dispatch.kind !== 'rent_warning' &&
|
||||||
|
dispatch.kind !== 'rent_due')
|
||||||
|
) {
|
||||||
|
return { outcome: 'noop' as const }
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentNow = nowInstant()
|
||||||
|
if (dispatch.dueAt.epochMilliseconds > currentNow.epochMilliseconds) {
|
||||||
|
return { outcome: 'not_due' as const }
|
||||||
|
}
|
||||||
|
|
||||||
|
const claimed = await options.scheduledDispatchService.claimDispatch(dispatch.id)
|
||||||
|
if (!claimed) {
|
||||||
|
return { outcome: 'duplicate' as const }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [chat, reminderTopic] = await Promise.all([
|
||||||
|
options.householdConfigurationRepository.getHouseholdChatByHouseholdId(
|
||||||
|
dispatch.householdId
|
||||||
|
),
|
||||||
|
options.householdConfigurationRepository.getHouseholdTopicBinding(
|
||||||
|
dispatch.householdId,
|
||||||
|
'reminders'
|
||||||
|
)
|
||||||
|
])
|
||||||
|
|
||||||
|
if (!chat) {
|
||||||
|
await options.scheduledDispatchService.markDispatchSent(dispatch.id, currentNow)
|
||||||
|
return { outcome: 'noop' as const }
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = buildScheduledReminderMessageContent({
|
||||||
|
locale: chat.defaultLocale,
|
||||||
|
reminderType: builtInReminderType(dispatch.kind),
|
||||||
|
period:
|
||||||
|
dispatch.period ??
|
||||||
|
BillingPeriod.fromInstant(
|
||||||
|
dispatch.dueAt.toZonedDateTimeISO(dispatch.timezone).toInstant()
|
||||||
|
).toString(),
|
||||||
|
...(options.miniAppUrl
|
||||||
|
? {
|
||||||
|
miniAppUrl: options.miniAppUrl
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(options.botUsername
|
||||||
|
? {
|
||||||
|
botUsername: options.botUsername
|
||||||
|
}
|
||||||
|
: {})
|
||||||
|
})
|
||||||
|
|
||||||
|
await options.sendTopicMessage({
|
||||||
|
householdId: dispatch.householdId,
|
||||||
|
chatId: chat.telegramChatId,
|
||||||
|
threadId: reminderTopic?.telegramThreadId ?? null,
|
||||||
|
text: content.text,
|
||||||
|
...(content.replyMarkup
|
||||||
|
? {
|
||||||
|
replyMarkup: content.replyMarkup
|
||||||
|
}
|
||||||
|
: {})
|
||||||
|
})
|
||||||
|
|
||||||
|
await options.scheduledDispatchService.markDispatchSent(dispatch.id, currentNow)
|
||||||
|
await options.scheduledDispatchService.reconcileHouseholdBuiltInDispatches(
|
||||||
|
dispatch.householdId,
|
||||||
|
currentNow.add({ seconds: 1 })
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
outcome: 'sent' as const
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
await options.scheduledDispatchService.releaseDispatch(dispatch.id)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
handle: async (_request, dispatchId) => {
|
||||||
|
try {
|
||||||
|
const dispatch = await options.scheduledDispatchService.getDispatchById(dispatchId)
|
||||||
|
if (!dispatch) {
|
||||||
|
return json({
|
||||||
|
ok: true,
|
||||||
|
dispatchId,
|
||||||
|
outcome: 'noop'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const result =
|
||||||
|
dispatch.kind === 'ad_hoc_notification'
|
||||||
|
? await sendAdHocNotification(dispatchId)
|
||||||
|
: await sendBuiltInReminder(dispatchId)
|
||||||
|
|
||||||
|
options.logger?.info(
|
||||||
|
{
|
||||||
|
event: 'scheduler.scheduled_dispatch.handle',
|
||||||
|
dispatchId,
|
||||||
|
householdId: dispatch.householdId,
|
||||||
|
kind: dispatch.kind,
|
||||||
|
outcome: result.outcome
|
||||||
|
},
|
||||||
|
'Scheduled dispatch handled'
|
||||||
|
)
|
||||||
|
|
||||||
|
return json({
|
||||||
|
ok: true,
|
||||||
|
dispatchId,
|
||||||
|
outcome: result.outcome
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
options.logger?.error(
|
||||||
|
{
|
||||||
|
event: 'scheduler.scheduled_dispatch.failed',
|
||||||
|
dispatchId,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
},
|
||||||
|
'Scheduled dispatch failed'
|
||||||
|
)
|
||||||
|
return json(
|
||||||
|
{
|
||||||
|
ok: false,
|
||||||
|
dispatchId,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
},
|
||||||
|
500
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
70
apps/bot/src/scheduled-reminder-content.ts
Normal file
70
apps/bot/src/scheduled-reminder-content.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import type { ReminderType } from '@household/ports'
|
||||||
|
import type { InlineKeyboardMarkup } from 'grammy/types'
|
||||||
|
|
||||||
|
import { getBotTranslations } from './i18n'
|
||||||
|
import type { BotLocale } from './i18n'
|
||||||
|
import { buildUtilitiesReminderReplyMarkup } from './reminder-topic-utilities'
|
||||||
|
|
||||||
|
export interface ScheduledReminderMessageContent {
|
||||||
|
text: string
|
||||||
|
replyMarkup?: InlineKeyboardMarkup
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildScheduledReminderMessageContent(input: {
|
||||||
|
locale: BotLocale
|
||||||
|
reminderType: ReminderType
|
||||||
|
period: string
|
||||||
|
miniAppUrl?: string
|
||||||
|
botUsername?: string
|
||||||
|
}): ScheduledReminderMessageContent {
|
||||||
|
const t = getBotTranslations(input.locale).reminders
|
||||||
|
const dashboardReplyMarkup = input.botUsername
|
||||||
|
? ({
|
||||||
|
inline_keyboard: [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: t.openDashboardButton,
|
||||||
|
url: `https://t.me/${input.botUsername}?start=dashboard`
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
} satisfies InlineKeyboardMarkup)
|
||||||
|
: null
|
||||||
|
|
||||||
|
switch (input.reminderType) {
|
||||||
|
case 'utilities':
|
||||||
|
return {
|
||||||
|
text: t.utilities(input.period),
|
||||||
|
replyMarkup: buildUtilitiesReminderReplyMarkup(input.locale, {
|
||||||
|
...(input.miniAppUrl
|
||||||
|
? {
|
||||||
|
miniAppUrl: input.miniAppUrl
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(input.botUsername
|
||||||
|
? {
|
||||||
|
botUsername: input.botUsername
|
||||||
|
}
|
||||||
|
: {})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
case 'rent-warning':
|
||||||
|
return {
|
||||||
|
text: t.rentWarning(input.period),
|
||||||
|
...(dashboardReplyMarkup
|
||||||
|
? {
|
||||||
|
replyMarkup: dashboardReplyMarkup
|
||||||
|
}
|
||||||
|
: {})
|
||||||
|
}
|
||||||
|
case 'rent-due':
|
||||||
|
return {
|
||||||
|
text: t.rentDue(input.period),
|
||||||
|
...(dashboardReplyMarkup
|
||||||
|
? {
|
||||||
|
replyMarkup: dashboardReplyMarkup
|
||||||
|
}
|
||||||
|
: {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ describe('createSchedulerRequestAuthorizer', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const authorized = await authorizer.authorize(
|
const authorized = await authorizer.authorize(
|
||||||
new Request('http://localhost/jobs/reminder/utilities', {
|
new Request('http://localhost/jobs/dispatch/test-dispatch', {
|
||||||
headers: {
|
headers: {
|
||||||
'x-household-scheduler-secret': 'secret'
|
'x-household-scheduler-secret': 'secret'
|
||||||
}
|
}
|
||||||
@@ -36,7 +36,7 @@ describe('createSchedulerRequestAuthorizer', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const authorized = await authorizer.authorize(
|
const authorized = await authorizer.authorize(
|
||||||
new Request('http://localhost/jobs/reminder/utilities', {
|
new Request('http://localhost/jobs/dispatch/test-dispatch', {
|
||||||
headers: {
|
headers: {
|
||||||
authorization: 'Bearer signed-id-token'
|
authorization: 'Bearer signed-id-token'
|
||||||
}
|
}
|
||||||
@@ -63,7 +63,7 @@ describe('createSchedulerRequestAuthorizer', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const authorized = await authorizer.authorize(
|
const authorized = await authorizer.authorize(
|
||||||
new Request('http://localhost/jobs/reminder/utilities', {
|
new Request('http://localhost/jobs/dispatch/test-dispatch', {
|
||||||
headers: {
|
headers: {
|
||||||
authorization: 'Bearer signed-id-token'
|
authorization: 'Bearer signed-id-token'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -564,7 +564,7 @@ describe('createBotWebhookServer', () => {
|
|||||||
|
|
||||||
test('rejects scheduler request with missing secret', async () => {
|
test('rejects scheduler request with missing secret', async () => {
|
||||||
const response = await server.fetch(
|
const response = await server.fetch(
|
||||||
new Request('http://localhost/jobs/reminder/utilities', {
|
new Request('http://localhost/jobs/dispatch/test-dispatch', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ period: '2026-03' })
|
body: JSON.stringify({ period: '2026-03' })
|
||||||
})
|
})
|
||||||
@@ -575,7 +575,7 @@ describe('createBotWebhookServer', () => {
|
|||||||
|
|
||||||
test('rejects non-post method for scheduler endpoint', async () => {
|
test('rejects non-post method for scheduler endpoint', async () => {
|
||||||
const response = await server.fetch(
|
const response = await server.fetch(
|
||||||
new Request('http://localhost/jobs/reminder/utilities', {
|
new Request('http://localhost/jobs/dispatch/test-dispatch', {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
'x-household-scheduler-secret': 'scheduler-secret'
|
'x-household-scheduler-secret': 'scheduler-secret'
|
||||||
@@ -588,7 +588,7 @@ describe('createBotWebhookServer', () => {
|
|||||||
|
|
||||||
test('accepts authorized scheduler request', async () => {
|
test('accepts authorized scheduler request', async () => {
|
||||||
const response = await server.fetch(
|
const response = await server.fetch(
|
||||||
new Request('http://localhost/jobs/reminder/rent-due', {
|
new Request('http://localhost/jobs/dispatch/test-dispatch', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'x-household-scheduler-secret': 'scheduler-secret'
|
'x-household-scheduler-secret': 'scheduler-secret'
|
||||||
@@ -600,7 +600,7 @@ describe('createBotWebhookServer', () => {
|
|||||||
expect(response.status).toBe(200)
|
expect(response.status).toBe(200)
|
||||||
expect(await response.json()).toEqual({
|
expect(await response.json()).toEqual({
|
||||||
ok: true,
|
ok: true,
|
||||||
reminderType: 'rent-due'
|
reminderType: 'dispatch/test-dispatch'
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -297,9 +297,7 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): {
|
|||||||
options.miniAppDeletePayment?.path ?? '/api/miniapp/admin/payments/delete'
|
options.miniAppDeletePayment?.path ?? '/api/miniapp/admin/payments/delete'
|
||||||
const miniAppLocalePreferencePath =
|
const miniAppLocalePreferencePath =
|
||||||
options.miniAppLocalePreference?.path ?? '/api/miniapp/preferences/locale'
|
options.miniAppLocalePreference?.path ?? '/api/miniapp/preferences/locale'
|
||||||
const schedulerPathPrefix = options.scheduler
|
const schedulerPathPrefix = options.scheduler ? (options.scheduler.pathPrefix ?? '/jobs') : null
|
||||||
? (options.scheduler.pathPrefix ?? '/jobs/reminder')
|
|
||||||
: null
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
fetch: async (request: Request) => {
|
fetch: async (request: Request) => {
|
||||||
|
|||||||
60
bun.lock
60
bun.lock
@@ -16,6 +16,7 @@
|
|||||||
"apps/bot": {
|
"apps/bot": {
|
||||||
"name": "@household/bot",
|
"name": "@household/bot",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-scheduler": "^3.913.0",
|
||||||
"@household/adapters-db": "workspace:*",
|
"@household/adapters-db": "workspace:*",
|
||||||
"@household/application": "workspace:*",
|
"@household/application": "workspace:*",
|
||||||
"@household/db": "workspace:*",
|
"@household/db": "workspace:*",
|
||||||
@@ -114,6 +115,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"name": "@household/scripts",
|
"name": "@household/scripts",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@aws-sdk/client-scheduler": "^3.913.0",
|
||||||
"@household/config": "workspace:*",
|
"@household/config": "workspace:*",
|
||||||
"@household/db": "workspace:*",
|
"@household/db": "workspace:*",
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
@@ -131,23 +133,25 @@
|
|||||||
|
|
||||||
"@aws-sdk/client-ecs": ["@aws-sdk/client-ecs@3.1014.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.23", "@aws-sdk/credential-provider-node": "^3.972.24", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.8", "@aws-sdk/middleware-user-agent": "^3.972.24", "@aws-sdk/region-config-resolver": "^3.972.9", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", "@aws-sdk/util-user-agent-node": "^3.973.10", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.12", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/hash-node": "^4.2.12", "@smithy/invalid-dependency": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-retry": "^4.4.44", "@smithy/middleware-serde": "^4.2.15", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/node-http-handler": "^4.5.0", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.43", "@smithy/util-defaults-mode-node": "^4.2.47", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "@smithy/util-waiter": "^4.2.13", "tslib": "^2.6.2" } }, "sha512-wCyy58TBSXzgYkkvrH69VJU7kGd6/XW39p7fJkoGLOvLnWQMFWYVoDHkmp5rId4hsAA1sXVjix1DhXpzyINIDQ=="],
|
"@aws-sdk/client-ecs": ["@aws-sdk/client-ecs@3.1014.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.23", "@aws-sdk/credential-provider-node": "^3.972.24", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.8", "@aws-sdk/middleware-user-agent": "^3.972.24", "@aws-sdk/region-config-resolver": "^3.972.9", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", "@aws-sdk/util-user-agent-node": "^3.973.10", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.12", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/hash-node": "^4.2.12", "@smithy/invalid-dependency": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-retry": "^4.4.44", "@smithy/middleware-serde": "^4.2.15", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/node-http-handler": "^4.5.0", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.43", "@smithy/util-defaults-mode-node": "^4.2.47", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "@smithy/util-waiter": "^4.2.13", "tslib": "^2.6.2" } }, "sha512-wCyy58TBSXzgYkkvrH69VJU7kGd6/XW39p7fJkoGLOvLnWQMFWYVoDHkmp5rId4hsAA1sXVjix1DhXpzyINIDQ=="],
|
||||||
|
|
||||||
"@aws-sdk/core": ["@aws-sdk/core@3.973.23", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@aws-sdk/xml-builder": "^3.972.15", "@smithy/core": "^3.23.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/signature-v4": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-aoJncvD1XvloZ9JLnKqTRL9dBy+Szkryoag9VT+V1TqsuUgIxV9cnBVM/hrDi2vE8bDqLiDR8nirdRcCdtJu0w=="],
|
"@aws-sdk/client-scheduler": ["@aws-sdk/client-scheduler@3.1015.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.24", "@aws-sdk/credential-provider-node": "^3.972.25", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.8", "@aws-sdk/middleware-user-agent": "^3.972.25", "@aws-sdk/region-config-resolver": "^3.972.9", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", "@aws-sdk/util-user-agent-node": "^3.973.11", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.12", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/hash-node": "^4.2.12", "@smithy/invalid-dependency": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-retry": "^4.4.44", "@smithy/middleware-serde": "^4.2.15", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/node-http-handler": "^4.5.0", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.43", "@smithy/util-defaults-mode-node": "^4.2.47", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-RZH9AxG62ICguhtgzJ33QTY+GbFr3la3YGPwnSABwJcgpEGY8NkNEWDSY6e4/rs9jdTGwZ7iAflr0BVzhXP8eQ=="],
|
||||||
|
|
||||||
"@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.21", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-BkAfKq8Bd4shCtec1usNz//urPJF/SZy14qJyxkSaRJQ/Vv1gVh0VZSTmS7aE6aLMELkFV5wHHrS9ZcdG8Kxsg=="],
|
"@aws-sdk/core": ["@aws-sdk/core@3.973.24", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@aws-sdk/xml-builder": "^3.972.15", "@smithy/core": "^3.23.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/signature-v4": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-vvf82RYQu2GidWAuQq+uIzaPz9V0gSCXVqdVzRosgl5rXcspXOpSD3wFreGGW6AYymPr97Z69kjVnLePBxloDw=="],
|
||||||
|
|
||||||
"@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/types": "^3.973.6", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/node-http-handler": "^4.5.0", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/util-stream": "^4.5.20", "tslib": "^2.6.2" } }, "sha512-4XZ3+Gu5DY8/n8zQFHBgcKTF7hWQl42G6CY9xfXVo2d25FM/lYkpmuzhYopYoPL1ITWkJ2OSBQfYEu5JRfHOhA=="],
|
"@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.22", "", { "dependencies": { "@aws-sdk/core": "^3.973.24", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-cXp0VTDWT76p3hyK5D51yIKEfpf6/zsUvMfaB8CkyqadJxMQ8SbEeVroregmDlZbtG31wkj9ei0WnftmieggLg=="],
|
||||||
|
|
||||||
"@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/credential-provider-env": "^3.972.21", "@aws-sdk/credential-provider-http": "^3.972.23", "@aws-sdk/credential-provider-login": "^3.972.23", "@aws-sdk/credential-provider-process": "^3.972.21", "@aws-sdk/credential-provider-sso": "^3.972.23", "@aws-sdk/credential-provider-web-identity": "^3.972.23", "@aws-sdk/nested-clients": "^3.996.13", "@aws-sdk/types": "^3.973.6", "@smithy/credential-provider-imds": "^4.2.12", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-PZLSmU0JFpNCDFReidBezsgL5ji9jOBry8CnZdw4Jj6d0K2z3Ftnp44NXgADqYx5BLMu/ZHujfeJReaDoV+IwQ=="],
|
"@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.24", "", { "dependencies": { "@aws-sdk/core": "^3.973.24", "@aws-sdk/types": "^3.973.6", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/node-http-handler": "^4.5.0", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/util-stream": "^4.5.20", "tslib": "^2.6.2" } }, "sha512-h694K7+tRuepSRJr09wTvQfaEnjzsKZ5s7fbESrVds02GT/QzViJ94/HCNwM7bUfFxqpPXHxulZfL6Cou0dwPg=="],
|
||||||
|
|
||||||
"@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/nested-clients": "^3.996.13", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-OmE/pSkbMM3dCj1HdOnZ5kXnKK+R/Yz+kbBugraBecp0pGAs21eEURfQRz+1N2gzIHLVyGIP1MEjk/uSrFsngg=="],
|
"@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.24", "", { "dependencies": { "@aws-sdk/core": "^3.973.24", "@aws-sdk/credential-provider-env": "^3.972.22", "@aws-sdk/credential-provider-http": "^3.972.24", "@aws-sdk/credential-provider-login": "^3.972.24", "@aws-sdk/credential-provider-process": "^3.972.22", "@aws-sdk/credential-provider-sso": "^3.972.24", "@aws-sdk/credential-provider-web-identity": "^3.972.24", "@aws-sdk/nested-clients": "^3.996.14", "@aws-sdk/types": "^3.973.6", "@smithy/credential-provider-imds": "^4.2.12", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-O46fFmv0RDFWiWEA9/e6oW92BnsyAXuEgTTasxHligjn2RCr9L/DK773m/NoFaL3ZdNAUz8WxgxunleMnHAkeQ=="],
|
||||||
|
|
||||||
"@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.24", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.21", "@aws-sdk/credential-provider-http": "^3.972.23", "@aws-sdk/credential-provider-ini": "^3.972.23", "@aws-sdk/credential-provider-process": "^3.972.21", "@aws-sdk/credential-provider-sso": "^3.972.23", "@aws-sdk/credential-provider-web-identity": "^3.972.23", "@aws-sdk/types": "^3.973.6", "@smithy/credential-provider-imds": "^4.2.12", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-9Jwi7aps3AfUicJyF5udYadPypPpCwUZ6BSKr/QjRbVCpRVS1wc+1Q6AEZ/qz8J4JraeRd247pSzyMQSIHVebw=="],
|
"@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.24", "", { "dependencies": { "@aws-sdk/core": "^3.973.24", "@aws-sdk/nested-clients": "^3.996.14", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-sIk8oa6AzDoUhxsR11svZESqvzGuXesw62Rl2oW6wguZx8i9cdGCvkFg+h5K7iucUZP8wyWibUbJMc+J66cu5g=="],
|
||||||
|
|
||||||
"@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.21", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-nRxbeOJ1E1gVA0lNQezuMVndx+ZcuyaW/RB05pUsznN5BxykSlH6KkZ/7Ca/ubJf3i5N3p0gwNO5zgPSCzj+ww=="],
|
"@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.25", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.22", "@aws-sdk/credential-provider-http": "^3.972.24", "@aws-sdk/credential-provider-ini": "^3.972.24", "@aws-sdk/credential-provider-process": "^3.972.22", "@aws-sdk/credential-provider-sso": "^3.972.24", "@aws-sdk/credential-provider-web-identity": "^3.972.24", "@aws-sdk/types": "^3.973.6", "@smithy/credential-provider-imds": "^4.2.12", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-m7dR0Dsva2P+VUpL+VkC0WwiDby5pgmWXkRVDB5rlwv0jXJrQJf7YMtCoM8Wjk0H9jPeCYOxOXXcIgp/qp5Alg=="],
|
||||||
|
|
||||||
"@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/nested-clients": "^3.996.13", "@aws-sdk/token-providers": "3.1014.0", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-APUccADuYPLL0f2htpM8Z4czabSmHOdo4r41W6lKEZdy++cNJ42Radqy6x4TopENzr3hR6WYMyhiuiqtbf/nAA=="],
|
"@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.22", "", { "dependencies": { "@aws-sdk/core": "^3.973.24", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-Os32s8/4gTZjBk5BtoS/cuTILaj+K72d0dVG7TCJX/fC4598cxwLDmf1AEHEpER5oL3K//yETjvFaz0V8oO5Xw=="],
|
||||||
|
|
||||||
"@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/nested-clients": "^3.996.13", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-H5JNqtIwOu/feInmMMWcK0dL5r897ReEn7n2m16Dd0DPD9gA2Hg8Cq4UDzZ/9OzaLh/uqBM6seixz0U6Fi2Eag=="],
|
"@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.24", "", { "dependencies": { "@aws-sdk/core": "^3.973.24", "@aws-sdk/nested-clients": "^3.996.14", "@aws-sdk/token-providers": "3.1015.0", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-PaFv7snEfypU2yXkpvfyWgddEbDLtgVe51wdZlinhc2doubBjUzJZZpgwuF2Jenl1FBydMhNpMjD6SBUM3qdSA=="],
|
||||||
|
|
||||||
|
"@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.24", "", { "dependencies": { "@aws-sdk/core": "^3.973.24", "@aws-sdk/nested-clients": "^3.996.14", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-J6H4R1nvr3uBTqD/EeIPAskrBtET4WFfNhpFySr2xW7bVZOXpQfPjrLSIx65jcNjBmLXzWq8QFLdVoGxiGG/SA=="],
|
||||||
|
|
||||||
"@aws-sdk/middleware-host-header": ["@aws-sdk/middleware-host-header@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-wAr2REfKsqoKQ+OkNqvOShnBoh+nkPurDKW7uAeVSu6kUECnWlSJiPvnoqxGlfousEY/v9LfS9sNc46hjSYDIQ=="],
|
"@aws-sdk/middleware-host-header": ["@aws-sdk/middleware-host-header@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-wAr2REfKsqoKQ+OkNqvOShnBoh+nkPurDKW7uAeVSu6kUECnWlSJiPvnoqxGlfousEY/v9LfS9sNc46hjSYDIQ=="],
|
||||||
|
|
||||||
@@ -155,13 +159,13 @@
|
|||||||
|
|
||||||
"@aws-sdk/middleware-recursion-detection": ["@aws-sdk/middleware-recursion-detection@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-BnnvYs2ZEpdlmZ2PNlV2ZyQ8j8AEkMTjN79y/YA475ER1ByFYrkVR85qmhni8oeTaJcDqbx364wDpitDAA/wCA=="],
|
"@aws-sdk/middleware-recursion-detection": ["@aws-sdk/middleware-recursion-detection@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-BnnvYs2ZEpdlmZ2PNlV2ZyQ8j8AEkMTjN79y/YA475ER1ByFYrkVR85qmhni8oeTaJcDqbx364wDpitDAA/wCA=="],
|
||||||
|
|
||||||
"@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.972.24", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@smithy/core": "^3.23.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-retry": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-dLTWy6IfAMhNiSEvMr07g/qZ54be6pLqlxVblbF6AzafmmGAzMMj8qMoY9B4+YgT+gY9IcuxZslNh03L6PyMCQ=="],
|
"@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.972.25", "", { "dependencies": { "@aws-sdk/core": "^3.973.24", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@smithy/core": "^3.23.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-retry": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-QxiMPofvOt8SwSynTOmuZfvvPM1S9QfkESBxB22NMHTRXCJhR5BygLl8IXfC4jELiisQgwsgUby21GtXfX3f/g=="],
|
||||||
|
|
||||||
"@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.996.13", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.23", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.8", "@aws-sdk/middleware-user-agent": "^3.972.24", "@aws-sdk/region-config-resolver": "^3.972.9", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", "@aws-sdk/util-user-agent-node": "^3.973.10", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.12", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/hash-node": "^4.2.12", "@smithy/invalid-dependency": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-retry": "^4.4.44", "@smithy/middleware-serde": "^4.2.15", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/node-http-handler": "^4.5.0", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.43", "@smithy/util-defaults-mode-node": "^4.2.47", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-ptZ1HF4yYHNJX8cgFF+8NdYO69XJKZn7ft0/ynV3c0hCbN+89fAbrLS+fqniU2tW8o9Kfqhj8FUh+IPXb2Qsuw=="],
|
"@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.996.14", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.24", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.8", "@aws-sdk/middleware-user-agent": "^3.972.25", "@aws-sdk/region-config-resolver": "^3.972.9", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", "@aws-sdk/util-user-agent-node": "^3.973.11", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.12", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/hash-node": "^4.2.12", "@smithy/invalid-dependency": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-retry": "^4.4.44", "@smithy/middleware-serde": "^4.2.15", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/node-http-handler": "^4.5.0", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.43", "@smithy/util-defaults-mode-node": "^4.2.47", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-fSESKvh1VbfjtV3QMnRkCPZWkUbQof6T/DOpiLp33yP2wA+rbwwnZeG3XT3Ekljgw2I8X4XaQPnw+zSR8yxJ5Q=="],
|
||||||
|
|
||||||
"@aws-sdk/region-config-resolver": ["@aws-sdk/region-config-resolver@3.972.9", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/config-resolver": "^4.4.13", "@smithy/node-config-provider": "^4.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-eQ+dFU05ZRC/lC2XpYlYSPlXtX3VT8sn5toxN2Fv7EXlMoA2p9V7vUBKqHunfD4TRLpxUq8Y8Ol/nCqiv327Ng=="],
|
"@aws-sdk/region-config-resolver": ["@aws-sdk/region-config-resolver@3.972.9", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/config-resolver": "^4.4.13", "@smithy/node-config-provider": "^4.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-eQ+dFU05ZRC/lC2XpYlYSPlXtX3VT8sn5toxN2Fv7EXlMoA2p9V7vUBKqHunfD4TRLpxUq8Y8Ol/nCqiv327Ng=="],
|
||||||
|
|
||||||
"@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1014.0", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/nested-clients": "^3.996.13", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-gHTHNUoaOGNrSWkl32A7wFsU78jlNTlqMccLu0byUk5CysYYXaxNMIonIVr4YcykC7vgtDS5ABuz83giy6fzJA=="],
|
"@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1015.0", "", { "dependencies": { "@aws-sdk/core": "^3.973.24", "@aws-sdk/nested-clients": "^3.996.14", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-3OSD4y110nisRhHzFOjoEeHU4GQL4KpzkX9PxzWaiZe0Yg2+thZKM0Pn9DjYwezH5JYfh/K++xK/SE0IHGrmCQ=="],
|
||||||
|
|
||||||
"@aws-sdk/types": ["@aws-sdk/types@3.973.6", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-Atfcy4E++beKtwJHiDln2Nby8W/mam64opFPTiHEqgsthqeydFS1pY+OUlN1ouNOmf8ArPU/6cDS65anOP3KQw=="],
|
"@aws-sdk/types": ["@aws-sdk/types@3.973.6", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-Atfcy4E++beKtwJHiDln2Nby8W/mam64opFPTiHEqgsthqeydFS1pY+OUlN1ouNOmf8ArPU/6cDS65anOP3KQw=="],
|
||||||
|
|
||||||
@@ -171,7 +175,7 @@
|
|||||||
|
|
||||||
"@aws-sdk/util-user-agent-browser": ["@aws-sdk/util-user-agent-browser@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-B3KGXJviV2u6Cdw2SDY2aDhoJkVfY/Q/Trwk2CMSkikE1Oi6gRzxhvhIfiRpHfmIsAhV4EA54TVEX8K6CbHbkA=="],
|
"@aws-sdk/util-user-agent-browser": ["@aws-sdk/util-user-agent-browser@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-B3KGXJviV2u6Cdw2SDY2aDhoJkVfY/Q/Trwk2CMSkikE1Oi6gRzxhvhIfiRpHfmIsAhV4EA54TVEX8K6CbHbkA=="],
|
||||||
|
|
||||||
"@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.973.10", "", { "dependencies": { "@aws-sdk/middleware-user-agent": "^3.972.24", "@aws-sdk/types": "^3.973.6", "@smithy/node-config-provider": "^4.3.12", "@smithy/types": "^4.13.1", "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" }, "peerDependencies": { "aws-crt": ">=1.0.0" }, "optionalPeers": ["aws-crt"] }, "sha512-E99zeTscCc+pTMfsvnfi6foPpKmdD1cZfOC7/P8UUrjsoQdg9VEWPRD+xdFduKnfPXwcvby58AlO9jwwF6U96g=="],
|
"@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.973.11", "", { "dependencies": { "@aws-sdk/middleware-user-agent": "^3.972.25", "@aws-sdk/types": "^3.973.6", "@smithy/node-config-provider": "^4.3.12", "@smithy/types": "^4.13.1", "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" }, "peerDependencies": { "aws-crt": ">=1.0.0" }, "optionalPeers": ["aws-crt"] }, "sha512-1qdXbXo2s5MMLpUvw00284LsbhtlQ4ul7Zzdn5n+7p4WVgCMLqhxImpHIrjSoc72E/fyc4Wq8dLtUld2Gsh+lA=="],
|
||||||
|
|
||||||
"@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.15", "", { "dependencies": { "@smithy/types": "^4.13.1", "fast-xml-parser": "5.5.8", "tslib": "^2.6.2" } }, "sha512-PxMRlCFNiQnke9YR29vjFQwz4jq+6Q04rOVFeTDR2K7Qpv9h9FOWOxG+zJjageimYbWqE3bTuLjmryWHAWbvaA=="],
|
"@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.15", "", { "dependencies": { "@smithy/types": "^4.13.1", "fast-xml-parser": "5.5.8", "tslib": "^2.6.2" } }, "sha512-PxMRlCFNiQnke9YR29vjFQwz4jq+6Q04rOVFeTDR2K7Qpv9h9FOWOxG+zJjageimYbWqE3bTuLjmryWHAWbvaA=="],
|
||||||
|
|
||||||
@@ -1365,6 +1369,14 @@
|
|||||||
|
|
||||||
"@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
|
"@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
|
||||||
|
|
||||||
|
"@aws-sdk/client-ecs/@aws-sdk/core": ["@aws-sdk/core@3.973.23", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@aws-sdk/xml-builder": "^3.972.15", "@smithy/core": "^3.23.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/signature-v4": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-aoJncvD1XvloZ9JLnKqTRL9dBy+Szkryoag9VT+V1TqsuUgIxV9cnBVM/hrDi2vE8bDqLiDR8nirdRcCdtJu0w=="],
|
||||||
|
|
||||||
|
"@aws-sdk/client-ecs/@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.24", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.21", "@aws-sdk/credential-provider-http": "^3.972.23", "@aws-sdk/credential-provider-ini": "^3.972.23", "@aws-sdk/credential-provider-process": "^3.972.21", "@aws-sdk/credential-provider-sso": "^3.972.23", "@aws-sdk/credential-provider-web-identity": "^3.972.23", "@aws-sdk/types": "^3.973.6", "@smithy/credential-provider-imds": "^4.2.12", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-9Jwi7aps3AfUicJyF5udYadPypPpCwUZ6BSKr/QjRbVCpRVS1wc+1Q6AEZ/qz8J4JraeRd247pSzyMQSIHVebw=="],
|
||||||
|
|
||||||
|
"@aws-sdk/client-ecs/@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.972.24", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@smithy/core": "^3.23.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-retry": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-dLTWy6IfAMhNiSEvMr07g/qZ54be6pLqlxVblbF6AzafmmGAzMMj8qMoY9B4+YgT+gY9IcuxZslNh03L6PyMCQ=="],
|
||||||
|
|
||||||
|
"@aws-sdk/client-ecs/@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.973.10", "", { "dependencies": { "@aws-sdk/middleware-user-agent": "^3.972.24", "@aws-sdk/types": "^3.973.6", "@smithy/node-config-provider": "^4.3.12", "@smithy/types": "^4.13.1", "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" }, "peerDependencies": { "aws-crt": ">=1.0.0" }, "optionalPeers": ["aws-crt"] }, "sha512-E99zeTscCc+pTMfsvnfi6foPpKmdD1cZfOC7/P8UUrjsoQdg9VEWPRD+xdFduKnfPXwcvby58AlO9jwwF6U96g=="],
|
||||||
|
|
||||||
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||||
|
|
||||||
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||||
@@ -1453,6 +1465,18 @@
|
|||||||
|
|
||||||
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
|
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
|
||||||
|
|
||||||
|
"@aws-sdk/client-ecs/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.21", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-BkAfKq8Bd4shCtec1usNz//urPJF/SZy14qJyxkSaRJQ/Vv1gVh0VZSTmS7aE6aLMELkFV5wHHrS9ZcdG8Kxsg=="],
|
||||||
|
|
||||||
|
"@aws-sdk/client-ecs/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/types": "^3.973.6", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/node-http-handler": "^4.5.0", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/util-stream": "^4.5.20", "tslib": "^2.6.2" } }, "sha512-4XZ3+Gu5DY8/n8zQFHBgcKTF7hWQl42G6CY9xfXVo2d25FM/lYkpmuzhYopYoPL1ITWkJ2OSBQfYEu5JRfHOhA=="],
|
||||||
|
|
||||||
|
"@aws-sdk/client-ecs/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/credential-provider-env": "^3.972.21", "@aws-sdk/credential-provider-http": "^3.972.23", "@aws-sdk/credential-provider-login": "^3.972.23", "@aws-sdk/credential-provider-process": "^3.972.21", "@aws-sdk/credential-provider-sso": "^3.972.23", "@aws-sdk/credential-provider-web-identity": "^3.972.23", "@aws-sdk/nested-clients": "^3.996.13", "@aws-sdk/types": "^3.973.6", "@smithy/credential-provider-imds": "^4.2.12", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-PZLSmU0JFpNCDFReidBezsgL5ji9jOBry8CnZdw4Jj6d0K2z3Ftnp44NXgADqYx5BLMu/ZHujfeJReaDoV+IwQ=="],
|
||||||
|
|
||||||
|
"@aws-sdk/client-ecs/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.21", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-nRxbeOJ1E1gVA0lNQezuMVndx+ZcuyaW/RB05pUsznN5BxykSlH6KkZ/7Ca/ubJf3i5N3p0gwNO5zgPSCzj+ww=="],
|
||||||
|
|
||||||
|
"@aws-sdk/client-ecs/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/nested-clients": "^3.996.13", "@aws-sdk/token-providers": "3.1014.0", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-APUccADuYPLL0f2htpM8Z4czabSmHOdo4r41W6lKEZdy++cNJ42Radqy6x4TopENzr3hR6WYMyhiuiqtbf/nAA=="],
|
||||||
|
|
||||||
|
"@aws-sdk/client-ecs/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/nested-clients": "^3.996.13", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-H5JNqtIwOu/feInmMMWcK0dL5r897ReEn7n2m16Dd0DPD9gA2Hg8Cq4UDzZ/9OzaLh/uqBM6seixz0U6Fi2Eag=="],
|
||||||
|
|
||||||
"@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
"@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
|
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
|
||||||
@@ -1641,6 +1665,16 @@
|
|||||||
|
|
||||||
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
|
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
|
||||||
|
|
||||||
|
"@aws-sdk/client-ecs/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/nested-clients": "^3.996.13", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-OmE/pSkbMM3dCj1HdOnZ5kXnKK+R/Yz+kbBugraBecp0pGAs21eEURfQRz+1N2gzIHLVyGIP1MEjk/uSrFsngg=="],
|
||||||
|
|
||||||
|
"@aws-sdk/client-ecs/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.996.13", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.23", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.8", "@aws-sdk/middleware-user-agent": "^3.972.24", "@aws-sdk/region-config-resolver": "^3.972.9", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", "@aws-sdk/util-user-agent-node": "^3.973.10", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.12", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/hash-node": "^4.2.12", "@smithy/invalid-dependency": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-retry": "^4.4.44", "@smithy/middleware-serde": "^4.2.15", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/node-http-handler": "^4.5.0", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.43", "@smithy/util-defaults-mode-node": "^4.2.47", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-ptZ1HF4yYHNJX8cgFF+8NdYO69XJKZn7ft0/ynV3c0hCbN+89fAbrLS+fqniU2tW8o9Kfqhj8FUh+IPXb2Qsuw=="],
|
||||||
|
|
||||||
|
"@aws-sdk/client-ecs/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.996.13", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.23", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.8", "@aws-sdk/middleware-user-agent": "^3.972.24", "@aws-sdk/region-config-resolver": "^3.972.9", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", "@aws-sdk/util-user-agent-node": "^3.973.10", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.12", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/hash-node": "^4.2.12", "@smithy/invalid-dependency": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-retry": "^4.4.44", "@smithy/middleware-serde": "^4.2.15", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/node-http-handler": "^4.5.0", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.43", "@smithy/util-defaults-mode-node": "^4.2.47", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-ptZ1HF4yYHNJX8cgFF+8NdYO69XJKZn7ft0/ynV3c0hCbN+89fAbrLS+fqniU2tW8o9Kfqhj8FUh+IPXb2Qsuw=="],
|
||||||
|
|
||||||
|
"@aws-sdk/client-ecs/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1014.0", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/nested-clients": "^3.996.13", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-gHTHNUoaOGNrSWkl32A7wFsU78jlNTlqMccLu0byUk5CysYYXaxNMIonIVr4YcykC7vgtDS5ABuz83giy6fzJA=="],
|
||||||
|
|
||||||
|
"@aws-sdk/client-ecs/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.996.13", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.23", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.8", "@aws-sdk/middleware-user-agent": "^3.972.24", "@aws-sdk/region-config-resolver": "^3.972.9", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", "@aws-sdk/util-user-agent-node": "^3.973.10", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.12", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/hash-node": "^4.2.12", "@smithy/invalid-dependency": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-retry": "^4.4.44", "@smithy/middleware-serde": "^4.2.15", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/node-http-handler": "^4.5.0", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.43", "@smithy/util-defaults-mode-node": "^4.2.47", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-ptZ1HF4yYHNJX8cgFF+8NdYO69XJKZn7ft0/ynV3c0hCbN+89fAbrLS+fqniU2tW8o9Kfqhj8FUh+IPXb2Qsuw=="],
|
||||||
|
|
||||||
"rimraf/glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
"rimraf/glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
||||||
|
|
||||||
"rimraf/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
"rimraf/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import * as pulumi from '@pulumi/pulumi'
|
|||||||
|
|
||||||
const config = new pulumi.Config()
|
const config = new pulumi.Config()
|
||||||
const awsConfig = new pulumi.Config('aws')
|
const awsConfig = new pulumi.Config('aws')
|
||||||
|
const region = awsConfig.get('region') || aws.getRegionOutput().name
|
||||||
|
const accountId = aws.getCallerIdentityOutput().accountId
|
||||||
|
|
||||||
const appName = config.get('appName') ?? 'household'
|
const appName = config.get('appName') ?? 'household'
|
||||||
const environment = config.get('environment') ?? pulumi.getStack()
|
const environment = config.get('environment') ?? pulumi.getStack()
|
||||||
@@ -23,6 +25,10 @@ const logLevel = config.get('logLevel') ?? 'info'
|
|||||||
const purchaseParserModel = config.get('purchaseParserModel') ?? 'gpt-4o-mini'
|
const purchaseParserModel = config.get('purchaseParserModel') ?? 'gpt-4o-mini'
|
||||||
const assistantModel = config.get('assistantModel') ?? 'gpt-4o-mini'
|
const assistantModel = config.get('assistantModel') ?? 'gpt-4o-mini'
|
||||||
const topicProcessorModel = config.get('topicProcessorModel') ?? 'gpt-4o-mini'
|
const topicProcessorModel = config.get('topicProcessorModel') ?? 'gpt-4o-mini'
|
||||||
|
const scheduledDispatchGroupName =
|
||||||
|
config.get('scheduledDispatchGroupName') ?? 'scheduled-dispatches'
|
||||||
|
const lambdaFunctionName = `${appName}-${environment}-bot`
|
||||||
|
const scheduledDispatchTargetLambdaArn = pulumi.interpolate`arn:aws:lambda:${region}:${accountId}:function:${lambdaFunctionName}`
|
||||||
|
|
||||||
const telegramBotToken = config.requireSecret('telegramBotToken')
|
const telegramBotToken = config.requireSecret('telegramBotToken')
|
||||||
const telegramWebhookSecret = config.requireSecret('telegramWebhookSecret')
|
const telegramWebhookSecret = config.requireSecret('telegramWebhookSecret')
|
||||||
@@ -58,6 +64,18 @@ new aws.iam.RolePolicyAttachment(`${appName}-${environment}-lambda-basic-exec`,
|
|||||||
policyArn: 'arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole'
|
policyArn: 'arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const schedulerGroup = new aws.scheduler.ScheduleGroup(`${appName}-${environment}-dispatches`, {
|
||||||
|
name: scheduledDispatchGroupName,
|
||||||
|
tags
|
||||||
|
})
|
||||||
|
|
||||||
|
const schedulerInvokeRole = new aws.iam.Role(`${appName}-${environment}-scheduler-invoke-role`, {
|
||||||
|
assumeRolePolicy: aws.iam.assumeRolePolicyForPrincipal({
|
||||||
|
Service: 'scheduler.amazonaws.com'
|
||||||
|
}),
|
||||||
|
tags
|
||||||
|
})
|
||||||
|
|
||||||
const secretSpecs = [
|
const secretSpecs = [
|
||||||
{
|
{
|
||||||
key: 'telegramBotToken',
|
key: 'telegramBotToken',
|
||||||
@@ -160,6 +178,7 @@ new aws.s3.BucketPolicy(`${appName}-${environment}-miniapp-policy`, {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const lambda = new aws.lambda.Function(`${appName}-${environment}-bot`, {
|
const lambda = new aws.lambda.Function(`${appName}-${environment}-bot`, {
|
||||||
|
name: lambdaFunctionName,
|
||||||
packageType: 'Image',
|
packageType: 'Image',
|
||||||
imageUri: botImage.imageUri,
|
imageUri: botImage.imageUri,
|
||||||
role: lambdaRole.arn,
|
role: lambdaRole.arn,
|
||||||
@@ -175,6 +194,11 @@ const lambda = new aws.lambda.Function(`${appName}-${environment}-bot`, {
|
|||||||
TELEGRAM_WEBHOOK_PATH: config.get('telegramWebhookPath') ?? '/webhook/telegram',
|
TELEGRAM_WEBHOOK_PATH: config.get('telegramWebhookPath') ?? '/webhook/telegram',
|
||||||
DATABASE_URL: databaseUrl ?? '',
|
DATABASE_URL: databaseUrl ?? '',
|
||||||
SCHEDULER_SHARED_SECRET: schedulerSharedSecret ?? '',
|
SCHEDULER_SHARED_SECRET: schedulerSharedSecret ?? '',
|
||||||
|
SCHEDULED_DISPATCH_PROVIDER: 'aws-eventbridge',
|
||||||
|
AWS_SCHEDULED_DISPATCH_REGION: region,
|
||||||
|
AWS_SCHEDULED_DISPATCH_TARGET_LAMBDA_ARN: scheduledDispatchTargetLambdaArn,
|
||||||
|
AWS_SCHEDULED_DISPATCH_ROLE_ARN: schedulerInvokeRole.arn,
|
||||||
|
AWS_SCHEDULED_DISPATCH_GROUP_NAME: schedulerGroup.name,
|
||||||
OPENAI_API_KEY: openaiApiKey ?? '',
|
OPENAI_API_KEY: openaiApiKey ?? '',
|
||||||
MINI_APP_URL: miniAppUrl,
|
MINI_APP_URL: miniAppUrl,
|
||||||
MINI_APP_ALLOWED_ORIGINS: miniAppAllowedOrigins.join(','),
|
MINI_APP_ALLOWED_ORIGINS: miniAppAllowedOrigins.join(','),
|
||||||
@@ -186,6 +210,43 @@ const lambda = new aws.lambda.Function(`${appName}-${environment}-bot`, {
|
|||||||
tags
|
tags
|
||||||
})
|
})
|
||||||
|
|
||||||
|
new aws.iam.RolePolicy(`${appName}-${environment}-lambda-scheduler-policy`, {
|
||||||
|
role: lambdaRole.id,
|
||||||
|
policy: schedulerInvokeRole.arn.apply((schedulerInvokeRoleArn) =>
|
||||||
|
JSON.stringify({
|
||||||
|
Version: '2012-10-17',
|
||||||
|
Statement: [
|
||||||
|
{
|
||||||
|
Effect: 'Allow',
|
||||||
|
Action: ['scheduler:CreateSchedule', 'scheduler:DeleteSchedule', 'scheduler:GetSchedule'],
|
||||||
|
Resource: '*'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Effect: 'Allow',
|
||||||
|
Action: ['iam:PassRole'],
|
||||||
|
Resource: schedulerInvokeRoleArn
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
new aws.iam.RolePolicy(`${appName}-${environment}-scheduler-invoke-policy`, {
|
||||||
|
role: schedulerInvokeRole.id,
|
||||||
|
policy: lambda.arn.apply((lambdaArn) =>
|
||||||
|
JSON.stringify({
|
||||||
|
Version: '2012-10-17',
|
||||||
|
Statement: [
|
||||||
|
{
|
||||||
|
Effect: 'Allow',
|
||||||
|
Action: ['lambda:InvokeFunction'],
|
||||||
|
Resource: lambdaArn
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
const functionUrl = new aws.lambda.FunctionUrl(`${appName}-${environment}-bot-url`, {
|
const functionUrl = new aws.lambda.FunctionUrl(`${appName}-${environment}-bot-url`, {
|
||||||
functionName: lambda.name,
|
functionName: lambda.name,
|
||||||
authorizationType: 'NONE',
|
authorizationType: 'NONE',
|
||||||
@@ -199,8 +260,6 @@ const functionUrl = new aws.lambda.FunctionUrl(`${appName}-${environment}-bot-ur
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const region = awsConfig.get('region') || aws.getRegionOutput().name
|
|
||||||
|
|
||||||
export const botOriginUrl = functionUrl.functionUrl
|
export const botOriginUrl = functionUrl.functionUrl
|
||||||
export const miniAppBucketName = bucket.bucket
|
export const miniAppBucketName = bucket.bucket
|
||||||
export const miniAppWebsiteUrl = pulumi.interpolate`http://${bucket.websiteEndpoint}`
|
export const miniAppWebsiteUrl = pulumi.interpolate`http://${bucket.websiteEndpoint}`
|
||||||
|
|||||||
@@ -12,21 +12,6 @@ locals {
|
|||||||
|
|
||||||
artifact_location = coalesce(var.artifact_repository_location, var.region)
|
artifact_location = coalesce(var.artifact_repository_location, var.region)
|
||||||
|
|
||||||
reminder_jobs = {
|
|
||||||
utilities = {
|
|
||||||
schedule = var.scheduler_utilities_cron
|
|
||||||
path = "/jobs/reminder/utilities"
|
|
||||||
}
|
|
||||||
rent-warning = {
|
|
||||||
schedule = var.scheduler_rent_warning_cron
|
|
||||||
path = "/jobs/reminder/rent-warning"
|
|
||||||
}
|
|
||||||
rent-due = {
|
|
||||||
schedule = var.scheduler_rent_due_cron
|
|
||||||
path = "/jobs/reminder/rent-due"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
runtime_secret_ids = toset(compact([
|
runtime_secret_ids = toset(compact([
|
||||||
var.telegram_webhook_secret_id,
|
var.telegram_webhook_secret_id,
|
||||||
var.scheduler_shared_secret_id,
|
var.scheduler_shared_secret_id,
|
||||||
@@ -37,6 +22,7 @@ locals {
|
|||||||
|
|
||||||
api_services = toset([
|
api_services = toset([
|
||||||
"artifactregistry.googleapis.com",
|
"artifactregistry.googleapis.com",
|
||||||
|
"cloudtasks.googleapis.com",
|
||||||
"cloudscheduler.googleapis.com",
|
"cloudscheduler.googleapis.com",
|
||||||
"iam.googleapis.com",
|
"iam.googleapis.com",
|
||||||
"iamcredentials.googleapis.com",
|
"iamcredentials.googleapis.com",
|
||||||
|
|||||||
@@ -58,10 +58,18 @@ resource "google_service_account" "mini_runtime" {
|
|||||||
display_name = "${local.name_prefix} mini runtime"
|
display_name = "${local.name_prefix} mini runtime"
|
||||||
}
|
}
|
||||||
|
|
||||||
resource "google_service_account" "scheduler_invoker" {
|
resource "google_cloud_tasks_queue" "scheduled_dispatches" {
|
||||||
project = var.project_id
|
project = var.project_id
|
||||||
account_id = "${var.environment}-scheduler"
|
location = var.region
|
||||||
display_name = "${local.name_prefix} scheduler invoker"
|
name = var.scheduled_dispatch_queue_name
|
||||||
|
|
||||||
|
depends_on = [google_project_service.enabled]
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "google_project_iam_member" "bot_runtime_cloud_tasks_enqueuer" {
|
||||||
|
project = var.project_id
|
||||||
|
role = "roles/cloudtasks.enqueuer"
|
||||||
|
member = "serviceAccount:${google_service_account.bot_runtime.email}"
|
||||||
}
|
}
|
||||||
|
|
||||||
resource "google_secret_manager_secret" "runtime" {
|
resource "google_secret_manager_secret" "runtime" {
|
||||||
@@ -169,8 +177,12 @@ module "bot_api_service" {
|
|||||||
var.bot_mini_app_url == null ? {} : {
|
var.bot_mini_app_url == null ? {} : {
|
||||||
MINI_APP_URL = var.bot_mini_app_url
|
MINI_APP_URL = var.bot_mini_app_url
|
||||||
},
|
},
|
||||||
{
|
var.scheduled_dispatch_public_base_url == null ? {} : {
|
||||||
SCHEDULER_OIDC_ALLOWED_EMAILS = google_service_account.scheduler_invoker.email
|
SCHEDULED_DISPATCH_PROVIDER = "gcp-cloud-tasks"
|
||||||
|
SCHEDULED_DISPATCH_PUBLIC_BASE_URL = var.scheduled_dispatch_public_base_url
|
||||||
|
GCP_SCHEDULED_DISPATCH_PROJECT_ID = var.project_id
|
||||||
|
GCP_SCHEDULED_DISPATCH_LOCATION = var.region
|
||||||
|
GCP_SCHEDULED_DISPATCH_QUEUE = google_cloud_tasks_queue.scheduled_dispatches.name
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -192,6 +204,8 @@ module "bot_api_service" {
|
|||||||
|
|
||||||
depends_on = [
|
depends_on = [
|
||||||
google_project_service.enabled,
|
google_project_service.enabled,
|
||||||
|
google_cloud_tasks_queue.scheduled_dispatches,
|
||||||
|
google_project_iam_member.bot_runtime_cloud_tasks_enqueuer,
|
||||||
google_secret_manager_secret.runtime,
|
google_secret_manager_secret.runtime,
|
||||||
google_secret_manager_secret_iam_member.bot_runtime_access
|
google_secret_manager_secret_iam_member.bot_runtime_access
|
||||||
]
|
]
|
||||||
@@ -218,54 +232,6 @@ module "mini_app_service" {
|
|||||||
depends_on = [google_project_service.enabled]
|
depends_on = [google_project_service.enabled]
|
||||||
}
|
}
|
||||||
|
|
||||||
resource "google_cloud_run_v2_service_iam_member" "scheduler_invoker" {
|
|
||||||
project = var.project_id
|
|
||||||
location = var.region
|
|
||||||
name = module.bot_api_service.name
|
|
||||||
role = "roles/run.invoker"
|
|
||||||
member = "serviceAccount:${google_service_account.scheduler_invoker.email}"
|
|
||||||
}
|
|
||||||
|
|
||||||
resource "google_service_account_iam_member" "scheduler_token_creator" {
|
|
||||||
service_account_id = google_service_account.scheduler_invoker.name
|
|
||||||
role = "roles/iam.serviceAccountTokenCreator"
|
|
||||||
member = "serviceAccount:service-${data.google_project.current.number}@gcp-sa-cloudscheduler.iam.gserviceaccount.com"
|
|
||||||
}
|
|
||||||
|
|
||||||
resource "google_cloud_scheduler_job" "reminders" {
|
|
||||||
for_each = local.reminder_jobs
|
|
||||||
|
|
||||||
project = var.project_id
|
|
||||||
region = var.region
|
|
||||||
name = "${local.name_prefix}-${each.key}"
|
|
||||||
schedule = each.value.schedule
|
|
||||||
time_zone = var.scheduler_timezone
|
|
||||||
paused = var.scheduler_paused
|
|
||||||
|
|
||||||
http_target {
|
|
||||||
uri = "${module.bot_api_service.uri}${each.value.path}"
|
|
||||||
http_method = "POST"
|
|
||||||
|
|
||||||
headers = {
|
|
||||||
"Content-Type" = "application/json"
|
|
||||||
}
|
|
||||||
|
|
||||||
body = base64encode(jsonencode({
|
|
||||||
dryRun = var.scheduler_dry_run
|
|
||||||
jobId = "${local.name_prefix}-${each.key}"
|
|
||||||
}))
|
|
||||||
|
|
||||||
oidc_token {
|
|
||||||
service_account_email = google_service_account.scheduler_invoker.email
|
|
||||||
audience = module.bot_api_service.uri
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
depends_on = [
|
|
||||||
module.bot_api_service,
|
|
||||||
google_service_account_iam_member.scheduler_token_creator
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
resource "google_service_account" "github_deployer" {
|
resource "google_service_account" "github_deployer" {
|
||||||
count = var.create_workload_identity ? 1 : 0
|
count = var.create_workload_identity ? 1 : 0
|
||||||
|
|||||||
@@ -23,11 +23,6 @@ output "mini_app_service_url" {
|
|||||||
value = module.mini_app_service.uri
|
value = module.mini_app_service.uri
|
||||||
}
|
}
|
||||||
|
|
||||||
output "scheduler_job_names" {
|
|
||||||
description = "Cloud Scheduler jobs for reminders"
|
|
||||||
value = { for name, job in google_cloud_scheduler_job.reminders : name => job.name }
|
|
||||||
}
|
|
||||||
|
|
||||||
output "runtime_secret_ids" {
|
output "runtime_secret_ids" {
|
||||||
description = "Secret Manager IDs expected by runtime"
|
description = "Secret Manager IDs expected by runtime"
|
||||||
value = sort([for secret in google_secret_manager_secret.runtime : secret.secret_id])
|
value = sort([for secret in google_secret_manager_secret.runtime : secret.secret_id])
|
||||||
|
|||||||
@@ -28,12 +28,8 @@ alert_notification_emails = [
|
|||||||
"alerts@example.com"
|
"alerts@example.com"
|
||||||
]
|
]
|
||||||
|
|
||||||
scheduler_utilities_cron = "0 9 * * *"
|
scheduled_dispatch_queue_name = "scheduled-dispatches"
|
||||||
scheduler_rent_warning_cron = "0 9 * * *"
|
scheduled_dispatch_public_base_url = "https://api.example.com"
|
||||||
scheduler_rent_due_cron = "0 9 * * *"
|
|
||||||
scheduler_timezone = "Asia/Tbilisi"
|
|
||||||
scheduler_paused = true
|
|
||||||
scheduler_dry_run = true
|
|
||||||
|
|
||||||
create_workload_identity = true
|
create_workload_identity = true
|
||||||
github_repository = "whekin/household-bot"
|
github_repository = "whekin/household-bot"
|
||||||
|
|||||||
@@ -165,40 +165,17 @@ variable "openai_api_key_secret_id" {
|
|||||||
nullable = true
|
nullable = true
|
||||||
}
|
}
|
||||||
|
|
||||||
variable "scheduler_timezone" {
|
variable "scheduled_dispatch_queue_name" {
|
||||||
description = "Scheduler timezone"
|
description = "Cloud Tasks queue name for one-shot reminder dispatches"
|
||||||
type = string
|
type = string
|
||||||
default = "Asia/Tbilisi"
|
default = "scheduled-dispatches"
|
||||||
}
|
}
|
||||||
|
|
||||||
variable "scheduler_utilities_cron" {
|
variable "scheduled_dispatch_public_base_url" {
|
||||||
description = "Cron expression for the utilities reminder scheduler job. Daily cadence is recommended because the app filters per household."
|
description = "Public bot base URL used by Cloud Tasks callbacks for scheduled dispatches"
|
||||||
type = string
|
type = string
|
||||||
default = "0 9 * * *"
|
default = null
|
||||||
}
|
nullable = true
|
||||||
|
|
||||||
variable "scheduler_rent_warning_cron" {
|
|
||||||
description = "Cron expression for the rent warning scheduler job. Daily cadence is recommended because the app filters per household."
|
|
||||||
type = string
|
|
||||||
default = "0 9 * * *"
|
|
||||||
}
|
|
||||||
|
|
||||||
variable "scheduler_rent_due_cron" {
|
|
||||||
description = "Cron expression for the rent due scheduler job. Daily cadence is recommended because the app filters per household."
|
|
||||||
type = string
|
|
||||||
default = "0 9 * * *"
|
|
||||||
}
|
|
||||||
|
|
||||||
variable "scheduler_dry_run" {
|
|
||||||
description = "Whether scheduler jobs should invoke the bot in dry-run mode"
|
|
||||||
type = bool
|
|
||||||
default = true
|
|
||||||
}
|
|
||||||
|
|
||||||
variable "scheduler_paused" {
|
|
||||||
description = "Whether scheduler should be paused initially"
|
|
||||||
type = bool
|
|
||||||
default = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
variable "bot_min_instances" {
|
variable "bot_min_instances" {
|
||||||
|
|||||||
@@ -49,8 +49,7 @@
|
|||||||
"ops:deploy:smoke": "bun run scripts/ops/deploy-smoke.ts",
|
"ops:deploy:smoke": "bun run scripts/ops/deploy-smoke.ts",
|
||||||
"ops:aws:miniapp:publish": "bun run scripts/ops/publish-miniapp-aws.ts",
|
"ops:aws:miniapp:publish": "bun run scripts/ops/publish-miniapp-aws.ts",
|
||||||
"ops:telegram:webhook": "bun run scripts/ops/telegram-webhook.ts",
|
"ops:telegram:webhook": "bun run scripts/ops/telegram-webhook.ts",
|
||||||
"ops:telegram:commands": "bun run scripts/ops/telegram-commands.ts",
|
"ops:telegram:commands": "bun run scripts/ops/telegram-commands.ts"
|
||||||
"ops:reminder": "bun run scripts/ops/trigger-reminder.ts"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "1.3.11",
|
"@types/bun": "1.3.11",
|
||||||
|
|||||||
@@ -3,6 +3,6 @@ export { createDbAnonymousFeedbackRepository } from './anonymous-feedback-reposi
|
|||||||
export { createDbFinanceRepository } from './finance-repository'
|
export { createDbFinanceRepository } from './finance-repository'
|
||||||
export { createDbHouseholdConfigurationRepository } from './household-config-repository'
|
export { createDbHouseholdConfigurationRepository } from './household-config-repository'
|
||||||
export { createDbProcessedBotMessageRepository } from './processed-bot-message-repository'
|
export { createDbProcessedBotMessageRepository } from './processed-bot-message-repository'
|
||||||
export { createDbReminderDispatchRepository } from './reminder-dispatch-repository'
|
export { createDbScheduledDispatchRepository } from './scheduled-dispatch-repository'
|
||||||
export { createDbTelegramPendingActionRepository } from './telegram-pending-action-repository'
|
export { createDbTelegramPendingActionRepository } from './telegram-pending-action-repository'
|
||||||
export { createDbTopicMessageHistoryRepository } from './topic-message-history-repository'
|
export { createDbTopicMessageHistoryRepository } from './topic-message-history-repository'
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
import { and, eq } from 'drizzle-orm'
|
|
||||||
|
|
||||||
import { createDbClient, schema } from '@household/db'
|
|
||||||
import type { ReminderDispatchRepository } from '@household/ports'
|
|
||||||
|
|
||||||
export function createDbReminderDispatchRepository(databaseUrl: string): {
|
|
||||||
repository: ReminderDispatchRepository
|
|
||||||
close: () => Promise<void>
|
|
||||||
} {
|
|
||||||
const { db, queryClient } = createDbClient(databaseUrl, {
|
|
||||||
max: 3,
|
|
||||||
prepare: false
|
|
||||||
})
|
|
||||||
|
|
||||||
const repository: ReminderDispatchRepository = {
|
|
||||||
async claimReminderDispatch(input) {
|
|
||||||
const dedupeKey = `${input.period}:${input.reminderType}`
|
|
||||||
const rows = await db
|
|
||||||
.insert(schema.processedBotMessages)
|
|
||||||
.values({
|
|
||||||
householdId: input.householdId,
|
|
||||||
source: 'scheduler-reminder',
|
|
||||||
sourceMessageKey: dedupeKey,
|
|
||||||
payloadHash: input.payloadHash
|
|
||||||
})
|
|
||||||
.onConflictDoNothing({
|
|
||||||
target: [
|
|
||||||
schema.processedBotMessages.householdId,
|
|
||||||
schema.processedBotMessages.source,
|
|
||||||
schema.processedBotMessages.sourceMessageKey
|
|
||||||
]
|
|
||||||
})
|
|
||||||
.returning({ id: schema.processedBotMessages.id })
|
|
||||||
|
|
||||||
return {
|
|
||||||
dedupeKey,
|
|
||||||
claimed: rows.length > 0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async releaseReminderDispatch(input) {
|
|
||||||
const dedupeKey = `${input.period}:${input.reminderType}`
|
|
||||||
|
|
||||||
await db
|
|
||||||
.delete(schema.processedBotMessages)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(schema.processedBotMessages.householdId, input.householdId),
|
|
||||||
eq(schema.processedBotMessages.source, 'scheduler-reminder'),
|
|
||||||
eq(schema.processedBotMessages.sourceMessageKey, dedupeKey)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
repository,
|
|
||||||
close: async () => {
|
|
||||||
await queryClient.end({ timeout: 5 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
252
packages/adapters-db/src/scheduled-dispatch-repository.ts
Normal file
252
packages/adapters-db/src/scheduled-dispatch-repository.ts
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
import { and, asc, eq } from 'drizzle-orm'
|
||||||
|
|
||||||
|
import { createDbClient, schema } from '@household/db'
|
||||||
|
import { instantFromDatabaseValue, instantToDate, nowInstant } from '@household/domain'
|
||||||
|
import type {
|
||||||
|
ClaimScheduledDispatchDeliveryResult,
|
||||||
|
ScheduledDispatchRecord,
|
||||||
|
ScheduledDispatchRepository
|
||||||
|
} from '@household/ports'
|
||||||
|
|
||||||
|
const DELIVERY_CLAIM_SOURCE = 'scheduled-dispatch'
|
||||||
|
|
||||||
|
function scheduledDispatchSelect() {
|
||||||
|
return {
|
||||||
|
id: schema.scheduledDispatches.id,
|
||||||
|
householdId: schema.scheduledDispatches.householdId,
|
||||||
|
kind: schema.scheduledDispatches.kind,
|
||||||
|
dueAt: schema.scheduledDispatches.dueAt,
|
||||||
|
timezone: schema.scheduledDispatches.timezone,
|
||||||
|
status: schema.scheduledDispatches.status,
|
||||||
|
provider: schema.scheduledDispatches.provider,
|
||||||
|
providerDispatchId: schema.scheduledDispatches.providerDispatchId,
|
||||||
|
adHocNotificationId: schema.scheduledDispatches.adHocNotificationId,
|
||||||
|
period: schema.scheduledDispatches.period,
|
||||||
|
sentAt: schema.scheduledDispatches.sentAt,
|
||||||
|
cancelledAt: schema.scheduledDispatches.cancelledAt,
|
||||||
|
createdAt: schema.scheduledDispatches.createdAt,
|
||||||
|
updatedAt: schema.scheduledDispatches.updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapScheduledDispatch(row: {
|
||||||
|
id: string
|
||||||
|
householdId: string
|
||||||
|
kind: string
|
||||||
|
dueAt: Date | string
|
||||||
|
timezone: string
|
||||||
|
status: string
|
||||||
|
provider: string
|
||||||
|
providerDispatchId: string | null
|
||||||
|
adHocNotificationId: string | null
|
||||||
|
period: string | null
|
||||||
|
sentAt: Date | string | null
|
||||||
|
cancelledAt: Date | string | null
|
||||||
|
createdAt: Date | string
|
||||||
|
updatedAt: Date | string
|
||||||
|
}): ScheduledDispatchRecord {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
householdId: row.householdId,
|
||||||
|
kind: row.kind as ScheduledDispatchRecord['kind'],
|
||||||
|
dueAt: instantFromDatabaseValue(row.dueAt)!,
|
||||||
|
timezone: row.timezone,
|
||||||
|
status: row.status as ScheduledDispatchRecord['status'],
|
||||||
|
provider: row.provider as ScheduledDispatchRecord['provider'],
|
||||||
|
providerDispatchId: row.providerDispatchId,
|
||||||
|
adHocNotificationId: row.adHocNotificationId,
|
||||||
|
period: row.period,
|
||||||
|
sentAt: instantFromDatabaseValue(row.sentAt),
|
||||||
|
cancelledAt: instantFromDatabaseValue(row.cancelledAt),
|
||||||
|
createdAt: instantFromDatabaseValue(row.createdAt)!,
|
||||||
|
updatedAt: instantFromDatabaseValue(row.updatedAt)!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDbScheduledDispatchRepository(databaseUrl: string): {
|
||||||
|
repository: ScheduledDispatchRepository
|
||||||
|
close: () => Promise<void>
|
||||||
|
} {
|
||||||
|
const { db, queryClient } = createDbClient(databaseUrl, {
|
||||||
|
max: 3,
|
||||||
|
prepare: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const repository: ScheduledDispatchRepository = {
|
||||||
|
async createScheduledDispatch(input) {
|
||||||
|
const timestamp = instantToDate(nowInstant())
|
||||||
|
const rows = await db
|
||||||
|
.insert(schema.scheduledDispatches)
|
||||||
|
.values({
|
||||||
|
householdId: input.householdId,
|
||||||
|
kind: input.kind,
|
||||||
|
dueAt: instantToDate(input.dueAt),
|
||||||
|
timezone: input.timezone,
|
||||||
|
status: 'scheduled',
|
||||||
|
provider: input.provider,
|
||||||
|
providerDispatchId: input.providerDispatchId ?? null,
|
||||||
|
adHocNotificationId: input.adHocNotificationId ?? null,
|
||||||
|
period: input.period ?? null,
|
||||||
|
updatedAt: timestamp
|
||||||
|
})
|
||||||
|
.returning(scheduledDispatchSelect())
|
||||||
|
|
||||||
|
const row = rows[0]
|
||||||
|
if (!row) {
|
||||||
|
throw new Error('Scheduled dispatch insert did not return a row')
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapScheduledDispatch(row)
|
||||||
|
},
|
||||||
|
|
||||||
|
async getScheduledDispatchById(dispatchId) {
|
||||||
|
const rows = await db
|
||||||
|
.select(scheduledDispatchSelect())
|
||||||
|
.from(schema.scheduledDispatches)
|
||||||
|
.where(eq(schema.scheduledDispatches.id, dispatchId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
return rows[0] ? mapScheduledDispatch(rows[0]) : null
|
||||||
|
},
|
||||||
|
|
||||||
|
async getScheduledDispatchByAdHocNotificationId(notificationId) {
|
||||||
|
const rows = await db
|
||||||
|
.select(scheduledDispatchSelect())
|
||||||
|
.from(schema.scheduledDispatches)
|
||||||
|
.where(eq(schema.scheduledDispatches.adHocNotificationId, notificationId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
return rows[0] ? mapScheduledDispatch(rows[0]) : null
|
||||||
|
},
|
||||||
|
|
||||||
|
async listScheduledDispatchesForHousehold(householdId) {
|
||||||
|
const rows = await db
|
||||||
|
.select(scheduledDispatchSelect())
|
||||||
|
.from(schema.scheduledDispatches)
|
||||||
|
.where(eq(schema.scheduledDispatches.householdId, householdId))
|
||||||
|
.orderBy(asc(schema.scheduledDispatches.dueAt), asc(schema.scheduledDispatches.createdAt))
|
||||||
|
|
||||||
|
return rows.map(mapScheduledDispatch)
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateScheduledDispatch(input) {
|
||||||
|
const updates: Record<string, unknown> = {
|
||||||
|
updatedAt: instantToDate(input.updatedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.dueAt) {
|
||||||
|
updates.dueAt = instantToDate(input.dueAt)
|
||||||
|
}
|
||||||
|
if (input.timezone) {
|
||||||
|
updates.timezone = input.timezone
|
||||||
|
}
|
||||||
|
if (input.providerDispatchId !== undefined) {
|
||||||
|
updates.providerDispatchId = input.providerDispatchId
|
||||||
|
}
|
||||||
|
if (input.period !== undefined) {
|
||||||
|
updates.period = input.period
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.update(schema.scheduledDispatches)
|
||||||
|
.set(updates)
|
||||||
|
.where(eq(schema.scheduledDispatches.id, input.dispatchId))
|
||||||
|
.returning(scheduledDispatchSelect())
|
||||||
|
|
||||||
|
return rows[0] ? mapScheduledDispatch(rows[0]) : null
|
||||||
|
},
|
||||||
|
|
||||||
|
async cancelScheduledDispatch(dispatchId, cancelledAt) {
|
||||||
|
const rows = await db
|
||||||
|
.update(schema.scheduledDispatches)
|
||||||
|
.set({
|
||||||
|
status: 'cancelled',
|
||||||
|
cancelledAt: instantToDate(cancelledAt),
|
||||||
|
updatedAt: instantToDate(nowInstant())
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.scheduledDispatches.id, dispatchId),
|
||||||
|
eq(schema.scheduledDispatches.status, 'scheduled')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.returning(scheduledDispatchSelect())
|
||||||
|
|
||||||
|
return rows[0] ? mapScheduledDispatch(rows[0]) : null
|
||||||
|
},
|
||||||
|
|
||||||
|
async markScheduledDispatchSent(dispatchId, sentAt) {
|
||||||
|
const rows = await db
|
||||||
|
.update(schema.scheduledDispatches)
|
||||||
|
.set({
|
||||||
|
status: 'sent',
|
||||||
|
sentAt: instantToDate(sentAt),
|
||||||
|
updatedAt: instantToDate(nowInstant())
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.scheduledDispatches.id, dispatchId),
|
||||||
|
eq(schema.scheduledDispatches.status, 'scheduled')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.returning(scheduledDispatchSelect())
|
||||||
|
|
||||||
|
return rows[0] ? mapScheduledDispatch(rows[0]) : null
|
||||||
|
},
|
||||||
|
|
||||||
|
async claimScheduledDispatchDelivery(dispatchId) {
|
||||||
|
const dispatch = await repository.getScheduledDispatchById(dispatchId)
|
||||||
|
if (!dispatch) {
|
||||||
|
return {
|
||||||
|
dispatchId,
|
||||||
|
claimed: false
|
||||||
|
} satisfies ClaimScheduledDispatchDeliveryResult
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.insert(schema.processedBotMessages)
|
||||||
|
.values({
|
||||||
|
householdId: dispatch.householdId,
|
||||||
|
source: DELIVERY_CLAIM_SOURCE,
|
||||||
|
sourceMessageKey: dispatchId
|
||||||
|
})
|
||||||
|
.onConflictDoNothing({
|
||||||
|
target: [
|
||||||
|
schema.processedBotMessages.householdId,
|
||||||
|
schema.processedBotMessages.source,
|
||||||
|
schema.processedBotMessages.sourceMessageKey
|
||||||
|
]
|
||||||
|
})
|
||||||
|
.returning({ id: schema.processedBotMessages.id })
|
||||||
|
|
||||||
|
return {
|
||||||
|
dispatchId,
|
||||||
|
claimed: rows.length > 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async releaseScheduledDispatchDelivery(dispatchId) {
|
||||||
|
const dispatch = await repository.getScheduledDispatchById(dispatchId)
|
||||||
|
if (!dispatch) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(schema.processedBotMessages)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.processedBotMessages.householdId, dispatch.householdId),
|
||||||
|
eq(schema.processedBotMessages.source, DELIVERY_CLAIM_SOURCE),
|
||||||
|
eq(schema.processedBotMessages.sourceMessageKey, dispatchId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
repository,
|
||||||
|
close: async () => {
|
||||||
|
await queryClient.end({ timeout: 5 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
HouseholdConfigurationRepository,
|
HouseholdConfigurationRepository,
|
||||||
HouseholdMemberRecord
|
HouseholdMemberRecord
|
||||||
} from '@household/ports'
|
} from '@household/ports'
|
||||||
|
import type { ScheduledDispatchService } from './scheduled-dispatch-service'
|
||||||
|
|
||||||
interface NotificationActor {
|
interface NotificationActor {
|
||||||
memberId: string
|
memberId: string
|
||||||
@@ -57,6 +58,7 @@ export type ScheduleAdHocNotificationResult =
|
|||||||
| 'delivery_mode_invalid'
|
| 'delivery_mode_invalid'
|
||||||
| 'friendly_assignee_missing'
|
| 'friendly_assignee_missing'
|
||||||
| 'scheduled_for_past'
|
| 'scheduled_for_past'
|
||||||
|
| 'dispatch_schedule_failed'
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CancelAdHocNotificationResult =
|
export type CancelAdHocNotificationResult =
|
||||||
@@ -78,7 +80,11 @@ export type UpdateAdHocNotificationResult =
|
|||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
status: 'invalid'
|
status: 'invalid'
|
||||||
reason: 'delivery_mode_invalid' | 'dm_recipients_missing' | 'scheduled_for_past'
|
reason:
|
||||||
|
| 'delivery_mode_invalid'
|
||||||
|
| 'dm_recipients_missing'
|
||||||
|
| 'scheduled_for_past'
|
||||||
|
| 'dispatch_schedule_failed'
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdHocNotificationService {
|
export interface AdHocNotificationService {
|
||||||
@@ -165,6 +171,7 @@ export function createAdHocNotificationService(input: {
|
|||||||
HouseholdConfigurationRepository,
|
HouseholdConfigurationRepository,
|
||||||
'getHouseholdMember' | 'listHouseholdMembers'
|
'getHouseholdMember' | 'listHouseholdMembers'
|
||||||
>
|
>
|
||||||
|
scheduledDispatchService?: ScheduledDispatchService
|
||||||
}): AdHocNotificationService {
|
}): AdHocNotificationService {
|
||||||
async function resolveActor(
|
async function resolveActor(
|
||||||
householdId: string,
|
householdId: string,
|
||||||
@@ -272,6 +279,28 @@ export function createAdHocNotificationService(input: {
|
|||||||
sourceTelegramThreadId: notificationInput.sourceTelegramThreadId ?? null
|
sourceTelegramThreadId: notificationInput.sourceTelegramThreadId ?? null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (input.scheduledDispatchService) {
|
||||||
|
try {
|
||||||
|
await input.scheduledDispatchService.scheduleAdHocNotification({
|
||||||
|
householdId: notification.householdId,
|
||||||
|
notificationId: notification.id,
|
||||||
|
dueAt: notification.scheduledFor,
|
||||||
|
timezone: notification.timezone
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
await input.repository.cancelNotification({
|
||||||
|
notificationId: notification.id,
|
||||||
|
cancelledByMemberId: notification.creatorMemberId,
|
||||||
|
cancelledAt: nowInstant()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'invalid',
|
||||||
|
reason: 'dispatch_schedule_failed'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: 'scheduled',
|
status: 'scheduled',
|
||||||
notification
|
notification
|
||||||
@@ -352,6 +381,10 @@ export function createAdHocNotificationService(input: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (input.scheduledDispatchService) {
|
||||||
|
await input.scheduledDispatchService.cancelAdHocNotification(notificationId, asOf)
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: 'cancelled',
|
status: 'cancelled',
|
||||||
notification: cancelled
|
notification: cancelled
|
||||||
@@ -397,6 +430,10 @@ export function createAdHocNotificationService(input: {
|
|||||||
input.householdConfigurationRepository,
|
input.householdConfigurationRepository,
|
||||||
notification.householdId
|
notification.householdId
|
||||||
)
|
)
|
||||||
|
const previousScheduledFor = notification.scheduledFor
|
||||||
|
const previousTimePrecision = notification.timePrecision
|
||||||
|
const previousDeliveryMode = notification.deliveryMode
|
||||||
|
const previousDmRecipientMemberIds = notification.dmRecipientMemberIds
|
||||||
|
|
||||||
if (scheduledFor && scheduledFor.epochMilliseconds <= asOf.epochMilliseconds) {
|
if (scheduledFor && scheduledFor.epochMilliseconds <= asOf.epochMilliseconds) {
|
||||||
return {
|
return {
|
||||||
@@ -455,6 +492,31 @@ export function createAdHocNotificationService(input: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (input.scheduledDispatchService) {
|
||||||
|
try {
|
||||||
|
await input.scheduledDispatchService.scheduleAdHocNotification({
|
||||||
|
householdId: updated.householdId,
|
||||||
|
notificationId: updated.id,
|
||||||
|
dueAt: updated.scheduledFor,
|
||||||
|
timezone: updated.timezone
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
await input.repository.updateNotification({
|
||||||
|
notificationId,
|
||||||
|
scheduledFor: previousScheduledFor,
|
||||||
|
timePrecision: previousTimePrecision,
|
||||||
|
deliveryMode: previousDeliveryMode,
|
||||||
|
dmRecipientMemberIds: previousDmRecipientMemberIds,
|
||||||
|
updatedAt: nowInstant()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'invalid',
|
||||||
|
reason: 'dispatch_schedule_failed'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: 'updated',
|
status: 'updated',
|
||||||
notification: updated
|
notification: updated
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
HouseholdTopicBindingRecord,
|
HouseholdTopicBindingRecord,
|
||||||
HouseholdTopicRole
|
HouseholdTopicRole
|
||||||
} from '@household/ports'
|
} from '@household/ports'
|
||||||
|
import type { ScheduledDispatchService } from './scheduled-dispatch-service'
|
||||||
|
|
||||||
export interface HouseholdSetupService {
|
export interface HouseholdSetupService {
|
||||||
setupGroupChat(input: {
|
setupGroupChat(input: {
|
||||||
@@ -72,7 +73,8 @@ function defaultHouseholdName(title: string | undefined, telegramChatId: string)
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createHouseholdSetupService(
|
export function createHouseholdSetupService(
|
||||||
repository: HouseholdConfigurationRepository
|
repository: HouseholdConfigurationRepository,
|
||||||
|
scheduledDispatchService?: ScheduledDispatchService
|
||||||
): HouseholdSetupService {
|
): HouseholdSetupService {
|
||||||
return {
|
return {
|
||||||
async setupGroupChat(input) {
|
async setupGroupChat(input) {
|
||||||
@@ -118,6 +120,12 @@ export function createHouseholdSetupService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (scheduledDispatchService) {
|
||||||
|
await scheduledDispatchService.reconcileHouseholdBuiltInDispatches(
|
||||||
|
registered.household.householdId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: registered.status,
|
status: registered.status,
|
||||||
household: registered.household
|
household: registered.household
|
||||||
|
|||||||
@@ -25,10 +25,9 @@ export {
|
|||||||
type HouseholdOnboardingService
|
type HouseholdOnboardingService
|
||||||
} from './household-onboarding-service'
|
} from './household-onboarding-service'
|
||||||
export {
|
export {
|
||||||
createReminderJobService,
|
createScheduledDispatchService,
|
||||||
type ReminderJobResult,
|
type ScheduledDispatchService
|
||||||
type ReminderJobService
|
} from './scheduled-dispatch-service'
|
||||||
} from './reminder-job-service'
|
|
||||||
export {
|
export {
|
||||||
createLocalePreferenceService,
|
createLocalePreferenceService,
|
||||||
type LocalePreferenceService
|
type LocalePreferenceService
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
HouseholdUtilityCategoryRecord
|
HouseholdUtilityCategoryRecord
|
||||||
} from '@household/ports'
|
} from '@household/ports'
|
||||||
import { Money, Temporal, type CurrencyCode } from '@household/domain'
|
import { Money, Temporal, type CurrencyCode } from '@household/domain'
|
||||||
|
import type { ScheduledDispatchService } from './scheduled-dispatch-service'
|
||||||
|
|
||||||
function isValidDay(value: number): boolean {
|
function isValidDay(value: number): boolean {
|
||||||
return Number.isInteger(value) && value >= 1 && value <= 31
|
return Number.isInteger(value) && value >= 1 && value <= 31
|
||||||
@@ -339,7 +340,8 @@ function normalizeAssistantText(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createMiniAppAdminService(
|
export function createMiniAppAdminService(
|
||||||
repository: HouseholdConfigurationRepository
|
repository: HouseholdConfigurationRepository,
|
||||||
|
scheduledDispatchService?: ScheduledDispatchService
|
||||||
): MiniAppAdminService {
|
): MiniAppAdminService {
|
||||||
return {
|
return {
|
||||||
async getSettings(input) {
|
async getSettings(input) {
|
||||||
@@ -531,6 +533,10 @@ export function createMiniAppAdminService(
|
|||||||
throw new Error('Failed to resolve household chat after settings update')
|
throw new Error('Failed to resolve household chat after settings update')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (scheduledDispatchService) {
|
||||||
|
await scheduledDispatchService.reconcileHouseholdBuiltInDispatches(input.householdId)
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
householdName: household.householdName,
|
householdName: household.householdName,
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
import { describe, expect, test } from 'bun:test'
|
|
||||||
|
|
||||||
import type {
|
|
||||||
ClaimReminderDispatchInput,
|
|
||||||
ClaimReminderDispatchResult,
|
|
||||||
ReminderDispatchRepository
|
|
||||||
} from '@household/ports'
|
|
||||||
|
|
||||||
import { createReminderJobService } from './reminder-job-service'
|
|
||||||
|
|
||||||
class ReminderDispatchRepositoryStub implements ReminderDispatchRepository {
|
|
||||||
nextResult: ClaimReminderDispatchResult = {
|
|
||||||
dedupeKey: '2026-03:utilities',
|
|
||||||
claimed: true
|
|
||||||
}
|
|
||||||
|
|
||||||
lastClaim: ClaimReminderDispatchInput | null = null
|
|
||||||
|
|
||||||
async claimReminderDispatch(
|
|
||||||
input: ClaimReminderDispatchInput
|
|
||||||
): Promise<ClaimReminderDispatchResult> {
|
|
||||||
this.lastClaim = input
|
|
||||||
return this.nextResult
|
|
||||||
}
|
|
||||||
|
|
||||||
async releaseReminderDispatch(): Promise<void> {}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('createReminderJobService', () => {
|
|
||||||
test('returns dry-run result without touching the repository', async () => {
|
|
||||||
const repository = new ReminderDispatchRepositoryStub()
|
|
||||||
const service = createReminderJobService(repository)
|
|
||||||
|
|
||||||
const result = await service.handleJob({
|
|
||||||
householdId: 'household-1',
|
|
||||||
period: '2026-03',
|
|
||||||
reminderType: 'utilities',
|
|
||||||
dryRun: true
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(result.status).toBe('dry-run')
|
|
||||||
expect(result.dedupeKey).toBe('2026-03:utilities')
|
|
||||||
expect(result.messageText).toBe('Utilities reminder for 2026-03')
|
|
||||||
expect(repository.lastClaim).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('claims a dispatch once and returns the dedupe key', async () => {
|
|
||||||
const repository = new ReminderDispatchRepositoryStub()
|
|
||||||
repository.nextResult = {
|
|
||||||
dedupeKey: '2026-03:rent-due',
|
|
||||||
claimed: true
|
|
||||||
}
|
|
||||||
const service = createReminderJobService(repository)
|
|
||||||
|
|
||||||
const result = await service.handleJob({
|
|
||||||
householdId: 'household-1',
|
|
||||||
period: '2026-03',
|
|
||||||
reminderType: 'rent-due'
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(result.status).toBe('claimed')
|
|
||||||
expect(result.dedupeKey).toBe('2026-03:rent-due')
|
|
||||||
expect(repository.lastClaim).toMatchObject({
|
|
||||||
householdId: 'household-1',
|
|
||||||
period: '2026-03',
|
|
||||||
reminderType: 'rent-due'
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test('returns duplicate when the repository rejects a replay', async () => {
|
|
||||||
const repository = new ReminderDispatchRepositoryStub()
|
|
||||||
repository.nextResult = {
|
|
||||||
dedupeKey: '2026-03:rent-warning',
|
|
||||||
claimed: false
|
|
||||||
}
|
|
||||||
|
|
||||||
const service = createReminderJobService(repository)
|
|
||||||
const result = await service.handleJob({
|
|
||||||
householdId: 'household-1',
|
|
||||||
period: '2026-03',
|
|
||||||
reminderType: 'rent-warning'
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(result.status).toBe('duplicate')
|
|
||||||
expect(result.dedupeKey).toBe('2026-03:rent-warning')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
import { createHash } from 'node:crypto'
|
|
||||||
|
|
||||||
import { BillingPeriod } from '@household/domain'
|
|
||||||
import type {
|
|
||||||
ClaimReminderDispatchResult,
|
|
||||||
ReminderDispatchRepository,
|
|
||||||
ReminderType
|
|
||||||
} from '@household/ports'
|
|
||||||
|
|
||||||
function computePayloadHash(payload: object): string {
|
|
||||||
return createHash('sha256').update(JSON.stringify(payload)).digest('hex')
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildReminderDedupeKey(period: string, reminderType: ReminderType): string {
|
|
||||||
return `${period}:${reminderType}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function createReminderMessage(reminderType: ReminderType, period: string): string {
|
|
||||||
switch (reminderType) {
|
|
||||||
case 'utilities':
|
|
||||||
return `Utilities reminder for ${period}`
|
|
||||||
case 'rent-warning':
|
|
||||||
return `Rent reminder for ${period}: payment is coming up soon.`
|
|
||||||
case 'rent-due':
|
|
||||||
return `Rent due reminder for ${period}: please settle payment today.`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ReminderJobResult {
|
|
||||||
status: 'dry-run' | 'claimed' | 'duplicate'
|
|
||||||
dedupeKey: string
|
|
||||||
payloadHash: string
|
|
||||||
reminderType: ReminderType
|
|
||||||
period: string
|
|
||||||
messageText: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ReminderJobService {
|
|
||||||
handleJob(input: {
|
|
||||||
householdId: string
|
|
||||||
period: string
|
|
||||||
reminderType: ReminderType
|
|
||||||
dryRun?: boolean
|
|
||||||
}): Promise<ReminderJobResult>
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createReminderJobService(
|
|
||||||
repository: ReminderDispatchRepository
|
|
||||||
): ReminderJobService {
|
|
||||||
return {
|
|
||||||
async handleJob(input) {
|
|
||||||
const period = BillingPeriod.fromString(input.period).toString()
|
|
||||||
const payloadHash = computePayloadHash({
|
|
||||||
householdId: input.householdId,
|
|
||||||
period,
|
|
||||||
reminderType: input.reminderType
|
|
||||||
})
|
|
||||||
const messageText = createReminderMessage(input.reminderType, period)
|
|
||||||
|
|
||||||
if (input.dryRun === true) {
|
|
||||||
return {
|
|
||||||
status: 'dry-run',
|
|
||||||
dedupeKey: buildReminderDedupeKey(period, input.reminderType),
|
|
||||||
payloadHash,
|
|
||||||
reminderType: input.reminderType,
|
|
||||||
period,
|
|
||||||
messageText
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const result: ClaimReminderDispatchResult = await repository.claimReminderDispatch({
|
|
||||||
householdId: input.householdId,
|
|
||||||
period,
|
|
||||||
reminderType: input.reminderType,
|
|
||||||
payloadHash
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: result.claimed ? 'claimed' : 'duplicate',
|
|
||||||
dedupeKey: result.dedupeKey,
|
|
||||||
payloadHash,
|
|
||||||
reminderType: input.reminderType,
|
|
||||||
period,
|
|
||||||
messageText
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
294
packages/application/src/scheduled-dispatch-service.test.ts
Normal file
294
packages/application/src/scheduled-dispatch-service.test.ts
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
|
||||||
|
import { Temporal } from '@household/domain'
|
||||||
|
import type {
|
||||||
|
HouseholdBillingSettingsRecord,
|
||||||
|
HouseholdTelegramChatRecord,
|
||||||
|
ReminderTarget,
|
||||||
|
ScheduledDispatchRecord,
|
||||||
|
ScheduledDispatchRepository,
|
||||||
|
ScheduledDispatchScheduler
|
||||||
|
} from '@household/ports'
|
||||||
|
|
||||||
|
import { createScheduledDispatchService } from './scheduled-dispatch-service'
|
||||||
|
|
||||||
|
class ScheduledDispatchRepositoryStub implements ScheduledDispatchRepository {
|
||||||
|
dispatches = new Map<string, ScheduledDispatchRecord>()
|
||||||
|
nextId = 1
|
||||||
|
claims = new Set<string>()
|
||||||
|
|
||||||
|
async createScheduledDispatch(input: {
|
||||||
|
householdId: string
|
||||||
|
kind: ScheduledDispatchRecord['kind']
|
||||||
|
dueAt: Temporal.Instant
|
||||||
|
timezone: string
|
||||||
|
provider: ScheduledDispatchRecord['provider']
|
||||||
|
providerDispatchId?: string | null
|
||||||
|
adHocNotificationId?: string | null
|
||||||
|
period?: string | null
|
||||||
|
}): Promise<ScheduledDispatchRecord> {
|
||||||
|
const id = `dispatch-${this.nextId++}`
|
||||||
|
const record: ScheduledDispatchRecord = {
|
||||||
|
id,
|
||||||
|
householdId: input.householdId,
|
||||||
|
kind: input.kind,
|
||||||
|
dueAt: input.dueAt,
|
||||||
|
timezone: input.timezone,
|
||||||
|
status: 'scheduled',
|
||||||
|
provider: input.provider,
|
||||||
|
providerDispatchId: input.providerDispatchId ?? null,
|
||||||
|
adHocNotificationId: input.adHocNotificationId ?? null,
|
||||||
|
period: input.period ?? null,
|
||||||
|
sentAt: null,
|
||||||
|
cancelledAt: null,
|
||||||
|
createdAt: Temporal.Instant.from('2026-03-24T00:00:00Z'),
|
||||||
|
updatedAt: Temporal.Instant.from('2026-03-24T00:00:00Z')
|
||||||
|
}
|
||||||
|
this.dispatches.set(id, record)
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
|
||||||
|
async getScheduledDispatchById(dispatchId: string): Promise<ScheduledDispatchRecord | null> {
|
||||||
|
return this.dispatches.get(dispatchId) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
async getScheduledDispatchByAdHocNotificationId(
|
||||||
|
notificationId: string
|
||||||
|
): Promise<ScheduledDispatchRecord | null> {
|
||||||
|
return (
|
||||||
|
[...this.dispatches.values()].find(
|
||||||
|
(dispatch) => dispatch.adHocNotificationId === notificationId
|
||||||
|
) ?? null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async listScheduledDispatchesForHousehold(
|
||||||
|
householdId: string
|
||||||
|
): Promise<readonly ScheduledDispatchRecord[]> {
|
||||||
|
return [...this.dispatches.values()].filter((dispatch) => dispatch.householdId === householdId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateScheduledDispatch(input: {
|
||||||
|
dispatchId: string
|
||||||
|
dueAt?: Temporal.Instant
|
||||||
|
timezone?: string
|
||||||
|
providerDispatchId?: string | null
|
||||||
|
period?: string | null
|
||||||
|
updatedAt: Temporal.Instant
|
||||||
|
}): Promise<ScheduledDispatchRecord | null> {
|
||||||
|
const current = this.dispatches.get(input.dispatchId)
|
||||||
|
if (!current) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const next: ScheduledDispatchRecord = {
|
||||||
|
...current,
|
||||||
|
dueAt: input.dueAt ?? current.dueAt,
|
||||||
|
timezone: input.timezone ?? current.timezone,
|
||||||
|
providerDispatchId:
|
||||||
|
input.providerDispatchId === undefined
|
||||||
|
? current.providerDispatchId
|
||||||
|
: input.providerDispatchId,
|
||||||
|
period: input.period === undefined ? current.period : input.period,
|
||||||
|
updatedAt: input.updatedAt
|
||||||
|
}
|
||||||
|
this.dispatches.set(input.dispatchId, next)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancelScheduledDispatch(
|
||||||
|
dispatchId: string,
|
||||||
|
cancelledAt: Temporal.Instant
|
||||||
|
): Promise<ScheduledDispatchRecord | null> {
|
||||||
|
const current = this.dispatches.get(dispatchId)
|
||||||
|
if (!current || current.status !== 'scheduled') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const next: ScheduledDispatchRecord = {
|
||||||
|
...current,
|
||||||
|
status: 'cancelled',
|
||||||
|
cancelledAt
|
||||||
|
}
|
||||||
|
this.dispatches.set(dispatchId, next)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
async markScheduledDispatchSent(
|
||||||
|
dispatchId: string,
|
||||||
|
sentAt: Temporal.Instant
|
||||||
|
): Promise<ScheduledDispatchRecord | null> {
|
||||||
|
const current = this.dispatches.get(dispatchId)
|
||||||
|
if (!current || current.status !== 'scheduled') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const next: ScheduledDispatchRecord = {
|
||||||
|
...current,
|
||||||
|
status: 'sent',
|
||||||
|
sentAt
|
||||||
|
}
|
||||||
|
this.dispatches.set(dispatchId, next)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
async claimScheduledDispatchDelivery(dispatchId: string) {
|
||||||
|
if (this.claims.has(dispatchId)) {
|
||||||
|
return { dispatchId, claimed: false }
|
||||||
|
}
|
||||||
|
this.claims.add(dispatchId)
|
||||||
|
return { dispatchId, claimed: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
async releaseScheduledDispatchDelivery(dispatchId: string) {
|
||||||
|
this.claims.delete(dispatchId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSchedulerStub(): ScheduledDispatchScheduler & {
|
||||||
|
scheduled: Array<{ dispatchId: string; dueAt: string }>
|
||||||
|
cancelled: string[]
|
||||||
|
} {
|
||||||
|
let nextId = 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider: 'gcp-cloud-tasks',
|
||||||
|
scheduled: [],
|
||||||
|
cancelled: [],
|
||||||
|
async scheduleOneShotDispatch(input) {
|
||||||
|
this.scheduled.push({
|
||||||
|
dispatchId: input.dispatchId,
|
||||||
|
dueAt: input.dueAt.toString()
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
providerDispatchId: `provider-${nextId++}`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async cancelDispatch(providerDispatchId) {
|
||||||
|
this.cancelled.push(providerDispatchId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function billingSettings(
|
||||||
|
timezone = 'Asia/Tbilisi'
|
||||||
|
): HouseholdBillingSettingsRecord & { householdId: string } {
|
||||||
|
return {
|
||||||
|
householdId: 'household-1',
|
||||||
|
settlementCurrency: 'GEL',
|
||||||
|
timezone,
|
||||||
|
rentDueDay: 5,
|
||||||
|
rentWarningDay: 3,
|
||||||
|
utilitiesReminderDay: 12,
|
||||||
|
utilitiesDueDay: 15,
|
||||||
|
rentAmountMinor: null,
|
||||||
|
rentCurrency: 'GEL',
|
||||||
|
rentPaymentDestinations: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function householdChat(): HouseholdTelegramChatRecord {
|
||||||
|
return {
|
||||||
|
householdId: 'household-1',
|
||||||
|
householdName: 'Kojori',
|
||||||
|
telegramChatId: 'chat-1',
|
||||||
|
telegramChatType: 'supergroup',
|
||||||
|
title: 'Kojori',
|
||||||
|
defaultLocale: 'ru'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('createScheduledDispatchService', () => {
|
||||||
|
test('schedules and reschedules ad hoc notifications via provider task', async () => {
|
||||||
|
const repository = new ScheduledDispatchRepositoryStub()
|
||||||
|
const scheduler = createSchedulerStub()
|
||||||
|
const service = createScheduledDispatchService({
|
||||||
|
repository,
|
||||||
|
scheduler,
|
||||||
|
householdConfigurationRepository: {
|
||||||
|
async getHouseholdBillingSettings() {
|
||||||
|
return billingSettings()
|
||||||
|
},
|
||||||
|
async getHouseholdChatByHouseholdId() {
|
||||||
|
return householdChat()
|
||||||
|
},
|
||||||
|
async listReminderTargets(): Promise<readonly ReminderTarget[]> {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const firstDueAt = Temporal.Instant.from('2026-03-25T08:00:00Z')
|
||||||
|
const secondDueAt = Temporal.Instant.from('2026-03-25T09:00:00Z')
|
||||||
|
|
||||||
|
const first = await service.scheduleAdHocNotification({
|
||||||
|
householdId: 'household-1',
|
||||||
|
notificationId: 'notif-1',
|
||||||
|
dueAt: firstDueAt,
|
||||||
|
timezone: 'Asia/Tbilisi'
|
||||||
|
})
|
||||||
|
const second = await service.scheduleAdHocNotification({
|
||||||
|
householdId: 'household-1',
|
||||||
|
notificationId: 'notif-1',
|
||||||
|
dueAt: secondDueAt,
|
||||||
|
timezone: 'Asia/Tbilisi'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(first.providerDispatchId).toBe('provider-1')
|
||||||
|
expect(second.providerDispatchId).toBe('provider-2')
|
||||||
|
expect(scheduler.cancelled).toEqual(['provider-1'])
|
||||||
|
|
||||||
|
await service.cancelAdHocNotification('notif-1', Temporal.Instant.from('2026-03-24T11:00:00Z'))
|
||||||
|
|
||||||
|
expect(scheduler.cancelled).toEqual(['provider-1', 'provider-2'])
|
||||||
|
expect((await repository.getScheduledDispatchByAdHocNotificationId('notif-1'))?.status).toBe(
|
||||||
|
'cancelled'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('reconciles one future built-in dispatch per reminder kind', async () => {
|
||||||
|
const repository = new ScheduledDispatchRepositoryStub()
|
||||||
|
const scheduler = createSchedulerStub()
|
||||||
|
const service = createScheduledDispatchService({
|
||||||
|
repository,
|
||||||
|
scheduler,
|
||||||
|
householdConfigurationRepository: {
|
||||||
|
async getHouseholdBillingSettings() {
|
||||||
|
return billingSettings()
|
||||||
|
},
|
||||||
|
async getHouseholdChatByHouseholdId() {
|
||||||
|
return householdChat()
|
||||||
|
},
|
||||||
|
async listReminderTargets(): Promise<readonly ReminderTarget[]> {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
householdId: 'household-1',
|
||||||
|
householdName: 'Kojori',
|
||||||
|
telegramChatId: 'chat-1',
|
||||||
|
telegramThreadId: '103',
|
||||||
|
locale: 'ru',
|
||||||
|
timezone: 'Asia/Tbilisi',
|
||||||
|
utilitiesReminderDay: 12,
|
||||||
|
utilitiesDueDay: 15,
|
||||||
|
rentWarningDay: 3,
|
||||||
|
rentDueDay: 5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await service.reconcileAllBuiltInDispatches(Temporal.Instant.from('2026-03-24T00:00:00Z'))
|
||||||
|
|
||||||
|
const scheduled = [...repository.dispatches.values()].filter(
|
||||||
|
(dispatch) => dispatch.status === 'scheduled'
|
||||||
|
)
|
||||||
|
expect(scheduled.map((dispatch) => dispatch.kind).sort()).toEqual([
|
||||||
|
'rent_due',
|
||||||
|
'rent_warning',
|
||||||
|
'utilities'
|
||||||
|
])
|
||||||
|
expect(scheduler.scheduled).toHaveLength(3)
|
||||||
|
expect(scheduled.every((dispatch) => dispatch.period === '2026-04')).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
327
packages/application/src/scheduled-dispatch-service.ts
Normal file
327
packages/application/src/scheduled-dispatch-service.ts
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
import { BillingPeriod, Temporal, nowInstant, type Instant } from '@household/domain'
|
||||||
|
import type {
|
||||||
|
HouseholdConfigurationRepository,
|
||||||
|
ScheduledDispatchKind,
|
||||||
|
ScheduledDispatchRecord,
|
||||||
|
ScheduledDispatchRepository,
|
||||||
|
ScheduledDispatchScheduler
|
||||||
|
} from '@household/ports'
|
||||||
|
|
||||||
|
const BUILT_IN_DISPATCH_KINDS = ['utilities', 'rent_warning', 'rent_due'] as const
|
||||||
|
|
||||||
|
function builtInDispatchDay(
|
||||||
|
kind: (typeof BUILT_IN_DISPATCH_KINDS)[number],
|
||||||
|
settings: Awaited<ReturnType<HouseholdConfigurationRepository['getHouseholdBillingSettings']>>
|
||||||
|
): number {
|
||||||
|
switch (kind) {
|
||||||
|
case 'utilities':
|
||||||
|
return settings.utilitiesReminderDay
|
||||||
|
case 'rent_warning':
|
||||||
|
return settings.rentWarningDay
|
||||||
|
case 'rent_due':
|
||||||
|
return settings.rentDueDay
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function builtInDispatchHour(): number {
|
||||||
|
return 9
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampDay(year: number, month: number, day: number): number {
|
||||||
|
const yearMonth = new Temporal.PlainYearMonth(year, month)
|
||||||
|
return Math.min(day, yearMonth.daysInMonth)
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextBuiltInDispatch(input: {
|
||||||
|
kind: (typeof BUILT_IN_DISPATCH_KINDS)[number]
|
||||||
|
timezone: string
|
||||||
|
day: number
|
||||||
|
asOf: Instant
|
||||||
|
}): {
|
||||||
|
dueAt: Instant
|
||||||
|
period: string
|
||||||
|
} {
|
||||||
|
const localNow = input.asOf.toZonedDateTimeISO(input.timezone)
|
||||||
|
let year = localNow.year
|
||||||
|
let month = localNow.month
|
||||||
|
|
||||||
|
const buildCandidate = (candidateYear: number, candidateMonth: number) => {
|
||||||
|
const candidateDay = clampDay(candidateYear, candidateMonth, input.day)
|
||||||
|
return new Temporal.PlainDateTime(
|
||||||
|
candidateYear,
|
||||||
|
candidateMonth,
|
||||||
|
candidateDay,
|
||||||
|
builtInDispatchHour(),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
).toZonedDateTime(input.timezone)
|
||||||
|
}
|
||||||
|
|
||||||
|
let candidate = buildCandidate(year, month)
|
||||||
|
if (candidate.epochMilliseconds <= localNow.epochMilliseconds) {
|
||||||
|
const nextMonth = new Temporal.PlainYearMonth(localNow.year, localNow.month).add({
|
||||||
|
months: 1
|
||||||
|
})
|
||||||
|
year = nextMonth.year
|
||||||
|
month = nextMonth.month
|
||||||
|
candidate = buildCandidate(year, month)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
dueAt: candidate.toInstant(),
|
||||||
|
period: BillingPeriod.fromString(
|
||||||
|
`${candidate.year}-${String(candidate.month).padStart(2, '0')}`
|
||||||
|
).toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScheduledDispatchService {
|
||||||
|
scheduleAdHocNotification(input: {
|
||||||
|
householdId: string
|
||||||
|
notificationId: string
|
||||||
|
dueAt: Instant
|
||||||
|
timezone: string
|
||||||
|
}): Promise<ScheduledDispatchRecord>
|
||||||
|
cancelAdHocNotification(notificationId: string, cancelledAt?: Instant): Promise<void>
|
||||||
|
reconcileHouseholdBuiltInDispatches(householdId: string, asOf?: Instant): Promise<void>
|
||||||
|
reconcileAllBuiltInDispatches(asOf?: Instant): Promise<void>
|
||||||
|
getDispatchById(dispatchId: string): Promise<ScheduledDispatchRecord | null>
|
||||||
|
claimDispatch(dispatchId: string): Promise<boolean>
|
||||||
|
releaseDispatch(dispatchId: string): Promise<void>
|
||||||
|
markDispatchSent(dispatchId: string, sentAt?: Instant): Promise<ScheduledDispatchRecord | null>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createScheduledDispatchService(input: {
|
||||||
|
repository: ScheduledDispatchRepository
|
||||||
|
scheduler: ScheduledDispatchScheduler
|
||||||
|
householdConfigurationRepository: Pick<
|
||||||
|
HouseholdConfigurationRepository,
|
||||||
|
'getHouseholdBillingSettings' | 'getHouseholdChatByHouseholdId' | 'listReminderTargets'
|
||||||
|
>
|
||||||
|
}): ScheduledDispatchService {
|
||||||
|
async function createDispatchRecord(record: {
|
||||||
|
householdId: string
|
||||||
|
kind: ScheduledDispatchKind
|
||||||
|
dueAt: Instant
|
||||||
|
timezone: string
|
||||||
|
adHocNotificationId?: string | null
|
||||||
|
period?: string | null
|
||||||
|
}) {
|
||||||
|
return input.repository.createScheduledDispatch({
|
||||||
|
householdId: record.householdId,
|
||||||
|
kind: record.kind,
|
||||||
|
dueAt: record.dueAt,
|
||||||
|
timezone: record.timezone,
|
||||||
|
provider: input.scheduler.provider,
|
||||||
|
providerDispatchId: null,
|
||||||
|
adHocNotificationId: record.adHocNotificationId ?? null,
|
||||||
|
period: record.period ?? null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function activateDispatch(
|
||||||
|
dispatch: ScheduledDispatchRecord,
|
||||||
|
dueAt: Instant,
|
||||||
|
timezone: string,
|
||||||
|
period?: string | null
|
||||||
|
) {
|
||||||
|
const result = await input.scheduler.scheduleOneShotDispatch({
|
||||||
|
dispatchId: dispatch.id,
|
||||||
|
dueAt
|
||||||
|
})
|
||||||
|
|
||||||
|
const updated = await input.repository.updateScheduledDispatch({
|
||||||
|
dispatchId: dispatch.id,
|
||||||
|
dueAt,
|
||||||
|
timezone,
|
||||||
|
providerDispatchId: result.providerDispatchId,
|
||||||
|
period: period ?? null,
|
||||||
|
updatedAt: nowInstant()
|
||||||
|
})
|
||||||
|
if (!updated) {
|
||||||
|
await input.scheduler.cancelDispatch(result.providerDispatchId)
|
||||||
|
throw new Error(`Failed to update scheduled dispatch ${dispatch.id}`)
|
||||||
|
}
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureBuiltInDispatch(inputValue: {
|
||||||
|
householdId: string
|
||||||
|
kind: (typeof BUILT_IN_DISPATCH_KINDS)[number]
|
||||||
|
dueAt: Instant
|
||||||
|
timezone: string
|
||||||
|
period: string
|
||||||
|
existing: ScheduledDispatchRecord | null
|
||||||
|
}) {
|
||||||
|
if (
|
||||||
|
inputValue.existing &&
|
||||||
|
inputValue.existing.status === 'scheduled' &&
|
||||||
|
inputValue.existing.dueAt.epochMilliseconds === inputValue.dueAt.epochMilliseconds &&
|
||||||
|
inputValue.existing.period === inputValue.period &&
|
||||||
|
inputValue.existing.provider === input.scheduler.provider &&
|
||||||
|
inputValue.existing.providerDispatchId
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inputValue.existing) {
|
||||||
|
const created = await createDispatchRecord({
|
||||||
|
householdId: inputValue.householdId,
|
||||||
|
kind: inputValue.kind,
|
||||||
|
dueAt: inputValue.dueAt,
|
||||||
|
timezone: inputValue.timezone,
|
||||||
|
period: inputValue.period
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await activateDispatch(created, inputValue.dueAt, inputValue.timezone, inputValue.period)
|
||||||
|
} catch (error) {
|
||||||
|
await input.repository.cancelScheduledDispatch(created.id, nowInstant())
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousProviderDispatchId = inputValue.existing.providerDispatchId
|
||||||
|
const updated = await activateDispatch(
|
||||||
|
inputValue.existing,
|
||||||
|
inputValue.dueAt,
|
||||||
|
inputValue.timezone,
|
||||||
|
inputValue.period
|
||||||
|
)
|
||||||
|
|
||||||
|
if (previousProviderDispatchId && previousProviderDispatchId !== updated.providerDispatchId) {
|
||||||
|
await input.scheduler.cancelDispatch(previousProviderDispatchId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reconcileHouseholdBuiltInDispatches(householdId: string, asOf = nowInstant()) {
|
||||||
|
const [chat, settings, existingDispatches] = await Promise.all([
|
||||||
|
input.householdConfigurationRepository.getHouseholdChatByHouseholdId(householdId),
|
||||||
|
input.householdConfigurationRepository.getHouseholdBillingSettings(householdId),
|
||||||
|
input.repository.listScheduledDispatchesForHousehold(householdId)
|
||||||
|
])
|
||||||
|
|
||||||
|
const existingByKind = new Map(
|
||||||
|
existingDispatches
|
||||||
|
.filter((dispatch) =>
|
||||||
|
BUILT_IN_DISPATCH_KINDS.includes(
|
||||||
|
dispatch.kind as (typeof BUILT_IN_DISPATCH_KINDS)[number]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.map((dispatch) => [dispatch.kind, dispatch])
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!chat) {
|
||||||
|
for (const dispatch of existingByKind.values()) {
|
||||||
|
if (dispatch.status !== 'scheduled') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dispatch.providerDispatchId) {
|
||||||
|
await input.scheduler.cancelDispatch(dispatch.providerDispatchId)
|
||||||
|
}
|
||||||
|
await input.repository.cancelScheduledDispatch(dispatch.id, asOf)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const kind of BUILT_IN_DISPATCH_KINDS) {
|
||||||
|
const next = nextBuiltInDispatch({
|
||||||
|
kind,
|
||||||
|
timezone: settings.timezone,
|
||||||
|
day: builtInDispatchDay(kind, settings),
|
||||||
|
asOf
|
||||||
|
})
|
||||||
|
|
||||||
|
await ensureBuiltInDispatch({
|
||||||
|
householdId,
|
||||||
|
kind,
|
||||||
|
dueAt: next.dueAt,
|
||||||
|
timezone: settings.timezone,
|
||||||
|
period: next.period,
|
||||||
|
existing: existingByKind.get(kind) ?? null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
async scheduleAdHocNotification(dispatchInput) {
|
||||||
|
const existing = await input.repository.getScheduledDispatchByAdHocNotificationId(
|
||||||
|
dispatchInput.notificationId
|
||||||
|
)
|
||||||
|
if (!existing) {
|
||||||
|
const created = await createDispatchRecord({
|
||||||
|
householdId: dispatchInput.householdId,
|
||||||
|
kind: 'ad_hoc_notification',
|
||||||
|
dueAt: dispatchInput.dueAt,
|
||||||
|
timezone: dispatchInput.timezone,
|
||||||
|
adHocNotificationId: dispatchInput.notificationId
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await activateDispatch(created, dispatchInput.dueAt, dispatchInput.timezone, null)
|
||||||
|
} catch (error) {
|
||||||
|
await input.repository.cancelScheduledDispatch(created.id, nowInstant())
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousProviderDispatchId = existing.providerDispatchId
|
||||||
|
const updated = await activateDispatch(
|
||||||
|
existing,
|
||||||
|
dispatchInput.dueAt,
|
||||||
|
dispatchInput.timezone,
|
||||||
|
null
|
||||||
|
)
|
||||||
|
|
||||||
|
if (previousProviderDispatchId && previousProviderDispatchId !== updated.providerDispatchId) {
|
||||||
|
await input.scheduler.cancelDispatch(previousProviderDispatchId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated
|
||||||
|
},
|
||||||
|
|
||||||
|
async cancelAdHocNotification(notificationId, cancelledAt = nowInstant()) {
|
||||||
|
const existing =
|
||||||
|
await input.repository.getScheduledDispatchByAdHocNotificationId(notificationId)
|
||||||
|
if (!existing || existing.status !== 'scheduled') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.providerDispatchId) {
|
||||||
|
await input.scheduler.cancelDispatch(existing.providerDispatchId)
|
||||||
|
}
|
||||||
|
await input.repository.cancelScheduledDispatch(existing.id, cancelledAt)
|
||||||
|
},
|
||||||
|
|
||||||
|
reconcileHouseholdBuiltInDispatches,
|
||||||
|
|
||||||
|
async reconcileAllBuiltInDispatches(asOf = nowInstant()) {
|
||||||
|
const targets = await input.householdConfigurationRepository.listReminderTargets()
|
||||||
|
const householdIds = [...new Set(targets.map((target) => target.householdId))]
|
||||||
|
|
||||||
|
for (const householdId of householdIds) {
|
||||||
|
await reconcileHouseholdBuiltInDispatches(householdId, asOf)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getDispatchById(dispatchId) {
|
||||||
|
return input.repository.getScheduledDispatchById(dispatchId)
|
||||||
|
},
|
||||||
|
|
||||||
|
async claimDispatch(dispatchId) {
|
||||||
|
const result = await input.repository.claimScheduledDispatchDelivery(dispatchId)
|
||||||
|
return result.claimed
|
||||||
|
},
|
||||||
|
|
||||||
|
releaseDispatch(dispatchId) {
|
||||||
|
return input.repository.releaseScheduledDispatchDelivery(dispatchId)
|
||||||
|
},
|
||||||
|
|
||||||
|
markDispatchSent(dispatchId, sentAt = nowInstant()) {
|
||||||
|
return input.repository.markScheduledDispatchSent(dispatchId, sentAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
"0020_silver_payments.sql": "9686235c75453f1eaa016f2f4ab7fce8fe964c76a4e3515987a2b9f90bd7b1ad",
|
"0020_silver_payments.sql": "9686235c75453f1eaa016f2f4ab7fce8fe964c76a4e3515987a2b9f90bd7b1ad",
|
||||||
"0021_sharp_payer.sql": "973596e154382984ba7769979ea58298b6d93c5139540854be01e8b283ddb4f1",
|
"0021_sharp_payer.sql": "973596e154382984ba7769979ea58298b6d93c5139540854be01e8b283ddb4f1",
|
||||||
"0022_carry_purchase_history.sql": "f031c9736e43e71eec3263a323332c29de9324c6409db034b0760051c8a9f074",
|
"0022_carry_purchase_history.sql": "f031c9736e43e71eec3263a323332c29de9324c6409db034b0760051c8a9f074",
|
||||||
"0023_huge_vision.sql": "9a682e8b62fc6c54711ccd7bb912dd7192e278f546d5853670bea6a0a4585c1c"
|
"0023_huge_vision.sql": "9a682e8b62fc6c54711ccd7bb912dd7192e278f546d5853670bea6a0a4585c1c",
|
||||||
|
"0024_lush_lucky_pierre.sql": "35d111486df774fde5add5cc98f2bf8bcb16d5bae8c4dd4df01fedb661a297d6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
22
packages/db/drizzle/0024_lush_lucky_pierre.sql
Normal file
22
packages/db/drizzle/0024_lush_lucky_pierre.sql
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
CREATE TABLE "scheduled_dispatches" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"household_id" uuid NOT NULL,
|
||||||
|
"kind" text NOT NULL,
|
||||||
|
"due_at" timestamp with time zone NOT NULL,
|
||||||
|
"timezone" text NOT NULL,
|
||||||
|
"status" text DEFAULT 'scheduled' NOT NULL,
|
||||||
|
"provider" text NOT NULL,
|
||||||
|
"provider_dispatch_id" text,
|
||||||
|
"ad_hoc_notification_id" uuid,
|
||||||
|
"period" text,
|
||||||
|
"sent_at" timestamp with time zone,
|
||||||
|
"cancelled_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "scheduled_dispatches" ADD CONSTRAINT "scheduled_dispatches_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "scheduled_dispatches" ADD CONSTRAINT "scheduled_dispatches_ad_hoc_notification_id_ad_hoc_notifications_id_fk" FOREIGN KEY ("ad_hoc_notification_id") REFERENCES "public"."ad_hoc_notifications"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "scheduled_dispatches_due_idx" ON "scheduled_dispatches" USING btree ("status","due_at");--> statement-breakpoint
|
||||||
|
CREATE INDEX "scheduled_dispatches_household_kind_idx" ON "scheduled_dispatches" USING btree ("household_id","kind","status");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "scheduled_dispatches_ad_hoc_notification_unique" ON "scheduled_dispatches" USING btree ("ad_hoc_notification_id");
|
||||||
4047
packages/db/drizzle/meta/0024_snapshot.json
Normal file
4047
packages/db/drizzle/meta/0024_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -169,6 +169,13 @@
|
|||||||
"when": 1774294611532,
|
"when": 1774294611532,
|
||||||
"tag": "0023_huge_vision",
|
"tag": "0023_huge_vision",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 24,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1774367260609,
|
||||||
|
"tag": "0024_lush_lucky_pierre",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -553,6 +553,41 @@ export const adHocNotifications = pgTable(
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const scheduledDispatches = pgTable(
|
||||||
|
'scheduled_dispatches',
|
||||||
|
{
|
||||||
|
id: uuid('id').defaultRandom().primaryKey(),
|
||||||
|
householdId: uuid('household_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => households.id, { onDelete: 'cascade' }),
|
||||||
|
kind: text('kind').notNull(),
|
||||||
|
dueAt: timestamp('due_at', { withTimezone: true }).notNull(),
|
||||||
|
timezone: text('timezone').notNull(),
|
||||||
|
status: text('status').default('scheduled').notNull(),
|
||||||
|
provider: text('provider').notNull(),
|
||||||
|
providerDispatchId: text('provider_dispatch_id'),
|
||||||
|
adHocNotificationId: uuid('ad_hoc_notification_id').references(() => adHocNotifications.id, {
|
||||||
|
onDelete: 'cascade'
|
||||||
|
}),
|
||||||
|
period: text('period'),
|
||||||
|
sentAt: timestamp('sent_at', { withTimezone: true }),
|
||||||
|
cancelledAt: timestamp('cancelled_at', { withTimezone: true }),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
dueIdx: index('scheduled_dispatches_due_idx').on(table.status, table.dueAt),
|
||||||
|
householdKindIdx: index('scheduled_dispatches_household_kind_idx').on(
|
||||||
|
table.householdId,
|
||||||
|
table.kind,
|
||||||
|
table.status
|
||||||
|
),
|
||||||
|
adHocNotificationUnique: uniqueIndex('scheduled_dispatches_ad_hoc_notification_unique').on(
|
||||||
|
table.adHocNotificationId
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
export const topicMessages = pgTable(
|
export const topicMessages = pgTable(
|
||||||
'topic_messages',
|
'topic_messages',
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,11 +1,4 @@
|
|||||||
export {
|
export { REMINDER_TYPES, type ReminderTarget, type ReminderType } from './reminders'
|
||||||
REMINDER_TYPES,
|
|
||||||
type ClaimReminderDispatchInput,
|
|
||||||
type ClaimReminderDispatchResult,
|
|
||||||
type ReminderDispatchRepository,
|
|
||||||
type ReminderTarget,
|
|
||||||
type ReminderType
|
|
||||||
} from './reminders'
|
|
||||||
export {
|
export {
|
||||||
AD_HOC_NOTIFICATION_DELIVERY_MODES,
|
AD_HOC_NOTIFICATION_DELIVERY_MODES,
|
||||||
AD_HOC_NOTIFICATION_STATUSES,
|
AD_HOC_NOTIFICATION_STATUSES,
|
||||||
@@ -20,6 +13,22 @@ export {
|
|||||||
type CreateAdHocNotificationInput,
|
type CreateAdHocNotificationInput,
|
||||||
type UpdateAdHocNotificationInput
|
type UpdateAdHocNotificationInput
|
||||||
} from './notifications'
|
} from './notifications'
|
||||||
|
export {
|
||||||
|
SCHEDULED_DISPATCH_KINDS,
|
||||||
|
SCHEDULED_DISPATCH_PROVIDERS,
|
||||||
|
SCHEDULED_DISPATCH_STATUSES,
|
||||||
|
type ClaimScheduledDispatchDeliveryResult,
|
||||||
|
type CreateScheduledDispatchInput,
|
||||||
|
type ScheduleOneShotDispatchInput,
|
||||||
|
type ScheduleOneShotDispatchResult,
|
||||||
|
type ScheduledDispatchKind,
|
||||||
|
type ScheduledDispatchProvider,
|
||||||
|
type ScheduledDispatchRecord,
|
||||||
|
type ScheduledDispatchRepository,
|
||||||
|
type ScheduledDispatchScheduler,
|
||||||
|
type ScheduledDispatchStatus,
|
||||||
|
type UpdateScheduledDispatchInput
|
||||||
|
} from './scheduled-dispatches'
|
||||||
export type {
|
export type {
|
||||||
ClaimProcessedBotMessageInput,
|
ClaimProcessedBotMessageInput,
|
||||||
ClaimProcessedBotMessageResult,
|
ClaimProcessedBotMessageResult,
|
||||||
|
|||||||
@@ -16,24 +16,3 @@ export interface ReminderTarget {
|
|||||||
utilitiesDueDay: number
|
utilitiesDueDay: number
|
||||||
utilitiesReminderDay: number
|
utilitiesReminderDay: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ClaimReminderDispatchInput {
|
|
||||||
householdId: string
|
|
||||||
period: string
|
|
||||||
reminderType: ReminderType
|
|
||||||
payloadHash: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ClaimReminderDispatchResult {
|
|
||||||
dedupeKey: string
|
|
||||||
claimed: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ReminderDispatchRepository {
|
|
||||||
claimReminderDispatch(input: ClaimReminderDispatchInput): Promise<ClaimReminderDispatchResult>
|
|
||||||
releaseReminderDispatch(input: {
|
|
||||||
householdId: string
|
|
||||||
period: string
|
|
||||||
reminderType: ReminderType
|
|
||||||
}): Promise<void>
|
|
||||||
}
|
|
||||||
|
|||||||
97
packages/ports/src/scheduled-dispatches.ts
Normal file
97
packages/ports/src/scheduled-dispatches.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import type { Instant } from '@household/domain'
|
||||||
|
|
||||||
|
export const SCHEDULED_DISPATCH_KINDS = [
|
||||||
|
'ad_hoc_notification',
|
||||||
|
'utilities',
|
||||||
|
'rent_warning',
|
||||||
|
'rent_due'
|
||||||
|
] as const
|
||||||
|
export const SCHEDULED_DISPATCH_STATUSES = ['scheduled', 'sent', 'cancelled'] as const
|
||||||
|
export const SCHEDULED_DISPATCH_PROVIDERS = ['gcp-cloud-tasks', 'aws-eventbridge'] as const
|
||||||
|
|
||||||
|
export type ScheduledDispatchKind = (typeof SCHEDULED_DISPATCH_KINDS)[number]
|
||||||
|
export type ScheduledDispatchStatus = (typeof SCHEDULED_DISPATCH_STATUSES)[number]
|
||||||
|
export type ScheduledDispatchProvider = (typeof SCHEDULED_DISPATCH_PROVIDERS)[number]
|
||||||
|
|
||||||
|
export interface ScheduledDispatchRecord {
|
||||||
|
id: string
|
||||||
|
householdId: string
|
||||||
|
kind: ScheduledDispatchKind
|
||||||
|
dueAt: Instant
|
||||||
|
timezone: string
|
||||||
|
status: ScheduledDispatchStatus
|
||||||
|
provider: ScheduledDispatchProvider
|
||||||
|
providerDispatchId: string | null
|
||||||
|
adHocNotificationId: string | null
|
||||||
|
period: string | null
|
||||||
|
sentAt: Instant | null
|
||||||
|
cancelledAt: Instant | null
|
||||||
|
createdAt: Instant
|
||||||
|
updatedAt: Instant
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateScheduledDispatchInput {
|
||||||
|
householdId: string
|
||||||
|
kind: ScheduledDispatchKind
|
||||||
|
dueAt: Instant
|
||||||
|
timezone: string
|
||||||
|
provider: ScheduledDispatchProvider
|
||||||
|
providerDispatchId?: string | null
|
||||||
|
adHocNotificationId?: string | null
|
||||||
|
period?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateScheduledDispatchInput {
|
||||||
|
dispatchId: string
|
||||||
|
dueAt?: Instant
|
||||||
|
timezone?: string
|
||||||
|
providerDispatchId?: string | null
|
||||||
|
period?: string | null
|
||||||
|
updatedAt: Instant
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClaimScheduledDispatchDeliveryResult {
|
||||||
|
dispatchId: string
|
||||||
|
claimed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScheduledDispatchRepository {
|
||||||
|
createScheduledDispatch(input: CreateScheduledDispatchInput): Promise<ScheduledDispatchRecord>
|
||||||
|
getScheduledDispatchById(dispatchId: string): Promise<ScheduledDispatchRecord | null>
|
||||||
|
getScheduledDispatchByAdHocNotificationId(
|
||||||
|
notificationId: string
|
||||||
|
): Promise<ScheduledDispatchRecord | null>
|
||||||
|
listScheduledDispatchesForHousehold(
|
||||||
|
householdId: string
|
||||||
|
): Promise<readonly ScheduledDispatchRecord[]>
|
||||||
|
updateScheduledDispatch(
|
||||||
|
input: UpdateScheduledDispatchInput
|
||||||
|
): Promise<ScheduledDispatchRecord | null>
|
||||||
|
cancelScheduledDispatch(
|
||||||
|
dispatchId: string,
|
||||||
|
cancelledAt: Instant
|
||||||
|
): Promise<ScheduledDispatchRecord | null>
|
||||||
|
markScheduledDispatchSent(
|
||||||
|
dispatchId: string,
|
||||||
|
sentAt: Instant
|
||||||
|
): Promise<ScheduledDispatchRecord | null>
|
||||||
|
claimScheduledDispatchDelivery(dispatchId: string): Promise<ClaimScheduledDispatchDeliveryResult>
|
||||||
|
releaseScheduledDispatchDelivery(dispatchId: string): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScheduleOneShotDispatchInput {
|
||||||
|
dispatchId: string
|
||||||
|
dueAt: Instant
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScheduleOneShotDispatchResult {
|
||||||
|
providerDispatchId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScheduledDispatchScheduler {
|
||||||
|
readonly provider: ScheduledDispatchProvider
|
||||||
|
scheduleOneShotDispatch(
|
||||||
|
input: ScheduleOneShotDispatchInput
|
||||||
|
): Promise<ScheduleOneShotDispatchResult>
|
||||||
|
cancelDispatch(providerDispatchId: string): Promise<void>
|
||||||
|
}
|
||||||
@@ -70,7 +70,7 @@ async function run(): Promise<void> {
|
|||||||
)
|
)
|
||||||
|
|
||||||
await expectJson(
|
await expectJson(
|
||||||
toUrl(botApiUrl, '/jobs/reminder/utilities'),
|
toUrl(botApiUrl, '/jobs/dispatch/test-dispatch'),
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
type ReminderType = 'utilities' | 'rent-warning' | 'rent-due'
|
|
||||||
|
|
||||||
function parseReminderType(raw: string | undefined): ReminderType {
|
|
||||||
const value = raw?.trim()
|
|
||||||
|
|
||||||
if (value === 'utilities' || value === 'rent-warning' || value === 'rent-due') {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(
|
|
||||||
'Usage: bun run ops:reminder <utilities|rent-warning|rent-due> [period] [--dry-run]'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseArgs(argv: readonly string[]) {
|
|
||||||
const reminderType = parseReminderType(argv[2])
|
|
||||||
const rawPeriod = argv[3]?.trim()
|
|
||||||
const dryRun = argv.includes('--dry-run')
|
|
||||||
|
|
||||||
return {
|
|
||||||
reminderType,
|
|
||||||
period: rawPeriod && rawPeriod.length > 0 ? rawPeriod : undefined,
|
|
||||||
dryRun
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function readText(command: string[], name: string): string {
|
|
||||||
const result = Bun.spawnSync(command, {
|
|
||||||
stdout: 'pipe',
|
|
||||||
stderr: 'pipe'
|
|
||||||
})
|
|
||||||
|
|
||||||
if (result.exitCode !== 0) {
|
|
||||||
const stderr = result.stderr.toString().trim()
|
|
||||||
throw new Error(`${name} failed: ${stderr || `exit code ${result.exitCode}`}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const value = result.stdout.toString().trim()
|
|
||||||
if (!value) {
|
|
||||||
throw new Error(`${name} returned an empty value`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveBotApiUrl(): string {
|
|
||||||
const envValue = process.env.BOT_API_URL?.trim()
|
|
||||||
if (envValue) {
|
|
||||||
return envValue
|
|
||||||
}
|
|
||||||
|
|
||||||
return readText(
|
|
||||||
['terraform', '-chdir=infra/terraform', 'output', '-raw', 'bot_api_service_url'],
|
|
||||||
'terraform output bot_api_service_url'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveSchedulerSecret(): string {
|
|
||||||
const envValue = process.env.SCHEDULER_SHARED_SECRET?.trim()
|
|
||||||
if (envValue) {
|
|
||||||
return envValue
|
|
||||||
}
|
|
||||||
|
|
||||||
const projectId = process.env.GCP_PROJECT_ID?.trim() || 'gen-lang-client-0200379851'
|
|
||||||
return readText(
|
|
||||||
[
|
|
||||||
'gcloud',
|
|
||||||
'secrets',
|
|
||||||
'versions',
|
|
||||||
'access',
|
|
||||||
'latest',
|
|
||||||
'--secret=scheduler-shared-secret',
|
|
||||||
'--project',
|
|
||||||
projectId
|
|
||||||
],
|
|
||||||
'gcloud secrets versions access'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function run() {
|
|
||||||
const { reminderType, period, dryRun } = parseArgs(process.argv)
|
|
||||||
const botApiUrl = resolveBotApiUrl().replace(/\/$/, '')
|
|
||||||
const schedulerSecret = resolveSchedulerSecret()
|
|
||||||
|
|
||||||
const response = await fetch(`${botApiUrl}/jobs/reminder/${reminderType}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'content-type': 'application/json',
|
|
||||||
'x-household-scheduler-secret': schedulerSecret
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
...(period ? { period } : {}),
|
|
||||||
...(dryRun ? { dryRun: true } : {})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const text = await response.text()
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`${response.status} ${response.statusText}: ${text}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(text)
|
|
||||||
}
|
|
||||||
|
|
||||||
run().catch((error) => {
|
|
||||||
console.error(error instanceof Error ? error.message : String(error))
|
|
||||||
process.exitCode = 1
|
|
||||||
})
|
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
"typecheck": "tsgo --project tsconfig.json --noEmit"
|
"typecheck": "tsgo --project tsconfig.json --noEmit"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@aws-sdk/client-scheduler": "^3.913.0",
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
"@household/config": "workspace:*",
|
"@household/config": "workspace:*",
|
||||||
"@household/db": "workspace:*"
|
"@household/db": "workspace:*"
|
||||||
|
|||||||
Reference in New Issue
Block a user