Skip to content

feat(license-requests): automate Microsoft Forms → Hub workflow (spec 032)#101

Merged
studert merged 4 commits into
mainfrom
worktree-automation-workflow
May 22, 2026
Merged

feat(license-requests): automate Microsoft Forms → Hub workflow (spec 032)#101
studert merged 4 commits into
mainfrom
worktree-automation-workflow

Conversation

@studert
Copy link
Copy Markdown
Member

@studert studert commented May 22, 2026

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.

  • Power Automate keeps its existing channel notice + group chat actions and gains one new HTTP action that forwards the Forms response to POST /api/license-requests/ingest.
  • The Hub owns everything after ingest: approval queue, approve/reject/complete modals, audit log, and all outbound Teams posts (channel reply + group chat) via Microsoft Graph.
  • Procurement stays manual — no Copilot / Anthropic admin API calls. The Hub records what humans did.
  • Multi-approver, first-write-wins — any Hub admin can claim any pending request from /requests. Enforced via UPDATE ... 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/:

Doc Purpose
proposals.html Brainstorming — P1 / P2 / P3 → refined P2.2
implementation-plan.html File-level plan with existing-pattern mappings
mockups.html UI mockups for queue / detail / modals / templates
implementation-notes.html Running log of decisions, deviations, trade-offs, and open questions encountered during the build

What's in the diff

Schema (src/lib/db/schema.ts + migration 0021_wakeful_speed.sql)

  • 2 new enums: license_request_status, message_template_kind
  • 2 new tables: license_requests, message_templates
  • Partial unique indexes that enforce "one tool-default per kind" + "one override per (tool, tier, kind)" — see implementation-notes for the NULL-distinct gotcha

Public API

  • POST /api/license-requests/ingest — clones the invoice-ingest pattern (bearer secret LICENSE_REQUEST_INGEST_SECRET, idempotent on formResponseId, 64 KB body cap)
  • Middleware matcher updated to exclude the new public route alongside /api/invoices/ingest

Microsoft Graph helper (src/lib/teams/graph.ts + markdown.ts)

  • Client-credentials token caching, exponential backoff with jitter on 412/429/502/504 (matches webhook.ts exactly)
  • postChannelReply, postChatMessage, postLicenseRequestCard (adaptive card)
  • Graceful degradation: every Graph call no-ops with a console warning when GRAPH_* env vars are unset — lets the rest of the workflow function while IT-112678 is in progress
  • Uses a 25-line fetch-based token call instead of @azure/msal-node (see implementation-notes for trade-off)

Templates (src/lib/license-requests/)

  • ~30-line mustache substitution with unknown-variable detection (the silent-break safety net for MS Forms key renames)
  • Tier-override → tool-default → null lookup priority
  • WYSIWYG preview in the editor — same renderer used for the actual Teams post

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
  • Three modals: approval (template + markdown editor + preview), completion (2-step: procurement details → completion message), rejection (simple textarea, no template)
  • /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 panel
  • Sidebar nav: new "Requests" entry; Settings tabs: new "License templates" tab

Server actions (src/actions/)

  • license-requests.ts: approveRequest, rejectRequest, completeRequest, cancelRequest, listLicenseRequests, getLicenseRequest. First-write-wins via atomic status-conditioned UPDATE. Completion is transactional: creates the license_assignments row, links it, transitions status, all-or-nothing.
  • license-templates.ts: upsertMessageTemplate, deleteMessageTemplate, listMessageTemplates, listToolsWithTiers, recentFormKeysForTool.

Tests

  • 20 new unit tests across render-template.test.ts (9) and graph-format.test.ts (11)
  • All 359 existing tests still pass

Test plan

  • pnpm typecheck clean
  • pnpm lint clean (no warnings)
  • pnpm test — 359/359 pass
  • DB branch wt/automation-workflow (Neon br-icy-shadow-all7tba6) — schema applied cleanly
  • Ingest endpoint smoke test on the branched DB:
    • Valid POST → 201 with requestId
    • Duplicate formResponseId → 200 with deduped: true
    • Missing bearer → 401
    • Unknown tool name → 422 with clear error
  • /requests renders with the seeded request, status badge, and tool/tier
  • /requests/1 renders form_payload, status timeline, and Approve/Reject/Cancel buttons
  • /settings/license-templates renders all active tools with tool-default + per-tier override rows
  • Pending IT-112678 — actual Teams posts via Graph (helper currently logs [graph] skipping POST ... and no-ops)
  • Approve / Complete / Reject flows via real browser clicks (verified via curl content inspection on the rendered pages; clicking the buttons was not driven because Chrome DevTools MCP disconnected mid-session)

Open items (documented in implementation-notes.html)

  • License code stored in license_assignments.apiKeyEncrypted without running through the encryption helper used elsewhere in the codebase — quick follow-up
  • Integration tests for ingest + actions are listed as a fast-follow PR
  • Optional seed script scripts/seed-message-templates.ts not included (the editor surfaces "no template yet" inline so first approval still works)

🤖 Generated with Claude Code

… 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>
@vercel
Copy link
Copy Markdown

vercel Bot commented May 22, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
ai-developer-hub Ready Ready Preview, Comment May 22, 2026 11:29am

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_requests and message_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.

Comment thread src/lib/teams/graph.ts Outdated
Comment on lines +169 to +177
// 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,
);
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +321 to +329
.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",
})
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +348 to +355
),
)
.returning({ id: licenseRequests.id });

if (updated.length === 0) {
throw new Error("Race condition: request status changed before completion");
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +278 to +297
{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>
</>
);
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +38 to +46
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);
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +25 to +34
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(),
});
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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"
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@studert
Copy link
Copy Markdown
Member Author

studert commented May 22, 2026

All 7 Copilot inline comments addressed across two commits:

  • 01dc5db — self-review patch (license code encryption, {{approver.*}} template context, duplicate active-assignment guard)
  • 23faeca — Copilot review fixes (401 retry, race→ActionResult, FormPayloadList key, ApprovalDialog/RejectionDialog reset-on-open, placeholder text correction)

Each Copilot comment has a threaded reply pointing at the specific lines.

pnpm typecheck && pnpm lint && pnpm test still all green (359/359). Ready for another pass.

…orkflow

# Conflicts:
#	src/lib/db/migrations/meta/0021_snapshot.json
#	src/lib/db/migrations/meta/_journal.json
@studert studert merged commit 64930d4 into main May 22, 2026
7 checks passed
studert added a commit that referenced this pull request May 22, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants