diff --git a/specs/030-claude-spend-teams-alerts/how-to.html b/specs/030-claude-spend-teams-alerts/how-to.html new file mode 100644 index 0000000..554c758 --- /dev/null +++ b/specs/030-claude-spend-teams-alerts/how-to.html @@ -0,0 +1,915 @@ + + + + + +How-to · Enable Claude Spend Alerts in Microsoft Teams + + + + +
+ + + +
+ +
+
Spec 030 · How-to
+

Enable Claude spend alerts in Microsoft Teams

+

+ Step-by-step for the operator who needs to turn this on in production, verify it's working, and respond to + the cards that show up in the channel. Read top to bottom for first-time setup; jump to + troubleshooting when something looks off. +

+
+
Audience
Operator / Admin
+
Time to enable
~15 minutes
+
Default state
Disabled
+
Backout
Unset 1 env var
+
+
+ + +

What this gives you

+ +

+ Once enabled, every successful hourly Anthropic cost sync posts up to three kinds of Adaptive Cards into one + Microsoft Teams channel: +

+ +
+
+ Hourly +

Spend digest

+ Posted on every successful sync (unless data is stale). MTD spend, # of workspaces over 80%, projected + month-end, and the top-5 workspaces by utilization. +
+
+ Edge-triggered +

Threshold breach

+ Fires the first time any workspace crosses 80%, 100%, or 120% of its monthly cap in the current billing + month. Each threshold fires at most once per workspace per month — no spam if a workspace stays elevated. +
+
+ Edge-triggered +

Forecast at-risk

+ Fires when a workspace flips from on-track to projected-to-overshoot based on its 7-day trailing run rate. +
+
+ +

+ A fourth card — stale-data warning — replaces the digest when the cost sync hasn't completed + in over 70 minutes, so silent ingestion outages don't look like "everything's fine." +

+ +

+ See the mockup for the exact visual layout of each card type. +

+ + +

Preflight

+ +

Before you start, confirm:

+ + + +
+ Why all of those? + The webhook URL is provisioned per-channel via Teams Workflows, the env var goes on Vercel, and you need an + operational sync for cards to have meaningful content. +
+ + +

Provision the Teams webhook

+ +

+ Microsoft retired the legacy O365 Connector incoming webhooks in May 2026. The replacement is the + Workflows app (Power Automate template). The URL you provision below is an HMAC-signed Logic + Apps trigger — treat it like a secret. +

+ +
    +
  1. + Open the channel +

    In Microsoft Teams, navigate to the team and channel that should receive the alerts (e.g., + "Unic · FinOps for AI > Claude Spend Alerts").

    +
  2. +
  3. + Open Workflows for the channel +

    Click the menu next to the channel name → Workflows. (If you don't see + Workflows, search the app store and add it to the team.)

    +
  4. +
  5. + Pick the template +

    Search for "Post to a channel when a webhook request is received" (also listed as + "Send webhook alerts to a channel"). Select it.

    +
  6. +
  7. + Confirm the workflow owner +

    The dialog asks who the workflow runs as. Pick a generic service account if your team has one — if not, + a personal account is fine, but be aware: when that person leaves, the workflow stops working until + re-provisioned.

    +
  8. +
  9. + Confirm team + channel +

    Auto-populated if launched from the channel. Click Add workflow / Save.

    +
  10. +
  11. + Copy the URL from the success dialog +

    The URL looks like:

    +
    https://prod-XX.<region>.logic.azure.com:443/workflows/<workflow-id>/triggers/manual/paths/invoke?api-version=2016-06-01&sp=%2Ftriggers%2Fmanual%2Frun&sv=1.0&sig=<SIGNATURE>
    +

    The sig= query parameter is the HMAC. Anyone with this URL can post to the channel. + Do not paste it into Slack, email, or commit it.

    +
  12. +
+ +
+ Lost the URL? + Workflows doesn't let you re-read the signature after the success dialog closes. If you lose it, delete the + workflow from Workflows app → My flows and provision a new one. +
+ + +

Configure the app

+ +

Production (Vercel)

+ +
    +
  1. + Open Vercel project settings +

    Vercel dashboard → AI Developer Hub project → Settings → Environment Variables.

    +
  2. +
  3. + Add TEAMS_WEBHOOK_URL +

    + Name: TEAMS_WEBHOOK_URL
    + Value: the Workflows URL you copied above
    + Environments: Production (and optionally Preview if you have a + separate staging Teams channel) +

    +
  4. +
  5. + Add TEAMS_DASHBOARD_BASE_URL (optional) +

    + Used to build the "Open dashboard" / "Open workspace" link buttons inside each card. Defaults to + NEXTAUTH_URL if unset, so you only need this if your card-target host differs (e.g., a + custom domain). Example: https://hub.unic.com. +

    +
  6. +
  7. + Redeploy +

    Vercel doesn't hot-load env changes for the running production deployment. Trigger a redeploy:

    +
    vercel redeploy --prod
    +

    Or push a no-op commit. The next cron run after deploy picks up the new env.

    +
  8. +
+ +

Local development (optional)

+ +

If you want to test cards while developing, add to .env.local in your worktree:

+
# .env.local
+TEAMS_WEBHOOK_URL="https://prod-XX.region.logic.azure.com/workflows/.../invoke?...&sig=..."
+TEAMS_DASHBOARD_BASE_URL="http://localhost:3000"
+ +

Restart the dev server (env is read at boot). Then trigger a sync manually:

+ +
curl -X GET http://localhost:3000/api/sync/anthropic-api-costs \
+  -H "Authorization: Bearer $CRON_SECRET"
+ +
+ Don't commit the webhook URL + .env.local is gitignored, but it's worth checking once: git check-ignore .env.local + should print the path. If TEAMS_WEBHOOK_URL ever ends up in git status, treat the + URL as compromised and rotate it (delete + recreate the workflow). +
+ + +

Verify it works

+ +

+ The cron runs every hour, so the quickest way to confirm wiring is to trigger it manually. From a machine + with the cron secret (or via the Vercel dashboard's Cron Jobs > "Run now"): +

+ +
curl -X GET https://hub.unic.com/api/sync/anthropic-api-costs \
+  -H "Authorization: Bearer $CRON_SECRET"
+ +

Expected response within ~10 seconds:

+ +
{"ok": true, "eventId": 2827}
+ +

Within seconds of that, your Teams channel should see at least the hourly digest card.

+ +

What "working" looks like

+ + + + + + + + + + + + + + + + + + + +
You seeWhat it means
Just the digest cardHealthy. No threshold breaches and no forecast-at-risk transitions this run.
Digest + one or more breach/forecast cardsFirst time the alert state for those workspaces was evaluated against the current month. Subsequent runs won't re-fire the same thresholds.
Only a yellow "stale-data" cardThe cost sync is more than 70 minutes behind. The evaluator suppressed the digest and other cards to avoid posting stale numbers. Fix the sync first.
Nothing in the channelSee Troubleshooting § "No cards appearing".
+ +

Cross-check the server logs

+ +

The cron handler logs a one-line summary every run. In Vercel logs (Runtime → Filter by route + /api/sync/anthropic-api-costs), look for:

+ +
[anthropic-api-costs] teams alerts posted=4 skipped=-
+ +

The numbers tell you what happened — posted=N is the total cards POSTed this run, + skipped=... is any kill-switch / stale-data reason. posted=0 skipped=webhook_disabled + means the env var isn't set on this environment yet.

+ + +

What to expect day-to-day

+ +

Cadence

+ +

+ The Anthropic cost sync runs hourly. That means up to 24 digest cards per day in your + channel, plus breach and forecast cards when state changes. For a typical month most days have only the + digest; breaches concentrate near the end of the month and when a new project ramps up. +

+ +

Idempotency guarantees

+ + + +

What lands in the channel after a single sync

+ +

Best case (steady state): 1 card (digest).
+ Worst case (first sync after enabling on a noisy environment): 1 digest + 1 card per + (workspace × newly-crossed-threshold) + 1 card per workspace newly flipped to at-risk.

+ +

For 11 workspaces with two over-budget and three at-risk, that's 1 + 2 + 3 = 6 cards. Posted serially over + roughly 1–3 seconds.

+ + +

Troubleshooting

+ +
+ No cards appearing in the channel +
+

In order of likelihood:

+
    +
  1. Env var not set on Production. Check vercel env ls or the dashboard. Look for the cron log line posted=0 skipped=webhook_disabled — that's the smoking gun.
  2. +
  3. Deployment hasn't picked up the env change. Trigger a redeploy: vercel redeploy --prod.
  4. +
  5. Cron is failing. Vercel dashboard → Cron Jobs → check status. If sync errors out, the evaluator never runs.
  6. +
  7. Workflow disabled or owner left the org. Open Workflows app → My flows → check status. Re-provision if needed; you'll get a new URL.
  8. +
  9. Channel was deleted or renamed. The webhook is bound to the channel ID; renaming is fine, deletion isn't. Check the channel exists.
  10. +
+
+
+ +
+ Cards appeared once, then stopped +
+

Most likely the URL was rotated by someone else, or Workflows hit a quota limit. Check the cron logs for a non-2xx HTTP status:

+
[anthropic-api-costs] teams alert evaluation failed (non-fatal): Teams webhook returned 401
+

401 / 403: URL is stale — re-provision and update the env var.
+ 429: You're hitting the 4 req/sec or 1800/hour-per-channel rate limit. Unlikely under + normal cadence; if you see this routinely, something is running the cron more often than hourly.
+ 413: Payload over 28 KB. The digest caps at top-5 workspaces; if the channel grew to + hundreds, file a follow-up to chunk the digest.

+
+
+ +
+ Only the stale-data warning is posting +
+

The cost sync hasn't completed in 70+ minutes. The evaluator deliberately suppresses the digest and + alert cards to avoid posting numbers nobody trusts. Fix the sync first:

+
    +
  1. Check /settings/sync in the app — look at the "Anthropic API costs" row for the last + outcome and error message.
  2. +
  3. Check Vercel cron logs for /api/sync/anthropic-api-costs. Common causes: Anthropic Admin + API key expired, Anthropic API outage (check status.claude.com), + Neon DB connectivity.
  4. +
  5. Once the sync recovers and one full hourly cycle completes, the digest resumes automatically.
  6. +
+
+
+ +
+ Duplicate cards posted to the channel +
+

By design, the entire card batch is all-or-nothing: if any single POST fails mid-batch, the next sync + re-evaluates and re-posts. Operators will see cards 1–2 duplicated and cards 3–5 appear fresh. This is + the cost of at-least-once delivery — preferred over partial state loss.

+

If duplicates persist across many runs, one specific card is consistently failing — most often a + 413 (oversized) or 400 (schema rejected). Check the cron logs; the error message names the failing card + type.

+
+
+ +
+ The wrong workspace name shows up +
+

Cards use the workspace name from anthropic_workspaces.name, which mirrors what Anthropic + returns from its workspaces API. If a workspace was renamed in the Anthropic console, run the workspace + sync manually to refresh local names:

+
curl -X POST https://hub.unic.com/api/sync/anthropic-api-costs \
+  -H "Authorization: Bearer $CRON_SECRET"
+
+
+ +
+ I want to test without spamming the production channel +
+

Provision a second Workflows webhook in a private channel (e.g., "Claude Alerts · Staging"), set + TEAMS_WEBHOOK_URL on the Vercel Preview environment only, and trigger the + cron against a preview deployment. The production channel stays clean.

+
+
+ +
+ How do I clear the alert state to re-test? +
+

The idempotency ledger is in the anthropic_alert_state table. To re-arm threshold cards for + a workspace in the current month, run against your staging Neon branch (never production):

+
DELETE FROM anthropic_alert_state WHERE billing_month = '2026-05';
+

Next sync will fire fresh threshold and forecast cards for any workspaces whose state should be active.

+

Don't do this in production unless you genuinely want the channel to see a re-fire of + every existing alert.

+
+
+ + +

Tuning

+ +

The feature ships with sensible defaults. If you need to change them, here's where they live:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
BehaviorWhereDefault
Threshold levelssrc/lib/teams/evaluator.ts · computeAlertDiff80%, 100%, 120%
Top-N workspaces in digestsrc/lib/teams/evaluator.ts · DIGEST_TOP_N5
Forecast lookback windowsrc/lib/teams/evaluator.ts · FORECAST_LOOKBACK_DAYS30
Forecast trailing-rate windowsrc/lib/anthropic/forecast-workspace.ts · hard-coded 7-day slice7 days
Stale-data thresholdsrc/lib/anthropic/queries.ts · STALE_MINUTES70 minutes
Sync cadencevercel.json · cron schedule for /api/sync/anthropic-api-costshourly
Webhook retrysrc/lib/teams/webhook.ts · MAX_ATTEMPTS, BASE_DELAY_MS3 attempts, 2 → 20 s backoff
+ +
+ Changes require a PR + None of these are runtime-configurable in v1. Treat tuning as a code change — open a PR, get a review, redeploy. +
+ + +

Disable / back out

+ +

Two ways, both safe and reversible:

+ +

Soft disable (recommended)

+

Unset TEAMS_WEBHOOK_URL on Vercel Production. The evaluator no-ops on its first line; sync continues to run; no cards post; no code rollback needed. To re-enable, put the env var back.

+ +

Workflow-side disable

+

From the Workflows app, turn off the flow (toggle "On" → "Off"). The webhook URL returns a 4xx; the evaluator logs the error and moves on. Use this when you can't reach the Vercel dashboard.

+ +
+ Do not delete the schema + The anthropic_alert_state table is small and harmless when the feature is off. Deleting it via + migration is not necessary and complicates re-enabling. +
+ + +

FAQ

+ +
+ Can I send threshold-breach cards to a different channel than the digest? +
+

Not in v1. The plan calls out a future TEAMS_WEBHOOK_URL_ALERTS variant for splitting noisy + digest from high-priority alerts — but it's deferred. File a request if you want it prioritized.

+
+
+ +
+ Can I @-mention people in the cards? +
+

Adaptive Cards posted via Workflows webhooks do not support mention entities — that's a + Bot Framework feature. For pings, the right path is a Microsoft Graph Activity Feed notification (also + deferred; see plan.html Phase 3).

+
+
+ +
+ Do users need any special permission to see the cards? +
+

No. Cards are visible to all members of the Teams channel, same as a regular chat message. The + "Open dashboard" / "Open workspace" buttons take them to the in-app dashboard, which still + enforces admin-only access.

+
+
+ +
+ Why USD when our budgets are tracked in CHF? +
+

Anthropic invoices in USD; the cards mirror the source-of-truth currency to avoid implying a conversion + we don't apply to the underlying numbers. Multi-currency display is a future enhancement (deferred + per spec Q4).

+
+
+ +
+ What happens if Anthropic has a status incident at the same time as a breach? +
+

v1 doesn't correlate against status.claude.com. If an incident + causes spend to spike (rare) or the sync to fail (more common), you may see false-positive alerts or the + stale-data warning. Status-page correlation is on the Phase 3 backlog.

+
+
+ +
+ Where is the data behind the cards stored? +
+

All data is already in the Neon Postgres database — anthropic_workspace_costs, + anthropic_workspace_limits, anthropic_sync_status. The only new table is + anthropic_alert_state (the idempotency ledger). No Anthropic data is sent to Microsoft + beyond what's rendered in the card.

+
+
+ +
+ Can I trigger a single card manually? +
+

Not directly — the evaluator runs as a whole batch from the sync. The closest equivalent is the + curl-based cron trigger above. For testing, point at a staging channel.

+
+
+ + + + +
+
+ + diff --git a/specs/030-claude-spend-teams-alerts/implementation-notes.html b/specs/030-claude-spend-teams-alerts/implementation-notes.html new file mode 100644 index 0000000..50da084 --- /dev/null +++ b/specs/030-claude-spend-teams-alerts/implementation-notes.html @@ -0,0 +1,420 @@ + + + + + +Implementation Notes · Spec 030 + + + +
+ +
+

Implementation Notes

+

Running log of design decisions, deviations, tradeoffs, and open questions while implementing spec 030 (Claude Spend → Microsoft Teams).

+
+ Branch: worktree-ms-teams-report · + Neon: wt/ms-teams-report (br-frosty-rice-alo5bla5, parent main) · + Started: 2026-05-21 +
+
+ +

1. Setup

+ +
+ Decision +

Worktree-isolated DB branch before touching schema

+

2026-05-21 · Task 1

+

+ The worktree didn't have its own .env.local (gitignored). Copied from main repo, then branched + Neon project ai-developer-hub off main into wt/ms-teams-report via the + neon-worktree-branch skill. DATABASE_URL and DATABASE_URL_UNPOOLED now point + at the branch. Verified host differs from main's host. Production data is untouched by anything done in this + worktree. +

+
+ +

2. Schema & data model

+ +
+ Deviation +

Nullable workspace_id with two partial unique indexes — not "__default__" coalesce

+

2026-05-21 · Task 2

+

+ The plan specified workspaceId: varchar(64).notNull() and coalescing the Anthropic default workspace's + null to a sentinel string "__default__". While reading the existing schema I found that + anthropic_workspace_costs and anthropic_workspace_limits already solve this with + varchar(100) nullable + two partial unique indexes (WHERE workspace_id IS NOT NULL and + WHERE workspace_id IS NULL). I matched that pattern in anthropic_alert_state. +

+

+ Why: consistency across the whole Anthropic sub-schema. The coalesce approach would have required + a thin translation layer at every read/write site. The partial-index approach is invisible at the application + layer — Drizzle just sees a nullable column. +

+

+ Tradeoff: the queries module now has to be careful to compare workspace_id IS NULL + rather than = '__default__'. Documented as a contract comment on the queries module. +

+
+ +
+ Decision +

Added a regex check constraint on billing_month

+

2026-05-21 · Task 2

+

+ Added CHECK (billing_month ~ '^[0-9]{4}-(0[1-9]|1[0-2])$') so a malformed value (e.g. + '2026-13' or 'May') can't reach the table even if a future caller forgets to + validate. Cheap belt-and-braces — TS types are not enforced at the DB layer, and the value is small enough that the + regex won't show up in EXPLAIN plans. +

+
+ +

3. Data layer (queries.ts)

+ +
+ Decision +

Surgical extraction, not full refactor

+

2026-05-21 · Task 4

+

+ The spec said "refactor the server actions to delegate" — taken literally that meant moving every _get* + private function out of anthropic-global.ts (16 of them). I only moved the three the evaluator + actually needs (loadSyncStatus, loadDashboardKpis, loadWorkspaceList) and + left the other 13 private functions in place. The rest can migrate later if a second consumer emerges. +

+

Why: minimum diff, zero behavior change for any of the other 13 dashboard panels. Typecheck passes.

+

+ Mitigations from the plan all applied: import "server-only" at top, contract comment naming the + only two allowed callers, load* naming convention. +

+
+ +

4. Forecast module

+ +
+ Decision +

Forecast function signature gained limitCents parameter

+

2026-05-21 · Task 5

+

+ Plan signature was forecastWorkspaceMonth(workspaceId, month, today) but its return shape includes + crossesCapOn and status: "at_risk" | "on_track" — both of which require the cap. + Added a 4th param limitCents: number | null. Caller already has it on WorkspaceListItem. +

+
+ +
+ Tradeoff +

Simple 7-day trailing average over 14-day window, not OLS or EWMA

+

2026-05-21 · Task 5

+

+ Considered: weighted exponential moving average, OLS on day-of-month progression, or two-line piecewise to detect + inflection. Picked plain 7-day trailing because: (1) the forecast card already shows a 7-day rate as a fact, so the + projection "7-day rate × days remaining" is internally consistent with what the operator sees, (2) Anthropic spend + has strong weekly seasonality (weekday spikes, weekend dips), and a 7-day window naturally averages over that, (3) + EWMA would chase noise from a single backfill spike. +

+

+ Downside: a single "experiment Friday" spike pulls the 7-day rate up for a week. Acceptable — the next 7 days will + smooth it back out. +

+
+ +
+ Decision +

insufficient_data when < 3 distinct billed days this month

+

2026-05-21 · Task 5

+

+ Plan didn't specify the data-sufficiency threshold. Picked 3 distinct billed days inside the current billing + month. Fewer than that and the projection is dominated by noise — no card fires, evaluator ignores the workspace + for forecast purposes. Threshold breaches still fire normally; they don't depend on the forecast. +

+
+ +
+ Decision +

If already over cap → crossesCapOn = null, not today's date

+

2026-05-21 · Task 5

+

+ The forecast card shows "Crosses 100% on YYYY-MM-DD". If a workspace is already over, the threshold breach card + has already signaled that — the forecast doesn't need to also say "crosses today". Returning null + here lets the renderer omit the field instead of showing a confusing date. +

+
+ +

5. Teams module

+ +
+ Deviation +

Custom retry loop in webhook.ts instead of retryWithBackoff()

+

2026-05-21 · Task 6

+

+ The plan said "reuse retryWithBackoff() from src/lib/sync/framework.ts". Reading the + existing helper showed it retries on every error and ignores HTTP Retry-After. Microsoft's rate-limit + docs say to honor Retry-After on 429 and to retry only specific statuses (412/429/502/504), not + everything. Wrote a small Teams-specific retry that does both. +

+

+ Tradeoff: < 60 lines of duplicated structural code vs. forking the shared helper. Picked + duplication because the Teams retry has different semantics than the data-sync retry (status-specific, header-honoring) + and tangling them would make the data-sync retry harder to reason about. +

+
+ +
+ Decision +

Deep links use /claude/workspaces/[id], not /anthropic/workspaces/[id]

+

2026-05-21 · Task 6

+

+ Plan's example payloads used https://hub.unic.com/anthropic/workspaces/research-claude. The actual + route in this codebase is /claude/workspaces/[workspaceId]. Used that. encodeURIComponent + on the id for safety. +

+
+ +
+ Decision +

Workspace key abstraction: "__default__" for in-memory state only

+

2026-05-21 · Task 6

+

+ The DB stores workspace_id IS NULL for the default workspace (matching existing tables — see entry + under Schema). But the evaluator's state-machine maps need a non-null key for Map<string, ...>. + Coined a single helper keyFor() that maps null → "__default__" for in-memory keys only; + everything that hits the DB still passes the original string | null. Avoids the "__default__" leak + that I rejected at the schema layer. +

+
+ +
+ Tradeoff +

Persist state AFTER all cards have posted, not per-card

+

2026-05-21 · Task 6

+

+ Two options: persist after each card (so a mid-batch failure doesn't lose progress), or persist after all cards + succeed (so a mid-batch failure causes the next sync to re-post the cards that did make it). Picked the second + because: (a) re-posting an identical card to a Teams channel within the hour is benign (operator just sees a + duplicate), (b) the alternative leaves a half-fired state where some cards were "announced" but no operator knows + the others didn't make it. At-least-once delivery, idempotent on the receiver. +

+
+ +
+ Decision +

computeAlertDiff() exported as a pure function

+

2026-05-21 · Task 6

+

+ The evaluator's state-machine logic is pure (no DB, no side effects). Split it out as + computeAlertDiff() and exported it so unit tests can hammer the threshold/forecast invariants without + needing a database. Plan didn't spell this out; this is a structural improvement. +

+
+ +

6. Cron integration

+ +
+ Decision +

Static import of evaluator (revised in code review)

+

2026-05-21 · Task 7 → revisited in Code Review

+

+ First pass used a dynamic await import() to keep the "server-only" chain out of the + sync source's static graph. Quality review flagged it as a smell. With the tests/shims/server-only.ts + alias already in both vitest configs, static import works fine in tests too. Switched back to static import. +

+
+ +

7. Tests

+ +
+ Deviation +

Added a vitest server-only shim — not in plan

+

2026-05-21 · Task 8

+

+ Both unit and integration test configs now alias the server-only package to an empty shim at + tests/shims/server-only.ts. Required because the plan's "import "server-only" + at the top of queries.ts" mitigation breaks vitest unless aliased — the package only exists at + runtime under Next.js. Net effect on production: zero. Tests now run; production builds still enforce the + server-only invariant. +

+
+ +
+ Deviation +

Integration vitest config now loads .env.local

+

2026-05-21 · Task 8

+

+ Existing integration tests (e.g. invoice-sync.test.ts) silently rely on the caller exporting + DATABASE_URL before running pnpm test:integration. Added an explicit + dotenv.config({ path: ".env.local" }) at the top of vitest.config.integration.mts. Also + bumped hookTimeout from 10s to 30s to tolerate Neon cold starts on first connection. +

+
+ +
+ Decision +

Seed includes today's row in forecast integration tests

+

2026-05-21 · Task 8

+

+ Initial expectations assumed seed covered last 14 days ending yesterday. The function fills missing days with 0 + (correct behavior — missing means "not synced yet," treat conservatively). Updated seeds to include today's + row, which is more realistic anyway since the hourly cron writes a partial today row on every run. +

+
+ +
+ Result +

317/317 unit tests · 7/7 forecast integration tests · 0 lint warnings · clean typecheck

+

2026-05-21 · Task 8

+

+ Existing anthropic-workspace backfill tests passed unchanged. They now emit + [anthropic-api-costs] teams alerts posted=0 skipped=webhook_disabled in their logs — confirming the + kill-switch behavior works correctly when TEAMS_WEBHOOK_URL is unset. +

+
+ +

8. Verification

+ +
+ Result +

Dashboard renders unchanged after server-action refactor

+

2026-05-21 · Task 9

+

+ Navigated http://localhost:3001/claude in Chrome via the chrome-devtools MCP. Page renders with real + Neon-branch data — $2,438.76 MTD, 11 workspaces, KPIs, daily breakdown, historical trend, all 13 workspace budget + cards. Single console error is a static-asset 404 unrelated to this change. Server-action refactor is transparent. + Screenshot saved at specs/030-claude-spend-teams-alerts/verification-dashboard.png. +

+
+ +
+ Result +

Full evaluator flow verified end-to-end

+

2026-05-21 · Task 9

+

+ Hit /api/sync/anthropic-api-costs via curl with the cron Bearer token in four + configurations: +

+ + + + + + + + + + + + + + + +
RunTEAMS_WEBHOOK_URLposted=Confirms
1unset0Kill switch
2set (httpbin echo)4Digest + 3 forecast edges (first run)
3set1Idempotency — forecast cards suppressed
4set1State stable across runs
+

+ SELECT * FROM anthropic_alert_state after run 2 shows three rows with forecast_at_risk=true + on the workspaces the dashboard's "on pace 102%/103%/97%" cards flagged. Threshold timestamps remain NULL because + no workspace is currently over 80%. +

+
+ +
+ Open question +

Cleanup recommendation: production rollout step list

+

2026-05-21 · Task 9

+

+ The plan's rollout step list assumes the operator has a real Workflows webhook URL to test against. For our + verification I used httpbin.org/anything as a wire test. Before production rollout, the operator should: (a) + provision a real Workflows webhook in a Teams channel, (b) replace TEAMS_WEBHOOK_URL in Vercel, (c) + seed a synthetic over-limit workspace in staging to test the threshold path (didn't exist in current data). +

+
+ +

9. Code review pass

+ +
+ Result +

Three parallel agent reviews · 24 findings · 9 applied, 15 deferred/skipped

+

2026-05-21 · /simplify

+

+ Three xhigh-effort review agents ran in parallel (reuse, quality, efficiency). High-value fixes applied: +

+ +

+ Smoke-tested against the Neon branch after the refactor: clean state → posted=4 (digest + 3 forecast cards) → re-run → posted=1 (digest only, idempotent). Same behavior as pre-refactor, fewer queries. +

+

+ Deferred (out of scope for spec 030): the "__default__" magic string appears in 13 sites across the + codebase — extracting a shared DEFAULT_WORKSPACE_KEY constant is a cross-cutting cleanup, separate + ticket. Loading loadDashboardKpis as one query instead of two is a pre-existing pattern lifted + unchanged — also separate. +

+
+ +

10. PR review fixes

+ +
+ Deviation +

Replaced __dirname with ESM-safe derivation in .mts config

+

2026-05-21 · PR #97 review

+

+ Copilot reviewer flagged that vitest.config.integration.mts uses __dirname, which is + technically undefined in Node ESM. It worked in practice (Vitest's esbuild transform polyfilled it), but the + spec is clear and the defensive fix is cheap. Switched to + path.dirname(fileURLToPath(import.meta.url)). Also rebased loadEnv() to use that + derived path so it works regardless of CWD. vitest.config.ts stays as-is — it's .ts + not .mts and resolved as CJS in this CJS project. +

+
+ +
+ Deviation +

Refuse to post cards in production when dashboard base URL is undeterminable

+

2026-05-21 · PR #97 review

+

+ Reviewer flagged that the silent fallback to "http://localhost:3000" for the dashboard base URL + would produce embarrassingly broken deep links if a production deployment lacked both + TEAMS_DASHBOARD_BASE_URL and NEXTAUTH_URL. Added a guard: when + VERCEL_ENV indicates a production-like environment and neither base URL is set, the evaluator + returns posted: 0, skipped: ["dashboard_base_url_missing"]. Local dev still falls back to localhost + so the kill switch can be tested without extra env wiring. +

+
+ +

11. Open questions for the spec author

+

(entries added as work progresses)

+ +
+ + diff --git a/specs/030-claude-spend-teams-alerts/mockup.html b/specs/030-claude-spend-teams-alerts/mockup.html new file mode 100644 index 0000000..2f422bf --- /dev/null +++ b/specs/030-claude-spend-teams-alerts/mockup.html @@ -0,0 +1,967 @@ + + + + + +Claude Spend → Microsoft Teams · Option 1 Mockup + + + +
+ + + + +
+ + + + + + + + +
+
+
+ Claude Spend Alerts + Hourly digest, threshold breaches, and forecast risk — posted by Unic Hub +
+
+ Posts + Files + Spend dashboard ⤴ +
+ +
+ +
+
Today · 21 May 2026
+ + +
+
UH
+
+
+ Unic Hub + via Workflows + · 14:02 +
+ +
+
+

+ + Claude API spend · hourly digest +

+
+ May 2026 month-to-date · synced 14:01 CET · data is 1 min old +
+
+ +
+
+
+ MTD spend + $18,420 + ▲ 12% vs same day last month +
+
+ Workspaces > 80% + 3 of 11 + ▲ 2 since 09:00 +
+
+ Forecast + At risk + 2 workspaces projected to overshoot +
+
+
+ +
+
+
+ Top utilization · % of monthly limit + Spend / limit +
+ +
+ research-claude 102% + $5,120 / $5,000 +
+
+ +
+ support-bots 91% + $2,730 / $3,000 +
+
+ +
+ product-ai 84% + $4,200 / $5,000 +
+
+ +
+ engineering-prod 67% + $4,020 / $6,000 +
+
+ +
+ internal-tools 42% + $840 / $2,000 +
+
+
+
+ +
+ + + +
+ +
+
+ Trigger: end of every successful hourly Anthropic sync. One post per channel per hour. Skipped if sync is stale (> 70 min) — a stale-data warning post is sent instead. +
+
+
+ + +
+
UH
+
+
+ Unic Hub + via Workflows + · 14:02 +
+ +
+
+

+ + Workspace over budget · research-claude +

+
+ Crossed 100% of monthly limit at 13:58 CET · first breach this month +
+
+ +
+
+ Workspaceresearch-claude + Monthly limit$5,000.00 + Spend MTD$5,120.40 · 102% + Last 24 h$612.18 · 3.4× the 30-day avg + Projected EOM$8,940 · +$3,940 over (78% overshoot) + Top modelclaude-opus-4-7 · 71% of spend +
+
+ +
+

+ Spend accelerated after a backfill job started at 11:20. The OLS forecast on the last + 14 days now exceeds the ceiling by $3,940. Recommended actions: raise the cap, + pause the workspace, or open the run-rate breakdown. +

+
+ +
+ + + + +
+ +
+
+ Trigger: any workspace crosses 80%, 100%, or 120% of anthropic_workspace_limits.monthly_cap since the previous sync. Idempotency on (workspace_id, threshold, billing_month). +
+
+
+ + +
+
UH
+
+
+ Unic Hub + via Workflows + · 14:03 +
+ +
+
+

+ + Forecast: product-ai projected to overshoot +

+
+ Linear forecast on the last 14 days · 89% confidence · status changed to at_risk +
+
+ +
+ +
+ +
+
+ Workspaceproduct-ai + Spend MTD$4,200 · 84% + 7-day run rate$210/day · ▲ 28% WoW + Projected EOM$5,890 · +$890 over + Crosses 100% on28 May 2026 · in 7 days + Driving modelclaude-sonnet-4-6 · +44% WoW invocations +
+
+ +
+ + + +
+ +
+
+ Trigger: forecastBudget() in src/lib/forecast.ts returns status: at_risk on this sync but was on_track on the previous sync. Edge-triggered, not level-triggered. +
+
+
+ +
+ +
+
+ ✎   Reply to Unic Hub…     📎 😊 🖼 ⋯ +
+
+
+
+ + +
+

How each card is produced

+

+ All three cards are static Adaptive Card v1.5 JSON payloads POSTed to a per-channel Workflows webhook URL. No bot, + no Entra app, no manifest — fire-and-forget HTTP from the existing hourly cron. Each post is a new message + (Workflows webhooks cannot edit in place). +

+ +
+
+

1. Hourly digest always

+

+ Posted once per successful sync. Pulls getDashboardKpis() + the top 5 entries from + getWorkspaceList() sorted by utilizationPct. Skipped if + getSyncStatus().isStale is true; a stale-data warning posts instead. +

+
+
+

2. Threshold breach edge-triggered

+

+ Fired once per workspace per threshold (80, 100, 120%) per billing month. Idempotency stored in a new + anthropic_alert_state table to survive cron restarts and re-runs. +

+
+
+

3. Forecast at-risk edge-triggered

+

+ Fired when forecastBudget() flips from on_trackat_risk, and + again when it flips back. Single source-of-truth: the same forecast already shown in the in-app dashboard. +

+
+
+ +
+ On track (< 80%) + Approaching limit (80–99%) + Over budget (≥ 100%) + Channel: Unic · FinOps for AI > Claude Spend Alerts + Webhook: https://prod-XX.westeurope.logic.azure.com/workflows/… +
+
+ +
+ + diff --git a/specs/030-claude-spend-teams-alerts/plan.html b/specs/030-claude-spend-teams-alerts/plan.html new file mode 100644 index 0000000..fb148bb --- /dev/null +++ b/specs/030-claude-spend-teams-alerts/plan.html @@ -0,0 +1,1260 @@ + + + + + +Plan · Claude Spend → Microsoft Teams (Option 1) + + + + +
+ + + + + +
+ +
+
Spec 030 · Claude Spend → Microsoft Teams
+

Implementation Plan — Option 1

+

+ Post Adaptive Cards into a Microsoft Teams channel from the existing hourly Anthropic sync via an + incoming Workflows webhook. Three card types: hourly digest, threshold-breach alert, forecast-at-risk + alert. No bot, no Teams app manifest, no Entra registration. +

+
+
Feature
030
+
Status
Plan · rev 2
+
Effort
~1.5 days
+
Risk
Low
+
+ +
+ Revisions in this pass + Self-review found three structural issues that would have hit during implementation. The plan now reflects the fixes — + sections marked (rev 2) below contain the updates. +
    +
  • Auth mismatch. Cron has no NextAuth session — every existing server action would return empty data. Solved by extracting an auth-free data layer at src/lib/anthropic/queries.ts.
  • +
  • Forecast & data sourcing. The existing forecastBudget() is for annual budgets, not workspace-monthly caps. Several card fields had no source. New forecastWorkspaceMonth() + "Top model" deferred to Phase 1.5.
  • +
  • Threshold model. Overlapping [80,100)/[100,120)/[≥120) ranges with an active boolean produced confusing semantics. Replaced with one row per workspace per month + three nullable "first-fired-at" timestamps. Each threshold fires exactly once per workspace per billing month.
  • +
+
+
+ + +

Scope

+ +

In scope

+ + +

Out of scope (deferred)

+ + +
+ Freshness ceiling + The sync runs hourly. "Real-time" here means "seconds after the hourly cron lands." Anthropic does not expose + webhooks for billing/usage events, so polling is the only architecture available. +
+ + +

Architecture

+ +

The integration is an additive post-step on the existing sync. No new cron, no new route, no new auth.

+ +
┌──────────────────────────────────────────────────────────────────────────────────┐ +│ GET /api/sync/anthropic-api-costs (Vercel Cron, hourly) │ +│ └─ makeCronSyncRoute(run, "Anthropic API costs") [existing] │ +│ └─ requireCronSecret(req) [existing] │ +│ └─ run() [existing, +1 step] │ +│ ├─ withSyncLock(...) → fetch Anthropic → upsert anthropic_workspace_costs │ +│ ├─ on success → stamp anthropic_sync_status [existing] │ +│ └─ on success → evaluateAndPostTeamsAlerts() [NEW, try/catch] │ +│ ├─ if !env.TEAMS_WEBHOOK_URL → return [no-op] │ +│ ├─ loadSyncStatus() [NEW · queries.ts] │ +│ ├─ loadDashboardKpis(month) [NEW · queries.ts] │ +│ ├─ loadWorkspaceList() [NEW · queries.ts] │ +│ ├─ forecastWorkspaceMonth(ws, month) [NEW · per workspace] │ +│ ├─ diff against anthropic_alert_state [NEW table] │ +│ ├─ build card payload(s): │ +│ │ • hourly digest (always, unless stale) │ +│ │ • threshold breach (per workspace × threshold, 1×/month) │ +│ │ • forecast at-risk (per workspace, on edge transition) │ +│ │ • stale-data warning (if isStale) │ +│ └─ for each payload: postCard(env.TEAMS_WEBHOOK_URL, payload) │ +│ └─ retryWithBackoff() — handle 429 / 5xx │ +└──────────────────────────────────────────────────────────────────────────────────┘ + │ + ▼ + https://prod-XX..logic.azure.com/... + (Workflows incoming webhook) + │ + ▼ + Microsoft Teams channel: Claude Spend Alerts + +──── Data layer (rev 2) ────────────────────────────────────────────────────────── + src/lib/anthropic/queries.ts ← auth-free pure DB queries (NEW) + │ + ├──→ called directly by the evaluator (no session, no admin gate) + └──→ wrapped by src/actions/anthropic-global.ts for admin UI + (requireAdmin + unstable_cache stay there, but no DB code below them)
+ +

Design choices

+ + + +

Data model

+ +

One new table. No new enums (the original tier enum was dropped — see rev 2 below). No changes to existing tables.

+ +

New table: anthropic_alert_state rev 2

+

+ One row per (workspace_id, billing_month) — the idempotency ledger. Each threshold has a nullable + *_fired_at timestamp: NULL means "never fired this month", non-NULL means "already fired, don't + fire again". Forecast tracking is stored as a boolean + timestamp pair on the same row (it's edge-triggered; + thresholds are not). +

+ +
src/lib/db/schema.ts (edit)// No new enum — the previous threshold enum is replaced by three nullable timestamp columns.
+
+export const anthropicAlertState = pgTable("anthropic_alert_state", {
+  id:                   serial("id").primaryKey(),
+
+  // workspaceId is varchar(64) NOT NULL. For the special "default" workspace
+  // (where Anthropic returns workspace_id = null), we coalesce to "__default__"
+  // before insert — see queries.ts. This sidesteps Postgres's NULL-is-not-equal-NULL
+  // gotcha on the unique index.
+  workspaceId:          varchar("workspace_id", { length: 64 }).notNull(),
+  billingMonth:         varchar("billing_month", { length: 7 }).notNull(),  // YYYY-MM
+
+  // Threshold alerts: fire-once-per-month, never re-fire.
+  threshold80FiredAt:   timestamp("threshold_80_fired_at"),
+  threshold100FiredAt:  timestamp("threshold_100_fired_at"),
+  threshold120FiredAt:  timestamp("threshold_120_fired_at"),
+
+  // Forecast: edge-triggered. Fires when forecastAtRisk transitions false → true.
+  forecastAtRisk:       boolean("forecast_at_risk").notNull().default(false),
+  forecastChangedAt:    timestamp("forecast_changed_at"),
+
+  createdAt:            timestamp("created_at").notNull().defaultNow(),
+  updatedAt:            timestamp("updated_at").notNull().defaultNow(),
+}, (t) => [
+  uniqueIndex("alert_state_workspace_month_idx").on(t.workspaceId, t.billingMonth),
+]);
+ + + + + + + +
ColumnPurpose
threshold_80_fired_at / _100_ / _120_NULL until that threshold first crosses this month. Once set, never re-fires for the same (workspace, month).
forecast_at_riskCurrent state of the forecast. Used to detect false → true edges (and optionally back).
forecast_changed_atTimestamp of the last state flip. Lets the card show "status changed N minutes ago".
composite unique(workspace_id, billing_month) — exactly one row per workspace per month.
+ +
+ Why fire-once-per-month for thresholds + A workspace stuck at 110% does not need a fresh card every hour, every day, or every time it dips to 79% and pops + back. The breach event is "crossed for the first time this month" — once an operator has seen it, more cards add + noise, not signal. Forecast cards remain edge-triggered because forecast flips are themselves rare and meaningful. +
+ +

Migration command

+
pnpm db:generate   # produces .migrations/XXXX_*.sql
+pnpm db:migrate    # applies to DATABASE_URL
+ +
+ Migration review + Run the drizzle-migration-reviewer agent on the generated SQL before applying to production — + this is a brand-new table with no FK references, so risk is minimal, but the policy applies. +
+ + +

Module layout

+ +

+ Two new namespaces — src/lib/anthropic/ for the auth-free data layer + (used by both the existing server actions and the new evaluator) and src/lib/teams/ for the + Teams-specific code — plus one schema edit and one source-handler edit. + rev 2 +

+ +
src/
+├── actions/
+│   └── anthropic-global.ts                              refactor: actions delegate to queries.ts (no behavior change for UI)
+├── lib/
+│   ├── db/
+│   │   └── schema.ts                                    + anthropicAlertState (rev 2 shape)
+│   ├── env.ts                                           + TEAMS_WEBHOOK_URL, TEAMS_DASHBOARD_BASE_URL
+│   ├── sync/
+│   │   └── sources/
+│   │       └── anthropic-workspace.ts                   + call evaluateAndPostTeamsAlerts() on success
+│   ├── anthropic/                                       NEW namespace — auth-free data layer
+│   │   ├── queries.ts                                   loadSyncStatus(), loadDashboardKpis(month), loadWorkspaceList()
+│   │   └── forecast-workspace.ts                        forecastWorkspaceMonth(ws, month, today) — trailing-rate, not OLS
+│   └── teams/
+│       ├── webhook.ts                                   postCard() — fetch wrapper w/ retry
+│       ├── cards.ts                                     renderDigestCard(), renderBreachCard(), renderForecastCard(), renderStaleCard()
+│       ├── evaluator.ts                                 evaluateAndPostTeamsAlerts() — pure orchestration
+│       ├── state.ts                                     readAlertState(month), upsertAlertState(diff)
+│       ├── format.ts                                    tiny helpers: $, %, ago()
+│       └── types.ts                                     card-input types, AlertEvaluation, CardEnvelope
+
+tests/
+├── unit/
+│   └── teams/
+│       ├── cards.test.ts                                snapshot tests for each card variant
+│       └── format.test.ts                               $, %, ago()
+└── integration/
+    ├── anthropic/
+    │   └── forecast-workspace.test.ts                   trailing-rate math against seeded daily costs
+    └── teams/
+        ├── evaluator.test.ts                            fire-once-per-month invariants + forecast edges
+        └── webhook.test.ts                              postCard against a mocked fetch
+ +

Server-action refactor (no UI behavior change)

+

+ The existing getSyncStatus(), getDashboardKpis(), getWorkspaceList() + keep their signatures and their requireAdmin() + unstable_cache() wrappers. Their + bodies shrink to a single call into src/lib/anthropic/queries.ts. The DB queries + themselves move into the queries module. Both surfaces (UI server actions and the cron-time evaluator) read from + the same source — actions enforce the admin gate, the evaluator skips it because it runs under + CRON_SECRET. +

+ +

Security hardening for the auth-free data layer

+

+ Hoisting org-financial queries above the auth boundary creates a footgun: a future route handler that imports a + load* function and forgets to gate would publicly expose spend KPIs. The mitigations below make + misuse hard to commit and easy to catch in review. +

+ +

+ Residual risk after mitigations: a hand-written new route that explicitly imports a load function and skips auth. + That requires an intentional act and is the kind of regression code review catches. Net assessment: small + incremental risk vs. the original "auth at the boundary" pattern, materially lower than the cost of leaving the + cron caller broken. +

+ +
+ CRON_SECRET blast radius + A leaked CRON_SECRET previously gave an attacker the ability to trigger syncs. After this change it + also gives them the ability to fire Teams posts to whatever channel TEAMS_WEBHOOK_URL points at. No + data exfil (the attacker would need both secrets to read responses), low real value, but worth noting. +
+ +

Why a workspace-monthly forecast module separate from forecastBudget()

+

+ src/lib/forecast.ts's forecastBudget() is designed for the annual budget tracker + (input: MonthlySpend[] over many months; output: annual projection via OLS). The Teams use case + needs monthly projection from daily data over a partial month. Different domain. + forecastWorkspaceMonth() uses a 7-day trailing rate scaled to days-remaining-in-month — well-suited + for a 30-day cycle and produces the "Crosses 100% on YYYY-MM-DD" date the forecast card needs. +

+ + +

Teams webhook client

+ +

+ A thin wrapper around fetch that POSTs an Adaptive Card envelope, honors 429 Retry-After, + and retries on 412/502/504 per Microsoft's guidance. Reuses retryWithBackoff() from + src/lib/sync/framework.ts. +

+ +
src/lib/teams/webhook.ts (new)import { env } from "@/lib/env";
+import { retryWithBackoff } from "@/lib/sync/framework";
+import type { CardEnvelope } from "./types";
+
+const RETRIABLE = new Set([412, 429, 502, 504]);
+
+export async function postCard(webhookUrl: string, envelope: CardEnvelope): Promise<void> {
+  await retryWithBackoff(async () => {
+    const res = await fetch(webhookUrl, {
+      method: "POST",
+      headers: { "content-type": "application/json" },
+      body: JSON.stringify(envelope),
+    });
+    if (res.ok) return;
+
+    if (RETRIABLE.has(res.status)) {
+      const retryAfter = Number(res.headers.get("retry-after") ?? 0);
+      throw new RetriableError(`Teams webhook ${res.status}`, retryAfter * 1000);
+    }
+    const body = await res.text();
+    throw new Error(`Teams webhook ${res.status}: ${body.slice(0, 500)}`);
+  }, { maxAttempts: 3, baseDelayMs: 2000, maxDelayMs: 20000, jitterPct: 0.2 });
+}
+ +
+ Limits we're designing under + Per-webhook throttle is 4 req/sec, per-channel cap is 1800 messages/hour, + max payload 28 KB. Hourly cadence with ≤ 5 cards per run sits at < 0.3% of the per-hour cap. +
+ + +

Card renderer

+ +

+ Pure functions: (input) => CardEnvelope. No DB access, no side effects, no clock reads — the caller + passes in now. This makes snapshot tests trivial and makes the cards reproducible. +

+ +

Renderer module shape

+
src/lib/teams/cards.ts (new)export function renderDigestCard(input: DigestInput): CardEnvelope;
+export function renderBreachCard(input: BreachInput): CardEnvelope;
+export function renderForecastCard(input: ForecastInput): CardEnvelope;
+export function renderStaleCard(input: StaleInput): CardEnvelope;
+ +

Input types

+
src/lib/teams/types.ts (new)export type DigestInput = {
+  kpis: DashboardKpis;             // from getDashboardKpis()
+  topWorkspaces: WorkspaceListItem[]; // top 5 by utilization, from getWorkspaceList()
+  sync: SyncStatus;                // from getSyncStatus()
+  month: string;                   // "2026-05"
+  dashboardUrl: string;
+};
+
+export type BreachInput = {
+  workspace: WorkspaceListItem;
+  threshold: "threshold_80" | "threshold_100" | "threshold_120";
+  projectedMonthEndCents: number;
+  topModel: { name: string; sharePct: number } | null;
+  workspaceUrl: string;
+  raiseLimitUrl: string;
+};
+
+export type ForecastInput = {
+  workspace: WorkspaceListItem;
+  forecast: BudgetForecast;        // from forecastBudget()
+  crossesCapOn: Date | null;
+  runRate7dCents: number;
+  runRateWoWPct: number;
+  workspaceUrl: string;
+};
+
+export type CardEnvelope = {
+  type: "message";
+  attachments: [{
+    contentType: "application/vnd.microsoft.card.adaptive";
+    contentUrl: null;
+    content: AdaptiveCard;
+  }];
+};
+ +

Visual contract

+

+ Cards must match the mockup at specs/030-claude-spend-teams-alerts/mockup.html. Notable rendering + constraints from the Teams contract investigation: +

+ + +
+ Don't leak secrets + The webhook URL is the secret. The HMAC sig= in the query string authenticates anyone holding it. + Never log the URL, never echo it back in error messages, never include it in sync_events.errorMessage. +
+ + +

Alert evaluator

+ +

+ The orchestration layer. Pulls data via the auth-free queries module, diffs against the alert-state ledger, + emits zero or more cards, persists the new state. + rev 2 +

+ +
src/lib/teams/evaluator.ts (new)import { loadSyncStatus, loadDashboardKpis, loadWorkspaceList } from "@/lib/anthropic/queries";
+import { forecastWorkspaceMonth } from "@/lib/anthropic/forecast-workspace";
+import { readAlertState, upsertAlertState } from "./state";
+import { postCard } from "./webhook";
+import { env } from "@/lib/env";
+
+export async function evaluateAndPostTeamsAlerts(opts?: {
+  now?: Date;
+}): Promise<{ posted: number; skipped: string[] }> {
+  if (!env.TEAMS_WEBHOOK_URL) return { posted: 0, skipped: ["webhook_disabled"] };
+
+  const now = opts?.now ?? new Date();
+  const month = getCurrentMonth(now);
+  const sync = await loadSyncStatus();
+
+  // Stale guard — short-circuit before doing any other work.
+  if (sync.isStale) {
+    await postCard(env.TEAMS_WEBHOOK_URL, renderStaleCard({ sync, month }));
+    return { posted: 1, skipped: ["digest_skipped_stale"] };
+  }
+
+  const [kpis, workspaces, state] = await Promise.all([
+    loadDashboardKpis(month),
+    loadWorkspaceList(),
+    readAlertState(month),
+  ]);
+
+  // Per-workspace forecasts run in parallel; the call is cheap (single SQL aggregation).
+  const forecasts = await Promise.all(
+    workspaces.filter(w => w.workspaceId !== null && w.limitCents !== null).map(w =>
+      forecastWorkspaceMonth(w.workspaceId!, month, now).then(f => ({ workspace: w, forecast: f }))
+    ),
+  );
+
+  const diff = computeAlertDiff({ workspaces, forecasts, state, now });
+  const envelopes: CardEnvelope[] = [];
+
+  // 1) Hourly digest — always (unless stale).
+  envelopes.push(renderDigestCard({ kpis, topWorkspaces: top5(workspaces), sync, month, dashboardUrl: dashUrl() }));
+
+  // 2) Threshold breaches — only thresholds with no firedAt timestamp yet this month.
+  for (const b of diff.thresholdsToFire) {
+    envelopes.push(renderBreachCard(toBreachInput(b)));
+  }
+
+  // 3) Forecast edges — false → true (and optionally true → false).
+  for (const f of diff.forecastEdges) {
+    envelopes.push(renderForecastCard(toForecastInput(f)));
+  }
+
+  // Post serially to stay under 4 req/sec.
+  for (const envelope of envelopes) {
+    await postCard(env.TEAMS_WEBHOOK_URL, envelope);
+  }
+
+  // Persist new state only after all cards have posted successfully.
+  await upsertAlertState(diff, now);
+  return { posted: envelopes.length, skipped: [] };
+}
+ +

State rules (rev 2)

+

For each (workspaceId, billingMonth) row, the evaluator decides per-column:

+ + + + + + + + +
ColumnConditionActionCard?
threshold_80_fired_atNULL AND pct ≥ 80set to nowyes
threshold_80_fired_atnon-NULL (any pct)no-op
threshold_100_fired_atNULL AND pct ≥ 100set to nowyes
threshold_120_fired_atNULL AND pct ≥ 120set to nowyes
forecast_at_riskwas false, now trueflip + stamp forecast_changed_atyes
forecast_at_riskwas true, now falseflip + stamp forecast_changed_atsee Q1
+ +

Threshold semantics

+ + + +

Cron integration

+ +

+ The only change to the existing cron path is a single try-wrapped call at the end of run(), after the + sync-status sentinel is stamped. +

+ +
export async function run(triggeredBy?: number, opts?: RunOpts): Promise<{ eventId: number }> { + const result = await withSyncLock( + { sourceType: "anthropic_api_costs", triggeredBy, operationType: opts?.operationType }, + async (eventId) => { /* ...existing... */ return counts; } + ); + if (counts.errorCount === 0) { + await db.insert(anthropicSyncStatus).values({ /* sentinel */ }).onConflictDoUpdate(...); ++ try { ++ const { posted, skipped } = await evaluateAndPostTeamsAlerts(); ++ console.log(`[teams] posted=${posted} skipped=${skipped.join(",") || "-"}`); ++ } catch (err) { ++ console.error("[teams] evaluation failed (non-fatal):", err); ++ } + } + return result; + }
+ + + +

Manual trigger

+

+ A dev or admin can re-fire the evaluator without re-running the upstream sync by calling + evaluateAndPostTeamsAlerts() from a one-off script (e.g., scripts/teams-test.ts). This is + not exposed as an API route in v1. +

+ + +

Configuration

+ +

New env vars

+ + + + + + + + + + + + + + +
NameTypeRequiredPurpose
TEAMS_WEBHOOK_URLURLNoIf unset, the evaluator no-ops. This is the kill switch.
TEAMS_DASHBOARD_BASE_URLURLNo (defaults to NEXTAUTH_URL)Base URL used to build deep links in card buttons.
+ +

Zod additions in src/lib/env.ts

+
src/lib/env.ts (edit)const envSchema = z.object({
+  /* ...existing... */
+  TEAMS_WEBHOOK_URL: z.string().url().optional(),
+  TEAMS_DASHBOARD_BASE_URL: z.string().url().optional(),
+});
+ +
+ Vercel env + Add TEAMS_WEBHOOK_URL in Vercel Project Settings → Environment Variables, scoped to Production only + at first. vercel env pull on local will pick it up. Treat the URL as a secret — it's an HMAC-signed + Logic Apps trigger and is auth by possession. +
+ + +

Card payloads

+ +

+ Three Adaptive Card v1.4 payloads, wrapped in the Workflows envelope. These are the contract — the renderer must + produce JSON that matches this shape. +

+ +

(a) Hourly digest

+
+ POST $TEAMS_WEBHOOK_URL — digest payload +
{
+  "type": "message",
+  "attachments": [
+    {
+      "contentType": "application/vnd.microsoft.card.adaptive",
+      "contentUrl": null,
+      "content": {
+        "$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
+        "type": "AdaptiveCard",
+        "version": "1.4",
+        "body": [
+          { "type": "TextBlock", "text": "Claude API spend · hourly digest", "weight": "Bolder", "size": "Large", "wrap": true },
+          { "type": "TextBlock", "text": "May 2026 MTD · synced 14:01 CET · 1 min old", "isSubtle": true, "spacing": "None", "wrap": true },
+          { "type": "ColumnSet", "spacing": "Medium", "columns": [
+            { "type": "Column", "width": "stretch", "items": [
+              { "type": "TextBlock", "text": "MTD spend", "isSubtle": true, "size": "Small" },
+              { "type": "TextBlock", "text": "$18,420", "weight": "Bolder", "size": "ExtraLarge", "spacing": "None" },
+              { "type": "TextBlock", "text": "▲ 12% vs same day last month", "color": "Attention", "size": "Small", "spacing": "None" }
+            ]},
+            { "type": "Column", "width": "stretch", "items": [
+              { "type": "TextBlock", "text": "Workspaces > 80%", "isSubtle": true, "size": "Small" },
+              { "type": "TextBlock", "text": "3 of 11", "weight": "Bolder", "size": "ExtraLarge", "spacing": "None" }
+            ]},
+            { "type": "Column", "width": "stretch", "items": [
+              { "type": "TextBlock", "text": "Forecast", "isSubtle": true, "size": "Small" },
+              { "type": "TextBlock", "text": "At risk", "weight": "Bolder", "size": "ExtraLarge", "color": "Attention", "spacing": "None" }
+            ]}
+          ]},
+          { "type": "TextBlock", "text": "Top utilization", "weight": "Bolder", "spacing": "Medium" },
+
+          /* repeat per workspace: name+pct, then a two-column "bar" */
+          { "type": "TextBlock", "text": "research-claude · 102% · $5,120 / $5,000", "spacing": "Small", "wrap": true },
+          { "type": "ColumnSet", "spacing": "None", "columns": [
+            { "type": "Column", "width": 100, "items": [{ "type": "Container", "style": "attention", "minHeight": "6px", "items": [{ "type": "TextBlock", "text": " " }]}]}
+          ]}
+          /* ...repeat for the remaining 4 workspaces... */
+        ],
+        "actions": [
+          { "type": "Action.OpenUrl", "title": "Open dashboard", "url": "https://hub.unic.com/anthropic" },
+          { "type": "Action.OpenUrl", "title": "View all workspaces", "url": "https://hub.unic.com/anthropic/workspaces" }
+        ]
+      }
+    }
+  ]
+}
+
+ +

(b) Threshold breach

+
+ POST $TEAMS_WEBHOOK_URL — breach payload (truncated for brevity) +
{
+  "type": "message",
+  "attachments": [{
+    "contentType": "application/vnd.microsoft.card.adaptive",
+    "contentUrl": null,
+    "content": {
+      "$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
+      "type": "AdaptiveCard",
+      "version": "1.4",
+      "body": [
+        { "type": "Container", "style": "attention", "bleed": true, "items": [
+          { "type": "TextBlock", "text": "⚠ Workspace over budget · research-claude", "weight": "Bolder", "size": "Large", "color": "Attention", "wrap": true },
+          { "type": "TextBlock", "text": "Crossed 100% at 13:58 CET · first breach this month", "isSubtle": true, "spacing": "None", "wrap": true }
+        ]},
+        { "type": "FactSet", "facts": [
+          { "title": "Workspace", "value": "research-claude" },
+          { "title": "Monthly limit", "value": "$5,000.00" },
+          { "title": "Spend MTD", "value": "$5,120.40 · 102%" },
+          { "title": "7-day run rate", "value": "$612/day · 3.4× the 30-day avg" },
+          { "title": "Projected EOM", "value": "$8,940 · +$3,940 over" }
+          /* "Top model" intentionally omitted in v1 — no per-workspace × model join
+             available. Deferred to Phase 1.5 once we wire anthropic_usage_metrics
+             into a workspace-attributed aggregation. */
+        ]}
+      ],
+      "actions": [
+        { "type": "Action.OpenUrl", "title": "Open workspace", "url": "https://hub.unic.com/anthropic/workspaces/research-claude" },
+        { "type": "Action.OpenUrl", "title": "Adjust limit", "url": "https://hub.unic.com/anthropic/workspaces/research-claude/limit" }
+      ]
+    }
+  }]
+}
+
+ +

(c) Forecast at-risk

+
+ POST $TEAMS_WEBHOOK_URL — forecast payload (truncated) +
{
+  "type": "message",
+  "attachments": [{
+    "contentType": "application/vnd.microsoft.card.adaptive",
+    "contentUrl": null,
+    "content": {
+      "$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
+      "type": "AdaptiveCard",
+      "version": "1.4",
+      "body": [
+        { "type": "Container", "style": "warning", "bleed": true, "items": [
+          { "type": "TextBlock", "text": "Forecast: product-ai projected to overshoot", "weight": "Bolder", "size": "Medium", "color": "Warning", "wrap": true },
+          { "type": "TextBlock", "text": "OLS on last 14 days · status flipped to at_risk", "isSubtle": true, "spacing": "None" }
+        ]},
+        { "type": "FactSet", "facts": [
+          { "title": "Spend MTD", "value": "$4,200 · 84%" },
+          { "title": "7-day run rate", "value": "$210/day · ▲ 28% WoW" },
+          { "title": "Projected EOM", "value": "$5,890 · +$890 over" },
+          { "title": "Crosses 100% on", "value": "28 May 2026 · in 7 days" }
+        ]}
+      ],
+      "actions": [
+        { "type": "Action.OpenUrl", "title": "Open forecast", "url": "https://hub.unic.com/anthropic/workspaces/product-ai/forecast" }
+      ]
+    }
+  }]
+}
+
+ + +

Failure modes & edge cases

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ScenarioBehavior
Webhook returns 429retryWithBackoff honors Retry-After, up to 3 attempts, max 20s delay.
Webhook returns 4xx (non-429)No retry. Log error (without URL). Sync still marked success — alerts are best-effort.
Workflow URL revoked / O365 connector retiredAll POSTs fail. Operator removes TEAMS_WEBHOOK_URL until a new Workflows URL is provisioned.
Sync stale (> 70 min)Digest skipped. Stale-data card posted instead. Breach/forecast cards skipped entirely.
Anthropic workspace removed upstreamWorkspace drops out of getWorkspaceList(). Its alert-state row goes dormant (active=false next eval).
New billing monthEval queries WHERE billing_month = '2026-06'. No rows exist; everything is treated as fresh. May → June rollover emits a fresh set of breach cards if conditions persist.
Limit increased mid-month (utilization drops below threshold)Row flips active=false. Next time utilization crosses again, a new card fires. No spam.
Limit removed entirelyWorkspace has no limitCents. Excluded from threshold evaluation. Forecast still runs if monthly history is sufficient.
Card > 28 KBRenderer caps the digest at top-5 workspaces; theoretical max is well under 6 KB. If somehow exceeded, the POST fails with 413 and the operator sees the error in cron logs.
Concurrent sync runswithSyncLock prevents concurrency. Evaluator runs only after the lock releases.
Partial sync (errorCount > 0)Evaluator skipped. Sync data may be incomplete; better silence than misleading alerts.
+ + +

Test plan

+ +

Unit Vitest

+ + +

Integration Vitest + real Neon branch

+ + +

Manual

+
    +
  1. Provision a real Workflows webhook in a staging channel.
  2. +
  3. Seed a workspace with a synthetic 110% utilization in staging DB.
  4. +
  5. Run the script pnpm tsx scripts/teams-test.ts → digest + breach card appear in the channel within seconds.
  6. +
  7. Re-run the script → only the digest re-appears (idempotency proof).
  8. +
  9. Trigger the cron via curl -H "Authorization: Bearer $CRON_SECRET" .../api/sync/anthropic-api-costs.
  10. +
+ + +

Rollout

+ +
    +
  1. + Spec freeze — review this plan with the AI-FinOps stakeholder. Confirm channel + audience. +
  2. +
  3. + Branch & Neon branch — create feature branch, spin a Neon worktree branch for schema work. +
  4. +
  5. + Schema migration — add anthropic_alert_type enum + anthropic_alert_state table. Generate, review with drizzle-migration-reviewer, apply. +
  6. +
  7. + Extract data layer — move DB queries from src/actions/anthropic-global.ts into new src/lib/anthropic/queries.ts. Server actions stay as thin admin-gated + cached wrappers. UI behavior unchanged; verify by running the existing dashboard against staging. +
  8. +
  9. + Add forecastWorkspaceMonth() in src/lib/anthropic/forecast-workspace.ts with an integration test. +
  10. +
  11. + Implement Teams modules in order: types.tsformat.tscards.tswebhook.tsstate.tsevaluator.ts. Each lands with unit tests. +
  12. +
  13. + Wire into sync — edit src/lib/sync/sources/anthropic-workspace.ts with the try-wrapped call. +
  14. +
  15. + Integration tests — run against the Neon branch; assert state-machine behavior. +
  16. +
  17. + Staging webhook — provision in a private staging Teams channel. Push branch to a Vercel preview. Set TEAMS_WEBHOOK_URL on the preview only. +
  18. +
  19. + Smoke test — seed a synthetic over-limit workspace, trigger the cron, observe the channel. +
  20. +
  21. + Security review — run the nextauth-security-reviewer sub-agent against the diff. Focus on src/lib/anthropic/queries.ts (server-only marker, no client imports), the cron handler, and any newly-touched session-handling code. +
  22. +
  23. + Code review — merge to main. +
  24. +
  25. + Production webhook — channel owner provisions a Workflows webhook in the production channel. Add TEAMS_WEBHOOK_URL to Vercel Production. +
  26. +
  27. + Watch for 24h — confirm one digest per hour, no duplicate breach cards on cron re-runs, no errors in cron logs. +
  28. +
  29. + Close spec — link the merged PRs from specs/030-claude-spend-teams-alerts/README.html (to be added later). +
  30. +
+ +

Backout

+

+ Remove or unset TEAMS_WEBHOOK_URL in Vercel. No code rollback needed — the evaluator early-returns when the var is missing. + Schema migration is additive (a new table) and safe to keep even when the feature is off. +

+ + +

Open questions

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#QuestionDefault if no answer
Q1Post a recovery card when forecast flips at_risk → on_track? (Thresholds are fire-once-per-month by design and don't apply.)No in v1 — reduces noise. The flip is recorded in the ledger and visible in the next digest. Reconsider after a month of real usage.
Q2One channel for everything, or split "digest" vs "alerts"?One channel (TEAMS_WEBHOOK_URL). Add a second var (TEAMS_WEBHOOK_URL_ALERTS) later if signal-to-noise becomes an issue.
Q3Should the digest post even when nothing has changed?Yes — the digest is the heartbeat. If it disappears, ops knows the sync is broken.
Q4Currency and time-zone formatting — USD + CET, or per-tenant config?USD + CET hard-coded in v1; pull from a settings module when there's more than one tenant.
Q5Hourly cadence too noisy?Keep hourly. The digest is one message; only edge-triggered breach/forecast cards add extra posts, and they're by definition uncommon.
Q6Where do the deep-link buttons point? Are there per-workspace pages today (/anthropic/workspaces/:id)?Confirm against the routing under src/app/(authed)/anthropic/ during implementation. If the per-workspace page doesn't exist, link to the global dashboard with an anchor.
+ +
+
+ + diff --git a/specs/030-claude-spend-teams-alerts/verification-dashboard.png b/specs/030-claude-spend-teams-alerts/verification-dashboard.png new file mode 100644 index 0000000..ae4f4e8 Binary files /dev/null and b/specs/030-claude-spend-teams-alerts/verification-dashboard.png differ diff --git a/src/actions/anthropic-global.ts b/src/actions/anthropic-global.ts index df17b72..73f19cd 100644 --- a/src/actions/anthropic-global.ts +++ b/src/actions/anthropic-global.ts @@ -39,6 +39,11 @@ import type { } from "@/types"; import { projectMonthEnd } from "@/lib/utils"; import { run as runAnthropicSync } from "@/lib/sync/sources/anthropic-workspace"; +import { + loadDashboardKpis, + loadSyncStatus, + loadWorkspaceList, +} from "@/lib/anthropic/queries"; // --------------------------------------------------------------------------- // getGlobalCostDashboard (T014) @@ -160,81 +165,20 @@ export const getAvailableMonths = getAvailableWorkspaceCostMonths; // getWorkspaceList (T020) // --------------------------------------------------------------------------- -async function _getWorkspaceList(): Promise { - const currentMonth = format(new Date(), "yyyy-MM"); - const startDate = `${currentMonth}-01`; - const endDate = format(endOfMonth(parseISO(`${currentMonth}-01`)), "yyyy-MM-dd"); - - // Sort order (spec 026 T115): - // 1. over 100% (utilization >= 100, limited) - // 2. over 80% (80 <= utilization < 100, limited) - // 3. with-limit by utilization DESC - // 4. no-limit by spend DESC - // 5. $0 + no-limit last - const rows = await db.execute(sql` - SELECT - w.workspace_id, - w.name, - w.is_default, - w.is_archived, - w.display_color, - COALESCE(c.total_cents, 0) as current_month_cents, - l.limit_cents - FROM anthropic_workspaces w - LEFT JOIN ( - SELECT workspace_id, SUM(cost_cents) as total_cents - FROM anthropic_workspace_costs - WHERE date >= ${startDate}::date AND date <= ${endDate}::date - GROUP BY workspace_id - ) c ON c.workspace_id IS NOT DISTINCT FROM w.workspace_id - LEFT JOIN anthropic_workspace_limits l - ON l.workspace_id IS NOT DISTINCT FROM w.workspace_id - WHERE w.is_archived = false - ORDER BY - CASE - WHEN l.limit_cents IS NOT NULL AND l.limit_cents > 0 - AND COALESCE(c.total_cents, 0) >= l.limit_cents THEN 1 - WHEN l.limit_cents IS NOT NULL AND l.limit_cents > 0 - AND COALESCE(c.total_cents, 0) >= 0.8 * l.limit_cents THEN 2 - WHEN l.limit_cents IS NOT NULL AND l.limit_cents > 0 THEN 3 - WHEN COALESCE(c.total_cents, 0) > 0 THEN 4 - ELSE 5 - END, - CASE - WHEN l.limit_cents IS NOT NULL AND l.limit_cents > 0 - THEN (COALESCE(c.total_cents, 0)::float / l.limit_cents) - ELSE NULL - END DESC NULLS LAST, - COALESCE(c.total_cents, 0) DESC, - w.name ASC - `); - - return rows.rows.map((r) => { - const currentMonthCents = Number(r.current_month_cents ?? 0); - const limitCents = r.limit_cents != null ? Number(r.limit_cents) : null; - const utilizationPct = - limitCents != null && limitCents > 0 - ? Math.round((currentMonthCents / limitCents) * 100) - : null; - return { - workspaceId: r.workspace_id as string | null, - name: r.name as string, - isDefault: r.is_default as boolean, - isArchived: r.is_archived as boolean, - currentMonthCents, - limitCents, - utilizationPct, - displayColor: (r.display_color as string | null) ?? null, - }; - }); -} - +// Sort order (spec 026 T115): +// 1. over 100% (utilization >= 100, limited) +// 2. over 80% (80 <= utilization < 100, limited) +// 3. with-limit by utilization DESC +// 4. no-limit by spend DESC +// 5. $0 + no-limit last +// Body lives in src/lib/anthropic/queries.ts so the cron-time Teams evaluator +// (no session) can read the same data without an admin gate. export async function getWorkspaceList(): Promise { const admin = await requireAdmin(); if (!admin) return []; return unstable_cache( - _getWorkspaceList, + loadWorkspaceList, ["anthropic-workspace-list"], { tags: ["anthropic-workspace-costs"] } )(); @@ -416,83 +360,7 @@ export async function syncWorkspacesManual(): Promise< // Spec 026 — Phase 1 additions // --------------------------------------------------------------------------- -async function _getDashboardKpis(month: string): Promise { - const monthStart = `${month}-01`; - const monthEnd = format(endOfMonth(parseISO(monthStart)), "yyyy-MM-dd"); - const priorMonthDate = subMonths(parseISO(monthStart), 1); - const priorMonthStart = format(startOfMonth(priorMonthDate), "yyyy-MM-dd"); - const priorMonthEnd = format(endOfMonth(priorMonthDate), "yyyy-MM-dd"); - - const totalsResult = await db.execute(sql` - SELECT - COALESCE(SUM(CASE WHEN date >= ${monthStart}::date AND date <= ${monthEnd}::date THEN cost_cents ELSE 0 END), 0) AS total_cents, - COALESCE(SUM(CASE WHEN date >= ${priorMonthStart}::date AND date <= ${priorMonthEnd}::date THEN cost_cents ELSE 0 END), 0) AS prior_cents - FROM anthropic_workspace_costs - `); - const totalsRow = totalsResult.rows[0]; - const totalCents = Number(totalsRow?.total_cents ?? 0); - const priorMonthCents = Number(totalsRow?.prior_cents ?? 0); - const momDeltaCents = totalCents - priorMonthCents; - const momDeltaPct = - priorMonthCents < 100 - ? null - : Math.round((momDeltaCents / priorMonthCents) * 100); - - const nowMonth = format(new Date(), "yyyy-MM"); - const daysInMonth = getDaysInMonth(parseISO(monthStart)); - const daysElapsed = - month === nowMonth ? Math.max(1, getDate(new Date())) : daysInMonth; - const projectedMonthEndCents = projectMonthEnd(totalCents, daysElapsed, daysInMonth); - - const overRows = await db.execute(sql` - SELECT - w.workspace_id, - w.name, - COALESCE(c.total_cents, 0) AS current_month_cents, - l.limit_cents - FROM anthropic_workspaces w - LEFT JOIN ( - SELECT workspace_id, SUM(cost_cents) AS total_cents - FROM anthropic_workspace_costs - WHERE date >= ${monthStart}::date AND date <= ${monthEnd}::date - GROUP BY workspace_id - ) c ON c.workspace_id IS NOT DISTINCT FROM w.workspace_id - LEFT JOIN anthropic_workspace_limits l - ON l.workspace_id IS NOT DISTINCT FROM w.workspace_id - WHERE w.is_archived = false - AND l.limit_cents IS NOT NULL - AND l.limit_cents > 0 - `); - - let overCount = 0; - let workspacesWithLimitCount = 0; - let topName: string | null = null; - let topPct: number | null = null; - for (const r of overRows.rows) { - const limit = Number(r.limit_cents); - const cents = Number(r.current_month_cents ?? 0); - workspacesWithLimitCount += 1; - const pct = Math.round((cents / limit) * 100); - if (pct >= 80) overCount += 1; - if (topPct === null || pct > topPct) { - topPct = pct; - topName = (r.name as string) ?? null; - } - } - - return { - totalCents, - momDeltaCents, - momDeltaPct, - projectedMonthEndCents, - workspacesOverEightyCount: overCount, - workspacesWithLimitCount, - topOverWorkspaceName: overCount > 0 ? topName : null, - topOverWorkspaceUtilizationPct: overCount > 0 ? topPct : null, - priorMonthCents, - }; -} - +// Body lives in src/lib/anthropic/queries.ts — see getWorkspaceList note above. export async function getDashboardKpis(month?: string): Promise { const admin = await requireAdmin(); if (!admin) { @@ -514,7 +382,7 @@ export async function getDashboardKpis(month?: string): Promise { : format(new Date(), "yyyy-MM"); return unstable_cache( - () => _getDashboardKpis(targetMonth), + () => loadDashboardKpis(targetMonth), ["anthropic-dashboard-kpis", targetMonth], { tags: ["anthropic-workspace-costs"] } )(); @@ -620,30 +488,13 @@ export async function getDailyTotalsByWorkspace(month?: string): Promise<{ )(); } -const STALE_MINUTES = 70; -const SYNC_SENTINEL_USER_ID = 0; - +// Body lives in src/lib/anthropic/queries.ts — see getWorkspaceList note above. export async function getSyncStatus(): Promise { const admin = await requireAdmin(); if (!admin) { return { lastSyncedAt: null, ageMinutes: null, isStale: true }; } - - const row = await db.query.anthropicSyncStatus.findFirst({ - where: eq(anthropicSyncStatus.userId, SYNC_SENTINEL_USER_ID), - }); - const lastSyncedAt = - row?.workspaceSyncCompletedAt ?? row?.lastSyncCompletedAt ?? null; - if (!lastSyncedAt) { - return { lastSyncedAt: null, ageMinutes: null, isStale: true }; - } - const ageMs = Date.now() - lastSyncedAt.getTime(); - const ageMinutes = Math.floor(ageMs / 60_000); - return { - lastSyncedAt, - ageMinutes, - isStale: ageMinutes > STALE_MINUTES, - }; + return loadSyncStatus(); } // --------------------------------------------------------------------------- diff --git a/src/lib/anthropic/forecast-workspace.ts b/src/lib/anthropic/forecast-workspace.ts new file mode 100644 index 0000000..fc7a6b3 --- /dev/null +++ b/src/lib/anthropic/forecast-workspace.ts @@ -0,0 +1,140 @@ +// Workspace-monthly spend forecast — pure function. Caller supplies the daily +// cost rows so the evaluator can batch-load history for all workspaces in one +// SQL query instead of N. See loadCostHistory() in queries.ts. +// +// Distinct from src/lib/forecast.ts (OLS over months for the annual budget +// tracker). This one projects month-end via a 7-day trailing rate from up to +// 30 days of daily history. Right tool for cap-based monthly alerts. + +import { + endOfMonth, + format, + getDate, + getDaysInMonth, + parseISO, + subDays, +} from "date-fns"; + +export type WorkspaceForecast = { + runRate7dCents: number; + runRate30dCents: number; + // (last 7d total − prior 7d total) / prior 7d total. Null when prior week + // had < $1 of spend (denominator too small to be meaningful). + runRateWoWPct: number | null; + // currentMTD + runRate7dCents * daysRemainingInMonth. + projectedMonthEndCents: number; + // YYYY-MM-DD date the projection crosses the cap. Null if no cap, no + // crossing, or already over (handled by the breach card instead). + crossesCapOn: string | null; + status: "on_track" | "at_risk" | "insufficient_data"; +}; + +const MIN_HISTORY_DAYS = 3; + +/** + * @param dailyCosts Map for this workspace. Missing days + * are treated as 0. Pre-loaded once via loadCostHistory(). + * @param month Billing month as "YYYY-MM". + * @param today Current time. Pass explicitly for deterministic tests. + * @param limitCents Monthly cap in cents, or null when no cap. + */ +export function forecastWorkspaceMonth( + dailyCosts: Map, + month: string, + today: Date, + limitCents: number | null, +): WorkspaceForecast { + const monthStart = parseISO(`${month}-01`); + const monthEndDate = endOfMonth(monthStart); + const daysInMonth = getDaysInMonth(monthStart); + const daysElapsed = Math.min(daysInMonth, Math.max(1, getDate(today))); + const daysRemaining = Math.max(0, daysInMonth - daysElapsed); + + // Build dense daily series — fill missing days with 0 so averages are over + // calendar days, not just billed days. + const last30: number[] = []; + for (let i = 29; i >= 0; i--) { + const d = format(subDays(today, i), "yyyy-MM-dd"); + last30.push(dailyCosts.get(d) ?? 0); + } + const last7 = last30.slice(-7); + const prev7 = last30.slice(-14, -7); + + const sum = (arr: number[]) => arr.reduce((a, b) => a + b, 0); + const runRate7dCents = Math.round(sum(last7) / 7); + const runRate30dCents = Math.round(sum(last30) / 30); + + const prev7Total = sum(prev7); + const last7Total = sum(last7); + const runRateWoWPct = + prev7Total < 100 + ? null + : Math.round(((last7Total - prev7Total) / prev7Total) * 100); + + const mtdStart = format(monthStart, "yyyy-MM-dd"); + const mtdEnd = format(today < monthEndDate ? today : monthEndDate, "yyyy-MM-dd"); + let mtdCents = 0; + let distinctMtdDays = 0; + for (const [date, cents] of dailyCosts) { + if (date >= mtdStart && date <= mtdEnd) { + mtdCents += cents; + if (cents > 0) distinctMtdDays += 1; + } + } + + const projectedMonthEndCents = mtdCents + runRate7dCents * daysRemaining; + + if (distinctMtdDays < MIN_HISTORY_DAYS) { + return { + runRate7dCents, + runRate30dCents, + runRateWoWPct, + projectedMonthEndCents, + crossesCapOn: null, + status: "insufficient_data", + }; + } + + if (limitCents === null || limitCents <= 0) { + return { + runRate7dCents, + runRate30dCents, + runRateWoWPct, + projectedMonthEndCents, + crossesCapOn: null, + status: "on_track", + }; + } + + // Already over — the breach card handles the signal; no "crosses" date. + if (mtdCents >= limitCents) { + return { + runRate7dCents, + runRate30dCents, + runRateWoWPct, + projectedMonthEndCents, + crossesCapOn: null, + status: projectedMonthEndCents > limitCents ? "at_risk" : "on_track", + }; + } + + const willOvershoot = projectedMonthEndCents > limitCents; + let crossesCapOn: string | null = null; + if (willOvershoot && runRate7dCents > 0) { + const centsToReachCap = limitCents - mtdCents; + const daysToReachCap = Math.ceil(centsToReachCap / runRate7dCents); + const crossDate = new Date(today); + crossDate.setDate(crossDate.getDate() + daysToReachCap); + const clamped = crossDate > monthEndDate ? monthEndDate : crossDate; + crossesCapOn = format(clamped, "yyyy-MM-dd"); + } + + return { + runRate7dCents, + runRate30dCents, + runRateWoWPct, + projectedMonthEndCents, + crossesCapOn, + status: willOvershoot ? "at_risk" : "on_track", + }; +} diff --git a/src/lib/anthropic/queries.ts b/src/lib/anthropic/queries.ts new file mode 100644 index 0000000..57315fd --- /dev/null +++ b/src/lib/anthropic/queries.ts @@ -0,0 +1,239 @@ +// Auth-free reads of Anthropic spend data. Callers MUST enforce auth: +// server actions call requireAdmin(); the cron evaluator runs under CRON_SECRET. +// `server-only` prevents accidental client bundling. Caching is the caller's +// responsibility (actions wrap with unstable_cache; evaluator wants fresh reads). + +import "server-only"; + +import { db } from "@/lib/db"; +import { + anthropicSyncStatus, +} from "@/lib/db/schema"; +import { eq, sql } from "drizzle-orm"; +import { + endOfMonth, + format, + getDate, + getDaysInMonth, + parseISO, + startOfMonth, + subDays, + subMonths, +} from "date-fns"; +import { projectMonthEnd } from "@/lib/utils"; +import type { + DashboardKpis, + SyncStatus, + WorkspaceListItem, +} from "@/types"; + +/** workspaceId → (YYYY-MM-DD date → cost in cents) for the requested lookback. */ +export type CostHistoryByWorkspace = Map>; + +const STALE_MINUTES = 70; +const SYNC_SENTINEL_USER_ID = 0; + +// --------------------------------------------------------------------------- +// loadSyncStatus +// --------------------------------------------------------------------------- + +export async function loadSyncStatus(): Promise { + const row = await db.query.anthropicSyncStatus.findFirst({ + where: eq(anthropicSyncStatus.userId, SYNC_SENTINEL_USER_ID), + }); + const lastSyncedAt = + row?.workspaceSyncCompletedAt ?? row?.lastSyncCompletedAt ?? null; + if (!lastSyncedAt) { + return { lastSyncedAt: null, ageMinutes: null, isStale: true }; + } + const ageMs = Date.now() - lastSyncedAt.getTime(); + const ageMinutes = Math.floor(ageMs / 60_000); + return { + lastSyncedAt, + ageMinutes, + isStale: ageMinutes > STALE_MINUTES, + }; +} + +// --------------------------------------------------------------------------- +// loadDashboardKpis +// --------------------------------------------------------------------------- + +export async function loadDashboardKpis(month: string): Promise { + const monthStart = `${month}-01`; + const monthEnd = format(endOfMonth(parseISO(monthStart)), "yyyy-MM-dd"); + const priorMonthDate = subMonths(parseISO(monthStart), 1); + const priorMonthStart = format(startOfMonth(priorMonthDate), "yyyy-MM-dd"); + const priorMonthEnd = format(endOfMonth(priorMonthDate), "yyyy-MM-dd"); + + const totalsResult = await db.execute(sql` + SELECT + COALESCE(SUM(CASE WHEN date >= ${monthStart}::date AND date <= ${monthEnd}::date THEN cost_cents ELSE 0 END), 0) AS total_cents, + COALESCE(SUM(CASE WHEN date >= ${priorMonthStart}::date AND date <= ${priorMonthEnd}::date THEN cost_cents ELSE 0 END), 0) AS prior_cents + FROM anthropic_workspace_costs + `); + const totalsRow = totalsResult.rows[0]; + const totalCents = Number(totalsRow?.total_cents ?? 0); + const priorMonthCents = Number(totalsRow?.prior_cents ?? 0); + const momDeltaCents = totalCents - priorMonthCents; + const momDeltaPct = + priorMonthCents < 100 + ? null + : Math.round((momDeltaCents / priorMonthCents) * 100); + + const nowMonth = format(new Date(), "yyyy-MM"); + const daysInMonth = getDaysInMonth(parseISO(monthStart)); + const daysElapsed = + month === nowMonth ? Math.max(1, getDate(new Date())) : daysInMonth; + const projectedMonthEndCents = projectMonthEnd(totalCents, daysElapsed, daysInMonth); + + const overRows = await db.execute(sql` + SELECT + w.workspace_id, + w.name, + COALESCE(c.total_cents, 0) AS current_month_cents, + l.limit_cents + FROM anthropic_workspaces w + LEFT JOIN ( + SELECT workspace_id, SUM(cost_cents) AS total_cents + FROM anthropic_workspace_costs + WHERE date >= ${monthStart}::date AND date <= ${monthEnd}::date + GROUP BY workspace_id + ) c ON c.workspace_id IS NOT DISTINCT FROM w.workspace_id + LEFT JOIN anthropic_workspace_limits l + ON l.workspace_id IS NOT DISTINCT FROM w.workspace_id + WHERE w.is_archived = false + AND l.limit_cents IS NOT NULL + AND l.limit_cents > 0 + `); + + let overCount = 0; + let workspacesWithLimitCount = 0; + let topName: string | null = null; + let topPct: number | null = null; + for (const r of overRows.rows) { + const limit = Number(r.limit_cents); + const cents = Number(r.current_month_cents ?? 0); + workspacesWithLimitCount += 1; + const pct = Math.round((cents / limit) * 100); + if (pct >= 80) overCount += 1; + if (topPct === null || pct > topPct) { + topPct = pct; + topName = (r.name as string) ?? null; + } + } + + return { + totalCents, + momDeltaCents, + momDeltaPct, + projectedMonthEndCents, + workspacesOverEightyCount: overCount, + workspacesWithLimitCount, + topOverWorkspaceName: overCount > 0 ? topName : null, + topOverWorkspaceUtilizationPct: overCount > 0 ? topPct : null, + priorMonthCents, + }; +} + +// --------------------------------------------------------------------------- +// loadWorkspaceList +// --------------------------------------------------------------------------- + +export async function loadWorkspaceList(): Promise { + const currentMonth = format(new Date(), "yyyy-MM"); + const startDate = `${currentMonth}-01`; + const endDate = format(endOfMonth(parseISO(`${currentMonth}-01`)), "yyyy-MM-dd"); + + const rows = await db.execute(sql` + SELECT + w.workspace_id, + w.name, + w.is_default, + w.is_archived, + w.display_color, + COALESCE(c.total_cents, 0) as current_month_cents, + l.limit_cents + FROM anthropic_workspaces w + LEFT JOIN ( + SELECT workspace_id, SUM(cost_cents) as total_cents + FROM anthropic_workspace_costs + WHERE date >= ${startDate}::date AND date <= ${endDate}::date + GROUP BY workspace_id + ) c ON c.workspace_id IS NOT DISTINCT FROM w.workspace_id + LEFT JOIN anthropic_workspace_limits l + ON l.workspace_id IS NOT DISTINCT FROM w.workspace_id + WHERE w.is_archived = false + ORDER BY + CASE + WHEN l.limit_cents IS NOT NULL AND l.limit_cents > 0 + AND COALESCE(c.total_cents, 0) >= l.limit_cents THEN 1 + WHEN l.limit_cents IS NOT NULL AND l.limit_cents > 0 + AND COALESCE(c.total_cents, 0) >= 0.8 * l.limit_cents THEN 2 + WHEN l.limit_cents IS NOT NULL AND l.limit_cents > 0 THEN 3 + WHEN COALESCE(c.total_cents, 0) > 0 THEN 4 + ELSE 5 + END, + CASE + WHEN l.limit_cents IS NOT NULL AND l.limit_cents > 0 + THEN (COALESCE(c.total_cents, 0)::float / l.limit_cents) + ELSE NULL + END DESC NULLS LAST, + COALESCE(c.total_cents, 0) DESC, + w.name ASC + `); + + return rows.rows.map((r) => { + const currentMonthCents = Number(r.current_month_cents ?? 0); + const limitCents = r.limit_cents != null ? Number(r.limit_cents) : null; + const utilizationPct = + limitCents != null && limitCents > 0 + ? Math.round((currentMonthCents / limitCents) * 100) + : null; + return { + workspaceId: r.workspace_id as string | null, + name: r.name as string, + isDefault: r.is_default as boolean, + isArchived: r.is_archived as boolean, + currentMonthCents, + limitCents, + utilizationPct, + displayColor: (r.display_color as string | null) ?? null, + }; + }); +} + +// --------------------------------------------------------------------------- +// loadCostHistory — one query, fanned out by workspace_id in JS. +// Replaces N separate per-workspace queries from forecastWorkspaceMonth. +// --------------------------------------------------------------------------- + +export async function loadCostHistory( + today: Date, + lookbackDays: number, +): Promise { + const start = format(subDays(today, lookbackDays - 1), "yyyy-MM-dd"); + const end = format(today, "yyyy-MM-dd"); + + const rows = await db.execute<{ + workspace_id: string | null; + date: string; + cost_cents: number; + }>(sql` + SELECT workspace_id, date::text AS date, cost_cents + FROM anthropic_workspace_costs + WHERE date >= ${start}::date AND date <= ${end}::date + ORDER BY workspace_id, date ASC + `); + + const byWorkspace: CostHistoryByWorkspace = new Map(); + for (const r of rows.rows) { + let inner = byWorkspace.get(r.workspace_id); + if (!inner) { + inner = new Map(); + byWorkspace.set(r.workspace_id, inner); + } + inner.set(r.date, Number(r.cost_cents)); + } + return byWorkspace; +} diff --git a/src/lib/db/migrations/0019_bouncy_scourge.sql b/src/lib/db/migrations/0019_bouncy_scourge.sql new file mode 100644 index 0000000..44f7dce --- /dev/null +++ b/src/lib/db/migrations/0019_bouncy_scourge.sql @@ -0,0 +1,17 @@ +CREATE TABLE "anthropic_alert_state" ( + "id" serial PRIMARY KEY NOT NULL, + "workspace_id" varchar(100), + "billing_month" varchar(7) NOT NULL, + "threshold_80_fired_at" timestamp, + "threshold_100_fired_at" timestamp, + "threshold_120_fired_at" timestamp, + "forecast_at_risk" boolean DEFAULT false NOT NULL, + "forecast_changed_at" timestamp, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "anthropic_alert_state_billing_month_format" CHECK ("anthropic_alert_state"."billing_month" ~ '^[0-9]{4}-(0[1-9]|1[0-2])$') +); +--> statement-breakpoint +CREATE UNIQUE INDEX "anthropic_alert_state_workspace_month_idx" ON "anthropic_alert_state" USING btree ("workspace_id","billing_month") WHERE "anthropic_alert_state"."workspace_id" IS NOT NULL;--> statement-breakpoint +CREATE UNIQUE INDEX "anthropic_alert_state_default_month_idx" ON "anthropic_alert_state" USING btree ("billing_month") WHERE "anthropic_alert_state"."workspace_id" IS NULL;--> statement-breakpoint +CREATE INDEX "anthropic_alert_state_month_idx" ON "anthropic_alert_state" USING btree ("billing_month"); \ No newline at end of file diff --git a/src/lib/db/migrations/meta/0019_snapshot.json b/src/lib/db/migrations/meta/0019_snapshot.json new file mode 100644 index 0000000..0f2e49f --- /dev/null +++ b/src/lib/db/migrations/meta/0019_snapshot.json @@ -0,0 +1,3729 @@ +{ + "id": "75224b1d-f37d-4283-8b4d-6fd85c802ec5", + "prevId": "f0ad84b9-a6bf-4b2b-8958-3cc146f276bc", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.access_tiers": { + "name": "access_tiers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "tool_id": { + "name": "tool_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "monthly_cost_cents": { + "name": "monthly_cost_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "access_tiers_tool_id_idx": { + "name": "access_tiers_tool_id_idx", + "columns": [ + { + "expression": "tool_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "access_tiers_tool_name_idx": { + "name": "access_tiers_tool_name_idx", + "columns": [ + { + "expression": "tool_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "access_tiers_tool_id_ai_tools_id_fk": { + "name": "access_tiers_tool_id_ai_tools_id_fk", + "tableFrom": "access_tiers", + "tableTo": "ai_tools", + "columnsFrom": [ + "tool_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_tools": { + "name": "ai_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "vendor": { + "name": "vendor", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_licenses": { + "name": "max_licenses", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "tool_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ai_tools_name_idx": { + "name": "ai_tools_name_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ai_tools_vendor_idx": { + "name": "ai_tools_vendor_idx", + "columns": [ + { + "expression": "vendor", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.annual_budgets": { + "name": "annual_budgets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "fiscal_year": { + "name": "fiscal_year", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "total_amount_cents": { + "name": "total_amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "period_type": { + "name": "period_type", + "type": "period_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "budget_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "annual_budgets_fiscal_year_idx": { + "name": "annual_budgets_fiscal_year_idx", + "columns": [ + { + "expression": "fiscal_year", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "annual_budgets_status_idx": { + "name": "annual_budgets_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.anthropic_alert_state": { + "name": "anthropic_alert_state", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "billing_month": { + "name": "billing_month", + "type": "varchar(7)", + "primaryKey": false, + "notNull": true + }, + "threshold_80_fired_at": { + "name": "threshold_80_fired_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "threshold_100_fired_at": { + "name": "threshold_100_fired_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "threshold_120_fired_at": { + "name": "threshold_120_fired_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "forecast_at_risk": { + "name": "forecast_at_risk", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "forecast_changed_at": { + "name": "forecast_changed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "anthropic_alert_state_workspace_month_idx": { + "name": "anthropic_alert_state_workspace_month_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_month", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"anthropic_alert_state\".\"workspace_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "anthropic_alert_state_default_month_idx": { + "name": "anthropic_alert_state_default_month_idx", + "columns": [ + { + "expression": "billing_month", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"anthropic_alert_state\".\"workspace_id\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "anthropic_alert_state_month_idx": { + "name": "anthropic_alert_state_month_idx", + "columns": [ + { + "expression": "billing_month", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "anthropic_alert_state_billing_month_format": { + "name": "anthropic_alert_state_billing_month_format", + "value": "\"anthropic_alert_state\".\"billing_month\" ~ '^[0-9]{4}-(0[1-9]|1[0-2])$'" + } + }, + "isRLSEnabled": false + }, + "public.anthropic_org_config": { + "name": "anthropic_org_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "default": 1 + }, + "billing_budget_limit_cents": { + "name": "billing_budget_limit_cents", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_by": { + "name": "updated_by", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "anthropic_org_config_updated_by_users_id_fk": { + "name": "anthropic_org_config_updated_by_users_id_fk", + "tableFrom": "anthropic_org_config", + "tableTo": "users", + "columnsFrom": [ + "updated_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "anthropic_org_config_id_check": { + "name": "anthropic_org_config_id_check", + "value": "\"anthropic_org_config\".\"id\" = 1" + } + }, + "isRLSEnabled": false + }, + "public.anthropic_sync_status": { + "name": "anthropic_sync_status", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "last_sync_started_at": { + "name": "last_sync_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_sync_completed_at": { + "name": "last_sync_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_sync_error": { + "name": "last_sync_error", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "synced_days": { + "name": "synced_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "resolved_api_key_id": { + "name": "resolved_api_key_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "resolved_workspace_id": { + "name": "resolved_workspace_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "workspace_sync_completed_at": { + "name": "workspace_sync_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "anthropic_sync_status_user_id_idx": { + "name": "anthropic_sync_status_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.anthropic_usage_metrics": { + "name": "anthropic_usage_metrics", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "uncached_input_tokens": { + "name": "uncached_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_read_input_tokens": { + "name": "cache_read_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_creation_input_tokens": { + "name": "cache_creation_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "output_tokens": { + "name": "output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "computed_cost_cents": { + "name": "computed_cost_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "pricing_resolved": { + "name": "pricing_resolved", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "anthropic_usage_metrics_user_date_model_idx": { + "name": "anthropic_usage_metrics_user_date_model_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "anthropic_usage_metrics_user_date_idx": { + "name": "anthropic_usage_metrics_user_date_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "anthropic_usage_metrics_date_idx": { + "name": "anthropic_usage_metrics_date_idx", + "columns": [ + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "anthropic_usage_metrics_pricing_resolved_idx": { + "name": "anthropic_usage_metrics_pricing_resolved_idx", + "columns": [ + { + "expression": "pricing_resolved", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "anthropic_usage_metrics_user_id_users_id_fk": { + "name": "anthropic_usage_metrics_user_id_users_id_fk", + "tableFrom": "anthropic_usage_metrics", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.anthropic_workspace_costs": { + "name": "anthropic_workspace_costs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "cost_cents": { + "name": "cost_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "anthropic_workspace_costs_workspace_date_idx": { + "name": "anthropic_workspace_costs_workspace_date_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"anthropic_workspace_costs\".\"workspace_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "anthropic_workspace_costs_default_date_idx": { + "name": "anthropic_workspace_costs_default_date_idx", + "columns": [ + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"anthropic_workspace_costs\".\"workspace_id\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "anthropic_workspace_costs_date_idx": { + "name": "anthropic_workspace_costs_date_idx", + "columns": [ + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "anthropic_workspace_costs_workspace_id_idx": { + "name": "anthropic_workspace_costs_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "anthropic_workspace_costs_cost_cents_check": { + "name": "anthropic_workspace_costs_cost_cents_check", + "value": "\"anthropic_workspace_costs\".\"cost_cents\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.anthropic_workspace_limits": { + "name": "anthropic_workspace_limits", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "limit_cents": { + "name": "limit_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "anthropic_workspace_limits_workspace_id_idx": { + "name": "anthropic_workspace_limits_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"anthropic_workspace_limits\".\"workspace_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "anthropic_workspace_limits_default_idx": { + "name": "anthropic_workspace_limits_default_idx", + "columns": [ + { + "expression": "(1)", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"anthropic_workspace_limits\".\"workspace_id\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.anthropic_workspaces": { + "name": "anthropic_workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "display_color": { + "name": "display_color", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_archived": { + "name": "is_archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "anthropic_created_at": { + "name": "anthropic_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "anthropic_workspaces_workspace_id_idx": { + "name": "anthropic_workspaces_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"anthropic_workspaces\".\"workspace_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "anthropic_workspaces_is_default_idx": { + "name": "anthropic_workspaces_is_default_idx", + "columns": [ + { + "expression": "is_default", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"anthropic_workspaces\".\"is_default\" = true", + "concurrently": false, + "method": "btree", + "with": {} + }, + "anthropic_workspaces_archived_idx": { + "name": "anthropic_workspaces_archived_idx", + "columns": [ + { + "expression": "is_archived", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.assignment_comments": { + "name": "assignment_comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "assignment_id": { + "name": "assignment_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "varchar(2000)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "assignment_comments_assignment_id_idx": { + "name": "assignment_comments_assignment_id_idx", + "columns": [ + { + "expression": "assignment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "assignment_comments_author_id_idx": { + "name": "assignment_comments_author_id_idx", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "assignment_comments_created_at_idx": { + "name": "assignment_comments_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "assignment_comments_assignment_id_license_assignments_id_fk": { + "name": "assignment_comments_assignment_id_license_assignments_id_fk", + "tableFrom": "assignment_comments", + "tableTo": "license_assignments", + "columnsFrom": [ + "assignment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "assignment_comments_author_id_users_id_fk": { + "name": "assignment_comments_author_id_users_id_fk", + "tableFrom": "assignment_comments", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billed_costs": { + "name": "billed_costs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "period_id": { + "name": "period_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "amount_cents": { + "name": "amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "invoice_date": { + "name": "invoice_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "vendor_reference": { + "name": "vendor_reference", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "billed_costs_period_id_idx": { + "name": "billed_costs_period_id_idx", + "columns": [ + { + "expression": "period_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billed_costs_invoice_date_idx": { + "name": "billed_costs_invoice_date_idx", + "columns": [ + { + "expression": "invoice_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "billed_costs_period_id_budget_periods_id_fk": { + "name": "billed_costs_period_id_budget_periods_id_fk", + "tableFrom": "billed_costs", + "tableTo": "budget_periods", + "columnsFrom": [ + "period_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.budget_periods": { + "name": "budget_periods", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "budget_id": { + "name": "budget_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "period_label": { + "name": "period_label", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "period_index": { + "name": "period_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "start_date": { + "name": "start_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "end_date": { + "name": "end_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "planned_amount_cents": { + "name": "planned_amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "budget_periods_budget_id_idx": { + "name": "budget_periods_budget_id_idx", + "columns": [ + { + "expression": "budget_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "budget_periods_budget_period_idx": { + "name": "budget_periods_budget_period_idx", + "columns": [ + { + "expression": "budget_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "budget_periods_budget_id_annual_budgets_id_fk": { + "name": "budget_periods_budget_id_annual_budgets_id_fk", + "tableFrom": "budget_periods", + "tableTo": "annual_budgets", + "columnsFrom": [ + "budget_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.change_history": { + "name": "change_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "change_type": { + "name": "change_type", + "type": "change_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "field_name": { + "name": "field_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "previous_value": { + "name": "previous_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "new_value": { + "name": "new_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "changed_by": { + "name": "changed_by", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "change_history_entity_idx": { + "name": "change_history_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "change_history_changed_by_idx": { + "name": "change_history_changed_by_idx", + "columns": [ + { + "expression": "changed_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "change_history_created_at_idx": { + "name": "change_history_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "change_history_changed_by_users_id_fk": { + "name": "change_history_changed_by_users_id_fk", + "tableFrom": "change_history", + "tableTo": "users", + "columnsFrom": [ + "changed_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_billing_snapshots": { + "name": "copilot_billing_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "billing_month": { + "name": "billing_month", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "plan_type": { + "name": "plan_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "total_seats": { + "name": "total_seats", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "active_seats": { + "name": "active_seats", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "seat_cost_cents": { + "name": "seat_cost_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "total_cost_cents": { + "name": "total_cost_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_billing_snapshots_connection_month_idx": { + "name": "copilot_billing_snapshots_connection_month_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_month", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_billing_snapshots_connection_id_github_connections_id_fk": { + "name": "copilot_billing_snapshots_connection_id_github_connections_id_fk", + "tableFrom": "copilot_billing_snapshots", + "tableTo": "github_connections", + "columnsFrom": [ + "connection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_usage_metrics": { + "name": "copilot_usage_metrics", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "total_active_users": { + "name": "total_active_users", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "total_engaged_users": { + "name": "total_engaged_users", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "total_suggestions": { + "name": "total_suggestions", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "total_acceptances": { + "name": "total_acceptances", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "total_lines_suggested": { + "name": "total_lines_suggested", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "total_lines_accepted": { + "name": "total_lines_accepted", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "total_chat_turns": { + "name": "total_chat_turns", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_chat_acceptances": { + "name": "total_chat_acceptances", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_dotcom_chat_turns": { + "name": "total_dotcom_chat_turns", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_pr_summaries": { + "name": "total_pr_summaries", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "language_breakdown": { + "name": "language_breakdown", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "editor_breakdown": { + "name": "editor_breakdown", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_usage_metrics_connection_date_idx": { + "name": "copilot_usage_metrics_connection_date_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_usage_metrics_date_idx": { + "name": "copilot_usage_metrics_date_idx", + "columns": [ + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_usage_metrics_connection_id_github_connections_id_fk": { + "name": "copilot_usage_metrics_connection_id_github_connections_id_fk", + "tableFrom": "copilot_usage_metrics", + "tableTo": "github_connections", + "columnsFrom": [ + "connection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_connections": { + "name": "github_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "org_login": { + "name": "org_login", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "org_avatar_url": { + "name": "org_avatar_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "token_encrypted": { + "name": "token_encrypted", + "type": "varchar(700)", + "primaryKey": false, + "notNull": true + }, + "token_scopes_csv": { + "name": "token_scopes_csv", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "github_connection_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "connected_by": { + "name": "connected_by", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "disconnected_at": { + "name": "disconnected_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "copilot_sync_enabled": { + "name": "copilot_sync_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "copilot_sync_schedule": { + "name": "copilot_sync_schedule", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'daily'" + } + }, + "indexes": { + "github_connections_status_idx": { + "name": "github_connections_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_connections_connected_by_users_id_fk": { + "name": "github_connections_connected_by_users_id_fk", + "tableFrom": "github_connections", + "tableTo": "users", + "columnsFrom": [ + "connected_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_profiles": { + "name": "github_profiles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "github_id": { + "name": "github_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "github_login": { + "name": "github_login", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_repos": { + "name": "public_repos", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "profile_url": { + "name": "profile_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_profiles_user_id_idx": { + "name": "github_profiles_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_profiles_github_id_idx": { + "name": "github_profiles_github_id_idx", + "columns": [ + { + "expression": "github_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_profiles_github_login_idx": { + "name": "github_profiles_github_login_idx", + "columns": [ + { + "expression": "github_login", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_profiles_user_id_users_id_fk": { + "name": "github_profiles_user_id_users_id_fk", + "tableFrom": "github_profiles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_sync_events": { + "name": "github_sync_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "triggered_by": { + "name": "triggered_by", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "github_sync_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "total_members": { + "name": "total_members", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "matched_count": { + "name": "matched_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "imported_count": { + "name": "imported_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "unmatched_count": { + "name": "unmatched_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "conflict_count": { + "name": "conflict_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "manually_matched_count": { + "name": "manually_matched_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_count": { + "name": "created_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_type": { + "name": "sync_type", + "type": "copilot_sync_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'members'" + }, + "seats_processed": { + "name": "seats_processed", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "metrics_processed": { + "name": "metrics_processed", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "billing_processed": { + "name": "billing_processed", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "billing_linked": { + "name": "billing_linked", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "billing_skipped": { + "name": "billing_skipped", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "github_sync_events_connection_id_idx": { + "name": "github_sync_events_connection_id_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_sync_events_triggered_by_idx": { + "name": "github_sync_events_triggered_by_idx", + "columns": [ + { + "expression": "triggered_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_sync_events_connection_id_github_connections_id_fk": { + "name": "github_sync_events_connection_id_github_connections_id_fk", + "tableFrom": "github_sync_events", + "tableTo": "github_connections", + "columnsFrom": [ + "connection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_sync_events_triggered_by_users_id_fk": { + "name": "github_sync_events_triggered_by_users_id_fk", + "tableFrom": "github_sync_events", + "tableTo": "users", + "columnsFrom": [ + "triggered_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ingestion_filters": { + "name": "ingestion_filters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "field": { + "name": "field", + "type": "filter_field", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "filter_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_by": { + "name": "created_by", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ingestion_filters_enabled_idx": { + "name": "ingestion_filters_enabled_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ingestion_filters_created_by_users_id_fk": { + "name": "ingestion_filters_created_by_users_id_fk", + "tableFrom": "ingestion_filters", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ingestion_log": { + "name": "ingestion_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "vendor": { + "name": "vendor", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "invoice_number": { + "name": "invoice_number", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "invoice_date": { + "name": "invoice_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "amount_cents": { + "name": "amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "ingestion_outcome", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "channel": { + "name": "channel", + "type": "ingestion_channel", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "blob_pathname": { + "name": "blob_pathname", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linked_invoice_id": { + "name": "linked_invoice_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ingestion_log_outcome_idx": { + "name": "ingestion_log_outcome_idx", + "columns": [ + { + "expression": "outcome", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ingestion_log_created_at_idx": { + "name": "ingestion_log_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ingestion_log_vendor_idx": { + "name": "ingestion_log_vendor_idx", + "columns": [ + { + "expression": "vendor", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ingestion_log_channel_idx": { + "name": "ingestion_log_channel_idx", + "columns": [ + { + "expression": "channel", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ingestion_log_linked_invoice_id_invoices_id_fk": { + "name": "ingestion_log_linked_invoice_id_invoices_id_fk", + "tableFrom": "ingestion_log", + "tableTo": "invoices", + "columnsFrom": [ + "linked_invoice_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ingestion_log_uploaded_by_users_id_fk": { + "name": "ingestion_log_uploaded_by_users_id_fk", + "tableFrom": "ingestion_log", + "tableTo": "users", + "columnsFrom": [ + "uploaded_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invite_tokens": { + "name": "invite_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "invite_token_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "invite_tokens_token_hash_idx": { + "name": "invite_tokens_token_hash_idx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invite_tokens_user_id_idx": { + "name": "invite_tokens_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invite_tokens_active_user_idx": { + "name": "invite_tokens_active_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"invite_tokens\".\"status\" = 'active'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invite_tokens_user_id_users_id_fk": { + "name": "invite_tokens_user_id_users_id_fk", + "tableFrom": "invite_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoices": { + "name": "invoices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "invoice_number": { + "name": "invoice_number", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "invoice_date": { + "name": "invoice_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "amount_cents": { + "name": "amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "vendor": { + "name": "vendor", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "linked_billed_cost_id": { + "name": "linked_billed_cost_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "blob_url": { + "name": "blob_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "blob_pathname": { + "name": "blob_pathname", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filtered_out": { + "name": "filtered_out", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invoices_invoice_number_idx": { + "name": "invoices_invoice_number_idx", + "columns": [ + { + "expression": "invoice_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invoices_created_at_idx": { + "name": "invoices_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invoices_linked_billed_cost_id_idx": { + "name": "invoices_linked_billed_cost_id_idx", + "columns": [ + { + "expression": "linked_billed_cost_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invoices_linked_billed_cost_id_billed_costs_id_fk": { + "name": "invoices_linked_billed_cost_id_billed_costs_id_fk", + "tableFrom": "invoices", + "tableTo": "billed_costs", + "columnsFrom": [ + "linked_billed_cost_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "invoices_uploaded_by_users_id_fk": { + "name": "invoices_uploaded_by_users_id_fk", + "tableFrom": "invoices", + "tableTo": "users", + "columnsFrom": [ + "uploaded_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.license_assignments": { + "name": "license_assignments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tool_id": { + "name": "tool_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tier_id": { + "name": "tier_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "cost_at_assignment_cents": { + "name": "cost_at_assignment_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "assignment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "workspace": { + "name": "workspace", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "api_key_encrypted": { + "name": "api_key_encrypted", + "type": "varchar(700)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "source": { + "name": "source", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + } + }, + "indexes": { + "license_assignments_user_id_idx": { + "name": "license_assignments_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "license_assignments_tool_id_idx": { + "name": "license_assignments_tool_id_idx", + "columns": [ + { + "expression": "tool_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "license_assignments_tier_id_idx": { + "name": "license_assignments_tier_id_idx", + "columns": [ + { + "expression": "tier_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "license_assignments_status_idx": { + "name": "license_assignments_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "license_assignments_active_lookup_idx": { + "name": "license_assignments_active_lookup_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tool_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "license_assignments_user_id_users_id_fk": { + "name": "license_assignments_user_id_users_id_fk", + "tableFrom": "license_assignments", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "license_assignments_tool_id_ai_tools_id_fk": { + "name": "license_assignments_tool_id_ai_tools_id_fk", + "tableFrom": "license_assignments", + "tableTo": "ai_tools", + "columnsFrom": [ + "tool_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "license_assignments_tier_id_access_tiers_id_fk": { + "name": "license_assignments_tier_id_access_tiers_id_fk", + "tableFrom": "license_assignments", + "tableTo": "access_tiers", + "columnsFrom": [ + "tier_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_events": { + "name": "sync_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "source_type": { + "name": "source_type", + "type": "sync_source_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "operation_type": { + "name": "operation_type", + "type": "sync_operation_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'regular'" + }, + "backfill_start_date": { + "name": "backfill_start_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "sync_outcome", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'in_progress'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "triggered_by": { + "name": "triggered_by", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_count": { + "name": "created_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_count": { + "name": "updated_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "skipped_count": { + "name": "skipped_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_count": { + "name": "error_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sync_events_source_type_idx": { + "name": "sync_events_source_type_idx", + "columns": [ + { + "expression": "source_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sync_events_outcome_idx": { + "name": "sync_events_outcome_idx", + "columns": [ + { + "expression": "outcome", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sync_events_started_at_idx": { + "name": "sync_events_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sync_events_source_started_idx": { + "name": "sync_events_source_started_idx", + "columns": [ + { + "expression": "source_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sync_events_triggered_by_users_id_fk": { + "name": "sync_events_triggered_by_users_id_fk", + "tableFrom": "sync_events", + "tableTo": "users", + "columnsFrom": [ + "triggered_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_sources": { + "name": "sync_sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "source_type": { + "name": "source_type", + "type": "sync_source_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "cron_schedule": { + "name": "cron_schedule", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sync_sources_source_type_idx": { + "name": "sync_sources_source_type_idx", + "columns": [ + { + "expression": "source_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "github_username": { + "name": "github_username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "circle": { + "name": "circle", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'viewer'" + }, + "status": { + "name": "status", + "type": "user_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "preferences": { + "name": "preferences", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{\"theme\":\"system\"}'::jsonb" + }, + "profile": { + "name": "profile", + "type": "user_profile", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "must_change_password": { + "name": "must_change_password", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_agent": { + "name": "is_agent", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_email_idx": { + "name": "users_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_circle_idx": { + "name": "users_circle_idx", + "columns": [ + { + "expression": "circle", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_status_idx": { + "name": "users_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.assignment_status": { + "name": "assignment_status", + "schema": "public", + "values": [ + "active", + "inactive" + ] + }, + "public.budget_status": { + "name": "budget_status", + "schema": "public", + "values": [ + "active", + "archived" + ] + }, + "public.change_type": { + "name": "change_type", + "schema": "public", + "values": [ + "created", + "updated", + "deleted", + "status_change" + ] + }, + "public.copilot_sync_type": { + "name": "copilot_sync_type", + "schema": "public", + "values": [ + "members", + "copilot" + ] + }, + "public.filter_field": { + "name": "filter_field", + "schema": "public", + "values": [ + "vendor", + "invoice_number" + ] + }, + "public.filter_mode": { + "name": "filter_mode", + "schema": "public", + "values": [ + "whitelist", + "blacklist" + ] + }, + "public.github_connection_status": { + "name": "github_connection_status", + "schema": "public", + "values": [ + "active", + "disconnected" + ] + }, + "public.github_sync_status": { + "name": "github_sync_status", + "schema": "public", + "values": [ + "in_progress", + "completed", + "partial", + "failed" + ] + }, + "public.ingestion_channel": { + "name": "ingestion_channel", + "schema": "public", + "values": [ + "manual", + "api", + "bulk" + ] + }, + "public.ingestion_outcome": { + "name": "ingestion_outcome", + "schema": "public", + "values": [ + "success", + "failed", + "filtered" + ] + }, + "public.invite_token_status": { + "name": "invite_token_status", + "schema": "public", + "values": [ + "active", + "consumed", + "invalidated" + ] + }, + "public.period_type": { + "name": "period_type", + "schema": "public", + "values": [ + "monthly", + "quarterly" + ] + }, + "public.sync_operation_type": { + "name": "sync_operation_type", + "schema": "public", + "values": [ + "regular", + "backfill" + ] + }, + "public.sync_outcome": { + "name": "sync_outcome", + "schema": "public", + "values": [ + "in_progress", + "success", + "partial", + "failed" + ] + }, + "public.sync_source_type": { + "name": "sync_source_type", + "schema": "public", + "values": [ + "github_copilot_billing", + "anthropic_api_usage", + "anthropic_team_invoices", + "github_members", + "invoice_period_matching", + "anthropic_api_costs" + ] + }, + "public.tool_status": { + "name": "tool_status", + "schema": "public", + "values": [ + "active", + "archived" + ] + }, + "public.user_profile": { + "name": "user_profile", + "schema": "public", + "values": [ + "boost", + "maxed", + "indie" + ] + }, + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": [ + "admin", + "viewer" + ] + }, + "public.user_status": { + "name": "user_status", + "schema": "public", + "values": [ + "active", + "inactive" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/src/lib/db/migrations/meta/_journal.json b/src/lib/db/migrations/meta/_journal.json index 836389a..9150a27 100644 --- a/src/lib/db/migrations/meta/_journal.json +++ b/src/lib/db/migrations/meta/_journal.json @@ -134,6 +134,13 @@ "when": 1778854766149, "tag": "0018_messy_sleepwalker", "breakpoints": true + }, + { + "idx": 19, + "version": "7", + "when": 1779370363541, + "tag": "0019_bouncy_scourge", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 9177065..208648f 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -743,6 +743,41 @@ export const anthropicOrgConfig = pgTable( ] ); +// Anthropic Alert State — idempotency ledger for Teams alert posts. +// One row per (workspace_id, billing_month). Thresholds fire exactly once per +// workspace per month: nullable timestamps go non-null on first cross. +// Forecast tracking is edge-triggered via the boolean + change-timestamp pair. +// Uses the same nullable-workspaceId + two-partial-unique-indexes pattern as +// anthropic_workspace_costs to handle the default workspace cleanly. +export const anthropicAlertState = pgTable( + "anthropic_alert_state", + { + id: serial("id").primaryKey(), + workspaceId: varchar("workspace_id", { length: 100 }), + billingMonth: varchar("billing_month", { length: 7 }).notNull(), + threshold80FiredAt: timestamp("threshold_80_fired_at"), + threshold100FiredAt: timestamp("threshold_100_fired_at"), + threshold120FiredAt: timestamp("threshold_120_fired_at"), + forecastAtRisk: boolean("forecast_at_risk").notNull().default(false), + forecastChangedAt: timestamp("forecast_changed_at"), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), + }, + (table) => [ + uniqueIndex("anthropic_alert_state_workspace_month_idx") + .on(table.workspaceId, table.billingMonth) + .where(sql`${table.workspaceId} IS NOT NULL`), + uniqueIndex("anthropic_alert_state_default_month_idx") + .on(table.billingMonth) + .where(sql`${table.workspaceId} IS NULL`), + index("anthropic_alert_state_month_idx").on(table.billingMonth), + check( + "anthropic_alert_state_billing_month_format", + sql`${table.billingMonth} ~ '^[0-9]{4}-(0[1-9]|1[0-2])$'` + ), + ] +); + // Relations export const usersRelations = relations(users, ({ many, one }) => ({ licenseAssignments: many(licenseAssignments), diff --git a/src/lib/env.ts b/src/lib/env.ts index cc13642..91a32f1 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -54,6 +54,14 @@ const envSchema = z.object({ // Profile API preview PROFILE_API_SECRET: z.string().optional(), VERCEL_AUTOMATION_BYPASS_SECRET: z.string().optional(), + + // Microsoft Teams — Workflows incoming webhook for Claude spend alerts. + // If unset, the post-sync evaluator is a no-op. This is the feature kill switch. + // The URL itself is the secret (HMAC-signed Logic Apps trigger) — never log it. + TEAMS_WEBHOOK_URL: z.string().url().optional(), + // Base URL used to build deep-link buttons in posted cards. Defaults to + // NEXTAUTH_URL when unset. + TEAMS_DASHBOARD_BASE_URL: z.string().url().optional(), }); export type Env = z.infer; diff --git a/src/lib/sync/sources/anthropic-workspace.ts b/src/lib/sync/sources/anthropic-workspace.ts index 7226a7f..5a872a7 100644 --- a/src/lib/sync/sources/anthropic-workspace.ts +++ b/src/lib/sync/sources/anthropic-workspace.ts @@ -5,6 +5,7 @@ import { sql } from "drizzle-orm"; import { z } from "zod"; import { ANTHROPIC_API_VERSION } from "@/lib/anthropic-constants"; import { env } from "@/lib/env"; +import { evaluateAndPostTeamsAlerts } from "@/lib/teams/evaluator"; // --------------------------------------------------------------------------- // Types @@ -332,6 +333,21 @@ export async function run( target: [anthropicSyncStatus.userId], set: { workspaceSyncCompletedAt: new Date() }, }); + + // Spec 030 — Microsoft Teams alerts. Best-effort: a posting failure + // must never fail the sync. The evaluator no-ops when TEAMS_WEBHOOK_URL + // is unset. + try { + const { posted, skipped } = await evaluateAndPostTeamsAlerts(); + console.log( + `[anthropic-api-costs] teams alerts posted=${posted} skipped=${skipped.join(",") || "-"}`, + ); + } catch (err) { + console.error( + "[anthropic-api-costs] teams alert evaluation failed (non-fatal):", + err instanceof Error ? err.message : String(err), + ); + } } return counts; diff --git a/src/lib/teams/cards.ts b/src/lib/teams/cards.ts new file mode 100644 index 0000000..d4ea8dc --- /dev/null +++ b/src/lib/teams/cards.ts @@ -0,0 +1,382 @@ +// Pure functions: (input) => CardEnvelope. No DB, no env, no clock. +// Snapshot-testable. See cards.test.ts for examples. + +import type { + AdaptiveCard, + BreachInput, + CardEnvelope, + DigestInput, + ForecastInput, + StaleInput, +} from "./types"; +import { fmtAgo, fmtDeltaPct, fmtMoney, fmtPct } from "./format"; +import { formatCurrency } from "@/lib/utils"; + +// --------------------------------------------------------------------------- +// Envelope helper — wraps an AdaptiveCard in the Workflows webhook envelope. +// --------------------------------------------------------------------------- + +function envelope(card: AdaptiveCard): CardEnvelope { + return { + type: "message", + attachments: [ + { + contentType: "application/vnd.microsoft.card.adaptive", + contentUrl: null, + content: card, + }, + ], + }; +} + +function projectedEomLabel(projectedCents: number, limitCents: number | null): string { + if (limitCents === null) return fmtMoney(projectedCents); + if (projectedCents > limitCents) { + return `${fmtMoney(projectedCents)} · +${fmtMoney(projectedCents - limitCents)} over`; + } + return `${fmtMoney(projectedCents)} · within cap`; +} + +// Adaptive Cards' Image element doesn't support SVG in Teams, so utilization +// bars are nested ColumnSets with tinted Container backgrounds. +function utilizationBar(pct: number): unknown { + const filled = Math.min(100, Math.max(0, pct)); + const remaining = Math.max(0, 100 - filled); + const style = pct >= 100 ? "attention" : pct >= 80 ? "warning" : "good"; + const columns: unknown[] = []; + if (filled > 0) { + columns.push({ + type: "Column", + width: filled, + items: [ + { + type: "Container", + style, + minHeight: "6px", + items: [{ type: "TextBlock", text: " ", spacing: "None" }], + }, + ], + }); + } + if (remaining > 0) { + columns.push({ + type: "Column", + width: remaining, + items: [ + { + type: "Container", + style: "default", + minHeight: "6px", + items: [{ type: "TextBlock", text: " ", spacing: "None" }], + }, + ], + }); + } + return { type: "ColumnSet", spacing: "None", columns }; +} + +// --------------------------------------------------------------------------- +// 1) Hourly digest +// --------------------------------------------------------------------------- + +export function renderDigestCard(input: DigestInput): CardEnvelope { + const { kpis, topWorkspaces, sync, month, dashboardUrl } = input; + + const kpiBlock = { + type: "ColumnSet", + spacing: "Medium", + columns: [ + { + type: "Column", + width: "stretch", + items: [ + { type: "TextBlock", text: "MTD spend", isSubtle: true, size: "Small" }, + { + type: "TextBlock", + text: fmtMoney(kpis.totalCents), + weight: "Bolder", + size: "ExtraLarge", + spacing: "None", + }, + { + type: "TextBlock", + text: `${fmtDeltaPct(kpis.momDeltaPct)} vs last month`, + color: (kpis.momDeltaPct ?? 0) > 0 ? "Attention" : "Default", + size: "Small", + spacing: "None", + }, + ], + }, + { + type: "Column", + width: "stretch", + items: [ + { type: "TextBlock", text: "Workspaces > 80%", isSubtle: true, size: "Small" }, + { + type: "TextBlock", + text: `${kpis.workspacesOverEightyCount} of ${kpis.workspacesWithLimitCount}`, + weight: "Bolder", + size: "ExtraLarge", + spacing: "None", + }, + ], + }, + { + type: "Column", + width: "stretch", + items: [ + { type: "TextBlock", text: "Projected EOM", isSubtle: true, size: "Small" }, + { + type: "TextBlock", + text: fmtMoney(kpis.projectedMonthEndCents), + weight: "Bolder", + size: "ExtraLarge", + spacing: "None", + }, + ], + }, + ], + }; + + const wsItems: unknown[] = []; + for (const w of topWorkspaces) { + const pctText = + w.utilizationPct !== null + ? `${fmtPct(w.utilizationPct)} · ${fmtMoney(w.currentMonthCents)} / ${fmtMoney(w.limitCents ?? 0)}` + : `${fmtMoney(w.currentMonthCents)} · no limit`; + wsItems.push({ + type: "TextBlock", + text: `**${w.name}** · ${pctText}`, + spacing: "Small", + wrap: true, + }); + if (w.utilizationPct !== null) { + wsItems.push(utilizationBar(w.utilizationPct)); + } + } + + const card: AdaptiveCard = { + $schema: "http://adaptivecards.io/schemas/adaptive-card.json", + type: "AdaptiveCard", + version: "1.4", + body: [ + { + type: "TextBlock", + text: "Claude API spend · hourly digest", + weight: "Bolder", + size: "Large", + wrap: true, + }, + { + type: "TextBlock", + text: `${month} MTD · data is ${fmtAgo(sync.ageMinutes)}`, + isSubtle: true, + spacing: "None", + wrap: true, + }, + kpiBlock, + { + type: "TextBlock", + text: "Top utilization", + weight: "Bolder", + spacing: "Medium", + }, + ...wsItems, + ], + actions: [ + { type: "Action.OpenUrl", title: "Open dashboard", url: dashboardUrl }, + ], + }; + + return envelope(card); +} + +// --------------------------------------------------------------------------- +// 2) Threshold breach +// --------------------------------------------------------------------------- + +const THRESHOLD_LABEL: Record = { + threshold_80: { pct: 80, level: "approaching", tone: "warning" }, + threshold_100: { pct: 100, level: "over budget", tone: "attention" }, + threshold_120: { pct: 120, level: "20% over budget", tone: "attention" }, +}; + +export function renderBreachCard(input: BreachInput): CardEnvelope { + const { workspace, threshold, forecast, workspaceUrl, raiseLimitUrl } = input; + const meta = THRESHOLD_LABEL[threshold]; + + const facts: Array<{ title: string; value: string }> = [ + { title: "Workspace", value: workspace.name }, + { + title: "Monthly limit", + value: workspace.limitCents !== null ? formatCurrency(workspace.limitCents) : "—", + }, + { + title: "Spend MTD", + value: `${formatCurrency(workspace.currentMonthCents)} · ${fmtPct(workspace.utilizationPct)}`, + }, + ]; + + if (forecast) { + facts.push({ + title: "7-day run rate", + value: `${fmtMoney(forecast.runRate7dCents)}/day`, + }); + facts.push({ + title: "Projected EOM", + value: fmtMoney(forecast.projectedMonthEndCents), + }); + } + + const card: AdaptiveCard = { + $schema: "http://adaptivecards.io/schemas/adaptive-card.json", + type: "AdaptiveCard", + version: "1.4", + body: [ + { + type: "Container", + style: meta.tone, + bleed: true, + items: [ + { + type: "TextBlock", + text: `Workspace ${meta.level} · ${workspace.name}`, + weight: "Bolder", + size: "Large", + color: meta.tone === "attention" ? "Attention" : "Warning", + wrap: true, + }, + { + type: "TextBlock", + text: `Crossed ${meta.pct}% of monthly limit this month`, + isSubtle: true, + spacing: "None", + wrap: true, + }, + ], + }, + { type: "FactSet", facts }, + ], + actions: [ + { type: "Action.OpenUrl", title: "Open workspace", url: workspaceUrl }, + { type: "Action.OpenUrl", title: "Adjust limit", url: raiseLimitUrl }, + ], + }; + + return envelope(card); +} + +// --------------------------------------------------------------------------- +// 3) Forecast at-risk +// --------------------------------------------------------------------------- + +export function renderForecastCard(input: ForecastInput): CardEnvelope { + const { workspace, forecast, workspaceUrl } = input; + + const facts: Array<{ title: string; value: string }> = [ + { title: "Workspace", value: workspace.name }, + { + title: "Spend MTD", + value: `${formatCurrency(workspace.currentMonthCents)} · ${fmtPct(workspace.utilizationPct)}`, + }, + { + title: "7-day run rate", + value: + forecast.runRateWoWPct !== null + ? `${fmtMoney(forecast.runRate7dCents)}/day · ${fmtDeltaPct(forecast.runRateWoWPct)} WoW` + : `${fmtMoney(forecast.runRate7dCents)}/day`, + }, + { title: "Projected EOM", value: projectedEomLabel(forecast.projectedMonthEndCents, workspace.limitCents) }, + ]; + + if (forecast.crossesCapOn) { + facts.push({ title: "Crosses 100% on", value: forecast.crossesCapOn }); + } + + const card: AdaptiveCard = { + $schema: "http://adaptivecards.io/schemas/adaptive-card.json", + type: "AdaptiveCard", + version: "1.4", + body: [ + { + type: "Container", + style: "warning", + bleed: true, + items: [ + { + type: "TextBlock", + text: `Forecast: ${workspace.name} projected to overshoot`, + weight: "Bolder", + size: "Medium", + color: "Warning", + wrap: true, + }, + { + type: "TextBlock", + text: "Status flipped to at_risk on the latest sync", + isSubtle: true, + spacing: "None", + wrap: true, + }, + ], + }, + { type: "FactSet", facts }, + ], + actions: [ + { type: "Action.OpenUrl", title: "Open workspace", url: workspaceUrl }, + ], + }; + + return envelope(card); +} + +// --------------------------------------------------------------------------- +// 4) Stale-data warning +// --------------------------------------------------------------------------- + +export function renderStaleCard(input: StaleInput): CardEnvelope { + const { sync, month, dashboardUrl } = input; + const card: AdaptiveCard = { + $schema: "http://adaptivecards.io/schemas/adaptive-card.json", + type: "AdaptiveCard", + version: "1.4", + body: [ + { + type: "Container", + style: "attention", + bleed: true, + items: [ + { + type: "TextBlock", + text: "Claude spend data is stale", + weight: "Bolder", + size: "Large", + color: "Attention", + wrap: true, + }, + { + type: "TextBlock", + text: `Last sync was ${fmtAgo(sync.ageMinutes)}. Digest and breach alerts are suppressed until the sync recovers.`, + isSubtle: true, + spacing: "None", + wrap: true, + }, + ], + }, + { + type: "FactSet", + facts: [ + { title: "Billing month", value: month }, + { + title: "Last sync", + value: sync.lastSyncedAt ? sync.lastSyncedAt.toISOString() : "never", + }, + ], + }, + ], + actions: [ + { type: "Action.OpenUrl", title: "Open dashboard", url: dashboardUrl }, + ], + }; + return envelope(card); +} diff --git a/src/lib/teams/evaluator.ts b/src/lib/teams/evaluator.ts new file mode 100644 index 0000000..088c3f4 --- /dev/null +++ b/src/lib/teams/evaluator.ts @@ -0,0 +1,241 @@ +// Orchestration: reads via queries.ts, diffs against the alert-state ledger, +// posts cards, persists state. Called from the cron sync, wrapped in try/catch +// by the caller — alert failures never fail a sync. + +import "server-only"; + +import { format } from "date-fns"; +import { env } from "@/lib/env"; +import { + loadCostHistory, + loadDashboardKpis, + loadSyncStatus, + loadWorkspaceList, +} from "@/lib/anthropic/queries"; +import { + forecastWorkspaceMonth, + type WorkspaceForecast, +} from "@/lib/anthropic/forecast-workspace"; +import { + renderBreachCard, + renderDigestCard, + renderForecastCard, + renderStaleCard, +} from "./cards"; +import { postCard } from "./webhook"; +import { readAlertState, upsertAlertState } from "./state"; +import type { + AlertDiff, + AlertStateRow, + CardEnvelope, +} from "./types"; +import type { WorkspaceListItem } from "@/types"; + +const DIGEST_TOP_N = 5; +const FORECAST_LOOKBACK_DAYS = 30; + +type EvaluatorResult = { + posted: number; + skipped: string[]; +}; + +export async function evaluateAndPostTeamsAlerts(opts?: { + now?: Date; +}): Promise { + if (!env.TEAMS_WEBHOOK_URL) { + return { posted: 0, skipped: ["webhook_disabled"] }; + } + const webhookUrl = env.TEAMS_WEBHOOK_URL; + + const now = opts?.now ?? new Date(); + const month = format(now, "yyyy-MM"); + + // Cards link back into the in-app dashboard. Refuse to post if we'd build + // a "http://localhost:3000" link in production — embarrassing for the + // channel and not actionable for the reader. Local dev gets a localhost + // fallback so the kill-switch can be tested without setting extra env. + const explicitBase = env.TEAMS_DASHBOARD_BASE_URL || env.NEXTAUTH_URL; + const isProductionLike = !!env.VERCEL_ENV && env.VERCEL_ENV !== "development"; + if (!explicitBase && isProductionLike) { + return { posted: 0, skipped: ["dashboard_base_url_missing"] }; + } + const dashboardBase = explicitBase || "http://localhost:3000"; + const dashboardUrl = `${dashboardBase.replace(/\/$/, "")}/claude`; + + const sync = await loadSyncStatus(); + + if (sync.isStale) { + await postCard(webhookUrl, renderStaleCard({ sync, month, dashboardUrl })); + return { posted: 1, skipped: ["digest_skipped_stale"] }; + } + + const [kpis, workspaces, priorState, costHistory] = await Promise.all([ + loadDashboardKpis(month), + loadWorkspaceList(), + readAlertState(month), + loadCostHistory(now, FORECAST_LOOKBACK_DAYS), + ]); + + // Compute forecasts purely from the pre-loaded cost history (no DB calls). + const forecastByKey = new Map(); + for (const w of workspaces) { + if (w.currentMonthCents === 0 && w.limitCents === null) continue; + const daily = costHistory.get(w.workspaceId) ?? new Map(); + forecastByKey.set(keyFor(w.workspaceId), forecastWorkspaceMonth(daily, month, now, w.limitCents)); + } + + const workspacesByKey = new Map(); + for (const w of workspaces) workspacesByKey.set(keyFor(w.workspaceId), w); + + const diff = computeAlertDiff({ + workspaces, + forecasts: forecastByKey, + priorState, + month, + now, + }); + + const envelopes: CardEnvelope[] = []; + + envelopes.push( + renderDigestCard({ + kpis, + topWorkspaces: workspaces.slice(0, DIGEST_TOP_N), + sync, + month, + dashboardUrl, + }), + ); + + for (const fire of diff.thresholdsToFire) { + const workspace = workspacesByKey.get(keyFor(fire.workspaceId)); + if (!workspace) continue; + const workspaceUrl = workspaceUrlFor(dashboardBase, workspace.workspaceId); + envelopes.push( + renderBreachCard({ + workspace, + threshold: fire.threshold, + forecast: forecastByKey.get(keyFor(workspace.workspaceId)) ?? null, + workspaceUrl, + raiseLimitUrl: `${workspaceUrl}#limit`, + }), + ); + } + + // Recovery cards deferred per spec Q1; only post when entering at_risk. + for (const edge of diff.forecastEdges) { + if (!edge.nextValue) continue; + const workspace = workspacesByKey.get(keyFor(edge.workspaceId)); + const forecast = forecastByKey.get(keyFor(edge.workspaceId)); + if (!workspace || !forecast) continue; + envelopes.push( + renderForecastCard({ + workspace, + forecast, + workspaceUrl: workspaceUrlFor(dashboardBase, workspace.workspaceId), + }), + ); + } + + // Post serially to stay under the per-webhook 4 req/sec throttle. If any + // POST throws, the whole batch fails and state is not persisted — next sync + // re-evaluates from the same prior state and re-attempts. Cards already + // delivered will appear again (duplicate in channel), which is the + // acceptable cost of all-or-nothing batch semantics. + for (const envelope of envelopes) { + await postCard(webhookUrl, envelope); + } + + await upsertAlertState(diff.rowsToUpsert); + + return { posted: envelopes.length, skipped: [] }; +} + +// Pure diff computation — exported for tests. +export function computeAlertDiff(input: { + workspaces: WorkspaceListItem[]; + forecasts: Map; + priorState: AlertStateRow[]; + month: string; + now: Date; +}): AlertDiff { + const { workspaces, forecasts, priorState, month, now } = input; + + const stateByKey = new Map(); + for (const r of priorState) stateByKey.set(keyFor(r.workspaceId), r); + + const thresholdsToFire: AlertDiff["thresholdsToFire"] = []; + const forecastEdges: AlertDiff["forecastEdges"] = []; + const rowsToUpsert: AlertStateRow[] = []; + + for (const w of workspaces) { + if (w.isArchived) continue; + + const key = keyFor(w.workspaceId); + const prior = stateByKey.get(key); + const forecast = forecasts.get(key); + + const pct = w.utilizationPct ?? 0; + const limited = w.limitCents !== null && w.limitCents > 0; + + let t80 = prior?.threshold80FiredAt ?? null; + let t100 = prior?.threshold100FiredAt ?? null; + let t120 = prior?.threshold120FiredAt ?? null; + + if (limited) { + if (t80 === null && pct >= 80) { + t80 = now; + thresholdsToFire.push({ workspaceId: w.workspaceId, threshold: "threshold_80" }); + } + if (t100 === null && pct >= 100) { + t100 = now; + thresholdsToFire.push({ workspaceId: w.workspaceId, threshold: "threshold_100" }); + } + if (t120 === null && pct >= 120) { + t120 = now; + thresholdsToFire.push({ workspaceId: w.workspaceId, threshold: "threshold_120" }); + } + } + + // `insufficient_data` is treated as on_track so we don't emit cards for + // noisy / new workspaces. + const nextAtRisk = forecast?.status === "at_risk"; + const wasAtRisk = prior?.forecastAtRisk ?? false; + let forecastChangedAt = prior?.forecastChangedAt ?? null; + if (nextAtRisk !== wasAtRisk) { + forecastChangedAt = now; + forecastEdges.push({ workspaceId: w.workspaceId, nextValue: nextAtRisk }); + } + + const changed = + t80 !== (prior?.threshold80FiredAt ?? null) || + t100 !== (prior?.threshold100FiredAt ?? null) || + t120 !== (prior?.threshold120FiredAt ?? null) || + nextAtRisk !== wasAtRisk; + if (!changed) continue; + + rowsToUpsert.push({ + workspaceId: w.workspaceId, + billingMonth: month, + threshold80FiredAt: t80, + threshold100FiredAt: t100, + threshold120FiredAt: t120, + forecastAtRisk: nextAtRisk, + forecastChangedAt, + }); + } + + return { thresholdsToFire, forecastEdges, rowsToUpsert }; +} + +// `"__default__"` is in-memory only — never persisted, never sent to SQL. +// Real Anthropic workspace ids are UUIDs, so collision is impossible. +function keyFor(workspaceId: string | null): string { + return workspaceId ?? "__default__"; +} + +function workspaceUrlFor(base: string, workspaceId: string | null): string { + const root = base.replace(/\/$/, ""); + if (workspaceId === null) return `${root}/claude`; + return `${root}/claude/workspaces/${encodeURIComponent(workspaceId)}`; +} diff --git a/src/lib/teams/format.ts b/src/lib/teams/format.ts new file mode 100644 index 0000000..11254c4 --- /dev/null +++ b/src/lib/teams/format.ts @@ -0,0 +1,35 @@ +// Tiny pure helpers for card text. Whole-dollar money + percent/delta helpers +// are bespoke; precise money and relative-time helpers reuse the codebase's +// standard helpers (formatCurrency / date-fns formatDistanceToNow). + +import { formatDistanceToNow } from "date-fns"; + +const USD_WHOLE = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + maximumFractionDigits: 0, +}); + +/** Whole-dollar formatting — "$18,420". Use formatCurrency() for 2-decimal. */ +export function fmtMoney(cents: number): string { + return USD_WHOLE.format(cents / 100); +} + +export function fmtPct(pct: number | null): string { + if (pct === null) return "—"; + return `${pct}%`; +} + +export function fmtDeltaPct(pct: number | null): string { + if (pct === null) return "—"; + if (pct > 0) return `▲ ${pct}%`; + if (pct < 0) return `▼ ${Math.abs(pct)}%`; + return "0%"; +} + +/** Relative age from minutes-ago — "1 min ago", "3 h ago". Returns "never" for null. */ +export function fmtAgo(ageMinutes: number | null): string { + if (ageMinutes === null) return "never"; + const date = new Date(Date.now() - ageMinutes * 60_000); + return formatDistanceToNow(date, { addSuffix: true }); +} diff --git a/src/lib/teams/state.ts b/src/lib/teams/state.ts new file mode 100644 index 0000000..2984435 --- /dev/null +++ b/src/lib/teams/state.ts @@ -0,0 +1,93 @@ +// Idempotency ledger for Teams alert posts. +// Schema: anthropic_alert_state in src/lib/db/schema.ts. +// Auth-free; callers are the evaluator and tests. + +import "server-only"; + +import { db } from "@/lib/db"; +import { anthropicAlertState } from "@/lib/db/schema"; +import { eq, sql } from "drizzle-orm"; +import type { AlertStateRow } from "./types"; + +export async function readAlertState(month: string): Promise { + const rows = await db + .select() + .from(anthropicAlertState) + .where(eq(anthropicAlertState.billingMonth, month)); + return rows.map((r) => ({ + workspaceId: r.workspaceId, + billingMonth: r.billingMonth, + threshold80FiredAt: r.threshold80FiredAt, + threshold100FiredAt: r.threshold100FiredAt, + threshold120FiredAt: r.threshold120FiredAt, + forecastAtRisk: r.forecastAtRisk, + forecastChangedAt: r.forecastChangedAt, + })); +} + +/** + * Persist post-evaluation state. The two-partial-unique-indexes pattern (one + * `WHERE workspace_id IS NOT NULL`, one `IS NULL`) means we can't share a + * single `onConflictDoUpdate` target across both — split into two statements. + * + * Caller (evaluator) runs inside `withSyncLock`, so no concurrent writer can + * race here; no transaction needed. + */ +export async function upsertAlertState(rows: AlertStateRow[]): Promise { + if (rows.length === 0) return; + + const namedRows = rows.filter((r) => r.workspaceId !== null); + const defaultRow = rows.find((r) => r.workspaceId === null); + + if (namedRows.length > 0) { + await db + .insert(anthropicAlertState) + .values(namedRows.map(toInsertValues)) + .onConflictDoUpdate({ + target: [anthropicAlertState.workspaceId, anthropicAlertState.billingMonth], + targetWhere: sql`${anthropicAlertState.workspaceId} IS NOT NULL`, + set: { + threshold80FiredAt: sql`excluded.threshold_80_fired_at`, + threshold100FiredAt: sql`excluded.threshold_100_fired_at`, + threshold120FiredAt: sql`excluded.threshold_120_fired_at`, + forecastAtRisk: sql`excluded.forecast_at_risk`, + forecastChangedAt: sql`excluded.forecast_changed_at`, + updatedAt: sql`now()`, + }, + }); + } + + if (defaultRow) { + // ON CONFLICT can't target a partial unique index whose predicate involves + // a NULL column. Update-or-insert with WHERE IS NULL (safe under + // withSyncLock — there is at most one default-workspace row per month). + const updated = await db + .update(anthropicAlertState) + .set({ + threshold80FiredAt: defaultRow.threshold80FiredAt, + threshold100FiredAt: defaultRow.threshold100FiredAt, + threshold120FiredAt: defaultRow.threshold120FiredAt, + forecastAtRisk: defaultRow.forecastAtRisk, + forecastChangedAt: defaultRow.forecastChangedAt, + updatedAt: new Date(), + }) + .where( + sql`${anthropicAlertState.workspaceId} IS NULL AND ${anthropicAlertState.billingMonth} = ${defaultRow.billingMonth}`, + ); + if (updated.rowCount === 0) { + await db.insert(anthropicAlertState).values(toInsertValues(defaultRow)); + } + } +} + +function toInsertValues(row: AlertStateRow) { + return { + workspaceId: row.workspaceId, + billingMonth: row.billingMonth, + threshold80FiredAt: row.threshold80FiredAt, + threshold100FiredAt: row.threshold100FiredAt, + threshold120FiredAt: row.threshold120FiredAt, + forecastAtRisk: row.forecastAtRisk, + forecastChangedAt: row.forecastChangedAt, + }; +} diff --git a/src/lib/teams/types.ts b/src/lib/teams/types.ts new file mode 100644 index 0000000..730a073 --- /dev/null +++ b/src/lib/teams/types.ts @@ -0,0 +1,95 @@ +// Shared types for the Teams alerts module. +// Pure types — safe to import from server-only and (theoretically) client code. + +import type { + DashboardKpis, + SyncStatus, + WorkspaceListItem, +} from "@/types"; +import type { WorkspaceForecast } from "@/lib/anthropic/forecast-workspace"; + +// --------------------------------------------------------------------------- +// Adaptive Card envelope per Workflows webhook contract. +// --------------------------------------------------------------------------- + +export type AdaptiveCard = { + $schema: "http://adaptivecards.io/schemas/adaptive-card.json"; + type: "AdaptiveCard"; + version: "1.4"; + body: unknown[]; + actions?: unknown[]; +}; + +export type CardEnvelope = { + type: "message"; + attachments: Array<{ + contentType: "application/vnd.microsoft.card.adaptive"; + contentUrl: null; + content: AdaptiveCard; + }>; +}; + +// --------------------------------------------------------------------------- +// Card-input shapes — what the evaluator passes to each renderer. +// --------------------------------------------------------------------------- + +export type DigestInput = { + kpis: DashboardKpis; + topWorkspaces: WorkspaceListItem[]; + sync: SyncStatus; + month: string; // "YYYY-MM" + dashboardUrl: string; +}; + +export type ThresholdKey = "threshold_80" | "threshold_100" | "threshold_120"; + +export type BreachInput = { + workspace: WorkspaceListItem; + threshold: ThresholdKey; + forecast: WorkspaceForecast | null; + workspaceUrl: string; + raiseLimitUrl: string; +}; + +export type ForecastInput = { + workspace: WorkspaceListItem; + forecast: WorkspaceForecast; + workspaceUrl: string; +}; + +export type StaleInput = { + sync: SyncStatus; + month: string; + dashboardUrl: string; +}; + +// --------------------------------------------------------------------------- +// State-machine output produced by the evaluator. +// --------------------------------------------------------------------------- + +export type AlertStateRow = { + workspaceId: string | null; + billingMonth: string; + threshold80FiredAt: Date | null; + threshold100FiredAt: Date | null; + threshold120FiredAt: Date | null; + forecastAtRisk: boolean; + forecastChangedAt: Date | null; +}; + +export type ThresholdToFire = { + workspaceId: string | null; + threshold: ThresholdKey; +}; + +export type ForecastEdge = { + workspaceId: string | null; + nextValue: boolean; // true = entered at_risk, false = recovered +}; + +export type AlertDiff = { + thresholdsToFire: ThresholdToFire[]; + forecastEdges: ForecastEdge[]; + // Final row state per workspace, ready to upsert. + rowsToUpsert: AlertStateRow[]; +}; diff --git a/src/lib/teams/webhook.ts b/src/lib/teams/webhook.ts new file mode 100644 index 0000000..c91b96a --- /dev/null +++ b/src/lib/teams/webhook.ts @@ -0,0 +1,76 @@ +// Outbound POST to a Microsoft Teams Workflows incoming webhook. +// +// Honors Retry-After on 429, retries on 412/502/504 per Microsoft's bot rate-limit +// guidance. NEVER logs the webhook URL — the URL is the auth (HMAC-signed Logic +// Apps trigger). On unrecoverable failure, the error message is sanitized. + +import "server-only"; + +import type { CardEnvelope } from "./types"; + +const RETRIABLE_STATUSES = new Set([412, 429, 502, 504]); +const MAX_ATTEMPTS = 3; +const BASE_DELAY_MS = 2_000; +const MAX_DELAY_MS = 20_000; +const JITTER_PCT = 0.2; + +class TeamsWebhookError extends Error { + constructor(message: string, public readonly retriable: boolean, public readonly retryAfterMs?: number) { + super(message); + this.name = "TeamsWebhookError"; + } +} + +/** + * POST one Adaptive Card envelope to a Workflows webhook URL. + * Caller MUST pass the URL (do not read env here — keeps the function testable). + */ +export async function postCard(webhookUrl: string, envelope: CardEnvelope): Promise { + let lastError: TeamsWebhookError | Error | null = null; + + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + try { + const res = await fetch(webhookUrl, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(envelope), + }); + + if (res.ok) return; + + if (RETRIABLE_STATUSES.has(res.status)) { + const retryAfterHeader = res.headers.get("retry-after"); + const retryAfterMs = retryAfterHeader ? Number(retryAfterHeader) * 1000 : undefined; + lastError = new TeamsWebhookError( + `Teams webhook returned ${res.status}`, + true, + retryAfterMs && Number.isFinite(retryAfterMs) ? retryAfterMs : undefined, + ); + } else { + // Non-retriable. Do NOT include the response body in the error — Logic + // Apps sometimes echoes back parts of the request URL, which is auth. + throw new TeamsWebhookError(`Teams webhook returned ${res.status}`, false); + } + } catch (err) { + if (err instanceof TeamsWebhookError && !err.retriable) { + throw err; + } + lastError = + err instanceof TeamsWebhookError + ? err + : new TeamsWebhookError(`Teams webhook network error`, true); + } + + if (attempt === MAX_ATTEMPTS) break; + + const explicit = + lastError instanceof TeamsWebhookError ? lastError.retryAfterMs : undefined; + const backoff = Math.min(BASE_DELAY_MS * 2 ** (attempt - 1), MAX_DELAY_MS); + const jitter = Math.floor((Math.random() * 2 - 1) * JITTER_PCT * backoff); + const delay = Math.max(0, explicit ?? backoff + jitter); + await new Promise((r) => setTimeout(r, delay)); + } + + // Re-throw the final error. + throw lastError ?? new Error("Teams webhook failed (unknown)"); +} diff --git a/tests/shims/server-only.ts b/tests/shims/server-only.ts new file mode 100644 index 0000000..4575b93 --- /dev/null +++ b/tests/shims/server-only.ts @@ -0,0 +1,4 @@ +// Empty shim for the "server-only" package — used in test envs only. +// In production Next.js rejects this import from client code by throwing. +// See vitest.config.ts alias. +export {}; diff --git a/tests/unit/teams/cards.test.ts b/tests/unit/teams/cards.test.ts new file mode 100644 index 0000000..148e92e --- /dev/null +++ b/tests/unit/teams/cards.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from "vitest"; +import { + renderBreachCard, + renderDigestCard, + renderForecastCard, + renderStaleCard, +} from "@/lib/teams/cards"; +import type { WorkspaceForecast } from "@/lib/anthropic/forecast-workspace"; +import type { WorkspaceListItem, DashboardKpis, SyncStatus } from "@/types"; + +const baseWorkspace: WorkspaceListItem = { + workspaceId: "ws-research", + name: "research-claude", + isDefault: false, + isArchived: false, + currentMonthCents: 5_120_40, + limitCents: 5_000_00, + utilizationPct: 102, + displayColor: null, +}; + +const baseForecast: WorkspaceForecast = { + runRate7dCents: 612_00, + runRate30dCents: 180_00, + runRateWoWPct: 240, + projectedMonthEndCents: 8_940_00, + crossesCapOn: "2026-05-28", + status: "at_risk", +}; + +const baseKpis: DashboardKpis = { + totalCents: 18_420_00, + momDeltaCents: 1_500_00, + momDeltaPct: 12, + projectedMonthEndCents: 25_000_00, + workspacesOverEightyCount: 3, + workspacesWithLimitCount: 11, + topOverWorkspaceName: "research-claude", + topOverWorkspaceUtilizationPct: 102, + priorMonthCents: 16_920_00, +}; + +const baseSync: SyncStatus = { + lastSyncedAt: new Date("2026-05-21T13:01:00Z"), + ageMinutes: 1, + isStale: false, +}; + +describe("renderDigestCard", () => { + it("produces a valid Workflows envelope with one Adaptive Card", () => { + const envelope = renderDigestCard({ + kpis: baseKpis, + topWorkspaces: [baseWorkspace], + sync: baseSync, + month: "2026-05", + dashboardUrl: "https://hub.example.com/claude", + }); + expect(envelope.type).toBe("message"); + expect(envelope.attachments).toHaveLength(1); + expect(envelope.attachments[0].contentType).toBe( + "application/vnd.microsoft.card.adaptive", + ); + expect(envelope.attachments[0].content.version).toBe("1.4"); + expect(envelope.attachments[0].content.type).toBe("AdaptiveCard"); + }); + + it("includes month, KPI text, and dashboard link", () => { + const envelope = renderDigestCard({ + kpis: baseKpis, + topWorkspaces: [baseWorkspace], + sync: baseSync, + month: "2026-05", + dashboardUrl: "https://hub.example.com/claude", + }); + const json = JSON.stringify(envelope); + expect(json).toContain("2026-05"); + expect(json).toContain("$18,420"); + expect(json).toContain("3 of 11"); + expect(json).toContain("https://hub.example.com/claude"); + }); + + it("only uses Action.OpenUrl (Workflows webhook constraint)", () => { + const envelope = renderDigestCard({ + kpis: baseKpis, + topWorkspaces: [], + sync: baseSync, + month: "2026-05", + dashboardUrl: "https://hub.example.com/claude", + }); + const actions = envelope.attachments[0].content.actions ?? []; + for (const a of actions) { + expect((a as { type: string }).type).toBe("Action.OpenUrl"); + } + }); +}); + +describe("renderBreachCard", () => { + it("includes workspace name, threshold label, and adjust-limit link", () => { + const envelope = renderBreachCard({ + workspace: baseWorkspace, + threshold: "threshold_100", + forecast: baseForecast, + workspaceUrl: "https://hub.example.com/claude/workspaces/ws-research", + raiseLimitUrl: "https://hub.example.com/claude/workspaces/ws-research#limit", + }); + const json = JSON.stringify(envelope); + expect(json).toContain("research-claude"); + expect(json).toContain("100%"); + expect(json).toContain("$5,120.40"); + expect(json).toContain("over budget"); + expect(json).toContain("ws-research"); + }); + + it("uses attention tone for over-budget thresholds", () => { + const envelope = renderBreachCard({ + workspace: baseWorkspace, + threshold: "threshold_120", + forecast: null, + workspaceUrl: "x", + raiseLimitUrl: "y", + }); + const json = JSON.stringify(envelope); + expect(json).toContain('"style":"attention"'); + }); + + it("uses warning tone for 80% threshold", () => { + const envelope = renderBreachCard({ + workspace: { ...baseWorkspace, utilizationPct: 85, currentMonthCents: 4_250_00 }, + threshold: "threshold_80", + forecast: null, + workspaceUrl: "x", + raiseLimitUrl: "y", + }); + const json = JSON.stringify(envelope); + expect(json).toContain('"style":"warning"'); + }); +}); + +describe("renderForecastCard", () => { + it("includes WoW delta and crosses-cap date when present", () => { + const envelope = renderForecastCard({ + workspace: { ...baseWorkspace, currentMonthCents: 4_200_00, utilizationPct: 84 }, + forecast: baseForecast, + workspaceUrl: "https://hub.example.com/claude/workspaces/ws-research", + }); + const json = JSON.stringify(envelope); + expect(json).toContain("▲ 240% WoW"); + expect(json).toContain("2026-05-28"); + }); + + it("omits crosses-cap when forecast has no date", () => { + const envelope = renderForecastCard({ + workspace: baseWorkspace, + forecast: { ...baseForecast, crossesCapOn: null }, + workspaceUrl: "x", + }); + const json = JSON.stringify(envelope); + expect(json).not.toContain("Crosses 100% on"); + }); +}); + +describe("renderStaleCard", () => { + it("is rendered in attention style with sync age", () => { + const staleSync: SyncStatus = { + lastSyncedAt: new Date("2026-05-21T10:00:00Z"), + ageMinutes: 180, + isStale: true, + }; + const envelope = renderStaleCard({ + sync: staleSync, + month: "2026-05", + dashboardUrl: "https://hub.example.com/claude", + }); + const json = JSON.stringify(envelope); + expect(json).toContain('"style":"attention"'); + expect(json).toContain("stale"); + // fmtAgo delegates to date-fns; assert any relative phrase landed. + expect(json).toMatch(/ago/); + }); +}); diff --git a/tests/unit/teams/evaluator-diff.test.ts b/tests/unit/teams/evaluator-diff.test.ts new file mode 100644 index 0000000..ed5aea1 --- /dev/null +++ b/tests/unit/teams/evaluator-diff.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, it } from "vitest"; +import { computeAlertDiff } from "@/lib/teams/evaluator"; +import type { WorkspaceForecast } from "@/lib/anthropic/forecast-workspace"; +import type { AlertStateRow } from "@/lib/teams/types"; +import type { WorkspaceListItem } from "@/types"; + +const now = new Date("2026-05-21T14:00:00Z"); +const month = "2026-05"; + +function ws(over: Partial = {}): WorkspaceListItem { + return { + workspaceId: "ws-1", + name: "ws-one", + isDefault: false, + isArchived: false, + currentMonthCents: 0, + limitCents: 1_000_00, + utilizationPct: 0, + displayColor: null, + ...over, + }; +} + +function priorRow(over: Partial = {}): AlertStateRow { + return { + workspaceId: "ws-1", + billingMonth: month, + threshold80FiredAt: null, + threshold100FiredAt: null, + threshold120FiredAt: null, + forecastAtRisk: false, + forecastChangedAt: null, + ...over, + }; +} + +const ONTRACK: WorkspaceForecast = { + runRate7dCents: 100, + runRate30dCents: 100, + runRateWoWPct: null, + projectedMonthEndCents: 100, + crossesCapOn: null, + status: "on_track", +}; +const ATRISK: WorkspaceForecast = { ...ONTRACK, status: "at_risk" }; + +describe("computeAlertDiff — thresholds", () => { + it("emits no card for a workspace below 80%", () => { + const diff = computeAlertDiff({ + workspaces: [ws({ utilizationPct: 79, currentMonthCents: 79_000 })], + forecasts: new Map([["ws-1", ONTRACK]]), + priorState: [], + month, + now, + }); + expect(diff.thresholdsToFire).toHaveLength(0); + expect(diff.rowsToUpsert).toHaveLength(0); + }); + + it("fires threshold_80 the first time a workspace crosses 85%", () => { + const diff = computeAlertDiff({ + workspaces: [ws({ utilizationPct: 85, currentMonthCents: 85_000 })], + forecasts: new Map([["ws-1", ONTRACK]]), + priorState: [], + month, + now, + }); + expect(diff.thresholdsToFire).toEqual([ + { workspaceId: "ws-1", threshold: "threshold_80" }, + ]); + expect(diff.rowsToUpsert[0].threshold80FiredAt).toBe(now); + }); + + it("does NOT re-fire threshold_80 if it already fired this month", () => { + const prior = priorRow({ threshold80FiredAt: new Date("2026-05-15T00:00:00Z") }); + const diff = computeAlertDiff({ + workspaces: [ws({ utilizationPct: 85, currentMonthCents: 85_000 })], + forecasts: new Map([["ws-1", ONTRACK]]), + priorState: [prior], + month, + now, + }); + expect(diff.thresholdsToFire).toHaveLength(0); + expect(diff.rowsToUpsert).toHaveLength(0); + }); + + it("fires BOTH threshold_80 and threshold_100 when crossing 0% → 105% in one tick", () => { + const diff = computeAlertDiff({ + workspaces: [ws({ utilizationPct: 105, currentMonthCents: 105_000 })], + forecasts: new Map([["ws-1", ONTRACK]]), + priorState: [], + month, + now, + }); + const fires = diff.thresholdsToFire.map((f) => f.threshold).sort(); + expect(fires).toEqual(["threshold_100", "threshold_80"]); + }); + + it("does NOT re-fire after a workspace falls below 80% and rises again", () => { + const prior = priorRow({ threshold80FiredAt: new Date("2026-05-01T00:00:00Z") }); + // Workspace fell to 60%, now back to 85%. + const diff = computeAlertDiff({ + workspaces: [ws({ utilizationPct: 85, currentMonthCents: 85_000 })], + forecasts: new Map([["ws-1", ONTRACK]]), + priorState: [prior], + month, + now, + }); + expect(diff.thresholdsToFire).toHaveLength(0); + }); + + it("skips threshold evaluation for workspaces with no limit", () => { + const diff = computeAlertDiff({ + workspaces: [ws({ limitCents: null, utilizationPct: null, currentMonthCents: 999_000 })], + forecasts: new Map([["ws-1", ONTRACK]]), + priorState: [], + month, + now, + }); + expect(diff.thresholdsToFire).toHaveLength(0); + }); + + it("skips archived workspaces entirely", () => { + const diff = computeAlertDiff({ + workspaces: [ws({ isArchived: true, utilizationPct: 999, currentMonthCents: 999_000 })], + forecasts: new Map([["ws-1", ONTRACK]]), + priorState: [], + month, + now, + }); + expect(diff.thresholdsToFire).toHaveLength(0); + expect(diff.rowsToUpsert).toHaveLength(0); + }); +}); + +describe("computeAlertDiff — forecast edges", () => { + it("emits edge card when forecast flips on_track → at_risk", () => { + const diff = computeAlertDiff({ + workspaces: [ws({ utilizationPct: 60, currentMonthCents: 60_000 })], + forecasts: new Map([["ws-1", ATRISK]]), + priorState: [priorRow({ forecastAtRisk: false })], + month, + now, + }); + expect(diff.forecastEdges).toEqual([{ workspaceId: "ws-1", nextValue: true }]); + expect(diff.rowsToUpsert[0].forecastAtRisk).toBe(true); + expect(diff.rowsToUpsert[0].forecastChangedAt).toBe(now); + }); + + it("emits edge card when forecast flips at_risk → on_track", () => { + const diff = computeAlertDiff({ + workspaces: [ws({ utilizationPct: 60, currentMonthCents: 60_000 })], + forecasts: new Map([["ws-1", ONTRACK]]), + priorState: [priorRow({ forecastAtRisk: true })], + month, + now, + }); + expect(diff.forecastEdges).toEqual([{ workspaceId: "ws-1", nextValue: false }]); + }); + + it("emits NO edge card while state is stable", () => { + const diff = computeAlertDiff({ + workspaces: [ws({ utilizationPct: 60, currentMonthCents: 60_000 })], + forecasts: new Map([["ws-1", ATRISK]]), + priorState: [priorRow({ forecastAtRisk: true })], + month, + now, + }); + expect(diff.forecastEdges).toHaveLength(0); + }); +}); + +describe("computeAlertDiff — default workspace", () => { + it("uses null workspaceId end-to-end (no string sentinel leak)", () => { + const diff = computeAlertDiff({ + workspaces: [ + ws({ workspaceId: null, name: "Default Workspace", utilizationPct: 85, currentMonthCents: 85_000 }), + ], + forecasts: new Map([["__default__", ONTRACK]]), + priorState: [], + month, + now, + }); + expect(diff.thresholdsToFire).toEqual([ + { workspaceId: null, threshold: "threshold_80" }, + ]); + expect(diff.rowsToUpsert[0].workspaceId).toBe(null); + }); +}); + +describe("computeAlertDiff — month rollover", () => { + it("re-arms thresholds when the prior row is from last month", () => { + // Prior state is for April; we're now evaluating May. The May row doesn't + // exist yet, so even though the workspace is at 85%, the May threshold_80 + // fires. (Caller filters priorState by month — verified below by passing + // an empty array.) + const diff = computeAlertDiff({ + workspaces: [ws({ utilizationPct: 85, currentMonthCents: 85_000 })], + forecasts: new Map([["ws-1", ONTRACK]]), + priorState: [], // caller filtered out the April row + month, + now, + }); + expect(diff.thresholdsToFire).toEqual([ + { workspaceId: "ws-1", threshold: "threshold_80" }, + ]); + }); +}); diff --git a/tests/unit/teams/forecast-workspace.test.ts b/tests/unit/teams/forecast-workspace.test.ts new file mode 100644 index 0000000..735dcaf --- /dev/null +++ b/tests/unit/teams/forecast-workspace.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { forecastWorkspaceMonth } from "@/lib/anthropic/forecast-workspace"; + +const today = new Date("2026-05-15T14:00:00Z"); +const month = "2026-05"; + +function daysBefore(n: number): string { + const d = new Date(today); + d.setDate(d.getDate() - n); + return d.toISOString().slice(0, 10); +} + +function build(daily: number[]): Map { + // daily[0] = oldest, daily[N-1] = today + const m = new Map(); + for (let i = 0; i < daily.length; i++) { + m.set(daysBefore(daily.length - 1 - i), daily[i]); + } + return m; +} + +describe("forecastWorkspaceMonth", () => { + it("returns insufficient_data when < 3 distinct billed days this month", () => { + const f = forecastWorkspaceMonth(build([100_00, 200_00]), month, today, 1000_00); + expect(f.status).toBe("insufficient_data"); + expect(f.crossesCapOn).toBeNull(); + }); + + it("returns on_track when projected EOM is below cap", () => { + const f = forecastWorkspaceMonth(build(Array(14).fill(50_00)), month, today, 5000_00); + expect(f.status).toBe("on_track"); + expect(f.crossesCapOn).toBeNull(); + expect(f.runRate7dCents).toBe(50_00); + }); + + it("returns at_risk and crossesCapOn date when projected to overshoot", () => { + // 14 days × $100 = $1400 MTD. 7-day rate = $100/day. + // 16 days remain × $100 = $1600 → projected EOM $3000. Cap $2000. + // Crosses cap when MTD reaches $2000 → ($2000 - $1400)/$100 = 6 days → May 21. + const f = forecastWorkspaceMonth(build(Array(14).fill(100_00)), month, today, 2000_00); + expect(f.status).toBe("at_risk"); + expect(f.runRate7dCents).toBe(100_00); + expect(f.crossesCapOn).toBe("2026-05-21"); + expect(f.projectedMonthEndCents).toBeGreaterThan(2000_00); + }); + + it("computes week-over-week delta when prior week has spend", () => { + const f = forecastWorkspaceMonth( + build([...Array(7).fill(20_00), ...Array(7).fill(40_00)]), + month, + today, + null, + ); + expect(f.runRate7dCents).toBe(40_00); + expect(f.runRateWoWPct).toBe(100); + }); + + it("returns null WoW when prior week had < $1 of spend", () => { + const f = forecastWorkspaceMonth( + build([...Array(7).fill(0), ...Array(7).fill(40_00)]), + month, + today, + null, + ); + expect(f.runRateWoWPct).toBeNull(); + }); + + it("treats null limitCents as on_track regardless of projection", () => { + const f = forecastWorkspaceMonth(build(Array(14).fill(1_000_00)), month, today, null); + expect(f.status).toBe("on_track"); + expect(f.crossesCapOn).toBeNull(); + }); + + it("returns null crossesCapOn when already over cap (breach card handles it)", () => { + const f = forecastWorkspaceMonth(build(Array(14).fill(300_00)), month, today, 2000_00); + expect(f.status).toBe("at_risk"); + expect(f.crossesCapOn).toBeNull(); + }); +}); diff --git a/tests/unit/teams/format.test.ts b/tests/unit/teams/format.test.ts new file mode 100644 index 0000000..228fd97 --- /dev/null +++ b/tests/unit/teams/format.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { fmtAgo, fmtDeltaPct, fmtMoney, fmtPct } from "@/lib/teams/format"; + +describe("fmtMoney", () => { + it("formats whole-dollar cents without decimals", () => { + expect(fmtMoney(184_2000)).toBe("$18,420"); + expect(fmtMoney(0)).toBe("$0"); + }); +}); + +describe("fmtPct", () => { + it("renders integer percent with sign", () => { + expect(fmtPct(84)).toBe("84%"); + expect(fmtPct(0)).toBe("0%"); + }); + it("dashes null", () => { + expect(fmtPct(null)).toBe("—"); + }); +}); + +describe("fmtDeltaPct", () => { + it("uses up arrow for positive", () => { + expect(fmtDeltaPct(12)).toBe("▲ 12%"); + }); + it("uses down arrow for negative", () => { + expect(fmtDeltaPct(-7)).toBe("▼ 7%"); + }); + it("returns 0% for zero", () => { + expect(fmtDeltaPct(0)).toBe("0%"); + }); + it("dashes null", () => { + expect(fmtDeltaPct(null)).toBe("—"); + }); +}); + +describe("fmtAgo", () => { + // Delegates to date-fns formatDistanceToNow — these tests assert shape, not exact wording + // (date-fns may evolve its phrasing). + it("returns a relative phrase for a recent age", () => { + expect(fmtAgo(2)).toMatch(/ago/); + }); + it("returns 'never' for null", () => { + expect(fmtAgo(null)).toBe("never"); + }); +}); diff --git a/tests/unit/teams/webhook.test.ts b/tests/unit/teams/webhook.test.ts new file mode 100644 index 0000000..7fb7785 --- /dev/null +++ b/tests/unit/teams/webhook.test.ts @@ -0,0 +1,106 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { postCard } from "@/lib/teams/webhook"; +import type { CardEnvelope } from "@/lib/teams/types"; + +const envelope: CardEnvelope = { + type: "message", + attachments: [ + { + contentType: "application/vnd.microsoft.card.adaptive", + contentUrl: null, + content: { + $schema: "http://adaptivecards.io/schemas/adaptive-card.json", + type: "AdaptiveCard", + version: "1.4", + body: [], + }, + }, + ], +}; + +describe("postCard", () => { + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + // Make setTimeout instant so retry sleeps don't slow tests. + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("posts envelope and resolves on 200", async () => { + fetchMock.mockResolvedValue({ ok: true, status: 200, headers: new Headers() }); + await postCard("https://example.com/webhook", envelope); + expect(fetchMock).toHaveBeenCalledOnce(); + const call = fetchMock.mock.calls[0]; + expect(call[0]).toBe("https://example.com/webhook"); + expect(call[1].method).toBe("POST"); + expect(call[1].headers["content-type"]).toBe("application/json"); + const body = JSON.parse(call[1].body); + expect(body.type).toBe("message"); + }); + + it("retries on 429 honoring Retry-After", async () => { + fetchMock + .mockResolvedValueOnce({ + ok: false, + status: 429, + headers: new Headers({ "retry-after": "1" }), + }) + .mockResolvedValueOnce({ ok: true, status: 200, headers: new Headers() }); + + const promise = postCard("https://example.com/webhook", envelope); + await vi.runAllTimersAsync(); + await promise; + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("retries on 502", async () => { + fetchMock + .mockResolvedValueOnce({ ok: false, status: 502, headers: new Headers() }) + .mockResolvedValueOnce({ ok: true, status: 200, headers: new Headers() }); + + const promise = postCard("https://example.com/webhook", envelope); + await vi.runAllTimersAsync(); + await promise; + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("does NOT retry on 400 — gives up immediately", async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 400, + headers: new Headers(), + text: () => Promise.resolve("bad request"), + }); + await expect(postCard("https://example.com/webhook", envelope)).rejects.toThrow( + /400/, + ); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it("never includes the webhook URL in thrown error messages", async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 400, + headers: new Headers(), + text: () => Promise.resolve("err"), + }); + const secretUrl = + "https://prod-03.westeurope.logic.azure.com/workflows/abc?sig=SECRET"; + try { + await postCard(secretUrl, envelope); + expect.fail("should have thrown"); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + expect(msg).not.toContain("SECRET"); + expect(msg).not.toContain("prod-03"); + } + }); +}); diff --git a/vitest.config.integration.mts b/vitest.config.integration.mts index 82fa2fb..8194999 100644 --- a/vitest.config.integration.mts +++ b/vitest.config.integration.mts @@ -1,11 +1,27 @@ import { defineConfig } from "vitest/config"; import tsconfigPaths from "vite-tsconfig-paths"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { config as loadEnv } from "dotenv"; + +// ESM-safe — __dirname is not defined in .mts modules. +const here = path.dirname(fileURLToPath(import.meta.url)); + +// Load .env.local for integration tests that hit the real Neon branch. +// (Unit tests don't need DB env; their config doesn't do this.) +loadEnv({ path: path.join(here, ".env.local"), quiet: true }); export default defineConfig({ plugins: [tsconfigPaths()], + resolve: { + alias: { + "server-only": path.resolve(here, "tests/shims/server-only.ts"), + }, + }, test: { environment: "node", include: ["tests/integration/**/*.test.ts"], testTimeout: 30_000, + hookTimeout: 30_000, }, }); diff --git a/vitest.config.ts b/vitest.config.ts index 726e1dd..f8fc4ba 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,8 +1,16 @@ import { defineConfig } from "vitest/config"; import tsconfigPaths from "vite-tsconfig-paths"; +import path from "node:path"; export default defineConfig({ plugins: [tsconfigPaths()], + resolve: { + alias: { + // server-only is a Next.js marker that throws if imported from client + // code. It has no runtime contents — alias to an empty shim in tests. + "server-only": path.resolve(__dirname, "tests/shims/server-only.ts"), + }, + }, test: { environment: "node", include: ["tests/unit/**/*.test.ts"],