From 816ca09b0b5bf7dd1c22ab9f2202ecceac1e9793 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 3 Jun 2026 08:05:48 +0000 Subject: [PATCH 1/5] docs(034): propose discriminated model for distinguishing ingestion types Analyses how invoice ingestion and license-request ingestion currently share one ingestion_log table, history table, activity feed and settings tab via column overloading, and proposes a kind/source discriminator plus a JSONB details payload and an ingestion-type registry to make each type self-describing and future ingestions cheap to add. --- .../proposal.html | 575 ++++++++++++++++++ 1 file changed, 575 insertions(+) create mode 100644 specs/034-ingestion-types-distinction/proposal.html diff --git a/specs/034-ingestion-types-distinction/proposal.html b/specs/034-ingestion-types-distinction/proposal.html new file mode 100644 index 0000000..1e8ead4 --- /dev/null +++ b/specs/034-ingestion-types-distinction/proposal.html @@ -0,0 +1,575 @@ + + + + + + Distinguishing Ingestion Types — AI Developer Hub + + + +
+ +
+
+ Proposal + spec/034-ingestion-types-distinction + Draft for review +
+

Telling ingestion types apart

+

+ The Hub already ingests two fundamentally different kinds of thing through one shared pipe, and a third class is + on the horizon. Right now they share a table, a history table, an activity feed and a settings tab — and the + columns assume "invoice". This document analyses how the conflation came to be, the concrete problems it + causes today, and proposes a discriminated, future-proof model so each ingestion type carries its own meaning. +

+

Drafted 2026-06-03 · author: T. Studer · status: draft · branch: claude/injection-types-distinction-Xcf9K

+
+ + + + +

1. TL;DR

+ +
+

The problem. ingestion_log was built for invoices, then license requests + (the "new request for a user" flow from Microsoft Forms) were squeezed into the same table by + overloading invoice columns. The Settings → Ingestion history table and the dashboard activity feed both render + invoice semantics (Document ID, Vendor, Amount, "Download document"), so a license request shows up as a half-empty, + partly-wrong invoice row.

+

The proposal. Introduce a first-class ingestion_kind discriminator and an + ingestion_source_type, move type-specific fields into a JSONB details payload, and drive the UI from a + small ingestion-type registry so each kind brings its own columns, badges, label and drill-through link. + Adding a future ingestion type becomes a registry entry, not a schema migration plus four UI edits.

+
+ + +

2. The two ingestion types today

+ +
+
+
Type A

Invoice / document ingestion

+

specs 007 · 023 · 024 · 019

+

A PDF arrives, gets OCR-extracted (vendor, invoice number, date, amount), is stored in R2, run through + the ingestion filters, and — if it passes — linked to a budget period as a billed cost. This adds money to + the budget / expenses.

+

Channels

+
+ manual (UI form) + bulk (multi-file UI) + api (POST /api/invoices/ingest) +
+

Carries

+

filename · vendor · invoice_number · invoice_date · amount_cents · blob_pathname · linked_invoice_id

+
+ +
+
Type B

License-request ingestion

+

spec 032-automation-workflow

+

A Microsoft Form is submitted, Power Automate forwards the JSON to the Hub, which creates a + license_requests row, matches the requester & tool, and posts a Teams adaptive card. This adds a + pending request for a person to get a tool — no money, no budget.

+

Channels

+
+ api (POST /api/license-requests/ingest) +
+

Carries

+

form_response_id · requester_email · requester_name · requested_tool · requested_tier · teams ids

+
+
+ +
+ They share almost nothing. The only common shape is "an external event arrived, we tried to process it, + it succeeded or failed, and we want an audit trail." Everything else — the fields, the drill-through target, the meaning + of the outcomes — differs. Yet both write to one table and render through one component. +
+ + +

3. Where they're conflated

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SurfaceFileWhat it assumesEffect on license requests
Audit tablesrc/lib/db/schema.tsingestion_logInvoice-shaped columnsoverloaded form id stuffed into invoice_number
History tablesettings/ingestion/ingestion-history-table.tsxColumns: Document ID, Vendor, Amount, Downloadmostly blank / wrong vendor "Unknown", amount "-", download broken
Activity feedcomponents/dashboard/admin/activity-timeline.tsx"Invoice ingestions and assignment changes"mislabelled a license request reads as an invoice event
Settings navsettings/settings-nav.tsxA single "Ingestion" tabno separation both kinds in one undifferentiated list
Loggerlib/ingestion-logger.tsLogIngestionParams is invoice-typedcallers must lie about field names to log a non-invoice event
+ +

The history table empty-state copy even says the quiet part out loud — "Documents ingested via manual upload, bulk +upload, or the API ingest endpoint will appear here." License requests are not documents, yet they land in the same list.

+ + +

4. The schema overload (and its bugs)

+ +

The license-request route reuses invoice columns by name. This was a deliberate, documented shortcut — spec 032's +implementation notes call it a "pragmatic overload… if/when this becomes confusing, a rename or a dedicated table is a +follow-up." This proposal is that follow-up.

+ + + + + + + + + +
ColumnBuilt for (Type A)Reused as (Type B)Risk
invoice_numberOCR'd invoice no.form_response_id (idempotency key)semantic "Document ID" column shows a Forms GUID
linked_invoice_idFK → invoices.idlicense_request_idreferential see callout below
outcome = "filtered"passed filter, not budget-linked"deduped (idempotent replay)"semantic same badge, two unrelated meanings
vendor / invoice_date / amount_centscore invoice dataalways NULLcosmetic empty cells
+ +
+ Latent referential bug. ingestion_log.linked_invoice_id is a real foreign key to + invoices.id (onDelete: "set null"). The license-request route writes the new + license_requests.id into it. Two failure modes follow: +
    +
  • If no invoices row happens to share that id, the FK constraint rejects the insert — + the audit log silently loses the event (the write is fire-and-forget / try-catch'd in places).
  • +
  • If an invoices row does share that id, the history table's "Download document" + button links to an unrelated invoice PDF (/api/invoices/<id>/pdf) — a data-leak-shaped bug.
  • +
+ Either way, the column is being used to mean something it cannot safely mean. A discriminator + typed details payload + removes the cross-type FK entirely. +
+ + +

5. Future ingestions on the horizon

+ +

This is not a two-case problem to hard-code around — the Hub is steadily growing inbound pipes. Designing for +n types now is much cheaper than a third overload later.

+ +
+

Already adjacent

+
    +
  • Sync framework (sync_events, spec 019) — GitHub Copilot billing, Anthropic usage, member sync. Today these live in a separate Sync Status tab.
  • +
+
+

Plausible next

+
    +
  • Bulk user import (CSV, spec 011) — today logged to change_history, not surfaced as ingestion.
  • +
  • More Microsoft Forms types (offboarding, tier-change requests).
  • +
+
+

Speculative

+
    +
  • Email-to-ingest for invoices.
  • +
  • Vendor API pulls (seat counts, usage exports).
  • +
+
+
+ +
+ Design tension to resolve (open question Q1). Is "ingestion" = document/event intake (invoices, + forms) while "sync" = scheduled pull from an API (Copilot, Anthropic)? Or should the history view eventually + unify both under one "Data intake" umbrella with a type filter? The model below supports either — sync stays separate + for now, but the registry pattern would let us fold it in without another rewrite. +
+ + +

6. Goals & non-goals

+
+
+

Goals

+
    +
  • Every ingestion record self-describes its type; no column overloading.
  • +
  • History UI shows type-appropriate columns, labels and drill-through.
  • +
  • Adding a new ingestion type is a registry entry, not a schema + 4-file UI change.
  • +
  • Remove the cross-type FK / download bug.
  • +
  • Backward-compatible migration — no lost audit history.
  • +
+
+
+

Non-goals

+
    +
  • Re-architecting the sync_events framework (out of scope; only a future-fold path is noted).
  • +
  • Changing invoice OCR / filter behaviour.
  • +
  • Changing the license-request approval workflow itself (spec 032).
  • +
  • Building email-to-ingest or vendor pulls now — only making them cheap to add later.
  • +
+
+
+ + +

7. Design options

+ +
+

Option A — Discriminator column + JSONB details recommended

+

Keep one ingestion_log table. Add kind (enum) + source_type (enum) + a typed + details JSONB for type-specific fields. Drop the cross-type FK; store drill-through as a polymorphic + (entity_type, entity_id) pair. UI driven by a registry keyed on kind.

+
+
    +
  • One audit pipe, one query for "everything that came in".
  • +
  • New type = enum value + registry entry; no new table.
  • +
  • Common columns (outcome, channel, timestamp, actor) stay first-class & indexable.
  • +
+
    +
  • Type-specific fields are in JSONB — not individually constrained by the DB.
  • +
  • Needs a small typed accessor layer so callers don't hand-write JSON.
  • +
+
+
+ +
+

Option B — One table per ingestion type

+

Split into invoice_ingestion_log, license_request_ingestion_log, … Each fully typed.

+
+
  • Maximum type-safety; real FKs & NOT NULLs per type.
+
    +
  • "Show me all ingestions" becomes a UNION across N tables — exactly the cross-type view the UI needs.
  • +
  • Every new type = new table + migration + new query path. Highest long-run cost.
  • +
+
+
+ +
+

Option C — Leave as-is, just relabel the UI

+

Add an inferred "type" column derived from heuristics (e.g. vendor IS NULL ⇒ license request) and split the UI.

+
+
  • No migration.
+
    +
  • Heuristic is fragile (a real invoice can lack a vendor).
  • +
  • Leaves the FK / download bug in place.
  • +
  • Third type has nothing to disambiguate on.
  • +
+
+
+ + +

8. Recommended model

+ +
+

Option A. A discriminated single table balances "one audit trail / one cross-type + view" (which the history page fundamentally wants) against per-type clarity. JSONB holds the long tail of type-specific + fields, while the columns that every ingestion shares — outcome, channel, actor, timestamps, drill-through — + stay relational and indexed. A registry maps each kind to its UI. This is also the cheapest path to the + n-th ingestion type, which is the explicit ask.

+
+ + +

9. Proposed schema

+ +

New enums

+
// Coarse category — what kind of thing was ingested
+export const ingestionKindEnum = pgEnum("ingestion_kind", [
+  "invoice",            // adds to budget / expenses
+  "license_request",    // a person requesting tool access
+  "user_import",        // future: bulk CSV upsert
+  "other",              // forward-compat escape hatch
+]);
+
+// Fine-grained origin — survives adding sources without touching `kind`
+export const ingestionSourceTypeEnum = pgEnum("ingestion_source_type", [
+  "invoice_pdf",
+  "ms_forms_license_request",
+  "csv_user_import",
+]);
+ +

Generalised ingestion_log

+
export const ingestionLog = pgTable("ingestion_log", {
+  id:           serial("id").primaryKey(),
+
+  // ── NEW: the discriminator ──
+  kind:         ingestionKindEnum("kind").notNull().default("invoice"),
+  sourceType:   ingestionSourceTypeEnum("source_type"),
+
+  // ── shared across every kind ──
+  outcome:      ingestionOutcomeEnum("outcome").notNull(),
+  channel:      ingestionChannelEnum("channel").notNull(),
+  label:        varchar("label", { length: 500 }),  // human-readable headline per row
+  errorMessage: text("error_message"),
+  uploadedBy:   integer("uploaded_by").references(() => users.id, { onDelete: "set null" }),
+  createdAt:    timestamp("created_at").notNull().defaultNow(),
+
+  // ── polymorphic drill-through (replaces the unsafe cross-type FK) ──
+  entityType:   varchar("entity_type", { length: 40 }),   // "invoice" | "license_request" | …
+  entityId:     integer("entity_id"),                     // no DB-level FK across types
+
+  // ── type-specific payload ──
+  details:      jsonb("details").$type<IngestionDetails>(),
+
+  // ── DEPRECATED (kept through migration window, then dropped) ──
+  // filename, vendor, invoiceNumber, invoiceDate, amountCents, blobPathname, linkedInvoiceId
+}, (t) => [
+  index("ingestion_log_kind_idx").on(t.kind),
+  index("ingestion_log_created_idx").on(t.createdAt),
+]);
+ +

Typed details (discriminated union in src/types)

+
type IngestionDetails =
+  | { kind: "invoice"; vendor?: string; invoiceNumber?: string;
+      invoiceDate?: string; amountCents?: number; filename?: string;
+      blobPathname?: string; filterRuleName?: string }
+  | { kind: "license_request"; formResponseId: string;
+      requesterEmail: string; requesterName: string;
+      toolName?: string; tierName?: string; deduped: boolean }
+  | { kind: "user_import"; rowCount: number; created: number;
+      updated: number; skipped: number; failed: number };
+ +
+ Note on outcome. The "filtered" value stays meaningful for invoices. For license + requests, the idempotent-replay case should stop borrowing "filtered" — instead record + outcome: "success" with details.deduped = true, so the badge means one thing again. (Optionally + add a dedicated "duplicate" outcome — see Q3.) +
+ + +

10. Proposed UI

+ +

Settings → Ingestion, with a type segmentation

+

Keep a single "Ingestion" tab but add a segmented control / sub-tabs at the top: All · Invoices · License +requests (driven by the registry, so new kinds appear automatically). A persistent "Kind" column + faceted filter +gives the same power inside "All".

+ +
┌─ Settings ─────────────────────────────────────────────────────────┐
+│  Integrations   Sync Status   Ingestion   License Templates   API   │
+├─────────────────────────────────────────────────────────────────────┤
+│  [ All ]  [ Invoices ]  [ License requests ]          🔍 search…      │
+│                                                                       │
+│  KIND        STATUS   SUMMARY                 SOURCE   WHEN     ↧      │
+ invoice    success  Anthropic · CHF 1,240   bulk     2h ago   📄    │
+ license    success  J. Doe → Copilot Biz    api      3h ago   →     │
+ license    success  K. Li → Cursor (dup)    api      3h ago   →     │
+ invoice    filtered Internal credit note    manual   1d ago   📄    │
+└─────────────────────────────────────────────────────────────────────┘
+ + + + + + + + + + +
Columninvoicelicense_request
Kindcoloured pill from registry (icon + label)
StatusOutcomeBadge — shared
Summary (registry-rendered)vendor · amountrequester → tool/tier
Source / channelshared
Drill-through📄 Download PDF (/api/invoices/:id/pdf)→ Open request (/requests/:id)
+ +

Activity timeline

+

Relabel from "Invoice ingestions…" to a kind-aware line: "{Vendor} invoice ingested" vs +"License request from {requester} for {tool}", each with the registry's icon/severity. One switch (kind), +fed by the same registry.

+ + +

11. The ingestion-type registry

+ +

The keystone of "consider future ingestions." One module declares everything type-specific; every surface reads from it. +Adding a type touches this file (and an enum value) — nothing else structural.

+ +
// src/lib/ingestion/registry.ts
+export const INGESTION_TYPES: Record<IngestionKind, IngestionTypeDef> = {
+  invoice: {
+    label: "Invoice",
+    icon: FileText,
+    accent: "sky",
+    summary: (d) => `${d.vendor ?? "Unknown"} · ${formatCurrency(d.amountCents)}`,
+    drillThrough: (r) => r.entityId ? `/api/invoices/${r.entityId}/pdf` : null,
+    drillLabel: "Download document",
+    columns: ["vendor", "amount", "invoiceDate"],
+  },
+  license_request: {
+    label: "License request",
+    icon: UserPlus,
+    accent: "violet",
+    summary: (d) => `${d.requesterName} → ${d.toolName ?? "?"}`,
+    drillThrough: (r) => r.entityId ? `/requests/${r.entityId}` : null,
+    drillLabel: "Open request",
+    columns: ["requester", "tool", "tier"],
+  },
+  // user_import, other … added here later
+};
+ +
+ Why this pays off. The history table, the activity feed, the faceted filters, and the segmented tabs all + iterate INGESTION_TYPES. A new Forms type or an email-to-ingest pipe becomes: add an enum value, add a + registry entry, write its ingest route. Zero changes to the table component, the feed, or the query. +
+ + +

12. Migration & rollout phases

+ +
P0
+ Schema additive. Add kind, source_type, label, entity_type, + entity_id, details (all nullable / defaulted). Keep legacy columns. pnpm db:generate → + reviewed migration. no behaviour change yet +
+ +
P1
+ Backfill. One-shot UPDATE: rows with a non-null vendor/amount/filenamekind='invoice', + copy fields into details, set entity_type='invoice', entity_id = linked_invoice_id. + Rows that look like license requests (came via the license route — distinguishable by linked_invoice_id pointing + at a license_requests id / null invoice) → kind='license_request'. Heuristic runs once, verified, not at read-time. +
+ +
P2
+ Writers. Generalise logIngestionAttempt into a discriminated API + (logIngestion({ kind, outcome, channel, details, entity })). Update both ingest routes. License route stops + writing the cross-type FK and stops abusing outcome:"filtered" for dedup. +
+ +
P3
+ Read paths & UI. getIngestionHistory returns kind + details; build the + registry; rebuild the history table (segmented), faceted "Kind" filter, and the activity timeline. +
+ +
P4
+ Drop legacy. After a soak window with everything reading from the new fields, drop + vendor / invoice_number / invoice_date / amount_cents / filename / blob_pathname / linked_invoice_id from the + table. Removes the unsafe FK for good. destructive — separate, reviewed migration +
+ +
+ Per repo convention, schema work goes through pnpm db:generate and the Drizzle migration reviewer; the + destructive P4 step in particular should be its own migration with the drizzle-migration-reviewer in the loop, and run + against a Neon branch first. +
+ + +

13. Open questions

+ + + + + + + + + +
#QuestionDefault if undecided
Q1Should "sync" (Copilot/Anthropic pulls) eventually fold into this history under the registry, or stay a separate tab?Keep separate now; registry leaves the door open.
Q2Segmented sub-tabs vs. a single list with a "Kind" faceted filter?Ship both: filter is free, sub-tabs are a thin wrapper over it.
Q3Add a dedicated "duplicate" outcome, or model dedup as success + details.deduped?success + details.deduped (no enum churn).
Q4Is bulk user import (spec 011) in scope as a first ingestion kind, or stays in change_history?Out of scope now; user_import enum reserved for later.
Q5JSONB details vs. promoting the 2–3 most-queried license fields to real columns?JSONB; promote only if a query needs an index.
+ +
+ spec/034-ingestion-types-distinction · proposal.html · drafted 2026-06-03
+ Source surfaces analysed: ingestion_log schema · /api/invoices/ingest · /api/license-requests/ingest · + settings/ingestion/* · components/dashboard/admin/activity-timeline · lib/ingestion-logger · actions/ingestion-log
+ Builds on the documented follow-up noted in spec/032-automation-workflow implementation-notes.html. +
+ +
+ + From 6b13e1da921b4ab124e9f25984b9ef0d91e0e21c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 3 Jun 2026 08:20:58 +0000 Subject: [PATCH 2/5] docs(034): add implementation plan for discriminated ingestion types Resolves all five open questions from the proposal with the default answers (sync stays separate, filter+sub-tabs, no new outcome enum, user_import reserved, JSONB details). Specifies the expand/migrate/ contract schema phases, the type registry, write/read path changes, a file-by-file map and phased task list. UI work uses the in-repo Nothing design system; the plan document itself is rendered in Nothing style (Doto/Space Grotesk/Space Mono, OLED dark). --- .../implementation-plan.html | 520 ++++++++++++++++++ 1 file changed, 520 insertions(+) create mode 100644 specs/034-ingestion-types-distinction/implementation-plan.html diff --git a/specs/034-ingestion-types-distinction/implementation-plan.html b/specs/034-ingestion-types-distinction/implementation-plan.html new file mode 100644 index 0000000..c447134 --- /dev/null +++ b/specs/034-ingestion-types-distinction/implementation-plan.html @@ -0,0 +1,520 @@ + + + + + +Ingestion Types — Implementation Plan + + + + + + +
+ + +
+
+ Implementation Plan + spec/034-ingestion-types-distinction + Ready to build + Nothing UI +
+
AI DEVELOPER HUB / DATA INTAKE
+

Distinguishing
Ingestion Types

+

+ Replace the overloaded single-shape ingestion_log with a discriminated, registry-driven model so + invoices, license requests, and every future intake type carry their own meaning — across the table, the activity + feed, and the settings surface. UI built in the Nothing design system. +

+

PLAN v1 · 2026-06-03 · T. STUDER · BRANCH claude/injection-types-distinction-Xcf9K · BUILDS ON proposal.html

+
+ + + + +

00 Locked decisions

+

All five open questions from the proposal are resolved here with the recommended defaults, so the plan is unambiguous.

+ + + + + + + + + +
QDecisionConsequence
Q1Sync stays separate. The sync_events framework (Copilot / Anthropic pulls) keeps its own Sync Status tab. The registry is built so it could fold in later, but we do not touch it now.scope -1
Q2Filter + sub-tabs both. One list with a faceted Kind filter is the engine; the All / Invoices / License requests segmented control is a thin wrapper that sets that filter.UI
Q3No new outcome enum. Idempotent replays record outcome: "success" + details.deduped = true. The "filtered" badge means one thing again (invoice passed-but-unlinked).no enum churn
Q4User import out of scope. The user_import kind is reserved in the enum but not wired; bulk CSV import stays in change_history for now.scope -1
Q5JSONB details. Type-specific fields live in details JSONB; we promote a field to a real column only if a query needs to index it (none do today).schema
+ + +

01 Target architecture

+
+
+

Before

+
invoice route ─┐
+license route ─┼─► ingestion_log  (invoice-shaped)
+               │      ▲ overloaded cols
+               ▼      │ invoice_number = formId
+        getIngestionHistory   linked_invoice_id = reqId
+               │
+               ▼
+   IngestionHistoryTable (invoice columns)
+        activity-timeline ("invoice")
+
+
+

After

+
invoice route ─┐  logIngestion({kind,…})
+license route ─┼─► ingestion_log (discriminated)
+               │   kind · source_type
+               │   details(jsonb) · entity ref
+               ▼
+        getIngestionHistory → {kind, details}
+               │
+               ▼  reads INGESTION_TYPES registry
+   IngestionHistoryTable (per-kind cols)
+        activity-timeline (per-kind copy)
+
+
+

Three load-bearing ideas: a discriminator (kind) on the row, a typed details payload +(JSONB) for the long tail, and a registry that every UI surface reads so a new type never edits the table, +the feed, or the query.

+ + +

02 Data model & schema

+ +

New enums — src/lib/db/schema.ts

+
export const ingestionKindEnum = pgEnum("ingestion_kind", [
+  "invoice", "license_request", "user_import", "other",
+]);
+export const ingestionSourceTypeEnum = pgEnum("ingestion_source_type", [
+  "invoice_pdf", "ms_forms_license_request", "csv_user_import",
+]);
+// ingestionOutcomeEnum + ingestionChannelEnum unchanged.
+ +

Generalised table (additive — legacy cols retained through P0–P3)

+
export const ingestionLog = pgTable("ingestion_log", {
+  id:           serial("id").primaryKey(),
+  kind:         ingestionKindEnum("kind").notNull().default("invoice"),
+  sourceType:   ingestionSourceTypeEnum("source_type"),
+  outcome:      ingestionOutcomeEnum("outcome").notNull(),
+  channel:      ingestionChannelEnum("channel").notNull(),
+  label:        varchar("label", { length: 500 }),        // headline per row
+  errorMessage: text("error_message"),
+  uploadedBy:   integer("uploaded_by").references(() => users.id, { onDelete: "set null" }),
+  createdAt:    timestamp("created_at").notNull().defaultNow(),
+  entityType:   varchar("entity_type", { length: 40 }),      // polymorphic drill-through
+  entityId:     integer("entity_id"),                       // NO cross-type FK
+  details:      jsonb("details").$type<IngestionDetails>(),
+  // DEPRECATED, dropped in P4:
+  // filename, vendor, invoiceNumber, invoiceDate, amountCents,
+  // blobPathname, linkedInvoiceId
+}, (t) => [
+  index("ingestion_log_kind_idx").on(t.kind),
+  index("ingestion_log_created_idx").on(t.createdAt),
+]);
+ +

Discriminated details type — src/types/ingestion.ts (new)

+
export type IngestionDetails =
+  | { kind: "invoice"; vendor?: string|null; invoiceNumber?: string|null;
+      invoiceDate?: string|null; amountCents?: number|null; filename?: string|null;
+      blobPathname?: string|null; filterRuleName?: string|null }
+  | { kind: "license_request"; formResponseId: string; requesterEmail: string;
+      requesterName: string; toolName?: string|null; tierName?: string|null; deduped: boolean }
+  | { kind: "user_import"; rowCount: number; created: number; updated: number;
+      skipped: number; failed: number };
+ +
+ FIXES — THE LATENT BUG + Dropping linked_invoice_id (a real FK to invoices.id that the license route was writing + license_requests.id into) removes both failure modes from the proposal: silent audit-insert rejection, and + the cross-wired "Download document" link. Drill-through becomes (entity_type, entity_id) resolved by the registry. +
+ + +

03 The type registry

+

One module, the single source of per-kind behaviour. Every surface imports it; nothing else branches on kind ad hoc.

+
// src/lib/ingestion/registry.ts
+import { FileText, UserPlus, Upload, Inbox } from "lucide-react";
+
+export interface IngestionTypeDef {
+  label: string;                 // "Invoice"
+  icon: LucideIcon;              // monoline, inherits text colour
+  /** one-line headline from details */
+  summary: (d: IngestionDetails) => string;
+  /** drill-through href or null */
+  drillThrough: (r: IngestionLogRow) => string | null;
+  drillLabel: string;
+  /** which extra columns this kind contributes to "All" */
+  columns: IngestionColumnId[];
+}
+
+export const INGESTION_TYPES: Record<IngestionKind, IngestionTypeDef> = {
+  invoice: {
+    label: "Invoice", icon: FileText,
+    summary: (d) => d.kind==="invoice" ? `${d.vendor ?? "Unknown"} · ${fmt(d.amountCents)}` : "",
+    drillThrough: (r) => r.entityType==="invoice" && r.entityId ? `/api/invoices/${r.entityId}/pdf` : null,
+    drillLabel: "Download document",
+    columns: ["vendor", "amount", "invoiceDate"],
+  },
+  license_request: {
+    label: "License request", icon: UserPlus,
+    summary: (d) => d.kind==="license_request" ? `${d.requesterName} → ${d.toolName ?? "?"}` : "",
+    drillThrough: (r) => r.entityType==="license_request" && r.entityId ? `/requests/${r.entityId}` : null,
+    drillLabel: "Open request",
+    columns: ["requester", "tool", "tier"],
+  },
+  user_import: { /* reserved — Q4, not wired */ label: "User import", icon: Upload, /* … */ },
+  other:       { label: "Other", icon: Inbox, summary: () => "", drillThrough: () => null, drillLabel: "", columns: [] },
+};
+ + +

04 Write path

+

The invoice-typed logIngestionAttempt becomes a discriminated logIngestion. Old signature kept as a thin deprecated shim during the phase window so nothing breaks mid-migration.

+
// src/lib/ingestion-logger.ts
+export async function logIngestion(p: {
+  kind: IngestionKind;
+  sourceType?: IngestionSourceType;
+  outcome: IngestionOutcome;
+  channel: IngestionChannel;
+  details: IngestionDetails;
+  entity?: { type: string; id: number } | null;
+  errorMessage?: string | null;
+  uploadedBy?: number | null;
+}) {
+  await db.insert(ingestionLog).values({
+    kind: p.kind, sourceType: p.sourceType ?? null,
+    outcome: p.outcome, channel: p.channel,
+    label: buildLabel(p.kind, p.details),      // registry-derived headline
+    details: p.details,
+    entityType: p.entity?.type ?? null, entityId: p.entity?.id ?? null,
+    errorMessage: p.errorMessage ?? null, uploadedBy: p.uploadedBy ?? null,
+  });
+}
+
+

/api/invoices/ingest + actions/invoices.ts

+
    +
  • saveInvoice / saveBulkInvoices call logIngestion({ kind:"invoice", sourceType:"invoice_pdf", details:{kind:"invoice", vendor, invoiceNumber, …}, entity:{type:"invoice", id} }).
  • +
  • "filtered" stays its true invoice meaning.
  • +
+
+

/api/license-requests/ingest

+
    +
  • Stop writing linked_invoice_id.
  • +
  • Stop using outcome:"filtered" for dedup.
  • +
  • Write kind:"license_request", entity:{type:"license_request", id:requestId}, outcome:"success" + details.deduped.
  • +
+
+
+ + +

05 Read path

+
// src/actions/ingestion-log.ts
+export interface IngestionLogRow {
+  id: number; kind: IngestionKind; sourceType: IngestionSourceType | null;
+  outcome: IngestionOutcome; channel: IngestionChannel;
+  label: string | null; errorMessage: string | null;
+  entityType: string | null; entityId: number | null;
+  details: IngestionDetails | null;
+  uploaderName: string | null; createdAt: string;
+}
+// getIngestionHistory: select the new columns; the cross-type users
+// leftJoin stays. Optional `kind?` arg → WHERE kind = … for server-side
+// sub-tab fetches (Q2). Default limit 500, ordered createdAt DESC.
+ + +

06 UI — Nothing style

+

All new UI uses the in-repo Nothing primitives (Card, Table, Badge, Button, +SegmentedBar, StatusText, LoadingState) and tokens from globals.css. Monochrome +canvas; the single red interrupt (--destructive #d71921) is reserved for failed ingestions only.

+ +
+
SETTINGS / INGESTION — WIREFRAME
+
┌──────────────────────────────────────────────────────────────────────┐
+ INGESTION                                    [ ALL ][ INVOICES ][ LICENSE ] 
+│ ──────────────────────────────────────────────────────────────────── │
+│  KIND        STATUS    SUMMARY                  CHANNEL  WHEN      ›    │
+│  ●INVOICE    [SUCCESS]  Anthropic · CHF 1,240    BULK     2h       ↓   │
+│  ●LICENSE    [SUCCESS]  J. Doe → Copilot Biz     API      3h       →   │
+│  ●LICENSE    [SUCCESS]  K. Li → Cursor  (dup)    API      3h       →   │
+│  ●INVOICE    [FILTERED] Internal credit note     MANUAL   1d       ↓   │
+│  ●INVOICE    [FAILED]   Parse error · acme.pdf     API      1d       ⓘ   │
+└──────────────────────────────────────────────────────────────────────┘
+
+ +
+
+

Hierarchy (3-layer rule)

+
    +
  • Primary — the SUMMARY cell (registry summary()), Space Grotesk.
  • +
  • SecondaryKIND pill + STATUS badge.
  • +
  • Tertiary — channel, timestamp, drill icon in Space Mono caps, pushed right.
  • +
+

Component mapping

+ + + + + + +
Kind pill<Badge variant="default"> + Lucide icon
Status<OutcomeBadge> → success/secondary/destructive
Sub-tabs<ToggleGroup> / segmented, sets kind filter
Drill<Button variant="ghost" size="icon"> from registry
Loading<LoadingState label="LOADING"> (no skeletons)
+
+
+

Nothing rules enforced

+
    +
  • Red only on FAILED rows — the one interrupt per screen.
  • +
  • Status colour on the value/badge text, never a row background. No zebra striping.
  • +
  • Labels (KIND, CHANNEL, STATUS) Space Mono ALL CAPS, tracked.
  • +
  • No shadows, no gradients, flat 1px borders, 14px card radius / pill buttons.
  • +
  • No toasts for the filter actions — inline StatusText where feedback is needed.
  • +
+

Activity timeline copy (per kind)

+
    +
  • invoice → "{vendor} invoice ingested · {amount}"
  • +
  • license_request → "License request · {requester} → {tool}"
  • +
+
+
+ +
+ DOC NOTE + This plan document is itself rendered in the Nothing system (Doto display, Space Grotesk body, Space Mono labels; + OLED-black instrument-panel mode; segmented-bar effort gauges below) as a working reference for the build. +
+ + +

07 File-by-file map

+ + + + + + + + + + + + + + + + + +
FileActionPhase
src/lib/db/schema.tsadd 2 enums; add cols to ingestion_log; later drop legacy colsP0 / P4
src/types/ingestion.ts newIngestionDetails union + kind/source/outcome/channel typesP0
drizzle migration (generated)additive SQL (P0); backfill SQL (P1); destructive drop (P4)P0/P1/P4
scripts/backfill-ingestion-kind.ts newone-shot classify + populate kind/details/entityP1
src/lib/ingestion-logger.tslogIngestion() + deprecated shimP2
src/lib/ingestion/registry.ts newINGESTION_TYPES + buildLabel()P2/P3
src/app/api/invoices/ingest/route.tscall logIngestion({kind:"invoice"…})P2
src/actions/invoices.tssaveInvoice/saveBulkInvoices → new loggerP2
src/app/api/license-requests/ingest/route.tskind+entity+deduped; drop FK abuseP2
src/actions/ingestion-log.tsreturn kind+details; optional kind argP3
src/app/settings/ingestion/ingestion-history-table.tsxregistry-driven columns, Kind facet, sub-tabsP3
src/app/settings/ingestion/page.tsxpass kind through; headingsP3
src/components/dashboard/admin/activity-timeline.tsxper-kind copy + icon via registryP3
+ + +

08 Phased task list

+ +
+
RELATIVE EFFORT PER PHASE
+
P0 SCHEMA ADDITIVE + S
+
P1 BACKFILL + M
+
P2 WRITERS + M
+
P3 READ + UI + L
+
P4 DROP LEGACY + S · DESTRUCTIVE
+
+ +

P0 · Schema additive no behaviour change

+
T-001
Add ingestionKindEnum + ingestionSourceTypeEnum.src/lib/db/schema.ts
S
+
T-002
Add columns kind (default 'invoice'), source_type, label, entity_type, entity_id, details + two indexes.src/lib/db/schema.ts
S
+
T-003
Add IngestionDetails union + shared kind/source/outcome/channel types.src/types/ingestion.ts
S
+
T-004
pnpm db:generate → review additive migration (drizzle-migration-reviewer) → apply on a Neon branch.migration
S
+ +

P1 · Backfill

+
T-010
One-shot script: rows with invoice fields → kind='invoice', copy into details, set entity=('invoice', linked_invoice_id).scripts/backfill-ingestion-kind.ts
M
+
T-011
Classify license-request rows (those whose linked_invoice_id resolves to a license_requests id / null invoice + API channel + form-id shaped invoice_number) → kind='license_request', build details, set entity=('license_request', id), normalise dedup rows to success + deduped.script
M
+
T-012
Dry-run report (counts per kind, unclassifiable rows → other); verify on Neon branch before prod.script
S
+ +

P2 · Write path

+
T-020
logIngestion() discriminated API + buildLabel(); keep logIngestionAttempt as deprecated shim.src/lib/ingestion-logger.ts
M
+
T-021
Invoice ingest route + saveInvoice/saveBulkInvoices → new logger with kind:"invoice", entity ref.api/invoices/ingest · actions/invoices.ts
M
+
T-022
License route → kind:"license_request", entity:("license_request",id), success+deduped; remove linked_invoice_id + filtered abuse.api/license-requests/ingest
M
+ +

P3 · Read path + UI Nothing

+
T-030
getIngestionHistory returns kind+details+entity; optional kind filter arg.src/actions/ingestion-log.ts
M
+
T-031
Build INGESTION_TYPES registry (summary, drill-through, icon, columns).src/lib/ingestion/registry.ts
M
+
T-032
Rebuild history table: Kind pill column, registry summary, faceted Kind filter, All/Invoices/License sub-tabs, registry drill button. Nothing primitives only.ingestion-history-table.tsx
L
+
T-033
Activity timeline → per-kind copy + icon + severity via registry.activity-timeline.tsx
M
+
T-034
Page headings/empty-state copy generalised ("Data ingested via …" → kind-aware).settings/ingestion/page.tsx
S
+ +

P4 · Drop legacy destructive · own migration

+
T-040
After soak: drop filename, vendor, invoice_number, invoice_date, amount_cents, blob_pathname, linked_invoice_id. Remove shim. Separate reviewed migration, Neon branch first.schema.ts · migration
S
+ + +

09 Testing

+
+

Unit (Vitest)

    +
  • registry summary() / drillThrough() per kind
  • +
  • buildLabel() for each details variant
  • +
  • logIngestion maps fields correctly
  • +
+

Integration (real DB)

    +
  • invoice ingest → row with kind='invoice', entity set
  • +
  • license ingest (new + replay) → success, deduped flips, no FK write
  • +
  • backfill script idempotent; correct counts
  • +
+

E2E + a11y (Playwright)

    +
  • sub-tabs filter the table
  • +
  • invoice row downloads PDF; license row opens /requests/:id
  • +
  • FAILED row is the only red; contrast holds dark+light
  • +
+
+

Gates: pnpm lint (zero warnings) · pnpm typecheck · pnpm test · pnpm test:integration · pnpm test:e2e.

+ + +

10 Migration safety

+
+ EXPAND → MIGRATE → CONTRACT + P0 adds nullable/defaulted columns (safe to deploy alone). P1 backfills idempotently. P2/P3 switch writers then readers + while legacy columns still exist — full rollback to any prior phase is non-destructive. Only P4 drops columns, gated behind + a soak window and its own reviewed migration. +
+
    +
  • Every schema step: pnpm db:generate → drizzle-migration-reviewer → Neon branch first, never straight to prod.
  • +
  • No data loss: legacy invoice columns are copied into details before being dropped; audit history preserved.
  • +
  • Rollback: revert app to a phase, legacy columns are still populated by the shim through P3.
  • +
  • Out of scope (unchanged): sync_events, OCR/filter logic, license approval workflow, bulk user import.
  • +
+ +
+ SPEC/034-INGESTION-TYPES-DISTINCTION · IMPLEMENTATION-PLAN.HTML · v1 · 2026-06-03
+ NOTHING DESIGN SYSTEM · DARK / INSTRUMENT-PANEL · DOTO + SPACE GROTESK + SPACE MONO
+ RESOLVES Q1–Q5 WITH DEFAULTS · BUILDS ON PROPOSAL.HTML · NEXT: /speckit.tasks +
+ +
+ + From 914fe9cdaabec530eefb5e0278f2c639e0e59bd1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 3 Jun 2026 08:41:14 +0000 Subject: [PATCH 3/5] feat(034): discriminate ingestion types (invoice vs license request) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the expand/migrate phases of the ingestion-types plan so the two ingestion kinds stop sharing one invoice-shaped record. Schema (P0, additive — migration 0023): - new ingestion_kind / ingestion_source_type enums - ingestion_log gains kind, source_type, label, entity_type, entity_id, details(jsonb) + kind index; deprecated invoice columns retained - IngestionDetails discriminated union in src/types/ingestion.ts Write path (P2): - logIngestion() canonical discriminated logger; logIngestionAttempt() kept as a dual-writing deprecated shim so invoice call sites are untouched and legacy columns stay populated during the window - license-request route stops overloading linked_invoice_id and the 'filtered' outcome; dedup is success + details.deduped (closes the latent cross-type FK bug) Read path + UI (P3): - getIngestionHistory returns kind/details/entity - ingestion-type registry (icon, drill-through, label) + server-safe label builder - history table rebuilt: Kind pill, Summary, per-kind drill-through, All/ sub-tabs; Nothing-design primitives - generalised settings + activity-feed copy Backfill (P1): scripts/backfill-ingestion-kind.ts (idempotent, dry-run by default) classifies legacy rows by form_response_id. Tests: +10 unit tests; typecheck + lint clean; 394 existing tests pass. Migration not applied here (no DB in env); P4 column drop deferred. --- scripts/backfill-ingestion-kind.ts | 157 + .../implementation-notes.html | 167 + src/actions/ingestion-log.ts | 43 +- src/app/api/license-requests/ingest/route.ts | 92 +- .../ingestion/ingestion-history-table.tsx | 172 +- src/app/settings/ingestion/page.tsx | 8 +- .../dashboard/admin/activity-timeline.tsx | 4 +- .../db/migrations/0023_perfect_runaways.sql | 9 + src/lib/db/migrations/meta/0023_snapshot.json | 4361 +++++++++++++++++ src/lib/db/migrations/meta/_journal.json | 7 + src/lib/db/schema.ts | 276 +- src/lib/ingestion-logger.ts | 102 +- src/lib/ingestion/labels.ts | 43 + src/lib/ingestion/registry.tsx | 77 + src/types/index.ts | 36 +- src/types/ingestion.ts | 65 + .../ingestion/labels-and-registry.test.ts | 137 + 17 files changed, 5488 insertions(+), 268 deletions(-) create mode 100644 scripts/backfill-ingestion-kind.ts create mode 100644 specs/034-ingestion-types-distinction/implementation-notes.html create mode 100644 src/lib/db/migrations/0023_perfect_runaways.sql create mode 100644 src/lib/db/migrations/meta/0023_snapshot.json create mode 100644 src/lib/ingestion/labels.ts create mode 100644 src/lib/ingestion/registry.tsx create mode 100644 src/types/ingestion.ts create mode 100644 tests/unit/ingestion/labels-and-registry.test.ts diff --git a/scripts/backfill-ingestion-kind.ts b/scripts/backfill-ingestion-kind.ts new file mode 100644 index 0000000..4a4efd8 --- /dev/null +++ b/scripts/backfill-ingestion-kind.ts @@ -0,0 +1,157 @@ +/** + * One-off backfill for the 034-ingestion-types-distinction expand/migrate step. + * + * The additive migration (0023) defaulted every existing ingestion_log row to + * kind='invoice'. This script reclassifies the license-request rows that the + * pre-034 code logged into the same table (via the invoice_number / form + * overload) and populates the new `details`, `label`, `source_type` and + * `entity_*` columns for ALL rows so the new read path renders correctly. + * + * Classification signal (robust): a row is a license request iff its + * invoice_number matches an existing license_requests.form_response_id — form + * response IDs are GUID-like and won't collide with real invoice numbers. + * + * Idempotent: safe to re-run. Dry-run by default; pass --apply to write. + * + * Usage: + * pnpm tsx --env-file=.env.local scripts/backfill-ingestion-kind.ts # dry run + * pnpm tsx --env-file=.env.local scripts/backfill-ingestion-kind.ts --apply # write + */ + +import { config } from "dotenv"; +config({ path: ".env.local" }); + +import { Pool } from "@neondatabase/serverless"; +import { drizzle } from "drizzle-orm/neon-serverless"; +import { eq } from "drizzle-orm"; +import { + ingestionLog, + licenseRequests, + aiTools, + accessTiers, +} from "../src/lib/db/schema"; +import { buildIngestionLabel } from "../src/lib/ingestion/labels"; +import type { IngestionDetails } from "../src/types"; + +async function main() { + const apply = process.argv.includes("--apply"); + const url = process.env.DATABASE_URL; + if (!url) { + console.error("DATABASE_URL is not set"); + process.exit(1); + } + + const pool = new Pool({ connectionString: url, max: 1 }); + const db = drizzle(pool); + + // Map form_response_id -> enriched license request (with tool/tier names). + const lrRows = await db + .select({ + id: licenseRequests.id, + formResponseId: licenseRequests.formResponseId, + requesterEmail: licenseRequests.requesterEmail, + requesterName: licenseRequests.requesterName, + toolName: aiTools.name, + tierName: accessTiers.name, + }) + .from(licenseRequests) + .leftJoin(aiTools, eq(licenseRequests.requestedToolId, aiTools.id)) + .leftJoin(accessTiers, eq(licenseRequests.requestedTierId, accessTiers.id)); + + const byFormId = new Map(lrRows.map((r) => [r.formResponseId, r])); + + const rows = await db + .select({ + id: ingestionLog.id, + outcome: ingestionLog.outcome, + filename: ingestionLog.filename, + vendor: ingestionLog.vendor, + invoiceNumber: ingestionLog.invoiceNumber, + invoiceDate: ingestionLog.invoiceDate, + amountCents: ingestionLog.amountCents, + blobPathname: ingestionLog.blobPathname, + linkedInvoiceId: ingestionLog.linkedInvoiceId, + }) + .from(ingestionLog); + + const counts = { invoice: 0, license_request: 0, normalizedDedup: 0 }; + + for (const row of rows) { + const lr = row.invoiceNumber ? byFormId.get(row.invoiceNumber) : undefined; + + let kind: "invoice" | "license_request"; + let sourceType: "invoice_pdf" | "ms_forms_license_request"; + let details: IngestionDetails; + let entityType: string | null; + let entityId: number | null; + let outcome = row.outcome; + + if (lr) { + kind = "license_request"; + sourceType = "ms_forms_license_request"; + // Pre-034 used outcome='filtered' to mean an idempotent dedup replay. + const deduped = row.outcome === "filtered"; + if (deduped) { + outcome = "success"; // Q3: dedup is a successful, idempotent outcome. + counts.normalizedDedup++; + } + details = { + kind: "license_request", + formResponseId: lr.formResponseId, + requesterEmail: lr.requesterEmail, + requesterName: lr.requesterName, + toolName: lr.toolName, + tierName: lr.tierName, + deduped, + }; + entityType = "license_request"; + entityId = lr.id; + counts.license_request++; + } else { + kind = "invoice"; + sourceType = "invoice_pdf"; + details = { + kind: "invoice", + vendor: row.vendor, + invoiceNumber: row.invoiceNumber, + invoiceDate: row.invoiceDate ? String(row.invoiceDate) : null, + amountCents: row.amountCents, + filename: row.filename, + blobPathname: row.blobPathname, + }; + entityType = row.linkedInvoiceId != null ? "invoice" : null; + entityId = row.linkedInvoiceId; + counts.invoice++; + } + + if (apply) { + await db + .update(ingestionLog) + .set({ + kind, + sourceType, + outcome, + label: buildIngestionLabel(details), + details, + entityType, + entityId, + }) + .where(eq(ingestionLog.id, row.id)); + } + } + + console.log( + `${apply ? "Applied" : "DRY RUN"} — total ${rows.length} rows:`, + counts, + ); + if (!apply) console.log("Re-run with --apply to write changes."); + + await pool.end(); +} + +main() + .then(() => process.exit(0)) + .catch((err) => { + console.error(err); + process.exit(1); + }); diff --git a/specs/034-ingestion-types-distinction/implementation-notes.html b/specs/034-ingestion-types-distinction/implementation-notes.html new file mode 100644 index 0000000..6c3566d --- /dev/null +++ b/specs/034-ingestion-types-distinction/implementation-notes.html @@ -0,0 +1,167 @@ + + + + + + Implementation Notes — Ingestion Types Distinction + + + +
+ +
+

Implementation notes

+

+ Running log of decisions, deviations, trade-offs, and open questions encountered while implementing + implementation-plan.html. Append-only — earlier entries are not retroactively edited. +

+

Started 2026-06-03 · branch: claude/injection-types-distinction-Xcf9K · all five open questions resolved with the plan defaults

+
+ +
Phase 0 — Schema (expand)
+ +
+

DecisionDiscriminator + JSONB details, legacy columns retained

+

src/lib/db/schema.ts · src/types/ingestion.ts · migration 0023_perfect_runaways.sql

+

Added ingestion_kind + ingestion_source_type enums and six columns to ingestion_logkind (NOT NULL DEFAULT 'invoice'), source_type, label, entity_type, entity_id, details (jsonb) — plus ingestion_log_kind_idx. All deprecated invoice columns are kept for now; this is the "expand" step. The default on kind backfills existing rows for free.

+
+ +
+

DecisionIngestionDetails modelled as plain string-literal unions, not derived from the pgEnums

+

src/types/ingestion.ts

+

The schema module imports IngestionDetails (for jsonb().$type<…>()), so deriving the TS kinds from the Drizzle enums would create an import cycle. The union literals are kept in lockstep with the enums by convention (a comment flags it). Requester fields on the license-request variant are optional, because early-failure logging (invalid JSON / schema rejection) happens before the payload is known.

+
+ +
+

VerifyMigration 0023 reviewed — PASS, purely additive

+

drizzle-migration-reviewer

+

Only CREATE TYPE / ADD COLUMN / CREATE INDEX. NOT-NULL kind with a constant default is metadata-only on PG 11+ (no table rewrite). Enums created before the columns that use them. entity_id is intentionally not a FK. One non-blocking note: the kind index is non-concurrent — fine at this table's volume, build with CONCURRENTLY post-deploy if the table is ever large.

+
+ +
Phase 2 — Write path
+ +
+

DecisionlogIngestion() canonical; logIngestionAttempt() kept as a dual-writing shim

+

src/lib/ingestion-logger.ts

+

Rather than churn all ~22 invoice call sites, the old invoice-shaped logIngestionAttempt now maps onto the new discriminated logIngestion with kind:"invoice". For kind:"invoice", logIngestion dual-writes the deprecated columns (vendor, amount_cents, linked_invoice_id, …) so the legacy read path keeps working until P3 flips it. License-request writes deliberately leave those columns null — which is what removes the unsafe FK abuse. The shim + dual-write are deleted in P4.

+
+ +
+

DecisionLicense route: stop the linked_invoice_id / filtered overloads (Q3)

+

src/app/api/license-requests/ingest/route.ts

+

All five log call sites now use logIngestion({ kind:"license_request", … }). The successful path records entity:{type:"license_request", id} (no more writing a license-request id into the invoice FK) and an idempotent replay is outcome:"success" + details.deduped=true instead of borrowing the invoice-only "filtered" outcome. This closes the latent referential bug called out in the proposal.

+
+ +
Phase 3 — Read path + UI (Nothing)
+ +
+

DecisionSplit registry: server-safe labels.ts vs client registry.tsx

+

src/lib/ingestion/labels.ts · src/lib/ingestion/registry.tsx

+

The headline string (buildIngestionLabel) is computed at write time and stored in ingestion_log.label, so it must be importable from the server logger — kept in a pure, React-free module. The UI registry (icons via lucide, drill-through hrefs) lives in registry.tsx. The Summary column just renders the stored label; the registry supplies the Kind pill icon and the per-kind drill-through (invoice → PDF in a new tab; license request → /requests/:id same tab). Built with the in-repo Nothing primitives (Badge outline + icon, Tabs, OutcomeBadge, ghost icon Button).

+
+ +
+

DecisionSub-tabs as the primary kind control (Q2)

+

src/app/settings/ingestion/ingestion-history-table.tsx

+

An All / <present kinds> Tabs strip (only shown when more than one kind is present, derived via presentKinds()) filters the rows client-side before they reach the DataTable. Status stays a faceted filter; search runs over the Summary (label) column.

+
+ +
+

DeviationDropped the standalone Vendor faceted filter

+

ingestion-history-table.tsx

+

The shared DataTable renders every column it's given and has no column-visibility state, so a facet on a hidden vendor column would render an empty column. Since the Summary label for invoices already contains the vendor and the table searches on label, vendor filtering is preserved via search instead. If a dedicated facet is wanted later, DataTable needs column-visibility support first.

+
+ +
+

DeviationActivity feed: only the static description generalised

+

src/components/dashboard/admin/activity-timeline.tsx

+

The per-item titles for the dashboard activity feed are composed upstream in src/actions/dashboard.ts, not in the timeline component. For this pass I generalised the card description ("Invoice ingestions…" → "Ingestions…"). Making each activity item kind-aware (icon + copy via the registry) is a follow-up in the dashboard action — see open question below.

+
+ +
Phase 1 — Backfill
+ +
+

DecisionClassify license rows by invoice_number ∈ license_requests.form_response_id

+

scripts/backfill-ingestion-kind.ts

+

Form-response IDs are GUID-like and won't collide with real invoice numbers, so matching the overloaded invoice_number against existing form_response_ids is a robust signal. Matched rows are reclassified to license_request, enriched with requester/tool/tier from the joined license_requests row, and old filtered dedup rows are normalised to success + deduped. Everything else stays invoice with details/entity populated from the legacy columns. The script is idempotent and dry-run by default (--apply to write).

+
+ +
Verification
+ +
+

VerifyGates green

+

pnpm typecheck clean · pnpm lint 0 warnings · pnpm test 394 existing pass + 10 new unit tests for buildIngestionLabel / registry drill-through / presentKinds. Migration 0023 generated and reviewed.

+
+ +
Open questions / follow-ups
+ +
+

OpenP4 (contract) deferred

+

Dropping the deprecated columns and the logIngestionAttempt shim is intentionally not in this change — it needs a soak window with the new readers live, then its own reviewed, destructive migration (Neon branch first).

+
+ +
+

OpenBackfill not yet run · no DB in this environment

+

This container has no DATABASE_URL, so db:push/db:migrate and the backfill were not executed here. The migration SQL is generated and committed; applying 0023 and running scripts/backfill-ingestion-kind.ts --apply against a Neon branch is the next operational step.

+
+ +
+

OpenActivity-feed items not yet kind-aware

+

Make src/actions/dashboard.ts emit per-kind titles/icons/severity via INGESTION_TYPES so a license request reads as a request event, not an invoice one.

+
+ +
+ spec/034-ingestion-types-distinction · implementation-notes.html · started 2026-06-03 · append-only +
+ +
+ + diff --git a/src/actions/ingestion-log.ts b/src/actions/ingestion-log.ts index f39ee28..88e87cb 100644 --- a/src/actions/ingestion-log.ts +++ b/src/actions/ingestion-log.ts @@ -4,21 +4,29 @@ import { db } from "@/lib/db"; import { ingestionLog, users } from "@/lib/db/schema"; import { desc, eq } from "drizzle-orm"; import { requireAdmin } from "@/lib/auth-helpers"; +import type { + IngestionChannel, + IngestionDetails, + IngestionKind, + IngestionOutcome, + IngestionSourceType, +} from "@/types"; export interface IngestionLogRow { id: number; - filename: string | null; - vendor: string | null; - invoiceNumber: string | null; - invoiceDate: string | null; - amountCents: number | null; - outcome: "success" | "failed" | "filtered"; + kind: IngestionKind; + sourceType: IngestionSourceType | null; + outcome: IngestionOutcome; + channel: IngestionChannel; + label: string | null; errorMessage: string | null; - channel: "manual" | "api" | "bulk"; - blobPathname: string | null; - linkedInvoiceId: number | null; + entityType: string | null; + entityId: number | null; + details: IngestionDetails | null; uploaderName: string | null; createdAt: string; + /** Retained for the vendor facet during the migration window (P3). */ + vendor: string | null; } export async function getIngestionHistory(): Promise< @@ -30,18 +38,18 @@ export async function getIngestionHistory(): Promise< const rows = await db .select({ id: ingestionLog.id, - filename: ingestionLog.filename, - vendor: ingestionLog.vendor, - invoiceNumber: ingestionLog.invoiceNumber, - invoiceDate: ingestionLog.invoiceDate, - amountCents: ingestionLog.amountCents, + kind: ingestionLog.kind, + sourceType: ingestionLog.sourceType, outcome: ingestionLog.outcome, - errorMessage: ingestionLog.errorMessage, channel: ingestionLog.channel, - blobPathname: ingestionLog.blobPathname, - linkedInvoiceId: ingestionLog.linkedInvoiceId, + label: ingestionLog.label, + errorMessage: ingestionLog.errorMessage, + entityType: ingestionLog.entityType, + entityId: ingestionLog.entityId, + details: ingestionLog.details, uploaderName: users.name, createdAt: ingestionLog.createdAt, + vendor: ingestionLog.vendor, }) .from(ingestionLog) .leftJoin(users, eq(ingestionLog.uploadedBy, users.id)) @@ -52,7 +60,6 @@ export async function getIngestionHistory(): Promise< success: true, data: rows.map((r) => ({ ...r, - invoiceDate: r.invoiceDate ? String(r.invoiceDate) : null, createdAt: r.createdAt.toISOString(), })), }; diff --git a/src/app/api/license-requests/ingest/route.ts b/src/app/api/license-requests/ingest/route.ts index 5ab7542..ed87e3f 100644 --- a/src/app/api/license-requests/ingest/route.ts +++ b/src/app/api/license-requests/ingest/route.ts @@ -5,16 +5,11 @@ import { NextRequest, NextResponse } from "next/server"; import { db } from "@/lib/db"; -import { - licenseRequests, - aiTools, - accessTiers, - users, -} from "@/lib/db/schema"; +import { licenseRequests, aiTools, accessTiers, users } from "@/lib/db/schema"; import { eq, sql } from "drizzle-orm"; import { requireBearerSecret } from "@/lib/auth-helpers"; import { licenseRequestIngestSchema } from "@/lib/validators"; -import { logIngestionAttempt } from "@/lib/ingestion-logger"; +import { logIngestion } from "@/lib/ingestion-logger"; import { postLicenseRequestCard } from "@/lib/teams/graph"; export const dynamic = "force-dynamic"; @@ -33,7 +28,10 @@ function hubBaseUrl(): string { } export async function POST(request: NextRequest) { - const authError = requireBearerSecret(request, "LICENSE_REQUEST_INGEST_SECRET"); + const authError = requireBearerSecret( + request, + "LICENSE_REQUEST_INGEST_SECRET", + ); if (authError) return authError; // Body size cap — pre-read on Content-Length, post-read fallback. @@ -59,10 +57,13 @@ export async function POST(request: NextRequest) { } payload = JSON.parse(text); } catch { - await logIngestionAttempt({ + await logIngestion({ + kind: "license_request", + sourceType: "ms_forms_license_request", outcome: "failed", - errorMessage: "Invalid JSON body", channel: "api", + errorMessage: "Invalid JSON body", + details: { kind: "license_request", deduped: false }, }); return NextResponse.json( { success: false, error: "Invalid JSON body" }, @@ -73,11 +74,16 @@ export async function POST(request: NextRequest) { const parsed = licenseRequestIngestSchema.safeParse(payload); if (!parsed.success) { const issue = parsed.error.issues[0]; - const error = issue ? `${issue.path.join(".")}: ${issue.message}` : "Invalid payload"; - await logIngestionAttempt({ + const error = issue + ? `${issue.path.join(".")}: ${issue.message}` + : "Invalid payload"; + await logIngestion({ + kind: "license_request", + sourceType: "ms_forms_license_request", outcome: "failed", - errorMessage: error, channel: "api", + errorMessage: error, + details: { kind: "license_request", deduped: false }, }); return NextResponse.json({ success: false, error }, { status: 400 }); } @@ -118,7 +124,11 @@ export async function POST(request: NextRequest) { toolName = tool[0].name; } else { // Refine guard already enforces this — defensive - return jsonError("toolId or toolName is required", 400, input.formResponseId); + return jsonError( + "toolId or toolName is required", + 400, + input.formResponseId, + ); } // Resolve tier (optional). @@ -188,14 +198,25 @@ export async function POST(request: NextRequest) { }); if (!existing) { console.error("license-requests ingest insert error:", err); - await logIngestionAttempt({ + await logIngestion({ + kind: "license_request", + sourceType: "ms_forms_license_request", outcome: "failed", - errorMessage: "Database insert failed", channel: "api", - invoiceNumber: input.formResponseId, + errorMessage: "Database insert failed", + details: { + kind: "license_request", + formResponseId: input.formResponseId, + requesterEmail: input.requesterEmail, + requesterName: input.requesterName, + deduped: false, + }, }); return NextResponse.json( - { success: false, error: "An unexpected error occurred. Please try again." }, + { + success: false, + error: "An unexpected error occurred. Please try again.", + }, { status: 500 }, ); } @@ -223,14 +244,23 @@ export async function POST(request: NextRequest) { }); } - await logIngestionAttempt({ - outcome: deduped ? "filtered" : "success", - errorMessage: deduped ? "Duplicate form response (idempotent)" : null, + // 034: a dedup replay is a successful, idempotent outcome — recorded as + // `success` with details.deduped, not the invoice-only "filtered" outcome. + await logIngestion({ + kind: "license_request", + sourceType: "ms_forms_license_request", + outcome: "success", channel: "api", - // Repurpose invoiceNumber for the form response key. This is a known - // pragmatic overload of the ingestion_log schema — see implementation-notes. - invoiceNumber: input.formResponseId, - linkedInvoiceId: requestId, + entity: { type: "license_request", id: requestId }, + details: { + kind: "license_request", + formResponseId: input.formResponseId, + requesterEmail: input.requesterEmail, + requesterName: input.requesterName, + toolName, + tierName, + deduped, + }, }); return NextResponse.json( @@ -244,11 +274,17 @@ export async function POST(request: NextRequest) { function jsonError(error: string, status: number, formResponseId?: string) { // Fire-and-forget log — don't block the response on logger failures. - void logIngestionAttempt({ + void logIngestion({ + kind: "license_request", + sourceType: "ms_forms_license_request", outcome: "failed", - errorMessage: error, channel: "api", - invoiceNumber: formResponseId ?? null, + errorMessage: error, + details: { + kind: "license_request", + formResponseId: formResponseId ?? null, + deduped: false, + }, }).catch(() => undefined); return NextResponse.json({ success: false, error }, { status }); } diff --git a/src/app/settings/ingestion/ingestion-history-table.tsx b/src/app/settings/ingestion/ingestion-history-table.tsx index 9b5db25..60346f9 100644 --- a/src/app/settings/ingestion/ingestion-history-table.tsx +++ b/src/app/settings/ingestion/ingestion-history-table.tsx @@ -1,72 +1,66 @@ "use client"; -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { ColumnDef } from "@tanstack/react-table"; import { DataTable, arrayIncludesFilterFn } from "@/components/data-table"; import { DataTableColumnHeader } from "@/components/data-table-column-header"; import { ErrorPopover } from "@/components/error-popover"; import { OutcomeBadge } from "@/components/outcome-badge"; -import { formatCurrency, formatDateTime } from "@/lib/utils"; -import { Download, FileText } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { formatDateTime } from "@/lib/utils"; +import { Inbox } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; +import { INGESTION_TYPES, presentKinds } from "@/lib/ingestion/registry"; import type { IngestionLogRow } from "@/actions/ingestion-log"; +import type { IngestionKind } from "@/types"; const columns: ColumnDef[] = [ { - accessorKey: "outcome", + accessorKey: "kind", header: ({ column }) => ( - - ), - cell: ({ row }) => ( - + ), + cell: ({ row }) => { + const kind = row.getValue("kind"); + const def = INGESTION_TYPES[kind]; + const Icon = def.icon; + return ( + + + {def.label} + + ); + }, filterFn: arrayIncludesFilterFn, }, { - accessorKey: "invoiceNumber", + accessorKey: "outcome", header: ({ column }) => ( - + ), cell: ({ row }) => ( - - {row.getValue("invoiceNumber") ?? ( - - - )} - - ), - }, - { - accessorKey: "vendor", - header: ({ column }) => ( - + ), - cell: ({ row }) => row.getValue("vendor") ?? "Unknown", filterFn: arrayIncludesFilterFn, }, { - accessorKey: "invoiceDate", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const val = row.getValue("invoiceDate") as string | null; - return val ?? -; - }, - }, - { - accessorKey: "amountCents", + accessorKey: "label", header: ({ column }) => ( - + ), cell: ({ row }) => { - const cents = row.getValue("amountCents") as number | null; - return cents != null ? ( - formatCurrency(cents) + const label = row.getValue("label"); + // Fallback for legacy rows logged before 034 populated `label`. + const fallback = row.original.vendor; + const text = label ?? fallback; + return text ? ( + {text} ) : ( - ); @@ -81,11 +75,12 @@ const columns: ColumnDef[] = [ const channel = row.getValue("channel"); return channel.charAt(0).toUpperCase() + channel.slice(1); }, + filterFn: arrayIncludesFilterFn, }, { accessorKey: "uploaderName", header: ({ column }) => ( - + ), cell: ({ row }) => row.getValue("uploaderName") ?? "API", }, @@ -105,19 +100,21 @@ const columns: ColumnDef[] = [ enableSorting: false, }, { - id: "download", + id: "drill", header: "", cell: ({ row }) => { - const linkedInvoiceId = row.original.linkedInvoiceId; - if (!linkedInvoiceId) { + const def = INGESTION_TYPES[row.original.kind]; + const href = def.drillThrough(row.original); + const Icon = def.icon; + if (!href) { return ( - No document available + Nothing to open ); } @@ -126,15 +123,16 @@ const columns: ColumnDef[] = [ - Download document + {def.drillLabel} ); }, @@ -147,50 +145,64 @@ interface IngestionHistoryTableProps { } export function IngestionHistoryTable({ data }: IngestionHistoryTableProps) { - const vendorOptions = useMemo( - () => - [...new Set(data.map((r) => r.vendor ?? "Unknown"))].map((v) => ({ - label: v, - value: v, - })), - [data] + // Sub-tabs (Q2) are the primary control; they set the kind filter. "All" + // keeps every row. The tab set is derived from kinds actually present. + const [tab, setTab] = useState("all"); + + const tabs = useMemo(() => presentKinds(data), [data]); + + const rows = useMemo( + () => (tab === "all" ? data : data.filter((r) => r.kind === tab)), + [data, tab], ); if (data.length === 0) { return (
- +

No ingestion history

- Documents ingested via manual upload, bulk upload, or the API ingest - endpoint will appear here. + Invoices and license requests ingested via the UI or the API endpoints + will appear here.

); } return ( - +
+ {tabs.length > 1 && ( + setTab(v as IngestionKind | "all")} + > + + All + {tabs.map((k) => ( + + {INGESTION_TYPES[k].label} + + ))} + + + )} + +
); } diff --git a/src/app/settings/ingestion/page.tsx b/src/app/settings/ingestion/page.tsx index 1dd7812..748cee7 100644 --- a/src/app/settings/ingestion/page.tsx +++ b/src/app/settings/ingestion/page.tsx @@ -21,10 +21,12 @@ export default async function IngestionSettingsPage() { return (
-

Ingestion

+

+ Ingestion +

- Manage filter rules and view the history of all ingested billing - documents. + Manage filter rules and view the history of every ingestion — invoices + and license requests.

{filtersResult.success ? ( diff --git a/src/components/dashboard/admin/activity-timeline.tsx b/src/components/dashboard/admin/activity-timeline.tsx index 48a5c53..dc985d5 100644 --- a/src/components/dashboard/admin/activity-timeline.tsx +++ b/src/components/dashboard/admin/activity-timeline.tsx @@ -32,9 +32,7 @@ export function ActivityTimeline({ activity }: ActivityTimelineProps) { Recent activity - - Invoice ingestions and assignment changes - + Ingestions and assignment changes diff --git a/src/lib/db/migrations/0023_perfect_runaways.sql b/src/lib/db/migrations/0023_perfect_runaways.sql new file mode 100644 index 0000000..3443d51 --- /dev/null +++ b/src/lib/db/migrations/0023_perfect_runaways.sql @@ -0,0 +1,9 @@ +CREATE TYPE "public"."ingestion_kind" AS ENUM('invoice', 'license_request', 'user_import', 'other');--> statement-breakpoint +CREATE TYPE "public"."ingestion_source_type" AS ENUM('invoice_pdf', 'ms_forms_license_request', 'csv_user_import');--> statement-breakpoint +ALTER TABLE "ingestion_log" ADD COLUMN "kind" "ingestion_kind" DEFAULT 'invoice' NOT NULL;--> statement-breakpoint +ALTER TABLE "ingestion_log" ADD COLUMN "source_type" "ingestion_source_type";--> statement-breakpoint +ALTER TABLE "ingestion_log" ADD COLUMN "label" varchar(500);--> statement-breakpoint +ALTER TABLE "ingestion_log" ADD COLUMN "entity_type" varchar(40);--> statement-breakpoint +ALTER TABLE "ingestion_log" ADD COLUMN "entity_id" integer;--> statement-breakpoint +ALTER TABLE "ingestion_log" ADD COLUMN "details" jsonb;--> statement-breakpoint +CREATE INDEX "ingestion_log_kind_idx" ON "ingestion_log" USING btree ("kind"); \ No newline at end of file diff --git a/src/lib/db/migrations/meta/0023_snapshot.json b/src/lib/db/migrations/meta/0023_snapshot.json new file mode 100644 index 0000000..17c8598 --- /dev/null +++ b/src/lib/db/migrations/meta/0023_snapshot.json @@ -0,0 +1,4361 @@ +{ + "id": "92f572bc-6815-488b-8a2e-6b4b4717e402", + "prevId": "911fa1e9-9a12-4665-9904-b06a4f4ea35f", + "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 + }, + "used_cli": { + "name": "used_cli", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "used_agent": { + "name": "used_agent", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "agent_edit_count": { + "name": "agent_edit_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cli_breakdown": { + "name": "cli_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 + }, + "kind": { + "name": "kind", + "type": "ingestion_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'invoice'" + }, + "source_type": { + "name": "source_type", + "type": "ingestion_source_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "ingestion_outcome", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "channel": { + "name": "channel", + "type": "ingestion_channel", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "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()" + }, + "entity_type": { + "name": "entity_type", + "type": "varchar(40)", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "blob_pathname": { + "name": "blob_pathname", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linked_invoice_id": { + "name": "linked_invoice_id", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "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": {} + }, + "ingestion_log_kind_idx": { + "name": "ingestion_log_kind_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "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" + }, + "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" + } + }, + "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.license_requests": { + "name": "license_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "form_response_id": { + "name": "form_response_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requester_email": { + "name": "requester_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requester_name": { + "name": "requester_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requester_user_id": { + "name": "requester_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "requested_tool_id": { + "name": "requested_tool_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "requested_tier_id": { + "name": "requested_tier_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "form_payload": { + "name": "form_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "teams_team_id": { + "name": "teams_team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "teams_channel_id": { + "name": "teams_channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "teams_parent_message_id": { + "name": "teams_parent_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "teams_chat_id": { + "name": "teams_chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "license_request_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending_review'" + }, + "decided_by": { + "name": "decided_by", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "decided_at": { + "name": "decided_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "decision_note": { + "name": "decision_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_by": { + "name": "completed_by", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "approval_message_md": { + "name": "approval_message_md", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completion_message_md": { + "name": "completion_message_md", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "assignment_id": { + "name": "assignment_id", + "type": "integer", + "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": { + "license_requests_requester_user_id_idx": { + "name": "license_requests_requester_user_id_idx", + "columns": [ + { + "expression": "requester_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "license_requests_requested_tool_id_idx": { + "name": "license_requests_requested_tool_id_idx", + "columns": [ + { + "expression": "requested_tool_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "license_requests_status_idx": { + "name": "license_requests_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "license_requests_decided_by_idx": { + "name": "license_requests_decided_by_idx", + "columns": [ + { + "expression": "decided_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "license_requests_assignment_id_idx": { + "name": "license_requests_assignment_id_idx", + "columns": [ + { + "expression": "assignment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "license_requests_created_at_idx": { + "name": "license_requests_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "license_requests_requester_user_id_users_id_fk": { + "name": "license_requests_requester_user_id_users_id_fk", + "tableFrom": "license_requests", + "tableTo": "users", + "columnsFrom": [ + "requester_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "license_requests_requested_tool_id_ai_tools_id_fk": { + "name": "license_requests_requested_tool_id_ai_tools_id_fk", + "tableFrom": "license_requests", + "tableTo": "ai_tools", + "columnsFrom": [ + "requested_tool_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "license_requests_requested_tier_id_access_tiers_id_fk": { + "name": "license_requests_requested_tier_id_access_tiers_id_fk", + "tableFrom": "license_requests", + "tableTo": "access_tiers", + "columnsFrom": [ + "requested_tier_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "license_requests_decided_by_users_id_fk": { + "name": "license_requests_decided_by_users_id_fk", + "tableFrom": "license_requests", + "tableTo": "users", + "columnsFrom": [ + "decided_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "license_requests_completed_by_users_id_fk": { + "name": "license_requests_completed_by_users_id_fk", + "tableFrom": "license_requests", + "tableTo": "users", + "columnsFrom": [ + "completed_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "license_requests_assignment_id_license_assignments_id_fk": { + "name": "license_requests_assignment_id_license_assignments_id_fk", + "tableFrom": "license_requests", + "tableTo": "license_assignments", + "columnsFrom": [ + "assignment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "license_requests_form_response_id_unique": { + "name": "license_requests_form_response_id_unique", + "nullsNotDistinct": false, + "columns": [ + "form_response_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message_templates": { + "name": "message_templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "tool_id": { + "name": "tool_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tier_id": { + "name": "tier_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "message_template_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "body_md": { + "name": "body_md", + "type": "text", + "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": { + "message_templates_tool_id_idx": { + "name": "message_templates_tool_id_idx", + "columns": [ + { + "expression": "tool_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "message_templates_tier_id_idx": { + "name": "message_templates_tier_id_idx", + "columns": [ + { + "expression": "tier_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "message_templates_tool_default_kind_idx": { + "name": "message_templates_tool_default_kind_idx", + "columns": [ + { + "expression": "tool_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"message_templates\".\"tier_id\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "message_templates_tool_tier_kind_idx": { + "name": "message_templates_tool_tier_kind_idx", + "columns": [ + { + "expression": "tool_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tier_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"message_templates\".\"tier_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "message_templates_tool_id_ai_tools_id_fk": { + "name": "message_templates_tool_id_ai_tools_id_fk", + "tableFrom": "message_templates", + "tableTo": "ai_tools", + "columnsFrom": [ + "tool_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "message_templates_tier_id_access_tiers_id_fk": { + "name": "message_templates_tier_id_access_tiers_id_fk", + "tableFrom": "message_templates", + "tableTo": "access_tiers", + "columnsFrom": [ + "tier_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "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 + }, + "discipline": { + "name": "discipline", + "type": "user_discipline", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'developer'" + }, + "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_kind": { + "name": "ingestion_kind", + "schema": "public", + "values": [ + "invoice", + "license_request", + "user_import", + "other" + ] + }, + "public.ingestion_outcome": { + "name": "ingestion_outcome", + "schema": "public", + "values": [ + "success", + "failed", + "filtered" + ] + }, + "public.ingestion_source_type": { + "name": "ingestion_source_type", + "schema": "public", + "values": [ + "invoice_pdf", + "ms_forms_license_request", + "csv_user_import" + ] + }, + "public.invite_token_status": { + "name": "invite_token_status", + "schema": "public", + "values": [ + "active", + "consumed", + "invalidated" + ] + }, + "public.license_request_status": { + "name": "license_request_status", + "schema": "public", + "values": [ + "pending_review", + "approved", + "rejected", + "completed", + "cancelled" + ] + }, + "public.message_template_kind": { + "name": "message_template_kind", + "schema": "public", + "values": [ + "approval", + "completion" + ] + }, + "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_discipline": { + "name": "user_discipline", + "schema": "public", + "values": [ + "developer", + "conception", + "business" + ] + }, + "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 0f10a4f..bb68f66 100644 --- a/src/lib/db/migrations/meta/_journal.json +++ b/src/lib/db/migrations/meta/_journal.json @@ -162,6 +162,13 @@ "when": 1779449159989, "tag": "0022_serious_tomas", "breakpoints": true + }, + { + "idx": 23, + "version": "7", + "when": 1780475418076, + "tag": "0023_perfect_runaways", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 16532fa..0a51a2c 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -14,7 +14,7 @@ import { jsonb, check, } from "drizzle-orm/pg-core"; -import type { UserPreferences } from "@/types"; +import type { UserPreferences, IngestionDetails } from "@/types"; import { relations, sql } from "drizzle-orm"; // Enums @@ -25,10 +25,7 @@ export const assignmentStatusEnum = pgEnum("assignment_status", [ "active", "inactive", ]); -export const budgetStatusEnum = pgEnum("budget_status", [ - "active", - "archived", -]); +export const budgetStatusEnum = pgEnum("budget_status", ["active", "archived"]); export const periodTypeEnum = pgEnum("period_type", ["monthly", "quarterly"]); export const changeTypeEnum = pgEnum("change_type", [ "created", @@ -72,10 +69,7 @@ export const filterFieldEnum = pgEnum("filter_field", [ "invoice_number", ]); -export const filterModeEnum = pgEnum("filter_mode", [ - "whitelist", - "blacklist", -]); +export const filterModeEnum = pgEnum("filter_mode", ["whitelist", "blacklist"]); // Ingestion log enums (023-ingestion-history) export const ingestionOutcomeEnum = pgEnum("ingestion_outcome", [ @@ -90,6 +84,20 @@ export const ingestionChannelEnum = pgEnum("ingestion_channel", [ "bulk", ]); +// Ingestion type discriminator (034-ingestion-types-distinction) +export const ingestionKindEnum = pgEnum("ingestion_kind", [ + "invoice", + "license_request", + "user_import", + "other", +]); + +export const ingestionSourceTypeEnum = pgEnum("ingestion_source_type", [ + "invoice_pdf", + "ms_forms_license_request", + "csv_user_import", +]); + // Sync framework enums (019-invoice-automations) export const syncSourceTypeEnum = pgEnum("sync_source_type", [ "github_copilot_billing", @@ -152,7 +160,7 @@ export const users = pgTable( uniqueIndex("users_email_idx").on(table.email), index("users_circle_idx").on(table.circle), index("users_status_idx").on(table.status), - ] + ], ); // Invite Tokens @@ -178,7 +186,7 @@ export const inviteTokens = pgTable( uniqueIndex("invite_tokens_active_user_idx") .on(table.userId) .where(sql`${table.status} = 'active'`), - ] + ], ); // AI Tools @@ -197,7 +205,7 @@ export const aiTools = pgTable( (table) => [ uniqueIndex("ai_tools_name_idx").on(table.name), index("ai_tools_vendor_idx").on(table.vendor), - ] + ], ); // Access Tiers @@ -218,7 +226,7 @@ export const accessTiers = pgTable( (table) => [ index("access_tiers_tool_id_idx").on(table.toolId), uniqueIndex("access_tiers_tool_name_idx").on(table.toolId, table.name), - ] + ], ); // License Assignments @@ -253,9 +261,9 @@ export const licenseAssignments = pgTable( index("license_assignments_active_lookup_idx").on( table.userId, table.toolId, - table.status + table.status, ), - ] + ], ); // Annual Budgets @@ -273,7 +281,7 @@ export const annualBudgets = pgTable( (table) => [ uniqueIndex("annual_budgets_fiscal_year_idx").on(table.fiscalYear), index("annual_budgets_status_idx").on(table.status), - ] + ], ); // Budget Periods @@ -296,9 +304,9 @@ export const budgetPeriods = pgTable( index("budget_periods_budget_id_idx").on(table.budgetId), uniqueIndex("budget_periods_budget_period_idx").on( table.budgetId, - table.periodIndex + table.periodIndex, ), - ] + ], ); // Change History @@ -321,7 +329,7 @@ export const changeHistory = pgTable( index("change_history_entity_idx").on(table.entityType, table.entityId), index("change_history_changed_by_idx").on(table.changedBy), index("change_history_created_at_idx").on(table.createdAt), - ] + ], ); // Assignment Comments @@ -343,7 +351,7 @@ export const assignmentComments = pgTable( index("assignment_comments_assignment_id_idx").on(table.assignmentId), index("assignment_comments_author_id_idx").on(table.authorId), index("assignment_comments_created_at_idx").on(table.createdAt), - ] + ], ); // Billed Costs @@ -366,7 +374,7 @@ export const billedCosts = pgTable( (table) => [ index("billed_costs_period_id_idx").on(table.periodId), index("billed_costs_invoice_date_idx").on(table.invoiceDate), - ] + ], ); // Invoices @@ -380,7 +388,7 @@ export const invoices = pgTable( vendor: varchar("vendor", { length: 255 }), linkedBilledCostId: integer("linked_billed_cost_id").references( () => billedCosts.id, - { onDelete: "set null" } + { onDelete: "set null" }, ), blobUrl: text("blob_url").notNull(), blobPathname: text("blob_pathname").notNull(), @@ -395,7 +403,7 @@ export const invoices = pgTable( index("invoices_invoice_number_idx").on(t.invoiceNumber), index("invoices_created_at_idx").on(t.createdAt), index("invoices_linked_billed_cost_id_idx").on(t.linkedBilledCostId), - ] + ], ); // GitHub Connections @@ -415,12 +423,14 @@ export const githubConnections = pgTable( connectedAt: timestamp("connected_at").notNull().defaultNow(), disconnectedAt: timestamp("disconnected_at"), lastSyncAt: timestamp("last_sync_at"), - copilotSyncEnabled: boolean("copilot_sync_enabled").notNull().default(false), + copilotSyncEnabled: boolean("copilot_sync_enabled") + .notNull() + .default(false), copilotSyncSchedule: varchar("copilot_sync_schedule", { length: 50 }) .notNull() .default("daily"), }, - (table) => [index("github_connections_status_idx").on(table.status)] + (table) => [index("github_connections_status_idx").on(table.status)], ); // GitHub Profiles @@ -447,7 +457,7 @@ export const githubProfiles = pgTable( uniqueIndex("github_profiles_user_id_idx").on(table.userId), index("github_profiles_github_id_idx").on(table.githubId), index("github_profiles_github_login_idx").on(table.githubLogin), - ] + ], ); // GitHub Sync Events @@ -482,7 +492,7 @@ export const githubSyncEvents = pgTable( (table) => [ index("github_sync_events_connection_id_idx").on(table.connectionId), index("github_sync_events_triggered_by_idx").on(table.triggeredBy), - ] + ], ); // Copilot Usage Metrics @@ -518,10 +528,10 @@ export const copilotUsageMetrics = pgTable( (table) => [ uniqueIndex("copilot_usage_metrics_connection_date_idx").on( table.connectionId, - table.date + table.date, ), index("copilot_usage_metrics_date_idx").on(table.date), - ] + ], ); // Copilot Billing Snapshots @@ -544,9 +554,9 @@ export const copilotBillingSnapshots = pgTable( (table) => [ uniqueIndex("copilot_billing_snapshots_connection_month_idx").on( table.connectionId, - table.billingMonth + table.billingMonth, ), - ] + ], ); // Anthropic Usage Metrics (daily token usage per user per model) @@ -582,12 +592,14 @@ export const anthropicUsageMetrics = pgTable( uniqueIndex("anthropic_usage_metrics_user_date_model_idx").on( table.userId, table.date, - table.model + table.model, ), index("anthropic_usage_metrics_user_date_idx").on(table.userId, table.date), index("anthropic_usage_metrics_date_idx").on(table.date), - index("anthropic_usage_metrics_pricing_resolved_idx").on(table.pricingResolved), - ] + index("anthropic_usage_metrics_pricing_resolved_idx").on( + table.pricingResolved, + ), + ], ); // Anthropic Sync Status (per-user sync tracking + cached API key ID) @@ -605,7 +617,9 @@ export const anthropicSyncStatus = pgTable( resolvedWorkspaceId: varchar("resolved_workspace_id", { length: 100 }), workspaceSyncCompletedAt: timestamp("workspace_sync_completed_at"), }, - (table) => [uniqueIndex("anthropic_sync_status_user_id_idx").on(table.userId)] + (table) => [ + uniqueIndex("anthropic_sync_status_user_id_idx").on(table.userId), + ], ); // Sync Sources (019-invoice-automations) @@ -619,7 +633,7 @@ export const syncSources = pgTable( createdAt: timestamp("created_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(), }, - (table) => [uniqueIndex("sync_sources_source_type_idx").on(table.sourceType)] + (table) => [uniqueIndex("sync_sources_source_type_idx").on(table.sourceType)], ); // Sync Events (019-invoice-automations) @@ -649,40 +663,64 @@ export const syncEvents = pgTable( index("sync_events_started_at_idx").on(table.startedAt), index("sync_events_source_started_idx").on( table.sourceType, - table.startedAt + table.startedAt, ), - ] + ], ); -// Ingestion Log (023-ingestion-history) +// Ingestion Log (023-ingestion-history; discriminated in 034-ingestion-types-distinction) export const ingestionLog = pgTable( "ingestion_log", { id: serial("id").primaryKey(), + + // ── Discriminator (034) ── + // `kind` classifies what was ingested; `sourceType` records the origin. + // Defaulted to "invoice" so the additive migration backfills cleanly. + kind: ingestionKindEnum("kind").notNull().default("invoice"), + sourceType: ingestionSourceTypeEnum("source_type"), + + // ── Shared across every kind ── + outcome: ingestionOutcomeEnum("outcome").notNull(), + channel: ingestionChannelEnum("channel").notNull(), + label: varchar("label", { length: 500 }), + errorMessage: text("error_message"), + uploadedBy: integer("uploaded_by").references(() => users.id, { + onDelete: "set null", + }), + createdAt: timestamp("created_at").notNull().defaultNow(), + + // ── Polymorphic drill-through (034) ── + // Replaces the unsafe cross-type FK that linked_invoice_id had become. + // Intentionally NOT a DB-level foreign key: entityId may reference + // invoices, license_requests, etc. depending on `kind`. + entityType: varchar("entity_type", { length: 40 }), + entityId: integer("entity_id"), + + // ── Type-specific payload (034) ── + details: jsonb("details").$type(), + + // ── DEPRECATED (034) ── + // Retained through the expand/migrate phases; dropped in the contract + // migration (P4) once all readers consume `details` / entity ref. filename: varchar("filename", { length: 500 }), vendor: varchar("vendor", { length: 255 }), invoiceNumber: varchar("invoice_number", { length: 255 }), invoiceDate: date("invoice_date"), amountCents: integer("amount_cents"), - outcome: ingestionOutcomeEnum("outcome").notNull(), - errorMessage: text("error_message"), - channel: ingestionChannelEnum("channel").notNull(), blobPathname: text("blob_pathname"), linkedInvoiceId: integer("linked_invoice_id").references( () => invoices.id, - { onDelete: "set null" } + { onDelete: "set null" }, ), - uploadedBy: integer("uploaded_by").references(() => users.id, { - onDelete: "set null", - }), - createdAt: timestamp("created_at").notNull().defaultNow(), }, (table) => [ index("ingestion_log_outcome_idx").on(table.outcome), index("ingestion_log_created_at_idx").on(table.createdAt), index("ingestion_log_vendor_idx").on(table.vendor), index("ingestion_log_channel_idx").on(table.channel), - ] + index("ingestion_log_kind_idx").on(table.kind), + ], ); // Anthropic Workspaces (workspace metadata from Anthropic Admin API) @@ -709,7 +747,7 @@ export const anthropicWorkspaces = pgTable( .on(table.isDefault) .where(sql`${table.isDefault} = true`), index("anthropic_workspaces_archived_idx").on(table.isArchived), - ] + ], ); // Anthropic Workspace Costs (daily cost per workspace from cost_report API) @@ -732,8 +770,11 @@ export const anthropicWorkspaceCosts = pgTable( .where(sql`${table.workspaceId} IS NULL`), index("anthropic_workspace_costs_date_idx").on(table.date), index("anthropic_workspace_costs_workspace_id_idx").on(table.workspaceId), - check("anthropic_workspace_costs_cost_cents_check", sql`${table.costCents} >= 0`), - ] + check( + "anthropic_workspace_costs_cost_cents_check", + sql`${table.costCents} >= 0`, + ), + ], ); // Anthropic Workspace Limits (admin-configured monthly spending limits) @@ -753,7 +794,7 @@ export const anthropicWorkspaceLimits = pgTable( uniqueIndex("anthropic_workspace_limits_default_idx") .on(sql`(1)`) .where(sql`${table.workspaceId} IS NULL`), - ] + ], ); // Anthropic Org Config (singleton row, id always = 1) @@ -765,9 +806,7 @@ export const anthropicOrgConfig = pgTable( updatedAt: timestamp("updated_at").notNull().defaultNow(), updatedBy: integer("updated_by").references(() => users.id), }, - (table) => [ - check("anthropic_org_config_id_check", sql`${table.id} = 1`), - ] + (table) => [check("anthropic_org_config_id_check", sql`${table.id} = 1`)], ); // Anthropic Alert State — idempotency ledger for Teams alert posts. @@ -800,9 +839,9 @@ export const anthropicAlertState = pgTable( 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])$'` + sql`${table.billingMonth} ~ '^[0-9]{4}-(0[1-9]|1[0-2])$'`, ), - ] + ], ); // License Requests (032-automation-workflow) @@ -821,7 +860,7 @@ export const licenseRequests = pgTable( .references(() => aiTools.id, { onDelete: "restrict" }), requestedTierId: integer("requested_tier_id").references( () => accessTiers.id, - { onDelete: "set null" } + { onDelete: "set null" }, ), formPayload: jsonb("form_payload") .$type>() @@ -846,7 +885,7 @@ export const licenseRequests = pgTable( completedAt: timestamp("completed_at"), assignmentId: integer("assignment_id").references( () => licenseAssignments.id, - { onDelete: "set null" } + { onDelete: "set null" }, ), createdAt: timestamp("created_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(), @@ -858,7 +897,7 @@ export const licenseRequests = pgTable( index("license_requests_decided_by_idx").on(table.decidedBy), index("license_requests_assignment_id_idx").on(table.assignmentId), index("license_requests_created_at_idx").on(table.createdAt), - ] + ], ); // Message Templates (032-automation-workflow) @@ -890,7 +929,7 @@ export const messageTemplates = pgTable( uniqueIndex("message_templates_tool_tier_kind_idx") .on(table.toolId, table.tierId, table.kind) .where(sql`${table.tierId} IS NOT NULL`), - ] + ], ); // Relations @@ -947,20 +986,23 @@ export const licenseAssignmentsRelations = relations( references: [accessTiers.id], }), comments: many(assignmentComments), - }) + }), ); export const annualBudgetsRelations = relations(annualBudgets, ({ many }) => ({ periods: many(budgetPeriods), })); -export const budgetPeriodsRelations = relations(budgetPeriods, ({ one, many }) => ({ - budget: one(annualBudgets, { - fields: [budgetPeriods.budgetId], - references: [annualBudgets.id], +export const budgetPeriodsRelations = relations( + budgetPeriods, + ({ one, many }) => ({ + budget: one(annualBudgets, { + fields: [budgetPeriods.budgetId], + references: [annualBudgets.id], + }), + billedCosts: many(billedCosts), }), - billedCosts: many(billedCosts), -})); +); export const assignmentCommentsRelations = relations( assignmentComments, @@ -973,7 +1015,7 @@ export const assignmentCommentsRelations = relations( fields: [assignmentComments.authorId], references: [users.id], }), - }) + }), ); export const billedCostsRelations = relations(billedCosts, ({ one, many }) => ({ @@ -1012,7 +1054,7 @@ export const githubConnectionsRelations = relations( syncEvents: many(githubSyncEvents), copilotUsageMetrics: many(copilotUsageMetrics), copilotBillingSnapshots: many(copilotBillingSnapshots), - }) + }), ); export const githubProfilesRelations = relations(githubProfiles, ({ one }) => ({ @@ -1033,7 +1075,7 @@ export const githubSyncEventsRelations = relations( fields: [githubSyncEvents.triggeredBy], references: [users.id], }), - }) + }), ); export const copilotUsageMetricsRelations = relations( @@ -1043,7 +1085,7 @@ export const copilotUsageMetricsRelations = relations( fields: [copilotUsageMetrics.connectionId], references: [githubConnections.id], }), - }) + }), ); export const copilotBillingSnapshotsRelations = relations( @@ -1053,7 +1095,7 @@ export const copilotBillingSnapshotsRelations = relations( fields: [copilotBillingSnapshots.connectionId], references: [githubConnections.id], }), - }) + }), ); export const anthropicUsageMetricsRelations = relations( @@ -1063,7 +1105,7 @@ export const anthropicUsageMetricsRelations = relations( fields: [anthropicUsageMetrics.userId], references: [users.id], }), - }) + }), ); export const anthropicSyncStatusRelations = relations( @@ -1073,7 +1115,7 @@ export const anthropicSyncStatusRelations = relations( fields: [anthropicSyncStatus.userId], references: [users.id], }), - }) + }), ); export const syncEventsRelations = relations(syncEvents, ({ one }) => ({ @@ -1111,7 +1153,7 @@ export const ingestionFilters = pgTable( createdAt: timestamp("created_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(), }, - (table) => [index("ingestion_filters_enabled_idx").on(table.enabled)] + (table) => [index("ingestion_filters_enabled_idx").on(table.enabled)], ); export const ingestionFiltersRelations = relations( @@ -1121,47 +1163,53 @@ export const ingestionFiltersRelations = relations( fields: [ingestionFilters.createdBy], references: [users.id], }), - }) + }), ); // License Requests / Message Templates relations (032-automation-workflow) -export const licenseRequestsRelations = relations(licenseRequests, ({ one }) => ({ - requesterUser: one(users, { - fields: [licenseRequests.requesterUserId], - references: [users.id], - relationName: "license_request_requester", - }), - requestedTool: one(aiTools, { - fields: [licenseRequests.requestedToolId], - references: [aiTools.id], - }), - requestedTier: one(accessTiers, { - fields: [licenseRequests.requestedTierId], - references: [accessTiers.id], - }), - decidedByUser: one(users, { - fields: [licenseRequests.decidedBy], - references: [users.id], - relationName: "license_request_decided_by", - }), - completedByUser: one(users, { - fields: [licenseRequests.completedBy], - references: [users.id], - relationName: "license_request_completed_by", - }), - assignment: one(licenseAssignments, { - fields: [licenseRequests.assignmentId], - references: [licenseAssignments.id], +export const licenseRequestsRelations = relations( + licenseRequests, + ({ one }) => ({ + requesterUser: one(users, { + fields: [licenseRequests.requesterUserId], + references: [users.id], + relationName: "license_request_requester", + }), + requestedTool: one(aiTools, { + fields: [licenseRequests.requestedToolId], + references: [aiTools.id], + }), + requestedTier: one(accessTiers, { + fields: [licenseRequests.requestedTierId], + references: [accessTiers.id], + }), + decidedByUser: one(users, { + fields: [licenseRequests.decidedBy], + references: [users.id], + relationName: "license_request_decided_by", + }), + completedByUser: one(users, { + fields: [licenseRequests.completedBy], + references: [users.id], + relationName: "license_request_completed_by", + }), + assignment: one(licenseAssignments, { + fields: [licenseRequests.assignmentId], + references: [licenseAssignments.id], + }), }), -})); +); -export const messageTemplatesRelations = relations(messageTemplates, ({ one }) => ({ - tool: one(aiTools, { - fields: [messageTemplates.toolId], - references: [aiTools.id], - }), - tier: one(accessTiers, { - fields: [messageTemplates.tierId], - references: [accessTiers.id], +export const messageTemplatesRelations = relations( + messageTemplates, + ({ one }) => ({ + tool: one(aiTools, { + fields: [messageTemplates.toolId], + references: [aiTools.id], + }), + tier: one(accessTiers, { + fields: [messageTemplates.tierId], + references: [accessTiers.id], + }), }), -})); +); diff --git a/src/lib/ingestion-logger.ts b/src/lib/ingestion-logger.ts index cc343a7..e64166e 100644 --- a/src/lib/ingestion-logger.ts +++ b/src/lib/ingestion-logger.ts @@ -2,33 +2,109 @@ import "server-only"; import { db } from "@/lib/db"; import { ingestionLog } from "@/lib/db/schema"; +import { buildIngestionLabel } from "@/lib/ingestion/labels"; +import type { + IngestionChannel, + IngestionDetails, + IngestionKind, + IngestionOutcome, + IngestionSourceType, +} from "@/types"; export interface LogIngestionParams { + kind: IngestionKind; + sourceType?: IngestionSourceType | null; + outcome: IngestionOutcome; + channel: IngestionChannel; + details: IngestionDetails; + /** Polymorphic drill-through target — NOT a DB foreign key. */ + entity?: { type: string; id: number } | null; + errorMessage?: string | null; + uploadedBy?: number | null; +} + +/** + * Canonical, discriminated ingestion logger (034). Writes the new + * kind/source/label/details/entity columns and — during the expand/migrate + * window — dual-writes the deprecated invoice columns for `kind: "invoice"` + * so the legacy read path keeps working until P3 switches it over. The + * deprecated columns (and this dual-write) are removed in P4. + */ +export async function logIngestion(params: LogIngestionParams) { + const { details } = params; + + // Legacy dual-write: only invoices ever populated these columns. License + // requests deliberately leave them null (this is what removes the unsafe + // linked_invoice_id FK abuse from the old code path). + const legacy = + details.kind === "invoice" + ? { + filename: details.filename ?? null, + vendor: details.vendor ?? null, + invoiceNumber: details.invoiceNumber ?? null, + invoiceDate: details.invoiceDate ?? null, + amountCents: details.amountCents ?? null, + blobPathname: details.blobPathname ?? null, + linkedInvoiceId: + params.entity?.type === "invoice" ? params.entity.id : null, + } + : {}; + + await db.insert(ingestionLog).values({ + kind: params.kind, + sourceType: params.sourceType ?? null, + outcome: params.outcome, + channel: params.channel, + label: buildIngestionLabel(details), + details, + entityType: params.entity?.type ?? null, + entityId: params.entity?.id ?? null, + errorMessage: params.errorMessage ?? null, + uploadedBy: params.uploadedBy ?? null, + ...legacy, + }); +} + +/** + * @deprecated Invoice-shaped shim retained so existing invoice call sites keep + * working during the migration. Maps the old params onto {@link logIngestion} + * with `kind: "invoice"`. New code should call `logIngestion` directly. + * Removed in P4 alongside the deprecated columns. + */ +export interface LegacyLogIngestionParams { filename?: string | null; vendor?: string | null; invoiceNumber?: string | null; invoiceDate?: string | null; amountCents?: number | null; - outcome: "success" | "failed" | "filtered"; + outcome: IngestionOutcome; errorMessage?: string | null; - channel: "manual" | "api" | "bulk"; + channel: IngestionChannel; blobPathname?: string | null; linkedInvoiceId?: number | null; uploadedBy?: number | null; } -export async function logIngestionAttempt(params: LogIngestionParams) { - await db.insert(ingestionLog).values({ - filename: params.filename ?? null, - vendor: params.vendor ?? null, - invoiceNumber: params.invoiceNumber ?? null, - invoiceDate: params.invoiceDate ?? null, - amountCents: params.amountCents ?? null, +export async function logIngestionAttempt(params: LegacyLogIngestionParams) { + await logIngestion({ + kind: "invoice", + sourceType: "invoice_pdf", outcome: params.outcome, - errorMessage: params.errorMessage ?? null, channel: params.channel, - blobPathname: params.blobPathname ?? null, - linkedInvoiceId: params.linkedInvoiceId ?? null, - uploadedBy: params.uploadedBy ?? null, + errorMessage: params.errorMessage, + uploadedBy: params.uploadedBy, + entity: + params.linkedInvoiceId != null + ? { type: "invoice", id: params.linkedInvoiceId } + : null, + details: { + kind: "invoice", + filename: params.filename ?? null, + vendor: params.vendor ?? null, + invoiceNumber: params.invoiceNumber ?? null, + invoiceDate: params.invoiceDate ?? null, + amountCents: params.amountCents ?? null, + blobPathname: params.blobPathname ?? null, + }, }); } diff --git a/src/lib/ingestion/labels.ts b/src/lib/ingestion/labels.ts new file mode 100644 index 0000000..852d782 --- /dev/null +++ b/src/lib/ingestion/labels.ts @@ -0,0 +1,43 @@ +// Ingestion display helpers (034-ingestion-types-distinction) +// +// Pure, dependency-light helpers shared by the server (logger writes the +// headline into ingestion_log.label) and the client (registry / table). No +// React, no "server-only" — safe to import from either side. + +import { formatCurrency } from "@/lib/utils"; +import type { IngestionDetails, IngestionKind } from "@/types"; + +// Human-readable name for each kind. Plain strings (no icons) so this stays +// importable from server code. +export const INGESTION_KIND_LABELS: Record = { + invoice: "Invoice", + license_request: "License request", + user_import: "User import", + other: "Other", +}; + +/** + * Build the one-line headline stored in `ingestion_log.label` and shown in the + * history table's Summary column and the activity feed. Derived from the typed + * details payload so every surface reads the same string. + */ +export function buildIngestionLabel(details: IngestionDetails): string { + switch (details.kind) { + case "invoice": { + const parts: string[] = []; + if (details.vendor) parts.push(details.vendor); + if (details.amountCents != null) + parts.push(formatCurrency(details.amountCents)); + if (parts.length > 0) return parts.join(" · "); + return details.invoiceNumber ?? details.filename ?? "Invoice"; + } + case "license_request": { + const who = details.requesterName ?? details.requesterEmail ?? "Unknown"; + const tool = details.toolName ?? "tool"; + const base = `${who} → ${tool}`; + return details.deduped ? `${base} (duplicate)` : base; + } + case "user_import": + return `${details.rowCount} rows · +${details.created} / ~${details.updated}`; + } +} diff --git a/src/lib/ingestion/registry.tsx b/src/lib/ingestion/registry.tsx new file mode 100644 index 0000000..82bdcd4 --- /dev/null +++ b/src/lib/ingestion/registry.tsx @@ -0,0 +1,77 @@ +// Ingestion-type registry (034-ingestion-types-distinction) +// +// Single source of per-kind UI behaviour. Every ingestion surface — the +// history table, sub-tabs, and (later) the activity feed — reads from this map +// so adding a new ingestion type is a registry entry, not edits scattered +// across components. Pairs with `labels.ts` (server-safe headline strings). + +import { + FileText, + UserPlus, + Upload, + Inbox, + type LucideIcon, +} from "lucide-react"; +import { INGESTION_KIND_LABELS } from "@/lib/ingestion/labels"; +import type { IngestionKind } from "@/types"; +import type { IngestionLogRow } from "@/actions/ingestion-log"; + +export interface IngestionTypeDef { + /** Human-readable kind name (from labels.ts). */ + label: string; + /** Monoline icon — inherits text colour, never filled. */ + icon: LucideIcon; + /** Drill-through href for a row, or null when there's nothing to open. */ + drillThrough: (row: IngestionLogRow) => string | null; + /** Tooltip / aria label for the drill-through control. */ + drillLabel: string; + /** Whether the drill-through opens in a new tab (downloads / external). */ + drillNewTab: boolean; +} + +export const INGESTION_TYPES: Record = { + invoice: { + label: INGESTION_KIND_LABELS.invoice, + icon: FileText, + drillThrough: (row) => + row.entityType === "invoice" && row.entityId + ? `/api/invoices/${row.entityId}/pdf` + : null, + drillLabel: "Download document", + drillNewTab: true, + }, + license_request: { + label: INGESTION_KIND_LABELS.license_request, + icon: UserPlus, + drillThrough: (row) => + row.entityType === "license_request" && row.entityId + ? `/requests/${row.entityId}` + : null, + drillLabel: "Open request", + drillNewTab: false, + }, + user_import: { + // Reserved (Q4) — not wired into any writer yet. + label: INGESTION_KIND_LABELS.user_import, + icon: Upload, + drillThrough: () => null, + drillLabel: "", + drillNewTab: false, + }, + other: { + label: INGESTION_KIND_LABELS.other, + icon: Inbox, + drillThrough: () => null, + drillLabel: "", + drillNewTab: false, + }, +}; + +/** Kinds that have at least one row in the data, for building the sub-tabs. */ +export function presentKinds(rows: IngestionLogRow[]): IngestionKind[] { + const seen = new Set(); + for (const r of rows) seen.add(r.kind); + return (Object.keys(INGESTION_TYPES) as IngestionKind[]).filter((k) => + seen.has(k), + ); +} diff --git a/src/types/index.ts b/src/types/index.ts index 5d4f6f1..18facf9 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -21,6 +21,9 @@ import type { anthropicOrgConfig, } from "@/lib/db/schema"; +// Ingestion type system (034-ingestion-types-distinction) +export * from "./ingestion"; + // Action result type export type ActionResult = | { success: true; data: T; warning?: string } @@ -351,7 +354,6 @@ export interface SyncPreview { rateLimitRemaining: number; } - // GitHub member sync — manual matching types export type PendingResolution = | { type: "import"; githubLogin: string } @@ -388,9 +390,15 @@ export interface ResolutionSummary { export type CopilotSyncType = "members" | "copilot"; export type CopilotUsageMetric = InferSelectModel; -export type NewCopilotUsageMetric = InferInsertModel; -export type CopilotBillingSnapshot = InferSelectModel; -export type NewCopilotBillingSnapshot = InferInsertModel; +export type NewCopilotUsageMetric = InferInsertModel< + typeof copilotUsageMetrics +>; +export type CopilotBillingSnapshot = InferSelectModel< + typeof copilotBillingSnapshots +>; +export type NewCopilotBillingSnapshot = InferInsertModel< + typeof copilotBillingSnapshots +>; export type CopilotSyncStatus = { enabled: boolean; @@ -537,11 +545,21 @@ export type ProfileData = { // 018-claude-global-metrics types export type AnthropicWorkspace = InferSelectModel; -export type NewAnthropicWorkspace = InferInsertModel; -export type AnthropicWorkspaceCost = InferSelectModel; -export type NewAnthropicWorkspaceCost = InferInsertModel; -export type AnthropicWorkspaceLimit = InferSelectModel; -export type NewAnthropicWorkspaceLimit = InferInsertModel; +export type NewAnthropicWorkspace = InferInsertModel< + typeof anthropicWorkspaces +>; +export type AnthropicWorkspaceCost = InferSelectModel< + typeof anthropicWorkspaceCosts +>; +export type NewAnthropicWorkspaceCost = InferInsertModel< + typeof anthropicWorkspaceCosts +>; +export type AnthropicWorkspaceLimit = InferSelectModel< + typeof anthropicWorkspaceLimits +>; +export type NewAnthropicWorkspaceLimit = InferInsertModel< + typeof anthropicWorkspaceLimits +>; export type AnthropicOrgConfig = InferSelectModel; export type NewAnthropicOrgConfig = InferInsertModel; diff --git a/src/types/ingestion.ts b/src/types/ingestion.ts new file mode 100644 index 0000000..9df0a16 --- /dev/null +++ b/src/types/ingestion.ts @@ -0,0 +1,65 @@ +// Ingestion type system (034-ingestion-types-distinction) +// +// The ingestion_log table is discriminated by `kind`. Type-specific fields +// live in the JSONB `details` payload, modelled here as a discriminated union +// so call sites and the UI registry get exhaustive, compile-time-checked +// access. These are plain string-literal unions (not derived from the Drizzle +// pgEnums) to avoid a runtime import cycle with the schema module — the values +// are kept in lockstep with `ingestionKindEnum` / `ingestionSourceTypeEnum`. + +export type IngestionKind = + | "invoice" + | "license_request" + | "user_import" + | "other"; + +export type IngestionSourceType = + | "invoice_pdf" + | "ms_forms_license_request" + | "csv_user_import"; + +export type IngestionOutcome = "success" | "failed" | "filtered"; + +export type IngestionChannel = "manual" | "api" | "bulk"; + +// Invoice / document ingestion — adds to budget / expenses. +export interface InvoiceIngestionDetails { + kind: "invoice"; + vendor?: string | null; + invoiceNumber?: string | null; + invoiceDate?: string | null; + amountCents?: number | null; + filename?: string | null; + blobPathname?: string | null; + /** Name of the filter rule that matched, when the outcome is "filtered". */ + filterRuleName?: string | null; +} + +// License-request ingestion — a person requesting tool access (MS Forms). +// Requester fields are optional because early-failure logging (invalid JSON, +// schema rejection) happens before the payload is known. +export interface LicenseRequestIngestionDetails { + kind: "license_request"; + formResponseId?: string | null; + requesterEmail?: string | null; + requesterName?: string | null; + toolName?: string | null; + tierName?: string | null; + /** True when this was an idempotent replay of an already-seen form response. */ + deduped: boolean; +} + +// Bulk user import — reserved (Q4), not yet wired. +export interface UserImportIngestionDetails { + kind: "user_import"; + rowCount: number; + created: number; + updated: number; + skipped: number; + failed: number; +} + +export type IngestionDetails = + | InvoiceIngestionDetails + | LicenseRequestIngestionDetails + | UserImportIngestionDetails; diff --git a/tests/unit/ingestion/labels-and-registry.test.ts b/tests/unit/ingestion/labels-and-registry.test.ts new file mode 100644 index 0000000..d33ba13 --- /dev/null +++ b/tests/unit/ingestion/labels-and-registry.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect } from "vitest"; +import { + buildIngestionLabel, + INGESTION_KIND_LABELS, +} from "@/lib/ingestion/labels"; +import { INGESTION_TYPES, presentKinds } from "@/lib/ingestion/registry"; +import type { IngestionLogRow } from "@/actions/ingestion-log"; + +describe("buildIngestionLabel", () => { + it("renders vendor · amount for an invoice", () => { + expect( + buildIngestionLabel({ + kind: "invoice", + vendor: "Anthropic", + amountCents: 124000, + }), + ).toBe("Anthropic · $1,240.00"); + }); + + it("falls back to invoice number then filename then 'Invoice'", () => { + expect( + buildIngestionLabel({ kind: "invoice", invoiceNumber: "INV-9" }), + ).toBe("INV-9"); + expect(buildIngestionLabel({ kind: "invoice", filename: "a.pdf" })).toBe( + "a.pdf", + ); + expect(buildIngestionLabel({ kind: "invoice" })).toBe("Invoice"); + }); + + it("renders requester → tool for a license request", () => { + expect( + buildIngestionLabel({ + kind: "license_request", + requesterName: "J. Doe", + toolName: "Copilot Business", + deduped: false, + }), + ).toBe("J. Doe → Copilot Business"); + }); + + it("marks duplicate (idempotent replay) license requests", () => { + expect( + buildIngestionLabel({ + kind: "license_request", + requesterName: "K. Li", + toolName: "Cursor", + deduped: true, + }), + ).toBe("K. Li → Cursor (duplicate)"); + }); + + it("uses email then 'Unknown' / 'tool' fallbacks for license requests", () => { + expect( + buildIngestionLabel({ + kind: "license_request", + requesterEmail: "x@y.com", + deduped: false, + }), + ).toBe("x@y.com → tool"); + expect( + buildIngestionLabel({ kind: "license_request", deduped: false }), + ).toBe("Unknown → tool"); + }); +}); + +describe("INGESTION_TYPES registry", () => { + const row = (over: Partial): IngestionLogRow => ({ + id: 1, + kind: "invoice", + sourceType: null, + outcome: "success", + channel: "api", + label: null, + errorMessage: null, + entityType: null, + entityId: null, + details: null, + uploaderName: null, + createdAt: new Date().toISOString(), + vendor: null, + ...over, + }); + + it("covers every kind label", () => { + for (const k of Object.keys(INGESTION_TYPES) as Array< + keyof typeof INGESTION_TYPES + >) { + expect(INGESTION_TYPES[k].label).toBe(INGESTION_KIND_LABELS[k]); + } + }); + + it("invoice drills through to the PDF endpoint, opens in a new tab", () => { + const def = INGESTION_TYPES.invoice; + expect( + def.drillThrough( + row({ kind: "invoice", entityType: "invoice", entityId: 42 }), + ), + ).toBe("/api/invoices/42/pdf"); + expect(def.drillNewTab).toBe(true); + }); + + it("license request drills through to the request page, same tab", () => { + const def = INGESTION_TYPES.license_request; + expect( + def.drillThrough( + row({ + kind: "license_request", + entityType: "license_request", + entityId: 7, + }), + ), + ).toBe("/requests/7"); + expect(def.drillNewTab).toBe(false); + }); + + it("returns null drill-through when entity is missing or mismatched", () => { + expect( + INGESTION_TYPES.invoice.drillThrough(row({ entityId: null })), + ).toBeNull(); + expect( + INGESTION_TYPES.invoice.drillThrough( + row({ entityType: "license_request", entityId: 7 }), + ), + ).toBeNull(); + }); +}); + +describe("presentKinds", () => { + it("returns only kinds present, in registry order", () => { + const rows = [ + { kind: "license_request" }, + { kind: "invoice" }, + { kind: "invoice" }, + ] as IngestionLogRow[]; + expect(presentKinds(rows)).toEqual(["invoice", "license_request"]); + }); +}); From 986dfcda26c65382ad84905ad94319731625f2cc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 3 Jun 2026 08:52:19 +0000 Subject: [PATCH 4/5] feat(034): make dashboard activity feed kind-aware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getRecentDashboardActivity now branches on ingestion kind: invoices read as 'Invoice from {vendor}', license requests as 'License request · {requester}' with tool/tier or duplicate/failed detail. Sourced from the new details payload with legacy columns as fallback for un-backfilled rows; pre-034 'filtered' dedup rows render the same as success+deduped. Resolves the activity-feed follow-up from the implementation notes. typecheck + lint clean; 404 tests pass. --- .../implementation-notes.html | 18 ++ src/actions/dashboard.ts | 184 ++++++++++-------- 2 files changed, 126 insertions(+), 76 deletions(-) diff --git a/specs/034-ingestion-types-distinction/implementation-notes.html b/specs/034-ingestion-types-distinction/implementation-notes.html index 6c3566d..83bcdac 100644 --- a/specs/034-ingestion-types-distinction/implementation-notes.html +++ b/specs/034-ingestion-types-distinction/implementation-notes.html @@ -158,6 +158,24 @@

OpenActivity-feed items not yet kind-awareMake src/actions/dashboard.ts emit per-kind titles/icons/severity via INGESTION_TYPES so a license request reads as a request event, not an invoice one.

+
Follow-up — Activity feed (resolves earlier open item)
+ +
+

DecisionDashboard activity items are now kind-aware

+

src/actions/dashboard.ts (getRecentDashboardActivity)

+

The ingestion query now selects kind / label / details alongside the legacy columns, and the item composition branches on kind:

+
    +
  • invoice → "Invoice from {vendor}" · "Added · {amount}" / "Filtered (duplicate)" / "Failed" (unchanged behaviour, now sourced from details with the legacy columns as fallback for not-yet-backfilled rows).
  • +
  • license_request → "License request · {requester}" · "Requested {tool} · {tier}" / "Duplicate (idempotent)" / "Failed".
  • +
+

A pre-034 dedup row that still carries outcome="filtered" is treated the same as the new success + deduped representation, so the feed reads correctly whether or not the backfill has run. The DashboardActivityItem.kind stays "ingestion" for both (the timeline dot keys off severity, which is unchanged), so no type or timeline-component change was needed. This closes the "activity-feed items not yet kind-aware" open question above.

+
+ +
+

VerifyGates green after activity-feed change

+

pnpm typecheck clean · pnpm lint 0 warnings · pnpm test 404 pass.

+
+
spec/034-ingestion-types-distinction · implementation-notes.html · started 2026-06-03 · append-only
diff --git a/src/actions/dashboard.ts b/src/actions/dashboard.ts index 7add3af..52d1dfe 100644 --- a/src/actions/dashboard.ts +++ b/src/actions/dashboard.ts @@ -150,11 +150,11 @@ export async function getAdminDashboardData(): Promise { const toolAssignments = activeAssignments.filter( - (a) => a.tool.id === tool.id + (a) => a.tool.id === tool.id, ); const totalCost = toolAssignments.reduce( (s, a) => s + a.costAtAssignmentCents, - 0 + 0, ); return { id: tool.id, @@ -169,7 +169,7 @@ export async function getAdminDashboardData(): Promise t.status === "active").length; const totalMonthlySpend = activeAssignments.reduce( (s, a) => s + a.costAtAssignmentCents, - 0 + 0, ); const billedYtdCents = trendsData.reduce((s, p) => s + p.billedCents, 0); const budgetCeilingCents = activeBudget?.totalAmountCents ?? 0; @@ -194,7 +194,7 @@ export async function getAdminDashboardData(): Promise s + a.costAtAssignmentCents, - 0 + 0, ); const previousAssignmentsByTool: Record = {}; const previousSpendByTool: Record = {}; @@ -264,10 +264,10 @@ export async function getAdminDashboardData(): Promise new Date(p.startDate) <= today + (p) => new Date(p.startDate) <= today, ); const runningResults = await Promise.all( - pastOrCurrent.map((p) => getRunningCostsForPeriod(p.id)) + pastOrCurrent.map((p) => getRunningCostsForPeriod(p.id)), ); const runningByPeriod = new Map(); pastOrCurrent.forEach((p, i) => { @@ -282,11 +282,11 @@ export async function getAdminDashboardData(): Promise classifyPeriod(p, today) === "current" + (p) => classifyPeriod(p, today) === "current", ); if (current) { const variancePct = @@ -322,7 +322,8 @@ export async function getAdminDashboardData(): Promise { +async function safeCopilotAcceptance(): Promise { try { const result = await getCopilotOverview(); if (!result.success) return null; @@ -349,7 +348,7 @@ async function safeCopilotAcceptance(): Promise< ? Math.round( (result.data.totalAcceptances / Math.max(1, result.data.totalSuggestions)) * - 100 + 100, ) : null, totalActiveUsers: result.data.totalActiveUsers, @@ -365,7 +364,7 @@ async function safeCopilotAcceptance(): Promise< // --------------------------------------------------------------------------- export async function getRecentDashboardActivity( - limit = 8 + limit = 8, ): Promise { const admin = await requireAdmin(); if (!admin) return []; @@ -376,6 +375,9 @@ export async function getRecentDashboardActivity( db .select({ id: ingestionLog.id, + kind: ingestionLog.kind, + label: ingestionLog.label, + details: ingestionLog.details, filename: ingestionLog.filename, vendor: ingestionLog.vendor, amountCents: ingestionLog.amountCents, @@ -399,11 +401,13 @@ export async function getRecentDashboardActivity( .where( or( isNotNull(licenseAssignments.assignedAt), - isNotNull(licenseAssignments.revokedAt) - ) + isNotNull(licenseAssignments.revokedAt), + ), ) .orderBy( - desc(sql`GREATEST(${licenseAssignments.assignedAt}, COALESCE(${licenseAssignments.revokedAt}, '1970-01-01'))`) + desc( + sql`GREATEST(${licenseAssignments.assignedAt}, COALESCE(${licenseAssignments.revokedAt}, '1970-01-01'))`, + ), ) .limit(halfLimit), ]); @@ -412,19 +416,48 @@ export async function getRecentDashboardActivity( for (const row of ingestionRows) { const ts = row.createdAt.toISOString(); - const title = row.vendor - ? `Invoice from ${row.vendor}` - : row.filename - ? `Invoice ${row.filename}` - : "Invoice ingested"; - const detail = - row.outcome === "success" - ? row.amountCents !== null - ? `Added · ${formatCurrency(row.amountCents)}` - : "Added" - : row.outcome === "filtered" - ? "Filtered (duplicate)" - : "Failed"; + const details = row.details; + + let title: string; + let detail: string | null; + + if (row.kind === "license_request") { + const d = details?.kind === "license_request" ? details : null; + const who = d?.requesterName ?? d?.requesterEmail ?? "Unknown"; + title = `License request · ${who}`; + // A pre-034 dedup row may still carry outcome="filtered"; treat it the + // same as the new success + deduped representation. + const deduped = d?.deduped || row.outcome === "filtered"; + detail = + row.outcome === "failed" + ? "Failed" + : deduped + ? "Duplicate (idempotent)" + : d?.toolName + ? `Requested ${d.toolName}${d.tierName ? ` · ${d.tierName}` : ""}` + : "Request received"; + } else { + // invoice (and any legacy / "other" rows) + const vendor = details?.kind === "invoice" ? details.vendor : row.vendor; + const amountCents = + details?.kind === "invoice" ? details.amountCents : row.amountCents; + const filename = + details?.kind === "invoice" ? details.filename : row.filename; + title = vendor + ? `Invoice from ${vendor}` + : filename + ? `Invoice ${filename}` + : (row.label ?? "Invoice ingested"); + detail = + row.outcome === "success" + ? amountCents != null + ? `Added · ${formatCurrency(amountCents)}` + : "Added" + : row.outcome === "filtered" + ? "Filtered (duplicate)" + : "Failed"; + } + const severity: DashboardActivityItem["severity"] = row.outcome === "success" ? "success" @@ -530,51 +563,56 @@ export interface ViewerDashboardData { } export async function getViewerDashboardData( - userId: number + userId: number, ): Promise { const session = await auth(); if (!session?.user) return null; const callerId = Number(session.user.id); if (callerId !== userId && session.user.role !== "admin") return null; - const [profileData, assignmentRows, syncRow, toolCatalog] = await Promise.all([ - fetchProfileDataInternal(userId), - db - .select({ - id: licenseAssignments.id, - toolId: licenseAssignments.toolId, - toolName: aiTools.name, - vendor: aiTools.vendor, - tierName: accessTiers.name, - status: licenseAssignments.status, - costCents: licenseAssignments.costAtAssignmentCents, - assignedAt: licenseAssignments.assignedAt, - revokedAt: licenseAssignments.revokedAt, - apiKeyEncrypted: licenseAssignments.apiKeyEncrypted, - }) - .from(licenseAssignments) - .innerJoin(aiTools, eq(licenseAssignments.toolId, aiTools.id)) - .innerJoin(accessTiers, eq(licenseAssignments.tierId, accessTiers.id)) - .where(eq(licenseAssignments.userId, userId)) - .orderBy( - desc(sql`GREATEST(${licenseAssignments.assignedAt}, COALESCE(${licenseAssignments.revokedAt}, '1970-01-01'))`) - ) - .limit(20), - db.query.anthropicSyncStatus.findFirst({ - where: eq(anthropicSyncStatus.userId, userId), - }), - db - .select({ id: aiTools.id }) - .from(aiTools) - .where(eq(aiTools.status, "active")), - ]); + const [profileData, assignmentRows, syncRow, toolCatalog] = await Promise.all( + [ + fetchProfileDataInternal(userId), + db + .select({ + id: licenseAssignments.id, + toolId: licenseAssignments.toolId, + toolName: aiTools.name, + vendor: aiTools.vendor, + tierName: accessTiers.name, + status: licenseAssignments.status, + costCents: licenseAssignments.costAtAssignmentCents, + assignedAt: licenseAssignments.assignedAt, + revokedAt: licenseAssignments.revokedAt, + apiKeyEncrypted: licenseAssignments.apiKeyEncrypted, + }) + .from(licenseAssignments) + .innerJoin(aiTools, eq(licenseAssignments.toolId, aiTools.id)) + .innerJoin(accessTiers, eq(licenseAssignments.tierId, accessTiers.id)) + .where(eq(licenseAssignments.userId, userId)) + .orderBy( + desc( + sql`GREATEST(${licenseAssignments.assignedAt}, COALESCE(${licenseAssignments.revokedAt}, '1970-01-01'))`, + ), + ) + .limit(20), + db.query.anthropicSyncStatus.findFirst({ + where: eq(anthropicSyncStatus.userId, userId), + }), + db + .select({ id: aiTools.id }) + .from(aiTools) + .where(eq(aiTools.status, "active")), + ], + ); const cost = profileData.costData; const userToolIds = new Set( - assignmentRows.filter((a) => a.status === "active").map((a) => a.toolId) + assignmentRows.filter((a) => a.status === "active").map((a) => a.toolId), ); - const availableToolCount = toolCatalog.filter((t) => !userToolIds.has(t.id)) - .length; + const availableToolCount = toolCatalog.filter( + (t) => !userToolIds.has(t.id), + ).length; const modelMap = new Map< string, @@ -604,14 +642,8 @@ export async function getViewerDashboardData( })) .sort((a, b) => b.costCents - a.costCents); - const totalInputTokens = modelTotals.reduce( - (s, m) => s + m.inputTokens, - 0 - ); - const totalOutputTokens = modelTotals.reduce( - (s, m) => s + m.outputTokens, - 0 - ); + const totalInputTokens = modelTotals.reduce((s, m) => s + m.inputTokens, 0); + const totalOutputTokens = modelTotals.reduce((s, m) => s + m.outputTokens, 0); let cacheReadTokens = 0; let uncachedInputTokens = 0; @@ -634,8 +666,8 @@ export async function getViewerDashboardData( and( eq(anthropicUsageMetrics.userId, userId), gte(anthropicUsageMetrics.date, formatDateOnly(monthStart)), - lte(anthropicUsageMetrics.date, formatDateOnly(monthEnd)) - ) + lte(anthropicUsageMetrics.date, formatDateOnly(monthEnd)), + ), ); cacheReadTokens = Number(agg?.cacheRead ?? 0); @@ -646,7 +678,7 @@ export async function getViewerDashboardData( const inputCostShare = totalInputTokens > 0 ? cacheReadTokens / totalInputTokens : 0; cacheSavingsCents = Math.round( - cost.monthlyTotalCents * inputCostShare * 0.9 + cost.monthlyTotalCents * inputCostShare * 0.9, ); } From ef7dfb440bcec6a3c27d8e3c76c88f1a3320efff Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 3 Jun 2026 09:06:32 +0000 Subject: [PATCH 5/5] fix(034): address PR review on ingestion logger and history table - logIngestion derives stored kind from details.kind (single source of truth); drop the redundant kind param so it can't disagree with details - add OtherIngestionDetails variant so IngestionDetails covers every ingestion_kind enum value (+ buildIngestionLabel 'other' case) - normalise label (label ?? vendor) before DataTable so legacy rows stay searchable during the migration window - restore 'bulk upload' in the empty-state copy typecheck + lint clean; 404 tests pass. --- src/app/api/license-requests/ingest/route.ts | 5 ----- .../settings/ingestion/ingestion-history-table.tsx | 14 ++++++++------ src/lib/ingestion-logger.ts | 8 ++++---- src/lib/ingestion/labels.ts | 2 ++ src/types/ingestion.ts | 10 +++++++++- 5 files changed, 23 insertions(+), 16 deletions(-) diff --git a/src/app/api/license-requests/ingest/route.ts b/src/app/api/license-requests/ingest/route.ts index ed87e3f..b8edfe7 100644 --- a/src/app/api/license-requests/ingest/route.ts +++ b/src/app/api/license-requests/ingest/route.ts @@ -58,7 +58,6 @@ export async function POST(request: NextRequest) { payload = JSON.parse(text); } catch { await logIngestion({ - kind: "license_request", sourceType: "ms_forms_license_request", outcome: "failed", channel: "api", @@ -78,7 +77,6 @@ export async function POST(request: NextRequest) { ? `${issue.path.join(".")}: ${issue.message}` : "Invalid payload"; await logIngestion({ - kind: "license_request", sourceType: "ms_forms_license_request", outcome: "failed", channel: "api", @@ -199,7 +197,6 @@ export async function POST(request: NextRequest) { if (!existing) { console.error("license-requests ingest insert error:", err); await logIngestion({ - kind: "license_request", sourceType: "ms_forms_license_request", outcome: "failed", channel: "api", @@ -247,7 +244,6 @@ export async function POST(request: NextRequest) { // 034: a dedup replay is a successful, idempotent outcome — recorded as // `success` with details.deduped, not the invoice-only "filtered" outcome. await logIngestion({ - kind: "license_request", sourceType: "ms_forms_license_request", outcome: "success", channel: "api", @@ -275,7 +271,6 @@ export async function POST(request: NextRequest) { function jsonError(error: string, status: number, formResponseId?: string) { // Fire-and-forget log — don't block the response on logger failures. void logIngestion({ - kind: "license_request", sourceType: "ms_forms_license_request", outcome: "failed", channel: "api", diff --git a/src/app/settings/ingestion/ingestion-history-table.tsx b/src/app/settings/ingestion/ingestion-history-table.tsx index 60346f9..3b6607e 100644 --- a/src/app/settings/ingestion/ingestion-history-table.tsx +++ b/src/app/settings/ingestion/ingestion-history-table.tsx @@ -151,10 +151,12 @@ export function IngestionHistoryTable({ data }: IngestionHistoryTableProps) { const tabs = useMemo(() => presentKinds(data), [data]); - const rows = useMemo( - () => (tab === "all" ? data : data.filter((r) => r.kind === tab)), - [data, tab], - ); + const rows = useMemo(() => { + const filtered = tab === "all" ? data : data.filter((r) => r.kind === tab); + // Normalise `label` so search (searchKey="label") still works on legacy + // rows logged before 034 populated it — mirrors the Summary cell fallback. + return filtered.map((r) => ({ ...r, label: r.label ?? r.vendor })); + }, [data, tab]); if (data.length === 0) { return ( @@ -162,8 +164,8 @@ export function IngestionHistoryTable({ data }: IngestionHistoryTableProps) {

No ingestion history

- Invoices and license requests ingested via the UI or the API endpoints - will appear here. + Invoices and license requests ingested via manual upload, bulk upload, + or the API endpoints will appear here.

); diff --git a/src/lib/ingestion-logger.ts b/src/lib/ingestion-logger.ts index e64166e..7ebf432 100644 --- a/src/lib/ingestion-logger.ts +++ b/src/lib/ingestion-logger.ts @@ -6,13 +6,11 @@ import { buildIngestionLabel } from "@/lib/ingestion/labels"; import type { IngestionChannel, IngestionDetails, - IngestionKind, IngestionOutcome, IngestionSourceType, } from "@/types"; export interface LogIngestionParams { - kind: IngestionKind; sourceType?: IngestionSourceType | null; outcome: IngestionOutcome; channel: IngestionChannel; @@ -32,6 +30,9 @@ export interface LogIngestionParams { */ export async function logIngestion(params: LogIngestionParams) { const { details } = params; + // `kind` is derived from the details payload so the stored discriminator can + // never disagree with the typed details (and the label built from it). + const kind = details.kind; // Legacy dual-write: only invoices ever populated these columns. License // requests deliberately leave them null (this is what removes the unsafe @@ -51,7 +52,7 @@ export async function logIngestion(params: LogIngestionParams) { : {}; await db.insert(ingestionLog).values({ - kind: params.kind, + kind, sourceType: params.sourceType ?? null, outcome: params.outcome, channel: params.channel, @@ -87,7 +88,6 @@ export interface LegacyLogIngestionParams { export async function logIngestionAttempt(params: LegacyLogIngestionParams) { await logIngestion({ - kind: "invoice", sourceType: "invoice_pdf", outcome: params.outcome, channel: params.channel, diff --git a/src/lib/ingestion/labels.ts b/src/lib/ingestion/labels.ts index 852d782..9bcae1b 100644 --- a/src/lib/ingestion/labels.ts +++ b/src/lib/ingestion/labels.ts @@ -39,5 +39,7 @@ export function buildIngestionLabel(details: IngestionDetails): string { } case "user_import": return `${details.rowCount} rows · +${details.created} / ~${details.updated}`; + case "other": + return details.description ?? "Ingestion"; } } diff --git a/src/types/ingestion.ts b/src/types/ingestion.ts index 9df0a16..145d58a 100644 --- a/src/types/ingestion.ts +++ b/src/types/ingestion.ts @@ -59,7 +59,15 @@ export interface UserImportIngestionDetails { failed: number; } +// Forward-compat escape hatch for ingestions that don't fit a known kind. +// Keeps the details union aligned with the `ingestion_kind` enum + registry. +export interface OtherIngestionDetails { + kind: "other"; + description?: string | null; +} + export type IngestionDetails = | InvoiceIngestionDetails | LicenseRequestIngestionDetails - | UserImportIngestionDetails; + | UserImportIngestionDetails + | OtherIngestionDetails;