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 zeroaudiooutputentries, leaving the Speaker dropdown empty with no explanation. - Root cause: Browser limitation — Safari has never supported
audiooutputenumeration orsetSinkId(). - Fix: Added
setSinkIdfeature 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:
selectedSpeakerstate in PreJoinScreen was set but never passed through to MeetingRoom. Even on Chrome, the pre-join speaker selection was cosmetic. - Fix: Added
initialSpeakertoPreJoinScreenProps.onJoinparams,JoinParamsin MeetingPage, andMeetingRoomProps. MeetingRoom applies the initial speaker viacallObject.setOutputDeviceAsync()after joining (only on browsers that supportsetSinkId). - 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.logafter enumeration in both components showing mic/camera/speaker counts andsetSinkIdsupport 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' }tostartScreenShare(). Chrome 107+ hides the current tab from the picker. Removed the redundant toast warning. Also suppressedNotAllowedErrortoast 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:
setInputDevicesAsyncsets 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
requestUnmuteandrequestStartVideofunctions 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-80tow-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-wordsclass to chat message<p>element. Class exists inindex.css(appliesoverflow-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'shandRaisedtransitions fromfalsetotrueand showtoast.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
isRecordingis true. Also improved error handling intoggleRecordingto 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:flexto hide on mobile and show on desktop. Butsm:flexwas never added toindex.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, sohiddenwon and buttons stayed permanently hidden at all breakpoints. - Fix: Added
.sm\:flex { display: flex; }inside@media (width >= 40rem)toindex.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' } }tocreateCallObject()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
useEffectthat registersmousemove/clicklisteners depended only on[startHideTimer]. During the loading state (isJoining = true), the meeting containerdivis not rendered —meetingContainerRef.currentisnull. The effect ran, hitif (!container) return, and registered zero listeners. WhenisJoiningflipped tofalseand the container mounted, the effect never re-ran becausestartHideTimerhadn't changed identity. Meanwhile, the panel-visibilityuseEffectcalledstartHideTimer()on mount, starting a 4s timer that permanently hid controls with no listener to bring them back. - Fix: (1) Added
isJoiningto the dependency array so the effect re-runs when the container materializes. (2) Desktopmousemoveregisters ondocument(not container) so it works regardless of container existence and fires even over<video>elements. (3) Mobileclickregisters on container only when available. (4) Addedpointer-events-nonetoVideoTile'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-xlcontainer 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]). TheactiveSpeakerIdwas 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
activeSpeakerIdto 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-updatedevent fires withlevel: '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
lobby→full(actual waiting room admission). Direct joins no longer trigger the spurious toast. - Affected files:
MeetingRoom.tsx
Missing startHideTimer Dependency (P2)
- Fixed: The
useEffectthat keeps controls visible when side panels are open calledstartHideTimer()but didn't list it in its dependency array. - Fix: Added
startHideTimerto the dependency array. - Affected files:
MeetingRoom.tsx
Stale callObject in Cleanup (P2)
- Fixed: The
useEffectcleanup for Daily.co call initialization capturedcallObjectfrom 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 handledscheduledmeetings past their end time, notin_progress. ThestaleMeetingsCheckedref also prevented re-running cleanup on subsequent mounts for the same entity. - Fix: (1)
getDisplayStatus()now shows "Ended" for bothscheduledandin_progressmeetings past theirscheduled_end + 30 min grace. (2) RemovedstaleMeetingsCheckedref guard soupdateStaleMeetings()runs on every component mount. (3) AddedrefetchInterval: 30000to the meetings query so webhook-driven status changes appear within 30 seconds. - Data fix: Manually updated stuck instant meeting
d830b602tocompleted. - 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-4andgap-y-1CSS classes were missing fromindex.css(no JIT — classes must be manually safelisted). - Fix: Added
gap-x-4(column-gap) andgap-y-1(row-gap) utility classes. - Affected files:
index.css
Participant Count Display (P1)
- Fixed: Meeting cards showed confusing
0/1 attendedformat for instant meetings. - Fix: Changed to
{count} joinedfor 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_text → transcript_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.tssupabase/functions/create-meeting-room/index.tssupabase/functions/transcribe-meeting/index.tssupabase/functions/send-meeting-reminders/index.tssrc/types/meetings.tssrc/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
+1prefix 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 optionallyTELNYX_MESSAGING_PROFILE_IDsecrets 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.endedwebhook fires when all participants leave the room, regardless of remaining allotted time. daily-webhookedge function setsstatus: 'completed'+actual_endtimestamp.- Room has hard expiry at
scheduled_end + 30 minwitheject_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/MeetingRoomis 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-webhookdownloads recording → uploads to Supabase Storage → triggerstranscribe-meeting✅transcribe-meetingdownloads → Whisper API → formats transcript → GPT-4o-mini summary → saves to DB → emails participants ✅- Fixed: Auth check now accepts service-role key from
daily-webhookinternal 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-webhookedge 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.stoppedis 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 showedstatus: scheduled,recording_status: none,actual_start: nullin DB. - Fix: Manually updated status to
completedwithactual_start/actual_endtimestamps. - Meeting
ef1408bcwas incorrectly markedno_showdespite having a 2-participant session — fixed tocompleted.
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_eventstable: 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-webhookedge function inserted intodaily_webhook_eventson 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_eventstable via migration with columns:id,event_type,room_name,payload(jsonb),status,error_message,created_at,processed_at. Added indexes onroom_nameandstatus. RLS enabled (service role bypasses). - Affected: Database schema,
daily-webhookedge function
Private Storage Bucket (P0)
- Fixed: The
meeting-recordingsstorage bucket was private, but the webhook usedgetPublicUrl()to generate recording URLs. These URLs would 403 when accessed by<video>elements orwindow.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/summarywas gated behindrecordingEnabled. 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
MeetingSummaryViewcomponent 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-webhookedge function (see Round 6 above). No manual dashboard configuration needed. CallPOST /functions/v1/configure-daily-webhookwith{"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/spotlightwithpip/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
spotlightParticipantIdstate). - 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
defaultCornerprop andzOffsetfor 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
MoreHorizontalmenu containing record, raise hand, chat, participants, settings. Desktop shows all buttons inline. - Affected files:
MeetingRoom.tsx
Stale Closure Fix (P1)
- Fixed:
handleAppMessagecallback captured staleparticipantsandshowChatstate - Root cause: Callback registered once at init but closed over initial state values
- Fix: Added
participantsRefandshowChatRefrefs synced viauseEffect. Callback reads from refs. Also changedcallObjectreferences tocallObjectRef.current. Dependency array set to[]. - Affected files:
MeetingRoom.tsx
Hardcoded Colors → Semantic Tokens (P1)
- Fixed:
text-yellow-400,text-green-400,text-red-400used in VideoTile and ScreenShareTile - Fix: Replaced with
text-primary(hand raised, screen share, monitor icon) andtext-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 usemin-h-[44px]. Fullscreen toggle usesmin-h-[44px] min-w-[44px]. - Affected files:
MeetingRoom.tsx
Desktop mousemove+click Double-Fire (P1)
- Fixed: On desktop, both
mousemoveandclicklisteners 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-colwithbg-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. Thevariant="secondary"buttons (#252f48) were nearly invisible against the navy. The video grid hadp-4padding creating 16px navy borders. - Fix: Complete layout restructure — video grid now fills the entire screen via
absolute inset-0. Header and controls bar areabsoluteoverlays withbackdrop-blur-sm bg-black/50for contrast. Container background changed frombg-backgroundtobg-black. Controls use slide animations (translate-y-full/-translate-y-full) instead ofh-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
VideoTilehadaspect-video max-w-4xl max-h-fullclasses regardless of participant count. On narrow mobile screens withgrid-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-fullfrom VideoTile (now fills container withw-full h-full). Single-participant view gets aspect-video via a wrapper div. Multi-participant grid usesauto-rows-frfor equal row heights. Responsive grid classes:grid-cols-1 sm:grid-cols-2for 2 participants,grid-cols-2for 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:
MeetingRoomdidn't receive the creator's name or avatar. OnlymeetingTitlewas passed through. The circle was a genericbg-primary/40animated blob with no content. - Fix: Added
creatorNameandcreatorAvatarUrlprops threaded fromPreJoinScreen→MeetingPage→MeetingRoom. PreJoinScreen now fetchesavatar_urlin 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 withw-32fixed-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=coverto 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 viasetInputDevicesAsync, state sync fromparticipant-updatedevents. 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-hiddencollapse classes were gated behindisFullscreen, which is alwaysfalseon mobile browsers. Alsobg-black/30made the empty space visible. - Fix: Removed
isFullscreenguard — controls now fully collapse whenever hidden. Removedbg-black/30from 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
mousemoveandtouchstartwere 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) andclick(tap-to-toggle). Tapping the video area toggles controls on/off. Interactive elements (header, controls bar) usestopPropagationto 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 isactual_start, set by thedaily-webhookonmeeting.started. The.maybeSingle()call silently returned null, always falling back tonew Date(). - Fix: Changed
started_at→actual_startin the Supabase query. - Affected files:
MeetingRoom.tsx
Recording Auto-Start Never Fired (P0)
- Fixed: Auto-start recording for hosts never executed
- Root cause:
handleJoinedMeetingcallback usedcallObjectfrom React state, which wasnullat the time the Daily.co event listener was registered (React state updates are async). The stale closure always sawcallObject = null. - Fix: Added
callObjectRef(React ref) that's set synchronously alongsidesetCallObject. 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/30dark background - User request: Controls should be transparent floating buttons, not a dark bar
- Fix: Removed
bg-black/30from 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 supportgetDisplayMedia - Affected files:
MeetingRoom.tsx
Manual Record/Stop Button (P1)
- Added: Record toggle button in controls bar (host only). Uses
Discicon with pulse animation when recording. CallscallObject.startRecording()/callObject.stopRecording()viacallObjectRef. - 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-meetingedge 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-tokenreturned 500 for all non-owner guests joining meetings with waiting room enabled - Root cause: Token properties used
enable_knocking: truewhich is a room property, not a token property. The correct Daily.co token property isknocking. Daily.co rejected tokens with the unrecognized property name. - Fix: Changed
tokenProperties.enable_knocking→tokenProperties.knocking(single-line fix) - Affected files:
supabase/functions/generate-meeting-token/index.ts - Deployed:
generate-meeting-tokenv19
Screen Share Not Visible (P0)
- Fixed: Screen share rendered as a thin line instead of filling the video area
- Root cause:
ScreenShareTileouter 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
useEffectfired on everyviewModechange 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:
ScreenShareTiletrack-startedhandler relied onevent.type === 'screenVideo'which may not match Daily.co's event structure. Now checksparticipant.tracks.screenVideodirectly. - 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
fullscreenchangeevent listener to syncisFullscreenstate 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()onjoined-meeting). Now fetchesmeetings.started_atfrom Supabase on mount and uses it as the reference. Falls back tonew 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'ssetInputDevicesAsync. - Affected files:
MeetingRoom.tsx
Bug Fixes & Improvements (February 15, 2026)
Guest Join 500 Error Fix
- Fixed:
generate-meeting-tokenedge function returned 500 when non-owner guests joined meetings with waiting room enabled - Root cause:
isOwnerdetermination only checkedparticipants.user_idmatch, which failed when participants were added by email (user_id was null) - Fix: Extended
isOwnerlogic 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:
addMeetingParticipantnow auto-linksuser_idwhen the participant's email matches an existing Alignmint auth account - Root cause: Participants added by email had
user_id: nulleven 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:
PreJoinScreennow 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-roomno longer sends invitation emails (was duplicating emails already sent bysend-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 indb.tsto auto-update stale meetings tocompletedorno_showstatus - 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()indb.tswas not calling thecreate-meeting-roomedge 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:
SelectContentviewport hadh-[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
2. Daily.co Platform Deep Dive
4. API Design
11. Cost Analysis
- 1.1 System Architecture
- 1.2 Data Flow
- 1.3 Integration Points
- 2.1 What Daily.co Provides
- 2.2 API Capabilities
- 2.3 Pricing Structure
- 2.4 Limitations
- 3.1 Meetings Table
- 3.2 Meeting Participants Table
- 3.3 RLS Policies
- 3.4 Indexes
- 4.1 Frontend Functions (db.ts)
- 4.2 Edge Functions
- 4.3 Webhook Handlers
- 5.1 VideoConferencingManager
- 5.2 MeetingScheduler
- 5.3 MeetingRoom
- 5.4 MeetingSummaryView
- 5.5 Component Hierarchy
- 6.1 Dashboard View
- 6.2 Schedule Meeting Modal
- 6.3 Meeting Room Page
- 6.4 Post-Meeting Summary
- 7.1 Meeting Invitation
- 7.2 Meeting Reminder
- 7.3 Post-Meeting Summary Email
- 8.1 Transcription Pipeline
- 8.2 MINTY AI Summary Generation
- 8.3 Action Item Extraction
- 9.1 Recording Storage
- 9.2 Compression
- 9.3 Retention Policy
- 9.4 Storage Costs
- 10.1 Meeting Tokens
- 10.2 Join Link Expiration
- 10.3 Authentication Flow
- 11.1 Daily.co Costs
- 11.2 Transcription Costs
- 11.3 AI Summary Costs
- 11.4 Storage Costs
- 11.5 Total Cost Projections
- 12.1 Phase 1: Core Infrastructure
- 12.2 Phase 2: Video Integration
- 12.3 Phase 3: Recording & AI
- 12.4 Phase 4: Polish & Launch
- 13.1 Migration to Self-Hosted
- 13.2 Additional Features
---
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
└── RecordingPlayer5.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
usersanddonorstables - 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 AlignmintVariables:
| 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 DatabaseEdge 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.004Scenarios:
| 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.006Scenarios:
| 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
meetingstable with all columns - [ ] Create
meeting_participantstable - [ ] 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_KEYto 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/:idroute - [ ] 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-meetingedge function - [ ] Integrate OpenAI Whisper API
- [ ] Store transcript with timestamps
- [ ] Add transcript viewer UI
AI Summary:
- [ ]
summarize-meetingedge 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'orrole: 'co_host'inmeeting_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
StartsSupabase 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 trueWhat 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 InboxesEdge 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_hereAppendix 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