Skip to content

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). The ZOOM_REDIRECT_URI secret is therefore set to https://devv.nefoxx.com/finfluencify/zoom/callback.
  • Because there is no environment isolation, promoting to production https://nefoxx.com is 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 the ZOOM_TOKEN_ENC_KEY secret); host_start_url never 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_path RPCs, idempotency uniques, and targeted indexes (see Data model).
  • EF patterns — shared entrypoint (handleCorsrequireAuthok/err), typed errors, helpers split.
  • UI/UXcn() + isLight, rounded-3xl shells, 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 → notifications pipeline was audited and confirmed production-grade; the gaps it has for targeted delivery, email preferences, a client supabase.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.jsx is 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

#DecisionChoiceWhyStatus
1Connection modelPer-trainer OAuthEach 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)
2RecordingsTrainer opt-in — stays on Zoom's cloud, metadata-only in NefoxxRecording 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)
3NotificationsIn-app + Email/.icsReuse 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)
4ProviderZoom-first, thin seamOne minimal provider interface, Zoom adapter only; live_platform drives dispatch so Meet/Teams can be added later without rework.✅ Implemented (_shared/meeting-providers/)
5Join experienceEmbedded — 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)
6Live pollsReuse the existing Engagement schemaTrainers 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
7Breakout rooms / whiteboardOut of scope — Component View limitationZoom 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)
8Association modelaudience_type enum — standalone | course | invitees, no mandatory batchA 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_typeAssociationWho sees itNotes
standaloneNoneOnly the trainer (host)Guest lecture, demo, 1:1, internal workshop — no course_id/invitees required.
coursecourse_idfinfluencify_coursesEvery student with an access_granted enrollment in that courseCourse picker reuses the existing, paginated courseService.getTrainerCourses() — no new courses-listing RPC.
inviteesRows in finfluencify_live_session_inviteesEach listed emailEmail-based, not a student_id FKfinfluencify_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.

MigrationTable / objectPurposeStatus
…_148_…feature_flag.sqlfeature_flags seedfinfluencify_live_classes = false (P0).✅ Deployed
…_149_…trainer_zoom_accounts.sqlfinfluencify_trainer_zoom_accountsEncrypted per-trainer OAuth tokens + connection status. Service-role only.✅ Deployed
…_150_…live_sessions.sqlfinfluencify_live_sessionsScheduled sessions with audience_type/course_id (not batch_id), ics_sequence, plaintext passcode, record_session.Written (P2), pending deploy
…_151_…live_session_invitees.sqlfinfluencify_live_session_inviteesEmail-based invitee list for audience_type='invitees'; UNIQUE(session_id, email).Written (P2), pending deploy
…_152_…live_sessions_rpcs.sqlRPCsget_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.sqlfinfluencify_live_session_participantsAttendance, populated by webhooks.Planned (P3)
…_154_…live_session_reminders.sqlfinfluencify_live_session_remindersReminder idempotency — UNIQUE(session_id, window, channel).Planned (P3)
…_155_…zoom_webhook_events.sqlfinfluencify_zoom_webhook_eventsWebhook idempotency + audit (mirror of finfluencify_stream_webhook_events).Planned (P3)
20260703_165_finfluencify_email_outbox.sqlfinfluencify_email_outbox + 2 functionsResilient 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.sqlnotification_events.target_user_ids uuid[]Adds targeted delivery; fan-out specific_users branch (see Notification enhancements).Planned (P3)
…_158_…notif_live_session_category.sqlnotification_preferencesAdds 'live_session' category (seed in_app=TRUE, email=TRUE); extends the whitelist in get_/update_notification_preference.Planned (P3)
…_159_…notif_hardening_rpcs.sqlRPCsclaim_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.sqlfinfluencify_live_session_recordingsRecording 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.sqlfinfluencify_polls.live_session_id (nullable FK) + RPCsAdditive 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.

EFTypePurposeStatus
finfluencify-zoom-connectAExchange OAuth {code, state} → tokens; store encrypted; mark connected.✅ Implemented, deployed
finfluencify-zoom-disconnectARevoke at Zoom; delete token row.✅ Implemented, deployed
finfluencify-live-session-saveAValidate (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-cancelAOwnership check → idempotent no-op if already cancelledprovider.cancelMeeting() (tolerates a 404 — meeting already gone at Zoom) → status='cancelled', cancelled_at, ics_sequence++.✅ Implemented (P2), pending deploy
finfluencify-live-session-joinAAuthz → 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-inviteATrainer-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-pollATrainer-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-webhookBCRC 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-sweepB cron (~5 min)Enqueue T-24h / T-1h / live-now reminders (idempotent via reminders table; bounded batch).Planned (P3)
finfluencify-email-outbox-sweepB 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):

ObjectFileRole
Outbox table + 2 functionssupabase/migrations/20260703_165_finfluencify_email_outbox.sqlfinfluencify_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 buildersupabase/functions/_shared/ics-builder.tsPure RFC 5545 buildIcs() — stable UID=session id, SEQUENCE=ics_sequence, METHOD REQUEST/CANCEL
Enqueue helpersupabase/functions/_shared/live-session-email.tsResolve recipients → build subject/HTML/text + .ics → insert one outbox row per recipient. Non-fatal (never fails the trainer's request)
Drain cronsupabase/functions/finfluencify-email-outbox-sweep/{index,helpers}.tsClaim → 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: courseaccess_granted enrollees whose live_session email pref is on (default on); inviteesall invited emails, including external guests with no platform account (the whole point of email); standalone → none.

Outbox row lifecycle: pending → (claimed) processingsent | 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)

  1. Create a ZeptoMail account on the India data center (zeptomail.in). Create a Mail Agent for transactional live-class mail.
  2. 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_FROM must be an address on this verified domain (e.g. live@nefoxx.com).
  3. Generate a Send-Mail token for the Mail Agent (this is the Zoho-enczapikey value). Treat it as a secret — it never appears in src/ or the repo.
  4. Set the three Edge-Function secrets (dashboard → Edge Functions → Secrets, or CLI):
    bash
    npx supabase secrets set \
      ZEPTOMAIL_TOKEN='<send-mail-token>' \
      ZEPTOMAIL_API_URL='https://api.zeptomail.in/v1.1/email' \
      EMAIL_FROM='live@nefoxx.com'
    Until all three are present the sweep logs email_sweep.not_configured and no-ops (outbox rows just accumulate as pending and drain once configured — no data loss).
  5. Deploy + schedule: npx supabase db push (migration 165) → redeploy the 4 lifecycle EFs (-live-session-save, -cancel, -resend-invite, -reminder-sweep) → deploy the cron npx 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).
  6. Smoke test on devv.nefoxx.com: schedule a course/invitees session → confirm an outbox row appears and flips to sent within 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:

  1. Get the error detail — the join EF logs WARN zoom.zak_fetch_failed with Zoom's code/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.
  2. 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.
  3. 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.
  4. Token/enc-key — confirm getValidZoomAccessToken() returns a fresh token and ZOOM_TOKEN_ENC_KEY on prod matches the key used at connect time (a rotated key ⇒ trainers must reconnect).
  5. 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.
  6. Validate E2E — host Start logs live_session.join_authorized (role 1, no zak_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: and child-src 'self' blob: (the SDK's blob web-workers)
  • media-src 'self' blob: https: and img-src … blob: (media + rendered frames)
  • connect-src/font-src/style-src already permit https:/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 + prepareWebSDKinit({ 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=true on Zoom's side — the setting is baked in at create. Edit the session (re-sends the settings via updateMeeting) 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:

  1. 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.
  2. Or publish the app (Marketplace review) so any Zoom account can install it — required for GA anyway.
  3. Confirm the app's scopes cover meeting:write, meeting:read, user:read, and user: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 secretthese 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.

  1. Develop → Build App → General App → Create. Name it e.g. Nefoxx Live Classes (dev) / Nefoxx Live Classes (prod).
  2. 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.
  3. OAuth Allow List / Redirect URL for OAuth — set the SPA callback:
    • Dev: http://localhost:3000/finfluencify/zoom/callback
    • Prod: https://<prod-domain>/finfluencify/zoom/callback The redirect URI is never hardcoded — buildZoomAuthorizeUrl() derives it from window.location.origin at runtime — so the value only lives here and in the ZOOM_REDIRECT_URI secret.
  4. Scopes (Add Scopes → search each, add exactly these — least privilege):
    • meeting:write:meeting — create meetings
    • meeting:update:meeting — edit meetings
    • meeting:delete:meeting — cancel meetings
    • user:read:user — read the connecting trainer's Zoom user id
    • user:read:zak — fetch the host ZAK for embedded hosting (Meeting SDK role=1)
  5. Copy credentials from App Credentials → set the Supabase EF secrets:
    bash
    npx supabase secrets set \
      ZOOM_CLIENT_ID=<client_id> \
      ZOOM_CLIENT_SECRET=<client_secret> \
      ZOOM_REDIRECT_URI=<the redirect URL from step 3>
    Also set VITE_ZOOM_CLIENT_ID=<client_id> in the frontend env (.env.local for dev; the production build env for prod).
  6. ZOOM_OAUTH_STATE_SECRET (any strong random string — signs the CSRF state) and ZOOM_TOKEN_ENC_KEY (base64 of 32 random bytes — AES-256-GCM token encryption) are ours, not from Zoom:
    bash
    npx supabase secrets set \
      ZOOM_OAUTH_STATE_SECRET="$(openssl rand -hex 32)" \
      ZOOM_TOKEN_ENC_KEY="$(openssl rand -base64 32)"
  7. 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:zak scope — 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

  1. 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.
  2. 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.

  3. Set them as Supabase Edge Function secrets (our env-var names keep the legacy SDK label for backward-compatibility, but the values are this app's Client ID / Client Secret — identical to the OAuth ones since it's one app):

    bash
    npx supabase secrets set \
      ZOOM_MEETING_SDK_KEY=<client_id> \
      ZOOM_MEETING_SDK_SECRET=<client_secret>

    finfluencify-live-session-join signs 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 no VITE_… frontend env var to set for the SDK (unlike VITE_ZOOM_CLIENT_ID for OAuth).

B.2 — App configuration inside the Zoom console

  1. 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.)
  2. 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, add http://localhost:3000 (dev) / https://<prod-domain> (prod).
  3. 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 its user:read:zak scope — so this app needs no added scopes. But if you choose to fetch the ZAK from this app instead, add user:read:zak here. Zoom's review flags missing scopes at submission time rather than warning upfront, so confirm the flow before submitting.
  4. 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.
  5. 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)

  1. npm dependency: @zoom/meetingsdk is added as a bundled dependency and lazy-loaded only on the /finfluencify/live/:sessionId room 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.
  2. ⚠️ 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-corp

The 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-corp

Without 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

  1. 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.
  2. 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.

  1. Deploy the function (if not already):
    bash
    supabase functions deploy finfluencify-zoom-webhook --no-verify-jwt
    Its public URL is https://<project-ref>.supabase.co/functions/v1/finfluencify-zoom-webhook (<project-ref> is the subdomain of VITE_SUPABASE_URL).
  2. Open the OAuth app → Feature → Event Subscriptions → + Add Event Subscription. Give it a name (e.g. live-session-lifecycle).
  3. Event notification endpoint URL: paste the function URL from step 1.
  4. 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:
    bash
    npx supabase secrets set ZOOM_WEBHOOK_SECRET_TOKEN=<secret_token_from_zoom>
    Setting 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.
  5. Add events (Add Events → Meeting + Recording categories) — subscribe to these five:
    • Meeting → Start Meeting (meeting.started) → sets session status='live'
    • Meeting → End Meeting (meeting.ended) → sets status='ended'
    • Meeting → Participant/Host joined meeting (meeting.participant_joined) → attendance row
    • Meeting → Participant/Host left meeting (meeting.participant_left) → attendance left_at
    • Recording → All Recordings have completed (recording.completed) → upserts recording metadata (share_url/play_url/passcode/duration) into finfluencify_live_session_recordingsshipped in slice 7, so subscribe it now (only fires for sessions the trainer opted to record).
  6. Click "Validate" next to the endpoint URL. Zoom POSTs endpoint.url_validation with a plainToken; 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.
  7. 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.

ActionEFIn-appEmail + .ics.ics method
Schedule (create)finfluencify-live-session-saveREQUEST, SEQUENCE 0
Modify (edit time/details)finfluencify-live-session-save (update path)✅ "updated"REQUEST, SEQUENCE++
Cancelfinfluencify-live-session-cancelCANCEL, SEQUENCE++
Manual resend (selected)finfluencify-live-session-resend-inviteREQUEST, 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:

  1. Create (OAuth REST API): finfluencify-live-session-save creates the meeting under the trainer's own account and stores provider_meeting_id (the meeting number) + passcode (plaintext).
  2. Join (Meeting SDK): finfluencify-live-session-join returns a short-lived Meeting SDK signature (a JWT, HS256, signed server-side with ZOOM_MEETING_SDK_SECRET; payload carries the SDK key, meeting number, role, and expiry). The React LiveClassRoom component feeds that signature to @zoom/meetingsdk and 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 from GET /users/me/token?type=zak using the trainer's decrypted OAuth token. The ZAK is short-lived and returned to the host's LiveClassRoom only.

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:

  1. Zoom records to its own cloud storage — governed by the trainer's own Zoom plan/quota, not ours.
  2. On recording.completed, finfluencify-zoom-webhook reads the payload's recording_files[] and stores only play_url (or share_url), an encrypted viewer passcode_enc if Zoom set one, duration_seconds, and total_size_bytes into finfluencify_live_session_recordings. No file is downloaded, copied, or re-hosted — zero incremental storage cost on our infrastructure.
  3. 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_id set to the current session, options included in one call) and close.
  • 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_notification precedent — so it goes through a submit_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 LiveClassRoom views subscribe via supabase.channel() to finfluencify_polls/finfluencify_poll_options filtered by live_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)

AreaFile(s)ChangeStatus
Configfinfluencify/config/zoomConfig.jsEDGE_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 tabcomponents/Settings/ZoomIntegrationSettings.jsxConnect/Disconnect, status badge, reauth CTA. Lives on the dedicated Zoom Meetings sidebar tab, not Settings.✅ Implemented
OAuth callbackroute /finfluencify/zoom/callback (pages/ZoomOAuthCallbackPage.jsx)Reads ?code&state, calls zoomIntegrationService.exchangeCode(), redirects back to /finfluencify.✅ Implemented
Live Classes — audience-type pickercomponents/ZoomMeetings/LiveSessionForm.jsxStep 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 — listcomponents/ZoomMeetings/LiveSessionList.jsxLists 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 wiringcomponents/ZoomMeetings/ZoomMeetings.jsxFeature-flag gate (friendly "not enabled" message vs. real UI) → ZoomIntegrationSettingsLiveSessionList once connected, else a "connect first" nudge.✅ Implemented
Student viewpages/MyCourseDetailPage.jsx"Live Sessions" panel: upcoming + 1-click Join; deep-link from notifications.Planned (P3)
Embedded roomcomponents/ZoomMeetings/LiveClassRoom.jsx + route /finfluencify/live/:sessionIdCalls 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 panelcomponents/ZoomMeetings/LiveSessionPollPanel.jsxCollapsible 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.jsCanonical 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.jsfunctions.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
Utilsutils/liveSessionValidation.jsClient-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 badgessession status (scheduled|live|ended|cancelled)Render with the documented badge triples (bg/text/border) via cn() + isLight, per the theme guidelines.✅ Implemented
Recording linkLive 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)
Gatingread finfluencify_plan_features (feature_key='live_sessions') + finfluencify_trainer_active_plansLive 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_url never 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) — standalone sessions 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 (default Asia/Kolkata); pass start_time + timezone to Zoom; display via formatInTimeZone; .ics uses 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 with attempts/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_id column on finfluencify_polls is 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):

#GapResolution
ATargeted 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.
BClient 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.
CEmail 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.
DFan-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.
ENo 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; webhook recording.completedfinfluencify_live_session_recordings row created from metadata only (assert no outbound download call is ever made); finfluencify-live-session-poll ownership + 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/ri mount assertion); L4 page integration with the never 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 by authenticated); submit_live_session_poll_response rejects votes on a closed/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

  1. Redirect URLs / Allow List — confirm the app's OAuth Allow List contains the production callback https://nefoxx.com/finfluencify/zoom/callback (the dev https://devv.nefoxx.com/... may stay listed during transition). Leave "Use Strict Mode for Redirect URLs" off unless every URL is exact.
  2. 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).
  3. 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). Add recording.completed if recordings (slice 7) are live.
  4. Meeting SDK (Embed) — confirm Meeting SDK is enabled; no per-domain change needed.
  5. Information tab — company, contact, descriptions, and any required policy URLs complete (review is blocked without them).
  6. 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 / varAction at go-liveNotes
ZOOM_REDIRECT_URISet to https://nefoxx.com/finfluencify/zoom/callbackThe 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_IDSet in the production build env (Cloudflare Pages env var) to the app's Client IDBuild-time; the nefoxx.com bundle must be built with it. Same app → same value as dev.
ZOOM_CLIENT_ID / ZOOM_CLIENT_SECRETNo changeSame single app.
ZOOM_MEETING_SDK_KEY / ZOOM_MEETING_SDK_SECRETNo changeSame app's Client ID/Secret.
ZOOM_WEBHOOK_SECRET_TOKENNo changeWebhook endpoint/project unchanged.
ZOOM_TOKEN_ENC_KEY, ZOOM_OAUTH_STATE_SECRETNo changeOurs, environment-independent.
ZEPTOMAIL_TOKEN / ZEPTOMAIL_API_URL / EMAIL_FROMRequired 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 FunctionsAlready deployedNo promotion step — they live on this one project. Redeploy only if a newer build exists.
finfluencify_live_classes flagLeave OFF until verification passes, then ONThe go/no-go switch.

Multi-origin caveat (know this): ZOOM_REDIRECT_URI is a single value, so OAuth connect works for exactly one origin at a time. If you need devv and nefoxx to 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 to nefoxx.com.

3. Frontend — Cloudflare Pages

  1. Build the production bundle with the prod env (VITE_ZOOM_CLIENT_ID, VITE_SUPABASE_URL, …): npm run build.
  2. Ensure public/_headers carries the COOP/COEP entry for the room route (B.3 step 10) so embedded A/V works — Cloudflare Pages serves these headers.
  3. Deploy dist/ to the Cloudflare Pages project; confirm the nefoxx.com custom 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 (check finfluencify_zoom_webhook_events has rows, no processing_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_URI to 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_time and a timezone field 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 by toZoomLocalTimeString() (_shared/meeting-providers/zoom.ts), which sends a timezone-LOCAL wall-clock string (no Z/offset) instead. Our own scheduled_start_at storage 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 in ensureFreshToken() + 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_url and 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_polls is 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, since live_session_id is just an additional nullable attribute) — no isolation work needed.

Open points to validate

  1. Plan tier threshold for live classes (Starter+ assumed) and per-tier limits (sessions/month, max participants).
  2. Whether Schedule tab is folded into ZoomMeetings or kept as a separate calendar host.
  3. Email provider = ZeptoMail (India DC, HTTP API) — confirm the verified sending domain + DMARC.
  4. Reminder windows (T-24h / T-1h / live-now) — confirm or adjust.
  5. Whether record_session defaults 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.