Skip to main content

Alignmint Video Conferencing Daily.co Integration

Alignmint Video Conferencing - Daily.co Integration

Created: January 24, 2026 Updated: February 19, 2026 Status: IMPLEMENTED - Phase 2 Complete Author: Development Team Location: My Workspace → Video Conferencing

Access & Tier Restrictions

Video conferencing is part of My Workspace, which is accessible to all authenticated users.

  • All Plans: Available to all authenticated users via My Workspace sidebar

Browser Compatibility

Speaker (Audio Output) Selection

Speaker selection relies on the Web Audio Output Devices API (enumerateDevices() returning audiooutput devices + HTMLMediaElement.setSinkId()). This API is not universally supported:

| Browser | `audiooutput` enumeration | `setSinkId()` | Speaker selector works? |
|---------|--------------------------|---------------|------------------------|
| Chrome 110+ | ✅ | ✅ | ✅ |
| Edge 110+ | ✅ | ✅ | ✅ |
| Firefox 116+ | ✅ (partial) | ✅ | ⚠️ Partial |
| **Safari (all versions)** | **❌** | **❌** | **❌** |

Graceful degradation: When setSinkId is not supported or no audiooutput devices are returned, both PreJoinScreen and MeetingRoom display an informational message instead of an empty dropdown: *"Speaker selection is not available in this browser. Audio will play through your system default output. For speaker selection, use Chrome or Edge."*

Detection method: 'setSinkId' in HTMLMediaElement.prototype — future-proof feature detection (no user-agent sniffing). If Safari ever adds support, the UI will automatically enable.

Audio still works on Safari: All <audio> elements use autoPlay playsInline which routes to the system default audio output. Users hear other participants — they just cannot change which output device is used.

Implementation Status

Safari Speaker Selection & Device Logging - Round 12 (February 19, 2026)

Safari/WebKit Returns Zero Audio Output Devices (P0)

  • Fixed: Safari does not implement the Web Audio Output Devices API. enumerateDevices() returns zero audiooutput entries, leaving the Speaker dropdown empty with no explanation.
  • Root cause: Browser limitation — Safari has never supported audiooutput enumeration or setSinkId().
  • Fix: Added setSinkId feature detection ('setSinkId' in HTMLMediaElement.prototype). When unsupported or no speakers found, both PreJoinScreen and MeetingRoom show an informational fallback message instead of an empty dropdown.
  • Affected files: PreJoinScreen.tsx, MeetingRoom.tsx

PreJoinScreen Speaker Selection Not Carried to MeetingRoom (P1)

  • Fixed: selectedSpeaker state in PreJoinScreen was set but never passed through to MeetingRoom. Even on Chrome, the pre-join speaker selection was cosmetic.
  • Fix: Added initialSpeaker to PreJoinScreenProps.onJoin params, JoinParams in MeetingPage, and MeetingRoomProps. MeetingRoom applies the initial speaker via callObject.setOutputDeviceAsync() after joining (only on browsers that support setSinkId).
  • Affected files: PreJoinScreen.tsx, MeetingPage.tsx, MeetingRoom.tsx

No Diagnostic Logging for Device Enumeration (P2)

  • Fixed: Neither component logged the results of enumerateDevices(), making it impossible to debug device issues from console logs.
  • Fix: Added console.log after enumeration in both components showing mic/camera/speaker counts and setSinkId support status.
  • Affected files: PreJoinScreen.tsx, MeetingRoom.tsx

UX, Controls & Panel Fixes - Round 11 (February 17, 2026)

Screen Share Infinite Mirror Loop (P0)

  • Fixed: Sharing the current browser tab created an infinite mirror effect (screen shows the shared screen which shows the shared screen...).
  • Fix: Added displayMediaOptions: { selfBrowserSurface: 'exclude', systemAudio: 'exclude' } to startScreenShare(). Chrome 107+ hides the current tab from the picker. Removed the redundant toast warning. Also suppressed NotAllowedError toast when user cancels the picker.
  • Affected files: MeetingRoom.tsx

Camera Input Not Applying Immediately (P1)

  • Fixed: Changing camera in Settings didn't take effect until manually toggling video off/on.
  • Root cause: setInputDevicesAsync sets the device but the browser doesn't always renegotiate the video track visually.
  • Fix: After setting the new device, cycle the video track (off → 200ms delay → on) to force the browser to apply the new camera immediately.
  • Affected files: MeetingRoom.tsx

Host Can't Request Unmute / Start Video in Participants Panel (P1)

  • Fixed: Participants panel dropdown only showed "Mute" and "Turn off video" when those were active. When a participant was muted or had video off, there were zero options — the dropdown was effectively empty.
  • Fix: Added "Ask to unmute" and "Ask to start video" options using ternary logic (show mute when audio on, show ask-to-unmute when audio off; same for video). The requestUnmute and requestStartVideo functions already existed — they send an app message that shows a toast with an action button on the recipient's side.
  • Note: WebRTC privacy rules prevent forcibly unmuting someone. This is by design (Google Meet, Zoom, Teams all work this way).
  • Affected files: MeetingRoom.tsx

Settings / Participants / Chat Panels Too Narrow (P1)

  • Fixed: All three side panels used w-80 (320px), causing cramped layout, truncated device names, and content overflow.
  • Fix: Widened all three panels from w-80 to w-96 (384px).
  • Affected files: MeetingRoom.tsx

Chat Messages Don't Text-Wrap (P1)

  • Fixed: Long messages without spaces (URLs, continuous text) overflowed horizontally and disappeared off-screen instead of wrapping.
  • Fix: Added break-words class to chat message <p> element. Class exists in index.css (applies overflow-wrap: break-word).
  • Affected files: MeetingRoom.tsx

Hand Raise — No Toast to Other Participants (P2)

  • Fixed: When someone raised their hand, only they saw a toast. Other participants only knew if they opened the Participants panel.
  • Fix: In handleParticipantUpdated, detect when a remote participant's handRaised transitions from false to true and show toast.info('X raised their hand') to everyone.
  • Affected files: MeetingRoom.tsx

Recording Indicator Not Visible to All + Error Handling (P2)

  • Fixed: Recording button was only visible to the host. Other participants had no indication they were being recorded.
  • Fix: Added a red pulsing "REC" indicator in the header bar visible to all participants when isRecording is true. Also improved error handling in toggleRecording to detect plan-limitation errors and show a specific message.
  • Affected files: MeetingRoom.tsx

Missing Controls, Naming & Quality Fixes - Round 10 (February 17, 2026)

Desktop Controls Permanently Hidden — Missing sm:flex in CSS (P0)

  • Fixed: Screen share, recording, raise hand, chat, participants, and settings buttons were permanently invisible on desktop. Only mic, video, and leave buttons showed.
  • Root cause: All 6 desktop-only buttons used hidden sm:flex to hide on mobile and show on desktop. But sm:flex was never added to index.css — per DEVELOPER-PLAYBOOK §19, Tailwind v4 is pre-compiled with no JIT, so new utility classes must be manually added. The class simply didn't exist, so hidden won and buttons stayed permanently hidden at all breakpoints.
  • Fix: Added .sm\:flex { display: flex; } inside @media (width >= 40rem) to index.css. This single CSS addition fixes 74 usages across 48 files — not just MeetingRoom but also DonorPortal, VolunteerPortal, BudgetRequestManager, dialog/alert-dialog components, and many more.
  • Affected files: index.css

"PiP" Label Renamed to "Focus" (P2)

  • Fixed: The view mode tab labeled "PiP" (Picture-in-Picture) is developer jargon that users won't understand.
  • Fix: Renamed display label from "PiP" to "Focus" and tooltip from "FaceTime View" to "Focus View". Internal type 'pip' unchanged. Aligns with Google Meet's naming convention.
  • Affected files: MeetingRoom.tsx

Video Quality — sendSettings maxQuality (P3)

  • Fixed: Video appeared soft/blurry, especially when stretched to full-screen.
  • Analysis: Daily.co's Adaptive Bitrate handles resolution automatically (up to 720p at 2 Mbps). Free plan does NOT cap resolution. Softness was likely due to sender bandwidth or upscaling 720p to a large display.
  • Fix: Added sendSettings: { video: { maxQuality: 'high' } } to createCallObject() to explicitly request 720p when bandwidth allows. This hints to Daily.co's adaptive system to prefer higher quality.
  • Affected files: MeetingRoom.tsx

Controls Visibility, UX & Layout Fixes - Round 9 (February 17, 2026)

Desktop Controls Never Reappearing (P0)

  • Fixed: Meeting controls appeared on load, disappeared after 4s, and never reappeared on mouse movement. Users had no way to access mic, video, chat, participants, settings, or any meeting controls.
  • Root cause: The useEffect that registers mousemove/click listeners depended only on [startHideTimer]. During the loading state (isJoining = true), the meeting container div is not rendered — meetingContainerRef.current is null. The effect ran, hit if (!container) return, and registered zero listeners. When isJoining flipped to false and the container mounted, the effect never re-ran because startHideTimer hadn't changed identity. Meanwhile, the panel-visibility useEffect called startHideTimer() on mount, starting a 4s timer that permanently hid controls with no listener to bring them back.
  • Fix: (1) Added isJoining to the dependency array so the effect re-runs when the container materializes. (2) Desktop mousemove registers on document (not container) so it works regardless of container existence and fires even over <video> elements. (3) Mobile click registers on container only when available. (4) Added pointer-events-none to VideoTile's <video> element as belt-and-suspenders.
  • Affected files: MeetingRoom.tsx

Solo Video Full-Screen Stretch (P1)

  • Fixed: When alone in a call, the local video stretched edge-to-edge filling the entire viewport with object-cover, distorting the image and looking nothing like Google Meet or FaceTime.
  • Fix: In PiP mode with no remote participants, the local video now renders in a centered max-w-2xl aspect-video rounded-xl container with dark background — matching Google Meet's solo preview layout. When remote participants join, the standard PiP behavior (remote fills screen, local as draggable PiP) takes over.
  • Affected files: MeetingRoom.tsx

No Dynamic Speaker Switching in PiP Mode (P1)

  • Fixed: PiP mode hardcoded the first remote participant as the main view (remoteParticipants[0]). The activeSpeakerId was tracked but only used for a highlight ring, not for determining who fills the main area. In a multi-participant call, the main view never changed when someone else started speaking.
  • Fix: PiP mode now uses activeSpeakerId to determine the main participant. When someone speaks, their video dynamically switches to fill the main area and the previous speaker moves to a PiP tile — matching Google Meet and FaceTime behavior.
  • Affected files: MeetingRoom.tsx

Spurious "Admitted" Toast on Direct Join (P2)

  • Fixed: Three toast notifications appeared on load: "Joined meeting", "You have been admitted to the meeting", and optionally "Recording started automatically". The "admitted" toast fired even when joining meetings without a waiting room.
  • Root cause: The access-state-updated event fires with level: 'full' on initial access grant regardless of whether a waiting room exists. The handler unconditionally showed the toast.
  • Fix: Track previous access level; only show "admitted" toast when transitioning from lobbyfull (actual waiting room admission). Direct joins no longer trigger the spurious toast.
  • Affected files: MeetingRoom.tsx

Missing startHideTimer Dependency (P2)

  • Fixed: The useEffect that keeps controls visible when side panels are open called startHideTimer() but didn't list it in its dependency array.
  • Fix: Added startHideTimer to the dependency array.
  • Affected files: MeetingRoom.tsx

Stale callObject in Cleanup (P2)

  • Fixed: The useEffect cleanup for Daily.co call initialization captured callObject from React state (null at effect run time). On unmount, callObject.leave() silently skipped.
  • Fix: Changed cleanup to use callObjectRef.current (set synchronously).
  • Affected files: MeetingRoom.tsx

Dashboard & Instant Meeting Fixes - Round 8 (February 17, 2026)

Instant Meeting Redirect (P0)

  • Fixed: After creating an instant meeting, the original Alignmint tab stayed on the scheduler with the success dialog open instead of returning to the VC dashboard.
  • Fix: For instant meetings, after opening the meeting in a new tab, skip the success dialog and immediately call onSave() to navigate back to the dashboard.
  • Affected files: MeetingScheduler.tsx

Stale "In Progress" Status (P1)

  • Fixed: Meetings stuck as "In Progress" on dashboard after all participants left, when Daily.co webhook hadn't fired yet.
  • Root cause: getDisplayStatus() only handled scheduled meetings past their end time, not in_progress. The staleMeetingsChecked ref also prevented re-running cleanup on subsequent mounts for the same entity.
  • Fix: (1) getDisplayStatus() now shows "Ended" for both scheduled and in_progress meetings past their scheduled_end + 30 min grace. (2) Removed staleMeetingsChecked ref guard so updateStaleMeetings() runs on every component mount. (3) Added refetchInterval: 30000 to the meetings query so webhook-driven status changes appear within 30 seconds.
  • Data fix: Manually updated stuck instant meeting d830b602 to completed.
  • Affected files: VideoConferencingManager.tsx

Meeting Info Line Spacing (P1)

  • Fixed: Date, duration, and participant count on meeting cards were crammed together with no visible spacing.
  • Root cause: gap-x-4 and gap-y-1 CSS classes were missing from index.css (no JIT — classes must be manually safelisted).
  • Fix: Added gap-x-4 (column-gap) and gap-y-1 (row-gap) utility classes.
  • Affected files: index.css

Participant Count Display (P1)

  • Fixed: Meeting cards showed confusing 0/1 attended format for instant meetings.
  • Fix: Changed to {count} joined for in_progress/completed/ended meetings. Only scheduled meetings show {total} invited.
  • Affected files: VideoConferencingManager.tsx

Code Audit Fixes + UX Improvements - Round 7 (February 16, 2026)

Audit Fixes Applied (11 total)

P0 — Critical: 1. Duplicate in-app notifications: send-meeting-invite now skips notification creation for type === 'invitation'createMeeting() in db.ts already creates invitation notifications inline. Prevents double notifications for internal participants. 2. Dead code in create-meeting-room: Removed ~75 lines of unused formatDateTime, calculateDuration, and generateInvitationEmail functions. 3. Missing auth on transcribe-meeting: Added JWT authentication. Accepts both user JWTs (from frontend MeetingSummaryView) and service-role keys (from daily-webhook internal call). Previously, any request with a valid meetingId could trigger transcription and incur OpenAI API costs. 4. Type mismatch: Fixed transcription_texttranscript_text in src/types/meetings.ts to match the actual DB column name and usage in MeetingSummaryView.tsx.

P1 — Functional/UX: 5. send-meeting-reminders: Now checks emailResponse.ok before marking reminder_sent_at. Failed emails skip the update so the cron can retry them. 6. handleParticipantLeft deps: Removed unused callObject and meetingId from dependency array. Moved autoSwitchedForScreenShare state declaration to top of component with other state. 7. leaveMeeting async: callObject.leave() is now awaited before calling onLeave(), preventing race conditions. 8. Recording toggle: Extracted duplicated inline recording toggle logic (desktop button + mobile dropdown) into shared toggleRecording function.

P2 — Code Quality: 9. Unused import: Removed unused Circle import from MeetingRoom.tsx. 10. Email sender inconsistency (historical): This was previously standardized to a single sender for consistency at the time. Current policy uses split-domain sending, and org-facing meeting/transcription notifications are on alignmint.app. 11. sortedParticipants: Wrapped in useMemo keyed on participants to avoid unnecessary re-sorts on every render.

Affected files:

  • supabase/functions/send-meeting-invite/index.ts
  • supabase/functions/create-meeting-room/index.ts
  • supabase/functions/transcribe-meeting/index.ts
  • supabase/functions/send-meeting-reminders/index.ts
  • src/types/meetings.ts
  • src/features/workspace/components/VideoConferencing/MeetingRoom.tsx

Instant Meeting UX Simplification (P0)

  • Simplified form: Instant meetings now show only title, duration, and recording toggle. Description, date/time pickers, waiting room toggle, and participants section are hidden.
  • Waiting room defaults OFF for instant meetings (was defaulting ON).
  • Auto-open: After instant meeting creation, the meeting automatically opens in a new tab. The original tab returns to the VC dashboard (Round 8 fix — previously showed success dialog).
  • Affected files: MeetingScheduler.tsx

SMS Meeting Link Sharing (New Feature)

  • New edge function: send-meeting-sms — sends meeting join link via Telnyx SMS.
  • Success dialog: Both instant and scheduled meeting success dialogs now include a phone number input to text the meeting link to anyone.
  • Phone normalization: Automatically adds +1 prefix for 10-digit US numbers.
  • Message format: You're invited to join "{title}" on Alignmint.\n\nJoin here: {link}\n\nReply STOP to opt out.
  • Auth: Requires valid user JWT.
  • Prereqs: Requires TELNYX_API_KEY, TELNYX_PHONE_NUMBER, and optionally TELNYX_MESSAGING_PROFILE_ID secrets configured in Supabase.
  • Affected files: MeetingScheduler.tsx, supabase/functions/send-meeting-sms/index.ts

Meeting Auto-End Behavior (Verified — No Changes Needed)

  • Daily.co meeting.ended webhook fires when all participants leave the room, regardless of remaining allotted time.
  • daily-webhook edge function sets status: 'completed' + actual_end timestamp.
  • Room has hard expiry at scheduled_end + 30 min with eject_at_room_exp: true.
  • updateStaleMeetings() catches edge cases where webhook didn't fire.

Mobile/Desktop Setup (Verified — No Changes Needed)

  • Desktop: Full control bar (mic, video, screen share, recording, hand raise, chat, participants, settings, fullscreen, leave).
  • Mobile: Simplified bar (mic, video, camera flip, overflow menu, leave). Screen share hidden (not supported on mobile browsers).
  • VideoConferencingManager (dashboard/scheduler) is desktop-gated. MeetingPage/MeetingRoom is NOT gated — users can join from any device via direct link.

Transcripts & Recordings (Verified + Auth Fix)

  • Auto-start recording on host join (2s delay) ✅
  • daily-webhook downloads recording → uploads to Supabase Storage → triggers transcribe-meeting
  • transcribe-meeting downloads → Whisper API → formats transcript → GPT-4o-mini summary → saves to DB → emails participants ✅
  • Fixed: Auth check now accepts service-role key from daily-webhook internal call (was regression from audit fix #3).

Webhook Registration & Pipeline Activation - Round 6 (February 16, 2026)

Root Cause

Round 5 fixed the database table, storage bucket, and webhook resilience issues, but the Daily.co webhook was still not delivering events. Investigation revealed a stale FAILED webhook registered with Daily.co (created Jan 27, circuit-breaker tripped Feb 2 after 4 failures caused by the missing daily_webhook_events table). Additionally, the configure-daily-webhook edge function existed locally but had never been deployed to Supabase.

Webhook Registration via API (P0)

  • Fixed: Deployed configure-daily-webhook edge function to Supabase and invoked it to register webhook URL with Daily.co via REST API (POST https://api.daily.co/v1/webhooks).
  • Issues encountered: (1) recording.stopped is not a valid Daily.co event type — removed. (2) Daily.co API returns webhook list as array, not {data: [...]} — fixed response parsing. (3) Stale FAILED webhook blocked creation ("only 1 webhook per domain") — function now auto-deletes FAILED webhooks before creating fresh ones.
  • Result: New webhook registered, state: ACTIVE, failedCount: 0. Events: meeting.started, meeting.ended, participant.joined, participant.left, recording.started, recording.ready-to-download, recording.error.
  • Affected files: supabase/functions/configure-daily-webhook/index.ts

configure-daily-webhook Function Improvements (P1)

  • Enhanced: Function now supports 3 actions via {action: "list"|"delete-all"|"configure"} POST body.
  • `list`: Returns all registered webhooks for diagnostics.
  • `delete-all`: Removes all webhooks (cleanup utility).
  • `configure`: Idempotent setup — checks for existing healthy webhook, deletes FAILED ones, creates fresh webhook with correct event types.
  • Affected files: supabase/functions/configure-daily-webhook/index.ts

Past Meeting Data Backfill (P1)

  • Fixed: Meetings held via Daily.co had stale DB status because webhooks never fired. Meeting 36cddd68 ("Alignmint Team") had 3 recordings and 3 participants on Daily.co but showed status: scheduled, recording_status: none, actual_start: null in DB.
  • Fix: Manually updated status to completed with actual_start/actual_end timestamps.
  • Meeting ef1408bc was incorrectly marked no_show despite having a 2-participant session — fixed to completed.

Verification

  • Webhook state: ACTIVE (uuid: 3aab32fc-dbbf-4c48-9e7d-eb947b85499f)
  • All future meetings will receive webhook events for meeting lifecycle, participant tracking, and recording processing.
  • daily_webhook_events table: ready to receive logs (0 rows pre-fix, will populate on next meeting).

Recording & Notes Pipeline Fix - Round 5 (February 15, 2026)

Root Cause

User reported "I do not see meeting notes or a recording." Database audit revealed zero meetings had ever transitioned to in_progress via webhook, had a recording processed, or had transcription/AI summary generated. The entire post-meeting pipeline was non-functional.

Missing daily_webhook_events Table (P0)

  • Fixed: The daily-webhook edge function inserted into daily_webhook_events on every invocation, but the table did not exist in the database. This caused every webhook call to 500 before any event processing occurred.
  • Fix: Created daily_webhook_events table via migration with columns: id, event_type, room_name, payload (jsonb), status, error_message, created_at, processed_at. Added indexes on room_name and status. RLS enabled (service role bypasses).
  • Affected: Database schema, daily-webhook edge function

Private Storage Bucket (P0)

  • Fixed: The meeting-recordings storage bucket was private, but the webhook used getPublicUrl() to generate recording URLs. These URLs would 403 when accessed by <video> elements or window.open() since they don't send auth headers.
  • Fix: Set bucket to public (consistent with avatars, donor-photos, event-media, etc.). URLs contain two UUIDs (org_id/meeting_id) making them unguessable. 30-day auto-delete limits exposure.
  • Affected: Supabase Storage configuration

Webhook Error Resilience (P1)

  • Fixed: .single() on meeting lookup could throw 500 for unknown rooms. Webhook logging insert was not wrapped in try/catch, so logging failures blocked event processing.
  • Fix: Changed .single().maybeSingle() for meeting lookup. Wrapped webhook event logging in try/catch so failures are logged but don't prevent event processing.
  • Affected files: supabase/functions/daily-webhook/index.ts

Post-Meeting Redirect (P1)

  • Fixed: After leaving a meeting, redirect to /meeting/:id/summary was gated behind recordingEnabled. Users who left meetings without recording enabled never saw the summary page — even if a recording was manually started during the meeting.
  • Fix: Always redirect to summary page after leaving. The MeetingSummaryView component already handles both with-recording and without-recording states gracefully.
  • Affected files: MeetingPage.tsx

Webhook Configuration (Resolved in Round 6)

  • Daily.co webhook URL: Now configured via API using configure-daily-webhook edge function (see Round 6 above). No manual dashboard configuration needed. Call POST /functions/v1/configure-daily-webhook with {"action":"configure"} to register or reset the webhook.

FaceTime-Style UI/UX Redesign - Round 4 (February 15, 2026)

New View Modes (P0)

  • Replaced gallery / speaker / spotlight with pip / gallery / speaker
  • PiP View (default): FaceTime-style layout — first remote participant fills the screen, local user rendered as a draggable picture-in-picture tile that snaps to corners. Additional remotes become extra PiPs.
  • Gallery View: Equal responsive grid (unchanged behavior, cleaned up).
  • Speaker View: Active speaker or screen share fills screen, other participants as draggable PiPs (no sidebar). When screen share is active, the speaker's camera moves to a PiP.
  • Removed spotlight/pin system entirely (no spotlightParticipantId state).
  • Auto-switch: Screen share start → speaker view; screen share end → return to previous view.
  • Affected files: MeetingRoom.tsx

DraggablePiP Component (P0)

  • New component DraggablePiP — pointer-event-based drag with corner snap animation.
  • Renders its own <video> + <audio> elements with track attachment.
  • Supports defaultCorner prop and zOffset for stacking multiple PiPs.
  • Constrained to container bounds with 8px margin, avoids header (48px) and controls (80px).
  • New component ScreenSharePiP — same drag behavior for screen share content shown as PiP.
  • New component HiddenAudio — renders audio-only element for remote participants not visible in PiPs (speaker view with 3+ participants).
  • Affected files: MeetingRoom.tsx

Mobile Controls Overflow Menu (P0)

  • Fixed: Controls bar showed 10+ buttons on mobile causing horizontal overflow
  • Fix: Mobile shows 4 core buttons (mic, video, flip camera, leave) + overflow MoreHorizontal menu containing record, raise hand, chat, participants, settings. Desktop shows all buttons inline.
  • Affected files: MeetingRoom.tsx

Stale Closure Fix (P1)

  • Fixed: handleAppMessage callback captured stale participants and showChat state
  • Root cause: Callback registered once at init but closed over initial state values
  • Fix: Added participantsRef and showChatRef refs synced via useEffect. Callback reads from refs. Also changed callObject references to callObjectRef.current. Dependency array set to [].
  • Affected files: MeetingRoom.tsx

Hardcoded Colors → Semantic Tokens (P1)

  • Fixed: text-yellow-400, text-green-400, text-red-400 used in VideoTile and ScreenShareTile
  • Fix: Replaced with text-primary (hand raised, screen share, monitor icon) and text-destructive (mic off, video off) per Developer Playbook §19.
  • Affected files: MeetingRoom.tsx

Touch Targets (P1)

  • Fixed: Host controls dropdown trigger was 32×32px (below 44px minimum)
  • Fix: Changed to h-11 w-11 (44px). View mode selector buttons use min-h-[44px]. Fullscreen toggle uses min-h-[44px] min-w-[44px].
  • Affected files: MeetingRoom.tsx

Desktop mousemove+click Double-Fire (P1)

  • Fixed: On desktop, both mousemove and click listeners fired on tap, causing controls to show then immediately toggle off
  • Fix: Uses window.matchMedia('(hover: hover)') to detect pointer type. Desktop: mousemove only. Mobile: click toggle only.
  • Affected files: MeetingRoom.tsx

Missing CSS Classes (P0)

  • Added to index.css: bg-black, bg-black/20, bg-black/40, hover:bg-black/60, from-black/60, backdrop-blur-sm, translate-y-full, -translate-y-full, auto-rows-fr, min-h-[80px], min-h-[120px], text-white/70, hover:text-white, hover:bg-white/10
  • Affected files: index.css

Bug Fixes & Improvements - Round 3 (February 15, 2026)

Blue Header/Footer Bars + Invisible Buttons on Mobile (P0)

  • Fixed: Users saw dark navy blue bars at top and bottom of the meeting screen with no visible buttons
  • Root cause: Layout used flex flex-col with bg-background (navy #0f1629) as a flex container. Header and controls were flex siblings of the video grid, occupying space with the navy background showing through. The variant="secondary" buttons (#252f48) were nearly invisible against the navy. The video grid had p-4 padding creating 16px navy borders.
  • Fix: Complete layout restructure — video grid now fills the entire screen via absolute inset-0. Header and controls bar are absolute overlays with backdrop-blur-sm bg-black/50 for contrast. Container background changed from bg-background to bg-black. Controls use slide animations (translate-y-full / -translate-y-full) instead of h-0 overflow-hidden.
  • Affected files: MeetingRoom.tsx

Video Tile Overlap on Mobile with 3+ Participants (P0)

  • Fixed: Host's video tile overlapped other participants' tiles on mobile phones
  • Root cause: Every VideoTile had aspect-video max-w-4xl max-h-full classes regardless of participant count. On narrow mobile screens with grid-cols-2, the fixed aspect ratio prevented tiles from fitting the available grid cells, causing overflow.
  • Fix: Removed aspect-video max-w-4xl max-h-full from VideoTile (now fills container with w-full h-full). Single-participant view gets aspect-video via a wrapper div. Multi-participant grid uses auto-rows-fr for equal row heights. Responsive grid classes: grid-cols-1 sm:grid-cols-2 for 2 participants, grid-cols-2 for 3-4, etc.
  • Affected files: MeetingRoom.tsx

Waiting Room — Blank Circle + Generic Text (P1)

  • Fixed: Waiting room showed a pulsing blank circle and meeting title instead of host info
  • Root cause: MeetingRoom didn't receive the creator's name or avatar. Only meetingTitle was passed through. The circle was a generic bg-primary/40 animated blob with no content.
  • Fix: Added creatorName and creatorAvatarUrl props threaded from PreJoinScreenMeetingPageMeetingRoom. PreJoinScreen now fetches avatar_url in the creator query. Waiting room shows host's profile photo (or initial fallback) and "{Name}'s Waiting Room" text.
  • Affected files: PreJoinScreen.tsx, MeetingPage.tsx, MeetingRoom.tsx

Speaker/Spotlight Sidebar Hidden on Mobile (P1)

  • Fixed: In speaker and spotlight view modes, other participants' thumbnails were completely hidden on mobile
  • Root cause: Sidebar used hidden md:flex (768px breakpoint), making it invisible on all phones.
  • Fix: Layout changed to flex-col sm:flex-row. Desktop retains vertical sidebar (hidden sm:flex). Mobile shows a horizontal scrollable thumbnail strip (flex sm:hidden h-24) at the bottom with w-32 fixed-width cards.
  • Affected files: MeetingRoom.tsx

Browser Chrome on Mobile (P2)

  • Note: Cannot programmatically hide browser URL bar on mobile — this is a browser security limitation (Opera, DuckDuckGo, Safari, Chrome).
  • Mitigation: Added viewport-fit=cover to viewport meta tag for edge-to-edge display on notched devices.
  • Affected files: index.html

Microphone Functionality (Verified)

  • Status: All microphone code paths verified correct — callObject.setLocalAudio() for toggling, <audio> elements with proper track attachment for remote participants, device switching via setInputDevicesAsync, state sync from participant-updated events. No bugs found.

Bug Fixes & Improvements - Round 2 (February 16, 2026)

Empty Footer Bar on Mobile (P0)

  • Fixed: Controls bar was invisible (opacity-0) but still occupied space on mobile, showing a dark bar at the bottom
  • Root cause: The h-0 overflow-hidden collapse classes were gated behind isFullscreen, which is always false on mobile browsers. Also bg-black/30 made the empty space visible.
  • Fix: Removed isFullscreen guard — controls now fully collapse whenever hidden. Removed bg-black/30 from both header and controls bar (transparent floating buttons).
  • Affected files: MeetingRoom.tsx

Controls Not Reappearing on Mobile (P0)

  • Fixed: Users could not make controls reappear by tapping the screen
  • Root cause: Only mousemove and touchstart were listened to, which reset a timer but didn't provide toggle behavior. Mobile users expect tap-to-toggle.
  • Fix: Replaced with mousemove (desktop show + timer) and click (tap-to-toggle). Tapping the video area toggles controls on/off. Interactive elements (header, controls bar) use stopPropagation to prevent accidental toggles when clicking buttons.
  • Affected files: MeetingRoom.tsx

Timer Column Name Mismatch (P0)

  • Fixed: Meeting duration timer still reset on rejoin despite previous fix attempt
  • Root cause: Timer persistence code queried meetings.started_at (non-existent column). The actual column is actual_start, set by the daily-webhook on meeting.started. The .maybeSingle() call silently returned null, always falling back to new Date().
  • Fix: Changed started_atactual_start in the Supabase query.
  • Affected files: MeetingRoom.tsx

Recording Auto-Start Never Fired (P0)

  • Fixed: Auto-start recording for hosts never executed
  • Root cause: handleJoinedMeeting callback used callObject from React state, which was null at the time the Daily.co event listener was registered (React state updates are async). The stale closure always saw callObject = null.
  • Fix: Added callObjectRef (React ref) that's set synchronously alongside setCallObject. The recording auto-start and manual record button both use this ref.
  • Affected files: MeetingRoom.tsx

Transparent Controls Bar (P1)

  • Fixed: Controls bar and header had bg-black/30 dark background
  • User request: Controls should be transparent floating buttons, not a dark bar
  • Fix: Removed bg-black/30 from both header and controls bar. Buttons have their own solid backgrounds (variant="secondary", variant="destructive") so they remain visible.
  • Affected files: MeetingRoom.tsx

Screen Share Infinite Recursion Warning (P1)

  • Added: Toast warning when starting screen share: "Tip: Share a specific window or app — avoid sharing this browser tab to prevent a mirror effect."
  • Added: Screen share button hidden on mobile (hidden sm:flex) since most mobile browsers don't support getDisplayMedia
  • Affected files: MeetingRoom.tsx

Manual Record/Stop Button (P1)

  • Added: Record toggle button in controls bar (host only). Uses Disc icon with pulse animation when recording. Calls callObject.startRecording() / callObject.stopRecording() via callObjectRef.
  • Affected files: MeetingRoom.tsx

Feature Status Notes

  • Note-taking: No in-meeting note-taking feature exists. The description field in MeetingScheduler is pre-meeting only. Would require a new feature build.
  • Recording: Now fully functional with auto-start (fixed) + manual toggle button
  • Transcription: Post-meeting only via transcribe-meeting edge function (OpenAI Whisper + GPT-4o-mini). Live captions are Phase 3.
  • Scheduled vs Instant meetings: No material difference in-room. Only the creation flow differs (instant skips date/time picker, sets status to in_progress, skips email invites).

Bug Fixes & Improvements - Round 1 (February 16, 2026)

Daily.co Token Property Name Fix (P0)

  • Fixed: generate-meeting-token returned 500 for all non-owner guests joining meetings with waiting room enabled
  • Root cause: Token properties used enable_knocking: true which is a room property, not a token property. The correct Daily.co token property is knocking. Daily.co rejected tokens with the unrecognized property name.
  • Fix: Changed tokenProperties.enable_knockingtokenProperties.knocking (single-line fix)
  • Affected files: supabase/functions/generate-meeting-token/index.ts
  • Deployed: generate-meeting-token v19

Screen Share Not Visible (P0)

  • Fixed: Screen share rendered as a thin line instead of filling the video area
  • Root cause: ScreenShareTile outer div had no explicit dimensions (w-full h-full min-h-[120px]). The absolutely positioned <video> inside collapsed to ~0px because the parent had no height.
  • Affected files: MeetingRoom.tsx

Gallery View Blocked During Screen Share (P0)

  • Fixed: Users could not manually switch back to gallery view while a screen share was active
  • Root cause: Auto-switch useEffect fired on every viewMode change and forcibly overrode the user's selection back to speaker view. Now only triggers on screen share count transition (0 → >0) using a ref.
  • Affected files: MeetingRoom.tsx

Screen Share Track Detection (P2)

  • Fixed: ScreenShareTile track-started handler relied on event.type === 'screenVideo' which may not match Daily.co's event structure. Now checks participant.tracks.screenVideo directly.
  • Affected files: MeetingRoom.tsx

Auto-Hide Controls Bar (P1)

  • Added: Controls bar and header auto-hide after 4 seconds of mouse/touch inactivity
  • Added: Controls reappear on mouse movement or touch, and stay visible when side panels (chat, participants, settings) are open
  • Added: In fullscreen mode, header and controls bar fully collapse (height → 0) when hidden, giving the video grid 100% of the screen
  • Affected files: MeetingRoom.tsx

Fullscreen Mode Improvements (P1)

  • Fixed: Fullscreen now targets the meeting container (not document.documentElement), keeping UI properly scoped
  • Fixed: Added fullscreenchange event listener to sync isFullscreen state when user presses Esc
  • Affected files: MeetingRoom.tsx

Timer Persists Across Rejoin (P1)

  • Fixed: Meeting duration timer reset to 0:00 when a user left and rejoined
  • Root cause: Timer was purely local state (new Date() on joined-meeting). Now fetches meetings.started_at from Supabase on mount and uses it as the reference. Falls back to new Date() only if no DB start time exists (first joiner).
  • Affected files: MeetingRoom.tsx

Camera Flip Button for Mobile (P1)

  • Added: Camera flip button (front/back) visible only on mobile (sm:hidden). Cycles through available video input devices using Daily.co's setInputDevicesAsync.
  • Affected files: MeetingRoom.tsx

Bug Fixes & Improvements (February 15, 2026)

Guest Join 500 Error Fix

  • Fixed: generate-meeting-token edge function returned 500 when non-owner guests joined meetings with waiting room enabled
  • Root cause: isOwner determination only checked participants.user_id match, which failed when participants were added by email (user_id was null)
  • Fix: Extended isOwner logic to check participant role by email for both guest-email joins and org-membership authorized users
  • Affected files: supabase/functions/generate-meeting-token/index.ts

Participant user_id Auto-Linking

  • Fixed: addMeetingParticipant now auto-links user_id when the participant's email matches an existing Alignmint auth account
  • Root cause: Participants added by email had user_id: null even when they had an account, causing authorization to fall back to less reliable paths
  • Affected files: src/lib/db.ts

Better Error Messages on Join Failure

  • Fixed: PreJoinScreen now parses the actual error message from the edge function response body instead of showing the generic "Edge Function returned a non-2xx status code"
  • Affected files: src/features/workspace/components/VideoConferencing/PreJoinScreen.tsx

Email Template Emoji Removal

  • Fixed: Removed all emojis from meeting invitation, reminder, cancellation, and update email templates
  • Affected files: supabase/functions/send-meeting-invite/index.ts, supabase/functions/create-meeting-room/index.ts

Duplicate Email Prevention

  • Fixed: create-meeting-room no longer sends invitation emails (was duplicating emails already sent by send-meeting-invite)
  • Root cause: Both edge functions had email-sending code, causing participants to receive two invitation emails
  • Affected files: supabase/functions/create-meeting-room/index.ts

Bug Fixes & Improvements (January 27, 2026)

Meeting Status Management

  • Fixed: Meetings that passed their end time but never had participants now correctly show as "Ended" instead of "Scheduled"
  • Added: getDisplayStatus() helper function to compute display status based on meeting time
  • Added: updateStaleMeetings() function in db.ts to auto-update stale meetings to completed or no_show status
  • Added: Auto-cleanup runs on component mount to update database for meetings past their grace period
  • Affected files: VideoConferencingManager.tsx, src/lib/db.ts

UI/UX Improvements

  • Fixed: Removed duplicate "Copy Link" option from dropdown menu (standalone button already exists)
  • Added: "Delete Meeting" option now available for all meetings (not just cancelled/completed)
  • Improved: Time filter now correctly categorizes meetings as "Upcoming" vs "Past" based on end time + 30 min grace period
  • Improved: Past meetings now sorted by most recent first
  • Affected files: VideoConferencingManager.tsx

PreJoinScreen Improvements

  • Improved: Clearer messaging when meeting has ended or is not yet joinable
  • Added: Destructive button variant for ended/cancelled meetings
  • Added: Contextual help text explaining why meeting cannot be joined
  • Affected files: PreJoinScreen.tsx

Bug Fix: Meeting Room Creation (January 25, 2026)

  • Fixed: Instant and scheduled meetings now properly create Daily.co rooms
  • Root cause: createMeeting() in db.ts was not calling the create-meeting-room edge function
  • Solution: Added automatic room creation after meeting insert, with fallback in PreJoinScreen
  • Affected files: src/lib/db.ts, src/features/workspace/components/VideoConferencing/PreJoinScreen.tsx

Bug Fix: Start Time Dropdown Not Scrolling (January 25, 2026)

  • Fixed: Start Time dropdown in MeetingScheduler was freezing/not scrolling
  • Root cause: SelectContent viewport had h-[var(--radix-select-trigger-height)] constraining height to trigger height
  • Solution: Removed height constraint from Select component viewport
  • Affected files: src/components/ui/select.tsx

✅ Completed - Ready for Full Testing

  • Database tables: meetings, meeting_participants, daily_usage, daily_global_usage
  • Recording columns: recording_status, recording_url, recording_expires_at, recording_deleted_at
  • Transcript columns: transcription_status, transcript, transcript_text, ai_summary_status, ai_summary
  • RLS policies for meetings and participants
  • Edge functions: create-meeting-room, daily-webhook, send-meeting-invite, cleanup-expired-recordings, generate-meeting-token, transcribe-meeting, send-meeting-sms, send-meeting-reminders
  • Frontend components: VideoConferencingManager, MeetingScheduler, MeetingRoom, PreJoinScreen, MeetingSummaryView
  • Meeting scheduling (scheduled + instant meetings)
  • Participant management (team members + external guests)
  • Email invitations via Resend (invitation, reminder, update, cancellation templates)
  • Rate limiting (10k free minutes global cap)
  • Meeting link sharing with copy functionality
  • Success dialog with meeting details and invite status
  • Recording retention policy: 30-day auto-delete with cron job (runs daily at 3 AM UTC)
  • Manual recording deletion from frontend
  • Recording expiration badges on meeting cards
  • Storage policy notice in dashboard
  • MeetingRoom: Full-featured video room with Daily.co SDK
  • Mic/video/screen share controls
  • Raise hand feature
  • In-meeting chat
  • Participant list with host controls (mute, kick, lower hand)
  • Fullscreen mode
  • Recording indicator
  • View Modes (Gallery, Speaker, Spotlight)
  • Pin/Spotlight participants for focused viewing
  • Auto-switch to Speaker view when screen sharing starts
  • PreJoinScreen: Camera/mic preview with device selection
  • MeetingSummaryView: Post-meeting transcript and AI summary viewer
  • AI Transcription: Automatic transcription via OpenAI Whisper when recording is ready
  • AI Summary: GPT-4o-mini generated meeting summaries with key points and action items

🔄 Phase 3 (Future Enhancements)

  • Live captions during meeting
  • Breakout rooms
  • Virtual backgrounds
  • Calendar integration (Google/Outlook)

---

Executive Summary

Alignmint Video Conferencing is a white-labeled video meeting solution powered by Daily.co, integrated directly into the Tools Hub. This feature enables nonprofit staff to schedule, conduct, and review video meetings with donors, volunteers, board members, and team members—all within Alignmint.

Key Features:

  • Scheduled meetings with email invitations via Resend
  • White-labeled video rooms embedded in Alignmint (no external redirects)
  • Cloud recording with automatic download to Supabase Storage
  • AI-powered transcription using existing Alignmint AI infrastructure
  • MINTY AI summaries with action items and next steps
  • 2-hour maximum duration per meeting
  • Secure join links with expiration

Why Daily.co:

  • White-label ready (looks like Alignmint, not Daily.co)
  • Free tier: 10,000 participant-minutes/month for testing
  • Simple React SDK for embedding
  • Built-in cloud recording with webhooks
  • No infrastructure to manage
  • Cost-effective at scale ($0.004/participant-minute)

Cost at Scale:

  • 1,000 users × 5 hours/week = ~$6,220/month total (video + transcription + AI)
  • Revenue at that scale: ~$899,000/month
  • Video feature cost: 0.7% of revenue

---

Table of Contents

1. Architecture Overview

2. Daily.co Platform Deep Dive

3. Database Schema

4. API Design

5. Frontend Components

6. User Interface Design

7. Email Integration

8. AI Integration

9. Storage Strategy

10. Security & Access Control

11. Cost Analysis

12. Implementation Roadmap

13. Future Considerations

---

1. Architecture Overview

1.1 System Architecture

┌─────────────────────────────────────────────────────────────────────────────┐
│                           ALIGNMINT VIDEO CONFERENCING                        │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                              │
│  ┌──────────────────┐     ┌──────────────────┐     ┌──────────────────┐     │
│  │   People Hub     │     │   Meeting Room   │     │  Meeting Summary │     │
│  │   Dashboard      │────▶│   (Embedded)     │────▶│   (Post-Call)    │     │
│  │                  │     │                  │     │                  │     │
│  │  • Upcoming      │     │  • Daily.co      │     │  • Recording     │     │
│  │  • Past meetings │     │    iframe        │     │  • Transcript    │     │
│  │  • Schedule new  │     │  • Controls      │     │  • AI Summary    │     │
│  └──────────────────┘     └──────────────────┘     └──────────────────┘     │
│           │                        │                        │               │
│           ▼                        ▼                        ▼               │
│  ┌─────────────────────────────────────────────────────────────────────┐   │
│  │                         SUPABASE BACKEND                             │   │
│  │  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐ │   │
│  │  │  meetings   │  │participants │  │   storage   │  │edge functions│ │   │
│  │  │   table     │  │   table     │  │ (recordings)│  │  (webhooks) │ │   │
│  │  └─────────────┘  └─────────────┘  └─────────────┘  └─────────────┘ │   │
│  └─────────────────────────────────────────────────────────────────────┘   │
│           │                        │                        │               │
│           ▼                        ▼                        ▼               │
│  ┌─────────────────────────────────────────────────────────────────────┐   │
│  │                         EXTERNAL SERVICES                            │   │
│  │  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐ │   │
│  │  │  Daily.co   │  │   Resend    │  │   OpenAI    │  │   OpenAI    │ │   │
│  │  │   (Video)   │  │   (Email)   │  │  (Whisper)  │  │   (GPT-4)   │ │   │
│  │  └─────────────┘  └─────────────┘  └─────────────┘  └─────────────┘ │   │
│  └─────────────────────────────────────────────────────────────────────┘   │
│                                                                              │
└─────────────────────────────────────────────────────────────────────────────┘

1.2 Data Flow

┌─────────────────────────────────────────────────────────────────────────────┐
│                              MEETING LIFECYCLE                               │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                              │
│  1. SCHEDULE                                                                 │
│     ┌─────────┐     ┌─────────┐     ┌─────────┐     ┌─────────┐            │
│     │  User   │────▶│ Create  │────▶│ Daily.co│────▶│  Send   │            │
│     │ clicks  │     │ meeting │     │  room   │     │ invites │            │
│     │ schedule│     │ in DB   │     │ created │     │ (Resend)│            │
│     └─────────┘     └─────────┘     └─────────┘     └─────────┘            │
│                                                                              │
│  2. JOIN                                                                     │
│     ┌─────────┐     ┌─────────┐     ┌─────────┐     ┌─────────┐            │
│     │  User   │────▶│ Validate│────▶│ Generate│────▶│  Join   │            │
│     │ clicks  │     │  token  │     │ Daily.co│     │  room   │            │
│     │  join   │     │ & expiry│     │  token  │     │         │            │
│     └─────────┘     └─────────┘     └─────────┘     └─────────┘            │
│                                                                              │
│  3. DURING MEETING                                                           │
│     ┌─────────┐     ┌─────────┐     ┌─────────┐                            │
│     │ Daily.co│────▶│ Record  │────▶│  Track  │                            │
│     │  video  │     │  cloud  │     │ duration│                            │
│     │  call   │     │         │     │         │                            │
│     └─────────┘     └─────────┘     └─────────┘                            │
│                                                                              │
│  4. POST-MEETING                                                             │
│     ┌─────────┐     ┌─────────┐     ┌─────────┐     ┌─────────┐            │
│     │ Webhook │────▶│Download │────▶│Transcribe────▶│   AI    │            │
│     │ meeting │     │recording│     │ (Whisper)│    │ Summary │            │
│     │  ended  │     │         │     │         │     │ (GPT-4) │            │
│     └─────────┘     └─────────┘     └─────────┘     └─────────┘            │
│           │                                               │                 │
│           ▼                                               ▼                 │
│     ┌─────────┐                                     ┌─────────┐            │
│     │  Store  │                                     │  Email  │            │
│     │ in DB + │                                     │ summary │            │
│     │ Storage │                                     │to users │            │
│     └─────────┘                                     └─────────┘            │
│                                                                              │
└─────────────────────────────────────────────────────────────────────────────┘

1.3 Integration Points

| System | Integration Type | Purpose |
|--------|------------------|---------|
| **Daily.co** | REST API + React SDK | Video rooms, recording, participant management |
| **Supabase Database** | Direct queries | Meeting metadata, participants, transcripts |
| **Supabase Storage** | File upload | Recording storage (compressed) |
| **Supabase Edge Functions** | Webhooks | Handle Daily.co events, process recordings |
| **Resend** | Email API | Meeting invitations, reminders, summaries |
| **OpenAI Whisper** | Transcription API | Audio-to-text conversion |
| **OpenAI GPT-4** | Chat API | Summary generation, action item extraction |

---

2. Daily.co Platform Deep Dive

2.1 What Daily.co Provides

Daily.co is a WebRTC-based video API platform that handles all the complexity of real-time video communication.

Core Features Included:

| Feature | Description | Our Usage |
|---------|-------------|-----------|
| **Video Rooms** | Private, secure video meeting spaces | One room per scheduled meeting |
| **Participant Tokens** | JWT-based access control | Secure join links with expiry |
| **Cloud Recording** | Automatic MP4/WebM recording | Download after meeting ends |
| **Screen Sharing** | Share screen, window, or tab | Enabled for all participants |
| **Chat** | In-meeting text chat | Enabled by default |
| **Breakout Rooms** | Split into smaller groups | Future consideration |
| **Webhooks** | Real-time event notifications | Meeting start/end, recording ready |
| **React SDK** | `@daily-co/daily-react` | Embed in Alignmint UI |
| **Custom UI** | Build your own interface | White-label Alignmint branding |

What We Don't Need to Build:

  • ❌ WebRTC infrastructure (STUN/TURN servers)
  • ❌ Video encoding/decoding
  • ❌ Bandwidth management
  • ❌ Recording infrastructure
  • ❌ Cross-browser compatibility
  • ❌ Mobile optimization

2.2 API Capabilities

Room Management API

// Create a meeting room
POST https://api.daily.co/v1/rooms
{
  "name": "alignmint-meeting-abc123",
  "privacy": "private",
  "properties": {
    "max_participants": 10,
    "enable_recording": "cloud",
    "enable_chat": true,
    "enable_screenshare": true,
    "exp": 1706140800,  // Unix timestamp - room expires
    "eject_at_room_exp": true,
    "autojoin": true,
    "start_video_off": false,
    "start_audio_off": false
  }
}

// Response
{
  "id": "abc123",
  "name": "alignmint-meeting-abc123",
  "url": "https://alignmint.daily.co/alignmint-meeting-abc123",
  "created_at": "2026-01-24T12:00:00.000Z",
  "config": { ... }
}

Meeting Token API

// Create participant token
POST https://api.daily.co/v1/meeting-tokens
{
  "properties": {
    "room_name": "alignmint-meeting-abc123",
    "user_name": "John Smith",
    "user_id": "user-uuid-here",
    "is_owner": true,  // Host privileges
    "enable_recording": "cloud",
    "exp": 1706140800  // Token expires
  }
}

// Response
{
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}

Recording API

// Get recording access link
GET https://api.daily.co/v1/recordings/{recording_id}/access-link

// Response
{
  "download_link": "https://...",
  "expires": 3600  // Link valid for 1 hour
}

2.3 Pricing Structure

Free Tier (Development/Testing)

  • 10,000 participant-minutes/month
  • All features included
  • Perfect for initial development and testing

Scale Plan (Production)

  • $0.004 per participant-minute
  • No monthly minimum
  • Pay only for what you use

Cost Examples:

| Scenario | Calculation | Monthly Cost |
|----------|-------------|--------------|
| 10 meetings, 3 people, 30 min each | 10 × 3 × 30 = 900 min | $3.60 |
| 50 meetings, 4 people, 45 min each | 50 × 4 × 45 = 9,000 min | $36.00 |
| 100 meetings, 5 people, 60 min each | 100 × 5 × 60 = 30,000 min | $120.00 |
| 500 meetings, 4 people, 60 min each | 500 × 4 × 60 = 120,000 min | $480.00 |

Recording Costs:

  • Cloud recording: Included in participant-minute cost
  • Recording storage: First 5GB free, then $0.10/GB/month
  • Download bandwidth: Included

2.4 Limitations

| Limitation | Details | Mitigation |
|------------|---------|------------|
| **Max participants** | 1,000 per room (webinar mode) | Sufficient for nonprofit meetings |
| **Max duration** | No hard limit | We enforce 2-hour max |
| **Recording format** | MP4 or WebM | MP4 for compatibility |
| **Recording delay** | 5-15 min after meeting ends | Show "Processing" status |
| **Custom domain** | Requires paid plan | Use `alignmint.daily.co` subdomain |
| **Branding** | Can hide Daily.co logo | Full white-label possible |

---

3. Database Schema

3.1 Meetings Table

-- Meetings table for video conferencing
CREATE TABLE meetings (
  -- Primary key
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  
  -- Organization context (for RLS)
  organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
  
  -- Meeting details
  title TEXT NOT NULL,
  description TEXT,
  
  -- Scheduling
  scheduled_start TIMESTAMPTZ NOT NULL,
  scheduled_end TIMESTAMPTZ NOT NULL,
  max_duration_minutes INTEGER NOT NULL DEFAULT 120,
  timezone TEXT NOT NULL DEFAULT 'America/Chicago',
  
  -- Daily.co integration
  daily_room_name TEXT UNIQUE,
  daily_room_url TEXT,
  daily_room_id TEXT,
  
  -- Status tracking
  status TEXT NOT NULL DEFAULT 'scheduled' 
    CHECK (status IN ('scheduled', 'in_progress', 'completed', 'cancelled', 'no_show')),
  actual_start TIMESTAMPTZ,
  actual_end TIMESTAMPTZ,
  actual_duration_minutes INTEGER,
  
  -- Recording
  recording_enabled BOOLEAN NOT NULL DEFAULT true,
  daily_recording_id TEXT,
  recording_status TEXT DEFAULT 'none'
    CHECK (recording_status IN ('none', 'recording', 'processing', 'ready', 'failed', 'deleted')),
  recording_url TEXT,
  recording_size_bytes BIGINT,
  recording_duration_seconds INTEGER,
  
  -- Transcription
  transcription_status TEXT DEFAULT 'none'
    CHECK (transcription_status IN ('none', 'pending', 'processing', 'completed', 'failed')),
  transcript TEXT,
  transcript_segments JSONB,  -- Word-level timestamps for playback sync
  
  -- AI Summary
  ai_summary_status TEXT DEFAULT 'none'
    CHECK (ai_summary_status IN ('none', 'pending', 'processing', 'completed', 'failed')),
  ai_summary JSONB,
  /*
    ai_summary structure:
    {
      "summary": "High-level meeting summary...",
      "key_points": ["Point 1", "Point 2", ...],
      "action_items": [
        { "task": "Send proposal", "assignee": "John", "due": "2026-01-30" },
        ...
      ],
      "next_steps": ["Schedule follow-up", ...],
      "sentiment": "positive",
      "topics_discussed": ["Budget", "Timeline", ...]
    }
  */
  
  -- Metadata
  created_by UUID NOT NULL REFERENCES users(id),
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  
  -- Soft delete
  deleted_at TIMESTAMPTZ
);

-- Indexes for common queries
CREATE INDEX idx_meetings_organization_id ON meetings(organization_id);
CREATE INDEX idx_meetings_status ON meetings(status);
CREATE INDEX idx_meetings_scheduled_start ON meetings(scheduled_start);
CREATE INDEX idx_meetings_created_by ON meetings(created_by);
CREATE INDEX idx_meetings_daily_room_name ON meetings(daily_room_name);

-- Updated at trigger
CREATE TRIGGER update_meetings_updated_at
  BEFORE UPDATE ON meetings
  FOR EACH ROW
  EXECUTE FUNCTION update_updated_at_column();

3.2 Meeting Participants Table

-- Meeting participants (both internal users and external guests)
CREATE TABLE meeting_participants (
  -- Primary key
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  
  -- Meeting reference
  meeting_id UUID NOT NULL REFERENCES meetings(id) ON DELETE CASCADE,
  
  -- Participant identity (one of these must be set)
  user_id UUID REFERENCES users(id) ON DELETE SET NULL,  -- Internal Alignmint user
  email TEXT,  -- External participant email
  name TEXT NOT NULL,
  
  -- Role and permissions
  role TEXT NOT NULL DEFAULT 'participant'
    CHECK (role IN ('host', 'co_host', 'participant')),
  can_record BOOLEAN NOT NULL DEFAULT false,
  can_screenshare BOOLEAN NOT NULL DEFAULT true,
  
  -- Daily.co token (generated when participant requests to join)
  daily_token TEXT,
  daily_token_expires_at TIMESTAMPTZ,
  
  -- Attendance tracking
  invitation_sent_at TIMESTAMPTZ,
  invitation_status TEXT DEFAULT 'pending'
    CHECK (invitation_status IN ('pending', 'sent', 'accepted', 'declined', 'bounced')),
  reminder_sent_at TIMESTAMPTZ,
  
  -- Actual attendance
  joined_at TIMESTAMPTZ,
  left_at TIMESTAMPTZ,
  duration_seconds INTEGER,
  join_count INTEGER DEFAULT 0,  -- Track rejoins
  
  -- Metadata
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  
  -- Constraints
  UNIQUE(meeting_id, user_id),
  UNIQUE(meeting_id, email),
  CHECK (user_id IS NOT NULL OR email IS NOT NULL)
);

-- Indexes
CREATE INDEX idx_meeting_participants_meeting_id ON meeting_participants(meeting_id);
CREATE INDEX idx_meeting_participants_user_id ON meeting_participants(user_id);
CREATE INDEX idx_meeting_participants_email ON meeting_participants(email);

-- Updated at trigger
CREATE TRIGGER update_meeting_participants_updated_at
  BEFORE UPDATE ON meeting_participants
  FOR EACH ROW
  EXECUTE FUNCTION update_updated_at_column();

3.3 RLS Policies

-- Enable RLS
ALTER TABLE meetings ENABLE ROW LEVEL SECURITY;
ALTER TABLE meeting_participants ENABLE ROW LEVEL SECURITY;

-- Meetings: Users can view meetings in their organization
CREATE POLICY "Users can view meetings in their org"
  ON meetings FOR SELECT
  USING (
    organization_id IN (
      SELECT organization_id FROM organization_users 
      WHERE user_id = auth.uid()
    )
    OR
    -- Parent org admins can see all child org meetings
    EXISTS (
      SELECT 1 FROM organization_users ou
      JOIN organizations o ON o.id = ou.organization_id
      WHERE ou.user_id = auth.uid()
      AND o.type = 'parent_org'
    )
  );

-- Meetings: Users can create meetings in their org
CREATE POLICY "Users can create meetings in their org"
  ON meetings FOR INSERT
  WITH CHECK (
    organization_id IN (
      SELECT organization_id FROM organization_users 
      WHERE user_id = auth.uid()
    )
  );

-- Meetings: Creators and admins can update meetings
CREATE POLICY "Creators and admins can update meetings"
  ON meetings FOR UPDATE
  USING (
    created_by = auth.uid()
    OR
    EXISTS (
      SELECT 1 FROM organization_users 
      WHERE user_id = auth.uid() 
      AND organization_id = meetings.organization_id
      AND role IN ('admin', 'parent_org')
    )
  );

-- Meetings: Creators and admins can delete (soft delete)
CREATE POLICY "Creators and admins can delete meetings"
  ON meetings FOR DELETE
  USING (
    created_by = auth.uid()
    OR
    EXISTS (
      SELECT 1 FROM organization_users 
      WHERE user_id = auth.uid() 
      AND organization_id = meetings.organization_id
      AND role IN ('admin', 'parent_org')
    )
  );

-- Participants: Users can view participants for meetings they can see
CREATE POLICY "Users can view meeting participants"
  ON meeting_participants FOR SELECT
  USING (
    meeting_id IN (
      SELECT id FROM meetings WHERE 
        organization_id IN (
          SELECT organization_id FROM organization_users 
          WHERE user_id = auth.uid()
        )
    )
  );

-- Participants: Meeting creators can manage participants
CREATE POLICY "Meeting creators can manage participants"
  ON meeting_participants FOR ALL
  USING (
    meeting_id IN (
      SELECT id FROM meetings WHERE created_by = auth.uid()
    )
  );

3.4 Indexes

-- Composite indexes for common query patterns

-- Upcoming meetings for a user
CREATE INDEX idx_meetings_upcoming 
  ON meetings(organization_id, scheduled_start) 
  WHERE status = 'scheduled' AND deleted_at IS NULL;

-- Past meetings with recordings
CREATE INDEX idx_meetings_with_recordings 
  ON meetings(organization_id, scheduled_start DESC) 
  WHERE status = 'completed' AND recording_status = 'ready';

-- Meetings needing processing
CREATE INDEX idx_meetings_pending_transcription 
  ON meetings(id) 
  WHERE recording_status = 'ready' AND transcription_status = 'pending';

CREATE INDEX idx_meetings_pending_summary 
  ON meetings(id) 
  WHERE transcription_status = 'completed' AND ai_summary_status = 'pending';

---

4. API Design

4.1 Frontend Functions (db.ts)

// ============================================
// VIDEO CONFERENCING - MEETINGS
// ============================================

export interface Meeting {
  id: string;
  organization_id: string;
  title: string;
  description: string | null;
  scheduled_start: string;
  scheduled_end: string;
  max_duration_minutes: number;
  timezone: string;
  daily_room_name: string | null;
  daily_room_url: string | null;
  status: 'scheduled' | 'in_progress' | 'completed' | 'cancelled' | 'no_show';
  actual_start: string | null;
  actual_end: string | null;
  actual_duration_minutes: number | null;
  recording_enabled: boolean;
  recording_status: 'none' | 'recording' | 'processing' | 'ready' | 'failed' | 'deleted';
  recording_url: string | null;
  recording_size_bytes: number | null;
  transcription_status: 'none' | 'pending' | 'processing' | 'completed' | 'failed';
  transcript: string | null;
  ai_summary_status: 'none' | 'pending' | 'processing' | 'completed' | 'failed';
  ai_summary: MeetingAISummary | null;
  created_by: string;
  created_at: string;
  updated_at: string;
  participants?: MeetingParticipant[];
  creator?: { id: string; first_name: string; last_name: string; email: string };
}

export interface MeetingAISummary {
  summary: string;
  key_points: string[];
  action_items: Array<{
    task: string;
    assignee: string | null;
    due: string | null;
  }>;
  next_steps: string[];
  sentiment: 'positive' | 'neutral' | 'negative';
  topics_discussed: string[];
}

export interface MeetingParticipant {
  id: string;
  meeting_id: string;
  user_id: string | null;
  email: string | null;
  name: string;
  role: 'host' | 'co_host' | 'participant';
  invitation_status: 'pending' | 'sent' | 'accepted' | 'declined' | 'bounced';
  joined_at: string | null;
  left_at: string | null;
  duration_seconds: number | null;
}

export interface CreateMeetingInput {
  title: string;
  description?: string;
  scheduled_start: string;
  scheduled_end: string;
  max_duration_minutes?: number;
  timezone?: string;
  recording_enabled?: boolean;
  participants: Array<{
    user_id?: string;
    email?: string;
    name: string;
    role?: 'host' | 'co_host' | 'participant';
  }>;
  send_invitations?: boolean;
}

/**
 * Fetch upcoming meetings for an organization
 */
export async function fetchUpcomingMeetings(
  entityId: EntityId,
  limit: number = 10
): Promise<Meeting[]> {
  const orgId = getOrgId(entityId);
  
  let query = supabase
    .from('meetings')
    .select(`
      *,
      participants:meeting_participants(*),
      creator:created_by(id, first_name, last_name, email)
    `)
    .eq('status', 'scheduled')
    .is('deleted_at', null)
    .gte('scheduled_start', new Date().toISOString())
    .order('scheduled_start', { ascending: true })
    .limit(limit);
  
  if (orgId) {
    query = query.eq('organization_id', orgId);
  }
  
  const { data, error } = await query;
  
  if (error) {
    console.error('[fetchUpcomingMeetings] Error:', error);
    throw error;
  }
  
  return data || [];
}

/**
 * Fetch past meetings with recordings/summaries
 */
export async function fetchPastMeetings(
  entityId: EntityId,
  options: {
    page?: number;
    pageSize?: number;
    hasRecording?: boolean;
  } = {}
): Promise<{ meetings: Meeting[]; total: number }> {
  const orgId = getOrgId(entityId);
  const page = options.page || 1;
  const pageSize = options.pageSize || 20;
  const offset = (page - 1) * pageSize;
  
  let query = supabase
    .from('meetings')
    .select(`
      *,
      participants:meeting_participants(*),
      creator:created_by(id, first_name, last_name, email)
    `, { count: 'exact' })
    .eq('status', 'completed')
    .is('deleted_at', null)
    .order('scheduled_start', { ascending: false })
    .range(offset, offset + pageSize - 1);
  
  if (orgId) {
    query = query.eq('organization_id', orgId);
  }
  
  if (options.hasRecording) {
    query = query.eq('recording_status', 'ready');
  }
  
  const { data, error, count } = await query;
  
  if (error) {
    console.error('[fetchPastMeetings] Error:', error);
    throw error;
  }
  
  return { meetings: data || [], total: count || 0 };
}

/**
 * Fetch a single meeting by ID
 */
export async function fetchMeetingById(meetingId: string): Promise<Meeting | null> {
  const { data, error } = await supabase
    .from('meetings')
    .select(`
      *,
      participants:meeting_participants(*),
      creator:created_by(id, first_name, last_name, email)
    `)
    .eq('id', meetingId)
    .single();
  
  if (error) {
    if (error.code === 'PGRST116') return null; // Not found
    console.error('[fetchMeetingById] Error:', error);
    throw error;
  }
  
  return data;
}

/**
 * Create a new meeting
 */
export async function createMeeting(
  entityId: EntityId,
  input: CreateMeetingInput
): Promise<Meeting> {
  const orgId = getActualOrgId(entityId);
  if (!orgId) throw new Error('Organization required to create meeting');
  
  const { data: { user } } = await supabase.auth.getUser();
  if (!user) throw new Error('Must be logged in to create meeting');
  
  // Generate unique room name
  const roomName = `alignmint-${orgId.slice(0, 8)}-${Date.now()}`;
  
  // Create meeting in database
  const { data: meeting, error: meetingError } = await supabase
    .from('meetings')
    .insert({
      organization_id: orgId,
      title: input.title,
      description: input.description,
      scheduled_start: input.scheduled_start,
      scheduled_end: input.scheduled_end,
      max_duration_minutes: input.max_duration_minutes || 120,
      timezone: input.timezone || 'America/Chicago',
      recording_enabled: input.recording_enabled ?? true,
      daily_room_name: roomName,
      created_by: user.id,
    })
    .select()
    .single();
  
  if (meetingError) {
    console.error('[createMeeting] Error creating meeting:', meetingError);
    throw meetingError;
  }
  
  // Add participants
  const participantInserts = input.participants.map(p => ({
    meeting_id: meeting.id,
    user_id: p.user_id || null,
    email: p.email || null,
    name: p.name,
    role: p.role || 'participant',
  }));
  
  // Add creator as host if not already in list
  const creatorInList = input.participants.some(
    p => p.user_id === user.id || p.email === user.email
  );
  if (!creatorInList) {
    participantInserts.unshift({
      meeting_id: meeting.id,
      user_id: user.id,
      email: null,
      name: user.user_metadata?.full_name || user.email || 'Host',
      role: 'host',
    });
  }
  
  const { error: participantsError } = await supabase
    .from('meeting_participants')
    .insert(participantInserts);
  
  if (participantsError) {
    console.error('[createMeeting] Error adding participants:', participantsError);
    // Don't throw - meeting was created, participants can be added later
  }
  
  // Trigger edge function to create Daily.co room and send invitations
  if (input.send_invitations !== false) {
    await supabase.functions.invoke('create-meeting-room', {
      body: { meetingId: meeting.id, sendInvitations: true }
    });
  }
  
  return meeting;
}

/**
 * Update a meeting
 */
export async function updateMeeting(
  meetingId: string,
  updates: Partial<CreateMeetingInput>
): Promise<Meeting> {
  const { data, error } = await supabase
    .from('meetings')
    .update({
      title: updates.title,
      description: updates.description,
      scheduled_start: updates.scheduled_start,
      scheduled_end: updates.scheduled_end,
      max_duration_minutes: updates.max_duration_minutes,
      recording_enabled: updates.recording_enabled,
      updated_at: new Date().toISOString(),
    })
    .eq('id', meetingId)
    .select()
    .single();
  
  if (error) {
    console.error('[updateMeeting] Error:', error);
    throw error;
  }
  
  return data;
}

/**
 * Cancel a meeting
 */
export async function cancelMeeting(meetingId: string): Promise<void> {
  const { error } = await supabase
    .from('meetings')
    .update({ 
      status: 'cancelled',
      updated_at: new Date().toISOString()
    })
    .eq('id', meetingId);
  
  if (error) {
    console.error('[cancelMeeting] Error:', error);
    throw error;
  }
  
  // Trigger edge function to delete Daily.co room and notify participants
  await supabase.functions.invoke('cancel-meeting', {
    body: { meetingId }
  });
}

/**
 * Get join token for a meeting
 */
export async function getMeetingJoinToken(
  meetingId: string
): Promise<{ token: string; roomUrl: string }> {
  const { data, error } = await supabase.functions.invoke('get-meeting-token', {
    body: { meetingId }
  });
  
  if (error) {
    console.error('[getMeetingJoinToken] Error:', error);
    throw error;
  }
  
  return data;
}

/**
 * Request transcription for a meeting
 */
export async function requestMeetingTranscription(meetingId: string): Promise<void> {
  const { error } = await supabase
    .from('meetings')
    .update({ transcription_status: 'pending' })
    .eq('id', meetingId);
  
  if (error) throw error;
  
  await supabase.functions.invoke('transcribe-meeting', {
    body: { meetingId }
  });
}

/**
 * Request AI summary for a meeting
 */
export async function requestMeetingAISummary(meetingId: string): Promise<void> {
  const { error } = await supabase
    .from('meetings')
    .update({ ai_summary_status: 'pending' })
    .eq('id', meetingId);
  
  if (error) throw error;
  
  await supabase.functions.invoke('summarize-meeting', {
    body: { meetingId }
  });
}

4.2 Edge Functions

create-meeting-room

// supabase/functions/create-meeting-room/index.ts
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';

const DAILY_API_KEY = Deno.env.get('DAILY_API_KEY')!;
const DAILY_API_URL = 'https://api.daily.co/v1';

serve(async (req) => {
  try {
    const { meetingId, sendInvitations } = await req.json();
    
    // Get Supabase client with service role
    const supabase = createClient(
      Deno.env.get('SUPABASE_URL')!,
      Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
    );
    
    // Fetch meeting details
    const { data: meeting, error: meetingError } = await supabase
      .from('meetings')
      .select('*, participants:meeting_participants(*)')
      .eq('id', meetingId)
      .single();
    
    if (meetingError || !meeting) {
      throw new Error('Meeting not found');
    }
    
    // Calculate room expiration (meeting end + 30 min buffer)
    const roomExp = Math.floor(
      new Date(meeting.scheduled_end).getTime() / 1000
    ) + 1800;
    
    // Create Daily.co room
    const roomResponse = await fetch(`${DAILY_API_URL}/rooms`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${DAILY_API_KEY}`,
      },
      body: JSON.stringify({
        name: meeting.daily_room_name,
        privacy: 'private',
        properties: {
          max_participants: 10,
          enable_recording: meeting.recording_enabled ? 'cloud' : false,
          enable_chat: true,
          enable_screenshare: true,
          enable_knocking: true,  // Waiting room
          exp: roomExp,
          eject_at_room_exp: true,
          autojoin: false,
          start_video_off: false,
          start_audio_off: false,
        },
      }),
    });
    
    if (!roomResponse.ok) {
      const error = await roomResponse.text();
      throw new Error(`Daily.co API error: ${error}`);
    }
    
    const room = await roomResponse.json();
    
    // Update meeting with room URL
    await supabase
      .from('meetings')
      .update({
        daily_room_url: room.url,
        daily_room_id: room.id,
      })
      .eq('id', meetingId);
    
    // Send invitations if requested
    if (sendInvitations && meeting.participants?.length > 0) {
      for (const participant of meeting.participants) {
        const email = participant.email || 
          (participant.user_id ? await getUserEmail(supabase, participant.user_id) : null);
        
        if (email) {
          await sendMeetingInvitation({
            to: email,
            participantName: participant.name,
            meetingTitle: meeting.title,
            meetingDate: meeting.scheduled_start,
            meetingDuration: meeting.max_duration_minutes,
            joinUrl: `${Deno.env.get('APP_URL')}/meeting/${meetingId}`,
            organizationName: await getOrgName(supabase, meeting.organization_id),
          });
          
          // Update invitation status
          await supabase
            .from('meeting_participants')
            .update({ 
              invitation_sent_at: new Date().toISOString(),
              invitation_status: 'sent'
            })
            .eq('id', participant.id);
        }
      }
    }
    
    return new Response(JSON.stringify({ success: true, room }), {
      headers: { 'Content-Type': 'application/json' },
    });
    
  } catch (error) {
    console.error('Error creating meeting room:', error);
    return new Response(JSON.stringify({ error: error.message }), {
      status: 500,
      headers: { 'Content-Type': 'application/json' },
    });
  }
});

async function getUserEmail(supabase: any, userId: string): Promise<string | null> {
  const { data } = await supabase
    .from('users')
    .select('email')
    .eq('id', userId)
    .single();
  return data?.email || null;
}

async function getOrgName(supabase: any, orgId: string): Promise<string> {
  const { data } = await supabase
    .from('organizations')
    .select('name')
    .eq('id', orgId)
    .single();
  return data?.name || 'Alignmint';
}

async function sendMeetingInvitation(params: {
  to: string;
  participantName: string;
  meetingTitle: string;
  meetingDate: string;
  meetingDuration: number;
  joinUrl: string;
  organizationName: string;
}) {
  const RESEND_API_KEY = Deno.env.get('RESEND_API_KEY')!;
  
  const formattedDate = new Date(params.meetingDate).toLocaleString('en-US', {
    weekday: 'long',
    year: 'numeric',
    month: 'long',
    day: 'numeric',
    hour: 'numeric',
    minute: '2-digit',
    timeZoneName: 'short',
  });
  
  await fetch('https://api.resend.com/emails', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${RESEND_API_KEY}`,
    },
    body: JSON.stringify({
      from: 'Alignmint <noreply@alignmint.app>',
      to: params.to,
      subject: `You're invited: ${params.meetingTitle}`,
      html: `
        <div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto;">
          <h2 style="color: #2a2826;">You're Invited to a Video Meeting</h2>
          
          <p>Hi ${params.participantName},</p>
          
          <p>You've been invited to a video meeting:</p>
          
          <div style="background: #f5f3f0; border-radius: 8px; padding: 20px; margin: 20px 0;">
            <p style="margin: 0 0 10px 0;"><strong>📹 ${params.meetingTitle}</strong></p>
            <p style="margin: 0 0 10px 0;">📅 ${formattedDate}</p>
            <p style="margin: 0;">⏱️ ${params.meetingDuration} minutes</p>
          </div>
          
          <a href="${params.joinUrl}" style="display: inline-block; background: #030213; color: white; padding: 12px 24px; border-radius: 6px; text-decoration: none; font-weight: 500;">
            Join Meeting
          </a>
          
          <p style="margin-top: 30px; color: #6e6b68; font-size: 14px;">
            Hosted by ${params.organizationName} via Alignmint
          </p>
        </div>
      `,
    }),
  });
}

4.3 Webhook Handlers

daily-webhook (Handle Daily.co events)

// supabase/functions/daily-webhook/index.ts
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';

serve(async (req) => {
  try {
    const event = await req.json();
    
    const supabase = createClient(
      Deno.env.get('SUPABASE_URL')!,
      Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
    );
    
    console.log('Daily.co webhook event:', event.type);
    
    switch (event.type) {
      case 'meeting.started':
        await handleMeetingStarted(supabase, event);
        break;
        
      case 'meeting.ended':
        await handleMeetingEnded(supabase, event);
        break;
        
      case 'recording.ready-to-download':
        await handleRecordingReady(supabase, event);
        break;
        
      case 'participant.joined':
        await handleParticipantJoined(supabase, event);
        break;
        
      case 'participant.left':
        await handleParticipantLeft(supabase, event);
        break;
    }
    
    return new Response(JSON.stringify({ received: true }), {
      headers: { 'Content-Type': 'application/json' },
    });
    
  } catch (error) {
    console.error('Webhook error:', error);
    return new Response(JSON.stringify({ error: error.message }), {
      status: 500,
      headers: { 'Content-Type': 'application/json' },
    });
  }
});

async function handleMeetingStarted(supabase: any, event: any) {
  const roomName = event.payload.room;
  
  await supabase
    .from('meetings')
    .update({
      status: 'in_progress',
      actual_start: new Date().toISOString(),
      recording_status: 'recording',
    })
    .eq('daily_room_name', roomName);
}

async function handleMeetingEnded(supabase: any, event: any) {
  const roomName = event.payload.room;
  const actualStart = new Date(event.payload.start_ts * 1000);
  const actualEnd = new Date();
  const durationMinutes = Math.round((actualEnd.getTime() - actualStart.getTime()) / 60000);
  
  await supabase
    .from('meetings')
    .update({
      status: 'completed',
      actual_end: actualEnd.toISOString(),
      actual_duration_minutes: durationMinutes,
      recording_status: 'processing',
    })
    .eq('daily_room_name', roomName);
}

async function handleRecordingReady(supabase: any, event: any) {
  const roomName = event.payload.room_name;
  const recordingId = event.payload.recording_id;
  
  // Get download link from Daily.co
  const DAILY_API_KEY = Deno.env.get('DAILY_API_KEY')!;
  const linkResponse = await fetch(
    `https://api.daily.co/v1/recordings/${recordingId}/access-link`,
    {
      headers: { 'Authorization': `Bearer ${DAILY_API_KEY}` },
    }
  );
  
  const { download_link } = await linkResponse.json();
  
  // Get meeting ID
  const { data: meeting } = await supabase
    .from('meetings')
    .select('id, organization_id')
    .eq('daily_room_name', roomName)
    .single();
  
  if (!meeting) return;
  
  // Download recording and upload to Supabase Storage
  const recordingResponse = await fetch(download_link);
  const recordingBlob = await recordingResponse.blob();
  
  const storagePath = `meetings/${meeting.organization_id}/${meeting.id}/recording.mp4`;
  
  const { error: uploadError } = await supabase.storage
    .from('meeting-recordings')
    .upload(storagePath, recordingBlob, {
      contentType: 'video/mp4',
      upsert: true,
    });
  
  if (uploadError) {
    console.error('Error uploading recording:', uploadError);
    await supabase
      .from('meetings')
      .update({ recording_status: 'failed' })
      .eq('id', meeting.id);
    return;
  }
  
  // Get public URL
  const { data: { publicUrl } } = supabase.storage
    .from('meeting-recordings')
    .getPublicUrl(storagePath);
  
  // Update meeting with recording info
  await supabase
    .from('meetings')
    .update({
      recording_status: 'ready',
      recording_url: publicUrl,
      recording_size_bytes: recordingBlob.size,
      daily_recording_id: recordingId,
      transcription_status: 'pending',  // Auto-trigger transcription
    })
    .eq('id', meeting.id);
  
  // Trigger transcription
  await supabase.functions.invoke('transcribe-meeting', {
    body: { meetingId: meeting.id }
  });
}

async function handleParticipantJoined(supabase: any, event: any) {
  const roomName = event.payload.room;
  const participantId = event.payload.participant.user_id;
  
  // Get meeting
  const { data: meeting } = await supabase
    .from('meetings')
    .select('id')
    .eq('daily_room_name', roomName)
    .single();
  
  if (!meeting) return;
  
  // Update participant
  await supabase
    .from('meeting_participants')
    .update({
      joined_at: new Date().toISOString(),
      join_count: supabase.sql`join_count + 1`,
    })
    .eq('meeting_id', meeting.id)
    .or(`user_id.eq.${participantId},email.eq.${event.payload.participant.user_name}`);
}

async function handleParticipantLeft(supabase: any, event: any) {
  const roomName = event.payload.room;
  const participantId = event.payload.participant.user_id;
  
  // Get meeting
  const { data: meeting } = await supabase
    .from('meetings')
    .select('id')
    .eq('daily_room_name', roomName)
    .single();
  
  if (!meeting) return;
  
  // Update participant with leave time and duration
  const { data: participant } = await supabase
    .from('meeting_participants')
    .select('joined_at')
    .eq('meeting_id', meeting.id)
    .or(`user_id.eq.${participantId},email.eq.${event.payload.participant.user_name}`)
    .single();
  
  if (participant?.joined_at) {
    const duration = Math.round(
      (Date.now() - new Date(participant.joined_at).getTime()) / 1000
    );
    
    await supabase
      .from('meeting_participants')
      .update({
        left_at: new Date().toISOString(),
        duration_seconds: duration,
      })
      .eq('meeting_id', meeting.id)
      .or(`user_id.eq.${participantId},email.eq.${event.payload.participant.user_name}`);
  }
}

---

5. Frontend Components

5.1 Component Hierarchy

VideoConferencingManager (Main Dashboard)
├── MeetingCard (Reusable meeting display)
├── MeetingScheduler (Modal for creating meetings)
│   ├── ParticipantSearch
│   └── DateTimePicker
├── MeetingRoom (Video call interface)
│   ├── PreJoinScreen
│   ├── VideoGrid
│   └── CallControls
└── MeetingSummaryView (Post-meeting review)
    ├── AISummaryPanel
    ├── TranscriptViewer
    └── RecordingPlayer

5.2 VideoConferencingManager

Main dashboard component located at src/features/workspace/components/VideoConferencing/VideoConferencingManager.tsx.

Key Features:

  • Displays upcoming and past meetings in tabbed interface
  • Highlights next meeting with quick-join card
  • Pagination for past meetings
  • Status badges for recordings, transcripts, AI summaries

State Management:

const [activeTab, setActiveTab] = useState<'upcoming' | 'past'>('upcoming');
const [upcomingMeetings, setUpcomingMeetings] = useState<Meeting[]>([]);
const [pastMeetings, setPastMeetings] = useState<Meeting[]>([]);
const [showScheduler, setShowScheduler] = useState(false);
const [selectedMeeting, setSelectedMeeting] = useState<Meeting | null>(null);

5.3 MeetingScheduler

Modal component for scheduling new meetings.

Form Fields:

| Field | Type | Required | Default |
|-------|------|----------|---------|
| Title | Text | Yes | - |
| Description | Textarea | No | - |
| Date | Date picker | Yes | Today |
| Start Time | Select (15-min intervals) | Yes | 9:00 AM |
| Duration | Select | Yes | 60 minutes |
| Participants | Search + manual add | Yes (min 1) | - |
| Recording | Toggle | No | Enabled |
| Send Invitations | Toggle | No | Enabled |

Participant Search:

  • Searches both users and donors tables
  • Allows adding external participants by email
  • Shows participant type badges (user/donor/external)

5.4 MeetingRoom

Embedded video call interface using @daily-co/daily-js.

Pre-Join Screen:

  • Camera/mic preview and toggles
  • Meeting details display
  • Join button

In-Call Controls:

  • Camera on/off
  • Microphone on/off
  • Screen share
  • Raise hand
  • Chat panel
  • Participants panel
  • Leave call
  • Fullscreen toggle

View Modes (January 26, 2026):

| Mode | Description | Use Case |
|------|-------------|----------|
| **Gallery** | Equal-sized grid of all participants | Team meetings, discussions |
| **Speaker** | Active speaker/screen share large, others in sidebar | Presentations, lectures |
| **Spotlight** | Pinned participant large, others in sidebar | Interviews, focused discussions |

Camera Mirroring (February 2, 2026):

The local participant's video is mirrored horizontally using CSS transform: scaleX(-1) so that users see themselves as they would in a mirror. This provides an intuitive self-view where raising your left hand shows your left hand on screen.

| Video Type | Mirrored | Reason |
|------------|----------|--------|
| Local participant video | ✅ Yes | Users expect mirror-like self-view |
| Remote participant video | ❌ No | Shows participants as they actually appear |
| Screen shares | ❌ No | Text and content must remain readable |

Implementation:

// VideoTile component applies mirroring only to local video
<video
  className={cn(
    'absolute inset-0 w-full h-full object-cover',
    participant.isLocal && 'scale-x-[-1]', // Mirror local video
    !participant.videoOn && 'hidden'
  )}
/>

View Mode Behavior:

  • Auto-switch: When someone starts screen sharing, automatically switches to Speaker view
  • Pin participant: Host can pin any participant to spotlight them (via dropdown menu)
  • Unpin: Click "Unpin" button in header to return to Gallery view
  • View mode selector in header bar: [Gallery] [Speaker] [Unpin]

Host Controls:

  • Mute participant
  • Turn off participant video
  • Lower raised hand
  • Remove participant from meeting
  • Pin participant to spotlight

Waiting Room:

  • Enabled by default for all meetings (including instant meetings)
  • Host sees "Waiting Room" section in participants panel
  • Admit/deny individual participants or "Admit All"

Dependencies:

{
  "@daily-co/daily-js": "^0.62.0"
}

5.5 MeetingSummaryView

Post-meeting review with three tabs:

AI Summary Tab:

  • Meeting summary paragraph
  • Key points list
  • Action items with assignees and due dates
  • Next steps
  • Topics discussed (badges)
  • Sentiment indicator

Transcript Tab:

  • Full transcript text
  • Copy to clipboard button
  • Scrollable container

Recording Tab:

  • Video player with controls
  • Download button
  • Duration and file size info

---

6. User Interface Design

6.1 Dashboard View

┌─────────────────────────────────────────────────────────────────┐
│  Video Conferencing                          [+ Schedule Meeting]│
│  Schedule and manage video meetings...                          │
├─────────────────────────────────────────────────────────────────┤
│  ┌─────────────────────────────────────────────────────────────┐│
│  │ 📹 Next Meeting                                    [Join Now]││
│  │ Weekly Team Sync                                             ││
│  │ 📅 Jan 24, 2026  🕐 2:00 PM  👥 5 participants  ⏰ in 30 min ││
│  └─────────────────────────────────────────────────────────────┘│
├─────────────────────────────────────────────────────────────────┤
│  [Upcoming (3)]  [Past Meetings (47)]                           │
├─────────────────────────────────────────────────────────────────┤
│  ┌─────────────────────────────────────────────────────────────┐│
│  │ 📹 Board Meeting                                      [Join] ││
│  │    📅 Jan 25  🕐 10:00 AM  👥 8                              ││
│  └─────────────────────────────────────────────────────────────┘│
│  ┌─────────────────────────────────────────────────────────────┐│
│  │ 📹 Donor Check-in: Smith Family                       [Join] ││
│  │    📅 Jan 26  🕐 3:30 PM  👥 2                               ││
│  └─────────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────────┘

6.2 Schedule Meeting Modal

┌─────────────────────────────────────────────────────────────────┐
│  📹 Schedule Video Meeting                                   [X] │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│  Meeting Title *                                                 │
│  ┌─────────────────────────────────────────────────────────────┐│
│  │ Weekly Team Sync                                            ││
│  └─────────────────────────────────────────────────────────────┘│
│                                                                  │
│  Description (optional)                                          │
│  ┌─────────────────────────────────────────────────────────────┐│
│  │ Discuss Q1 goals and project updates...                     ││
│  └─────────────────────────────────────────────────────────────┘│
│                                                                  │
│  Date *                          Start Time *                    │
│  ┌─────────────────────┐        ┌─────────────────────┐         │
│  │ 📅 January 24, 2026 │        │ 🕐 2:00 PM          │         │
│  └─────────────────────┘        └─────────────────────┘         │
│                                                                  │
│  Duration                                                        │
│  ┌─────────────────────┐                                        │
│  │ 1 hour              │  ⚠️ Meetings limited to 2 hours max    │
│  └─────────────────────┘                                        │
│                                                                  │
│  👥 Participants *                                               │
│  ┌─────────────────────────────────────────────────────────────┐│
│  │ 🔍 Search users or donors...                                ││
│  └─────────────────────────────────────────────────────────────┘│
│  [+ Add external participant]                                    │
│                                                                  │
│  [John Smith ×] [Sarah Johnson ×] [mike@example.com ×]          │
│                                                                  │
│  ─────────────────────────────────────────────────────────────  │
│                                                                  │
│  Enable Recording                                         [ON]   │
│  Automatically record the meeting for later review               │
│                                                                  │
│  Send Email Invitations                                   [ON]   │
│  Notify participants via email with join link                    │
│                                                                  │
├─────────────────────────────────────────────────────────────────┤
│                                    [Cancel]  [Schedule Meeting]  │
└─────────────────────────────────────────────────────────────────┘

6.3 Meeting Room Page

┌─────────────────────────────────────────────────────────────────┐
│  Weekly Team Sync                    🕐 45:23  👥 5 participants │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│  ┌───────────────────────────────────────────────────────────┐  │
│  │                                                           │  │
│  │                    ┌─────────┐  ┌─────────┐              │  │
│  │                    │  John   │  │  Sarah  │              │  │
│  │                    │   📹    │  │   📹    │              │  │
│  │                    └─────────┘  └─────────┘              │  │
│  │                                                           │  │
│  │         ┌─────────┐  ┌─────────┐  ┌─────────┐           │  │
│  │         │  Mike   │  │  Lisa   │  │   You   │           │  │
│  │         │   📹    │  │   📹    │  │   📹    │           │  │
│  │         └─────────┘  └─────────┘  └─────────┘           │  │
│  │                                                           │  │
│  └───────────────────────────────────────────────────────────┘  │
│                                                                  │
├─────────────────────────────────────────────────────────────────┤
│              [📹]    [🎤]    [🖥️]    [📞 Leave]                 │
│             Camera   Mic    Share     End Call                   │
└─────────────────────────────────────────────────────────────────┘

6.4 Post-Meeting Summary

┌─────────────────────────────────────────────────────────────────┐
│  ← Back                                                          │
│                                                                  │
│  Weekly Team Sync                                                │
│  📅 January 24, 2026  🕐 2:00 PM  ⏱️ 52 min  👥 5 participants  │
├─────────────────────────────────────────────────────────────────┤
│  [✨ AI Summary ✓]  [📄 Transcript ✓]  [▶️ Recording ✓]         │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│  ┌─────────────────────────┐  ┌─────────────────────────┐       │
│  │ Meeting Summary         │  │ Key Points              │       │
│  │                         │  │                         │       │
│  │ The team discussed Q1   │  │ → Budget approved for   │       │
│  │ goals, budget approval, │  │   new marketing campaign│       │
│  │ and upcoming events...  │  │ → Gala date set for     │       │
│  │                         │  │   March 15th            │       │
│  └─────────────────────────┘  │ → New volunteer program │       │
│                               │   launching in February │       │
│  ┌─────────────────────────┐  └─────────────────────────┘       │
│  │ Action Items            │                                    │
│  │                         │  ┌─────────────────────────┐       │
│  │ ☐ Send budget proposal  │  │ Next Steps              │       │
│  │   → John | Due: Jan 30  │  │                         │       │
│  │                         │  │ 1. Schedule follow-up   │       │
│  │ ☐ Book venue for gala   │  │    with board           │       │
│  │   → Sarah | Due: Feb 5  │  │ 2. Draft volunteer      │       │
│  │                         │  │    recruitment email    │       │
│  │ ☐ Create volunteer app  │  │ 3. Review marketing     │       │
│  │   → Mike | Due: Feb 10  │  │    materials            │       │
│  └─────────────────────────┘  └─────────────────────────┘       │
│                                                                  │
│  Topics: [Budget] [Events] [Volunteers]    Sentiment: 😊 Positive│
└─────────────────────────────────────────────────────────────────┘

---

7. Email Integration

7.1 Meeting Invitation Email

Trigger: When meeting is created with send_invitations: true

Template:

Subject: You're invited: {meeting_title}

Hi {participant_name},

You've been invited to a video meeting:

📹 {meeting_title}
📅 {formatted_date}
⏱️ {duration} minutes

[Join Meeting] → {join_url}

Hosted by {organization_name} via Alignmint

Variables:

| Variable | Source |
|----------|--------|
| `meeting_title` | `meetings.title` |
| `participant_name` | `meeting_participants.name` |
| `formatted_date` | `meetings.scheduled_start` (formatted) |
| `duration` | `meetings.max_duration_minutes` |
| `join_url` | `{APP_URL}/meeting/{meeting_id}` |
| `organization_name` | `organizations.name` |

7.2 Meeting Reminder Email

Trigger: Cron job every 15 minutes, sends reminders for meetings starting in ~55-65 minutes

Template:

Subject: Reminder: {meeting_title} starts in 1 hour

Hi {participant_name},

Your meeting "{meeting_title}" starts in 1 hour.

📅 {formatted_date}

[Join Meeting] → {join_url}

7.3 Post-Meeting Summary Email

Trigger: When AI summary is completed (transcribe-meeting edge function)

Template:

Subject: Meeting Summary: {meeting_title}

Hi {participant_name},

Here's the summary from your recent meeting:

📹 {meeting_title}
📅 {formatted_date}
⏱️ {actual_duration} minutes

## Summary
{ai_summary.summary}

## Action Items
{for each action_item}
- {task} (Assigned to: {assignee}, Due: {due})
{end for}

## Next Steps
{for each next_step}
- {step}
{end for}

[View Full Summary] → {summary_url}
[Watch Recording] → {recording_url}

---

8. AI Integration

8.1 Transcription Pipeline

Recording Ready → Download Audio → Whisper API → Store Transcript
     ↓                  ↓               ↓              ↓
  Webhook         Edge Function    OpenAI API      Database

Edge Function: transcribe-meeting

// supabase/functions/transcribe-meeting/index.ts

async function transcribeMeeting(meetingId: string) {
  // 1. Get meeting with recording URL
  const meeting = await getMeeting(meetingId);
  
  // 2. Download recording
  const audioBuffer = await downloadRecording(meeting.recording_url);
  
  // 3. Send to Whisper API
  const transcription = await openai.audio.transcriptions.create({
    file: audioBuffer,
    model: 'whisper-1',
    response_format: 'verbose_json',  // Includes timestamps
    language: 'en',
  });
  
  // 4. Store transcript
  await supabase
    .from('meetings')
    .update({
      transcript: transcription.text,
      transcript_segments: transcription.segments,  // Word-level timestamps
      transcription_status: 'completed',
    })
    .eq('id', meetingId);
  
  // 5. Auto-trigger AI summary
  await supabase
    .from('meetings')
    .update({ ai_summary_status: 'pending' })
    .eq('id', meetingId);
  
  await supabase.functions.invoke('summarize-meeting', {
    body: { meetingId }
  });
}

Whisper API Response Format:

{
  "text": "Full transcript text...",
  "segments": [
    {
      "id": 0,
      "start": 0.0,
      "end": 4.5,
      "text": "Hello everyone, let's get started.",
      "tokens": [...]
    }
  ]
}

8.2 MINTY AI Summary Generation

Edge Function: summarize-meeting

// supabase/functions/summarize-meeting/index.ts

const SUMMARY_PROMPT = `You are MINTY, an AI assistant for nonprofit organizations.
Analyze this meeting transcript and provide a structured summary.

Return a JSON object with:
- summary: A 2-3 paragraph overview of the meeting
- key_points: Array of 3-7 main discussion points
- action_items: Array of tasks with {task, assignee (if mentioned), due (if mentioned)}
- next_steps: Array of 2-5 recommended follow-up actions
- sentiment: "positive", "neutral", or "negative" overall tone
- topics_discussed: Array of main topics/themes

Meeting Transcript:
{transcript}`;

async function summarizeMeeting(meetingId: string) {
  // 1. Get meeting with transcript
  const meeting = await getMeeting(meetingId);
  
  if (!meeting.transcript) {
    throw new Error('Transcript required for summary');
  }
  
  // 2. Call GPT-4
  const completion = await openai.chat.completions.create({
    model: 'gpt-4-turbo-preview',
    messages: [
      {
        role: 'system',
        content: 'You are MINTY, an AI assistant specializing in nonprofit operations.'
      },
      {
        role: 'user',
        content: SUMMARY_PROMPT.replace('{transcript}', meeting.transcript)
      }
    ],
    response_format: { type: 'json_object' },
    temperature: 0.3,  // Lower for more consistent output
  });
  
  const summary = JSON.parse(completion.choices[0].message.content);
  
  // 3. Store summary
  await supabase
    .from('meetings')
    .update({
      ai_summary: summary,
      ai_summary_status: 'completed',
    })
    .eq('id', meetingId);
  
  // 4. Send summary email to participants
  await sendSummaryEmails(meeting, summary);
}

8.3 Action Item Extraction

MINTY AI extracts action items with intelligent parsing:

Input (from transcript): > "John, can you send the budget proposal by next Friday? And Sarah, please book the venue before February 5th."

Output:

{
  "action_items": [
    {
      "task": "Send budget proposal",
      "assignee": "John",
      "due": "2026-01-31"
    },
    {
      "task": "Book venue for gala",
      "assignee": "Sarah", 
      "due": "2026-02-05"
    }
  ]
}

Extraction Rules:

  • Identifies imperative statements ("please do X", "can you X")
  • Extracts names mentioned before or after tasks
  • Parses relative dates ("next Friday", "by end of month")
  • Handles vague assignments ("someone should", "we need to")

---

9. Storage Strategy

9.1 Recording Storage

Storage Location: Supabase Storage bucket meeting-recordings

Path Structure:

meeting-recordings/
├── {organization_id}/
│   ├── {meeting_id}/
│   │   ├── recording.mp4
│   │   └── thumbnail.jpg (optional)

Bucket Configuration:

-- Create storage bucket
INSERT INTO storage.buckets (id, name, public)
VALUES ('meeting-recordings', 'meeting-recordings', false);

-- RLS policy: Users can access recordings from their org
CREATE POLICY "Users can access org recordings"
ON storage.objects FOR SELECT
USING (
  bucket_id = 'meeting-recordings' AND
  (storage.foldername(name))[1] IN (
    SELECT organization_id::text FROM organization_users 
    WHERE user_id = auth.uid()
  )
);

9.2 Compression

Daily.co Recording Format:

  • Default: MP4 (H.264 video, AAC audio)
  • Resolution: 1280x720 (720p)
  • Bitrate: ~2.5 Mbps

Estimated File Sizes:

| Duration | Raw Size | Compressed |
|----------|----------|------------|
| 30 min | ~560 MB | ~280 MB |
| 60 min | ~1.1 GB | ~550 MB |
| 120 min | ~2.2 GB | ~1.1 GB |

Future Optimization:

  • Implement FFmpeg compression in edge function
  • Target 50% size reduction
  • Audio-only option for transcription-only meetings

9.3 Retention Policy

| Tier | Recording Retention | Transcript Retention |
|------|---------------------|---------------------|
| Free | 30 days | 90 days |
| Paid ($899/mo) | 1 year | Unlimited |
| Enterprise | Unlimited | Unlimited |

Automated Cleanup:

-- Cron job to delete expired recordings
CREATE OR REPLACE FUNCTION cleanup_expired_recordings()
RETURNS void AS $$
BEGIN
  -- Delete recordings older than retention period
  DELETE FROM storage.objects
  WHERE bucket_id = 'meeting-recordings'
  AND created_at < NOW() - INTERVAL '30 days'
  AND name IN (
    SELECT 'meeting-recordings/' || organization_id || '/' || id || '/recording.mp4'
    FROM meetings m
    JOIN organizations o ON o.id = m.organization_id
    WHERE o.subscription_tier = 'free'
  );
END;
$$ LANGUAGE plpgsql;

9.4 Storage Costs

Supabase Storage Pricing:

  • Included: 1 GB (Free), 100 GB (Pro)
  • Additional: $0.021/GB/month

Projected Storage Usage (1,000 users):

| Scenario | Monthly Recordings | Avg Size | Total Storage | Monthly Cost |
|----------|-------------------|----------|---------------|--------------|
| Light | 500 meetings | 300 MB | 150 GB | $1.05 |
| Medium | 2,000 meetings | 400 MB | 800 GB | $14.70 |
| Heavy | 5,000 meetings | 500 MB | 2.5 TB | $52.50 |

---

10. Security & Access Control

10.1 Meeting Tokens

Daily.co uses JWT tokens for participant authentication.

Token Structure:

{
  "r": "alignmint-meeting-abc123",  // Room name
  "d": "alignmint.daily.co",         // Domain
  "o": true,                          // Is owner (host)
  "u": "user-uuid",                   // User ID
  "ud": "John Smith",                 // Display name
  "iat": 1706140800,                  // Issued at
  "exp": 1706148000                   // Expires (2 hours after meeting end)
}

Token Generation:

async function generateMeetingToken(
  meetingId: string,
  userId: string,
  isHost: boolean
): Promise<string> {
  const meeting = await getMeeting(meetingId);
  const user = await getUser(userId);
  
  // Token expires 30 min after scheduled end
  const exp = Math.floor(
    new Date(meeting.scheduled_end).getTime() / 1000
  ) + 1800;
  
  const response = await fetch('https://api.daily.co/v1/meeting-tokens', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${DAILY_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      properties: {
        room_name: meeting.daily_room_name,
        user_name: `${user.first_name} ${user.last_name}`,
        user_id: userId,
        is_owner: isHost,
        enable_recording: isHost ? 'cloud' : false,
        exp: exp,
      },
    }),
  });
  
  const { token } = await response.json();
  return token;
}

10.2 Join Link Expiration & Time Limits

Join Link Format:

https://app.alignmint.com/meeting/{meeting_id}

Join Window (Updated January 26, 2026):

| Time | Event |
|------|-------|
| `scheduled_start - 15 min` | Join window opens (early join allowed) |
| `scheduled_start` | Meeting officially starts |
| `scheduled_end` | Meeting officially ends |
| `scheduled_end + 30 min` | Join window closes, room expires, all ejected |

Room Expiration:

  • Daily.co room is configured with exp = scheduled_end + 30 minutes
  • eject_at_room_exp: true - All participants are automatically ejected when room expires
  • This gives participants a 30-minute buffer after the scheduled end time

Token Expiration:

  • Meeting tokens expire at scheduled_end + 30 minutes
  • Tokens cannot be generated outside the join window

Validation Rules: 1. Meeting must exist and not be deleted 2. User must be authenticated 3. User must be a participant OR in the same organization 4. Current time must be within join window:

  • Start: 15 minutes before scheduled_start
  • End: 30 minutes after scheduled_end

Validation Flow:

async function validateMeetingAccess(
  meetingId: string,
  userId: string
): Promise<{ allowed: boolean; reason?: string }> {
  const meeting = await getMeeting(meetingId);
  
  if (!meeting) {
    return { allowed: false, reason: 'Meeting not found' };
  }
  
  if (meeting.status === 'cancelled') {
    return { allowed: false, reason: 'Meeting was cancelled' };
  }
  
  if (meeting.status === 'completed') {
    return { allowed: false, reason: 'Meeting has ended' };
  }
  
  const now = new Date();
  const joinableFrom = new Date(meeting.scheduled_start);
  joinableFrom.setMinutes(joinableFrom.getMinutes() - 15);
  
  if (now < joinableFrom) {
    return { 
      allowed: false, 
      reason: `Meeting opens at ${format(joinableFrom, 'h:mm a')}` 
    };
  }
  
  // Check if user is participant or org member
  const isParticipant = meeting.participants?.some(
    p => p.user_id === userId
  );
  
  const isOrgMember = await checkOrgMembership(userId, meeting.organization_id);
  
  if (!isParticipant && !isOrgMember) {
    return { allowed: false, reason: 'Not authorized to join this meeting' };
  }
  
  return { allowed: true };
}

10.3 Authentication Flow

┌─────────────────────────────────────────────────────────────────┐
│                     MEETING JOIN FLOW                            │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│  1. User clicks join link                                        │
│     └─→ /meeting/{meeting_id}                                   │
│                                                                  │
│  2. Check Supabase auth                                          │
│     ├─→ Authenticated: Continue                                  │
│     └─→ Not authenticated: Redirect to login with return URL    │
│                                                                  │
│  3. Validate meeting access                                      │
│     ├─→ Allowed: Continue                                        │
│     └─→ Not allowed: Show error message                         │
│                                                                  │
│  4. Request Daily.co token (Edge Function)                       │
│     └─→ get-meeting-token                                       │
│                                                                  │
│  5. Store token in participant record                            │
│     └─→ meeting_participants.daily_token                        │
│                                                                  │
│  6. Initialize Daily.co SDK with token                           │
│     └─→ daily.join({ url, token })                              │
│                                                                  │
│  7. User enters pre-join screen                                  │
│     └─→ Camera/mic preview                                      │
│                                                                  │
│  8. User clicks "Join Meeting"                                   │
│     └─→ Connected to video room                                 │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

External Participant Flow: For participants without Alignmint accounts (invited by email):

1. Click join link in email 2. Redirected to guest join page 3. Enter name (pre-filled from invitation) 4. Verify email via one-time code 5. Generate guest token with limited permissions 6. Join meeting

---

11. Cost Analysis

11.1 Daily.co Costs

Pricing: $0.004 per participant-minute

Cost Formula:

Monthly Cost = Meetings × Avg Participants × Avg Duration (min) × $0.004

Scenarios:

| Scale | Meetings/mo | Avg Participants | Avg Duration | Participant-Minutes | Cost |
|-------|-------------|------------------|--------------|---------------------|------|
| Startup | 50 | 3 | 45 min | 6,750 | $27 |
| Growing | 200 | 4 | 50 min | 40,000 | $160 |
| Established | 500 | 4 | 55 min | 110,000 | $440 |
| Scale | 2,000 | 5 | 60 min | 600,000 | $2,400 |

11.2 Transcription Costs

OpenAI Whisper API: $0.006 per minute of audio

Cost Formula:

Monthly Cost = Total Recording Minutes × $0.006

Scenarios:

| Scale | Recordings/mo | Avg Duration | Total Minutes | Cost |
|-------|---------------|--------------|---------------|------|
| Startup | 40 | 45 min | 1,800 | $10.80 |
| Growing | 150 | 50 min | 7,500 | $45.00 |
| Established | 400 | 55 min | 22,000 | $132.00 |
| Scale | 1,500 | 60 min | 90,000 | $540.00 |

11.3 AI Summary Costs

OpenAI GPT-4 Turbo: ~$0.03 per 1K tokens (input + output)

Average tokens per meeting:

  • Input (transcript): ~8,000 tokens (1 hour meeting)
  • Output (summary): ~1,000 tokens
  • Total: ~9,000 tokens = ~$0.27 per meeting

Scenarios:

| Scale | Summaries/mo | Cost per Summary | Total Cost |
|-------|--------------|------------------|------------|
| Startup | 40 | $0.27 | $10.80 |
| Growing | 150 | $0.27 | $40.50 |
| Established | 400 | $0.27 | $108.00 |
| Scale | 1,500 | $0.27 | $405.00 |

11.4 Storage Costs

Supabase Storage: $0.021/GB/month (beyond included)

Assumptions:

  • Average recording: 400 MB
  • 30-day retention for free tier
  • 1-year retention for paid tier

Scenarios:

| Scale | Active Storage | Monthly Cost |
|-------|----------------|--------------|
| Startup | 20 GB | $0.42 |
| Growing | 100 GB | $2.10 |
| Established | 500 GB | $10.50 |
| Scale | 2 TB | $42.00 |

11.5 Total Cost Projections

1,000 Users @ 5 hours/week each:

| Component | Calculation | Monthly Cost |
|-----------|-------------|--------------|
| **Daily.co** | 1,000 users × 5 hr × 4 weeks × 2 avg participants × 60 min × $0.004 | $4,800 |
| **Transcription** | 1,000 users × 5 hr × 4 weeks × 60 min × $0.006 | $1,200 |
| **AI Summaries** | 1,000 users × 5 meetings/week × 4 weeks × $0.27 | $5,400 |
| **Storage** | ~2 TB active storage | $42 |
| **Total** | | **$11,442** |

Revenue Context:

  • 1,000 users at $899/month = $899,000/month revenue
  • Video feature cost: 1.3% of revenue

Break-Even Analysis:

| Users | Monthly Cost | Revenue Needed | Break-Even Price |
|-------|--------------|----------------|------------------|
| 100 | $1,144 | $1,144 | $11.44/user |
| 500 | $5,721 | $5,721 | $11.44/user |
| 1,000 | $11,442 | $11,442 | $11.44/user |

Recommendation: Video conferencing is highly cost-effective as a bundled feature. At $899/month per client, the video feature represents only 1.3% of revenue even with heavy usage.

---

12. Implementation Roadmap

12.1 Phase 1: Core Infrastructure (Week 1-2)

Database:

  • [ ] Create meetings table with all columns
  • [ ] Create meeting_participants table
  • [ ] Set up RLS policies
  • [ ] Create indexes for common queries
  • [ ] Add migration file

Edge Functions:

  • [ ] create-meeting-room - Create Daily.co room
  • [ ] get-meeting-token - Generate participant tokens
  • [ ] cancel-meeting - Delete room and notify

Environment Setup:

  • [ ] Create Daily.co account and get API key
  • [ ] Add DAILY_API_KEY to Supabase secrets
  • [ ] Configure Daily.co webhook URL

12.2 Phase 2: Video Integration (Week 3-4)

Frontend Components:

  • [ ] VideoConferencingManager - Main dashboard
  • [ ] MeetingCard - Reusable meeting display
  • [ ] MeetingScheduler - Create meeting modal
  • [ ] MeetingRoom - Video call interface
  • [ ] Pre-join screen with camera/mic preview

Daily.co Integration:

  • [ ] Install @daily-co/daily-react
  • [ ] Implement join/leave flow
  • [ ] Add camera/mic/screenshare controls
  • [ ] Handle participant events

Navigation:

  • [ ] Add Video Conferencing to People Hub
  • [ ] Create /meeting/:id route
  • [ ] Add meeting links to sidebar (if applicable)

12.3 Phase 3: Recording & AI (Week 5-6)

Recording Pipeline:

  • [ ] Configure Daily.co cloud recording
  • [ ] daily-webhook - Handle recording.ready event
  • [ ] Download and store recordings in Supabase Storage
  • [ ] Create storage bucket with RLS

Transcription:

  • [ ] transcribe-meeting edge function
  • [ ] Integrate OpenAI Whisper API
  • [ ] Store transcript with timestamps
  • [ ] Add transcript viewer UI

AI Summary:

  • [ ] summarize-meeting edge function
  • [ ] Design MINTY prompt for summaries
  • [ ] Extract action items and next steps
  • [ ] Build summary display UI

12.4 Phase 4: Polish & Launch (Week 7-8)

Email Integration:

  • [ ] Meeting invitation template
  • [ ] Meeting reminder (1 hour before)
  • [ ] Post-meeting summary email
  • [ ] Set up reminder cron job

UI Polish:

  • [ ] Loading states and skeletons
  • [ ] Error handling and messages
  • [ ] Empty states
  • [ ] Mobile responsiveness
  • [ ] Dark mode styling

Testing:

  • [ ] Unit tests for edge functions
  • [ ] Integration tests for meeting flow
  • [ ] Manual testing across browsers
  • [ ] Load testing with multiple participants

Documentation:

  • [ ] User guide for video conferencing
  • [ ] Admin documentation
  • [ ] API documentation for edge functions

---

13. Future Considerations

13.1 Migration to Self-Hosted

When to Consider:

  • 10,000+ users
  • $50,000+/month in Daily.co costs
  • Need for custom video processing

Self-Hosted Options:

| Solution | Complexity | Cost Savings | Features |
|----------|------------|--------------|----------|
| **LiveKit** | Medium | 70-80% | Full-featured, Kubernetes |
| **Jitsi Meet** | High | 90%+ | Open source, self-managed |
| **MediaSoup** | Very High | 95%+ | Low-level, maximum control |

Migration Path: 1. Abstract video provider interface 2. Build LiveKit integration alongside Daily.co 3. A/B test with subset of users 4. Gradual migration with feature flags 5. Full cutover when stable

13.2 Additional Features

Near-Term (3-6 months):

  • [ ] Calendar integration (Google Calendar, Outlook)
  • [ ] Recurring meetings
  • [ ] Meeting templates
  • [ ] Waiting room / lobby
  • [ ] Virtual backgrounds

Medium-Term (6-12 months):

  • [ ] Breakout rooms
  • [ ] Live captions (real-time transcription)
  • [ ] Meeting polls and Q&A
  • [ ] Whiteboard integration
  • [ ] Meeting analytics dashboard

Long-Term (12+ months):

  • [ ] Webinar mode (large audiences)
  • [ ] Live streaming to YouTube/Facebook
  • [ ] AI meeting assistant (real-time suggestions)
  • [ ] Automated meeting scheduling (AI)
  • [ ] Integration with donor management workflows

---

14. In-Meeting Features & Controls

14.1 Participant Admission (Waiting Room / Knocking)

How It Works: Daily.co supports a "knocking" feature which acts as a waiting room. When enabled, participants must request to join and the host admits them.

Configuration:

// Room creation with knocking enabled
{
  "properties": {
    "enable_knocking": true,  // Waiting room enabled
    "enable_prejoin_ui": true // Show pre-join screen
  }
}

Admission Flow:

┌─────────────────────────────────────────────────────────────────┐
│                    PARTICIPANT ADMISSION FLOW                    │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│  1. Participant clicks join link                                 │
│     └─→ /meeting/{meeting_id}?token={invite_token}              │
│                                                                  │
│  2. Pre-join screen appears                                      │
│     ├─→ Enter display name (pre-filled if known)                │
│     ├─→ Camera/mic preview and toggles                          │
│     └─→ Click "Request to Join"                                 │
│                                                                  │
│  3. Participant enters waiting room                              │
│     └─→ "Waiting for host to admit you..."                      │
│                                                                  │
│  4. Host sees notification                                       │
│     └─→ "{Name} is waiting to join"                             │
│                                                                  │
│  5. Host admits or denies                                        │
│     ├─→ Admit: Participant joins call                           │
│     └─→ Deny: Participant sees "Request denied" message         │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

Auto-Admit Options:

| Setting | Behavior |
|---------|----------|
| `enable_knocking: false` | Anyone with valid token joins immediately |
| `enable_knocking: true` | All participants wait for host admission |
| Token with `is_owner: true` | Bypasses waiting room (hosts) |

Recommendation: Enable knocking by default for security. Hosts and co-hosts bypass the waiting room automatically.

14.2 Host Controls

Available Controls for Hosts:

| Control | API Method | Description |
|---------|------------|-------------|
| **Mute participant** | `daily.updateParticipant(id, { setAudio: false })` | Force mute a participant's mic |
| **Turn off video** | `daily.updateParticipant(id, { setVideo: false })` | Force disable a participant's camera |
| **Remove/Kick** | `daily.updateParticipant(id, { eject: true })` | Remove participant from call |
| **Mute all** | Loop through participants | Mute everyone except host |
| **Lock meeting** | Update room config | Prevent new participants |

Host Controls UI:

// Host controls component
function ParticipantControls({ participantId, isHost }: Props) {
  const daily = useDaily();
  
  if (!isHost) return null;
  
  const muteParticipant = () => {
    daily?.updateParticipant(participantId, { setAudio: false });
  };
  
  const turnOffVideo = () => {
    daily?.updateParticipant(participantId, { setVideo: false });
  };
  
  const kickParticipant = () => {
    daily?.updateParticipant(participantId, { eject: true });
  };
  
  return (
    <DropdownMenu>
      <DropdownMenuTrigger>
        <MoreVertical className="h-4 w-4" />
      </DropdownMenuTrigger>
      <DropdownMenuContent>
        <DropdownMenuItem onClick={muteParticipant}>
          <MicOff className="h-4 w-4 mr-2" />
          Mute
        </DropdownMenuItem>
        <DropdownMenuItem onClick={turnOffVideo}>
          <VideoOff className="h-4 w-4 mr-2" />
          Turn off video
        </DropdownMenuItem>
        <DropdownMenuSeparator />
        <DropdownMenuItem onClick={kickParticipant} className="text-destructive">
          <UserMinus className="h-4 w-4 mr-2" />
          Remove from call
        </DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  );
}

Who Can Be a Host:

  • Meeting creator (automatic)
  • Users with role: 'host' or role: 'co_host' in meeting_participants
  • Token generated with is_owner: true

14.3 Pre-Join Name Entry

Flow for Known Users (Alignmint accounts): 1. User clicks join link 2. System fetches user profile from Supabase 3. Name pre-filled: {first_name} {last_name} 4. User can edit name if desired 5. Camera/mic preview shown 6. Click "Join Meeting"

Flow for External Guests: 1. Guest clicks join link from email invitation 2. Pre-join screen shows with name from invitation pre-filled 3. Guest can edit their display name 4. Email verification (optional, configurable) 5. Camera/mic preview shown 6. Click "Request to Join" (enters waiting room)

Pre-Join Screen Component:

function PreJoinScreen({ meeting, onJoin }: Props) {
  const { user } = useAuth();
  const [displayName, setDisplayName] = useState(
    user ? `${user.first_name} ${user.last_name}` : ''
  );
  const [cameraOn, setCameraOn] = useState(true);
  const [micOn, setMicOn] = useState(true);
  
  return (
    <Card className="max-w-lg">
      <CardContent className="p-8">
        <h2>{meeting.title}</h2>
        
        {/* Camera preview */}
        <div className="aspect-video bg-muted rounded-lg mb-4">
          <VideoPreview enabled={cameraOn} />
        </div>
        
        {/* Name input */}
        <div className="mb-4">
          <Label>Your name</Label>
          <Input 
            value={displayName}
            onChange={(e) => setDisplayName(e.target.value)}
            placeholder="Enter your name"
          />
        </div>
        
        {/* Device toggles */}
        <div className="flex gap-4 mb-6">
          <Button variant={cameraOn ? 'outline' : 'destructive'} onClick={() => setCameraOn(!cameraOn)}>
            {cameraOn ? <Video /> : <VideoOff />}
          </Button>
          <Button variant={micOn ? 'outline' : 'destructive'} onClick={() => setMicOn(!micOn)}>
            {micOn ? <Mic /> : <MicOff />}
          </Button>
        </div>
        
        <Button onClick={() => onJoin({ displayName, cameraOn, micOn })} className="w-full">
          {meeting.enable_knocking ? 'Request to Join' : 'Join Meeting'}
        </Button>
      </CardContent>
    </Card>
  );
}

14.4 Screen Sharing

Do We Need It? YES - Essential for nonprofit meetings:

  • Presenting financial reports
  • Sharing donor data dashboards
  • Reviewing documents together
  • Training sessions

Implementation:

// Screen share controls
const startScreenShare = async () => {
  await daily?.startScreenShare();
};

const stopScreenShare = async () => {
  await daily?.stopScreenShare();
};

// Listen for screen share events
daily?.on('participant-updated', (event) => {
  if (event.participant.screen) {
    // Someone started sharing
    setScreenShareActive(true);
    setScreenShareParticipant(event.participant);
  }
});

Screen Share Permissions:

| Role | Can Share Screen |
|------|------------------|
| Host | ✅ Always |
| Co-Host | ✅ Always |
| Participant | ✅ By default (configurable) |

Room Configuration:

{
  "properties": {
    "enable_screenshare": true,  // Enable for all
    // OR restrict to owners only:
    "owner_only_broadcast": true
  }
}

14.5 Companion Mode

What Is It? Companion mode allows a user to join a meeting from a second device (e.g., phone for camera while laptop for screen share) without being counted as a separate participant.

Do We Need It? NOT FOR MVP - Nice to have for power users, but adds complexity.

Future Implementation:

// Join in companion mode
daily?.join({
  url: roomUrl,
  token: token,
  companionMode: true  // Links to existing session
});

Recommendation: Defer to Phase 2 or later. Most fund users won't need this initially.

14.6 In-Meeting Chat

Do We Need It? YES - Essential for:

  • Sharing links during calls
  • Asking questions without interrupting
  • Accessibility (hearing impaired participants)
  • Sharing notes

Daily.co Chat Implementation:

// Send a chat message
daily?.sendAppMessage({ message: 'Hello everyone!' }, '*');

// Receive chat messages
daily?.on('app-message', (event) => {
  const { data, fromId } = event;
  addChatMessage({
    from: participants[fromId]?.user_name,
    message: data.message,
    timestamp: new Date()
  });
});

Chat UI Component:

function MeetingChat() {
  const [messages, setMessages] = useState<ChatMessage[]>([]);
  const [newMessage, setNewMessage] = useState('');
  const daily = useDaily();
  
  const sendMessage = () => {
    if (!newMessage.trim()) return;
    daily?.sendAppMessage({ message: newMessage }, '*');
    setNewMessage('');
  };
  
  return (
    <div className="w-80 border-l flex flex-col">
      <div className="p-3 border-b font-medium">Chat</div>
      <ScrollArea className="flex-1 p-3">
        {messages.map((msg, i) => (
          <div key={i} className="mb-2">
            <span className="font-medium">{msg.from}:</span>
            <span className="ml-2">{msg.message}</span>
          </div>
        ))}
      </ScrollArea>
      <div className="p-3 border-t flex gap-2">
        <Input 
          value={newMessage}
          onChange={(e) => setNewMessage(e.target.value)}
          onKeyDown={(e) => e.key === 'Enter' && sendMessage()}
          placeholder="Type a message..."
        />
        <Button size="icon" onClick={sendMessage}>
          <Send className="h-4 w-4" />
        </Button>
      </div>
    </div>
  );
}

14.7 Raise Hand

Do We Need It? YES - Important for:

  • Larger meetings (5+ people)
  • Board meetings
  • Q&A sessions
  • Orderly discussions

Implementation:

// Raise/lower hand using custom participant data
const raiseHand = () => {
  daily?.setUserData({ handRaised: true, handRaisedAt: Date.now() });
};

const lowerHand = () => {
  daily?.setUserData({ handRaised: false });
};

// Host can lower someone's hand
const lowerParticipantHand = (participantId: string) => {
  // Send app message to that participant
  daily?.sendAppMessage({ action: 'lower-hand' }, participantId);
};

UI Indicator:

  • Hand icon appears on participant's video tile
  • Host sees list of raised hands in order
  • Notification sound when hand is raised

---

15. Meeting Links & Security

15.1 Link Structure

Meeting URL Format:

https://app.alignmint.com/meeting/{meeting_id}

With Invitation Token (for external guests):

https://app.alignmint.com/meeting/{meeting_id}?token={invite_token}

Components:

| Part | Description | Example |
|------|-------------|---------|
| `meeting_id` | UUID from `meetings` table | `a1b2c3d4-e5f6-7890-abcd-ef1234567890` |
| `invite_token` | Short-lived JWT for guest access | `eyJhbGciOiJIUzI1NiIs...` |

Why UUIDs Prevent Duplicates:

  • UUIDs are globally unique (collision probability: 1 in 2^122)
  • Generated by gen_random_uuid() in PostgreSQL
  • No sequential patterns to guess

Custom Shareable Domains (Future):

https://meet.{org-subdomain}.alignmint.com/{meeting_slug}

Example: https://meet.infocus.alignmint.com/board-meeting-jan

Implementation for Custom Domains: 1. Add meeting_slug column to meetings table 2. Configure Vercel for wildcard subdomains 3. Route meet.*.alignmint.com to meeting pages 4. Lookup meeting by org subdomain + slug

15.2 Link Expiration

When Links Expire:

| Link Type | Expiration |
|-----------|------------|
| Meeting page URL | Never (but access is time-gated) |
| Daily.co room | 30 minutes after `scheduled_end` |
| Participant token | 30 minutes after `scheduled_end` |
| Invitation token | 24 hours after meeting ends |

Access Window:

                    ← Can Join →
─────────────────────────────────────────────────────
     │               │                    │
  -15 min      scheduled_start      scheduled_end + 30min
     │               │                    │
   Opens          Meeting              Closes
                  Starts

Supabase Handling:

// Edge function: validate-meeting-access
async function validateAccess(meetingId: string, userId: string | null, inviteToken: string | null) {
  const meeting = await getMeeting(meetingId);
  
  // Check if meeting exists
  if (!meeting) {
    return { allowed: false, reason: 'Meeting not found' };
  }
  
  // Check time window
  const now = new Date();
  const opensAt = new Date(meeting.scheduled_start);
  opensAt.setMinutes(opensAt.getMinutes() - 15);
  
  const closesAt = new Date(meeting.scheduled_end);
  closesAt.setMinutes(closesAt.getMinutes() + 30);
  
  if (now < opensAt) {
    return { 
      allowed: false, 
      reason: `Meeting opens at ${format(opensAt, 'h:mm a')}`,
      opensAt: opensAt.toISOString()
    };
  }
  
  if (now > closesAt) {
    return { allowed: false, reason: 'Meeting has ended' };
  }
  
  // Validate user or invite token
  if (userId) {
    // Check if user is participant or org member
    const isAuthorized = await checkUserAuthorization(userId, meeting);
    if (!isAuthorized) {
      return { allowed: false, reason: 'Not authorized' };
    }
  } else if (inviteToken) {
    // Validate invite token
    const tokenValid = await validateInviteToken(inviteToken, meetingId);
    if (!tokenValid) {
      return { allowed: false, reason: 'Invalid or expired invitation' };
    }
  } else {
    return { allowed: false, reason: 'Authentication required' };
  }
  
  return { allowed: true };
}

Daily.co Room Expiration:

// Set room to expire 30 min after meeting end
const roomExp = Math.floor(
  new Date(meeting.scheduled_end).getTime() / 1000
) + 1800; // +30 minutes

await createDailyRoom({
  name: roomName,
  properties: {
    exp: roomExp,
    eject_at_room_exp: true  // Kick everyone when room expires
  }
});

15.3 Security Measures

Multi-Layer Security:

| Layer | Protection |
|-------|------------|
| **Supabase RLS** | Only org members can see meeting data |
| **Meeting tokens** | JWT with expiration, user binding |
| **Daily.co private rooms** | Requires valid token to join |
| **Waiting room** | Host must admit participants |
| **Link expiration** | Time-limited access |

Token Security:

// Invite token structure (signed JWT)
{
  "meeting_id": "uuid",
  "participant_id": "uuid",
  "email": "guest@example.com",
  "name": "Guest Name",
  "exp": 1706227200,  // Expires
  "iat": 1706140800   // Issued at
}

Preventing Unauthorized Access: 1. No public rooms - All rooms are privacy: 'private' 2. Token required - Can't join without valid Daily.co token 3. User binding - Tokens tied to specific user/email 4. Single use - Tokens invalidated after meeting ends 5. Rate limiting - Prevent brute force token guessing

---

16. Alignmint AI Transcription Bot

16.1 How AI Joins for Transcription

Two Approaches:

| Approach | Description | Pros | Cons |
|----------|-------------|------|------|
| **Cloud Recording + Post-Processing** | Record via Daily.co, transcribe after | Simple, no bot needed | Delay before transcript ready |
| **Live AI Bot** | Bot joins call, transcribes in real-time | Live captions possible | More complex, costs more |

Recommended: Cloud Recording + Post-Processing

For MVP, we use Daily.co's cloud recording feature. No AI "bot" actually joins the call. Instead:

1. Meeting is recorded via Daily.co cloud recording 2. When meeting ends, webhook triggers 3. Recording is downloaded 4. Audio sent to Whisper API for transcription 5. Transcript stored and AI summary generated

This means:

  • ✅ No visible "AI bot" in the call
  • ✅ Simpler implementation
  • ✅ Lower real-time costs
  • ✅ Users see recording indicator, not a bot participant
  • ❌ No live captions (future feature)

16.2 User Control Over Recording/Transcription

Recording is Opt-In at Meeting Creation:

// Meeting creation form
<div className="flex items-center justify-between">
  <div>
    <Label>Enable Recording & AI Summary</Label>
    <p className="text-sm text-muted-foreground">
      Record the meeting for transcription and AI-generated summary
    </p>
  </div>
  <Switch
    checked={recordingEnabled}
    onCheckedChange={setRecordingEnabled}
    defaultChecked={true}  // Default ON
  />
</div>

Database Column:

recording_enabled BOOLEAN NOT NULL DEFAULT true

What Happens When Disabled:

  • Daily.co room created with enable_recording: false
  • No recording saved
  • No transcription generated
  • No AI summary available
  • Meeting still functions normally

Can Users Turn It Off Mid-Call?

  • Host only can stop recording during call
  • Uses Daily.co API: daily.stopRecording()
  • Partial recording is still processed

16.3 Can AI Be "Kicked Out"?

Since we're using cloud recording (not a bot participant), there's no AI entity to kick. However:

Host Can Stop Recording:

// Stop recording button (host only)
const stopRecording = async () => {
  await daily?.stopRecording();
  toast.info('Recording stopped');
};

Recording Indicator:

  • All participants see "Recording" indicator
  • Required for legal compliance in many jurisdictions
  • Cannot be hidden

16.4 Default Settings

| Setting | Default | User Can Change |
|---------|---------|-----------------|
| Recording enabled | ✅ ON | Yes, at meeting creation |
| AI transcription | ✅ ON (if recorded) | Yes, can skip transcription |
| AI summary | ✅ ON (if transcribed) | Yes, can skip summary |
| Recording indicator | Always shown | No (legal requirement) |

---

17. Mobile Support

17.1 Does It Work on Mobile?

YES - Daily.co's React SDK works on mobile browsers.

Supported Platforms:

| Platform | Browser | Support |
|----------|---------|---------|
| iOS | Safari | ✅ Full support |
| iOS | Chrome | ✅ Full support |
| Android | Chrome | ✅ Full support |
| Android | Firefox | ✅ Full support |

Mobile-Specific Considerations:

1. Responsive UI:

  • Video grid adapts to portrait/landscape
  • Controls sized for touch
  • Full-screen mode available

2. Camera Switching:

  • Front/back camera toggle
  • daily.setCamera({ facingMode: 'user' | 'environment' })

3. Background Handling:

  • iOS may pause video when app backgrounded
  • Audio continues in background
  • Reconnects when returning to foreground

4. Bandwidth Adaptation:

  • Daily.co automatically adjusts quality for mobile networks
  • Lower resolution on slow connections

17.2 Mobile UI Adaptations

// Responsive video grid
function VideoGrid({ participants }: Props) {
  const isMobile = useMediaQuery('(max-width: 768px)');
  
  return (
    <div className={cn(
      'grid gap-2',
      isMobile 
        ? 'grid-cols-1' // Stack on mobile
        : participants.length <= 4 
          ? 'grid-cols-2' 
          : 'grid-cols-3'
    )}>
      {participants.map(p => (
        <VideoTile key={p.id} participant={p} />
      ))}
    </div>
  );
}

// Mobile-friendly controls
function MobileControls() {
  return (
    <div className="fixed bottom-0 left-0 right-0 bg-background/95 backdrop-blur p-4">
      <div className="flex justify-center gap-4">
        <Button size="lg" className="rounded-full h-14 w-14">
          <Mic className="h-6 w-6" />
        </Button>
        <Button size="lg" className="rounded-full h-14 w-14">
          <Video className="h-6 w-6" />
        </Button>
        <Button size="lg" variant="destructive" className="rounded-full h-14 w-14">
          <PhoneOff className="h-6 w-6" />
        </Button>
      </div>
    </div>
  );
}

---

18. Email Delivery System

18.1 Yes, Using Resend + Edge Functions

Email Flow:

Meeting Created → Edge Function → Resend API → Participant Inboxes

Edge Functions for Email:

| Function | Trigger | Email Sent |
|----------|---------|------------|
| `create-meeting-room` | Meeting created | Invitation emails |
| `send-meeting-reminders` | Cron (every 15 minutes) | Reminder emails |
| `transcribe-meeting` | AI summary completed | Summary emails |
| `cancel-meeting` | Meeting cancelled | Cancellation emails |

18.2 Email Templates

Stored in Edge Functions:

// supabase/functions/_shared/email-templates.ts

export const meetingInvitationTemplate = (params: {
  participantName: string;
  meetingTitle: string;
  meetingDate: string;
  duration: number;
  joinUrl: string;
  organizationName: string;
}) => ({
  subject: `You're invited: ${params.meetingTitle}`,
  html: `
    <div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 600px; margin: 0 auto;">
      <h2>You're Invited to a Video Meeting</h2>
      <p>Hi ${params.participantName},</p>
      <p>You've been invited to a video meeting:</p>
      
      <div style="background: #f5f3f0; border-radius: 8px; padding: 20px; margin: 20px 0;">
        <p style="margin: 0 0 10px 0;"><strong>📹 ${params.meetingTitle}</strong></p>
        <p style="margin: 0 0 10px 0;">📅 ${params.meetingDate}</p>
        <p style="margin: 0;">⏱️ ${params.duration} minutes</p>
      </div>
      
      <a href="${params.joinUrl}" style="display: inline-block; background: #030213; color: white; padding: 12px 24px; border-radius: 6px; text-decoration: none;">
        Join Meeting
      </a>
      
      <p style="margin-top: 30px; color: #6e6b68; font-size: 14px;">
        Hosted by ${params.organizationName} via Alignmint
      </p>
    </div>
  `
});

18.3 Resend Integration

// supabase/functions/_shared/resend.ts
const RESEND_API_KEY = Deno.env.get('RESEND_API_KEY')!;

export async function sendEmail(params: {
  to: string;
  subject: string;
  html: string;
  from?: string;
}) {
  const response = await fetch('https://api.resend.com/emails', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${RESEND_API_KEY}`,
    },
    body: JSON.stringify({
      from: params.from || 'Alignmint <noreply@alignmint.app>',
      to: params.to,
      subject: params.subject,
      html: params.html,
    }),
  });
  
  if (!response.ok) {
    const error = await response.text();
    throw new Error(`Resend API error: ${error}`);
  }
  
  return response.json();
}

18.4 Reminder Cron Job

// supabase/functions/send-meeting-reminders/index.ts
// Triggered by Supabase cron: every 15 minutes
// Finds meetings starting in ~55-65 minutes and sends reminder emails

---

19. Feature Summary Matrix

| Feature | Included in MVP | Notes |
|---------|-----------------|-------|
| **Scheduling** | ✅ | Full scheduling with date/time picker |
| **Email invitations** | ✅ | Via Resend |
| **Email reminders** | ✅ | Sent 55-65 minutes before start |
| **Waiting room** | ✅ | Knocking enabled by default |
| **Pre-join name entry** | ✅ | With camera/mic preview |
| **Host controls (mute/kick)** | ✅ | For hosts and co-hosts |
| **Screen sharing** | ✅ | All participants by default |
| **In-meeting chat** | ✅ | Text chat via Daily.co |
| **Raise hand** | ✅ | Custom implementation |
| **Cloud recording** | ✅ | Opt-in at creation |
| **AI transcription** | ✅ | Post-meeting via Whisper |
| **AI summary** | ✅ | Via GPT-4 |
| **Mobile support** | ✅ | Responsive web app |
| **Custom domains** | ❌ Phase 2 | `meet.{org}.alignmint.com` |
| **Companion mode** | ❌ Phase 2 | Join from second device |
| **Live captions** | ❌ Phase 2 | Real-time transcription |
| **Breakout rooms** | ❌ Phase 2 | Split into groups |
| **Virtual backgrounds** | ❌ Phase 2 | Daily.co supports this |
| **Calendar integration** | ❌ Phase 2 | Google/Outlook sync |

---

Appendix A: Daily.co Webhook Events

| Event | Description | Our Handler |
|-------|-------------|-------------|
| `meeting.started` | First participant joined | Update status to `in_progress` |
| `meeting.ended` | All participants left | Update status to `completed` |
| `recording.started` | Recording began | Update `recording_status` |
| `recording.ready-to-download` | Recording available | Download and store |
| `participant.joined` | Participant entered | Track attendance |
| `participant.left` | Participant exited | Calculate duration |

Appendix B: Environment Variables

# Daily.co
DAILY_API_KEY=your_daily_api_key_here
DAILY_WEBHOOK_SECRET=your_webhook_secret_here

# OpenAI (for transcription and summaries)
OPENAI_API_KEY=your_openai_api_key_here

# App URLs
APP_URL=https://app.alignmint.com
MEETING_URL_PREFIX=https://app.alignmint.com/meeting

# Resend (for emails)
RESEND_API_KEY=your_resend_api_key_here

Appendix C: Dependencies

{
  "dependencies": {
    "@daily-co/daily-js": "^0.62.0",
    "@daily-co/daily-react": "^0.18.0"
  }
}

Installation:

npm install @daily-co/daily-js @daily-co/daily-react

---

*End of Document*


Synced from IFMmvp-Frontend documentation: workspace/02-VIDEO-CONFERENCING.md

Ready to Get Started?

See how Alignmint can simplify your nonprofit's operations. Schedule a free demo with our team and we'll walk you through everything.

Questions? Email us at steven@getalignmint.org

Ready to get started?Start Plus Trial