feat(license-requests): automate Microsoft Forms → Hub workflow (spec 032)#101
Conversation
… 032) Multi-approver license-request workflow with manual procurement and direct Hub→Teams messaging via Microsoft Graph. - Schema: license_requests + message_templates tables with two enums and partial unique indexes that enforce "one tool-default per kind" + "one override per (tool, tier, kind)" semantics on nullable tier_id. - Ingest: bearer-secret-protected POST /api/license-requests/ingest, idempotent on formResponseId, resolves tool/tier by name or id. - Graph helper: src/lib/teams/graph.ts with token caching, retry/backoff mirroring webhook.ts, postChannelReply + postChatMessage + adaptive-card builder. No-ops gracefully when env vars are unset (IT-112678 pending). - Templates: per-tool/per-tier mustache substitution with unknown-variable warnings. Editor at /settings/license-templates with markdown source + WYSIWYG preview (same renderer used for the actual Teams post). - Queue + detail: /requests with TanStack DataTable + status filter, /requests/[id] with form_payload rendering + approve/reject/complete modals. First-write-wins enforced via UPDATE ... WHERE status=... - Specs: proposals.html (3 revisions), implementation-plan.html, mockups.html, implementation-notes.html documenting design decisions and deviations. 20 new unit tests (template rendering + markdown converter). All 359 tests pass. Smoke-verified on a Neon DB branch (wt/automation-workflow) — ingest endpoint round-trips through to the queue page; detail and templates pages render correctly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
Pull request overview
Implements spec 032’s Microsoft Forms → Hub workflow for multi-approver license requests: a public ingest endpoint for Power Automate, Hub-managed approval/completion UX, template management, and Microsoft Graph-based Teams posting (with graceful no-op when Graph env vars are unset).
Changes:
- Adds DB schema + migration for
license_requestsandmessage_templates(with enums + uniqueness constraints). - Introduces
/api/license-requests/ingest, request queue/detail UI, and admin actions (approve/reject/complete/cancel) with first-write-wins state transitions. - Adds Teams posting helpers (Graph + markdown-to-Teams-HTML) and template rendering + unit tests.
Reviewed changes
Copilot reviewed 31 out of 32 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/unit/teams/graph-format.test.ts | Unit tests for Teams markdown→HTML renderer. |
| tests/unit/license-requests/render-template.test.ts | Unit tests for mustache-style template rendering + missing-variable detection. |
| src/middleware.ts | Excludes the new public ingest route from auth middleware matching. |
| src/lib/validators.ts | Adds Zod schemas/types for license request ingest and workflow actions. |
| src/lib/teams/markdown.ts | Adds minimal, HTML-escaping markdown renderer compatible with Teams HTML subset. |
| src/lib/teams/graph.ts | Adds Graph token acquisition + retrying POST helpers + Teams posting functions. |
| src/lib/license-requests/templates.ts | Adds template resolution helpers and recent form-key extraction. |
| src/lib/license-requests/render-template.ts | Adds template rendering utility + known variable paths list. |
| src/lib/db/schema.ts | Adds enums + tables + relations for license requests and message templates. |
| src/lib/db/migrations/meta/_journal.json | Registers new migration in drizzle journal. |
| src/lib/db/migrations/0021_wakeful_speed.sql | Creates enums/tables/indexes for license requests + message templates. |
| src/components/app-sidebar.tsx | Adds “Requests” nav entry for admins. |
| src/app/settings/settings-nav.tsx | Adds “License Templates” admin settings tab. |
| src/app/settings/license-templates/templates-client.tsx | Client UI for grouped template list and editor launch. |
| src/app/settings/license-templates/template-editor-dialog.tsx | Template editor modal with live preview + variable picker. |
| src/app/settings/license-templates/page.tsx | Admin page wiring for template settings. |
| src/app/requests/requests-table.tsx | Admin queue table for license requests. |
| src/app/requests/page.tsx | Admin requests list page wiring. |
| src/app/requests/[id]/request-detail-client.tsx | Client detail view: payload rendering, audit blocks, action bar. |
| src/app/requests/[id]/rejection-dialog.tsx | Reject modal (free-text) calling server action. |
| src/app/requests/[id]/page.tsx | Server detail page wiring + template prefetch. |
| src/app/requests/[id]/completion-dialog.tsx | Completion modal (2-step) + preview + server action call. |
| src/app/requests/[id]/approval-dialog.tsx | Approval modal + preview + server action call. |
| src/app/api/license-requests/ingest/route.ts | Public bearer-secret ingest endpoint + idempotency + Graph card posting. |
| src/actions/license-templates.ts | Server actions for listing/upserting/deleting message templates + tool/tier listing. |
| src/actions/license-requests.ts | Server actions for listing + approve/reject/complete/cancel + Teams posting. |
| specs/032-automation-workflow/proposals.html | Design/proposal documentation for spec 032. |
| specs/032-automation-workflow/implementation-plan.html | Implementation plan documentation for spec 032. |
| specs/032-automation-workflow/implementation-notes.html | Implementation notes/trade-offs documentation for spec 032. |
| .env.local.example | Documents new ingest + Graph env vars for local setup. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // 401 specifically: token may have been invalidated mid-flight; invalidate | ||
| // cache and let the next attempt re-acquire. Still non-retriable for the | ||
| // outer loop on this attempt — we don't recursively retry within one call. | ||
| if (res.status === 401) cached = null; | ||
| throw new GraphApiError( | ||
| `Graph POST ${path} returned ${res.status}`, | ||
| false, | ||
| res.status, | ||
| ); |
There was a problem hiding this comment.
Good catch — fixed in 23faeca (src/lib/teams/graph.ts). The 401 path now sets lastError as retriable (with the cache cleared) instead of throwing non-retriable, so the next loop iteration re-acquires a fresh token and retries the POST. The comment now matches the behavior.
| .values({ | ||
| userId: req.requesterUserId!, | ||
| toolId: req.requestedToolId, | ||
| tierId, | ||
| costAtAssignmentCents: tier.monthlyCostCents, | ||
| assignedAt: new Date(`${assignedAt}T00:00:00Z`), | ||
| apiKeyEncrypted: licenseCode ?? null, | ||
| source: "license-request-workflow", | ||
| }) |
There was a problem hiding this comment.
Fixed in 01dc5db (one commit earlier than this review). completeRequest now runs licenseCode through encryptApiKey() from @/lib/crypto before writing — see src/actions/license-requests.ts. Matches the existing pattern in src/actions/assignments.ts:92.
| ), | ||
| ) | ||
| .returning({ id: licenseRequests.id }); | ||
|
|
||
| if (updated.length === 0) { | ||
| throw new Error("Race condition: request status changed before completion"); | ||
| } | ||
|
|
There was a problem hiding this comment.
Fixed in 23faeca. The transaction now uses a Symbol("race-lost") sentinel that we throw to roll back the assignment insert, then catch outside the transaction and convert to { success: false, error: "actioned by another admin…" } — see src/actions/license-requests.ts. The UI now gets the same shape as approve/reject.
| {entries.map(([key, value]) => { | ||
| const stringValue = stringifyValue(value); | ||
| const isLong = stringValue.length > 120; | ||
| return isLong ? ( | ||
| <div key={key} className="col-span-2 pt-2 border-t"> | ||
| <dt className="text-xs text-muted-foreground uppercase tracking-wide mb-1"> | ||
| {prettifyKey(key)} | ||
| </dt> | ||
| <dd className="whitespace-pre-wrap">{stringValue}</dd> | ||
| </div> | ||
| ) : ( | ||
| <> | ||
| <dt key={`${key}-k`} className="text-muted-foreground"> | ||
| {prettifyKey(key)} | ||
| </dt> | ||
| <dd key={`${key}-v`} className="font-mono text-xs"> | ||
| {stringValue} | ||
| </dd> | ||
| </> | ||
| ); |
There was a problem hiding this comment.
Fixed in 23faeca — the short-entry branch now wraps in <Fragment key={key}> (imported from React) and drops the redundant inner keys. See src/app/requests/[id]/request-detail-client.tsx.
| const initialBody = useMemo(() => { | ||
| if (!template) return ""; | ||
| const ctx = buildContext(detail); | ||
| return renderTemplate(template, ctx).rendered; | ||
| }, [detail, template]); | ||
|
|
||
| const [bodyMd, setBodyMd] = useState(initialBody); | ||
| const [pending, startTransition] = useTransition(); | ||
| const previewHtml = markdownToTeamsHtml(bodyMd); |
There was a problem hiding this comment.
Fixed in 23faeca — added a useEffect on open that resets bodyMd to the freshly-rendered template body each time the dialog opens. See src/app/requests/[id]/approval-dialog.tsx. Same pattern as CompletionDialog.
| export function RejectionDialog({ open, onOpenChange, detail, onSuccess }: Props) { | ||
| const [note, setNote] = useState(""); | ||
| const [pending, startTransition] = useTransition(); | ||
|
|
||
| function handleSend() { | ||
| startTransition(async () => { | ||
| const result = await rejectRequest({ | ||
| requestId: detail.id, | ||
| decisionNote: note.trim(), | ||
| }); |
There was a problem hiding this comment.
Fixed in 23faeca — same reset-on-open pattern, clears note to empty string when open flips true. See src/app/requests/[id]/rejection-dialog.tsx.
| <Textarea | ||
| value={licenseCode} | ||
| onChange={(e) => setLicenseCode(e.target.value)} | ||
| placeholder="Paste the key — stored encrypted in license_assignments.apiKey" |
There was a problem hiding this comment.
Fixed in 23faeca — placeholder now reads "Paste the key — encrypted via encryptApiKey() and stored in license_assignments.api_key_encrypted". See src/app/requests/[id]/completion-dialog.tsx.
… dup seats
Three must-fix findings from self-review, patched on the spec/032 branch:
1. completeRequest now runs the entered license code through encryptApiKey()
before writing to license_assignments.apiKeyEncrypted — matches the manual-
assignment flow and closes the security gap flagged in implementation-notes.
2. The detail page reads the current admin via auth() and threads
approver: { name, firstName } through RequestDetailClient into both
approval and completion dialogs' buildContext(). The seeded default
templates' trailing "— {{approver.firstName}}" now resolves to the
admin's name instead of rendering as literal text in Teams.
3. completeRequest now checks for an existing active license_assignments row
on (userId, toolId, status='active') before inserting; returns a clear
error pointing at the existing assignment id. Prevents silent
double-counting in cost reports when a user already has the tool.
All 359 tests still pass; typecheck + lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Addresses all seven inline comments from the automated PR review:
- graph.ts 401 path now treats the response as retriable (clears the token
cache and falls through to the backoff loop) so a mid-flight token
invalidation recovers on the next attempt, matching the inline comment.
- completeRequest's race-on-status no longer throws a bare Error; uses a
symbol sentinel + outer .catch() to convert into a structured
ActionResult so the UI sees "already actioned by another admin" instead
of an unhandled server error.
- FormPayloadList's short-entry branch now wraps in <Fragment key={key}>
so the iteration satisfies React's array-element key requirement;
removes redundant keys on the inner <dt>/<dd>.
- ApprovalDialog and RejectionDialog now reset their body/note state on
the open-true transition via useEffect, matching CompletionDialog's
reset-on-open behavior. Eliminates stale-draft surprises after cancel.
- Completion dialog's license-code placeholder now references
encryptApiKey() + license_assignments.api_key_encrypted accurately.
(The encryption itself was already in place from the previous patch
commit — this is just the placeholder text correction.)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
All 7 Copilot inline comments addressed across two commits:
Each Copilot comment has a threaded reply pointing at the specific lines.
|
…orkflow # Conflicts: # src/lib/db/migrations/meta/0021_snapshot.json # src/lib/db/migrations/meta/_journal.json
Resolves migration-numbering conflict — main shipped 0022_serious_tomas (license_requests / message_templates from #101) while this branch had its own 0022_far_aaron_stack. Renumbered the budget-extensions migration to 0023_white_gauntlet so both schemas land in order: 0022_serious_tomas — license_requests, message_templates (from main) 0023_white_gauntlet — budget_extensions, budget_extension_period_allocations, annual_budgets.original_amount_cents The 0023 SQL retains the three-step ADD/UPDATE/SET-NOT-NULL backfill on annual_budgets.original_amount_cents. Snapshot regenerated against main's 0022 state via pnpm db:generate so both schemas are reflected. No code conflicts — schema.ts, validators.ts, and all UI files auto-merged cleanly. Verified end-to-end: typecheck, lint, integration tests (11/11) all pass against a freshly reset wt/budget-extensions Neon branch with both migrations applied. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
Implements spec 032 — automation-workflow: multi-approver license-request workflow with manual procurement, sourced from Microsoft Forms via Power Automate, with all outbound Teams messaging driven directly from the Hub via Microsoft Graph.
POST /api/license-requests/ingest./requests. Enforced viaUPDATE ... WHERE status='pending_review'(0 rows updated → "already actioned by X").Spec evolution and design decisions are captured in four companion HTML docs under
specs/032-automation-workflow/:proposals.htmlimplementation-plan.htmlmockups.htmlimplementation-notes.htmlWhat's in the diff
Schema (
src/lib/db/schema.ts+ migration0021_wakeful_speed.sql)license_request_status,message_template_kindlicense_requests,message_templatesPublic API
POST /api/license-requests/ingest— clones the invoice-ingest pattern (bearer secretLICENSE_REQUEST_INGEST_SECRET, idempotent onformResponseId, 64 KB body cap)/api/invoices/ingestMicrosoft Graph helper (
src/lib/teams/graph.ts+markdown.ts)webhook.tsexactly)postChannelReply,postChatMessage,postLicenseRequestCard(adaptive card)GRAPH_*env vars are unset — lets the rest of the workflow function while IT-112678 is in progress@azure/msal-node(see implementation-notes for trade-off)Templates (
src/lib/license-requests/)UI
/requests— admin-only queue with TanStack DataTable, faceted status filter/requests/[id]— detail page with form_payload rendering, status timeline, audit log of sent messages, conditional Approve/Reject/Complete/Cancel action bar/settings/license-templates— list grouped by tool with tool-default + tier-override rows; editor with three-pane layout (markdown / preview / variable picker) and unknown-variable warning panelServer actions (
src/actions/)license-requests.ts:approveRequest,rejectRequest,completeRequest,cancelRequest,listLicenseRequests,getLicenseRequest. First-write-wins via atomic status-conditioned UPDATE. Completion is transactional: creates thelicense_assignmentsrow, links it, transitions status, all-or-nothing.license-templates.ts:upsertMessageTemplate,deleteMessageTemplate,listMessageTemplates,listToolsWithTiers,recentFormKeysForTool.Tests
render-template.test.ts(9) andgraph-format.test.ts(11)Test plan
pnpm typecheckcleanpnpm lintclean (no warnings)pnpm test— 359/359 passwt/automation-workflow(Neonbr-icy-shadow-all7tba6) — schema applied cleanlyrequestIdformResponseId→ 200 withdeduped: true/requestsrenders with the seeded request, status badge, and tool/tier/requests/1renders form_payload, status timeline, and Approve/Reject/Cancel buttons/settings/license-templatesrenders all active tools with tool-default + per-tier override rows[graph] skipping POST ...and no-ops)Open items (documented in
implementation-notes.html)license_assignments.apiKeyEncryptedwithout running through the encryption helper used elsewhere in the codebase — quick follow-upscripts/seed-message-templates.tsnot included (the editor surfaces "no template yet" inline so first approval still works)🤖 Generated with Claude Code