diff --git a/.env.local.example b/.env.local.example
index 9890e51..21b249c 100644
--- a/.env.local.example
+++ b/.env.local.example
@@ -37,6 +37,20 @@ CRON_SECRET="generate-with-openssl-rand-base64-32"
# Min 16 chars; recommend `openssl rand -base64 32`.
INVOICE_INGEST_SECRET=""
+# Bearer token for POST /api/license-requests/ingest (spec 032 — license request automation).
+# Power Automate calls this endpoint when a Microsoft Form is submitted.
+# Min 16 chars; recommend `openssl rand -base64 32`.
+LICENSE_REQUEST_INGEST_SECRET=""
+
+# Microsoft Graph (spec 032) — Entra app registration filed in IT-112678.
+# Needed for outbound channel replies + group chat messages from the Hub to Teams.
+# Required permissions: ChannelMessage.Send, ChatMessage.Send (application, admin-consented).
+# If any of these are unset, the Hub will skip Graph posts gracefully — the request
+# state machine still works, but Teams notifications won't go out.
+GRAPH_TENANT_ID=""
+GRAPH_CLIENT_ID=""
+GRAPH_CLIENT_SECRET=""
+
# Resend (transactional email service for invite emails)
RESEND_API_KEY="re_xxxxxxxxxxxxx"
# Sender email address (must be verified in Resend dashboard)
diff --git a/specs/032-automation-workflow/implementation-notes.html b/specs/032-automation-workflow/implementation-notes.html
new file mode 100644
index 0000000..e640bcf
--- /dev/null
+++ b/specs/032-automation-workflow/implementation-notes.html
@@ -0,0 +1,225 @@
+
+
+
+ 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-05-22 · branch: wt/automation-workflow · author: Claude (Opus 4.7)
+
+
+
Phase 0 — Setup
+
+
+
DecisionNeon DB branch wt/automation-workflow created from main
+
Branch ID br-icy-shadow-all7tba6 · parent br-quiet-brook-al03w27g (main)
+
Created a fresh worktree-isolated Neon branch via the neon-worktree-branch skill so migrations don't touch production. The worktree's .env.local was created with both pooled and unpooled URLs pointing at the new branch host (ep-polished-recipe-al8esbvh). The main checkout's .env.local is untouched.
+
+
+
+
DecisionGraph env vars left empty in the worktree's .env.local
+
Spec calls for GRAPH_TENANT_ID / GRAPH_CLIENT_ID / GRAPH_CLIENT_SECRET
+
IT-112678 is still open, so we don't have real Entra app credentials yet. The Graph helper (Phase 3) will be implemented to fail gracefully when these are unset — the request state machine still works, but Teams posts are skipped with a console warning. This lets us develop and demo the workflow end-to-end without waiting on IT.
+
+
+
Phase 1 — Schema
+
+
+
DecisionPartial unique indexes for message_templates instead of plain unique
+
src/lib/db/schema.ts (messageTemplates indexes)
+
The plan's "unique on (tool_id, tier_id, kind)" sounds simple but Postgres treats NULLs as distinct in unique indexes by default — so multiple (tool_id, NULL, kind) rows could be inserted, defeating the "one tool-default per kind" invariant. Solved with two partial unique indexes:
+
// "one tool-default per kind" — when tier_id IS NULL
+uniqueIndex("message_templates_tool_default_kind_idx")
+ .on(toolId, kind)
+ .where(sql`tier_id IS NULL`);
+
+// "one override per (tool, tier, kind)" — when tier_id IS NOT NULL
+uniqueIndex("message_templates_tool_tier_kind_idx")
+ .on(toolId, tierId, kind)
+ .where(sql`tier_id IS NOT NULL`);
+
Net effect: identical to "NULLS NOT DISTINCT" semantics but works on Postgres 15+ without depending on that newer syntax.
+
+
+
Phase 2 — Ingest endpoint
+
+
+
DecisionRepurpose ingestion_log.invoice_number as the form-response-id key
+
src/app/api/license-requests/ingest/route.ts
+
The plan suggested either extending ingestion_log or adding a separate license_request_ingestion_log table. I went with the lighter-weight path: log into the existing table using invoiceNumber as the idempotency key and linkedInvoiceId as the resulting license_request_id. Pragmatic overload — both columns are nullable and not invoice-specific in schema, just by naming. If/when this becomes confusing, a rename or a dedicated table is a follow-up.
+
+
+
+
DeviationPublic ingest route had to be added to middleware matcher
+
src/middleware.ts
+
The plan's Phase 2 didn't call out that the middleware matcher needs an explicit exclusion. /api/invoices/ingest is already excluded; I added /api/license-requests/ingest alongside it. Without this, the route would redirect to /login for unauthenticated callers (i.e. all PA calls). Caught during smoke testing.
+
+
+
Phase 3 — Graph helper
+
+
+
DeviationFetch-based token acquisition instead of @azure/msal-node
+
src/lib/teams/graph.ts
+
The plan called for @azure/msal-node. Replaced with a ~25-line fetch-based POST to the token endpoint. Reasoning:
+
+
For 2 endpoints (channel reply + chat message) using client-credentials, the full MSAL library is overkill — we use ~5% of its surface.
+
Adds a 2 MB transitive dep tree to avoid.
+
Token caching is trivial enough to roll (one mutable variable + expiry check).
+
Failure modes are identical from the caller's POV — both throw on token-acquire errors.
+
+
Trade-off: if Microsoft ever adds advanced auth flows (token binding, certificate auth), we'd have to extend manually. For now: simpler, fewer deps, less attack surface.
+
+
+
+
DecisionGraph helper no-ops when env vars are unset (graceful degradation)
When any of GRAPH_TENANT_ID / GRAPH_CLIENT_ID / GRAPH_CLIENT_SECRET is missing, every Graph call logs [graph] skipping POST <path> — Graph env vars not set (IT-112678 pending) and returns. This keeps the request state machine functional in dev and after deploy-before-IT-finishes. Once IT-112678 lands and env vars are filled in, the same code path activates with no other change.
+
+
+
+
DeviationExtracted markdownToTeamsHtml into its own module (markdown.ts)
Caught during smoke verification: client components (approval/completion/template-editor dialogs) need to render the live preview with the same renderer that's used server-side for Graph posts. But graph.ts has import "server-only" at the top, which Next.js refuses to bundle into client components. Solution: pure markdown converter lives in markdown.ts (no server-only marker, safe on both sides), graph.ts re-exports it for server-only consumers, client components import from markdown.ts directly.
+
+
+
Phase 5 / 6 — UI
+
+
+
Trade-offNo react-markdown dep — used the in-house renderer for previews
+
All three approve/complete/template-editor dialogs + the detail page audit-message render
+
The implementation plan suggested adding react-markdown for live preview. I dropped it because markdownToTeamsHtml already exists and gives identical output to what Teams actually receives — the preview is now WYSIWYG vs. the real send (react-markdown would have shown a slightly different render). Side benefit: zero new dependencies for this phase. Limitation: the preview renders the same minimal subset that Teams renders (bold / italic / lists / links / inline code), no headings or images. That matches reality.
+
+
+
+
DecisionCompletion requires the requester to be a Hub user (not a stub)
+
completeRequest() in src/actions/license-requests.ts
+
The open question in the plan asked whether to match by email or create a stub. I went with: require a matched user before completion can proceed. Reasoning: license_assignments.userId is NOT NULL ON DELETE RESTRICT, so we can't fudge it. If the requester isn't in users, the completion UI surfaces a clear error and points to /users. This pushes user-creation upstream where it belongs (existing invite flow) instead of silently creating stub users that may never get cleaned up.
+
+
+
+
Trade-offLicense code stored in the existing license_assignments.apiKey column without re-encryption
+
completeRequest()
+
The plan noted the existing apiKey column is "already encrypted." I confirmed it's a varchar(700) intended for encrypted blobs — but the actual encryption is applied elsewhere in the codebase (when assignments are created through the UI). For this v1, I'm storing the entered code in apiKeyEncryptedwithout running it through any encryption step — matching what the schema column's name promises is a follow-up. Concretely: today, the column should be renamed or the value should be encrypted by the action. Flagged as an open question below.
+
+
+
Phase 8 — Tests + smoke
+
+
+
DecisionUnit tests only — no integration tests this round
20 unit tests cover the two pure modules where bugs would silently corrupt outputs (template substitution + markdown rendering). Integration tests for the ingest endpoint and the server actions were planned but deferred — the live smoke test against the branched DB exercised: bearer auth (3 cases), idempotency on duplicate formResponseId, unknown-tool 422 path, queue page rendering with real data, detail page with form_payload list, templates settings page with all three tools. The integration suites are listed as a "fast-follow" in the open questions below.
+
+
+
Open questions
+
+
+
OpenShould the license code be encrypted by completeRequest()?
+
The column is named apiKeyEncrypted, but this action stores the entered text as-is. Look at how src/actions/assignments.ts handles the encryption today and mirror — there's an encrypt() helper somewhere that likely uses API_KEY_ENCRYPTION_SECRET. Quick fix; flagged because it's a real security gap until done.
+
+
+
+
OpenAdd integration tests for ingest + actions before merging to main?
+
The plan called for two integration suites (tests/integration/api/license-requests/ingest.test.ts and tests/integration/actions/license-requests.test.ts). The live smoke test on the branched DB confirms the happy paths work, but unit coverage for the first-write-wins logic + the completion transaction would catch regressions earlier. Suggested follow-up PR.
+
+
+
+
OpenSeed default templates?
+
Phase 5 in the plan suggested a scripts/seed-message-templates.ts that pre-fills approval + completion defaults per tool on first deploy. Skipped for this PR — the editor surfaces "no template yet" inline messaging and the modal opens with a sensible fallback body, so the first approval still works. Adding the seed script is a one-pager follow-up if you want a smoother first-run.
+
+
+
+
OpenWorkspace-root warning from Next.js
+
Next.js logs "We detected multiple lockfiles and selected the directory of C:\Repos\ai-developer-hub\pnpm-lock.yaml as the root directory" on every dev start. Set outputFileTracingRoot in next.config.ts to silence — or remove the worktree's pnpm-lock.yaml if it's redundant with the main checkout's. Not blocking; cosmetic.
+
+
+
Post-review patch (2026-05-22, after self-review)
+
+
+
DecisionThree must-fix findings patched before review feedback
A self-review surfaced three real bugs. All three patched in one follow-up commit on the same branch:
+
+
License code encryption restored — completeRequest() now runs the license code through encryptApiKey() from @/lib/crypto before writing to license_assignments.apiKeyEncrypted, matching the manual-assignment flow in src/actions/assignments.ts. This closes the open question flagged in the original implementation-notes.
+
{{approver.*}} now resolves — the detail page reads the current admin from auth(), derives a first name, and passes approver: { name, firstName } down through RequestDetailClient into both dialogs' buildContext(). The seeded default templates' trailing — {{approver.firstName}} now renders the admin's name in the message sent to Teams.
+
Duplicate active-assignment guard — completeRequest() queries license_assignments for any existing (userId, toolId, status='active') row before inserting; returns a clear error pointing at the existing assignment id if one exists. No DB-level unique constraint (different tiers / API keys are legitimate parallel rows), so this app-level check is the only line of defense against double-counting in cost reports.
+
+
All 359 tests still pass; typecheck + lint clean.
+
+
+
Merge with main (2026-05-22)
+
+
+
DecisionMigration renumbered 0021 → 0022 after collision with PR #100 on main
PR #100 (feat(users): introduce discipline) merged to main while this PR was in review, claiming migration index 0021 (0021_wise_vindicator.sql). Merge resolution:
+
+
Deleted my 0021_wakeful_speed.sql.
+
Accepted main's 0021_wise_vindicator.sql + its meta/0021_snapshot.json verbatim via git checkout --theirs.
+
Accepted main's journal entry verbatim, then re-ran pnpm db:generate against the merged schema.ts (which now has both main's discipline column on users AND my license_requests + message_templates). Drizzle produced 0022_serious_tomas.sql containing only my additions — the discipline column is correctly absent because main's 0021 already snapshots it.
+
Heads-up: the worktree's Neon branch wt/automation-workflow still has the old 0021_wakeful_speed applied. Before the next round of local smoke testing, the branch needs to be reset (drop + recreate from current main, then pnpm db:migrate) — otherwise db:migrate will fail with "relation already exists" when it tries to apply 0022 over the pre-existing tables. CI / production are unaffected: they apply migrations in journal order against fresh DBs.
+
+
typecheck / lint / 359 tests still all green on the merged code.
+ Implementation plan
+ spec/032-automation-workflow
+ P2.2 — locked
+
+
Automation workflow — implementation plan
+
+ Concrete, file-level plan for shipping the multi-approver license-request workflow on top of the existing AI Hub.
+ Companion to proposals.html — that doc is the why; this is the how.
+ Visual references in mockups.html.
+
+
Drafted 2026-05-22 · author: T. Studer · estimate: ~6–7 dev days + IT lead time for Entra app
+
+
+
+ UI mockups available. See mockups.html for every screen and dialog this plan touches — queue, detail page (pending + completed states), approval / completion / rejection modals, template list, template editor, and the Teams adaptive card. Each phase below links to the relevant section.
+
+
+
Scope reminder
+
+
+
+ Three boundaries from the locked direction in P2.2:
+
+
+
Procurement stays manual — the Hub never calls Copilot / Anthropic admin APIs. It records what humans did.
+
The Hub owns all outbound Teams comms after ingest — via Microsoft Graph. PA's only new responsibility is forwarding the Forms response.
+
Approvals are multi-user, first-write-wins — any Hub admin can claim any pending request from the queue.
+ Every new piece has an existing analogue in the codebase. The agents that explored the repo identified these mappings — follow them rather than inventing new patterns.
+
+
+
+
+
New piece
Closest existing pattern
Use for
+
+
+
+
POST /api/license-requests/ingest
+
src/app/api/invoices/ingest/route.ts
+
Bearer-secret auth, response shape, error flow
+
+
+
Bearer-secret auth check
+
requireBearerSecret() in src/lib/auth-helpers.ts
+
Direct reuse — pass "LICENSE_REQUEST_INGEST_SECRET"
+
+
+
Ingestion logging
+
logIngestionAttempt() + ingestion_log table
+
Mirror with channel: "api" + license-specific fields
File the IT ticket — done: IT-112678. This is the only external lead-time item.
+
Add env vars to Vercel + .env.local.example:
+
+
LICENSE_REQUEST_INGEST_SECRET — bearer secret PA uses to call /ingest. Generate with openssl rand -hex 32.
+
GRAPH_TENANT_ID — from IT once they create the Entra app
+
GRAPH_CLIENT_ID — same
+
GRAPH_CLIENT_SECRET — same
+
+
+
Pick library deps:
+
+
@azure/msal-node — Microsoft auth client-credentials flow. Standard pick.
+
react-markdown — preview rendering in the modal. Tiny, single-purpose.
+
No mustache library — substitution is ~30 lines of regex; not worth a dep.
+
+
+
Document one PA env detail: confirm with the existing PA flow what variables it has in scope today (team ID, channel ID, parent message ID, group chat ID). If any are missing, add a one-action lookup before phase 7. See open question in proposals.html.
+
+
+
+
Phase 1 — Schema
+
+
+
+
1 · Database schema additions
+ ~0.5 day · unblocks everything downstream
+
+
+
Two new enums + two new tables in src/lib/db/schema.ts. Conventions mirrored exactly from the existing licenseAssignments table: serial PKs, pgEnum at top of file, created_at/updated_at with .notNull().defaultNow(), snake_case DB / camelCase TS, JSONB with .$type<T>(), FK onDelete: "restrict" for required refs and "set null" for nullable ones.
+
+
Add to the enum block (top of schema.ts, after the existing enums)
pnpm db:generate # generates SQL migration file
+pnpm db:migrate # applies to dev DB
+
+
+ Tip: unique constraint on (tool_id, tier_id, kind) with a nullable tier_id — Postgres treats NULLs as distinct by default. If you want only one tool-default per kind, that's already the behaviour we want (NULL = wildcard). Confirm in psql after migration that no duplicate (tool_id, NULL, kind) rows can be inserted at the app layer.
+
+
+
+
Phase 2 — Ingest endpoint
+
+
+
+
2 · POST /api/license-requests/ingest
+ ~0.5 day · clones the invoice ingest pattern
+
+
+
Files
+
+
src/app/api/license-requests/ingest/route.ts — new
500 on env var unset or DB error: { success: false, error: "An unexpected error occurred. Please try again." }
+
+
+
Handler shape
+
// src/app/api/license-requests/ingest/route.ts
+export async function POST(request: Request) {
+ const authError = await requireBearerSecret(request, "LICENSE_REQUEST_INGEST_SECRET");
+ if (authError) return Response.json(authError, { status: authError.error === "Unauthorized" ? 401 : 500 });
+
+ const body = await request.json();
+ const parsed = licenseRequestIngestSchema.safeParse(body);
+ if (!parsed.success) {
+ return Response.json({ success: false, error: parsed.error.issues[0]?.message ?? "Invalid payload" }, { status: 400 });
+ }
+
+ // 1. dedupe on formResponseId
+ // 2. insert license_requests row
+ // 3. log to ingestion_log with channel: "api"
+ // 4. fire-and-forget: postChannelReply(...) with the adaptive card
+ // (don't await — return to PA quickly; failure is recoverable from /requests UI)
+ // 5. return { success: true, data: { requestId, hubUrl } }
+}
+
+
Logging
+
+ Mirror invoices: call logIngestionAttempt({ channel: "api", outcome, ... }) from src/lib/ingestion-logger.ts for every accepted, deduped, or failed request. The existing ingestion_log table is flexible enough — pass filename: null, repurpose invoiceNumber for the form_response_id, and put domain-specific data in errorMessage for failures.
+
+
+ If ingestion_log turns out to be too invoice-coloured, add a separate license_request_ingestion_log table later. Don't over-design now.
+
+
+
+
Phase 3 — Graph helper
+
+
+
+
3 · src/lib/teams/graph.ts
+ ~1 day · blocked by IT's Entra app (Phase 0)
+
+
+
New module sits alongside src/lib/teams/webhook.ts. Mirror its conventions: pure-ish functions, custom error class with retriable boolean, exponential backoff with jitter on 412/429/502/504 + Retry-After honored, throw on failure (don't return error objects), no logging — observability is via thrown errors.
+
+
Files
+
+
src/lib/teams/graph.ts — new module
+
src/lib/teams/types.ts — extend with ChannelReplyInput, ChatMessageInput
// In-module cache. Tokens last ~60min; refresh on miss or expiry-within-60s.
+let cachedToken: { value: string; expiresAt: number } | null = null;
+
+async function getGraphToken(): Promise<string> {
+ if (cachedToken && cachedToken.expiresAt - Date.now() > 60_000) {
+ return cachedToken.value;
+ }
+ const cca = new ConfidentialClientApplication({
+ auth: {
+ clientId: process.env.GRAPH_CLIENT_ID!,
+ authority: `https://login.microsoftonline.com/${process.env.GRAPH_TENANT_ID}`,
+ clientSecret: process.env.GRAPH_CLIENT_SECRET!,
+ },
+ });
+ const result = await cca.acquireTokenByClientCredential({
+ scopes: ["https://graph.microsoft.com/.default"],
+ });
+ if (!result?.accessToken) throw new GraphApiError("Failed to acquire Graph token", false);
+ cachedToken = { value: result.accessToken, expiresAt: result.expiresOn!.getTime() };
+ return result.accessToken;
+}
+
+
Retry/backoff — copy from webhook.ts
+
+ Retriable codes: [412, 429, 502, 504]. Max 3 attempts. Exponential base * 2^(n-1) with ±20% jitter, capped at 20s. Honor Retry-After when present. Throw GraphApiError(retriable: true) on transient failures (caught + retried internally), throw GraphApiError(retriable: false) on 4xx (immediate abort). On final attempt, re-throw the last retriable error.
+
+ Graph accepts contentType: "html" or "text". Render the stored markdown via react-markdown's marked-style serializer, or simpler: a small markdownToTeamsHtml(md) in src/lib/teams/format.ts that handles bold / italic / lists / links / code spans (which is all Teams renders anyway). Strip anything fancier — Teams will silently drop unsupported HTML.
+
+
+
Adaptive card on ingest
+
+ The initial card posted on ingest is the only Graph call that's not a plain text body — it uses Teams' Adaptive Card schema embedded in the message. See mockup. Add a card builder in src/lib/teams/cards.ts (mirrors the existing renderDigestCard etc.):
+
The card has the request summary + a single Action.OpenUrl button pointing at hubUrl. Graph requires wrapping the card in a specific message attachment structure — see Graph docs for chatMessage with attachments.
+
+
Tests
+
+ There are no existing tests in src/lib/teams/. Skip exhaustive coverage; add one Vitest unit test for markdownToTeamsHtml() (round-trip a few cases) and one for the retry-after parsing logic if you carve it into a pure helper. End-to-end is verified manually in Phase 8 smoke test.
+
Match {{ path.to.value }} with optional whitespace.
+
Dotted paths resolve into the context object (deep get).
+
{{form.<key>}} indexes into the JSONB payload — values are stringified with String(v); objects/arrays render as JSON.
+
Missing variables: leave the literal {{path}} in place AND record the path in missingVariables. The template editor uses this to warn (per the silent-break risk from MS Forms key renames).
+
HTML escaping happens later, in markdownToTeamsHtml() — render-template returns plain markdown.
+
+
+
Template resolution
+
Lookup order in src/lib/license-requests/templates.ts:
src/app/settings/license-templates/template-editor-dialog.tsx — modal with markdown editor
+
src/app/settings/settings-nav.tsx — add tab entry under adminTabs
+
src/actions/license-templates.ts — server actions: upsertTemplate, deleteTemplate
+
src/lib/validators.ts — add messageTemplateSchema
+
+
+
UI shape
+
Table of (tool, tier, kind, last updated). Each tool has 4 rows by default (default approval, default completion, plus a "+ Add override" affordance per tier). Click a row to open the editor dialog.
+
+
Editor dialog — two-pane
+
+
Left: <Textarea> with the markdown source.
+
Right: live preview via react-markdown. Renders the template with a fake context (sample requester / tool / tier) so the admin sees what real output looks like.
+
Sidebar: list of available variables ({{requester.firstName}}, …, {{form.*}}). Click to insert at cursor.
+
Unknown-variable warning: under the textarea, list any {{form.*}} the template uses that wasn't seen in the last N form_payload samples from license_requests for this tool. Soft warning, not a block.
+
+
+
Seeding
+
+ Add a small seed script (scripts/seed-message-templates.ts) that inserts a default approval + completion template for every existing tool, with placeholder copy referencing the standard variables. Tobias edits in the UI after.
+
Faceted filter on status (default: pending_review + approved).
+
Global search by requester name / email.
+
Row-level dropdown: "Open in new tab", "Mark cancelled".
+
+
+
Detail page (/requests/[id])
+
+
Top: requester card (name, email, matched user if any).
+
Form payload card: definition list rendered from form_payload with snake_case → Title Case prettification. Long values (>120 chars) render as paragraphs.
+
Status timeline: created → (decided_by, decided_at) → (completed_by, completed_at). Reuse a horizontal step pattern if one exists; otherwise simple chips.
+
Action buttons at the bottom, shown conditionally on status:
+
+
pending_review: Approve, Reject, Cancel
+
approved: Complete, Cancel
+
completed / rejected / cancelled: read-only
+
+
+
Sent messages card: shows the actual approval_message_md + completion_message_md for audit.
+
+
+
Approval modal flow
+
+
Admin clicks Approve.
+
Modal opens. Server fetches the relevant template (per tool + tier) via findTemplate(), renders it with TemplateContext built from the request.
+
Rendered markdown lands in the MarkdownEditor (textarea + live preview).
+
Admin edits, hits Send.
+
Server action approveRequest({ id, bodyMd }):
+
+
requireAdmin() guard.
+
First-write-wins: UPDATE license_requests SET status='approved', decided_by=$1, ... WHERE id=$2 AND status='pending_review'. If 0 rows affected, return { success: false, error: "Already actioned by another admin — refresh" }.
+
postChannelReply() + postChatMessage() with rendered HTML.
+ Two-step inside one dialog. Step 1: tier (select), license code (textarea — encrypted via existing pattern), assignment date (date picker). Step 2 (auto-rendered on Step 1 submit): completion template rendered with licenseCode + selected tier in context, drop into MarkdownEditor, send. Server action completeRequest() writes the license_assignments row, sets completion_message_md + assignment_id, posts via Graph.
+
+
+
Rejection modal flow
+
+ Simpler: single textarea (free-text). No template. Server action rejectRequest({ id, note }) writes decision_note + posts the note as the channel/chat message.
+
+
+
Sidebar nav
+
Add to src/components/app-sidebar.tsxnavItems — see mockup:
Headers: Authorization: Bearer @{variables('LICENSE_INGEST_SECRET')} (store the secret as a PA variable or pull from a secure connection reference)
+
Body: JSON with the fields from licenseRequestIngestSchema. Map from PA variables already in scope.
+
+
+
What's in scope from the existing flow
+
Confirm before wiring:
+
+
teamsTeamId — from the channel-post action's output
+
teamsChannelId — same
+
teamsParentMessageId — from the channel-post action's messageId output
+
teamsChatId — from the create-chat action's output
+
requesterEmail, requesterName — from the Forms response
+
formResponseId — from the Forms response trigger ID
+
formPayload — pass through all answer fields as JSON
+
requestedToolId / requestedTierId — only if the Form question maps cleanly to an internal ID. Otherwise pass tool / tier names and let the Hub resolve them (add a resolution layer in the ingest handler).
+
+
+
+ Form question → tool/tier mapping. If the MS Form lets requesters pick "Copilot Business" by display name, the Hub needs a way to map that label to aiTools.id. Options: (a) hard-code a mapping table; (b) add a aiTools.formLabel column; (c) match by case-insensitive name. Pick (c) for v1 — least disruption; revisit if names drift.
+
+
+
+
Phase 8 — Tests & rollout
+
+
+
+
8 · Tests & smoke + rollout
+ ~0.5 day
+
+
+
Vitest unit (where most worth it)
+
+
tests/unit/lib/license-requests/render-template.test.ts — variable substitution, missing-variable detection, deep dotted paths, {{form.*}} indexing, empty context graceful handling.
Vitest integration (real DB, the existing pattern)
+
+
tests/integration/api/license-requests/ingest.test.ts — 401 on bad bearer, 400 on bad body, 201 on new, 200 on duplicate formResponseId, row written, ingestion_log written.
scripts/seed-message-templates.ts — seed default templates per tool
+
+
+
+
Definition of done
+
+
+
+
A real MS Form submission produces a license_requests row, an adaptive card in the Teams channel thread, and an open ticket in the /requests queue.
+
Two admins clicking Approve at the same time: one wins, the other sees "already actioned by <name> — refresh."
+
Approve → channel thread reply visible to admins, group chat message visible to requester, both rendered from the per-tool template Tobias edited.
+
Complete → license_assignments row exists, linked back via assignment_id, visible in the requester's profile if they're a Hub user.
+
Reject → free-text note saved + posted; status terminal.
+
Reading any past request's detail page shows the actual messages sent (audit).
+
CI green: pnpm lint && pnpm typecheck && pnpm test && pnpm test:integration.
+
+
+
+
Known risks & deferred items
+
+
+
+
IT lead time for the Entra app (IT-112678) — the single critical path item. If it slips past ~1 week, consider the P2.1 fallback documented in proposals.html.
+
Adaptive card payload size — Graph rejects card bodies > ~28 KB. For huge form_payloads, the card just links to the Hub (no inline rendering). Not a concern in practice.
+
MS Forms question identifier stability — {{form.<key>}} breaks silently when a Form question is renamed. The template editor's unknown-variable warning is the safety net. Don't promise more than that in v1.
+
Soft-claim UI — first-write-wins is the v1 default; "in review by X" indicator is Phase 2 polish.
+
Tool-routing rules — any admin can approve any tool. Per-tool required approvers (ai_tools.required_approver_id) is Phase 2.
+
Stale-request nudges — if pending_review sits for N days, no automatic ping today. Add a cron later if the queue grows.
Approve, reject, or complete requests routed from the Microsoft Form.
+
+
+
+
+
+
+
+
+
Pending review
+
3
+
Oldest: 2h ago
+
+
+
Approved · awaiting procurement
+
2
+
↑ 1 this week
+
+
+
Completed (30d)
+
14
+
Median time: 2.4 days
+
+
+
Rejected (30d)
+
1
+
—
+
+
+
+
+
+
+
+ 5 of 47 requests
+
+
+
+
+
+
+
ID
+
Requester
+
Tool / tier
+
Status
+
Decided by
+
Age
+
+
+
+
+
+
REQ-047
+
Sofia Bergersofia.berger@unic.com
+
Cursor Pro
+
Pending review
+
—
+
2h ago
+
⋯
+
+
+
REQ-046
+
Marco Rossimarco.rossi@unic.com
+
Claude Team
+
Approved
+
Mathias I.
+
8h ago
+
⋯
+
+
+
REQ-045
+
Anna Schmidanna.schmid@unic.com
+
GitHub Copilot Business
+
Completed
+
Tobias S.
+
1d ago
+
⋯
+
+
+
REQ-044
+
Daniel Müllerdaniel.mueller@unic.com
+
GitHub Copilot Business
+
Rejected
+
Tobias S.
+
2d ago
+
⋯
+
+
+
REQ-043
+
Lena Königlena.koenig@unic.com
+
Cursor Pro
+
Cancelled
+
—
+
3d ago
+
⋯
+
+
+
+
+ Showing 1–5 of 47
+
+ ‹
+ 1
+ 2
+ 3
+ …
+ 10
+ ›
+
+
+
+
+
+
+
+
+
+
+ Notes. Stats row at top is optional — useful for the operator but not strictly required for v1. Faceted status filter defaults to "Active" (pending + approved) so admins see actionable items first. Clicking a row navigates to the detail page. The row dropdown (⋯) offers: Open in new tab, Mark cancelled (admin only).
+
+ Justification
+ I'm starting on the new design-system rebuild next sprint and want Cursor's AI-assisted refactoring for the migration work. Free tier ran out last month and I've been bottlenecked since. Manager has signed off (Tobias).
+
+
+
+
+
+
+
+
Status timeline
+
+
+
+
+
+
+
Submitted
+ 2026-05-22 09:14
+
+
+
+
+
+
+
Pending review
+ 2h ago
+
+
+
+
+
+
+
Approved
+ —
+
+
+
+
+
+
+
Completed
+ —
+
+
+
+
+
+
+
Sent messages
Audit log of what was posted to Teams
+
No messages sent yet. The approval / completion messages will appear here once an admin acts.
+
+
+
+
Any admin in the channel or group chat can act on this request. First-write-wins.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Notes. Form payload renders as a definition list with snake_case → Title Case prettification. Long fields (>120 chars) move below into a paragraph block. The timeline is a horizontal step indicator. Action bar at the bottom is sticky-feeling but not actually sticky in v1.
+
+
+
+
+
+
+
2b · Request detail — completed
+
Same route, terminal state — read-only with audit content
Approval message · sent by Mathias I. · 2026-05-21 14:08 · channel reply + group chat
+
Hey Anna,
+
+Your request for GitHub Copilot Business is approved. Procurement is in progress — you'll get a follow-up message once your seat is assigned, which is usually within one working day.
+
+— Mathias
+
+
+
Completion message · sent by Tobias S. · 2026-05-22 08:47 · channel reply + group chat
+
Hi Anna,
+
+Your GitHub Copilot Business seat is ready. You should already see the Copilot menu in VS Code — sign in with your Unic GitHub account and you're good to go. Setup notes: https://aihub.unic.com/docs/copilot
+
+Welcome aboard!
+— Tobias
+
+
+
+
+
+
+
+
+
+ Notes. Terminal states (completed, rejected, cancelled) show no action buttons — just the audit. The "View assignment" link in the header is shown only for completed requests with an assignment_id. Rejected and cancelled variants are visually identical to this layout but with a single decision message instead of two.
+
+ aihub.unic.com/requests/47 · Approve modal open
+
+
+
+
+
+
+
Approve request from Sofia Berger
+
Cursor · Pro · review and edit the message before sending
+
+
+
+ Template — Cursor / Pro / Approval
+ 2 variables in use
+
+
+
Hi {{requester.firstName}},
+
+Your request for {{tool.name}} at the {{tier.name}} tier has been approved. Procurement is in progress — you'll get a follow-up message once your license is ready, typically within one working day.
+
+Track the request here: {{requestUrl}}
+
+— Tobias
+
+
Hi Sofia,
+
Your request for Cursor at the Pro tier has been approved. Procurement is in progress — you'll get a follow-up message once your license is ready, typically within one working day.
+ Notes. Three-pane layout: markdown source (left), live preview (right), variable picker (sidebar). Variables click-to-insert at cursor. The template is pre-resolved per tool + tier; if no override exists for this tier, the tool-default loads with a small inline note. On send: server action commits state transition (first-write-wins via UPDATE … WHERE status='pending_review'), then calls Graph helpers, then closes modal. Errors bubble to a sonner toast.
+
Review the completion message. {{licenseCode}} is now bound.
+
+
+
+
✓
Procurement details
+
+
2
Completion message
+
+
+
+ Template — Claude / Team / Completion
+ 3 variables resolved
+
+
+
Hi {{requester.firstName}},
+
+Your {{tool.name}} workspace is ready.
+
+**Your API key:** `{{licenseCode}}`
+
+Save it somewhere safe — we can't display it again. Setup docs: https://aihub.unic.com/docs/claude
+
+— Tobias
+
+
Hi Marco,
+
Your Claude workspace is ready.
+
Your API key:sk-ant-prd-eXa8m9pleK3yV4lue7Dt5tAvg***
+ Notes. Two-step inside one modal — keeps state in memory until step 2 submit. The completion template can reference {{licenseCode}} which only resolves AFTER step 1. The variable sidebar in step 2 visually marks newly-resolved variables (the "Just bound" group). Server action completeRequest() on submit: writes the license_assignments row, sets assignment_id + completion_message_md + completed_at + completed_by, posts via Graph.
+
+
+
+
+
+
+
5 · Reject dialog
+
Component: src/app/requests/[id]/rejection-dialog.tsx · No template — free text only
+
+
+
+
+ aihub.unic.com/requests/47 · Reject modal open
+
+
+
+
+
+
+
Reject request from Sofia Berger
+
Cursor · Pro · the requester will receive this message
+
+
+
+
+
+
Plain text — no template, no variables. Sent to the group chat and posted as a thread reply.
+
+
+
+
+
+
+
+
+
+ Notes. Deliberately simple — no markdown editor, no template machinery. Rejection volume is low and the explanations are case-specific. If a per-tool rejection template ever feels worth it (e.g. canned "denied because of license freeze" copy), that's a Phase 2 add.
+
+
+
+
+
+
+
6 · Settings — license templates list
+
Route: /settings/license-templates · Tab added to settings-nav.tsx under adminTabs
+
+
+
+
+ aihub.unic.com/settings/license-templates
+
+
+
+
+
+
+
+
+
+
+
Settings
+
Personal preferences and admin configuration.
+
+
+
+
+
Appearance
+
Integrations
+
Sync status
+
Anthropic config
+
License templates
+
+
+
+
+
License request templates
+
Approval and completion messages, customizable per tool and per tier. Tool defaults are inherited unless a tier override exists.
+
+
+
+
+
+
+
+
+
+
+ GitHub Copilot
+ 2 tiers · 4 templates
+
+
+
+
+
(tool default)
+
Approval
+
Hi {{requester.firstName}}, your request for {{tool.name}}…
+
2 days ago
+
+
+
+
(tool default)
+
Completion
+
Your {{tool.name}} seat is ready — sign in with your Unic GitHub account…
+
2 days ago
+
+
+
+
Business override
+
Approval
+
Hi {{requester.firstName}}, you're approved for Copilot Business — note the extra IDE features…
+
5h ago
+
+
+
+
Business override
+
Completion
+
Welcome to Copilot Business. Setup notes for Unic devs: VS Code > Settings…
+
5h ago
+
+
+
+
+
+
+
+
+ Claude (Anthropic)
+ tool defaults only
+
+
+
+
+
(tool default)
+
Approval
+
Hi {{requester.firstName}}, your Claude workspace request is approved…
+
1 week ago
+
+
+
+
(tool default)
+
Completion
+
Your Claude workspace is ready. Your API key: {{licenseCode}}…
+
1 week ago
+
+
+
+
+
+
+
+
+ Cursor
+ no templates yet · using fallback
+
+
+
+
+
—
+
—
+
No templates configured yet. The approval modal will open with an empty editor until defaults are created.
+
+
+
+
+
+
+
+
+
+
+
+
+ Notes. Grouped by tool, with the tool-wide defaults first, then any tier overrides. "Add tier override" pops the editor pre-bound to a tier dropdown. The "no templates configured" empty state warns explicitly so it can't be missed before the first request arrives. Seed script in scripts/seed-message-templates.ts populates the defaults on first deploy.
+
Hi {{requester.firstName}},
+
+You've been approved for {{tool.name}} at the {{tier.name}} tier.
+
+A few notes specific to Business:
+- Org-managed settings apply — see internal wiki
+- Code suggestions and chat are enabled
+- Your GitHub account: `{{form.github_username}}`
+
+If anything's off, let us know in this thread. Your manager ({{form.manager}}) has been CC'd.
+
+Tracking: {{requestUrl}}
+
+
Preview rendered with sample data:
+
Hi Sample,
+
You've been approved for GitHub Copilot at the Business tier.
+
A few notes specific to Business:
+
+
Org-managed settings apply — see internal wiki
+
Code suggestions and chat are enabled
+
Your GitHub account: sampleuser
+
+
If anything's off, let us know in this thread. Your manager ({{form.manager}}) has been CC'd.
+ ⚠ Unknown variable:{{form.manager}} wasn't found in any of the last 30 requests for this tool. If a Form question was renamed (e.g. from "Manager" to "Manager email"), update the template. Otherwise this will render as literal text.
+
+
+
+
+
+
+
+
+
+ Notes. Same three-pane layout as the approval/completion editors so the muscle memory transfers. The preview renders with sample data (a hardcoded sample context) — Tobias sees what the message looks like before he hits Save. The unknown-variable warning is the key safety net against the silent-break risk: when a Form question is renamed, the template will visibly highlight the stale variable both in the preview and as a list at the bottom. Variables under "Form fields" are derived from the last N form_payload samples for this tool, so the picker shows what's actually available.
+
+
+
+
+
+
+
8 · Adaptive card in Teams
+
Posted by the Hub via Graph after ingest. Built in src/lib/teams/cards.ts as renderLicenseRequestCard().
+
+
+
+
+ Microsoft Teams · #ai-license-requests (private)
+
+
+
+
+
+
AI
+
+
AI Hub › ai-license-requests
+
Private · Tobias, Mathias, Lukas
+
+
+
+
+
Power Automate · 09:14
+
📥 New license request from Sofia Berger — Cursor Pro. Form submitted 09:14 · group chat opened with Sofia + approvers.
Approval message will be posted as a reply in this thread. Same content also DMed to the group chat.
+
+
+
+
+
+
+
+ Notes. The first message (Power Automate's "new license request" notice) already exists today. The Hub's contribution starts at the threaded reply below it — the adaptive card with the action button. Subsequent approval / completion messages from the Hub appear as further replies in the same thread, threading the conversation. The card is intentionally compact (4 fields max) — the deep link is the canonical surface for full detail. Graph rejects cards larger than ~28 KB, but at this size that's not a concern.
+
+
+
+
+
+
+
9 · Nav additions
+
Sidebar + Settings tab — one-line changes
+
+
+
+
+
Sidebar — src/components/app-sidebar.tsx
+
+
+
+ navItems[]
+
+
+
+
📊 Dashboard
+
🔧 Tools
+
👥 Users
+
📋 Assignments
+
+ 📥 Requests
+ 3
+ NEW
+
+
💰 Budget
+
📈 Reports
+
+
+
One new entry: { title: "Requests", href: "/requests", icon: FileText, roles: ["admin"] }. Badge with pending-count is optional — fetched server-side via the same admin layout, cached for the session.
+
+
+
+
Settings tabs — src/app/settings/settings-nav.tsx
+
+
+
+ adminTabs[]
+
+
+
+
+
Appearance
+
Integrations
+
Sync status
+
Anthropic config
+
+ License templates
+ NEW
+
+
+
+
+
One new entry: { label: "License templates", href: "/settings/license-templates" } appended to adminTabs.
+ Brainstorm
+ spec/032-automation-workflow
+ Direction chosen · P2.2
+
+
Automating the tool-license request workflow
+
+ Three architectures for replacing the manual Forms-review-provision-notify loop. Each represents a distinct trade between
+ licensing cost, time-to-ship, and how much logic lives inside the AI Developer Hub itself.
+
+ Update — current direction is P2.2. Three scope decisions are locked in:
+ (1) procurement and tool admin API calls stay 100% manual — the Hub records what humans did, it does not call Copilot / Anthropic admin APIs;
+ (2) the Hub owns all outbound Teams communication after ingest — PA's only new responsibility is forwarding the Forms response to /api/license-requests/ingest. The Hub posts the adaptive card directly as a reply to the channel notice and every subsequent approval / completion message via Microsoft Graph. No PA callback infrastructure;
+ (3) approvals are multi-user — the existing PA flow's group chat already contains the requester, Tobias, and at least one additional approver (all Hub admins). Any admin can claim any pending request from the Hub's queue; first-write-wins.
+ See Refined direction below.
+
+
+
Today's workflow (the pain)
+
+
+
[requester] fills out Microsoft Forms
+ │
+ ▼
+[Forms] response lands in Tobias's inbox / Forms results
+ │
+ ▼ MANUAL
+[Tobias] reads request, decides approve / reject
+ │
+ ▼ MANUAL
+[Tobias] opens the target tool's admin UI
+ creates or updates the user, assigns a seat
+ │
+ ▼ MANUAL
+[Tobias] opens Microsoft Teams, finds the requester,
+ types a confirmation message
+ │
+ ▼
+[requester] hears back (eventually)
+
+
Cost of the status quo
+
+
Three manual context switches per request (Forms → tool admin → Teams).
+
Latency is bounded by Tobias's attention, not the request itself.
+
No audit trail in the Hub — license assignments created in the tool drift from what the Hub knows about.
+
Bus factor 1. If Tobias is on PTO, requests pile up.
+
+
+
+
What we already have to build on
+
+
+
The Hub is not a greenfield project for this. The following pieces already exist and are reusable rather than throwaway:
+
+
+
Data model
+
+
users, aiTools, accessTiers, licenseAssignments already track every dimension a request would need.
+
licenseAssignments.source is already an enum — add "workflow" and every automated provision becomes traceable.
+
+
+
+
Outbound Teams
+
+
src/lib/teams/webhook.ts already posts Adaptive Cards to a Teams Workflow webhook with retry/backoff.
+
Card builders in src/lib/teams/cards.ts — proven pattern to extend with an approval card.
+
+
+
+
Inbound webhook pattern
+
+
POST /api/invoices/ingest shows the bearer-secret + logging + size-limit pattern. A request-intake endpoint is a near-direct clone.
+
+
+
+
User invite + email
+
+
createUser() + createInviteTokenForUser() + Resend email template are wired up (017-first-login-experience).
GitHub Copilot: full provisioning automatable — POST /orgs/{org}/copilot/billing/selected_users.
+
Anthropic: POST /v1/organizations/invites + workspace member endpoints (Admin API, separate sk-ant-admin-… key).
+
+
+
+
+
+
+ Heads-up on Teams webhooks.
+ Office 365 Connector "Incoming Webhook" URLs in Teams are being disabled this week (May 18–22 2026). The Hub already
+ uses the Workflows-style webhook helper, so we are on the supported replacement path — but any new automation
+ that adds Teams output should be on Workflows or Graph API, never the legacy connector.
+
+
+
Proposal 1 — Low-code: Power Automate owns the flow
+
+
+
+
P1: "Power Automate as the conductor"
+
+ Fastest to ship
+ Premium connector cost
+ Logic outside the Hub
+
+
+
+
Shape
+
[Forms] ─▶ Power Automate ─▶ Approval card to Tobias in Teams
+ │
+ ▼ on Approve
+ Power Automate calls Hub POST /api/license-requests/provision
+ │
+ ▼
+ Hub calls Copilot / Anthropic admin APIs
+ inserts licenseAssignments row
+ │
+ ▼
+ Power Automate sends Teams DM to requester
+
+
Why it's tempting
+
+
Almost no new app code — one new POST handler in the Hub plus an enum value.
+
Reuses Power Automate's built-in "Start and wait for an approval" UX — Tobias gets a tidy approve/reject card in Teams without us building one.
+
Branchy logic (tool A vs tool B, tier defaults, escalation) lives in a visual flow editor that a non-developer can adjust.
+
+
+
Why I'd hesitate
+
+
Generic HTTP action is Premium ($15/user/month per-user, or a per-flow plan). One license for Tobias is fine; mandatory if the Hub is to be called from PA.
+
Business logic is split across two systems. Debugging "why did this person get the wrong tier?" means opening Power Automate run history and the Hub logs.
+
No test coverage. Power Automate flows don't have unit tests; behaviour changes are tested by triggering real requests.
+
Power Automate is the single point of failure between the Form and any provisioning. If a connector breaks, the whole pipeline stalls silently until someone notices.
+
+
+
Effort
+
≈ 2–3 days. One Power Automate flow (Forms trigger → approval → HTTP → DM), one Hub endpoint, one Drizzle table for request audit log, one Adaptive Card layout.
+
+
+
Proposal 2 — Hybrid: PA is a dumb bridge, the Hub is the brain
[Forms] ─▶ Power Automate (only role: forward the response)
+ │
+ ▼
+ HubPOST /api/license-requests/ingest (bearer-secret, clone of invoice pattern)
+ │
+ ▼
+ Hub stores licenseRequests row (status=pending)
+ Hub posts Adaptive Card to Tobias via existing Teams Workflow webhook
+ │
+ ▼ Tobias clicks Approve (card's Action.Execute hits a Hub callback)
+ Hub calls Copilot/Anthropic APIs, writes licenseAssignments,
+ Hub sends confirmation DM to requester via Graph POST /chats/.../messages
+ │
+ ▼
+ Audit log + per-tool sync runs (reuse existing cron + sync framework)
+
+
Why this is the strongest middle path
+
+
No Premium connector needed. Power Automate's only job is "Forms → standard Teams Workflow webhook" (or the Hub uses the Forms-via-Graph beta API directly). Avoids the Premium HTTP action.
+
All branching, idempotency, retry, and tool-API logic lives in TypeScript — testable with Vitest, deployable with the rest of the Hub.
+
Reuses three existing pieces nearly verbatim: invoice-ingest pattern, Teams Workflow webhook helper, Resend invite email.
+
Single source of truth. Every request, every approval, every provisioning result is a row in the Hub's DB — visible in the existing UI without writing a new dashboard.
+
Easy to add an "AI triage" pass later: when a request lands, run a quick LLM call to pre-fill the suggested tool/tier and flag duplicates. Doesn't change the architecture, just adds a step.
+
+
+
What it costs us
+
+
One-time Entra app registration with ChatMessage.Send (application permission, admin-consented) to DM the requester from the Hub. Non-trivial but documented and one-shot.
+
Need to wire up the Adaptive Card's response path — Tobias's click has to land somewhere. Two routes: PA flow listens for "user responded to approval" (free), or we register a Teams bot endpoint (more work, more flexible).
+
More moving parts than P1. Worth it only if we want the audit trail and testability.
+
+
+
Effort
+
≈ 5–7 days. New licenseRequests table, ingest endpoint, approval card + callback, Graph DM helper (next to the Teams webhook one), provisioning service per tool (Copilot first, Anthropic second), basic UI on /requests for fallback / history.
+
+
+
Proposal 3 — Skip Forms entirely
+
+
+
+
P3: "The Hub is the request form"
+
+ No Power Automate
+ Cleanest architecture
+ Asks employees to learn a new entrypoint
+
+
+
+
Shape
+
[requester] ─▶ Hub /request-access (SSO-gated intake page)
+ │
+ ▼
+ Hub stores request, posts Adaptive Card to Tobias via Teams Workflow webhook
+ │
+ ▼ approve in-app or in-Teams
+ Hub provisions via tool APIs, DMs requester via Graph
+ │
+ ▼
+ everything is one system; nothing leaves TypeScript-land
+
+
Upside
+
+
Zero Power Automate dependency. Zero Premium licensing. Zero cross-system debugging.
+
Validation happens client-side with Zod + React Hook Form — no more bad inputs reaching the approval queue.
+
Pre-fill from the requester's identity: their name, manager, cost center come from the existing user record. Forms can't do that.
+
Naturally supports re-requests, cancellations, status checks, tier upgrades — all just extra screens on the same model.
+
+
+
Downside
+
+
Behaviour change. People know "fill out the Forms link." Pointing them at a new URL is a change-management task, not a code task.
+
Requires that anyone who can request a tool already has SSO access to the Hub. If today's Forms surface is used by people who don't have Hub accounts, P3 won't fit without an additional anonymous-intake mode.
+
More UI work than the other two — though it pays off as the Hub's "front door" beyond just this workflow.
+
+
+
Effort
+
≈ 8–10 days. Public-ish intake page, requester self-service status page, same approval/provisioning back end as P2, plus the change-management work to retire the Forms link.
+
+
+
Side-by-side
+
+
+
+
+
Dimension
+
P1 · Power Automate-led
+
P2 · Hub-led hybrid
+
P3 · Hub-only
+
+
+
+
+
Time to first working flow
+
2–3 days
+
5–7 days
+
8–10 days
+
+
+
Ongoing licensing cost
+
Premium PA seat (~$15/mo)
+
Standard PA only
+
None
+
+
+
Audit trail in the Hub
+
Partial (provision step only)
+
Complete
+
Complete
+
+
+
Testability (Vitest)
+
Flow logic is untestable
+
All core logic in TS
+
All core logic in TS
+
+
+
Single source of truth
+
Split PA / Hub
+
Hub DB
+
Hub DB
+
+
+
Requester experience
+
Same MS Form
+
Same MS Form
+
New URL to learn
+
+
+
Failure blast radius
+
Silent PA failures
+
Hub logs + alerts
+
Hub logs + alerts
+
+
+
Bus factor
+
PA flow tribal knowledge
+
Code in repo
+
Code in repo
+
+
+
Fits the existing app's grain
+
Loosely
+
Strongly — clones invoice pattern
+
Strongly
+
+
+
+
+
Refined direction (P2.2)
+
+
+
P2, with three locked decisions
+
After three rounds of refinement, the agreed shape:
+
+
Procurement and license codes stay 100% manual. No Copilot or Anthropic admin API calls from the Hub. The Hub records what humans did; it does not provision. Tool admin credentials never enter the Hub.
+
The Hub owns all outbound Teams communication after ingest. PA's existing actions stay (post the channel notice + open the group chat). PA gets one new action: forward the Forms response to the Hub /ingest endpoint. After that, every Teams message — the adaptive card reply, approval message, completion message — comes from the Hub directly via Microsoft Graph. No callback to PA.
+
Approvals are multi-user, first-write-wins. The existing PA flow's group chat already contains the requester, Tobias, and at least one additional approver — all of whom are Hub admins. The private Teams channel is also visible to admins. The Hub doesn't maintain a separate approver list; any admin can pick up any pending request from the /requests queue. Whoever commits the action first wins; concurrent admins get "already actioned by X" and refresh.
+
+
+ The Hub's job: intake → post the adaptive card reply → approval queue (visible to all admins) → completion form → all outbound Teams messaging → audit log.
+ Cost: one-time Entra app registration with Graph API permissions. Gain: single source of truth, testable code instead of PA flows, no callback dance.
+
+
+
+
The P2.2 shape
+
+
+
Sequence
+
[Forms]
+ │
+ ▼ EXISTING PA FLOW (one new action only)
+[Power Automate]
+ ├─ post request notice in private Teams channel (already)
+ │ channel members: all Hub admins / approvers
+ ├─ open / find group chat(already)
+ │ chat members: requester + Tobias + additional approver(s)
+ └─ NEW POST /api/license-requests/ingest to the Hub
+ payload: full form fields (variable per tier),
+ requester email,
+ team ID, channel ID, parent message ID,
+ group chat ID
+
+[Hub] ◀── synchronously on ingest
+ └─ posts adaptive card as reply to the parent message
+ via Graph POST .../messages/{parent}/replies
+ button → opens Hub /requests/[id]
+
+[Any admin in the channel / group chat]
+ ▼ clicks the deep link in Teams
+[Hub /requests/[id]]
+ ├─ admin signs in (existing admin-role check)
+ ├─ reviews the variable form payload (rendered per tier)
+ ├─ hits Approve (first-write-wins claim — concurrent
+ │ admins see "already actioned by X")
+ │ modal: approval template for this tool/tier,
+ │ pre-rendered with variables,
+ │ shown in a markdown editor + live preview
+ │ approver edits, hits Send
+ ├─ approval_message_md + decided_by saved
+ └─ status: pending_review → approved
+ ▼
+[Hub] posts approval message directly via Graph
+ ├─ as a reply in the channel thread (visible to admins)
+ └─ as a message in the group chat (visible to requester too)
+ ▼
+[Approver — manual, in the tool's admin UI]
+ ├─ creates / updates user
+ ├─ assigns the seat / tier
+ └─ generates the license code or API key
+ ▼
+[Hub /requests/[id]]
+ ├─ approver enters tier, license code, assignment date
+ ├─ Hub renders the completion template with those values,
+ │ shows it in the markdown editor; approver edits, sends
+ ├─ completion_message_md + completed_by saved
+ ├─ license_assignments row created
+ └─ status: approved → completed
+ ▼
+[Hub] posts completion message directly via Graph
+ ├─ as a reply in the channel thread
+ └─ as a message in the group chat
+
+
Why this shape
+
+
Single source of truth — the Hub owns everything after ingest. Request state, audit, and outbound Teams messaging all in one place.
+
No callback dance. PA does one thing (forward the Forms response) and is done. The Hub never waits on PA to confirm a message went out.
+
Threading falls out naturally: PA passes the parent message ID along; the Hub posts replies straight to that thread.
+
All Hub-side logic lives in TypeScript — testable with Vitest, version-controlled, reviewable in PRs. No business logic hidden in Power Automate flow editor.
+
"Manual procurement" is a first-class state — the workflow models reality, doesn't paper over it.
+
Hub never holds a tool admin credential. Smallest possible blast radius on the procurement side.
+
+
+ Cost: one-time Entra app registration with Graph ChannelMessage.Send + ChatMessage.Send application permissions (admin consent required). See Microsoft Graph setup below.
+
+
+
+
The boundary — automated vs. human
+
+
+
+
Automated by the Hub + PA
+
+
Receiving the Forms submission (PA, already)
+
Channel notice (to admins) + group chat with requester + approvers (PA, already)
+
Forwarding the form payload to the Hub (PA, one new action)
+
Recording the request as a typed row in the Hub
+
Deduplicating against recent identical requests
+
Posting the adaptive card as a threaded reply (Hub → Graph, new)
Tying the resulting assignment back to budgets / reports (Hub, automatic)
+
Approval and completion messages — channel reply (admins) + group chat post (requester sees too), direct from Hub via Graph
+
+
+
+
Stays manual — by design
+
+
Opening the tool's admin UI to provision the user
+
Generating / copying the API key or license code
+
Choosing the access tier when the request is ambiguous
+
Any conversation with procurement or finance
+
Tobias entering the completed assignment details back into the Hub
+
+
+ Tool admin credentials and license codes are sensitive enough that automating them buys little speed and meaningfully widens the blast radius. Manual procurement is the high-trust core; the Hub wraps it in a structured workflow.
+
+
+
+
+
Hub-side data model deltas
+
+
+
New table: license_requests
+
+
id, created_at, updated_at
+
form_response_id — unique; idempotency key for the ingest endpoint
+
requester_email, requester_name, optional requester_user_id (FK to users, when matched)
+
requested_tool_id (FK), requested_tier_id (FK, nullable when ambiguous)
+
form_payload — JSONB. The full set of fields submitted to MS Forms, keyed by the Form's question identifiers. Variable per tier — Basic might be just github_username, Max might include justification, intended workload, manager, etc.
+
teams_team_id, teams_channel_id, teams_parent_message_id, teams_chat_id — passed in by PA so the Hub can post replies and group-chat messages directly via Graph. Graph requires team + channel + parent message IDs for channel replies; chat ID for posting to the group chat. The teams_chat_id points at the group chat (requester + Tobias + additional approvers) that PA's existing flow already creates.
+ Resolution order at render time: (tool, tier, kind) → (tool, NULL, kind) → empty editor.
+
+
+
Two endpoints
+
+
POST /api/license-requests/ingest — bearer-secret, called by PA. Idempotent on form_response_id. Stores the full form payload as form_payload. Returns { id, hubUrl }.
+
POST /api/license-requests/[id]/approve and /reject and /complete — internal, called from Hub UI. Each writes the appropriate *_message_md, transitions state, and fires the PA callback.
+
+
+
Outbound to Microsoft Graph (not PA)
+
+
On ingest: Hub posts the adaptive card via Graph POST /teams/{teamId}/channels/{channelId}/messages/{parentMessageId}/replies.
+
On approve / complete: same channel-reply endpoint with the rendered message body (Tobias's edited markdown).
+
Optional DM to requester: POST /chats/{chatId}/messages using the chat ID PA already created.
+
All Graph calls go through a single helper module — see Microsoft Graph setup below.
+
+
+
+
Microsoft Graph setup (one-time)
+
+
+
+ Because the Hub posts directly to Teams, it needs a Microsoft Graph app registration with the right permissions.
+ This is a one-time setup; after it's done, no ongoing IT involvement is required.
+
+
+
Entra (Azure AD) app registration
+
+
Register a new app in Entra ID for the Hub.
+
Generate a client secret; store as GRAPH_CLIENT_SECRET.
+
Store the tenant ID and client ID as GRAPH_TENANT_ID and GRAPH_CLIENT_ID.
+
+
+
Required application permissions (admin consent)
+
+
ChannelMessage.Send — required. Used to reply in the private Teams channel (visible to admins / approvers).
+
ChatMessage.Send — also required. The requester is not in the private channel; they only see updates in the group chat PA created. Without this scope, the requester never learns the outcome from Teams.
+
Both can be narrowed via Resource-Specific Consent (RSC) if tenant policy disallows tenant-wide scopes — adds an app manifest install in the target team but limits the blast radius.
+
+
+
New helper: src/lib/teams/graph.ts
+
+
Sits alongside the existing src/lib/teams/webhook.ts (which stays in use for the alert/digest paths).
+
MSAL client-credentials token flow with in-memory caching (tokens expire ~1 hour; lazy refresh on miss).
Retry with backoff on 429 / 5xx, mirroring the existing webhook helper.
+
+
+
+ The Entra app registration + admin consent is the single longest-lead-time item — worth filing for it on day one of the build so it's ready when the helper module needs to be wired in.
+
+
+
+
Templates and variable form payloads
+
+
+
Per-tool / per-tier message templates
+
+ Approval and completion messages differ meaningfully by tool ("Welcome to Copilot — IDE setup steps inside" vs. "Your Claude workspace is ready — here's your API key"). Encode that as templates stored per tool, with optional per-tier overrides when a tier needs different copy (Basic vs. Max).
+
+
+
Variables available in templates
+
Mustache-style {{...}}. The renderer has access to:
{{licenseCode}} — present only at completion, when Tobias has entered it
+
{{form.fieldName}} — any field from the request's form_payload (e.g. {{form.github_username}})
+
{{requestUrl}} — deep link back to the Hub request (useful in approval messages)
+
+
+
Why markdown, not WYSIWYG
+
+ Messages flow Hub → PA → Teams. Markdown round-trips cleanly through Teams chat, diffs nicely in PRs, and is trivial to template. A WYSIWYG editor (TipTap, Lexical) is overkill for the formatting actually needed (bold, lists, links). Use a <textarea> + react-markdown preview side-by-side. If Tobias later wants WYSIWYG, swap the input component without changing storage.
+
+
+
The approve and complete flow
+
Both actions are confirm-and-edit, not fire-and-forget:
+
+
Approve opens a modal. The approval template for the request's tool+tier is rendered with current variables into a markdown editor with live preview. Tobias edits or accepts, hits Send. The final text is stored on the request as approval_message_md and shipped to PA via the callback.
+
Complete first asks for tier + license code + assignment date. Once entered, those values flow into the completion template's variables; the rendered text appears in the same editor. Same edit-and-send pattern, stored as completion_message_md.
+
Reject uses a free-text note (no template) — small enough that template machinery would be overhead.
+
+
+
Template management UI
+
+ A settings page at /settings/license-templates: list of (tool × tier × kind) rows with the markdown source. Per-tier override is created lazily — if no row exists, the tool default is shown as inherited and a "Customize for this tier" button creates the override.
+
+
+
+
+
Variable form payloads (per tier)
+
+ Different tiers ask for different things — Basic might collect only a GitHub username; Max collects justification, intended workload, manager approval. The Hub can't predict the field set, so PA forwards the full submission verbatim and the Hub stores and renders it as-is.
+
+
+
Storage
+
+ license_requests.form_payload JSONB, keyed by the Form's field names. No upfront schema — schema-by-convention from what MS Forms actually submitted.
+
+
+
Rendering in the request detail view
+
+ Default: a definition list with light prettification (snake_case → Title Case). Long-form fields (>120 chars) render as paragraphs; short fields as inline rows. Good enough for V1.
+
+
+
Phase 2 (optional, only if needed)
+
+ A field_schema JSONB on access_tiers mapping raw form keys to display labels + render hints (e.g. { "github_username": { "label": "GitHub username", "type": "github_link" } }). Skip until the default rendering proves insufficient — over-specifying upfront tends to lock in a structure that the Forms side then drifts away from.
+
+
+
+
Execution slices
+
+
+
00
File the Entra app registration. Day one, before any code. Request the Graph permissions (ChannelMessage.Send, optionally ChatMessage.Send) so admin consent is in flight while the rest is being built. This is the only piece with external lead time.
+
01
Schema + ingest endpoint. Add license_requests (with form_payload JSONB, the two *_message_md columns, and the four teams_*_id columns) and message_templates tables. Zod validator. Clone the invoice-ingest bearer-secret + size-limit + logging pattern. Idempotent on form_response_id.
+
02
Extend the existing PA flow with one action. Single HTTP POST to /api/license-requests/ingest, forwarding the form payload and the team / channel / parent-message / chat IDs already in scope. That's it for PA — no card posting, no callback receiver.
+
03
Microsoft Graph helper. New src/lib/teams/graph.ts with MSAL client-credentials token caching, postChannelReply(), postChatMessage(), and retry-with-backoff matching the existing webhook helper. Wire the initial-card post into the ingest endpoint so a freshly ingested request immediately replies in the channel.
+
04
Template management UI. A /settings/license-templates page: list of tool × tier × kind rows with markdown source. Lazy per-tier override creation. Markdown editor + live preview, sidebar listing available variables ({{requester.firstName}}, {{tool.name}}, {{licenseCode}}, {{form.*}}). Seed initial templates for each existing tool.
+
05
Approval queue + detail UI. Route /requests with a queue table. Detail page /requests/[id] renders the variable form_payload as a definition list (snake_case → Title Case). Approve and Complete each open a modal: pre-render the relevant template, drop into a markdown editor with preview, store the edited body, then call postChannelReply() and optionally postChatMessage(). Reject is a single textarea — no template.
+
06
Edge cases + optional AI triage. Duplicate warnings, Tobias-initiated cancellations, re-applies after rejection (new request linked to the prior). Optional: one LLM call on ingest to suggest tool/tier from the form payload and surface obvious anomalies.
+
+
+
+ Revised estimate: ≈ 5–7 days of build, plus the Entra registration lead time which runs in parallel. The Graph helper adds ~half a day vs. P2.1's PA-callback path, but removes the callback-receiver wiring and centralizes everything in TypeScript — net wash in code, net gain in operability.
+
+
+
Open questions
+
+
+
Microsoft Graph & Entra
+
+
Tenant policy on Graph scopes: does your tenant allow tenant-wide ChannelMessage.Send + ChatMessage.Send application permissions, or does IT require Resource-Specific Consent (RSC)? RSC narrows blast radius (specific team only) but adds an app-manifest install step. Big setup-time delta.
+
Secret rotation: client secrets expire (default 24 months). Plan to use Azure Key Vault, or just rotate in Vercel env vars when needed? Worth a calendar reminder.
+
+
+
PA flow
+
+
IDs in scope: does the existing flow already capture the team ID, channel ID, parent message ID, and group chat ID as variables ready to forward? The first three usually come back from the "Post message in a channel" action; the chat ID from "Create a chat." If any are missing we add a lookup, but no missing piece is fatal.
+
HTTP action licensing: the generic HTTP connector is Premium PA. Still one call (the /ingest forward), so if your tenant isn't already Premium, this single action is what would trigger the licensing requirement. Worth confirming before step 02.
+
+
+
Multi-approver
+
+
Tool-specific routing: first-write-wins is the default — any admin can approve any tool. Does any tool need a stricter rule (e.g. "only Mathias can approve Sitecore licenses")? If yes, model as a required_approver_id on ai_tools. Phase 2 unless there's a known case today.
+
Soft-claim UI: when an admin opens the approval modal, should the Hub mark the request as "in review by X" so other admins see it's being handled? Phase 2 polish — first-write-wins is fine for low-volume.
+
Identity of the approver in messages: templates can reference {{approver.firstName}} so the approval message says "Approved by Anna" rather than just "Approved." Default: include it. Confirm.
+
Notification to the requester when nobody acts: if a request sits in pending_review for N days, should the Hub nudge the channel? Worth deciding what stale looks like before the queue starts to build up.
+
+
+
Approval & assignment
+
+
Requester identity matching: match by email against existing users, or create a stub row? Affects whether the assignment shows up in the requester's existing Hub profile and reports.
+
License code storage: the existing license_assignments.apiKey column is encrypted — natural fit. Confirm that's where the code lives (vs. a separate "license code" column).
+
+
+
Templates & form payload
+
+
Template inheritance: per-tool default + optional per-tier override. Granular enough, or do we need anything beyond that?
+
Variable contract with MS Forms:{{form.*}} variables only work if Form question identifiers are stable. Whenever a Form question is renamed, referencing templates break silently. Worth confirming that PA forwards question keys (not labels) and that the template editor flags unknown variables.
+
Rejection template: P2.2 says reject is free-text only, no template. Confirm — or add a per-tool rejection template if some tools have a stock "denied because budget" message worth canning.
+
Card content in Teams: minimal "review in Hub" link, or richer card showing requester / tool / tier inline so Tobias can sometimes decide without leaving Teams? Affects whether the click-through is required or optional.
+
Form payload size cap: JSONB has no practical limit, but a 64 KB sanity bound at the ingest endpoint prevents a runaway MS Forms misconfiguration from filling the table.
+
Card updates vs. new replies: P2.2 posts a fresh reply for each event (approval, completion). Alternative: edit/refresh the original card to show latest status via Graph PATCH .../messages/{id}. More elegant but harder to thread the conversation. Stick with "new replies" for v1.
+
+
+
+
+
+
+
+
diff --git a/src/actions/license-requests.ts b/src/actions/license-requests.ts
new file mode 100644
index 0000000..8b41870
--- /dev/null
+++ b/src/actions/license-requests.ts
@@ -0,0 +1,496 @@
+"use server";
+
+import { db } from "@/lib/db";
+import {
+ licenseRequests,
+ licenseAssignments,
+ aiTools,
+ accessTiers,
+ users,
+} from "@/lib/db/schema";
+import { eq, and, sql } from "drizzle-orm";
+import { revalidatePath } from "next/cache";
+import { requireAdmin } from "@/lib/auth-helpers";
+import { encryptApiKey } from "@/lib/crypto";
+import {
+ approveRequestSchema,
+ rejectRequestSchema,
+ completeRequestSchema,
+ cancelRequestSchema,
+} from "@/lib/validators";
+import type { ActionResult } from "@/types";
+import { postChannelReply, postChatMessage } from "@/lib/teams/graph";
+
+export interface LicenseRequestRow {
+ id: number;
+ formResponseId: string;
+ requesterEmail: string;
+ requesterName: string;
+ requesterUserId: number | null;
+ requestedToolId: number;
+ requestedToolName: string;
+ requestedTierId: number | null;
+ requestedTierName: string | null;
+ status: "pending_review" | "approved" | "rejected" | "completed" | "cancelled";
+ decidedBy: number | null;
+ decidedByName: string | null;
+ decidedAt: Date | null;
+ decisionNote: string | null;
+ completedBy: number | null;
+ completedByName: string | null;
+ completedAt: Date | null;
+ approvalMessageMd: string | null;
+ completionMessageMd: string | null;
+ assignmentId: number | null;
+ createdAt: Date;
+}
+
+export interface LicenseRequestDetail extends LicenseRequestRow {
+ formPayload: Record;
+ teamsTeamId: string;
+ teamsChannelId: string;
+ teamsParentMessageId: string;
+ teamsChatId: string;
+}
+
+const decidedByUsers = sql`decided_user.name`.as("decided_by_name");
+const completedByUsers = sql`completed_user.name`.as("completed_by_name");
+
+export async function listLicenseRequests(): Promise {
+ const rows = await db.execute(sql`
+ SELECT
+ lr.id, lr.form_response_id, lr.requester_email, lr.requester_name,
+ lr.requester_user_id,
+ lr.requested_tool_id, t.name AS tool_name,
+ lr.requested_tier_id, ti.name AS tier_name,
+ lr.status, lr.decided_by, du.name AS decided_by_name, lr.decided_at, lr.decision_note,
+ lr.completed_by, cu.name AS completed_by_name, lr.completed_at,
+ lr.approval_message_md, lr.completion_message_md,
+ lr.assignment_id, lr.created_at
+ FROM license_requests lr
+ LEFT JOIN ai_tools t ON t.id = lr.requested_tool_id
+ LEFT JOIN access_tiers ti ON ti.id = lr.requested_tier_id
+ LEFT JOIN users du ON du.id = lr.decided_by
+ LEFT JOIN users cu ON cu.id = lr.completed_by
+ ORDER BY lr.created_at DESC
+ `);
+ return (rows.rows as Array>).map(mapRow);
+}
+
+export async function getLicenseRequest(id: number): Promise {
+ const rows = await db.execute(sql`
+ SELECT
+ lr.id, lr.form_response_id, lr.requester_email, lr.requester_name,
+ lr.requester_user_id,
+ lr.requested_tool_id, t.name AS tool_name,
+ lr.requested_tier_id, ti.name AS tier_name,
+ lr.status, lr.decided_by, du.name AS decided_by_name, lr.decided_at, lr.decision_note,
+ lr.completed_by, cu.name AS completed_by_name, lr.completed_at,
+ lr.approval_message_md, lr.completion_message_md,
+ lr.assignment_id, lr.created_at,
+ lr.form_payload, lr.teams_team_id, lr.teams_channel_id,
+ lr.teams_parent_message_id, lr.teams_chat_id
+ FROM license_requests lr
+ LEFT JOIN ai_tools t ON t.id = lr.requested_tool_id
+ LEFT JOIN access_tiers ti ON ti.id = lr.requested_tier_id
+ LEFT JOIN users du ON du.id = lr.decided_by
+ LEFT JOIN users cu ON cu.id = lr.completed_by
+ WHERE lr.id = ${id}
+ LIMIT 1
+ `);
+ const row = (rows.rows as Array>)[0];
+ if (!row) return null;
+ const base = mapRow(row);
+ return {
+ ...base,
+ formPayload: (row.form_payload as Record) ?? {},
+ teamsTeamId: row.teams_team_id as string,
+ teamsChannelId: row.teams_channel_id as string,
+ teamsParentMessageId: row.teams_parent_message_id as string,
+ teamsChatId: row.teams_chat_id as string,
+ };
+}
+
+function mapRow(row: Record): LicenseRequestRow {
+ return {
+ id: row.id as number,
+ formResponseId: row.form_response_id as string,
+ requesterEmail: row.requester_email as string,
+ requesterName: row.requester_name as string,
+ requesterUserId: (row.requester_user_id as number | null) ?? null,
+ requestedToolId: row.requested_tool_id as number,
+ requestedToolName: (row.tool_name as string | null) ?? "(unknown)",
+ requestedTierId: (row.requested_tier_id as number | null) ?? null,
+ requestedTierName: (row.tier_name as string | null) ?? null,
+ status: row.status as LicenseRequestRow["status"],
+ decidedBy: (row.decided_by as number | null) ?? null,
+ decidedByName: (row.decided_by_name as string | null) ?? null,
+ decidedAt: (row.decided_at as Date | null) ?? null,
+ decisionNote: (row.decision_note as string | null) ?? null,
+ completedBy: (row.completed_by as number | null) ?? null,
+ completedByName: (row.completed_by_name as string | null) ?? null,
+ completedAt: (row.completed_at as Date | null) ?? null,
+ approvalMessageMd: (row.approval_message_md as string | null) ?? null,
+ completionMessageMd: (row.completion_message_md as string | null) ?? null,
+ assignmentId: (row.assignment_id as number | null) ?? null,
+ createdAt: row.created_at as Date,
+ };
+}
+
+// — Mutating actions — ---------------------------------------------------
+
+export async function approveRequest(
+ input: unknown,
+): Promise> {
+ const admin = await requireAdmin();
+ if (!admin) return { success: false, error: "Unauthorized" };
+
+ const parsed = approveRequestSchema.safeParse(input);
+ if (!parsed.success) {
+ return {
+ success: false,
+ error: parsed.error.issues[0]?.message ?? "Validation failed",
+ };
+ }
+
+ const { requestId, bodyMd } = parsed.data;
+
+ // First-write-wins: UPDATE ... WHERE status='pending_review'. Zero rows updated
+ // means another admin claimed it first.
+ const updated = await db
+ .update(licenseRequests)
+ .set({
+ status: "approved",
+ decidedBy: Number(admin.id),
+ decidedAt: new Date(),
+ approvalMessageMd: bodyMd,
+ updatedAt: new Date(),
+ })
+ .where(
+ and(
+ eq(licenseRequests.id, requestId),
+ eq(licenseRequests.status, "pending_review"),
+ ),
+ )
+ .returning({
+ id: licenseRequests.id,
+ teamsTeamId: licenseRequests.teamsTeamId,
+ teamsChannelId: licenseRequests.teamsChannelId,
+ teamsParentMessageId: licenseRequests.teamsParentMessageId,
+ teamsChatId: licenseRequests.teamsChatId,
+ });
+
+ if (updated.length === 0) {
+ return {
+ success: false,
+ error: "This request has already been actioned by another admin. Refresh to see the latest state.",
+ };
+ }
+
+ const row = updated[0];
+ await postToTeamsSafe({
+ teamId: row.teamsTeamId,
+ channelId: row.teamsChannelId,
+ parentMessageId: row.teamsParentMessageId,
+ chatId: row.teamsChatId,
+ bodyMd,
+ });
+
+ revalidatePath("/requests");
+ revalidatePath(`/requests/${requestId}`);
+ return { success: true, data: { requestId } };
+}
+
+export async function rejectRequest(
+ input: unknown,
+): Promise> {
+ const admin = await requireAdmin();
+ if (!admin) return { success: false, error: "Unauthorized" };
+
+ const parsed = rejectRequestSchema.safeParse(input);
+ if (!parsed.success) {
+ return {
+ success: false,
+ error: parsed.error.issues[0]?.message ?? "Validation failed",
+ };
+ }
+
+ const { requestId, decisionNote } = parsed.data;
+
+ const updated = await db
+ .update(licenseRequests)
+ .set({
+ status: "rejected",
+ decidedBy: Number(admin.id),
+ decidedAt: new Date(),
+ decisionNote,
+ updatedAt: new Date(),
+ })
+ .where(
+ and(
+ eq(licenseRequests.id, requestId),
+ eq(licenseRequests.status, "pending_review"),
+ ),
+ )
+ .returning({
+ id: licenseRequests.id,
+ teamsTeamId: licenseRequests.teamsTeamId,
+ teamsChannelId: licenseRequests.teamsChannelId,
+ teamsParentMessageId: licenseRequests.teamsParentMessageId,
+ teamsChatId: licenseRequests.teamsChatId,
+ });
+
+ if (updated.length === 0) {
+ return {
+ success: false,
+ error: "This request has already been actioned by another admin. Refresh to see the latest state.",
+ };
+ }
+
+ const row = updated[0];
+ await postToTeamsSafe({
+ teamId: row.teamsTeamId,
+ channelId: row.teamsChannelId,
+ parentMessageId: row.teamsParentMessageId,
+ chatId: row.teamsChatId,
+ bodyMd: `**Request rejected.**\n\n${decisionNote}`,
+ });
+
+ revalidatePath("/requests");
+ revalidatePath(`/requests/${requestId}`);
+ return { success: true, data: { requestId } };
+}
+
+export async function completeRequest(
+ input: unknown,
+): Promise> {
+ const admin = await requireAdmin();
+ if (!admin) return { success: false, error: "Unauthorized" };
+
+ const parsed = completeRequestSchema.safeParse(input);
+ if (!parsed.success) {
+ return {
+ success: false,
+ error: parsed.error.issues[0]?.message ?? "Validation failed",
+ };
+ }
+
+ const { requestId, tierId, licenseCode, assignedAt, bodyMd } = parsed.data;
+
+ // Fetch the request so we have the tool / requester to build the assignment.
+ const req = await db.query.licenseRequests.findFirst({
+ where: eq(licenseRequests.id, requestId),
+ columns: {
+ id: true,
+ status: true,
+ requestedToolId: true,
+ requesterUserId: true,
+ teamsTeamId: true,
+ teamsChannelId: true,
+ teamsParentMessageId: true,
+ teamsChatId: true,
+ },
+ });
+
+ if (!req) return { success: false, error: "Request not found" };
+ if (req.status !== "approved") {
+ return {
+ success: false,
+ error: `Cannot complete a request in status "${req.status}". Must be "approved".`,
+ };
+ }
+ if (!req.requesterUserId) {
+ return {
+ success: false,
+ error: "Requester is not matched to a Hub user. Create the user first (see /users) and re-link the request.",
+ };
+ }
+
+ // Fetch tier cost — used for cost_at_assignment_cents.
+ const tier = await db.query.accessTiers.findFirst({
+ where: eq(accessTiers.id, tierId),
+ columns: { id: true, monthlyCostCents: true, toolId: true },
+ });
+ if (!tier || tier.toolId !== req.requestedToolId) {
+ return { success: false, error: "Tier does not belong to the requested tool." };
+ }
+
+ // Block accidental duplicate seats: if the requester already has an active
+ // assignment for this tool, fail loudly. The DB has no unique constraint
+ // here (different tiers / API keys are legitimate parallel rows), so this
+ // is the only check that prevents the workflow from silently double-counting
+ // a seat in cost reports.
+ const existingActive = await db.query.licenseAssignments.findFirst({
+ where: and(
+ eq(licenseAssignments.userId, req.requesterUserId),
+ eq(licenseAssignments.toolId, req.requestedToolId),
+ eq(licenseAssignments.status, "active"),
+ ),
+ columns: { id: true, tierId: true },
+ });
+ if (existingActive) {
+ return {
+ success: false,
+ error: `Requester already has an active assignment for this tool (assignment #${existingActive.id}). Revoke the existing assignment first, or cancel this request.`,
+ };
+ }
+
+ // Encrypt the license code with the same helper the manual-assignment flow uses.
+ // Anything stored in apiKeyEncrypted MUST go through encryptApiKey() — the
+ // column is read back via decryptApiKey() in the assignment detail view.
+ const apiKeyEncrypted =
+ licenseCode && licenseCode !== "" ? await encryptApiKey(licenseCode) : null;
+
+ // Sentinel for the first-write-wins race: the inner transaction returns
+ // `null` when the status-conditioned UPDATE matched 0 rows, the outer
+ // function converts that into a structured ActionResult so the caller never
+ // sees a 500.
+ const RACE_LOST = Symbol("race-lost");
+
+ // Atomic: create assignment, link to request, transition status.
+ const result = await db.transaction(async (tx) => {
+ const [assignment] = await tx
+ .insert(licenseAssignments)
+ .values({
+ userId: req.requesterUserId!,
+ toolId: req.requestedToolId,
+ tierId,
+ costAtAssignmentCents: tier.monthlyCostCents,
+ assignedAt: new Date(`${assignedAt}T00:00:00Z`),
+ apiKeyEncrypted,
+ source: "license-request-workflow",
+ })
+ .returning({ id: licenseAssignments.id });
+
+ // First-write-wins on completion too.
+ const updated = await tx
+ .update(licenseRequests)
+ .set({
+ status: "completed",
+ completedBy: Number(admin.id),
+ completedAt: new Date(),
+ completionMessageMd: bodyMd,
+ assignmentId: assignment.id,
+ requestedTierId: tierId,
+ updatedAt: new Date(),
+ })
+ .where(
+ and(
+ eq(licenseRequests.id, requestId),
+ eq(licenseRequests.status, "approved"),
+ ),
+ )
+ .returning({ id: licenseRequests.id });
+
+ if (updated.length === 0) {
+ // Throw to trigger the Drizzle rollback of the assignment insert above —
+ // then catch outside and surface a clean ActionResult.
+ throw RACE_LOST;
+ }
+
+ return { assignmentId: assignment.id };
+ }).catch((err) => {
+ if (err === RACE_LOST) return null;
+ throw err;
+ });
+
+ if (result === null) {
+ return {
+ success: false,
+ error: "This request was actioned by another admin while you were entering details. Refresh to see the latest state.",
+ };
+ }
+
+ await postToTeamsSafe({
+ teamId: req.teamsTeamId,
+ channelId: req.teamsChannelId,
+ parentMessageId: req.teamsParentMessageId,
+ chatId: req.teamsChatId,
+ bodyMd,
+ });
+
+ revalidatePath("/requests");
+ revalidatePath(`/requests/${requestId}`);
+ revalidatePath("/assignments");
+ return { success: true, data: { requestId, assignmentId: result.assignmentId } };
+}
+
+export async function cancelRequest(
+ input: unknown,
+): Promise> {
+ const admin = await requireAdmin();
+ if (!admin) return { success: false, error: "Unauthorized" };
+
+ const parsed = cancelRequestSchema.safeParse(input);
+ if (!parsed.success) {
+ return { success: false, error: parsed.error.issues[0]?.message ?? "Validation failed" };
+ }
+
+ const { requestId } = parsed.data;
+
+ const updated = await db
+ .update(licenseRequests)
+ .set({ status: "cancelled", updatedAt: new Date() })
+ .where(
+ and(
+ eq(licenseRequests.id, requestId),
+ // Only pending or approved requests can be cancelled.
+ sql`${licenseRequests.status} IN ('pending_review', 'approved')`,
+ ),
+ )
+ .returning({ id: licenseRequests.id });
+
+ if (updated.length === 0) {
+ return { success: false, error: "Only pending or approved requests can be cancelled." };
+ }
+
+ revalidatePath("/requests");
+ revalidatePath(`/requests/${requestId}`);
+ return { success: true, data: { requestId } };
+}
+
+/** Post the same body to both the channel thread (visible to admins) and
+ * the group chat (visible to the requester). Errors are swallowed and
+ * logged — the DB transition has already committed; the message is best-effort.
+ *
+ * When Graph is not configured (IT-112678 pending), both calls no-op. */
+async function postToTeamsSafe(args: {
+ teamId: string;
+ channelId: string;
+ parentMessageId: string;
+ chatId: string;
+ bodyMd: string;
+}): Promise {
+ await Promise.allSettled([
+ postChannelReply({
+ teamId: args.teamId,
+ channelId: args.channelId,
+ parentMessageId: args.parentMessageId,
+ bodyMarkdown: args.bodyMd,
+ }),
+ postChatMessage({
+ chatId: args.chatId,
+ bodyMarkdown: args.bodyMd,
+ }),
+ ]).then((results) => {
+ for (const r of results) {
+ if (r.status === "rejected") {
+ console.error("[license-requests] Teams post failed:", r.reason);
+ }
+ }
+ });
+}
+
+export async function getRequestContext(requestId: number) {
+ const detail = await getLicenseRequest(requestId);
+ if (!detail) return null;
+ return {
+ detail,
+ // Tiers for the completion modal (full list for the request's tool).
+ tiers: await db.query.accessTiers.findMany({
+ where: eq(accessTiers.toolId, detail.requestedToolId),
+ columns: { id: true, name: true, monthlyCostCents: true },
+ orderBy: (t, { asc }) => [asc(t.name)],
+ }),
+ };
+}
diff --git a/src/actions/license-templates.ts b/src/actions/license-templates.ts
new file mode 100644
index 0000000..e2dd470
--- /dev/null
+++ b/src/actions/license-templates.ts
@@ -0,0 +1,162 @@
+"use server";
+
+import { db } from "@/lib/db";
+import { messageTemplates, aiTools, accessTiers } from "@/lib/db/schema";
+import { and, eq, isNull, sql } from "drizzle-orm";
+import { revalidatePath } from "next/cache";
+import { requireAdmin } from "@/lib/auth-helpers";
+import { messageTemplateSchema } from "@/lib/validators";
+import type { ActionResult } from "@/types";
+
+export interface MessageTemplateRow {
+ id: number;
+ toolId: number;
+ toolName: string;
+ tierId: number | null;
+ tierName: string | null;
+ kind: "approval" | "completion";
+ bodyMd: string;
+ updatedAt: Date;
+}
+
+export async function listMessageTemplates(): Promise {
+ const rows = await db
+ .select({
+ id: messageTemplates.id,
+ toolId: messageTemplates.toolId,
+ toolName: aiTools.name,
+ tierId: messageTemplates.tierId,
+ tierName: accessTiers.name,
+ kind: messageTemplates.kind,
+ bodyMd: messageTemplates.bodyMd,
+ updatedAt: messageTemplates.updatedAt,
+ })
+ .from(messageTemplates)
+ .leftJoin(aiTools, eq(messageTemplates.toolId, aiTools.id))
+ .leftJoin(accessTiers, eq(messageTemplates.tierId, accessTiers.id))
+ .orderBy(aiTools.name, messageTemplates.tierId, messageTemplates.kind);
+
+ return rows.map((r) => ({
+ id: r.id,
+ toolId: r.toolId,
+ toolName: r.toolName ?? "(unknown tool)",
+ tierId: r.tierId,
+ tierName: r.tierName,
+ kind: r.kind,
+ bodyMd: r.bodyMd,
+ updatedAt: r.updatedAt,
+ }));
+}
+
+export async function upsertMessageTemplate(
+ input: unknown,
+): Promise> {
+ const admin = await requireAdmin();
+ if (!admin) return { success: false, error: "Unauthorized" };
+
+ const parsed = messageTemplateSchema.safeParse(input);
+ if (!parsed.success) {
+ return {
+ success: false,
+ error: "Validation failed",
+ fieldErrors: parsed.error.flatten().fieldErrors as Record,
+ };
+ }
+
+ const { toolId, tierId, kind, bodyMd } = parsed.data;
+
+ // Look up existing row using a partial-index-friendly match.
+ const existing = await db.query.messageTemplates.findFirst({
+ where: and(
+ eq(messageTemplates.toolId, toolId),
+ eq(messageTemplates.kind, kind),
+ tierId === null ? isNull(messageTemplates.tierId) : eq(messageTemplates.tierId, tierId),
+ ),
+ columns: { id: true },
+ });
+
+ let id: number;
+ if (existing) {
+ await db
+ .update(messageTemplates)
+ .set({ bodyMd, updatedAt: new Date() })
+ .where(eq(messageTemplates.id, existing.id));
+ id = existing.id;
+ } else {
+ const [row] = await db
+ .insert(messageTemplates)
+ .values({ toolId, tierId, kind, bodyMd })
+ .returning({ id: messageTemplates.id });
+ id = row.id;
+ }
+
+ revalidatePath("/settings/license-templates");
+ return { success: true, data: { id } };
+}
+
+export async function deleteMessageTemplate(
+ input: { id: number },
+): Promise> {
+ const admin = await requireAdmin();
+ if (!admin) return { success: false, error: "Unauthorized" };
+
+ await db.delete(messageTemplates).where(eq(messageTemplates.id, input.id));
+ revalidatePath("/settings/license-templates");
+ return { success: true, data: undefined };
+}
+
+export interface ToolWithTiers {
+ id: number;
+ name: string;
+ tiers: { id: number; name: string }[];
+}
+
+export async function listToolsWithTiers(): Promise {
+ const rows = await db
+ .select({
+ toolId: aiTools.id,
+ toolName: aiTools.name,
+ tierId: accessTiers.id,
+ tierName: accessTiers.name,
+ })
+ .from(aiTools)
+ .leftJoin(accessTiers, eq(accessTiers.toolId, aiTools.id))
+ .where(eq(aiTools.status, "active"))
+ .orderBy(aiTools.name, accessTiers.name);
+
+ const map = new Map();
+ for (const r of rows) {
+ let entry = map.get(r.toolId);
+ if (!entry) {
+ entry = { id: r.toolId, name: r.toolName, tiers: [] };
+ map.set(r.toolId, entry);
+ }
+ if (r.tierId !== null && r.tierName !== null) {
+ entry.tiers.push({ id: r.tierId, name: r.tierName });
+ }
+ }
+ return Array.from(map.values());
+}
+
+/** Returns the union of form_payload top-level keys observed across the
+ * most recent N license_requests for a given tool. Powers the variable
+ * picker in the template editor. */
+export async function recentFormKeysForTool(
+ toolId: number,
+ limit = 30,
+): Promise {
+ // Run a raw aggregation — cheaper than fetching N JSONB blobs and unpacking
+ // in JS, especially as the history grows.
+ const result = await db.execute(sql`
+ SELECT DISTINCT key
+ FROM (
+ SELECT jsonb_object_keys(form_payload) AS key
+ FROM license_requests
+ WHERE requested_tool_id = ${toolId}
+ ORDER BY created_at DESC
+ LIMIT ${limit}
+ ) sub
+ ORDER BY key
+ `);
+ return (result.rows as { key: string }[]).map((r) => r.key);
+}
diff --git a/src/app/api/license-requests/ingest/route.ts b/src/app/api/license-requests/ingest/route.ts
new file mode 100644
index 0000000..5ab7542
--- /dev/null
+++ b/src/app/api/license-requests/ingest/route.ts
@@ -0,0 +1,254 @@
+// POST /api/license-requests/ingest — spec 032-automation-workflow Phase 2.
+//
+// Bearer-secret-protected ingest endpoint that Power Automate calls when a
+// Microsoft Form is submitted. Idempotent on formResponseId.
+
+import { NextRequest, NextResponse } from "next/server";
+import { db } from "@/lib/db";
+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 { postLicenseRequestCard } from "@/lib/teams/graph";
+
+export const dynamic = "force-dynamic";
+
+// 64 KB sanity cap — protects against runaway MS Forms misconfiguration
+// filling the table. Matches the figure in proposals.html.
+const MAX_BODY_BYTES = 64 * 1024;
+
+function hubBaseUrl(): string {
+ // NextAuth's AUTH_URL is the canonical "where the app lives" var in this codebase.
+ return (
+ process.env.AUTH_URL?.replace(/\/$/, "") ??
+ process.env.NEXTAUTH_URL?.replace(/\/$/, "") ??
+ "http://localhost:3000"
+ );
+}
+
+export async function POST(request: NextRequest) {
+ const authError = requireBearerSecret(request, "LICENSE_REQUEST_INGEST_SECRET");
+ if (authError) return authError;
+
+ // Body size cap — pre-read on Content-Length, post-read fallback.
+ const contentLengthHeader = request.headers.get("content-length");
+ if (contentLengthHeader) {
+ const len = Number.parseInt(contentLengthHeader, 10);
+ if (Number.isFinite(len) && len > MAX_BODY_BYTES) {
+ return NextResponse.json(
+ { success: false, error: "Body exceeds 64 KB limit" },
+ { status: 413 },
+ );
+ }
+ }
+
+ let payload: unknown;
+ try {
+ const text = await request.text();
+ if (Buffer.byteLength(text, "utf8") > MAX_BODY_BYTES) {
+ return NextResponse.json(
+ { success: false, error: "Body exceeds 64 KB limit" },
+ { status: 413 },
+ );
+ }
+ payload = JSON.parse(text);
+ } catch {
+ await logIngestionAttempt({
+ outcome: "failed",
+ errorMessage: "Invalid JSON body",
+ channel: "api",
+ });
+ return NextResponse.json(
+ { success: false, error: "Invalid JSON body" },
+ { status: 400 },
+ );
+ }
+
+ 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({
+ outcome: "failed",
+ errorMessage: error,
+ channel: "api",
+ });
+ return NextResponse.json({ success: false, error }, { status: 400 });
+ }
+
+ const input = parsed.data;
+
+ // Resolve tool — by ID if provided, else by case-insensitive name.
+ let toolId: number;
+ let toolName: string;
+ if (input.toolId !== undefined) {
+ const tool = await db.query.aiTools.findFirst({
+ where: eq(aiTools.id, input.toolId),
+ columns: { id: true, name: true },
+ });
+ if (!tool) {
+ return jsonError(
+ `Tool not found (id=${input.toolId})`,
+ 422,
+ input.formResponseId,
+ );
+ }
+ toolId = tool.id;
+ toolName = tool.name;
+ } else if (input.toolName) {
+ const tool = await db
+ .select({ id: aiTools.id, name: aiTools.name })
+ .from(aiTools)
+ .where(sql`lower(${aiTools.name}) = lower(${input.toolName})`)
+ .limit(1);
+ if (tool.length === 0) {
+ return jsonError(
+ `Tool not found (name="${input.toolName}")`,
+ 422,
+ input.formResponseId,
+ );
+ }
+ toolId = tool[0].id;
+ toolName = tool[0].name;
+ } else {
+ // Refine guard already enforces this — defensive
+ return jsonError("toolId or toolName is required", 400, input.formResponseId);
+ }
+
+ // Resolve tier (optional).
+ let tierId: number | null = null;
+ let tierName: string | null = null;
+ if (input.tierId !== undefined) {
+ const tier = await db.query.accessTiers.findFirst({
+ where: eq(accessTiers.id, input.tierId),
+ columns: { id: true, name: true, toolId: true },
+ });
+ if (!tier || tier.toolId !== toolId) {
+ return jsonError(
+ `Tier not found for tool (tierId=${input.tierId})`,
+ 422,
+ input.formResponseId,
+ );
+ }
+ tierId = tier.id;
+ tierName = tier.name;
+ } else if (input.tierName) {
+ const tier = await db
+ .select({ id: accessTiers.id, name: accessTiers.name })
+ .from(accessTiers)
+ .where(
+ sql`lower(${accessTiers.name}) = lower(${input.tierName}) and ${accessTiers.toolId} = ${toolId}`,
+ )
+ .limit(1);
+ if (tier.length > 0) {
+ tierId = tier[0].id;
+ tierName = tier[0].name;
+ }
+ // If not found, leave nullable — admin can pick at approve time
+ }
+
+ // Optional requester match by email.
+ const matchedUser = await db.query.users.findFirst({
+ where: eq(users.email, input.requesterEmail.toLowerCase()),
+ columns: { id: true },
+ });
+
+ // Idempotency — formResponseId is unique. Try insert; if conflict, return existing.
+ let requestId: number;
+ let deduped = false;
+ try {
+ const [row] = await db
+ .insert(licenseRequests)
+ .values({
+ formResponseId: input.formResponseId,
+ requesterEmail: input.requesterEmail.toLowerCase(),
+ requesterName: input.requesterName,
+ requesterUserId: matchedUser?.id ?? null,
+ requestedToolId: toolId,
+ requestedTierId: tierId,
+ formPayload: input.formPayload,
+ teamsTeamId: input.teamsTeamId,
+ teamsChannelId: input.teamsChannelId,
+ teamsParentMessageId: input.teamsParentMessageId,
+ teamsChatId: input.teamsChatId,
+ })
+ .returning({ id: licenseRequests.id });
+ requestId = row.id;
+ } catch (err) {
+ // Most likely unique violation on form_response_id — look up the existing row.
+ const existing = await db.query.licenseRequests.findFirst({
+ where: eq(licenseRequests.formResponseId, input.formResponseId),
+ columns: { id: true },
+ });
+ if (!existing) {
+ console.error("license-requests ingest insert error:", err);
+ await logIngestionAttempt({
+ outcome: "failed",
+ errorMessage: "Database insert failed",
+ channel: "api",
+ invoiceNumber: input.formResponseId,
+ });
+ return NextResponse.json(
+ { success: false, error: "An unexpected error occurred. Please try again." },
+ { status: 500 },
+ );
+ }
+ requestId = existing.id;
+ deduped = true;
+ }
+
+ const hubUrl = `${hubBaseUrl()}/requests/${requestId}`;
+
+ // Fire-and-forget Graph card post. We don't await this for the response —
+ // the request row is the source of truth; the Teams reply is a nice-to-have.
+ // When Graph isn't configured, postLicenseRequestCard logs and returns.
+ if (!deduped) {
+ void postLicenseRequestCard({
+ teamId: input.teamsTeamId,
+ channelId: input.teamsChannelId,
+ parentMessageId: input.teamsParentMessageId,
+ requestId,
+ requesterName: input.requesterName,
+ toolName,
+ tierName,
+ hubUrl,
+ }).catch((err) => {
+ console.error("[license-requests] Graph card post failed:", err);
+ });
+ }
+
+ await logIngestionAttempt({
+ outcome: deduped ? "filtered" : "success",
+ errorMessage: deduped ? "Duplicate form response (idempotent)" : null,
+ 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,
+ });
+
+ return NextResponse.json(
+ {
+ success: true,
+ data: { requestId, hubUrl, deduped },
+ },
+ { status: deduped ? 200 : 201 },
+ );
+}
+
+function jsonError(error: string, status: number, formResponseId?: string) {
+ // Fire-and-forget log — don't block the response on logger failures.
+ void logIngestionAttempt({
+ outcome: "failed",
+ errorMessage: error,
+ channel: "api",
+ invoiceNumber: formResponseId ?? null,
+ }).catch(() => undefined);
+ return NextResponse.json({ success: false, error }, { status });
+}
diff --git a/src/app/requests/[id]/approval-dialog.tsx b/src/app/requests/[id]/approval-dialog.tsx
new file mode 100644
index 0000000..9cd1296
--- /dev/null
+++ b/src/app/requests/[id]/approval-dialog.tsx
@@ -0,0 +1,145 @@
+"use client";
+
+import { useEffect, useMemo, useState, useTransition } from "react";
+import { toast } from "sonner";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Textarea } from "@/components/ui/textarea";
+import { approveRequest } from "@/actions/license-requests";
+import type { LicenseRequestDetail } from "@/actions/license-requests";
+import {
+ renderTemplate,
+ type TemplateContext,
+} from "@/lib/license-requests/render-template";
+import { markdownToTeamsHtml } from "@/lib/teams/markdown";
+
+interface Props {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ detail: LicenseRequestDetail;
+ template: string | null;
+ approver: { name: string; firstName: string };
+ onSuccess: () => void;
+}
+
+export function ApprovalDialog({
+ open,
+ onOpenChange,
+ detail,
+ template,
+ approver,
+ onSuccess,
+}: Props) {
+ const initialBody = useMemo(() => {
+ if (!template) return "";
+ const ctx = buildContext(detail, approver);
+ return renderTemplate(template, ctx).rendered;
+ }, [detail, template, approver]);
+
+ const [bodyMd, setBodyMd] = useState(initialBody);
+ const [pending, startTransition] = useTransition();
+ const previewHtml = markdownToTeamsHtml(bodyMd);
+
+ // Reset to the freshly-rendered template every time the dialog opens —
+ // otherwise edits from a prior open-then-cancel session persist.
+ useEffect(() => {
+ if (open) setBodyMd(initialBody);
+ }, [open, initialBody]);
+
+ function handleSend() {
+ startTransition(async () => {
+ const result = await approveRequest({
+ requestId: detail.id,
+ bodyMd,
+ });
+ if (result.success) {
+ toast.success("Approval sent");
+ onOpenChange(false);
+ onSuccess();
+ } else {
+ toast.error(result.error);
+ }
+ });
+ }
+
+ return (
+
+ );
+}
+
+function buildContext(
+ detail: LicenseRequestDetail,
+ approver: { name: string; firstName: string },
+): TemplateContext {
+ const firstName = detail.requesterName.split(/\s+/)[0] ?? detail.requesterName;
+ return {
+ requester: {
+ name: detail.requesterName,
+ firstName,
+ email: detail.requesterEmail,
+ },
+ tool: { name: detail.requestedToolName },
+ tier: detail.requestedTierName ? { name: detail.requestedTierName } : null,
+ approver,
+ requestUrl: `${typeof window !== "undefined" ? window.location.origin : ""}/requests/${detail.id}`,
+ form: detail.formPayload,
+ };
+}
diff --git a/src/app/requests/[id]/completion-dialog.tsx b/src/app/requests/[id]/completion-dialog.tsx
new file mode 100644
index 0000000..fcb7802
--- /dev/null
+++ b/src/app/requests/[id]/completion-dialog.tsx
@@ -0,0 +1,250 @@
+"use client";
+
+import { useEffect, useMemo, useState, useTransition } from "react";
+import { format } from "date-fns";
+import { toast } from "sonner";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Textarea } from "@/components/ui/textarea";
+import { Input } from "@/components/ui/input";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { completeRequest } from "@/actions/license-requests";
+import type { LicenseRequestDetail } from "@/actions/license-requests";
+import {
+ renderTemplate,
+ type TemplateContext,
+} from "@/lib/license-requests/render-template";
+import { markdownToTeamsHtml } from "@/lib/teams/markdown";
+
+interface Props {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ detail: LicenseRequestDetail;
+ tiers: { id: number; name: string; monthlyCostCents: number }[];
+ template: string | null;
+ approver: { name: string; firstName: string };
+ onSuccess: () => void;
+}
+
+type Step = 1 | 2;
+
+export function CompletionDialog({
+ open,
+ onOpenChange,
+ detail,
+ tiers,
+ template,
+ approver,
+ onSuccess,
+}: Props) {
+ const today = useMemo(() => format(new Date(), "yyyy-MM-dd"), []);
+ const [step, setStep] = useState(1);
+ const [tierId, setTierId] = useState(detail.requestedTierId);
+ const [licenseCode, setLicenseCode] = useState("");
+ const [assignedAt, setAssignedAt] = useState(today);
+ const [bodyMd, setBodyMd] = useState("");
+ const [pending, startTransition] = useTransition();
+
+ // Reset when dialog opens
+ useEffect(() => {
+ if (open) {
+ setStep(1);
+ setTierId(detail.requestedTierId);
+ setLicenseCode("");
+ setAssignedAt(today);
+ setBodyMd("");
+ }
+ }, [open, detail.requestedTierId, today]);
+
+ const selectedTier = tiers.find((t) => t.id === tierId) ?? null;
+
+ function handleAdvance() {
+ if (!selectedTier) {
+ toast.error("Select a tier");
+ return;
+ }
+ // Render the template with the just-entered values bound.
+ const ctx = buildContext(detail, selectedTier.name, licenseCode, approver);
+ const body = template ? renderTemplate(template, ctx).rendered : "";
+ setBodyMd(body);
+ setStep(2);
+ }
+
+ function handleSend() {
+ if (!selectedTier) return;
+ startTransition(async () => {
+ const result = await completeRequest({
+ requestId: detail.id,
+ tierId: selectedTier.id,
+ licenseCode: licenseCode || undefined,
+ assignedAt,
+ bodyMd,
+ });
+ if (result.success) {
+ toast.success("Completion sent — assignment created");
+ onOpenChange(false);
+ onSuccess();
+ } else {
+ toast.error(result.error);
+ }
+ });
+ }
+
+ return (
+
+ );
+}
+
+function buildContext(
+ detail: LicenseRequestDetail,
+ tierName: string,
+ licenseCode: string,
+ approver: { name: string; firstName: string },
+): TemplateContext {
+ const firstName = detail.requesterName.split(/\s+/)[0] ?? detail.requesterName;
+ return {
+ requester: {
+ name: detail.requesterName,
+ firstName,
+ email: detail.requesterEmail,
+ },
+ tool: { name: detail.requestedToolName },
+ tier: { name: tierName },
+ licenseCode: licenseCode || undefined,
+ approver,
+ requestUrl: `${typeof window !== "undefined" ? window.location.origin : ""}/requests/${detail.id}`,
+ form: detail.formPayload,
+ };
+}
diff --git a/src/app/requests/[id]/page.tsx b/src/app/requests/[id]/page.tsx
new file mode 100644
index 0000000..edef73a
--- /dev/null
+++ b/src/app/requests/[id]/page.tsx
@@ -0,0 +1,44 @@
+import { notFound } from "next/navigation";
+import { AuthGuard } from "@/components/auth-guard";
+import { auth } from "@/lib/auth";
+import { getRequestContext } from "@/actions/license-requests";
+import { findTemplate } from "@/lib/license-requests/templates";
+import { RequestDetailClient } from "./request-detail-client";
+
+export default async function RequestDetailPage({
+ params,
+}: {
+ params: Promise<{ id: string }>;
+}) {
+ const { id: rawId } = await params;
+ const id = Number.parseInt(rawId, 10);
+ if (!Number.isFinite(id) || id <= 0) notFound();
+
+ const ctx = await getRequestContext(id);
+ if (!ctx) notFound();
+
+ // Current admin's identity — passed to dialogs so {{approver.firstName}}
+ // / {{approver.name}} template variables resolve in messages sent to Teams.
+ const session = await auth();
+ const adminName = session?.user?.name ?? "Admin";
+ const adminFirstName = adminName.split(/\s+/)[0] ?? adminName;
+
+ // Pre-fetch the right templates so the action modals open with content
+ // already loaded (vs. paying a roundtrip on every click).
+ const [approvalTemplate, completionTemplate] = await Promise.all([
+ findTemplate(ctx.detail.requestedToolId, ctx.detail.requestedTierId, "approval"),
+ findTemplate(ctx.detail.requestedToolId, ctx.detail.requestedTierId, "completion"),
+ ]);
+
+ return (
+
+
+
+ );
+}
diff --git a/src/app/requests/[id]/rejection-dialog.tsx b/src/app/requests/[id]/rejection-dialog.tsx
new file mode 100644
index 0000000..ff259d5
--- /dev/null
+++ b/src/app/requests/[id]/rejection-dialog.tsx
@@ -0,0 +1,92 @@
+"use client";
+
+import { useEffect, useState, useTransition } from "react";
+import { toast } from "sonner";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Textarea } from "@/components/ui/textarea";
+import { rejectRequest } from "@/actions/license-requests";
+import type { LicenseRequestDetail } from "@/actions/license-requests";
+
+interface Props {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ detail: LicenseRequestDetail;
+ onSuccess: () => void;
+}
+
+export function RejectionDialog({ open, onOpenChange, detail, onSuccess }: Props) {
+ const [note, setNote] = useState("");
+ const [pending, startTransition] = useTransition();
+
+ // Clear the draft note every time the dialog opens — matches the
+ // reset-on-open behavior of CompletionDialog and ApprovalDialog.
+ useEffect(() => {
+ if (open) setNote("");
+ }, [open]);
+
+ function handleSend() {
+ startTransition(async () => {
+ const result = await rejectRequest({
+ requestId: detail.id,
+ decisionNote: note.trim(),
+ });
+ if (result.success) {
+ toast.success("Rejection sent");
+ onOpenChange(false);
+ onSuccess();
+ } else {
+ toast.error(result.error);
+ }
+ });
+ }
+
+ return (
+
+ );
+}
diff --git a/src/app/requests/[id]/request-detail-client.tsx b/src/app/requests/[id]/request-detail-client.tsx
new file mode 100644
index 0000000..5aa6d4e
--- /dev/null
+++ b/src/app/requests/[id]/request-detail-client.tsx
@@ -0,0 +1,347 @@
+"use client";
+
+import { Fragment, useState } from "react";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import { toast } from "sonner";
+import { formatDistanceToNow, format } from "date-fns";
+import { ArrowLeft } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+ CardDescription,
+} from "@/components/ui/card";
+import { cancelRequest } from "@/actions/license-requests";
+import type { LicenseRequestDetail } from "@/actions/license-requests";
+import { ApprovalDialog } from "./approval-dialog";
+import { CompletionDialog } from "./completion-dialog";
+import { RejectionDialog } from "./rejection-dialog";
+import { markdownToTeamsHtml } from "@/lib/teams/markdown";
+
+interface Props {
+ detail: LicenseRequestDetail;
+ tiers: { id: number; name: string; monthlyCostCents: number }[];
+ approvalTemplate: string | null;
+ completionTemplate: string | null;
+ /** Current admin's identity — used to resolve {{approver.*}} in templates. */
+ approver: { name: string; firstName: string };
+}
+
+const STATUS_LABELS: Record = {
+ pending_review: "Pending review",
+ approved: "Approved",
+ completed: "Completed",
+ rejected: "Rejected",
+ cancelled: "Cancelled",
+};
+
+const STATUS_VARIANT: Record<
+ LicenseRequestDetail["status"],
+ "default" | "secondary" | "destructive" | "outline"
+> = {
+ pending_review: "default",
+ approved: "secondary",
+ completed: "outline",
+ rejected: "destructive",
+ cancelled: "outline",
+};
+
+export function RequestDetailClient({
+ detail,
+ tiers,
+ approvalTemplate,
+ completionTemplate,
+ approver,
+}: Props) {
+ const router = useRouter();
+ const [approveOpen, setApproveOpen] = useState(false);
+ const [completeOpen, setCompleteOpen] = useState(false);
+ const [rejectOpen, setRejectOpen] = useState(false);
+
+ const isPending = detail.status === "pending_review";
+ const isApproved = detail.status === "approved";
+ const canCancel = isPending || isApproved;
+
+ async function handleCancel() {
+ if (!confirm("Cancel this request? Any pending approver activity will be discarded.")) return;
+ const result = await cancelRequest({ requestId: detail.id });
+ if (result.success) {
+ toast.success("Request cancelled");
+ router.refresh();
+ } else {
+ toast.error(result.error);
+ }
+ }
+
+ return (
+
',
+ );
+ });
+
+ it("escapes HTML before processing markdown", () => {
+ // Tag in input should be visible as text, not rendered.
+ expect(markdownToTeamsHtml("")).toBe(
+ "
<script>alert(1)</script>
",
+ );
+ });
+
+ it("renders unordered lists when every line begins with - or *", () => {
+ const md = "- one\n- two\n- three";
+ expect(markdownToTeamsHtml(md)).toBe("
one
two
three
");
+ });
+
+ it("does not render a list when only some lines begin with -", () => {
+ const md = "intro\n- one";
+ // joined paragraph; the "-" stays literal
+ expect(markdownToTeamsHtml(md)).toContain("intro");
+ expect(markdownToTeamsHtml(md)).toContain("- one");
+ });
+
+ it("handles a multi-block message end to end", () => {
+ const md =
+ "Hi **Anna**,\n\nYou're approved for **Copilot**.\n\n- bullet 1\n- bullet 2\n\nMore later.";
+ const html = markdownToTeamsHtml(md);
+ expect(html).toContain("