diff --git a/.env.local.example b/.env.local.example index 602a9728f1..6c0889d8c6 100644 --- a/.env.local.example +++ b/.env.local.example @@ -168,6 +168,7 @@ SECURITY_AGENT_AUDIT_RELIABLE_COVERAGE_START=2026-06-17T00:00:00.000Z SLACK_CLIENT_ID= SLACK_CLIENT_SECRET= SLACK_SIGNING_SECRET= +SLACK_ADMIN_NOTIFICATIONS_WEBHOOK_URL= SLACK_USER_FEEDBACK_WEBHOOK_URL= SLACK_DEPLOY_THREAT_WEBHOOK_URL= # Discord bot diff --git a/ENVIRONMENT.md b/ENVIRONMENT.md index 94e0140c60..ec8d7cf9bf 100644 --- a/ENVIRONMENT.md +++ b/ENVIRONMENT.md @@ -255,6 +255,7 @@ When `VERCEL_TARGET_ENV` is absent in local development or a script process, tra - `SLACK_CLIENT_ID` - Slack OAuth app client ID. [PUBLIC] - `SLACK_CLIENT_SECRET` - Slack OAuth app client secret. `[SECRET]` - `SLACK_SIGNING_SECRET` - Slack request signing secret for webhooks. `[SECRET]` +- `SLACK_ADMIN_NOTIFICATIONS_WEBHOOK_URL` - Slack incoming webhook used by server-side Admin UI code to send events, summaries, reminders, and actions. `[SECRET]` - `SLACK_USER_FEEDBACK_WEBHOOK_URL` - Slack incoming webhook for user feedback. [SERVER] - `SLACK_DEPLOY_THREAT_WEBHOOK_URL` - Slack incoming webhook for deploy threat alerts. [SERVER] diff --git a/apps/web/src/app/api/cron/coding-plans-inventory-summary/route.test.ts b/apps/web/src/app/api/cron/coding-plans-inventory-summary/route.test.ts new file mode 100644 index 0000000000..2dd40644a2 --- /dev/null +++ b/apps/web/src/app/api/cron/coding-plans-inventory-summary/route.test.ts @@ -0,0 +1,68 @@ +jest.mock('@/lib/config.server', () => ({ CRON_SECRET: 'cron-secret' })); +jest.mock('@/lib/coding-plans/inventory-slack-summary', () => ({ + sendCodingPlanInventorySlackSummary: jest.fn(), +})); +jest.mock('@sentry/nextjs', () => ({ captureException: jest.fn() })); + +import { captureException } from '@sentry/nextjs'; +import { sendCodingPlanInventorySlackSummary } from '@/lib/coding-plans/inventory-slack-summary'; +import { GET } from './route'; + +const mockSendSummary = jest.mocked(sendCodingPlanInventorySlackSummary); +const mockCaptureException = jest.mocked(captureException); + +function request(authorization?: string) { + return new Request('http://localhost:3000/api/cron/coding-plans-inventory-summary', { + headers: authorization ? { authorization } : undefined, + }); +} + +describe('GET /api/cron/coding-plans-inventory-summary', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('rejects invalid cron authorization', async () => { + const response = await GET(request('Bearer wrong-secret')); + + expect(response.status).toBe(401); + await expect(response.json()).resolves.toEqual({ error: 'Unauthorized' }); + expect(mockSendSummary).not.toHaveBeenCalled(); + }); + + it('sends the current inventory summary for valid cron authorization', async () => { + mockSendSummary.mockResolvedValue({ + loaded: 263, + assigned: 156, + available: 83, + revocationPending: 5, + revocationFailed: 0, + revoked: 19, + }); + + const response = await GET(request('Bearer cron-secret')); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + success: true, + totals: { loaded: 263, available: 83, revocationPending: 5 }, + }); + expect(mockSendSummary).toHaveBeenCalledTimes(1); + }); + + it('returns a failed cron response when Slack delivery fails', async () => { + const error = new Error('Slack unavailable'); + mockSendSummary.mockRejectedValue(error); + + const response = await GET(request('Bearer cron-secret')); + + expect(response.status).toBe(500); + await expect(response.json()).resolves.toEqual({ + success: false, + error: 'Failed to send Coding Plans inventory summary', + }); + expect(mockCaptureException).toHaveBeenCalledWith(error, { + tags: { endpoint: 'cron/coding-plans-inventory-summary' }, + }); + }); +}); diff --git a/apps/web/src/app/api/cron/coding-plans-inventory-summary/route.ts b/apps/web/src/app/api/cron/coding-plans-inventory-summary/route.ts new file mode 100644 index 0000000000..6e854610a7 --- /dev/null +++ b/apps/web/src/app/api/cron/coding-plans-inventory-summary/route.ts @@ -0,0 +1,39 @@ +import { captureException } from '@sentry/nextjs'; +import { NextResponse } from 'next/server'; + +import { CRON_SECRET } from '@/lib/config.server'; +import { isCronAuthorizationValid } from '@/lib/cron-auth'; +import { sendCodingPlanInventorySlackSummary } from '@/lib/coding-plans/inventory-slack-summary'; + +if (!CRON_SECRET) { + throw new Error('CRON_SECRET is not configured in environment variables'); +} + +export async function GET(request: Request) { + if (!isCronAuthorizationValid(request.headers.get('authorization'), CRON_SECRET)) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + try { + const totals = await sendCodingPlanInventorySlackSummary(); + console.info('[cron/coding-plans-inventory-summary] sent', totals); + + return NextResponse.json({ + success: true, + totals, + timestamp: new Date().toISOString(), + }); + } catch (error) { + console.error('[cron/coding-plans-inventory-summary] failed', { + errorName: error instanceof Error ? error.name : 'UnknownError', + }); + captureException(error, { + tags: { endpoint: 'cron/coding-plans-inventory-summary' }, + }); + + return NextResponse.json( + { success: false, error: 'Failed to send Coding Plans inventory summary' }, + { status: 500 } + ); + } +} diff --git a/apps/web/src/lib/coding-plans/inventory-slack-summary.test.ts b/apps/web/src/lib/coding-plans/inventory-slack-summary.test.ts new file mode 100644 index 0000000000..99941c3583 --- /dev/null +++ b/apps/web/src/lib/coding-plans/inventory-slack-summary.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it, jest } from '@jest/globals'; + +import type { AdminSlackNotification } from '@/lib/slack/admin-notifications'; +import { + buildCodingPlanInventorySlackNotification, + sendCodingPlanInventorySlackSummary, + type CodingPlanInventoryCount, +} from './inventory-slack-summary'; + +const CURRENT_COUNTS: CodingPlanInventoryCount[] = [ + { providerId: 'minimax', planId: 'minimax-token-plan-max', status: 'assigned', count: 7 }, + { providerId: 'minimax', planId: 'minimax-token-plan-max', status: 'available', count: 3 }, + { providerId: 'minimax', planId: 'minimax-token-plan-plus', status: 'assigned', count: 148 }, + { providerId: 'minimax', planId: 'minimax-token-plan-plus', status: 'available', count: 79 }, + { + providerId: 'minimax', + planId: 'minimax-token-plan-plus', + status: 'revocation_pending', + count: 5, + }, + { providerId: 'minimax', planId: 'minimax-token-plan-plus', status: 'revoked', count: 19 }, + { providerId: 'minimax', planId: 'minimax-token-plan-ultra', status: 'assigned', count: 1 }, + { providerId: 'minimax', planId: 'minimax-token-plan-ultra', status: 'available', count: 1 }, +]; + +function blockText(notification: ReturnType) { + return JSON.stringify(notification.notification.blocks); +} + +describe('buildCodingPlanInventorySlackNotification', () => { + it('formats the current inventory into a compact Slack summary', () => { + const result = buildCodingPlanInventorySlackNotification( + CURRENT_COUNTS, + new Date('2026-07-30T12:00:00.000Z') + ); + + expect(result.totals).toEqual({ + loaded: 263, + assigned: 156, + available: 83, + revocationPending: 5, + revocationFailed: 0, + revoked: 19, + }); + expect(result.notification.text).toBe( + 'Coding Plans inventory: 83 available, 156 assigned, 263 loaded. 5 pending revocation. Token Plan Plus: 79 available. Token Plan Max: 3 available. Token Plan Ultra: 1 available.' + ); + + const rendered = blockText(result); + expect(rendered).toContain('MiniMax · Current snapshot'); + expect(rendered).toContain('Token Plan Plus'); + expect(rendered).toContain('79 available · 148 assigned · 251 loaded'); + expect(rendered).toContain('5 pending revocation · 19 revoked'); + expect(rendered).toContain('Snapshot: 2026-07-30 12:00 UTC'); + expect(rendered).toContain('/admin/coding-plans|Open Coding Plans'); + expect(rendered).not.toContain('open_coding_plans_inventory'); + }); + + it('prioritizes failed revocations and preserves unknown statuses', () => { + const result = buildCodingPlanInventorySlackNotification([ + ...CURRENT_COUNTS, + { + providerId: 'minimax', + planId: 'minimax-token-plan-plus', + status: 'revocation_failed', + count: 2, + }, + { providerId: 'other', planId: 'future-plan', status: 'quarantined', count: 3 }, + ]); + + expect(result.totals.revocationFailed).toBe(2); + expect(result.totals.loaded).toBe(268); + const rendered = blockText(result); + expect(rendered).toContain('2 failed revocation'); + expect(rendered).toContain('3 quarantined'); + expect(rendered).toContain('Action required'); + }); + + it('renders an explicit empty-inventory state', () => { + const result = buildCodingPlanInventorySlackNotification([]); + + expect(result.totals.loaded).toBe(0); + expect(result.notification.text).toContain('No inventory recorded'); + expect(blockText(result)).toContain('No Coding Plans inventory is currently recorded.'); + }); +}); + +describe('sendCodingPlanInventorySlackSummary', () => { + it('queries current counts and sends the generated notification', async () => { + const getCounts = jest.fn(async () => CURRENT_COUNTS); + const sendNotification = jest.fn(async (_notification: AdminSlackNotification) => undefined); + + await expect( + sendCodingPlanInventorySlackSummary({ getCounts, sendNotification }) + ).resolves.toMatchObject({ + available: 83, + loaded: 263, + }); + + expect(getCounts).toHaveBeenCalledWith(); + expect(sendNotification).toHaveBeenCalledWith( + expect.objectContaining({ text: expect.stringContaining('83 available') }) + ); + }); +}); diff --git a/apps/web/src/lib/coding-plans/inventory-slack-summary.ts b/apps/web/src/lib/coding-plans/inventory-slack-summary.ts new file mode 100644 index 0000000000..37baf56799 --- /dev/null +++ b/apps/web/src/lib/coding-plans/inventory-slack-summary.ts @@ -0,0 +1,262 @@ +import 'server-only'; + +import { APP_URL } from '@/lib/constants'; +import { getKeyInventoryCounts } from '@/lib/coding-plans'; +import { getCodingPlanCatalog } from '@/lib/coding-plans/pricing'; +import { + sendAdminSlackNotification, + type AdminSlackNotification, +} from '@/lib/slack/admin-notifications'; + +export type CodingPlanInventoryCount = { + providerId: string; + planId: string; + status: string; + count: number; +}; + +type InventoryPlanSummary = { + providerId: string; + providerName: string; + planId: string; + displayName: string; + loaded: number; + statusCounts: Record; +}; + +export type CodingPlanInventoryTotals = { + loaded: number; + assigned: number; + available: number; + revocationPending: number; + revocationFailed: number; + revoked: number; +}; + +const KNOWN_STATUSES = new Set([ + 'assigned', + 'available', + 'revocation_pending', + 'revocation_failed', + 'revoked', +]); + +function statusCount(summary: InventoryPlanSummary, status: string): number { + return summary.statusCounts[status] ?? 0; +} + +function escapeSlackText(value: string): string { + return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>'); +} + +function humanizeStatus(status: string): string { + return status.replaceAll('_', ' '); +} + +function summarizeInventory(counts: CodingPlanInventoryCount[]): InventoryPlanSummary[] { + const catalog = getCodingPlanCatalog(); + const catalogByPlanId = new Map( + catalog.map(plan => [plan.planId, plan]) + ); + const catalogOrder = new Map(catalog.map((plan, index) => [plan.planId, index])); + const summaries = new Map(); + + for (const item of counts) { + const key = `${item.providerId}\u0000${item.planId}`; + const existing = summaries.get(key); + const catalogPlan = catalogByPlanId.get(item.planId); + const summary = existing ?? { + providerId: item.providerId, + providerName: catalogPlan?.providerName ?? item.providerId, + planId: item.planId, + displayName: catalogPlan?.name ?? item.planId, + loaded: 0, + statusCounts: {}, + }; + + summary.loaded += item.count; + summary.statusCounts[item.status] = (summary.statusCounts[item.status] ?? 0) + item.count; + summaries.set(key, summary); + } + + return Array.from(summaries.values()).sort((left, right) => { + const leftOrder = catalogOrder.get(left.planId) ?? Number.MAX_SAFE_INTEGER; + const rightOrder = catalogOrder.get(right.planId) ?? Number.MAX_SAFE_INTEGER; + return ( + leftOrder - rightOrder || + left.providerId.localeCompare(right.providerId) || + left.planId.localeCompare(right.planId) + ); + }); +} + +function inventoryTotals(summaries: InventoryPlanSummary[]): CodingPlanInventoryTotals { + return summaries.reduce( + (totals, summary) => ({ + loaded: totals.loaded + summary.loaded, + assigned: totals.assigned + statusCount(summary, 'assigned'), + available: totals.available + statusCount(summary, 'available'), + revocationPending: totals.revocationPending + statusCount(summary, 'revocation_pending'), + revocationFailed: totals.revocationFailed + statusCount(summary, 'revocation_failed'), + revoked: totals.revoked + statusCount(summary, 'revoked'), + }), + { + loaded: 0, + assigned: 0, + available: 0, + revocationPending: 0, + revocationFailed: 0, + revoked: 0, + } + ); +} + +function formatPlanSummary(summary: InventoryPlanSummary): string { + const available = statusCount(summary, 'available'); + const assigned = statusCount(summary, 'assigned'); + const pending = statusCount(summary, 'revocation_pending'); + const failed = statusCount(summary, 'revocation_failed'); + const revoked = statusCount(summary, 'revoked'); + const isCatalogPlan = getCodingPlanCatalog().some(plan => plan.planId === summary.planId); + const displayName = isCatalogPlan + ? escapeSlackText(summary.displayName) + : `\`${escapeSlackText(summary.displayName.replaceAll('`', "'"))}\``; + const lines = [ + `*${displayName}*`, + `${available} available · ${assigned} assigned · ${summary.loaded} loaded`, + ]; + + const lifecycleParts = [ + failed > 0 ? `${failed} failed revocation` : null, + pending > 0 ? `${pending} pending revocation` : null, + revoked > 0 ? `${revoked} revoked` : null, + ].filter((value): value is string => value !== null); + if (lifecycleParts.length > 0) { + lines.push(`${failed > 0 || pending > 0 ? ':warning: ' : ''}${lifecycleParts.join(' · ')}`); + } + + const unknownStatuses = Object.entries(summary.statusCounts) + .filter(([status]) => !KNOWN_STATUSES.has(status)) + .sort(([left], [right]) => left.localeCompare(right)); + if (unknownStatuses.length > 0) { + lines.push( + unknownStatuses + .map(([status, count]) => `${count} ${escapeSlackText(humanizeStatus(status))}`) + .join(' · ') + ); + } + + return lines.join('\n'); +} + +function formatSnapshotTime(timestamp: Date): string { + return timestamp + .toISOString() + .replace('T', ' ') + .replace(/:\d{2}\.\d{3}Z$/, ' UTC'); +} + +export function buildCodingPlanInventorySlackNotification( + counts: CodingPlanInventoryCount[], + timestamp = new Date() +): { notification: AdminSlackNotification; totals: CodingPlanInventoryTotals } { + const summaries = summarizeInventory(counts); + const totals = inventoryTotals(summaries); + const needsAttention = totals.revocationFailed + totals.revocationPending; + const providerNames = Array.from(new Set(summaries.map(summary => summary.providerName))); + const providerLabel = providerNames.length > 0 ? providerNames.join(', ') : 'All providers'; + const planAvailability = summaries + .map(summary => `${summary.displayName}: ${statusCount(summary, 'available')} available`) + .join('. '); + const attentionFallback = [ + totals.revocationFailed > 0 ? `${totals.revocationFailed} failed revocation` : null, + totals.revocationPending > 0 ? `${totals.revocationPending} pending revocation` : null, + ] + .filter((value): value is string => value !== null) + .join(', '); + const text = [ + `Coding Plans inventory: ${totals.available} available, ${totals.assigned} assigned, ${totals.loaded} loaded`, + attentionFallback || null, + planAvailability || 'No inventory recorded', + ] + .filter((value): value is string => value !== null) + .join('. ') + .concat('.'); + + const blocks: NonNullable = [ + { + type: 'header', + text: { type: 'plain_text', text: 'Coding Plans inventory', emoji: true }, + }, + { + type: 'context', + elements: [{ type: 'mrkdwn', text: `${escapeSlackText(providerLabel)} · Current snapshot` }], + }, + { + type: 'section', + fields: [ + { type: 'mrkdwn', text: `*${totals.available}*\nAvailable` }, + { type: 'mrkdwn', text: `*${totals.assigned}*\nAssigned` }, + { type: 'mrkdwn', text: `*${totals.loaded}*\nLoaded` }, + { type: 'mrkdwn', text: `*${needsAttention}*\nNeeds attention` }, + ], + }, + { type: 'divider' }, + ]; + + if (summaries.length === 0) { + blocks.push({ + type: 'section', + text: { type: 'mrkdwn', text: 'No Coding Plans inventory is currently recorded.' }, + }); + } else { + for (const summary of summaries) { + blocks.push({ + type: 'section', + text: { type: 'mrkdwn', text: formatPlanSummary(summary) }, + }); + } + } + + if (needsAttention > 0) { + const attentionLines = [ + totals.revocationFailed > 0 + ? `:rotating_light: *Action required:* ${totals.revocationFailed} credential${totals.revocationFailed === 1 ? '' : 's'} failed revocation.` + : null, + totals.revocationPending > 0 + ? `:warning: *${totals.revocationPending} credential${totals.revocationPending === 1 ? ' is' : 's are'} pending revocation.*` + : null, + ].filter((value): value is string => value !== null); + blocks.push({ type: 'section', text: { type: 'mrkdwn', text: attentionLines.join('\n') } }); + } + + blocks.push({ + type: 'context', + elements: [ + { + type: 'mrkdwn', + text: `Snapshot: ${formatSnapshotTime(timestamp)} · <${APP_URL}/admin/coding-plans|Open Coding Plans>`, + }, + ], + }); + + return { + totals, + notification: { text, blocks, unfurl_links: false, unfurl_media: false }, + }; +} + +type InventorySummaryDependencies = { + getCounts?: typeof getKeyInventoryCounts; + sendNotification?: typeof sendAdminSlackNotification; +}; + +export async function sendCodingPlanInventorySlackSummary({ + getCounts = getKeyInventoryCounts, + sendNotification = sendAdminSlackNotification, +}: InventorySummaryDependencies = {}): Promise { + const counts = await getCounts(); + const { notification, totals } = buildCodingPlanInventorySlackNotification(counts); + await sendNotification(notification); + return totals; +} diff --git a/apps/web/src/lib/config.server.ts b/apps/web/src/lib/config.server.ts index d365953b90..8388ad80b8 100644 --- a/apps/web/src/lib/config.server.ts +++ b/apps/web/src/lib/config.server.ts @@ -176,6 +176,11 @@ export const APP_BUILDER_DB_PROXY_AUTH_TOKEN = getEnvVariable('APP_BUILDER_DB_PR export const SLACK_CLIENT_ID = getEnvVariable('SLACK_CLIENT_ID'); export const SLACK_CLIENT_SECRET = getEnvVariable('SLACK_CLIENT_SECRET'); export const SLACK_SIGNING_SECRET = getEnvVariable('SLACK_SIGNING_SECRET'); +// Posts notifications from server-side Admin UI code to a fixed Slack channel. +// Expected to be a Slack Incoming Webhook URL. Keep this server-only. +export const SLACK_ADMIN_NOTIFICATIONS_WEBHOOK_URL = getEnvVariable( + 'SLACK_ADMIN_NOTIFICATIONS_WEBHOOK_URL' +); // Linear (bot integration) // @chat-adapter/linear 4.27 does not (yet) support encryption-at-rest via diff --git a/apps/web/src/lib/slack/admin-notifications.test.ts b/apps/web/src/lib/slack/admin-notifications.test.ts new file mode 100644 index 0000000000..f93c4a791e --- /dev/null +++ b/apps/web/src/lib/slack/admin-notifications.test.ts @@ -0,0 +1,84 @@ +import { afterEach, describe, expect, it, jest } from '@jest/globals'; + +const WEBHOOK_URL = 'https://hooks.slack.com/services/test/webhook/url'; + +async function loadModule(webhookUrl: string | undefined) { + jest.resetModules(); + jest.doMock('@/lib/config.server', () => ({ + SLACK_ADMIN_NOTIFICATIONS_WEBHOOK_URL: webhookUrl, + })); + return import('./admin-notifications'); +} + +afterEach(() => { + jest.restoreAllMocks(); + jest.dontMock('@/lib/config.server'); +}); + +describe('sendAdminSlackNotification', () => { + it('posts text and Block Kit content to the configured webhook', async () => { + const fetchSpy = jest + .spyOn(global, 'fetch') + .mockResolvedValue(new Response('ok', { status: 200 })); + const { sendAdminSlackNotification } = await loadModule(WEBHOOK_URL); + const notification = { + text: 'Daily admin summary', + blocks: [ + { + type: 'section' as const, + text: { type: 'mrkdwn' as const, text: '*Daily admin summary*' }, + }, + ], + unfurl_links: false, + }; + + await sendAdminSlackNotification(notification); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy).toHaveBeenCalledWith( + WEBHOOK_URL, + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json; charset=utf-8' }, + body: JSON.stringify(notification), + }) + ); + }); + + it('logs a warning and skips delivery when the webhook is not configured', async () => { + const fetchSpy = jest.spyOn(global, 'fetch'); + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + const { sendAdminSlackNotification } = await loadModule(undefined); + + await expect(sendAdminSlackNotification({ text: 'Test' })).resolves.toBeUndefined(); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith( + '[AdminSlackNotifications] SLACK_ADMIN_NOTIFICATIONS_WEBHOOK_URL is not configured; notification skipped' + ); + }); + + it('throws an upstream error when Slack rejects the request', async () => { + jest.spyOn(global, 'fetch').mockResolvedValue(new Response('invalid_payload', { status: 400 })); + const { sendAdminSlackNotification } = await loadModule(WEBHOOK_URL); + + await expect(sendAdminSlackNotification({ text: 'Test' })).rejects.toMatchObject({ + kind: 'upstream', + status: 400, + }); + }); + + it('maps fetch failures to an error that does not expose the webhook URL', async () => { + jest.spyOn(global, 'fetch').mockRejectedValue(new Error(`Could not reach ${WEBHOOK_URL}`)); + const { sendAdminSlackNotification } = await loadModule(WEBHOOK_URL); + + let error: unknown; + try { + await sendAdminSlackNotification({ text: 'Test' }); + } catch (caught) { + error = caught; + } + + expect(error).toMatchObject({ kind: 'network' }); + expect(String(error)).not.toContain(WEBHOOK_URL); + }); +}); diff --git a/apps/web/src/lib/slack/admin-notifications.ts b/apps/web/src/lib/slack/admin-notifications.ts new file mode 100644 index 0000000000..678103e1f9 --- /dev/null +++ b/apps/web/src/lib/slack/admin-notifications.ts @@ -0,0 +1,72 @@ +import 'server-only'; + +import { SLACK_ADMIN_NOTIFICATIONS_WEBHOOK_URL } from '@/lib/config.server'; +import type { AnyBlock, MessageAttachment } from '@slack/types'; + +const SLACK_WEBHOOK_TIMEOUT_MS = 10_000; + +/** + * Payload accepted by the Admin Slack incoming webhook. `text` is required as + * the notification fallback for clients and assistive technology that do not + * render Block Kit content. + */ +export type AdminSlackNotification = { + text: string; + blocks?: AnyBlock[]; + attachments?: MessageAttachment[]; + unfurl_links?: boolean; + unfurl_media?: boolean; +}; + +export class AdminSlackNotificationError extends Error { + constructor( + readonly kind: 'network' | 'upstream', + readonly status?: number + ) { + super('Admin Slack notification request failed'); + this.name = 'AdminSlackNotificationError'; + } +} + +/** + * Sends a notification to the Slack channel configured for Admin UI events. + * + * This function is server-only. Call it from an admin tRPC procedure, route + * handler, or server action; never send the webhook URL to a browser. + */ +export async function sendAdminSlackNotification( + notification: AdminSlackNotification +): Promise { + if (!SLACK_ADMIN_NOTIFICATIONS_WEBHOOK_URL) { + console.warn( + '[AdminSlackNotifications] SLACK_ADMIN_NOTIFICATIONS_WEBHOOK_URL is not configured; notification skipped' + ); + return; + } + + let response: Response; + + try { + response = await fetch(SLACK_ADMIN_NOTIFICATIONS_WEBHOOK_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json; charset=utf-8' }, + body: JSON.stringify(notification), + signal: AbortSignal.timeout(SLACK_WEBHOOK_TIMEOUT_MS), + }); + } catch { + // Do not retain the original error: fetch errors can contain the secret URL. + throw new AdminSlackNotificationError('network'); + } + + try { + // Slack normally responds with a short plain-text "ok" body. Consuming it + // allows the underlying connection to be reused. + await response.text(); + } catch { + throw new AdminSlackNotificationError('network'); + } + + if (!response.ok) { + throw new AdminSlackNotificationError('upstream', response.status); + } +} diff --git a/apps/web/vercel.json b/apps/web/vercel.json index 572f322d70..b648c3144a 100644 --- a/apps/web/vercel.json +++ b/apps/web/vercel.json @@ -48,6 +48,10 @@ "path": "/api/cron/coding-plans-billing", "schedule": "0 * * * *" }, + { + "path": "/api/cron/coding-plans-inventory-summary", + "schedule": "0 12 * * *" + }, { "path": "/api/cron/cost-insights-hourly", "schedule": "5 * * * *"