Tiger Portal - Let users upload a transcript or meeting link to generate a dashboard - #147
Open
yaqi-lyu wants to merge 30 commits into
Open
Tiger Portal - Let users upload a transcript or meeting link to generate a dashboard#147yaqi-lyu wants to merge 30 commits into
yaqi-lyu wants to merge 30 commits into
Conversation
- Untrack .claude/launch.json (local editor artifact) and gitignore it. - Remove @azure/storage-blob/@azure/storage-queue from azure-function: the code using them moved to portal-api, so they were dead deps. This reverts azure-function/package-lock.json back to master (removes ~230 lines of lockfile churn from the PR). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- node: import protocol + optional chaining + import-type (mechanical). - Suppress intentional control-char filename regex and the drag-drop static-element a11y rule, each with a documented reason. - "Transcript file" label → <p> (the dropzone input carries its own label). Left untouched: pre-existing noUnusedVariables in ProcessTranscriptQueue.js and projectSetup.js (predate this PR) and idiomatic non-null assertions. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds AC #2/#4 from #137 — a second submission mode alongside file upload. - portal: UploadView gains an "Upload transcript | Paste meeting link" toggle; SubmissionClient.submitLink() POSTs JSON to /api/v1/meetings. - portal-api: SubmitMeetingLink (POST /api/v1/meetings) validates the link format (parses context/Oid - no Graph call), writes the shared history record, and enqueues a `meetingLink` message. Stays Graph-free. - azure-function: queueMessage normalizes/deduplicates/env-maps `meetingLink`. - processor: downloadFromMeetingLink.js resolves joinUrl to the organizer's onlineMeetings, latest transcript, VTT - in the Job (which already has Graph creds). Reuses validateDownloadedVtt + detectVttSpeakers. - entrypoint.sh: PORTAL_SUBMISSION covers upload + meetingLink (shared history write-back, Logic App notifications suppressed). Webhook untouched. Authorization: any authenticated SSW-tenant user with a valid link can process it; participant-level restriction is a future tightening. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Removes deploy-portal-api-test.yml and deploy-portal-swa-test.yml. The existing func deploy job and manual runs cover deployment; these can be re-added later. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…the pipeline Meeting-link submissions now accept a short teams.microsoft.com/meet link or a bare Meeting ID, not just the long meetup-join link. The Container App Job resolves them by joinMeetingId under the submitter (or a supplied attendee) — Graph's onlineMeetings filter returns a meeting for any invited attendee, so no organizer is needed — then fetches the transcript as the meeting's real organizer. Long links keep the organizer path. - Project name is now optional for links; unnamed submissions derive their project from the meeting subject (parseSubject, now shared with the webhook path) instead of a synthetic slug, and the resolved subject is written back as the display name. - Password-protected dashboards store the password on the owner-scoped submission record so the portal can show it (portal submissions get no Teams notification carrying it). - Review fixes: SubmitTranscript rejects the service-identity fallback (401) like the sibling endpoints; the Graph JoinWebUrl filter is percent-encoded (was silently never matching); dead per-instance dedup helpers removed and the cache-cleanup restored. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…olish - Submit view accepts a meeting link OR a Meeting ID, with an optional attendee email (for a Meeting ID when you weren't on the invite). Project name is optional for links; required/optional field markers; Northwind placeholder. - Rename "My dashboards" -> "My submissions" (it lists the user's own submissions, not every meeting they attended). - Show the dashboard password on completed, password-protected rows. - Persist the active tab in the URL hash so a refresh (SWA serves index.html for every path) keeps you on the tab; back/forward work too. - Seed the submissions list from a per-tab cache and revalidate in the background so refreshes render instantly. Prominent "Open dashboard" button. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ilures diagnosable The meeting-link path started failing with a bare "Graph request failed: 403" that named neither the call nor the reason, and the uploaded-transcript path failed with a completely empty message. Both were unfixable from the logs. Meeting-ID resolution - A candidate that throws no longer aborts the search. Graph returns 403 when the Teams application access policy does not cover THAT user, which says nothing about the next candidate — so the loop now records the reason and continues. Previously a 403 on the submitter meant the attendee email was never tried, defeating the point of accepting one. - When every candidate fails the error names each one and why. - Tests now make a candidate THROW. The old fake only returned null, so the loop looked like it tolerated failures when it did not; both new tests fail against the previous implementation. Error reporting - Graph errors carry the operation name and the response body, so "403" becomes e.g. "Graph findMeeting failed: 403 - Forbidden: Application is not allowed ...", which distinguishes a missing app permission from an uncovered user. - describeError() appends code/status when a message is empty. Azure Storage getProperties() is a HEAD request and a HEAD response has no body, so a 403 from a missing Blob Data role surfaced as message:"" — now it reads "code=AuthorizationPermissionMismatch status=403". Local development - submissionStorage / downloadUploadedTranscript accept TRANSCRIPT_STORAGE_CONNECTION. Deployed apps keep using the managed identity; a developer holding only control-plane Contributor (which grants no data-plane blob access and cannot self-assign it) can supply an account key or SAS. Deliberately its own variable: falling back to AzureWebJobsStorage would silently downgrade production writes from managed identity to a long-lived account key. - downloadUploadedTranscript.readConfig uses an explicit required list instead of "everything except outputDir", so adding an optional field cannot make it required. - debug-credential.js: prints which credential in the DefaultAzureCredential chain can actually get a token, and how long it takes. Infrastructure - manageTranscriptBlobRoleAssignment gates the transcript container's Blob Data Contributor assignment, mirroring manageKeyVaultRoleAssignment. Writing a role assignment needs Owner / User Access Administrator, so without this a Contributor could no longer deploy TEST at all. The assignment is NOT optional at runtime — the Container App Job cannot download an uploaded transcript without it — it is only opt-in at deploy time. Portal - Drop the reference ID from the success screen: it appeared nowhere else in the UI, so it could not be matched to anything. My submissions is the tracking entry point and is already linked from that screen. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ID failures, header polish Rename: - portal-api → functions-portal so the deploy unit reads as an Azure Function (git mv preserves history). Package name → tiger-functions-portal; .gitignore and code-comment path references updated. Azure resource names are unchanged (derived from project/environment in Bicep, decoupled from the directory), so no resources are recreated and callbacks/subscriptions are unaffected. Note: azure-function/ is intentionally left untouched for a later pass. Meeting-ID fixes: - Graph errors now carry status + code; an Authorization_RequestDenied on the email→object-id lookup reports a clear "server is missing User.Read.All, contact the administrator" message instead of blaming the submitter with "you did not attend it". - A job that fails AFTER resolving the meeting now backfills the failed history row with the real meeting subject instead of the "Meeting <id>" placeholder. Portal UI/config: - AppHeader: fixed-height (h-16) bar with the Tiger logo sized within it (h-14). - Fill the SWA openIdIssuer with the SSW tenant id. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…al API
Avatar initials filtered out Teams-style name suffixes so "Willow Lyu
[SSW]" renders "WL" instead of "W[" (token must start with a letter).
Portal API Function App now rides the Graph app's existing Y1 plan via a
new hostingPlanId param — creating a second Y1 Linux plan in this RG
fails ("Dynamic SKU, Linux Worker not available"); Y1 scales per-app so
isolation is preserved.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
yaqi-lyu
marked this pull request as ready for review
July 24, 2026 09:30
…hardening - Route the SPA by path (/submit, /submissions) instead of a "#" hash; refresh/deep-link still work via SWA navigationFallback, back/forward via popstate. - Rename the "Paste meeting link" tab to "Meeting link / ID" so it matches the field label and validation (which already accept a Meeting ID); update the 3 test queries that referenced the old label. - Extract buildJoinMeetingIdFilterUrl (was inline) so the Meeting-ID filter URL is symmetric with the long-link one, and add an encoding regression test. - Make latestTranscript's sort comparator total: coerce a missing/invalid createdDateTime to 0 so the "latest" pick stays well-defined; add a test. - Remove functions-portal/debug-credential.js (local-only debug script that loads gitignored local.settings.json and is never wired into the app). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The list endpoint runs on a Y1 Consumption Function and App Insights shows the platform drains the worker every ~8 minutes regardless of traffic (DrainMode -> Host started pairs, 2m32s apart, not aligned to any timer tick), so roughly a third of visits landed on a cold worker. The one sampled request took 2583ms; measured cold TTFB 2.9s vs 0.9s warm. Ping-based warming cannot beat a platform-scheduled recycle, so this attacks the shorter cold window on the server and the perceived wait on the client. Server: share ONE CosmosClient and credential process-wide, prime the AAD token exchange and the client's first-request setup at module load, and keep a worker resident with a 4-minute timer. That does not stop the recycle, but it collapsed the post-drain dead window from 8-53 minutes to a consistent 2m32s. Name the managed identity credential rather than walking the DefaultAzureCredential chain, and turn off endpoint discovery on a single-region account. Client: - Start GET /api/v1/submissions from an inline <head> script, before the browser has even requested the JS bundle; SubmissionClient adopts that in-flight response instead of issuing a second one. - Cache the principal so /.auth/me no longer gates the first paint, and hydrate this tab's rows before React mounts. bindOwner now runs twice - once with the remembered identity, once with the one SWA vouches for - and discards storage-hydrated rows on a mismatch, so a browser that switched users without signing out cannot paint the previous user's dashboard passwords. - Serve /assets/* with an immutable cache header. The global no-store was also suppressing caching of the hashed bundle, so 219KB of JS and 20KB of CSS were re-downloaded on every visit. The rule deliberately omits allowedRoles: with the auth gate on, an unauthenticated request returns the 401->302 override, and an immutable Cache-Control on that redirect would permanently break the app for that browser. The hashed assets carry no secrets. - Self-host Inter (one 48KB latin variable file, all weights) instead of a render-blocking fonts.googleapis.com stylesheet, removing two cross-origin round trips ahead of first paint. Drop IBM Plex Mono entirely - it was a whole webfont for the single password <code> element. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t trail
Four review findings, all in the submitter's feedback loop.
Live status: "My submissions" only refreshed on mount, so somebody who submitted
and stayed on the list watched a frozen "Queued" for the whole multi-minute run.
It now polls while any row is still moving and stops the moment none are, with a
manual Refresh for work submitted in another tab. The poll deliberately bypasses
the FRESH_MS guard rather than reusing revalidate() - coupling the two meant
raising that constant would have silently killed the poll.
Failure reasons: failed rows said "Unavailable" while the Job was throwing away
text written specifically for the user ("No transcript is available for this
meeting yet", "add the email of someone who did"). That now reaches the row via
failureReason. Processor failures get a generic message instead: their stdout is
internal diagnostics with no user action in it, and it is already in the Job log.
The field is set unconditionally so a row leaving "failed" cannot keep showing a
stale error.
Submitter audit: a Meeting-ID submission can pull a transcript for a meeting the
submitter only knows the ID of, so "who asked for this" has to be answerable from
the logs, not only from a Cosmos record. The actor now survives normalization
onto the queue as {email, subject} - roles and type are dropped so nobody
mistakes a value that arrived over a queue for an authorization input.
Stale sweep: a hard SIGKILL stranded a row on "processing" forever (SIGTERM was
already handled). KeepWarm now also sweeps hourly. Two things that bit during
implementation and are covered by regression tests: the timer built a fresh
handler per tick, which reset the throttle and turned "hourly" into every 4
minutes; and the 90-minute cutoff has to clear the Job's own 3600s
replicaTimeout or a slow-but-healthy run gets failed underneath itself.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…deploy script down staging.bicepparam now carries the four portal flags explicitly. deployPortal turns the stack on; the other three are all module defaults, written out so the intent is stated rather than implied by silence: the role assignments and the SWA's Entra app settings were configured by hand, and Bicep must not touch them. manageSwaAuthSettings is new, and it fixes a real deploy blocker. getSecret() on the RBAC-enabled vault silently required the DEPLOYING principal to hold Key Vault Secrets User - a grant a plain Contributor does not have, spent re-writing app settings that were already correct. The ternary keeps the Key Vault reference out of the compiled template entirely when the flag is off (verified against the ARM output, not assumed). Also documented that this config resource is a full replace rather than a merge, which is the other reason it should stay off. Nothing was removed from the two Microsoft.Authorization/roleAssignments: the compiled ARM confirms both are already gated, one at module level and one at resource level, so with the flags false ARM never sees them. They stay as the only written record of which roles this identity needs. The Cosmos sqlRoleAssignment stays too and is deliberately ungated - it is a data-plane role written through the Cosmos RP, idempotent by GUID, and the only thing granting the identity Cosmos access. portal-post-deploy.sh becomes verify-portal-auth-boundary.sh. It had accreted stale reminders - including one pointing at a config placeholder that no longer exists - around the single check that mattered, which is exactly what a name like "post-deploy" invites. It now verifies the "Azure Static Web Apps (Linked)" EasyAuth provider and nothing else, in one API call rather than two that could disagree, and the name says so. Doc drift: setup-cosmos.sh was missing the submissions container; portalApiApp claimed the queue uses managed identity when it uses the AzureWebJobsStorage account key; staging.bicepparam pointed at a main.bicepparam that does not exist. Ignore the deployment package func publish drops in the repo root. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The button was added alongside the poll to cover a submission made in another
tab. With the poll running it is redundant clutter: any row still moving keeps
the list live on its own, and the two error states already have their own
recovery actions ("Try again" / "Retry").
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
Introduces a new self-serve “Tiger Portal” (React SPA + separate Portal API Azure Functions app) that lets authenticated SSW users submit either an uploaded .vtt transcript or a Teams meeting reference, flowing through the existing queue → Container App Job → dashboard pipeline with submission history persisted in Cosmos DB.
Changes:
- Adds Portal SPA (
portal/) and Portal API (functions-portal/) with SWA-based authentication, per-user submission history, polling, and actionable failure reasons. - Extends the processor + queue message contract to support new source types (
uploadedTranscript,meetingLink) and best-effort status updates back into Cosmos. - Updates infrastructure (Bicep + scripts) to provision Portal resources, Cosmos
submissionscontainer, transcript-submissions blob container, and deployment verification for the SWA-linked auth boundary.
Reviewed changes
Copilot reviewed 86 out of 93 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| processor/updateSubmissionStatus.js | Best-effort Cosmos status/URL/password updates for portal submissions after job completion. |
| processor/projectSetup.test.js | Tests for transcript filename validation including collision-resistant suffix. |
| processor/projectSetup.js | Allows YYYY-MM-DD-HHmmss[-suffix].vtt and uses node: builtins. |
| processor/parseSubject.js | Shared subject parsing for consistent project/title derivation across sources. |
| processor/downloadUploadedTranscript.test.js | Tests for uploaded-transcript download + validation + speaker-label detection. |
| processor/downloadUploadedTranscript.js | Downloads private uploaded transcript from Blob, validates/canonicalizes it for processing. |
| processor/downloadTranscript.js | Reuses shared subject parsing (moved into parseSubject.js). |
| portal/vite.config.ts | Vite dev proxy config with local SWA principal injection and Vitest setup. |
| portal/tsconfig.node.json | TS config for Vite config project reference. |
| portal/tsconfig.json | TS project references for app + node configs. |
| portal/tsconfig.app.json | Strict TS config for portal app source. |
| portal/src/vite-env.d.ts | Vite client type references. |
| portal/src/views/UploadView.test.tsx | Tests for upload/link submission validation, flows, and error rendering. |
| portal/src/views/SignInView.tsx | Sign-in screen used for logout landing + local-dev fallback. |
| portal/src/views/DashboardsView.test.tsx | Tests for cached rendering, polling, failure reasons, and password display behavior. |
| portal/src/testSetup.ts | Common test cleanup and jest-dom setup for Vitest. |
| portal/src/styles.css | Tailwind v4 + self-hosted Inter + design-system tokens for portal styling. |
| portal/src/main.tsx | Bootstraps app, primes submissions fetch, binds remembered principal for fast paint. |
| portal/src/components/TranscriptDropzone.tsx | Upload dropzone with progressive drag-and-drop and accessible file input. |
| portal/src/components/AppHeader.tsx | Header with tab navigation, user identity display, and sign-out handling. |
| portal/src/App.tsx | SPA shell with auth gating, navigation via path, and cache binding on auth confirm. |
| portal/src/App.test.tsx | Tests for auth gating, remembered principal fast paint, and cache owner binding. |
| portal/src/api/submissionsCache.test.ts | Tests for cache hydration, owner scoping, persistence, concurrency, and clearing. |
| portal/src/api/SubmissionClient.ts | Client for submissions + meetings endpoints with auth-challenge handling and primed GET adoption. |
| portal/src/api/SubmissionClient.test.ts | Tests for stable response parsing, auth redirect handling, 403 surfacing, and primed-request adoption. |
| portal/src/api/RequestAdapter.ts | Request abstraction (same-origin cookies) for future auth/proxy swaps. |
| portal/src/api/authClient.ts | SWA auth client with remembered principal (localStorage) and name/email/initials helpers. |
| portal/public/staticwebapp.config.json | SWA routing + auth enforcement + immutable assets caching + 401 redirect override. |
| portal/package.json | Portal dependencies/scripts (Vite, React 19, Tailwind v4, Vitest). |
| portal/index.html | App shell with early submissions prefetch to reduce perceived cold start latency. |
| package.json | Adds Biome lint/format scripts and devDependency at repo root. |
| lib/cosmosClient.js | Adds submissions container access + updateSubmissionStatus patch helper. |
| infra/test.bicepparam | Adds portal-related flags and disables role/auth settings management by default in test. |
| infra/staging.bicepparam | Enables portal deployment and documents/locks down manual RBAC/auth settings behavior. |
| infra/setup-cosmos.sh | Adds submissions container to Cosmos setup script. |
| infra/scripts/verify-portal-auth-boundary.sh | Verifies SWA-linked EasyAuth provider boundary exists on Portal API Function App. |
| infra/modules/storage.bicep | Adds transcript-submissions private container + optional Blob Data Contributor assignment. |
| infra/modules/staticWebApp.bicep | New SWA module: Standard plan, linked backend, optional Entra auth appsettings. |
| infra/modules/portalApiApp.bicep | New Portal API Function App module (Linux consumption) on shared plan with required app settings. |
| infra/modules/functionApp.bicep | Adds request body size limit + transcript storage env vars + exposes hostingPlanId output. |
| infra/modules/cosmosDb.bicep | Adds Cosmos submissions container (partitioned by /projectName). |
| infra/modules/containerApp.bicep | Passes transcript storage account/container env vars to the Container App Job. |
| infra/main.bicep | Orchestrates portal stack (Portal API + SWA), transcript-submissions container, and related flags/outputs. |
| functions-portal/src/services/submissionValidation.test.js | Tests project slugification, VTT validation, and canonical filename generation. |
| functions-portal/src/services/submissionValidation.js | Validates project name + uploaded VTT, sanitizes filename, builds canonical processor filename. |
| functions-portal/src/services/submissionStore.test.js | Tests Cosmos query/patch shapes for user listing + stale sweep behavior. |
| functions-portal/src/services/submissionStore.js | Cosmos-backed per-user submissions store with warmup + stale sweep. |
| functions-portal/src/services/submissionStorage.test.js | Tests connection-string path avoids managed-identity client construction. |
| functions-portal/src/services/submissionStorage.js | Blob storage client for transcript submissions with MI/connection-string seam. |
| functions-portal/src/services/submissionService.test.js | Tests upload, queue publish, rollback behavior, and optional history record persistence. |
| functions-portal/src/services/submissionService.js | Service to validate, upload transcript source, write history record, and enqueue v2 message. |
| functions-portal/src/services/submissionQueue.js | Queue publisher for transcript-notifications messages (base64 JSON). |
| functions-portal/src/services/submissionActor.test.js | Tests SWA principal decoding + role dropping + defensive fallback identity. |
| functions-portal/src/services/submissionActor.js | Resolves authenticated actor from x-ms-client-principal (portable identity). |
| functions-portal/src/services/meetingLinkValidation.test.js | Tests meeting link classification + attendee email validation. |
| functions-portal/src/services/meetingLinkValidation.js | Validates/normalizes Teams join links or numeric meeting IDs (no Graph call). |
| functions-portal/src/services/credential.test.js | Tests MI-vs-DefaultAzureCredential selection and caching behavior. |
| functions-portal/src/services/credential.js | Shared cached data-plane credential selection (MI when deployed, default chain locally). |
| functions-portal/src/http.js | Shared JSON response helper (no-store + JSON content-type). |
| functions-portal/src/functions/SubmitTranscript.test.js | Tests multipart handling, size rejection, validation mapping, and auth gating. |
| functions-portal/src/functions/SubmitTranscript.js | Portal API endpoint for transcript upload submission (anonymous + SWA-bound identity). |
| functions-portal/src/functions/SubmitMeetingLink.test.js | Tests meeting-link/ID submission flows, resolver identity list, and rollback behavior. |
| functions-portal/src/functions/SubmitMeetingLink.js | Portal API endpoint for meeting-link/ID submission (enqueues v2 meetingLink message). |
| functions-portal/src/functions/ListSubmissions.test.js | Tests per-user listing mapping + unauthenticated rejection + error mapping. |
| functions-portal/src/functions/ListSubmissions.js | Portal API endpoint for per-user submission history with Cosmos warmup. |
| functions-portal/src/functions/KeepWarm.test.js | Tests keep-warm cadence, sweep throttling, and failure swallowing. |
| functions-portal/src/functions/KeepWarm.js | Timer to reduce cold starts and sweep stale submissions hourly. |
| functions-portal/src/functions/index.js | Portal Functions entrypoint wiring. |
| functions-portal/package.json | Portal API package metadata and test/start scripts. |
| functions-portal/host.json | Azure Functions host logging + extension bundle config for portal app. |
| biome.json | Adds Biome formatter/linter configuration for the repo. |
| azure-function/src/services/queueMessage.test.js | Tests queue message normalization/dedup/env building for new source types and actor audit label. |
| azure-function/src/services/graphMeetingUrl.test.js | Tests correct escaping/encoding of Graph onlineMeetings JoinWebUrl filter. |
| azure-function/src/services/graphMeetingUrl.js | Shared helper for building properly encoded JoinWebUrl filter URLs. |
| azure-function/src/functions/TriggerProcessing.js | Uses graphMeetingUrl helper for JoinWebUrl-based meeting lookup. |
| azure-function/package.json | Adds test script and bumps Node engine to >=20. |
| azure-function/package-lock.json | Updates lockfile (notably pulls core-rest-pipeline requiring Node >=22). |
| .gitignore | Ignores portal + portal-functions artifacts and SWA emulator output. |
| .gitattributes | Enforces LF normalization and marks binary assets. |
| .editorconfig | Enforces consistent formatting defaults (UTF-8, LF, 2-space indent). |
Files not reviewed (2)
- azure-function/package-lock.json: Generated file
- functions-portal/package-lock.json: Generated file
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Member
|
For next time: My first feeling is that we can just implement a one-way: use the meeting link to generate the dashboard. Implement a minimum workable feature. |
Member
Member
…t Teams host match, Node 22 Four review findings on #147. **Stale dashboard password (2 comments, one root cause).** `updateSubmissionStatus` patched `passwordProtected`/`dashboardPassword` only when the value was truthy, and the processor collapsed its env vars to `true || undefined` — so neither field could ever be cleared. A re-run that produced an unprotected dashboard, or rotated the password, left the previous run's password readable in the portal history. Both fields now carry definite values (false/null clear, `undefined` still means "nothing to say") and are cleared on any non-completed status. The patch-op builder is split out as `buildSubmissionPatchOperations` so this is testable without a live Cosmos. **Teams hostname bypass.** `hostname.includes("teams.microsoft.com")` accepted `teams.microsoft.com.attacker.com`, letting an arbitrary URL through validation and into the pipeline as a Graph filter value. Replaced with an exact host / real-subdomain match in all three copies of the check — functions-portal, processor and azure-function are separate deploy units, so the helper is duplicated with a pointer comment, matching the existing convention around `buildMeetingFilterUrl`. **Node engines mismatch.** Both Function Apps ran `NODE|20` while their locked Azure SDKs declare `engines.node >=22` (2 packages in azure-function, 12 in functions-portal). Node 20 left support on 30/04/2026; Node 22 is GA on Linux Functions and is the last version Linux Consumption will take. Moved both apps, both package.json `engines`, and the deploy workflow to 22. Both apps already use the v4 programming model that Node 22 requires. Deploy note: this changes the runtime stack on the existing `func-tiger-staging`, so the bicep apply must land before the next `func azure functionapp publish`. The container image (`node:20-slim`) is deliberately untouched — the root lockfile has no `>=22` dependency, and rebasing the Claude CLI image is its own change. Tests: root 135, functions-portal 58, azure-function 9 — all green. `az bicep build` OK. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Member
Member
Author
Two pieces of chrome that were costing more attention than they earned.
The (optional) marker on Attendee email restated what the copy right below
it already says ("Leave blank if you are already invited"), so the field
carried the same caveat twice. Project name keeps its marker - there the
requirement genuinely flips with the selected mode, so it carries
information.
"Refreshing..." announced a background revalidate the user did not ask for
and cannot act on. It showed on nearly every visit to My submissions, since
the poll runs while any row is still going, so the steady state was a hint
permanently pinned beside the heading. The revalidate itself is unchanged
and still never blanks the cached rows.
Removing the hint leaves `refreshing` unread in the view, so it comes off
the Shell prop and the store destructure; the cache still tracks it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The portal resources in TEST were all created by hand, so bicep did not know about them: swa-tiger-portal-test, its "portalApi" linked backend, the func-tiger-portal-test app, the transcript-submissions blob container and the Cosmos submissions container. Left at deployPortal = false, a TEST apply silently skipped every one of them, so TEST could not verify the portal half of this PR and drifted further from staging with each deploy. Turning the flag on ADOPTS them rather than creating anything. Checked each against what is deployed before flipping it - name, region, SKU, link name and link target all already match the template - and what-if agrees: 18 changes, every one a Modify, no Create and no Delete. The only substantive change is linuxFxVersion NODE|20 -> NODE|22 on func-tiger-test and func-tiger-portal-test, which is the runtime move this PR already made in the module. what-if additionally reports Delete on four SWA properties (stableInboundIP, provider, trafficSplitting, deploymentAuthPolicy); those are computed fields the template does not declare, which what-if habitually over-reports, and incremental mode leaves them alone. The three manage* flags stay false on purpose - the role assignments and the SWA auth app settings are already granted/set by hand, and managing them would cost the deployer Owner and Key Vault Secrets User for no change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The script reported [FAIL] against a TEST environment whose boundary was
provisioned and working. Three bugs, two of which made the verdict meaningless.
readFileSync('/dev/stdin') - node is a native Windows binary and resolves that
path against the drive root, so it threw ENOENT on every Windows run. Both
extractions swallow errors and fall back to "false", so the script did not
report "could not check"; it confidently reported the boundary as MISSING.
Reading fd 0 works on every platform.
az -o tsv returns CRLF on Windows because the launcher is a .cmd, and $() only
strips the \n. The surviving \r went into the ARM URL (breaking the request that
feeds both checks) and into the remediation hint, where the carriage return
overwrote the line and printed a command that could not be copy-pasted.
The provider check tested Object.keys(...).some(k => /static/i.test(k)). The
authsettingsV2 response always contains the full provider schema - facebook and
apple included - so that key is present whether or not anything is configured.
This one fails open: on a machine where the parse succeeds, a genuinely unlinked
backend would still pass. Now checks enabled === true.
Verified against TEST, which independently works: [ OK ], exit 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`swa deploy` from a workstation first downloads a StaticSitesClient binary from a Microsoft CDN, and that download fails on some networks - "Could not load StaticSitesClient metadata from remote". The CLI is already at the newest published version, so there is nothing to upgrade to. Running the publish server-side sidesteps the binary entirely. Triggers on pushes to any non-master branch touching portal/, so it works from a feature branch without being merged first. workflow_dispatch is declared too, but that button only appears once the file reaches the default branch - a GitHub restriction, not something this file can work around. master is excluded deliberately: master ships to swa-tiger-portal-staging, which is production, and that deploy stays manual. production_branch and deployment_environment are both left unset so a push goes to the SWA's production environment. Setting either would route branches into named PREVIEW environments, which is exactly wrong here - previews are disabled on this SWA because linked backends do not work in them, so the SPA would come up against an /api with nothing behind it. Also asserts staticwebapp.config.json survived into dist/ before uploading. Vite copies it from public/, and without it the deployed site has no Entra auth and no routing rules - a silent downgrade that looks like a successful deploy. Needs the SWA_TIGER_PORTAL_TEST_TOKEN repo secret (added). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review feedback on #147. The eyebrow above the form read "Submit" directly over a heading reading "Submit a meeting", so the word appeared twice in two lines - and a third time in the nav tab that got you here. The eyebrow is meant to name the section, the way "Your history" sits over "My submissions" on the other tab; repeating the heading's first word makes it decoration. Now "New request". "Generate dashboard" undersold what the button starts. Now "Generate AI insights", which is what the reviewer asked for, in sentence case to match the rest of the UI copy. Worth noting the vocabulary now splits: the finished artefact is still called a dashboard everywhere else ("Open dashboard", the dashboards.sswtiger.com host), so the button and the result use different words. The busy label moves from "Submitting…" to "Sending…" - "Generate" then "Submitting" read as two different actions on the same click. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review feedback on #147: the list said when a submission went in but never how long it took, so "is this normal or is it stuck?" had no answer on the page. No new field. Every status write already stamps updatedAt, so on a terminal row that IS the finish time, and updatedAt - submittedAt is the duration. The query and the API response just had to stop dropping it. Only computed for completed/failed rows. While a run is in flight updatedAt keeps moving, so a duration derived from it would be "time since the last status change" wearing the label "time taken" - wrong, and wrong in the reassuring direction. Rows that are terminal but have no updatedAt (anything written before this shipped) render no duration rather than a guess. Rendered inline on the existing submitted line ("Submitted 24/07/2026 · took 12 min") instead of as another element, since it is the same fact about the same run. Sub-minute runs say "under 1 min" rather than "0 min". ListSubmissions maps a missing value to null, not undefined, so it survives the JSON round trip as an explicit "unknown" - undefined would vanish from the body and the client could not tell it apart from an older cached row. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Member
Author
The first run succeeded but warned: "Unexpected input(s) 'skip_api_build'". static-web-apps-deploy has no such input - it decides there is an API to build from api_location, which this workflow never sets. The input was silently ignored, which is the bad case: it read as an explicit instruction while doing nothing, so anyone changing api_location later would have trusted a guard that was not there. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Member
Author
Five tag rules, three of which fired on their own. `type=ref,event=branch` tagged every build with its branch name. That is what published a `test` image as a side effect of pushing - test moved because a branch existed, not because anyone decided to move it. `type=sha` added a short-sha tag to every build. Nothing pulls by sha; it only accumulated in the registry. The `preview` branch published `latest` alongside master. `latest` is what the Container App Job pulls, so that let a branch quietly replace what production runs - the one tag that should have been hardest to move was the easiest. What is left is the two intentional paths: master publishes `latest`, and a manual dispatch publishes the tag you name. Removing the branch-name fallback made the dispatch input load-bearing, so it is now required with a `test` default. Left optional it would have produced a green run that pushed nothing at all, which is the worst way for this to fail. A pull_request build now matches no rule and gets no tag. It never pushed anyway - it exists to prove the Dockerfile still builds - but the summary used to announce "Container Published" above an empty tag list, so it now says what actually happened. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.










Addresses #137
Summary
Adds the Parrot portal — a self-serve entry point where an SSW user signs in and either uploads a Teams
.vtttranscript or points at a Teams meeting (full join link, short link, or a Meeting ID + attendee email), then gets a generated Tiger dashboard, with a per-user history of their submissions and their status.Both paths flow through the existing queue → Container App Job → dashboard pipeline via a
sourceTypediscriminated union, so no separate processing path was introduced and the automated Graph webhook path is untouched.Architecture
portal/) — React 19 + Vite, styled with Tailwind using the same SSW design tokens as the generated dashboards. Views: Submit (Upload transcript / Meeting link or ID) + "My submissions".functions-portal/) — a separate Azure Functions app (func-tiger-portal-*), split from the Graph webhook app so its SWA-only auth boundary never touches the public webhook. Endpoints:POST /api/v1/submissions(file upload),POST /api/v1/meetings(meeting link or Meeting ID),GET /api/v1/submissions(per-user history). Stays Graph-free — the meeting endpoint only validates the link/ID format; Graph resolution happens in the Job.authLevel: anonymous; the trust boundary is the auto-provisioned "Azure Static Web Apps (Linked)" EasyAuth provider (verified post-deploy).processor/downloadFromMeetingLink.jsruns in the Container App Job (which already has Graph creds):onlineMeetings→ latest transcript → VTT. The organizer/tenant come from the link's owncontextparam, so a join link alone is sufficient.submissionscontainer (partitioned by/projectName) holds{owner, status, dashboardUrl, …}. The Portal API writes the record on submit; the processor patchesstatus/dashboardUrlon completion. For meeting submissions the history display name backfills from the resolved meeting subject when no project name was given.Acceptance criteria (from #137)
.vttuploadmeetingLinkjobuploadedTranscriptjobTriggerProcessing); participant-level restriction is a follow-up. Report-body isolation is intentionally deferred: dashboards remain public shareable URLs so users can send them to attendees.Design notes
hostingPlanId, wired fromfunctionApp.outputs.hostingPlanId) — no separate plan is created, and referencing that output sequences the Portal API module after the Graph app.australiaeastis not one; defaults to East Asia).TriggerProcessinguses), plus theUser.Read.Allapplication permission (Meeting-ID submissions resolve an attendee email → object id).staticwebapp.config.jsoncarries the SSW tenant GUID directly in the file. There is no build-time substitution and noSSW_TENANT_IDvariable — the portal deploy workflows were removed in1cd4805and the portal is deployed by hand for now.Deploying to
staging(which is production)infra/staging.bicepparamnow carries the four portal flags explicitly:The three
falseflags are the point: with them off, Bicep emits noMicrosoft.Authorization/roleAssignmentsresource and never callsgetSecret(), so a plain Contributor can run this deployment and nothing collides with the sysadmin's manual grants.Order matters
Two independent constraints, and they compose into one order:
func azure functionapp publish. The Node 22 move lives in Bicep (linuxFxVersion: 'NODE|22'), while the locked Azure SDKs in both Function Apps declareengines.node >=22. Publishing first drops a Node-22 package onto an app still running Node 20.Missing required IDs in queue messageand sends them to the poison queue.So: image → Bicep → Graph app → Portal API → SPA. Running Bicep first is safe for constraint 2 — it only creates
func-tiger-portal-stagingas an empty shell with no code, so nothing can enqueue a portal message until step 4.linuxFxVersionrestartsfunc-tiger-staging. Between that restart and step 3 the app serves its previously-deployed package on Node 22 (pure JS, no native modules — fine), but the Graph webhook is briefly unavailable. Graph retries failed notifications, so this is low risk rather than no risk.Before you start
SSW.Transcript-Intelligence-Group-Event-Reasoning.Dev. (Key Vault Secrets User is not needed whilemanageSwaAuthSettings = false.)Storage Blob Data Contributorforid-tiger-staging→satigerstagingexists (granted manually at account scope). Without it the Job cannot download an uploaded transcript.swa-tiger-portal-stagingexists (hand-created; hostpolite-stone-048f66900.7.azurestaticapps.net). Bicep adopts it in place — same name/region/SKU. Never delete and recreate it: a new hostname invalidates every registered Entra callback.Steps
Build + push the container image —
build-container.yml, taglatest.Carries the new
entrypoint.sh,downloadFromMeetingLink.js,downloadUploadedTranscript.js,updateSubmissionStatus.js,parseSubject.js. Easy to forget: on the old image, a portal job falls through theTRANSCRIPT_SOURCE_TYPEcheck into local mode and dies without ever updating its history row.Dry-run the infra change, then apply:
Creates
func-tiger-portal-staging, thetranscript-submissionsblob container and the Cosmossubmissionscontainer, links the backend to the SWA, moves both Function Apps toNODE|22, and addsFUNCTIONS_REQUEST_BODY_SIZE_LIMIT+TRANSCRIPT_STORAGE_*to the existing Graph app. Note theportalSwaUrl/portalEntraRedirectUrioutputs.Deploy the Graph webhook Function App —
cd azure-function && func azure functionapp publish func-tiger-staging.This is what teaches the queue consumer about
uploadedTranscript/meetingLink.ProcessTranscriptQueue.jswas substantially refactored, so this also needs a regression check in step 8.Deploy the Portal API —
cd functions-portal && func azure functionapp publish func-tiger-portal-staging.Build + deploy the SPA —
cd portal && npm ci && npm run build, then publishportal/distwith the SWA deploy token:Verify the auth boundary —
./infra/scripts/verify-portal-auth-boundary.sh staging.Do not hand out the portal URL until this reports
[ OK ]. It checks the one control that makes theauthLevel: anonymousendpoints safe: the "Azure Static Web Apps (Linked)" EasyAuth provider. If it is missing, the API is reachable from the internet andx-ms-client-principalbecomes caller-supplied — anyone can impersonate any user. Nothing in the code can detect this, and this is a point-in-time check, not monitoring.One-off Entra setup (likely already done with the sysadmin — confirm, don't assume):
https://polite-stone-048f66900.7.azurestaticapps.net/.auth/login/aad/callbackregistered on app registration10e2928c-….openid/profile/email. It was previously app-only.Smoke test — upload a
.vttand submit a meeting link; watch each row goQueued → Processing → Readyand open its dashboard. Then re-check the Graph webhook path end to end, since its queue consumer changed.Rollback
Setting
deployPortal = falseand redeploying does not delete the SWA or the Portal API — incremental mode simply stops managing them. To actually take the portal down, unlink the backend or stop serving the SPA.Deploying to
testinfra/test.bicepparamnow setsparam deployPortal = true. The whole portal stack already exists in test, hand-created, so this adopts it rather than building anything:what-ifreports 18 changes, allModify— noCreate, noDelete.What is already in place and matches the Bicep exactly:
swa-tiger-portal-testportalApifunc-tiger-portal-test,australiaeastfunc-tiger-portal-testAZURE_CLIENT_ID,AZURE_CLIENT_SECRET_APP_SETTING_NAMEset by handmanageSwaAuthSettings = false)azureStaticWebAppsEasyAuth providertranscript-submissionscontainersatigertestsubmissionscontainercosmos-tiger-testStorage Blob Data Contributorforid-tiger-testsatigertestmanageTranscriptBlobRoleAssignment = false)The only substantive change is
linuxFxVersion: NODE|20 → NODE|22onfunc-tiger-testandfunc-tiger-portal-test.what-ifalso reportsDeleteon four SWA properties (stableInboundIP,provider,trafficSplitting,deploymentAuthPolicy) — these are computed/read-only fields the template does not declare, which is a knownwhat-ifover-report, not a real removal.Same order as staging:
build-container.yml, tagtest(only ifprocessor/changed).az deployment group create -g "SSW.Transcript-Intelligence-Group-Event-Reasoning.Dev" --template-file main.bicep --parameters test.bicepparam. This is what movesfunc-tiger-testto Node 22, so it must come first.Deploy Azure Function Testworkflow (already pinned tonode-version: 22).cd functions-portal && func azure functionapp publish func-tiger-portal-test.swa-tiger-portal-test../infra/scripts/verify-portal-auth-boundary.sh test.Testing
processor+lib122,functions-portal56,azure-function9,portal45.portaltypechecks (tsc -b) and builds;az bicep buildsucceeds;staging.bicepparambinds;entrypoint.shand bothinfra/scripts/*.shpassbash -n.npm run lintis currently red repo-wide (102 errors, a handful in new code). Biome config landed here; the sweep did not. Do not wirelintinto CI until that is done.Review fixes folded into this PR
{email, subject}(roles/type deliberately dropped so nobody mistakes it for an authorization input), lands inSUBMITTED_BY, and is logged byProcessTranscriptQueueand by the Job itself. A Meeting-ID submission can pull a transcript for a meeting the submitter only knows the ID of, so "who asked for this" must be answerable from logs alone.Queuedfor the whole multi-minute run. It now polls every 20 s while any row is still running and stops the moment none are.Unavailablewhile the Job was throwing away genuinely useful text. The reason is now persisted tofailureReason(capped, cleared when a row leavesfailed) and rendered. Download errors surface the user-facing message; processor errors get a generic one, since that stdout is internal diagnostics with no user action in it.SIGKILLused to strand a row onprocessingforever (SIGTERMwas already handled). The existingKeepWarmtimer now also sweeps hourly, failing rows older than 90 min — comfortably clear of the Job's own 3600 sreplicaTimeoutso a slow-but-healthy run is never killed underneath itself.manageSwaAuthSettingsflag —getSecret()on the RBAC-enabled vault silently required the deploying principal to hold Key Vault Secrets User, to re-write app settings that were already correct. Now gated off by default. Also documented that this config resource is a full replace, not a merge.portal-post-deploy.sh→verify-portal-auth-boundary.sh— it had rotted into stale reminders (including one pointing at a config placeholder that no longer exists) around a single check that mattered. Cut to that one check; the name now prevents the same accretion.Follow-ups
🤖 Generated with Claude Code