Appearance
FinFluencify — Zoom Live Classes (Plan)
Status
P1 (Zoom OAuth connect/disconnect) is implemented and live. P2 (schedule / edit / cancel a live class + the trainer's Live Classes list) is implemented in code — migrations 150–152 and the finfluencify-live-session-save/-cancel edge functions are written and unit-tested, pending npx supabase db push + supabase functions deploy before end-to-end use. P3 onward (embedded join, webhook, reminders, recordings, live polls) remain planned, not yet built. This page is the design of record; sections below are annotated with their actual implementation status.
Current testing posture — READ BEFORE GO-LIVE (until further notice)
All Zoom integration testing currently runs against the PRODUCTION Supabase project while the Zoom App stays in DEVELOPMENT mode. Concretely:
- There is one Supabase project (no separate staging) — every migration, edge function, and secret deployed for this feature is already live on production Supabase. Deploy carefully.
- The Zoom App is unpublished (Development mode), so only users on the app owner's own Zoom account can connect/host/join embedded. This is why testing works for the internal account but would fail for outside trainers.
- Testing is happening on the dev host
https://devv.nefoxx.com(same prod Supabase backend). TheZOOM_REDIRECT_URIsecret is therefore set tohttps://devv.nefoxx.com/finfluencify/zoom/callback. - Because there is no environment isolation, promoting to production
https://nefoxx.comis a configuration switch, not a data migration — follow Production Go-Live exactly so the live site is never disrupted.
Validation summary (reviewed against the live codebase)
This design was validated against the current schema, edge-function patterns, and CLAUDE.md guidelines and is production-grade on each axis, with the corrections already folded in below:
- Scalability — per-trainer OAuth (concurrency bounded by each trainer's own Zoom plan, not the platform), stateless EFs, bounded cron sweeps, and a queue-backed email outbox.
- Security — OAuth tokens encrypted at rest with AES-256-GCM (
_shared/crypto.ts, key in theZOOM_TOKEN_ENC_KEYsecret);host_start_urlnever sent to students; webhook HMAC + CRC + replay-window + idempotency; server-side plan gate; RLS on every table (token table service-role only). - Schema — RLS +
SECURITY DEFINER/SET search_pathRPCs, idempotency uniques, and targeted indexes (see Data model). - EF patterns — shared entrypoint (
handleCors→requireAuth→ok/err), typed errors, helpers split. - UI/UX —
cn()+isLight,rounded-3xlshells, orange CTA, Lucide page-title icon, SEBI disclaimer; the live class renders embedded in-platform via the Zoom Meeting SDK (trainer host + student attendee, no redirect). - Notification reuse — the existing
notification_events → notifications-fanout → notificationspipeline was audited and confirmed production-grade; the gaps it has for targeted delivery, email preferences, a clientsupabase.from()breach, and fan-out robustness are addressed in Notification system: dependencies & enhancements. - Recordings — trainer opt-in only; the file stays on Zoom's own cloud (its storage, its cost) — we capture nothing but
play_url/passcode/duration from the webhook. See Recordings. - Live polls — reuse the platform's own, already-deployed Engagement schema (
finfluencify_polls) rather than Zoom's native polling, so response data lands in Nefoxx's DB from day one. See Live polls. - Migration numbering — the Data model table had an off-by-one (it omitted the already-deployed feature-flag migration as a row); corrected to the real contiguous block 148–161.
- Association model — v1 deliberately does not require a batch. No batch creation/listing UI or RPC exists anywhere in the codebase today (
Batches.jsxis still a stub), so gating scheduling on batches would block real trainer use cases (guest lectures, 1:1 mentoring, demos, ad-hoc meetings). See Association model (audience_type).
Context & goal
FinFluencify trainers teach in many shapes beyond cohort batches — guest lectures, 1:1 mentoring, demo sessions for prospects, ad-hoc meetings with selected students, internal workshops, open community sessions, as well as full course cohorts. Today "live classes" is only a stub: FinFluencifyPage.jsx lazy-loads placeholder ZoomMeetings, Schedule, and Batches tabs.
Goal: let trainers schedule, manage, and host live classes from inside Nefoxx without switching apps, choosing freely how each meeting is associated (a course, specific invitees, or no association at all — see Association model) — and let invited students be notified, reminded, and join in one click — the class itself renders embedded in Nefoxx via the Zoom Meeting SDK (no redirect to Zoom) — reusing the platform's proven patterns (webhook validation + idempotency from finfluencify-stream-webhook, the notification_events → notifications-fanout → notifications pipeline, Type B cron sweeps, _shared EF utilities) with zero changes to unrelated modules.
Locked decisions
| # | Decision | Choice | Why | Status |
|---|---|---|---|---|
| 1 | Connection model | Per-trainer OAuth | Each trainer connects their own Zoom account; meetings host under their account/plan, so real concurrency scales to thousands. Requires net-new encrypted token storage + refresh. | ✅ Implemented (P1) |
| 2 | Recordings | Trainer opt-in — stays on Zoom's cloud, metadata-only in Nefoxx | Recording is never automatic. If the trainer enables it (schedule-time toggle or starting it live), Zoom records and stores the file on its own cloud storage — we don't copy, proxy, or re-host it. The recording.completed webhook captures only play_url/share_url/passcode/duration and a "Watch recording" link opens Zoom's hosted player. Zero extra storage cost on our side. | ✅ record_session flag + autoRecordCloud (P2); recording.completed webhook capture + read RPC + "Watch recording" link (P3 slice 7) |
| 3 | Notifications | In-app + Email/.ics | Reuse the in-app fan-out pipeline; build net-new transactional email via Zoho ZeptoMail HTTP API (India DC) with .ics invites through a resilient outbox. | Planned (P3/P4) |
| 4 | Provider | Zoom-first, thin seam | One minimal provider interface, Zoom adapter only; live_platform drives dispatch so Meet/Teams can be added later without rework. | ✅ Implemented (_shared/meeting-providers/) |
| 5 | Join experience | Embedded — Zoom Meeting SDK (Web) | Trainer and student join the live class inside Nefoxx (no redirect to Zoom). The OAuth API still creates the meeting (trainer hosts under their own account); the Web Meeting SDK renders it in-page. Adds a Meeting SDK app (own Key/Secret + review), a signature EF, and a @zoom/meetingsdk component. Recording playback is the one deliberate exception — it opens Zoom's hosted player, since the file lives on Zoom, not us. | Planned (P3) |
| 6 | Live polls | Reuse the existing Engagement schema | Trainers launch polls during class via the platform's own finfluencify_polls/_poll_options/_poll_responses tables (already deployed, currently unused) instead of Zoom's native polling — response data lands in Nefoxx's DB, reusable for analytics/Engagement features later. Surveys/announcements (the rest of Engagement) stay out of scope here. | ✅ Implemented (P3 slice 8) — live_session_id link (migration 160, course_id relaxed to nullable), finfluencify-live-session-poll EF (launch/close), get_/submit_finfluencify_live_session_poll* RPCs, LiveSessionPollPanel in the room |
| 7 | Breakout rooms / whiteboard | Out of scope — Component View limitation | Zoom Meeting SDK Web Component View can join but not create breakout rooms, and has no real whiteboard support. Trading-class format is lecture/one-to-many, not breakout-driven, so this is a non-issue; a trainer wanting freehand drawing can screen-share an external tool as a workaround. | N/A (scope decision) |
| 8 | Association model | audience_type enum — standalone | course | invitees, no mandatory batch | A live class should work the way trainers naturally use Zoom — schedulable with zero prerequisites. standalone (no association — guest lecture, demo, 1:1, internal workshop), course (notify every enrolled student, reusing courseService.getTrainerCourses), or invitees (explicit email list — covers both platform users and external guests, since finfluencify_students has no auth.users FK to key off reliably). batch is deliberately dropped from v1 — no batch creation/listing UI or RPC exists anywhere in the codebase; it can be added as a 4th audience_type once batch management ships, with zero changes to standalone/course/invitees. | ✅ Implemented (P2) |
Architecture
The provider seam (supabase/functions/_shared/meeting-providers/) exposes createMeeting / updateMeeting / cancelMeeting via types.ts, with a zoom.ts adapter that also handles token refresh. All EFs call the interface, never Zoom directly; live_platform selects the adapter (only 'zoom' in v1).
Data model
New migrations form one contiguous block, 148–161 (137 and 141–147 are already taken by index-radar and trade-journal; the NNN counter is monotonic, not date-aligned). 148–152 are written; 148/149 are deployed (P1), 150–152 were written in P2 and are pending npx supabase db push. 153 onward are not yet written (P3/P4 scope). All public., RLS enabled; RPCs SECURITY DEFINER + SET search_path = public. Token table is service-role only (no authenticated SELECT).
Association model (audience_type)
finfluencify_live_sessions does not FK to a batch. Instead audience_type drives an optional, type-conditional association, validated both client-side (utils/liveSessionValidation.js) and server-side (finfluencify-live-session-save/helpers.ts):
audience_type | Association | Who sees it | Notes |
|---|---|---|---|
standalone | None | Only the trainer (host) | Guest lecture, demo, 1:1, internal workshop — no course_id/invitees required. |
course | course_id → finfluencify_courses | Every student with an access_granted enrollment in that course | Course picker reuses the existing, paginated courseService.getTrainerCourses() — no new courses-listing RPC. |
invitees | Rows in finfluencify_live_session_invitees | Each listed email | Email-based, not a student_id FK — finfluencify_students (the CRM-style roster) has its own independent UUID PK with no auth.users foreign key, so it can't reliably target in-app notifications. Email uniformly covers registered platform users (matched by account email) and external/guest invitees with no platform account at all. |
batch is intentionally not a 4th value yet (see locked decision #8) — adding it later is additive (new enum value + batch_id column + a join on finfluencify_course_batches), with zero impact on the standalone/course/invitees paths.
| Migration | Table / object | Purpose | Status |
|---|---|---|---|
…_148_…feature_flag.sql | feature_flags seed | finfluencify_live_classes = false (P0). | ✅ Deployed |
…_149_…trainer_zoom_accounts.sql | finfluencify_trainer_zoom_accounts | Encrypted per-trainer OAuth tokens + connection status. Service-role only. | ✅ Deployed |
…_150_…live_sessions.sql | finfluencify_live_sessions | Scheduled sessions with audience_type/course_id (not batch_id), ics_sequence, plaintext passcode, record_session. | Written (P2), pending deploy |
…_151_…live_session_invitees.sql | finfluencify_live_session_invitees | Email-based invitee list for audience_type='invitees'; UNIQUE(session_id, email). | Written (P2), pending deploy |
…_152_…live_sessions_rpcs.sql | RPCs | get_finfluencify_live_sessions_for_trainer(p_course_id) (every status, course title + invitees joined, includes provider_meeting_id/join_url/passcode for the trainer's own card, excludes host_start_url_enc) and get_finfluencify_student_live_sessions() (course-enrollment OR invitee-email match; standalone never visible to students; no meeting ID/passcode exposed). | Written (P2), pending deploy |
…_153_…live_session_participants.sql | finfluencify_live_session_participants | Attendance, populated by webhooks. | Planned (P3) |
…_154_…live_session_reminders.sql | finfluencify_live_session_reminders | Reminder idempotency — UNIQUE(session_id, window, channel). | Planned (P3) |
…_155_…zoom_webhook_events.sql | finfluencify_zoom_webhook_events | Webhook idempotency + audit (mirror of finfluencify_stream_webhook_events). | Planned (P3) |
20260703_165_finfluencify_email_outbox.sql | finfluencify_email_outbox + 2 functions | Resilient email queue (retry/backoff/dead-letter) + get_finfluencify_live_session_email_recipients() (email resolver — reaches external invitees, honors the live_session email pref) + claim_finfluencify_email_outbox() (FOR UPDATE SKIP LOCKED claim + stuck-processing watchdog). Actual number is 165, not the planned 156. | ✅ Implemented (P4) — see P4 as built |
…_157_…notif_specific_users.sql | notification_events.target_user_ids uuid[] | Adds targeted delivery; fan-out specific_users branch (see Notification enhancements). | Planned (P3) |
…_158_…notif_live_session_category.sql | notification_preferences | Adds 'live_session' category (seed in_app=TRUE, email=TRUE); extends the whitelist in get_/update_notification_preference. | Planned (P3) |
…_159_…notif_hardening_rpcs.sql | RPCs | claim_notification_events (FOR UPDATE SKIP LOCKED), stuck-processing watchdog reset, and get_user_notifications(p_limit, p_before) (replaces client from('notifications')). | Planned (P3) |
…_160_…live_session_recordings.sql | finfluencify_live_session_recordings | Recording metadata only (play_url, passcode_enc, duration) — the file itself never leaves Zoom's cloud. RLS: trainer-owner + the session's course-enrolled/invited students. | Planned (P3) |
…_161_…polls_live_session_link.sql | finfluencify_polls.live_session_id (nullable FK) + RPCs | Additive link to the existing, already-deployed Engagement schema — no new poll/option/response tables. Adds get_live_session_polls(p_session_id) and submit_live_session_poll_response(p_poll_id, p_option_ids). | Planned (P3) |
Token encryption uses a self-contained AES-256-GCM helper (_shared/crypto.ts) with the 32-byte key held only in the edge-function secret ZOOM_TOKEN_ENC_KEY (never in the DB); encrypt in the EF before store, decrypt in the EF on read. (The KYC columns are only documented as pgsodium-encrypted — no reusable helper exists in the repo — so this keeps the key out of the database and out of pgsodium key-management entirely.)
Indexes (as built, migration 150): (trainer_id, scheduled_start_at) for the trainer list read, (status, scheduled_start_at) for the future reminder sweep, and a partial index (course_id) WHERE course_id IS NOT NULL. Indexes (as built, migration 165): a partial index finfluencify_email_outbox(next_attempt_at) WHERE status='pending' (the sweep's claim scan) plus a partial (updated_at) WHERE status='processing' (watchdog) and (session_id). Indexes (P3): participants UNIQUE(session_id, participant_email) to keep attendance idempotent under webhook retries; finfluencify_live_session_recordings(session_id) for the one-recording-per-session lookup; finfluencify_polls(live_session_id) (partial, WHERE live_session_id IS NOT NULL) for the in-room poll panel.
Edge functions
.ts is allowed for EFs (the no-TypeScript rule is src/ only). Each follows the shared entrypoint pattern; names registered in a new config/zoomConfig.js EDGE_FN map.
| EF | Type | Purpose | Status |
|---|---|---|---|
finfluencify-zoom-connect | A | Exchange OAuth {code, state} → tokens; store encrypted; mark connected. | ✅ Implemented, deployed |
finfluencify-zoom-disconnect | A | Revoke at Zoom; delete token row. | ✅ Implemented, deployed |
finfluencify-live-session-save | A | Validate (audience_type-conditional course_id/invitee_emails, date/duration bounds) → course-ownership check when audience_type='course' → getValidZoomAccessToken() (refreshes if <5 min validity left) → provider.createMeeting()/updateMeeting() → persist row (encrypts host_start_url_enc/passcode_enc) → best-effort compensating cancelMeeting() if the DB insert fails → upsert invitee rows. Single endpoint: create when session_id is absent, edit when present. | ✅ Implemented (P2), pending deploy |
finfluencify-live-session-cancel | A | Ownership check → idempotent no-op if already cancelled → provider.cancelMeeting() (tolerates a 404 — meeting already gone at Zoom) → status='cancelled', cancelled_at, ics_sequence++. | ✅ Implemented (P2), pending deploy |
finfluencify-live-session-join | A | Authz → returns an embedded-join payload: { meetingNumber, passcode, signature, sdkKey, role, userName }. Trainer → host role (role=1) + a live-fetched ZAK; course-enrolled+access_granted OR matched-by-email invitee → attendee role (role=0). Never returns host_start_url/join_url. | Planned (P3) |
finfluencify-live-session-resend-invite | A | Trainer-ownership check → re-enqueue in-app + email (with current/latest .ics) for selected course-enrolled or invitee recipients; server-side rate-limit. Disabled for cancelled sessions. A subset request restricts the email to the selected accounts; a full resend (no participant_ids) also reaches external invitees by email. | ✅ Implemented (P3 in-app; P4 email wired) |
finfluencify-live-session-poll | A | Trainer-ownership of the session → launch (creates+activates a finfluencify_polls row scoped to live_session_id) or close. Pure reuse of the existing Engagement schema/RLS/triggers — no new poll tables. Voting is a separate SECURITY DEFINER RPC (submit_finfluencify_live_session_poll_response). | ✅ Implemented (P3 slice 8) |
finfluencify-zoom-webhook | B | CRC challenge + x-zm-signature HMAC + replay window + idempotency; maps meeting.started/ended → status, participant_joined/left → attendance, recording.completed → captures play_url/passcode/duration only into finfluencify_live_session_recordings (no file download — the recording stays on Zoom's cloud). | ✅ Implemented (meeting/participant P3 slice 3; recording.completed P3 slice 7) |
finfluencify-live-session-reminder-sweep | B cron (~5 min) | Enqueue T-24h / T-1h / live-now reminders (idempotent via reminders table; bounded batch). | Planned (P3) |
finfluencify-email-outbox-sweep | B cron (~1–2 min) | claim_finfluencify_email_outbox() (SKIP LOCKED claim + watchdog) → POST each row to ZeptoMail HTTP API with the .ics attachment → patch outcome (sent/failed(non-retriable 4xx)/dead(exhausted)/pending+backoff 1m→5m→30m→2h). No-ops (logs email_sweep.not_configured) until the ZeptoMail secrets are set. | ✅ Implemented (P4) |
getValidZoomAccessToken(trainerId, serviceClient) (_shared/zoom-trainer-token.ts, built in P2) is the token-refresh helper referenced as ensureFreshToken() in the original architecture sketch below — it checks token_expires_at with a 5-minute skew, refreshes via Zoom's grant_type=refresh_token flow, persists the rotated refresh token (Zoom always rotates it), and flips connection_status='reauth_required' + throws a typed ForbiddenError on failure.
Secrets (via Deno.env.get): ZOOM_CLIENT_ID, ZOOM_CLIENT_SECRET, ZOOM_REDIRECT_URI, ZOOM_OAUTH_STATE_SECRET, ZOOM_TOKEN_ENC_KEY (base64 of 32 bytes), ZOOM_MEETING_SDK_KEY, ZOOM_MEETING_SDK_SECRET (embedded-join signature), ZOOM_WEBHOOK_SECRET_TOKEN, ZEPTOMAIL_TOKEN (Send-Mail Token), ZEPTOMAIL_API_URL (https://api.zeptomail.in/v1.1/email — India DC), EMAIL_FROM (verified ZeptoMail domain sender).
P4 — Email + .ics (ZeptoMail), as built
The email channel runs entirely in the backend, in parallel with the in-app fan-out. Lifecycle EFs enqueue durable outbox rows; a cron drains them to ZeptoMail. Nothing here touches src/ → no vite rebuild to ship or change it.
Objects (all shipped):
| Object | File | Role |
|---|---|---|
| Outbox table + 2 functions | supabase/migrations/20260703_165_finfluencify_email_outbox.sql | finfluencify_email_outbox (RLS service-role only); get_finfluencify_live_session_email_recipients(uuid) (SECURITY DEFINER — email resolver); claim_finfluencify_email_outbox(int) (SKIP LOCKED claim + watchdog) |
.ics builder | supabase/functions/_shared/ics-builder.ts | Pure RFC 5545 buildIcs() — stable UID=session id, SEQUENCE=ics_sequence, METHOD REQUEST/CANCEL |
| Enqueue helper | supabase/functions/_shared/live-session-email.ts | Resolve recipients → build subject/HTML/text + .ics → insert one outbox row per recipient. Non-fatal (never fails the trainer's request) |
| Drain cron | supabase/functions/finfluencify-email-outbox-sweep/{index,helpers}.ts | Claim → POST to ZeptoMail → patch outcome |
Recipient resolution (why a second resolver): the in-app resolver (get_finfluencify_live_session_recipient_ids, migration 155) returns auth.users ids and can't reach guests with no account. The email resolver returns addresses: course → access_granted enrollees whose live_session email pref is on (default on); invitees → all invited emails, including external guests with no platform account (the whole point of email); standalone → none.
Outbox row lifecycle: pending → (claimed) processing → sent | failed (non-retriable 4xx, terminal) | dead (retries exhausted) | back to pending with a backoff'd next_attempt_at (1m → 5m → 30m → 2h, max_attempts 5). A crashed tick's processing rows are auto-reclaimed after 10 min by the watchdog inside claim_finfluencify_email_outbox().
Lifecycle coverage: create (scheduled), edit (updated, SEQUENCE++), cancel (cancelled, CANCEL .ics), reminders T-24 / T-1 only (independent email channel claim in the reminders ledger; live stays in-app only — an email arriving mid-class is noise), manual resend.
ZeptoMail setup runbook (one-time, manual)
- Create a ZeptoMail account on the India data center (
zeptomail.in). Create a Mail Agent for transactional live-class mail. - Verify the sending domain under the Mail Agent: add the SPF, DKIM, and DMARC DNS records ZeptoMail shows, and wait for all three to go green.
EMAIL_FROMmust be an address on this verified domain (e.g.live@nefoxx.com). - Generate a Send-Mail token for the Mail Agent (this is the
Zoho-enczapikeyvalue). Treat it as a secret — it never appears insrc/or the repo. - Set the three Edge-Function secrets (dashboard → Edge Functions → Secrets, or CLI):bashUntil all three are present the sweep logs
npx supabase secrets set \ ZEPTOMAIL_TOKEN='<send-mail-token>' \ ZEPTOMAIL_API_URL='https://api.zeptomail.in/v1.1/email' \ EMAIL_FROM='live@nefoxx.com'email_sweep.not_configuredand no-ops (outbox rows just accumulate aspendingand drain once configured — no data loss). - Deploy + schedule:
npx supabase db push(migration 165) → redeploy the 4 lifecycle EFs (-live-session-save,-cancel,-resend-invite,-reminder-sweep) → deploy the cronnpx supabase functions deploy finfluencify-email-outbox-sweep --no-verify-jwt→ add a Supabase cron trigger for it at every 1–2 min (same mechanism as the reminder sweep). - Smoke test on
devv.nefoxx.com: schedule acourse/inviteessession → confirm an outbox row appears and flips tosentwithin a tick → the invite lands in a mailbox and adds to the calendar → edit the time and confirm the same calendar event updates (SEQUENCE++, not a duplicate) → cancel and confirm the calendar entry is removed (METHOD:CANCEL).
Monitoring: email_sweep.completed logs { claimed, sent, retried, failed, dead }; email_sweep.undelivered warns when any failed/dead occur. Rows in dead/failed are the dead-letter set — inspect last_error on finfluencify_email_outbox for the ZeptoMail response.
ZAK host-start runbook (operational — the current P3 blocker)
Embedded host-start needs a ZAK fetched by finfluencify-live-session-join via GET /users/me/token?type=zak; it currently 400s. This is a Zoom app scope/consent issue, not code. Work top-down, stop at the first step that fixes it:
- Get the error detail — the join EF logs WARN
zoom.zak_fetch_failedwith Zoom'scode/message:npx supabase functions logs finfluencify-live-session-join | grep zak_fetch_failed.4711/scopes → step 1–2;124/invalid token → step 3;1001/user-not-on-account → step 4. - Add the scope — Marketplace → your OAuth app → Scopes → add
user:read:zak("View a user's ZAK token") and Save. Other scopes being present does not imply ZAK. - Force re-consent (most common fix) — Disconnect in-app, also Uninstall the app from Zoom (Settings → Installed Apps) to clear the stored grant, then Reconnect and confirm the consent screen now lists the ZAK permission. Tokens issued before the scope existed never gain it retroactively.
- Token/enc-key — confirm
getValidZoomAccessToken()returns a fresh token andZOOM_TOKEN_ENC_KEYon prod matches the key used at connect time (a rotated key ⇒ trainers must reconnect). - Dev-mode membership — while the Zoom app is unpublished, ZAK is only fetchable for users on the app-owner's Zoom account. Test host-start with an owner-account user; publish the app for GA.
- Validate E2E — host Start logs
live_session.join_authorized(role 1, nozak_fetch_failed); then Join from a second account (validates attendee path + passcode). Once ZAK succeeds, the next failure you'll hit is the CSP asset block below.
Embedded-join browser requirements — CSP (the "code 5001" fix)
Symptom: ZAK works (live_session.join_authorized, role host) but LiveClassRoom shows "dependent assets are not accessible (code 5001)" and DevTools Network shows js_media.min.js as (blocked:csp).
Cause: @zoom/meetingsdk v6 Component View (client.init({ patchJsMedia: true })) loads its media library, WebAssembly, and audio/video web-workers at runtime from source.zoom.us. Our Content-Security-Policy is a single <meta http-equiv> in index.html (there is no public/_headers; Cloudflare Pages ignores the legacy .htaccess) whose restrictive script-src blocked those assets.
Fix (shipped): the index.html CSP now grants what the SDK needs —
script-src:https://source.zoom.us https://*.zoom.us+'unsafe-eval' 'wasm-unsafe-eval'(WASM compile) +blob:(worker/worklet scripts)worker-src 'self' blob:andchild-src 'self' blob:(the SDK's blob web-workers)media-src 'self' blob: https:andimg-src … blob:(media + rendered frames)connect-src/font-src/style-srcalready permithttps:/wss:, so Zoom's API, websocket, fonts, and styles were never blocked.
Because the CSP lives in index.html, this fix requires a npm run build + redeploy of dist/ to Cloudflare Pages (unlike the pure-backend P4 work). It is the one and only CSP source — keep any future CSP changes here (or, if you later move CSP to a public/_headers HTTP header for stronger directives like frame-ancestors, remove the meta to avoid two policies intersecting).
View: Client View (not Component View). Per Zoom's docs, Component View is "designed specifically for desktop … not mobile" and needs manual pixel-sizing; Client View is "the recommended implementation for mobile and tablet browsers," renders the native full-page Zoom UI inside the app (#zmmtg-root, same route — no new tab / no off-platform nav), is responsive across laptop/tablet/mobile out of the box, and does native Gallery View. LiveClassRoom.jsx uses ZoomMtg (preLoadWasm + prepareWebSDK → init({ leaveUrl, patchJsMedia:true }) → join({ signature, sdkKey, meetingNumber, passWord, userName, zak })). leaveUrl sends the user back to /finfluencify?tab=schedule on leave/host-end (fixes the blank-screen exit). The custom Live Poll panel is createPortal-ed to document.body above #zmmtg-root.
Cross-origin isolation — REQUIRED for Gallery View (implemented, route-scoped). Multi-participant video needs SharedArrayBuffer → cross-origin isolation; without it the SDK renders only the single active speaker (the "only host's video shows" symptom). public/_headers sets COOP: same-origin + COEP: credentialless only on /finfluencify/live/*, so the rest of the app (Cloudflare Stream course videos, YouTube embeds, Images, GA) is untouched. Because the headers land only on a full document load of that route, LiveClassRoom does a one-time location.reload() (sessionStorage-guarded, no loop) when !crossOriginIsolated, so a client-side nav into the room becomes isolated; direct link loads are isolated immediately. credentialless (not require-corp) avoids needing CORP headers on every cross-origin asset. Chromium/Firefox support credentialless; on Safari gallery degrades to active-speaker. Also re-validate Zoom's 2026-03-02 attendee-join policy when testing the student path.
Meeting settings & multi-trainer onboarding
Waiting room is OFF by design. toZoomCreateBody sends settings.waiting_room = false (+ join_before_host = false). Access is already gated by finfluencify-live-session-join — only course-enrolled or invited users receive a signature + the meeting number + passcode — so a second manual-admit gate would just strand authorized attendees on "Host will let you in", and the embedded Component View gives the host no reliable admit UI. The app is the gate.
- Existing meetings created before this change keep
waiting_room=trueon Zoom's side — the setting is baked in at create. Edit the session (re-sends the settings viaupdateMeeting) or recreate it to clear the waiting room. - Account/group lock overrides the API. If the trainer's Zoom account (Admin → Account/Group Settings → Security → Waiting Room) is locked on, Zoom ignores the API
false. Ensure Waiting Room is not locked at the account/group level (individually toggleable is fine). - If you ever want a moderated waiting room with host admit/deny, that needs Component-View admit handling (
client.getWaitingRoomClient()+ admit UI) — a deliberate feature, not the default.
Multi-trainer scheduling — "app is not installed / permissions missing". This is the Zoom app's Development-mode limit, not a code or scope problem. While the General App is unpublished, only Zoom users on the app-owner's account — or accounts explicitly added as test users — can authorize it; any other trainer's OAuth connect fails with "app not installed". Each trainer must also connect their own Zoom (per-trainer OAuth) first. To onboard more trainers:
- Add them as test users — Marketplace → Manage → your app → Add / Test Users → add each trainer's Zoom login email (Development mode allows a limited set). They can then Connect + schedule.
- Or publish the app (Marketplace review) so any Zoom account can install it — required for GA anyway.
- Confirm the app's scopes cover
meeting:write,meeting:read,user:read, anduser:read:zak(host ZAK). Missing scopes surface as authorize/permission errors distinct from "not installed".
Dev → Production cutover (Zoom app)
Current reality (supersedes the earlier "create a second prod app" plan): there is one Zoom app (a single General App carrying OAuth + Meeting SDK + the webhook) and one Supabase project shared by dev and prod — see the Current testing posture callout at the top of this page. Testing runs on https://devv.nefoxx.com against that production Supabase, with the Zoom app in Development mode. buildZoomAuthorizeUrl() derives the authorize redirect_uri from window.location.origin, and the connect EF exchanges the code with the ZOOM_REDIRECT_URI secret — these two must match the same origin, which is why moving hosts requires flipping that one secret.
Going to https://nefoxx.com is therefore a configuration switch, not a new app or a data migration. The full, ordered procedure — Zoom portal changes, Supabase secret/env updates, a verification checklist, and rollback — is in Production Go-Live. Do not improvise it; follow that section.
Provisioning guide — Zoom apps & webhook setup
Step-by-step for standing up the Zoom side of this feature in the Zoom App Marketplace (Develop → Build App). Two separate Zoom apps are required — an OAuth app (creates/manages meetings + owns the webhook) and a Meeting SDK app (mints the embedded-join signature). Do each once per environment (a dev app for localhost, a production app for the live domain — see Dev → Production cutover). Everything below is what was actually done for the P1/P3 setup; keep it as the runbook for future environments.
A. Create the Zoom OAuth app (General App)
Owns meeting create/update/cancel, token refresh, and the webhook Event Subscription.
- Develop → Build App → General App → Create. Name it e.g.
Nefoxx Live Classes (dev)/Nefoxx Live Classes (prod). - App type / auth: choose User-managed OAuth (each trainer connects their own account — not Account-level / Server-to-Server). This is what makes concurrency scale per-trainer.
- OAuth Allow List / Redirect URL for OAuth — set the SPA callback:
- Dev:
http://localhost:3000/finfluencify/zoom/callback - Prod:
https://<prod-domain>/finfluencify/zoom/callbackThe redirect URI is never hardcoded —buildZoomAuthorizeUrl()derives it fromwindow.location.originat runtime — so the value only lives here and in theZOOM_REDIRECT_URIsecret.
- Dev:
- Scopes (Add Scopes → search each, add exactly these — least privilege):
meeting:write:meeting— create meetingsmeeting:update:meeting— edit meetingsmeeting:delete:meeting— cancel meetingsuser:read:user— read the connecting trainer's Zoom user iduser:read:zak— fetch the host ZAK for embedded hosting (Meeting SDK role=1)
- Copy credentials from App Credentials → set the Supabase EF secrets:bashAlso set
npx supabase secrets set \ ZOOM_CLIENT_ID=<client_id> \ ZOOM_CLIENT_SECRET=<client_secret> \ ZOOM_REDIRECT_URI=<the redirect URL from step 3>VITE_ZOOM_CLIENT_ID=<client_id>in the frontend env (.env.localfor dev; the production build env for prod). ZOOM_OAUTH_STATE_SECRET(any strong random string — signs the CSRFstate) andZOOM_TOKEN_ENC_KEY(base64 of 32 random bytes — AES-256-GCM token encryption) are ours, not from Zoom:bashnpx supabase secrets set \ ZOOM_OAUTH_STATE_SECRET="$(openssl rand -hex 32)" \ ZOOM_TOKEN_ENC_KEY="$(openssl rand -base64 32)"- Leave the app in development (unpublished) for the pilot; submit for Zoom's review only before opening to non-pilot trainers (see Risks — review has lead time).
B. Create the Zoom Meeting SDK credentials (embedded join — P3)
This is what makes the class render inside Nefoxx (the LiveClassRoom page) instead of redirecting to Zoom. Zoom deprecated the standalone "Meeting SDK" app type (2023); the Meeting SDK is now a feature enabled on a General App. So there is no separate "Meeting SDK app" to pick from a list anymore — you create a General App and turn the SDK feature on.
One app (chosen) — reuse the section-A General App. Because OAuth, the webhook, and the Meeting SDK are all just features of a General App, we keep a single app for everything (easiest to maintain): the section-A app already carries OAuth + the Event Subscription webhook (section C); we simply enable the Meeting SDK on it too. Its single Client ID/Secret then serves OAuth and Meeting-SDK JWT signing — set ZOOM_MEETING_SDK_KEY/ZOOM_MEETING_SDK_SECRET to the same values as ZOOM_CLIENT_ID/ZOOM_CLIENT_SECRET. (A dedicated second app is possible for review isolation, but not what we do here.)
Don't get lost in the console — three different "SDK" products share this app:
- Access → Plugin SDK = native desktop-app IPC. Not ours.
- Surface → Zoom App SDK / Guest Mode / RTMS / "Select where to use your app" = a Zoom App that runs inside the Zoom client. Not ours — do not configure the Surface page at all, and ignore the "requires RTMS scopes" warnings there.
- Embed → Meeting SDK = embedding a Zoom meeting into our own web page (
LiveClassRoom). This is the one.The "Domain Allow List" under Surface is for the in-client browser (tied to Home URL), not the Meeting SDK web embed — leave it alone.
Scheduling (P2) works without any of this; only the embedded-join feature (P3 slice 6) needs it.
How the pieces cooperate (why the ZAK matters):
- The OAuth integration (A) creates the meeting under the trainer's account and — via its
user:read:zakscope — lets the join EF fetch the host ZAK at join time. - The Meeting SDK credentials (B) are used to sign the client join signature (HS256 JWT) so the Web SDK can render the meeting in-page.
The link between them is purely in our EF, which reads both sets of secrets — Zoom's console doesn't cross-reference them.
B.1 — Enable the Meeting SDK on the existing app + copy credentials
Open the existing section-A General App in the Marketplace (Develop → Build App → your app). In the left nav go to Features → Embed and enable Meeting SDK. Do not use Access → Plugin SDK or the Surface page (those are the desktop-IPC and in-client-Zoom-App products — see the callout above).
On the Embed → Meeting SDK page:
- Platform: Web. Nefoxx is a PWA, so it uses the Web Meeting SDK (
@zoom/meetingsdk) — the web runtime applies even when the PWA is installed on a phone, so no native Android/iOS SDK is needed. (No manual "Download" required; the npm package ships the SDK.) - Leave the three sub-toggles OFF to start:
- Use Device OAuth — code/URL device-login flow (TV/IoT style); irrelevant to us → off.
- "Are you developing a programmatic join use case?" and Request Anonymous Join Exception — these relate to the guest / cross-account attendee join (see B.2 step 8). Our users join interactively through the embedded UI and meetings are created via our OAuth integration, so these are likely unnecessary.
[VERIFY DURING SLICE 6]— only enable them (and wire ZAK/OBF) if testing shows a student can't join a trainer-hosted meeting with signature + passcode alone. Don't flip them speculatively — extra permissions can trigger extra Zoom review.
- Platform: Web. Nefoxx is a PWA, so it uses the Web Meeting SDK (
On Basic Information → App Credentials, copy the Client ID and Client Secret. (Older Zoom docs call these the "SDK Key" / "SDK Secret" — same values, legacy names.) Because this is the same app as section A, these are the same Client ID/Secret you already used for
ZOOM_CLIENT_ID/ZOOM_CLIENT_SECRET.Set them as Supabase Edge Function secrets (our env-var names keep the legacy
SDKlabel for backward-compatibility, but the values are this app's Client ID / Client Secret — identical to the OAuth ones since it's one app):bashnpx supabase secrets set \ ZOOM_MEETING_SDK_KEY=<client_id> \ ZOOM_MEETING_SDK_SECRET=<client_secret>finfluencify-live-session-joinsigns a short-lived HS256 signature with these; the secret never leaves the EF and the key is returned to the browser only inside the signed join payload — so there is noVITE_…frontend env var to set for the SDK (unlikeVITE_ZOOM_CLIENT_IDfor OAuth).
B.2 — App configuration inside the Zoom console
- Information tab: already completed for the section-A app — nothing new to do for a single-app setup. (Zoom blocks activation/review until company name, contact name/email, and short/long description are filled, so confirm they are.)
- Domain allow-list: the only allow-list in this app is the Surface → Domain Allow List (paired with the in-client browser's Home URL) — that belongs to the Zoom-Apps product and is not the Meeting SDK embed allow-list, so leave it alone. The Web Meeting SDK web embed needs no separate domain allow-list here. The relevant URL field remains the OAuth Redirect URL you already set in section A.
[VERIFY IN CONSOLE]only if the Embed → Meeting SDK page itself surfaces an origins/allow-list field (some versions do) — if so, addhttp://localhost:3000(dev) /https://<prod-domain>(prod). - Scopes —
[VERIFY IN CONSOLE]: as a General App it has a Scopes tab, so don't assume it stays empty. In our design the SDK app only signs JWTs and makes no REST calls — the host ZAK is fetched by the section-A OAuth app via itsuser:read:zakscope — so this app needs no added scopes. But if you choose to fetch the ZAK from this app instead, adduser:read:zakhere. Zoom's review flags missing scopes at submission time rather than warning upfront, so confirm the flow before submitting. - Redirect URL: no OAuth callback is needed for pure JWT-based join. The Basic Information form may still display a redirect-URL field even though this flow doesn't use it — leaving it blank is expected, not an error.
- Cross-account join authorization (effective 2026-03-02) —
[VERIFY IN CONSOLE / against Zoom policy]: Zoom now requires apps that join meetings hosted outside the app's own Zoom account to authorize via a ZAK token, an OBF (On-Behalf-Of) token, or RTMS. Our per-trainer OAuth model is multi-tenant — every trainer hosts on their own external Zoom account — so this rule applies:- Host join (trainer): already compliant — the trainer starts as host using a ZAK fetched from their own OAuth token (section-A app). No change needed.
- Participant join (student): must be validated against this policy. A ZAK is host-specific and can't be handed to a different user, and RTMS is for media streaming (not join), so if participant join now requires explicit authorization, the applicable path is an OBF token. Confirm whether student (attendee) join across accounts still works with signature + passcode alone, or now needs OBF — and whether minting OBF tokens requires an additional app registration/scope. Resolve this during slice 6, before enabling embedded join for real (multi-trainer) users.
B.3 — Frontend dependency + hosting requirement (the parts beyond Zoom)
- npm dependency:
@zoom/meetingsdkis added as a bundled dependency and lazy-loaded only on the/finfluencify/live/:sessionIdroom route (added in slice 6), so it never weighs down the rest of the app. No action needed from you here — it ships with the frontend build. - ⚠️ Cross-origin isolation (a Cloudflare Pages hosting task, likely required): the Web Meeting SDK's full audio/video path uses
SharedArrayBuffer, which browsers only enable on a cross-origin-isolated page. The host must send these response headers on the room route:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corpThe app is hosted on Cloudflare Pages, which reads custom headers from a _headers file at the build-output root. Add public/_headers (Vite copies public/* verbatim into dist/) scoped to the room route so the rest of the app is unaffected:
/finfluencify/live/*
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corpWithout it the SDK falls back to a degraded mode (or fails on some browsers). This is the most common non-obvious blocker — validate it on Cloudflare Pages during slice 6 before enabling embedded join for real users. Note that COEP can break other cross-origin assets (images/fonts/iframes) that lack Cross-Origin-Resource-Policy / CORP headers, so scope these headers to the room route if the global app can't satisfy them. 11. Zoom SDK terms: keep Zoom's required branding/attribution in the embedded UI (the SDK terms mandate it) — handled in the LiveClassRoom component, no action from you.
B.4 — Review & go-live
- Keep the app in development for the pilot — in dev mode only users on your own Zoom account can join embedded, which is fine for internal testing.
- Before non-pilot / public users can join embedded, submit the app for Zoom's review — a single submission covering the app's OAuth + Meeting SDK feature sets (has lead time, see Risks).
Your action checklist for section B (single-app): open the section-A General App → Features → Embed → enable Meeting SDK (ignore Access→Plugin SDK and the whole Surface page) → copy Client ID + Client Secret → run the supabase secrets set in B.1 step 3 (same values as the OAuth secrets) → [VERIFY IN CONSOLE] scopes (B.2 step 6 — likely none, ZAK comes from the OAuth flow) → resolve the 2026-03-02 cross-account authorization question for participant join (B.2 step 8) → add the COOP/COEP public/_headers entry for Cloudflare Pages (B.3 step 10) → submit for review before public launch. Everything else (signature EF, @zoom/meetingsdk, LiveClassRoom, branding) ships with slice 6 code.
C. Create & register the webhook (Event Subscription)
The webhook lives inside the OAuth app from section A (not a third app). This is the step that was blocking deployment — the ordering below matters because Zoom validates the endpoint with a CRC challenge that our function can only answer after the secret is set.
Prerequisite: the function must already be deployed as Type B (no JWT) so Zoom (which sends no auth header) can reach it:
supabase functions deploy finfluencify-zoom-webhook --no-verify-jwt.
- Deploy the function (if not already):bashIts public URL is
supabase functions deploy finfluencify-zoom-webhook --no-verify-jwthttps://<project-ref>.supabase.co/functions/v1/finfluencify-zoom-webhook(<project-ref>is the subdomain ofVITE_SUPABASE_URL). - Open the OAuth app → Feature → Event Subscriptions → + Add Event Subscription. Give it a name (e.g.
live-session-lifecycle). - Event notification endpoint URL: paste the function URL from step 1.
- Copy the auto-generated Secret Token shown on this Event Subscription page, and set it as the EF secret before validating — the CRC reply is
HMAC-SHA256(plainToken, secret), so validation fails if the secret isn't live yet:bashSetting a secret redeploys the function's config within a few seconds; wait for it to finish before the next step. If the token is ever rotated in Zoom, re-run this and re-validate.npx supabase secrets set ZOOM_WEBHOOK_SECRET_TOKEN=<secret_token_from_zoom> - Add events (Add Events → Meeting + Recording categories) — subscribe to these five:
- Meeting → Start Meeting (
meeting.started) → sets sessionstatus='live' - Meeting → End Meeting (
meeting.ended) → setsstatus='ended' - Meeting → Participant/Host joined meeting (
meeting.participant_joined) → attendance row - Meeting → Participant/Host left meeting (
meeting.participant_left) → attendanceleft_at - Recording → All Recordings have completed (
recording.completed) → upserts recording metadata (share_url/play_url/passcode/duration) intofinfluencify_live_session_recordings— shipped in slice 7, so subscribe it now (only fires for sessions the trainer opted to record).
- Meeting → Start Meeting (
- Click "Validate" next to the endpoint URL. Zoom POSTs
endpoint.url_validationwith aplainToken; the function replies{ plainToken, encryptedToken }and Zoom shows a green Validated. If it fails, the cause is almost always: secret not set / not yet propagated (step 4), the function deployed with JWT (redeploy with--no-verify-jwt), or a wrong/typo'd URL. - Save the Event Subscription, then Save the app. Subscriptions only fire once saved at the app level.
Verify end-to-end: schedule a test session, start it from the trainer card, and confirm the row flips to live (webhook meeting.started) and back to ended on leave. Each delivery writes a finfluencify_zoom_webhook_events audit row (idempotent on dedupe_key = SHA-256(body)); a processing_error column there is the first place to look if a state change misbehaves. The handler verifies x-zm-signature (v0=HMAC-SHA256(v0:${timestamp}:${rawBody})) with a 5-minute replay window and returns fast 200s, so Zoom won't retry-storm.
Workflows
1. Trainer connects Zoom (per-trainer OAuth)
2. Schedule a live class — implemented (P2)
The trainer picks how the meeting is associated first (an "intuitive first step" — standalone / course / invitees), then fills in the details. Both create and edit go through the same EF; session_id present in the body means edit.
3. Join — embedded in Nefoxx (trainer host or student)
4. Webhook → status + attendance (idempotent)
5. Reminders (in-app + email)
6. Live poll (in-room, reusing Engagement)
Notification coverage (lifecycle × channel)
Every lifecycle change notifies all invited (enrolled) participants on both channels — in-app and ZeptoMail email — with correct calendar semantics. In-app rows flow through the extended specific_users fan-out; email rows are enqueued only for recipients whose 'live_session' email preference is on.
| Action | EF | In-app | Email + .ics | .ics method |
|---|---|---|---|---|
| Schedule (create) | finfluencify-live-session-save | ✅ | ✅ | REQUEST, SEQUENCE 0 |
| Modify (edit time/details) | finfluencify-live-session-save (update path) | ✅ "updated" | ✅ | REQUEST, SEQUENCE++ |
| Cancel | finfluencify-live-session-cancel | ✅ | ✅ | CANCEL, SEQUENCE++ |
| Manual resend (selected) | finfluencify-live-session-resend-invite | ✅ | ✅ | REQUEST, current state |
To make modify/cancel update the same calendar event instead of creating duplicates, the session row carries ics_sequence and the stable .ics UID is the session id; the server-side builder _shared/ics-builder.ts buildIcs({ uid, sequence, method, … }) is called at enqueue time (the built .ics is stored on the outbox row, not re-built by a client util). The save (update) and cancel EFs increment ics_sequence and re-enqueue both channels for the full invited list. The "invited list" depends on audience_type: for course it's every student with an access_granted enrollment in course_id; for invitees it's the rows in finfluencify_live_session_invitees; standalone sessions have no invited list to notify (only the trainer/host is involved). Neither case reads the attendance table.
Embedded join (Zoom Meeting SDK)
The live class is hosted and watched inside Nefoxx — no redirect to Zoom. Two layers cooperate:
- Create (OAuth REST API):
finfluencify-live-session-savecreates the meeting under the trainer's own account and storesprovider_meeting_id(the meeting number) +passcode(plaintext). - Join (Meeting SDK):
finfluencify-live-session-joinreturns a short-lived Meeting SDK signature (a JWT, HS256, signed server-side withZOOM_MEETING_SDK_SECRET; payload carries the SDK key, meeting number,role, and expiry). The ReactLiveClassRoomcomponent feeds that signature to@zoom/meetingsdkand the meeting renders in a Nefoxx page.
Roles & ZAK:
- Student → attendee (
role=0). Joins as a guest (display name from their profile) — no Zoom account required. - Trainer → host (
role=1). Embedded hosting additionally needs a ZAK (Zoom Access Key) which the join EF fetches live fromGET /users/me/token?type=zakusing the trainer's decrypted OAuth token. The ZAK is short-lived and returned to the host'sLiveClassRoomonly.
The signature builder is a pure helper (key/secret/meeting-number/role/exp → JWT) → fully unit-testable; the EF adds authz + ZAK fetch. Signatures are minted per-join and expire quickly, so nothing joinable is stored at rest beyond the encrypted passcode.
Hosting constraint: depending on the @zoom/meetingsdk version, the embedded client may require a cross-origin-isolated context (COOP: same-origin + COEP: require-corp headers / SharedArrayBuffer) for full A/V features. Set these via a public/_headers file on Cloudflare Pages (see B.3 step 10) and confirm during P3; the "Component View" degrades more gracefully than "Client View" if headers can't be set.
Scoped exception — recording playback redirects to Zoom. "No redirect" applies to joining the live class. Watching a finished recording is a separate, asynchronous action — it opens Zoom's own hosted player (play_url, with the passcode appended where Zoom's URL scheme supports it) in a new tab. This is deliberate: the recording file lives on Zoom's storage, not ours (see Recordings below), so there is nothing to embed.
Recordings (Zoom cloud, metadata-only)
Recording is never automatic — a trainer opts in per session (record_session toggle at schedule time, which sets settings.auto_recording = "cloud" on provider.createMeeting()) or starts it manually inside the meeting. Either way:
- Zoom records to its own cloud storage — governed by the trainer's own Zoom plan/quota, not ours.
- On
recording.completed,finfluencify-zoom-webhookreads the payload'srecording_files[]and stores onlyplay_url(orshare_url), an encrypted viewerpasscode_encif Zoom set one,duration_seconds, andtotal_size_bytesintofinfluencify_live_session_recordings. No file is downloaded, copied, or re-hosted — zero incremental storage cost on our infrastructure. - The Live Classes tab (trainer) and the student's "Live Sessions" panel show a "Watch recording" link once the row exists; clicking it opens Zoom's hosted player in a new tab.
Retention caveat: Zoom's own cloud-recording retention/quota applies (shorter on lower-tier plans) — if a trainer wants a permanent asset, they can download it from Zoom and upload it as a course lesson via the existing finfluencify-get-video-upload-url flow; that is a manual, trainer-initiated choice, not something this feature automates.
Live polls (reusing Engagement, not Zoom)
Trainers run quick engagement polls from inside LiveClassRoom using the platform's own, already-deployed Engagement schema (finfluencify_polls / _poll_options / _poll_responses, with their existing triggers and RLS) — not Zoom's native polling API (deliberately left out of the OAuth scope set). Response data therefore lands in Nefoxx's database from day one, reusable for future Engagement/analytics features instead of being locked inside Zoom.
finfluencify-live-session-poll(Type A) handles the two ownership-sensitive actions:launch(creates- activates a poll with
live_session_idset to the current session, options included in one call) andclose.
- activates a poll with
- Submitting a response is a trivial owned single-row insert with no secrets and no external HTTP — same shape as the existing
mark_notification_read/dismiss_notificationprecedent — so it goes through asubmit_live_session_poll_response(p_poll_id, p_option_ids)RPC, called from a hook, rather than a third EF. It inherits the Engagement schema's existing ownership/active-poll-only RLS untouched. - Live tally: both trainer and student
LiveClassRoomviews subscribe viasupabase.channel()tofinfluencify_polls/finfluencify_poll_optionsfiltered bylive_session_id=eq.<id>— the existing denormalization triggers (response_count,response_rate) update in real time with zero new aggregation logic. - Out of scope here: surveys and announcements (the rest of Engagement) — only the minimal poll-launch slice needed for in-class use is wired into the live room.
Frontend changes (JSX only, cn() + isLight)
| Area | File(s) | Change | Status |
|---|---|---|---|
| Config | finfluencify/config/zoomConfig.js | EDGE_FN map, OAuth redirect path/authorize builder, LIVE_SESSION_STATUS, ACTIVE_SESSION_STATUSES, LIVE_CLASSES_FLAG, LIVE_SESSION_NOTIFICATION_CATEGORY, REMINDER_WINDOWS. | ✅ Implemented |
| Settings → Zoom Meetings tab | components/Settings/ZoomIntegrationSettings.jsx | Connect/Disconnect, status badge, reauth CTA. Lives on the dedicated Zoom Meetings sidebar tab, not Settings. | ✅ Implemented |
| OAuth callback | route /finfluencify/zoom/callback (pages/ZoomOAuthCallbackPage.jsx) | Reads ?code&state, calls zoomIntegrationService.exchangeCode(), redirects back to /finfluencify. | ✅ Implemented |
| Live Classes — audience-type picker | components/ZoomMeetings/LiveSessionForm.jsx | Step 1: pick standalone / course / invitees first — no mandatory batch/course dependency. Step 2: title, description, schedule, and the audience-specific field (course search via courseService.getTrainerCourses(), or an invitee-email chip list with inline validation), plus the record_session toggle. One form handles both create and edit. | ✅ Implemented |
| Live Classes — list | components/ZoomMeetings/LiveSessionList.jsx | Lists all of the trainer's sessions with a status badge each; default view is active-only (scheduled+live), ended/cancelled ("expired") hidden until a "Show past/expired" toggle. Per-row "Copy link", "Edit", "Cancel" (confirm dialog). | ✅ Implemented |
| Zoom Meetings tab wiring | components/ZoomMeetings/ZoomMeetings.jsx | Feature-flag gate (friendly "not enabled" message vs. real UI) → ZoomIntegrationSettings → LiveSessionList once connected, else a "connect first" nudge. | ✅ Implemented |
| Student view | pages/MyCourseDetailPage.jsx | "Live Sessions" panel: upcoming + 1-click Join; deep-link from notifications. | Planned (P3) |
| Embedded room | components/ZoomMeetings/LiveClassRoom.jsx + route /finfluencify/live/:sessionId | Calls liveSessionService.join(), mounts @zoom/meetingsdk (Component View) in-page with cn()+isLight chrome; host vs attendee role from the join payload. Carries the SEBI disclaimer. | Planned (P3) |
| In-room poll panel | components/ZoomMeetings/LiveSessionPollPanel.jsx | Collapsible panel inside LiveClassRoom; trainer launches/closes a poll (reuses Engagement schema), student votes; live tally via Realtime. | ✅ Implemented (P3 slice 8) |
| Dependency | @zoom/meetingsdk (devDependency → bundled) | Zoom Web Meeting SDK for embedded join. Lazy-loaded on the room route only. | Planned (P3) |
| Hooks (reads, RPC) | hooks/useZoomConnection.js, useLiveSessions.js | Canonical RPC-hook pattern ({ data/sessions, loading, error, refetch }); useLiveSessions fetches all statuses, active-only filter applied in LiveSessionList.jsx. useStudentLiveSessions.js/useLiveSessionPoll.js are P3. | ✅ Implemented (useZoomConnection, useLiveSessions); rest planned |
| Services (writes, EF) | services/zoomIntegrationService.js, liveSessionService.js | functions.invoke(EDGE_FN.…) only. liveSessionService.save({ sessionId?, title, audienceType, courseId?, invitees?, ... }) (create/edit) and .cancel(sessionId). resendInvite/launchPoll/closePoll are P3. | ✅ Implemented (save/cancel); rest planned |
| Utils | utils/liveSessionValidation.js | Client-side validation mirroring the EF (validateLiveSessionForm): title/description length, audience-type-conditional course/invitee requireds, email format, start-in-future + 15–480 min duration bounds. The .ics builder is server-side (_shared/ics-builder.ts) — email/calendar is entirely backend, so there is no client icsBuilder.js. | ✅ Implemented (liveSessionValidation.js) |
| Status badges | session status (scheduled|live|ended|cancelled) | Render with the documented badge triples (bg/text/border) via cn() + isLight, per the theme guidelines. | ✅ Implemented |
| Recording link | Live Classes list + pages/MyCourseDetailPage.jsx | "Watch recording" — renders only once finfluencify_live_session_recordings has a row for the session; opens Zoom's play_url in a new tab. No player embed (file isn't ours to embed). | Planned (P3) |
| Gating | read finfluencify_plan_features (feature_key='live_sessions') + finfluencify_trainer_active_plans | Live sessions = Starter+; check in UI, enforce server-side in finfluencify-live-session-save. | Planned |
Cross-cutting concerns
- Security / authz: tokens encrypted at rest, never sent to client;
host_start_urlnever sent to students;requireAuth+ ownership on all Type A; student authz = course-enrolled +access_granted(audience_type=course) OR email-matched invitee (audience_type=invitees) —standalonesessions are never visible to students; webhook HMAC + CRC + replay window + idempotency; plan gate enforced server-side; RLS on all tables (token table service-role only). RLS = defense-in-depth, EF = primary enforcement. Embedded join: Meeting SDK signatures are minted server-side per join and expire quickly; the host ZAK is fetched live and returned only to the verified trainer; the SDK secret never leaves the EF. - Timezone: store UTC
timestamptz+timezone(defaultAsia/Kolkata); passstart_time+timezoneto Zoom; display viaformatInTimeZone;.icsuses TZID. See Auth / Backend. - Scalability: per-trainer accounts → concurrency bounded by each trainer's own Zoom licenses, not the platform's; EFs stateless; cron sweeps batch-limited with hard timeout; email via outbox queue.
- Failure handling / observability: token-refresh failure →
connection_status='reauth_required'+ notify trainer + block scheduling with reconnect CTA; Zoom create failure → typed error, no partial row; webhook retries idempotent; email retry withattempts/last_error/dead-letter; structured logs (zoom.meeting_created,zoom.token_refreshed,zoom.webhook_received,email.sent/failed). - Compliance: live sessions remain educational-only; carry the existing SEBI disclaimer into the live UI.
- Cost containment (recordings): no file ever transits or lands on our infrastructure — only a
play_url- passcode + duration row. Storage/minutes cost stays entirely on the trainer's own Zoom plan.
- Live polls: zero new RLS surface — the additive
live_session_idcolumn onfinfluencify_pollsis nullable and inherits the existing, already-audited Engagement RLS/triggers untouched.
Notification system: dependencies & enhancements
This feature reuses the platform's existing notification_events → notifications-fanout → notifications pipeline. That pipeline was audited and confirmed production-grade: async fan-out removes the original O(N-users) publish-transaction block; partial indexes (idx_notif_user_unread, idx_notif_events_pending, idx_notif_source) keep queries flat; RLS denies all authenticated access to notification_events (service-role only); notification_preferences is owner-scoped; every RPC is SECURITY DEFINER + SET search_path + REVOKE…/GRANT EXECUTE with auth.uid() ownership and a category whitelist; an idempotency guard prevents duplicate delivery. The audit surfaced gaps that this work resolves (the first three are bundled with the Zoom backend phase; admin management is a later phase):
| # | Gap | Resolution |
|---|---|---|
| A | Targeted delivery missing (blocking). Fan-out implements only target_type='all_users'; role is unimplemented and specific_users returns []. | Extend the fan-out. Add notification_events.target_user_ids uuid[] and implement the specific_users branch (getRecipientIds returns target_user_ids), keeping preference-filter + idempotency. Zoom EFs emit one event row (specific_users, enrolled student ids, category 'live_session') — no bespoke delivery code. |
| B | Client supabase.from('notifications') breach. NotificationsProvider.jsx reads + paginates the table directly — violates the data-access rule and leaks the table name. | Add get_user_notifications(p_limit, p_before) (SECURITY DEFINER, owner-scoped, excludes dismissed) + a useUserNotifications hook; the provider keeps its realtime channel but reads via the RPC. |
| C | Email channel preference unused. notification_preferences.email defaults FALSE and nothing reads it. | Introduce a distinct 'live_session' category seeded in_app=TRUE, email=TRUE (opt-out available); extend the whitelist in get_/update_notification_preference + seed. EFs read the email pref before enqueuing finfluencify_email_outbox rows — invites/cancels are transactional, so email defaults on. |
| D | Fan-out concurrency/recovery. Pickup is SELECT … LIMIT 10 then per-row UPDATE 'processing' (claim race); crashed processing events need manual reset. | claim_notification_events(p_limit) RPC using FOR UPDATE SKIP LOCKED; a watchdog (in the existing cleanup cron) resets processing events older than ~10 min back to pending. |
| E | No admin management (later phase). Only the guide trigger produces events. | Add an admin-only notifications-admin-broadcast EF (admin auth → inserts a notification_events row for all_users/role/specific_users) + a lean admin UI to compose announcements and view fan-out health (fanout_status, fanout_count, error_message). Ships after Zoom GA (rollout P6). |
The header/NotificationsProvider is a deprecated re-export shim only — no action needed.
Testing
- EF (
npm run test:ef): success; validation; authz/ownership (typed errors); webhook signature + CRC + idempotency; token-refresh/reauth path (mocked Zoom); safe 500s; webhookrecording.completed→finfluencify_live_session_recordingsrow created from metadata only (assert no outbound download call is ever made);finfluencify-live-session-pollownership + active-session-only checks. P4 email:_shared/tests/ics-builder.test.ts(REQUEST/CANCEL, stable UID/SEQUENCE, escaping/folding),_shared/tests/live-session-email.test.ts(per-kind copy, join/cancel semantics, outbox row shape),finfluencify-email-outbox-sweep/tests/helpers.test.ts(retriability, backoff, ZeptoMail payload + base64.ics, and the sent/failed/dead/pending+backoff decision). - Frontend 4-layer (
npm test): L1 utils (liveSessionValidation); L2 hooks/services (mock supabase + cache); L3 component smoke (real import +react-icons/rimount assertion); L4 page integration with thenever calls supabase.from()guard. - DB (
npm run test:db, pgTAP): RPC projections exclude token/host columns; RLS isolation (student of batch A cannot read batch B sessions; tokens unreadable byauthenticated);submit_live_session_poll_responserejects votes on aclosed/foreign-batch poll; existing Engagement RLS unaffected by the new nullable column.
Rollout (feature-flag gated, pilot trainers first)
P2 shipped a deliberate scope pivot mid-build: the original plan assumed every live class hangs off a finfluencify_course_batches row. Batches have no creation/listing UI or RPC anywhere in the codebase, so that dependency was dropped before implementation in favor of the audience_type model (see locked decision #8 and Association model) — adding batch support later is a 4th audience_type value, not a rework.
Production Go-Live
Promoting the Zoom integration from the devv.nefoxx.com test host (Zoom app in Development) to https://nefoxx.com (Zoom app in Production). Because there is one Supabase project (already prod) and one Zoom app, this is a configuration switch — no data migration, no second app. The goals: (a) publish the Zoom app so any trainer can connect, (b) point auth at nefoxx.com, (c) keep the live site undisrupted the whole time.
Golden rule for zero disruption: the feature is gated by the finfluencify_live_classes feature flag. Keep it OFF for production users until every check below passes, then flip it on. Flipping it off is the instant kill-switch (see Rollback).
1. Zoom App portal — Development → Production
- Redirect URLs / Allow List — confirm the app's OAuth Allow List contains the production callback
https://nefoxx.com/finfluencify/zoom/callback(the devhttps://devv.nefoxx.com/...may stay listed during transition). Leave "Use Strict Mode for Redirect URLs" off unless every URL is exact. - Scopes — confirm all required scopes are present and will be part of the published app:
meeting:write:meeting,meeting:update:meeting,meeting:delete:meeting,user:read:user,user:read:zak(host ZAK — the one that broke embedded host-start when missing). - Event Subscription (webhook) — the endpoint is the Supabase function URL, which does not change (same project). Confirm the subscription is Validated and the four meeting events are subscribed (
meeting.started/ended/participant_joined/participant_left). Addrecording.completedif recordings (slice 7) are live. - Meeting SDK (Embed) — confirm Meeting SDK is enabled; no per-domain change needed.
- Information tab — company, contact, descriptions, and any required policy URLs complete (review is blocked without them).
- Submit for review / Publish. This is the gating, lead-time step. In Development mode only users on the app owner's own Zoom account can connect/host/join; outside trainers cannot use the feature until the app is published. Submit the single app (covers OAuth + Meeting SDK) for Zoom's review and wait for approval before enabling the flag for non-pilot trainers.
2. Supabase (production project) — secrets & config
Same project throughout — you are updating secrets, not migrating. Only the origin-bound value changes:
| Secret / var | Action at go-live | Notes |
|---|---|---|
ZOOM_REDIRECT_URI | Set to https://nefoxx.com/finfluencify/zoom/callback | The one required flip. Must equal the origin the browser authorizes from. ⚠️ After this, connecting from devv.nefoxx.com stops working (single-value secret) — expected. |
VITE_ZOOM_CLIENT_ID | Set in the production build env (Cloudflare Pages env var) to the app's Client ID | Build-time; the nefoxx.com bundle must be built with it. Same app → same value as dev. |
ZOOM_CLIENT_ID / ZOOM_CLIENT_SECRET | No change | Same single app. |
ZOOM_MEETING_SDK_KEY / ZOOM_MEETING_SDK_SECRET | No change | Same app's Client ID/Secret. |
ZOOM_WEBHOOK_SECRET_TOKEN | No change | Webhook endpoint/project unchanged. |
ZOOM_TOKEN_ENC_KEY, ZOOM_OAUTH_STATE_SECRET | No change | Ours, environment-independent. |
ZEPTOMAIL_TOKEN / ZEPTOMAIL_API_URL / EMAIL_FROM | Required for P4 email to send — set once the ZeptoMail domain is verified (see P4 as built) | Code is deployed; until these are set the sweep no-ops (email_sweep.not_configured) and outbox rows queue harmlessly. Not needed for P1–P3. |
| Migrations / Edge Functions | Already deployed | No promotion step — they live on this one project. Redeploy only if a newer build exists. |
finfluencify_live_classes flag | Leave OFF until verification passes, then ON | The go/no-go switch. |
Multi-origin caveat (know this):
ZOOM_REDIRECT_URIis a single value, so OAuth connect works for exactly one origin at a time. If you needdevvandnefoxxto both connect simultaneously, the connect EF must be changed to derive/validate the redirect origin from the request against an allow-list instead of a fixed secret — a small code change, out of scope here. For a clean cutover, just flip the secret tonefoxx.com.
3. Frontend — Cloudflare Pages
- Build the production bundle with the prod env (
VITE_ZOOM_CLIENT_ID,VITE_SUPABASE_URL, …):npm run build. - Ensure
public/_headerscarries the COOP/COEP entry for the room route (B.3 step 10) so embedded A/V works — Cloudflare Pages serves these headers. - Deploy
dist/to the Cloudflare Pages project; confirm thenefoxx.comcustom domain points at this deployment.
4. Verification checklist (run on https://nefoxx.com before flag-on for real users)
Do these logged in as a pilot trainer + a pilot student on the app owner's Zoom account first, then, once the app is published, with an outside trainer:
- [ ] Connect Zoom from Settings → returns Connected (token exchange 200, no
zoom.token_exchange_failed). - [ ] Schedule a live class → Zoom meeting created; card shows meeting ID + passcode.
- [ ] Notification delivered to an invited student (fanout cron ran; bell shows it; click deep-links to the Schedule tab).
- [ ] Webhook: start the meeting → session flips to
live; end →ended(checkfinfluencify_zoom_webhook_eventshas rows, noprocessing_error). - [ ] Embedded host-start (trainer clicks Start) → joins in-page (ZAK issued, no
zak_fetch_failed). - [ ] Embedded attendee-join (student clicks Join) → renders in-page with A/V (confirms COOP/COEP is active — the browser is cross-origin-isolated).
- [ ] Cancel → status
cancelled; cancellation notification delivered. - [ ] Reminders cron enqueues at the right windows (if scheduled).
- [ ] Regression: the rest of
nefoxx.com(courses, dashboard, notifications bell) is unaffected by the COOP/COEP headers (they're scoped to/finfluencify/live/*).
Only after all pass: set finfluencify_live_classes = true for the target trainer cohort.
5. Rollback considerations
- Instant kill-switch: set
finfluencify_live_classes = false— hides the entire feature from the UI without touching data or Zoom. First lever for any problem. - Frontend rollback: Cloudflare Pages keeps deployment history — roll back to the previous deployment in the Pages dashboard (one click) if a build regresses the live site.
- Redirect rollback: if connect breaks, revert
ZOOM_REDIRECT_URIto the last-known-good origin (devv.nefoxx.com) to keep internal testing alive while diagnosing. - Zoom app: if the published app misbehaves, it can revert to Development (only internal users affected); existing stored trainer tokens keep working for meeting APIs regardless of publish state.
- Data safety: all migrations are additive (new tables/columns, idempotency ledgers) — there is no destructive step to roll back, and disabling the flag leaves scheduled sessions intact for later.
- Webhook: unaffected by the frontend/flag; if noisy, remove the event subscription in the Zoom app (the handler is idempotent and no-ops on unknown meetings anyway).
Risks
- Zoom start_time double-timezone-conversion (found + fixed in P2): sending Zoom's Create/Update Meeting API a UTC (
Z-suffixed)start_timeand atimezonefield causes Zoom to re-apply the timezone offset on top of the already-UTC instant — the meeting then shows the wrong wall-clock time in the Zoom client/calendar versus what Nefoxx scheduled. Fixed bytoZoomLocalTimeString()(_shared/meeting-providers/zoom.ts), which sends a timezone-LOCAL wall-clock string (noZ/offset) instead. Our ownscheduled_start_atstorage is unaffected — only the value handed to Zoom changed. - Zoom refresh-token rotation: each refresh returns a new refresh token that must be persisted, or the trainer silently drops to
reauth_required. Atomic update inensureFreshToken()+ monitoring. - Zoom OAuth app review: a production user-managed OAuth app needs Zoom's submission/security review (lead time) and verified scopes (
meeting:read/write,user:read). Pilot on a dev app first. - Meeting SDK (embedded join): the Meeting SDK app needs its own production review; the Web SDK has some feature gaps vs the native client, requires a modern browser, may need a cross-origin-isolated page (COOP/COEP) for full A/V, and Zoom's SDK terms require keeping Zoom branding/attribution. Validate on the static host during P3.
- Trainer Zoom plan limits: Basic (free) Zoom caps group meetings at 40 min — set UX expectations.
- Email deliverability: the ZeptoMail sending domain must be verified with SPF/DKIM/DMARC records (India DC) and bounce-monitored; keep the
ZEPTOMAIL_TOKEN(Send-Mail Token) scoped to the mail agent. - Webhook correctness: CRC + signature scheme must be exact; cover with tests and a staging tunnel before GA.
- Recording URL/passcode validity: Zoom's
play_url/share_urland viewer passcode are controlled entirely by Zoom (sharing settings, retention policy) — if a trainer later disables sharing or the retention window lapses, our stored link goes stale. No remediation beyond surfacing a clear "recording unavailable" state — we deliberately do not mirror the file, so we can't refresh it ourselves. - Live polls: because
finfluencify_pollsis shared with the (not-yet-built) general Engagement UI, confirm the trainer's general poll list also surfaces session-launched polls sensibly (it will, by default, sincelive_session_idis just an additional nullable attribute) — no isolation work needed.
Open points to validate
- Plan tier threshold for live classes (Starter+ assumed) and per-tier limits (sessions/month, max participants).
- Whether
Scheduletab is folded intoZoomMeetingsor kept as a separate calendar host. - Email provider = ZeptoMail (India DC, HTTP API) — confirm the verified sending domain + DMARC.
- Reminder windows (T-24h / T-1h / live-now) — confirm or adjust.
- Whether
record_sessiondefaults to a per-plan-tier capability (e.g. only Pro+ trainers can enable cloud recording) or is available to every tier that can schedule a live class.
Out of scope: breakout rooms and freehand whiteboard in the embedded room (Meeting SDK Component View can't create the former and doesn't meaningfully support the latter — a trainer wanting freehand drawing can screen-share an external tool instead); surveys/announcements beyond the minimal live-poll slice (the full Engagement UI ships independently); recording storage/editing (the file stays on Zoom — see Recordings); and non-Zoom providers (seam only). The shared notification pipeline is touched deliberately (targeted delivery, email preference, the client
from()fix, and fan-out hardening) because Zoom depends on it; admin broadcast is a later phase. No other unrelated modules change. See the broader feature context in FinFluencify.