| import { |
| createSlackWebClient, |
| ensureBotInChannel, |
| sendSlackInvoiceOverdueNotification, |
| sendSlackInvoicePaidNotification, |
| sendSlackMatchNotification, |
| } from "@midday/app-store/slack/server"; |
| import { sendMatchNotification } from "@midday/app-store/whatsapp/server"; |
| import type { Database } from "@midday/db/client"; |
| import { |
| getAppByAppId, |
| getApps, |
| getWhatsAppConnections, |
| } from "@midday/db/queries"; |
| import { createLoggerWithContext } from "@midday/logger"; |
|
|
| const logger = createLoggerWithContext("provider-notifications"); |
|
|
| |
| export type ProviderNotificationType = |
| | "transaction" |
| | "match" |
| | "invoice_paid" |
| | "invoice_overdue"; |
|
|
| |
| export type TransactionPayload = { |
| transactions: Array<{ |
| id: string; |
| name: string; |
| amount: number; |
| currency: string; |
| date: string; |
| }>; |
| }; |
|
|
| export type MatchPayload = { |
| inboxId: string; |
| transactionId: string; |
| documentName: string; |
| documentAmount: number; |
| documentCurrency: string; |
| documentDate?: string; |
| transactionName: string; |
| transactionAmount: number; |
| transactionCurrency: string; |
| transactionDate?: string; |
| confidenceScore: number; |
| matchType: "auto_matched" | "high_confidence" | "suggested"; |
| }; |
|
|
| export type InvoicePaidPayload = { |
| invoiceId: string; |
| invoiceNumber: string; |
| customerName?: string; |
| paidAt?: string; |
| }; |
|
|
| export type InvoiceOverduePayload = { |
| invoiceId: string; |
| invoiceNumber: string; |
| customerName: string; |
| }; |
|
|
| type ProviderPayload = { |
| transaction: TransactionPayload; |
| match: MatchPayload; |
| invoice_paid: InvoicePaidPayload; |
| invoice_overdue: InvoiceOverduePayload; |
| }; |
|
|
| |
| type AppConfig = { |
| id: string; |
| appId: string; |
| teamId: string; |
| config?: { |
| access_token?: string; |
| channel_id?: string; |
| [key: string]: unknown; |
| }; |
| settings?: Array<{ id: string; value: boolean | string | number }>; |
| }; |
|
|
| |
| export type SendToProvidersOptions = { |
| inboxMeta?: { |
| source?: string; |
| sourceMetadata?: { |
| channelId?: string; |
| threadTs?: string; |
| messageTs?: string; |
| phoneNumber?: string; |
| }; |
| }; |
| }; |
|
|
| |
| |
| |
| const SETTING_MAP: Record<ProviderNotificationType, string> = { |
| transaction: "transactions", |
| match: "matches", |
| invoice_paid: "invoices", |
| invoice_overdue: "invoices", |
| }; |
|
|
| |
| |
| |
| function isSettingEnabled( |
| app: AppConfig, |
| type: ProviderNotificationType, |
| ): boolean { |
| const settings = app.settings || []; |
| const settingId = SETTING_MAP[type]; |
| const setting = settings.find((s) => s.id === settingId); |
| |
| return setting?.value !== false; |
| } |
|
|
| |
| |
| |
| export async function sendToProviders<T extends ProviderNotificationType>( |
| db: Database, |
| teamId: string, |
| type: T, |
| payload: ProviderPayload[T], |
| options?: SendToProvidersOptions, |
| ): Promise<void> { |
| try { |
| const apps = await getApps(db, teamId); |
|
|
| if (apps.length === 0) { |
| logger.debug("No apps installed for team", { teamId }); |
| return; |
| } |
|
|
| |
| for (const app of apps) { |
| const appConfig = await getAppConfig(db, app.app_id, teamId); |
| if (!appConfig) continue; |
|
|
| if (!isSettingEnabled(appConfig, type)) { |
| logger.debug("Notification type disabled for app", { |
| appId: app.app_id, |
| type, |
| teamId, |
| }); |
| continue; |
| } |
|
|
| try { |
| |
| switch (app.app_id) { |
| case "slack": |
| await sendSlackNotification(appConfig, type, payload, options); |
| break; |
| case "whatsapp": |
| await sendWhatsAppNotification( |
| db, |
| appConfig, |
| type, |
| payload, |
| options, |
| ); |
| break; |
| |
| |
| |
| |
| } |
| } catch (error) { |
| logger.warn("Failed to send provider notification", { |
| appId: app.app_id, |
| type, |
| teamId, |
| error: error instanceof Error ? error.message : "Unknown error", |
| }); |
| } |
| } |
| } catch (error) { |
| logger.error("Failed to process provider notifications", { |
| type, |
| teamId, |
| error: error instanceof Error ? error.message : "Unknown error", |
| }); |
| } |
| } |
|
|
| |
| |
| |
| async function getAppConfig( |
| db: Database, |
| appId: string, |
| teamId: string, |
| ): Promise<AppConfig | null> { |
| const app = await getAppByAppId(db, { appId, teamId }); |
| if (!app) return null; |
|
|
| return { |
| id: app.id, |
| appId: app.appId, |
| teamId: app.teamId ?? teamId, |
| config: app.config as AppConfig["config"], |
| settings: app.settings as AppConfig["settings"], |
| }; |
| } |
|
|
| |
| |
| |
| async function sendSlackNotification<T extends ProviderNotificationType>( |
| app: AppConfig, |
| type: T, |
| payload: ProviderPayload[T], |
| options?: SendToProvidersOptions, |
| ): Promise<void> { |
| const accessToken = app.config?.access_token; |
| if (!accessToken) { |
| logger.debug("Slack access token not found", { teamId: app.teamId }); |
| return; |
| } |
|
|
| const channelId = app.config?.channel_id; |
|
|
| switch (type) { |
| case "transaction": { |
| if (!channelId) { |
| logger.warn("Slack channel ID not found for transaction notification", { |
| teamId: app.teamId, |
| }); |
| return; |
| } |
|
|
| const txPayload = payload as TransactionPayload; |
| const slackTransactions = txPayload.transactions.map((tx) => ({ |
| amount: new Intl.NumberFormat("en-US", { |
| style: "currency", |
| currency: tx.currency, |
| }).format(tx.amount), |
| name: tx.name, |
| })); |
|
|
| const client = createSlackWebClient({ token: accessToken }); |
| await ensureBotInChannel({ client, channelId }); |
|
|
| await client.chat.postMessage({ |
| channel: channelId, |
| text: `You got ${txPayload.transactions.length} new transaction${txPayload.transactions.length === 1 ? "" : "s"}`, |
| blocks: [ |
| { |
| type: "section", |
| text: { |
| type: "mrkdwn", |
| text: "You got some new transactions! We'll do our best to match these with receipts in your Inbox or you can simply upload them in your <slack://app?id=A07PN48FW3A|Midday Assistant>.", |
| }, |
| }, |
| { type: "divider" }, |
| ...slackTransactions.map((transaction) => ({ |
| type: "section" as const, |
| fields: [ |
| { type: "mrkdwn" as const, text: transaction.name }, |
| { type: "mrkdwn" as const, text: transaction.amount }, |
| ], |
| })), |
| { type: "divider" }, |
| { |
| type: "actions", |
| elements: [ |
| { |
| type: "button", |
| text: { type: "plain_text", text: "View transactions" }, |
| url: "https://app.midday.ai/transactions", |
| action_id: "button_click", |
| }, |
| ], |
| }, |
| ], |
| }); |
|
|
| logger.info("Slack transaction notification sent", { |
| teamId: app.teamId, |
| transactionCount: txPayload.transactions.length, |
| }); |
| break; |
| } |
|
|
| case "match": { |
| const matchPayload = payload as MatchPayload; |
|
|
| |
| const matchChannelId = |
| options?.inboxMeta?.sourceMetadata?.channelId || channelId; |
|
|
| if (!matchChannelId) { |
| logger.warn("Slack channel ID not found for match notification", { |
| teamId: app.teamId, |
| inboxId: matchPayload.inboxId, |
| }); |
| return; |
| } |
|
|
| |
| |
| const slackThreadTs = |
| options?.inboxMeta?.sourceMetadata?.threadTs || |
| options?.inboxMeta?.sourceMetadata?.messageTs; |
|
|
| await sendSlackMatchNotification({ |
| teamId: app.teamId, |
| inboxId: matchPayload.inboxId, |
| transactionId: matchPayload.transactionId, |
| documentName: matchPayload.documentName, |
| documentAmount: matchPayload.documentAmount, |
| documentCurrency: matchPayload.documentCurrency, |
| documentDate: matchPayload.documentDate, |
| transactionName: matchPayload.transactionName, |
| transactionAmount: matchPayload.transactionAmount, |
| transactionCurrency: matchPayload.transactionCurrency, |
| transactionDate: matchPayload.transactionDate, |
| matchType: matchPayload.matchType, |
| slackChannelId: matchChannelId, |
| slackThreadTs, |
| }); |
| break; |
| } |
|
|
| case "invoice_paid": { |
| if (!channelId) { |
| logger.warn( |
| "Slack channel ID not found for invoice paid notification", |
| { |
| teamId: app.teamId, |
| }, |
| ); |
| return; |
| } |
|
|
| const invoicePaidPayload = payload as InvoicePaidPayload; |
| await sendSlackInvoicePaidNotification(app, { |
| invoiceId: invoicePaidPayload.invoiceId, |
| invoiceNumber: invoicePaidPayload.invoiceNumber, |
| customerName: invoicePaidPayload.customerName, |
| paidAt: invoicePaidPayload.paidAt, |
| }); |
| break; |
| } |
|
|
| case "invoice_overdue": { |
| if (!channelId) { |
| logger.warn( |
| "Slack channel ID not found for invoice overdue notification", |
| { teamId: app.teamId }, |
| ); |
| return; |
| } |
|
|
| const invoiceOverduePayload = payload as InvoiceOverduePayload; |
| await sendSlackInvoiceOverdueNotification(app, { |
| invoiceId: invoiceOverduePayload.invoiceId, |
| invoiceNumber: invoiceOverduePayload.invoiceNumber, |
| customerName: invoiceOverduePayload.customerName, |
| }); |
| break; |
| } |
| } |
| } |
|
|
| |
| |
| |
| async function sendWhatsAppNotification<T extends ProviderNotificationType>( |
| db: Database, |
| app: AppConfig, |
| type: T, |
| payload: ProviderPayload[T], |
| options?: SendToProvidersOptions, |
| ): Promise<void> { |
| |
| if (type !== "match") { |
| logger.debug("WhatsApp notification type not supported", { |
| type, |
| teamId: app.teamId, |
| }); |
| return; |
| } |
|
|
| const matchPayload = payload as MatchPayload; |
|
|
| |
| const connections = await getWhatsAppConnections(db, app.teamId); |
|
|
| if (connections.length === 0) { |
| logger.debug("No WhatsApp connections found for team", { |
| teamId: app.teamId, |
| }); |
| return; |
| } |
|
|
| |
| const sourcePhoneNumber = options?.inboxMeta?.sourceMetadata?.phoneNumber; |
|
|
| |
| const connection = sourcePhoneNumber |
| ? connections.find((c) => c.phoneNumber === sourcePhoneNumber) || |
| connections[0] |
| : connections[0]; |
|
|
| if (!connection) { |
| logger.debug("No matching WhatsApp connection found", { |
| teamId: app.teamId, |
| sourcePhoneNumber, |
| }); |
| return; |
| } |
|
|
| try { |
| |
| if (matchPayload.matchType === "auto_matched") { |
| logger.debug("Skipping WhatsApp notification for auto-matched", { |
| teamId: app.teamId, |
| inboxId: matchPayload.inboxId, |
| }); |
| return; |
| } |
|
|
| await sendMatchNotification({ |
| phoneNumber: connection.phoneNumber, |
| inboxId: matchPayload.inboxId, |
| transactionId: matchPayload.transactionId, |
| inboxName: matchPayload.documentName, |
| transactionName: matchPayload.transactionName, |
| amount: matchPayload.documentAmount, |
| currency: matchPayload.documentCurrency, |
| confidence: matchPayload.confidenceScore, |
| transactionDate: matchPayload.transactionDate, |
| }); |
|
|
| logger.info("WhatsApp match notification sent", { |
| teamId: app.teamId, |
| phoneNumber: connection.phoneNumber, |
| inboxId: matchPayload.inboxId, |
| transactionId: matchPayload.transactionId, |
| }); |
| } catch (error) { |
| logger.warn("Failed to send WhatsApp notification", { |
| teamId: app.teamId, |
| phoneNumber: connection.phoneNumber, |
| error: error instanceof Error ? error.message : "Unknown error", |
| }); |
| } |
| } |
|
|