diff --git a/minutely-api/cmd/api/main.go b/minutely-api/cmd/api/main.go index 5339fd753..7d8101063 100644 --- a/minutely-api/cmd/api/main.go +++ b/minutely-api/cmd/api/main.go @@ -140,9 +140,6 @@ func main() { // Public routes r.Get("/{meetingId}/transcript", transcriptionHandler.GetMeetingTranscript) r.Get("/{meetingId}/ai-insights", aiHandler.GetMeetingInsights) - r.Post("/{meetingId}/recordings/upload", transcriptionHandler.UploadRecording) - r.Post("/{meetingId}/transcription/start", liveHandler.StartSession) - r.Post("/{meetingId}/transcription/end", liveHandler.EndSession) // Authenticated routes r.Group(func(r chi.Router) { @@ -151,6 +148,9 @@ func main() { r.Get("/", handler.ListMeetings) r.Get("/summaries", handler.ListMeetingSummaries) r.Get("/{id}", handler.GetMeeting) + r.Post("/{meetingId}/recordings/upload", transcriptionHandler.UploadRecording) + r.Post("/{meetingId}/transcription/start", liveHandler.StartSession) + r.Post("/{meetingId}/transcription/end", liveHandler.EndSession) }) }) diff --git a/minutely-api/internal/adapters/postgres/dummy_repo.go b/minutely-api/internal/adapters/postgres/dummy_repo.go index bb733415c..622dafb62 100644 --- a/minutely-api/internal/adapters/postgres/dummy_repo.go +++ b/minutely-api/internal/adapters/postgres/dummy_repo.go @@ -19,6 +19,10 @@ func (r *dummyProfileRepo) GetByID(ctx context.Context, id uuid.UUID) (*domain.P return &domain.Profile{ID: id, FullName: &name}, nil } +func (r *dummyProfileRepo) Create(ctx context.Context, profile *domain.Profile) error { + return nil +} + func (r *dummyProfileRepo) Update(ctx context.Context, profile *domain.Profile) error { return nil } diff --git a/minutely-api/internal/adapters/postgres/supabase_repo.go b/minutely-api/internal/adapters/postgres/supabase_repo.go index 8613ea383..0f15e6df6 100644 --- a/minutely-api/internal/adapters/postgres/supabase_repo.go +++ b/minutely-api/internal/adapters/postgres/supabase_repo.go @@ -37,6 +37,11 @@ func (r *supabaseProfileRepo) GetByID(ctx context.Context, id uuid.UUID) (*domai return &profile, nil } +func (r *supabaseProfileRepo) Create(ctx context.Context, profile *domain.Profile) error { + _, _, err := r.client.From("profiles").Insert(profile, false, "exact", "representation", "id").Execute() + return err +} + func (r *supabaseProfileRepo) Update(ctx context.Context, profile *domain.Profile) error { _, _, err := r.client.From("profiles").Update(profile, "exact", "id").Eq("id", profile.ID.String()).Execute() return err diff --git a/minutely-api/internal/core/domain/profile.go b/minutely-api/internal/core/domain/profile.go index f46be2efd..85d2c3125 100644 --- a/minutely-api/internal/core/domain/profile.go +++ b/minutely-api/internal/core/domain/profile.go @@ -19,6 +19,7 @@ type Profile struct { // ProfileRepository defines datastore interactions for profiles type ProfileRepository interface { GetByID(ctx context.Context, id uuid.UUID) (*Profile, error) + Create(ctx context.Context, profile *Profile) error Update(ctx context.Context, profile *Profile) error } diff --git a/minutely-api/internal/core/services/profile_service.go b/minutely-api/internal/core/services/profile_service.go index 0151f42a9..89a71dd5d 100644 --- a/minutely-api/internal/core/services/profile_service.go +++ b/minutely-api/internal/core/services/profile_service.go @@ -16,7 +16,19 @@ func NewProfileService(repo domain.ProfileRepository) domain.ProfileService { } func (s *profileService) GetProfile(ctx context.Context, id uuid.UUID) (*domain.Profile, error) { - return s.repo.GetByID(ctx, id) + profile, err := s.repo.GetByID(ctx, id) + if err != nil { + // If profile not found, create a default one (lazy creation) + // This handles cases where the profiles table was reset but the auth user still exists + newProfile := &domain.Profile{ + ID: id, + } + if err := s.repo.Create(ctx, newProfile); err != nil { + return nil, err + } + return newProfile, nil + } + return profile, nil } func (s *profileService) UpdateProfile(ctx context.Context, profile *domain.Profile) error { diff --git a/minutely-api/internal/core/services/transcription_service.go b/minutely-api/internal/core/services/transcription_service.go index ba366bc55..90ec7a56f 100644 --- a/minutely-api/internal/core/services/transcription_service.go +++ b/minutely-api/internal/core/services/transcription_service.go @@ -87,6 +87,11 @@ func (s *transcriptionService) EndLiveSession(ctx context.Context, meetingID uui if err == nil { transcript.Status = domain.TranscriptStatusCompleted transcript.CompletedAt = &now + + // Calculate duration + duration := now.Sub(session.StartedAt).Seconds() + transcript.DurationSecs = &duration + _ = s.repo.Update(ctx, transcript) } } diff --git a/minutely-api/internal/transport/http/handlers.go b/minutely-api/internal/transport/http/handlers.go index d2774e25b..33e937a68 100644 --- a/minutely-api/internal/transport/http/handlers.go +++ b/minutely-api/internal/transport/http/handlers.go @@ -33,6 +33,13 @@ func (h *Handler) GetProfile(w http.ResponseWriter, r *http.Request) { return } + // If name is missing in DB, use the one from context (from auth metadata) + if profile.FullName == nil || *profile.FullName == "" || *profile.FullName == "User" { + if name, ok := r.Context().Value(middleware.UserNameKey).(string); ok { + profile.FullName = &name + } + } + json.NewEncoder(w).Encode(profile) } diff --git a/minutely-api/internal/transport/http/live_transcription_handler.go b/minutely-api/internal/transport/http/live_transcription_handler.go index 6c21a52f8..a40fd0072 100644 --- a/minutely-api/internal/transport/http/live_transcription_handler.go +++ b/minutely-api/internal/transport/http/live_transcription_handler.go @@ -10,6 +10,7 @@ import ( "github.com/google/uuid" "github.com/MinutelyAI/minutely-api/internal/core/domain" + "github.com/MinutelyAI/minutely-api/internal/transport/http/middleware" "github.com/MinutelyAI/minutely-api/internal/transport/ws" ) @@ -33,10 +34,20 @@ func (h *LiveTranscriptionHandler) parseOrGenerateMeetingID(r *http.Request) uui if err != nil { // It's a Jitsi string name, generate a deterministic UUID meetingID = uuid.NewMD5(uuid.NameSpaceURL, []byte(meetingIDStr)) + + // Try to extract userID from context (if route is authenticated) + var userIDPtr *uuid.UUID + if val := r.Context().Value(middleware.UserIDKey); val != nil { + if uid, ok := val.(uuid.UUID); ok { + userIDPtr = &uid + } + } + // Lazily create a dummy meeting if it doesn't exist so foreign keys don't fail _, _ = h.meetingService.GetMeeting(r.Context(), meetingID) err = h.meetingService.CreateMeeting(r.Context(), &domain.Meeting{ ID: meetingID, + UserID: userIDPtr, Title: meetingIDStr, Status: domain.MeetingStatusInProgress, CreatedAt: time.Now(), diff --git a/minutely-api/internal/transport/http/middleware/auth.go b/minutely-api/internal/transport/http/middleware/auth.go index 20017af36..1bd6eb27e 100644 --- a/minutely-api/internal/transport/http/middleware/auth.go +++ b/minutely-api/internal/transport/http/middleware/auth.go @@ -11,6 +11,9 @@ import ( type contextKey string const UserIDKey contextKey = "user_id" +const UserKey contextKey = "user" +const UserNameKey contextKey = "user_name" +const UserEmailKey contextKey = "user_email" type SupabaseAuth struct { client *supabase.Client @@ -50,6 +53,28 @@ func (m *SupabaseAuth) Handle(next http.Handler) http.Handler { } ctx := context.WithValue(r.Context(), UserIDKey, userID) + ctx = context.WithValue(ctx, UserKey, user) + + // Extract name from metadata if possible + name := "" + if user.UserMetadata != nil { + if n, ok := user.UserMetadata["full_name"].(string); ok { + name = n + } else if n, ok := user.UserMetadata["name"].(string); ok { + name = n + } + } + if name == "" { + // Fallback to email prefix + if user.Email != "" { + name = strings.Split(user.Email, "@")[0] + } else { + name = "User" + } + } + ctx = context.WithValue(ctx, UserNameKey, name) + ctx = context.WithValue(ctx, UserEmailKey, user.Email) + next.ServeHTTP(w, r.WithContext(ctx)) }) } diff --git a/minutely-api/internal/transport/http/transcription_handler.go b/minutely-api/internal/transport/http/transcription_handler.go index fbafe81a6..d09e69c4e 100644 --- a/minutely-api/internal/transport/http/transcription_handler.go +++ b/minutely-api/internal/transport/http/transcription_handler.go @@ -43,9 +43,19 @@ func (h *TranscriptionHandler) parseOrGenerateMeetingID(r *http.Request) uuid.UU meetingID, err := uuid.Parse(meetingIDStr) if err != nil { meetingID = uuid.NewMD5(uuid.NameSpaceURL, []byte(meetingIDStr)) + + // Try to extract userID from context (if route is authenticated) + var userIDPtr *uuid.UUID + if val := r.Context().Value(middleware.UserIDKey); val != nil { + if uid, ok := val.(uuid.UUID); ok { + userIDPtr = &uid + } + } + _, _ = h.meetingService.GetMeeting(r.Context(), meetingID) err = h.meetingService.CreateMeeting(r.Context(), &domain.Meeting{ ID: meetingID, + UserID: userIDPtr, Title: meetingIDStr, Status: domain.MeetingStatusInProgress, CreatedAt: time.Now(), @@ -61,7 +71,11 @@ func (h *TranscriptionHandler) parseOrGenerateMeetingID(r *http.Request) uuid.UU // UploadRecording accepts a multipart file upload, stores it, and enqueues a transcription job. // POST /api/v1/meetings/{meetingId}/recordings/upload func (h *TranscriptionHandler) UploadRecording(w http.ResponseWriter, r *http.Request) { - _ = r.Context().Value(middleware.UserIDKey).(uuid.UUID) + // Safely get UserID + var userID uuid.UUID + if val := r.Context().Value(middleware.UserIDKey); val != nil { + userID = val.(uuid.UUID) + } meetingID := h.parseOrGenerateMeetingID(r) @@ -109,6 +123,11 @@ func (h *TranscriptionHandler) UploadRecording(w http.ResponseWriter, r *http.Re SizeBytes: &size, Status: domain.MediaStatusUploaded, } + + if userID != uuid.Nil { + mediaFile.UploadedBy = &userID + } + if err := h.jobRepo.CreateMediaFile(r.Context(), mediaFile); err != nil { jsonError(w, "failed to record media file: "+err.Error(), http.StatusInternalServerError) return diff --git a/minutely-api/internal/workers/file_processor.go b/minutely-api/internal/workers/file_processor.go index 7999f1c4b..cfba372ea 100644 --- a/minutely-api/internal/workers/file_processor.go +++ b/minutely-api/internal/workers/file_processor.go @@ -321,8 +321,16 @@ func (p *FileProcessor) processAIJob(ctx context.Context, job *domain.Processing } fullText := "" - if transcript.FullText != nil { + if transcript.FullText != nil && *transcript.FullText != "" { fullText = *transcript.FullText + } else if len(segments) > 0 { + // Reconstruct from segments if FullText is missing (common for live sessions) + for i, seg := range segments { + if i > 0 { + fullText += " " + } + fullText += seg.Text + } } durationSecs := 0.0 diff --git a/minutely-api/supabase/migrations/999999_full_reset.sql b/minutely-api/supabase/migrations/999999_full_reset.sql new file mode 100644 index 000000000..58d504045 --- /dev/null +++ b/minutely-api/supabase/migrations/999999_full_reset.sql @@ -0,0 +1,535 @@ +-- ============================================= +-- MINUTELY — FINAL CONSOLIDATED SCHEMA +-- Run this on a fresh database after dropping +-- all existing tables. +-- ============================================= + +-- ============================================= +-- DROP EVERYTHING (run this block first) +-- ============================================= + + +DROP VIEW IF EXISTS public.meeting_summaries CASCADE; +DROP VIEW IF EXISTS public.user_action_items_view CASCADE; +DROP TABLE IF EXISTS public.ai_outputs CASCADE; +DROP TABLE IF EXISTS public.live_sessions CASCADE; +DROP TABLE IF EXISTS public.transcript_segments CASCADE; +DROP TABLE IF EXISTS public.transcripts CASCADE; +DROP TABLE IF EXISTS public.media_files CASCADE; +DROP TABLE IF EXISTS public.processing_jobs CASCADE; +DROP TABLE IF EXISTS public.notifications CASCADE; +DROP TABLE IF EXISTS public.preferences CASCADE; +DROP TABLE IF EXISTS public.action_items CASCADE; +DROP TABLE IF EXISTS public.meeting_notes CASCADE; +DROP TABLE IF EXISTS public.meeting_participants CASCADE; +DROP TABLE IF EXISTS public.meetings CASCADE; +DROP TABLE IF EXISTS public.team_members CASCADE; +DROP TABLE IF EXISTS public.teams CASCADE; +DROP TABLE IF EXISTS public.profiles CASCADE; + +DROP TYPE IF EXISTS meeting_status CASCADE; +DROP TYPE IF EXISTS participant_role CASCADE; +DROP TYPE IF EXISTS team_role CASCADE; +DROP TYPE IF EXISTS action_status CASCADE; +DROP TYPE IF EXISTS theme_preference CASCADE; +DROP TYPE IF EXISTS job_status CASCADE; +DROP TYPE IF EXISTS job_type CASCADE; +DROP TYPE IF EXISTS media_status CASCADE; +DROP TYPE IF EXISTS ai_output_type CASCADE; +DROP TYPE IF EXISTS ai_output_status CASCADE; + +-- ============================================= +-- ENUMS +-- ============================================= + +CREATE TYPE meeting_status AS ENUM ('scheduled', 'in_progress', 'completed', 'canceled'); +CREATE TYPE participant_role AS ENUM ('host', 'co-host', 'participant'); +CREATE TYPE team_role AS ENUM ('owner', 'admin', 'member'); +CREATE TYPE action_status AS ENUM ('pending', 'completed'); +CREATE TYPE theme_preference AS ENUM ('light', 'dark', 'system'); +CREATE TYPE job_status AS ENUM ('pending', 'processing', 'completed', 'failed', 'retrying'); +CREATE TYPE job_type AS ENUM ('live_transcription', 'file_transcription', 'ai_processing'); +CREATE TYPE media_status AS ENUM ('uploaded', 'processing', 'ready', 'error'); +CREATE TYPE ai_output_type AS ENUM ('summary', 'action_items', 'key_topics', 'sentiment', 'tasks', 'custom'); +CREATE TYPE ai_output_status AS ENUM ('pending', 'processing', 'completed', 'failed'); + +-- ============================================= +-- USERS & TEAMS +-- ============================================= + +CREATE TABLE public.profiles ( + id uuid PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, + full_name text, + avatar_url text, + created_at timestamptz DEFAULT timezone('utc', now()), + updated_at timestamptz DEFAULT timezone('utc', now()) +); + +CREATE TABLE public.teams ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + name text NOT NULL, + description text, + created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL, + created_at timestamptz DEFAULT timezone('utc', now()) +); + +CREATE TABLE public.team_members ( + team_id uuid NOT NULL REFERENCES public.teams(id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + role team_role DEFAULT 'member', + joined_at timestamptz DEFAULT timezone('utc', now()), + PRIMARY KEY (team_id, user_id) +); + +-- ============================================= +-- MEETINGS +-- ============================================= + +CREATE TABLE public.meetings ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid REFERENCES auth.users(id) ON DELETE SET NULL, -- Host + team_id uuid REFERENCES public.teams(id) ON DELETE CASCADE, + title text NOT NULL, + description text, + status meeting_status DEFAULT 'scheduled', + scheduled_for timestamptz, + is_archived boolean DEFAULT false, + created_at timestamptz DEFAULT timezone('utc', now()), + updated_at timestamptz DEFAULT timezone('utc', now()) +); + +CREATE TABLE public.meeting_participants ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + meeting_id uuid NOT NULL REFERENCES public.meetings(id) ON DELETE CASCADE, + user_id uuid REFERENCES auth.users(id) ON DELETE SET NULL, + email text NOT NULL, + display_name text, + role participant_role DEFAULT 'participant', + added_at timestamptz DEFAULT timezone('utc', now()), + UNIQUE (meeting_id, email) +); + +-- ============================================= +-- BACKGROUND JOBS (Upload Processing) +-- ============================================= + +CREATE TABLE public.processing_jobs ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + meeting_id uuid NOT NULL REFERENCES public.meetings(id) ON DELETE CASCADE, + job_type job_type NOT NULL, + status job_status DEFAULT 'pending', + attempt_count int DEFAULT 0, + max_attempts int DEFAULT 3, + error_message text, + payload jsonb, -- Input params (language, speakers, etc.) + result jsonb, -- Storage path, duration, etc. + started_at timestamptz, + completed_at timestamptz, + created_at timestamptz DEFAULT timezone('utc', now()), + updated_at timestamptz DEFAULT timezone('utc', now()) +); + +-- ============================================= +-- MEDIA FILES (Uploaded Recordings) +-- ============================================= + +CREATE TABLE public.media_files ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + meeting_id uuid NOT NULL REFERENCES public.meetings(id) ON DELETE CASCADE, + uploaded_by uuid REFERENCES auth.users(id), + storage_path text NOT NULL, -- Supabase Storage path + original_name text NOT NULL, + mime_type text NOT NULL, + size_bytes bigint, + duration_secs float, + status media_status DEFAULT 'uploaded', + job_id uuid REFERENCES public.processing_jobs(id), + created_at timestamptz DEFAULT timezone('utc', now()) +); + +-- ============================================= +-- TRANSCRIPTION +-- ============================================= + +CREATE TABLE public.transcripts ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + meeting_id uuid NOT NULL REFERENCES public.meetings(id) ON DELETE CASCADE, + media_file_id uuid REFERENCES public.media_files(id), + source text NOT NULL CHECK (source IN ('live', 'upload')), + language text DEFAULT 'en', + full_text text, -- Assembled from segments on completion + duration_secs float, + speaker_count int, + status text DEFAULT 'in_progress' CHECK (status IN ('in_progress', 'completed', 'failed')), + created_at timestamptz DEFAULT timezone('utc', now()), + completed_at timestamptz +); + +-- One segment = one speaker turn. +-- speaker_name + speaker_email are MANDATORY — captured from Jitsi participant identity. +CREATE TABLE public.transcript_segments ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + transcript_id uuid NOT NULL REFERENCES public.transcripts(id) ON DELETE CASCADE, + speaker_label text, -- Raw label from Deepgram/pyannote: "SPEAKER_00" + speaker_name text NOT NULL, -- Participant display name (from Jitsi) + speaker_email text NOT NULL, -- Participant email (for task assignment) + text text NOT NULL, + start_secs float NOT NULL, + end_secs float NOT NULL, + confidence float, + is_partial boolean DEFAULT false, + sequence_num int, + created_at timestamptz DEFAULT timezone('utc', now()) +); + +-- Tracks an active live transcription session. +-- One per meeting at a time (enforced by unique constraint on active sessions). +CREATE TABLE public.live_sessions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + meeting_id uuid NOT NULL REFERENCES public.meetings(id) ON DELETE CASCADE, + transcript_id uuid REFERENCES public.transcripts(id), + deepgram_token_expires timestamptz, -- When the short-lived client token expires + started_at timestamptz DEFAULT timezone('utc', now()), + ended_at timestamptz, + participant_count int DEFAULT 0 +); + +-- ============================================= +-- AI PIPELINE (LLM-Ready Slots) +-- ============================================= + +CREATE TABLE public.ai_outputs ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + meeting_id uuid NOT NULL REFERENCES public.meetings(id) ON DELETE CASCADE, + transcript_id uuid REFERENCES public.transcripts(id), + output_type ai_output_type NOT NULL, + status ai_output_status DEFAULT 'pending', + model_used text, -- e.g. "gpt-4o", "claude-3-sonnet" + prompt_version text, + result jsonb, -- Structured LLM output (any shape) + tokens_used int, + cost_usd float, + error_message text, + created_at timestamptz DEFAULT timezone('utc', now()), + completed_at timestamptz +); + +-- ============================================= +-- MEETING METADATA +-- ============================================= + +-- action_items table moved to end + +CREATE TABLE public.meeting_notes ( + meeting_id uuid PRIMARY KEY REFERENCES public.meetings(id) ON DELETE CASCADE, + summary text, + key_points jsonb, + is_approved boolean DEFAULT false, + created_at timestamptz DEFAULT timezone('utc', now()), + updated_at timestamptz DEFAULT timezone('utc', now()) +); + +-- ============================================= +-- USER PREFERENCES & NOTIFICATIONS +-- ============================================= + +CREATE TABLE public.preferences ( + user_id uuid PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, + theme theme_preference DEFAULT 'system', + default_mic text, + default_camera text, + default_speaker text, + join_muted boolean DEFAULT false, + enable_captions boolean DEFAULT true, + updated_at timestamptz DEFAULT timezone('utc', now()) +); + +CREATE TABLE public.notifications ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + title text NOT NULL, + message text NOT NULL, + action_url text, + is_read boolean DEFAULT false, + created_at timestamptz DEFAULT timezone('utc', now()) +); + +-- ============================================= +-- INDEXES +-- ============================================= + +CREATE INDEX idx_team_members_user_id ON public.team_members(user_id); +CREATE INDEX idx_meetings_user_id ON public.meetings(user_id); +CREATE INDEX idx_meetings_team_id ON public.meetings(team_id); +CREATE INDEX idx_meeting_participants_meeting_id ON public.meeting_participants(meeting_id); +CREATE INDEX idx_processing_jobs_meeting_id ON public.processing_jobs(meeting_id); +CREATE INDEX idx_processing_jobs_status ON public.processing_jobs(status); +CREATE INDEX idx_media_files_meeting_id ON public.media_files(meeting_id); +CREATE INDEX idx_transcripts_meeting_id ON public.transcripts(meeting_id); +CREATE INDEX idx_segments_transcript_id ON public.transcript_segments(transcript_id); +CREATE INDEX idx_segments_speaker_email ON public.transcript_segments(speaker_email); +CREATE INDEX idx_segments_start ON public.transcript_segments(transcript_id, start_secs); +CREATE INDEX idx_ai_outputs_meeting_id ON public.ai_outputs(meeting_id); +-- index removed +CREATE INDEX idx_notifications_user_id ON public.notifications(user_id); + +-- ============================================= +-- ROW LEVEL SECURITY — ENABLE +-- ============================================= + +ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.teams ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.team_members ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.meetings ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.meeting_participants ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.processing_jobs ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.media_files ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.transcripts ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.transcript_segments ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.live_sessions ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.ai_outputs ENABLE ROW LEVEL SECURITY; +-- RLS moved +ALTER TABLE public.meeting_notes ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.preferences ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.notifications ENABLE ROW LEVEL SECURITY; + +-- ============================================= +-- ROW LEVEL SECURITY — POLICIES +-- ============================================= + +-- Profiles +CREATE POLICY "Anyone can view profiles" + ON public.profiles FOR SELECT USING (true); +CREATE POLICY "Users manage own profile" + ON public.profiles FOR ALL USING (auth.uid() = id); + +-- Teams +CREATE POLICY "Team members can view teams" + ON public.teams FOR SELECT USING ( + EXISTS (SELECT 1 FROM public.team_members WHERE team_id = teams.id AND user_id = auth.uid()) + ); +CREATE POLICY "Users can create teams" + ON public.teams FOR INSERT WITH CHECK (auth.uid() = created_by); +CREATE POLICY "Team owners can update teams" + ON public.teams FOR UPDATE USING ( + EXISTS (SELECT 1 FROM public.team_members WHERE team_id = teams.id AND user_id = auth.uid() AND role = 'owner') + ); + +-- Team Members +CREATE POLICY "Team members can view members" + ON public.team_members FOR SELECT USING ( + EXISTS (SELECT 1 FROM public.team_members tm WHERE tm.team_id = team_members.team_id AND tm.user_id = auth.uid()) + ); +CREATE POLICY "Team admins can manage members" + ON public.team_members FOR ALL USING ( + EXISTS (SELECT 1 FROM public.team_members tm WHERE tm.team_id = team_members.team_id AND tm.user_id = auth.uid() AND tm.role IN ('owner', 'admin')) + ); + +-- Helper: is user a meeting member? +-- Used as sub-expression in policies below. +-- A user has access if they are: host, team member, or invited participant. + +-- Meetings +CREATE POLICY "Users can view accessible meetings" + ON public.meetings FOR SELECT USING ( + auth.uid() = user_id + OR EXISTS (SELECT 1 FROM public.team_members WHERE team_id = meetings.team_id AND user_id = auth.uid()) + OR EXISTS (SELECT 1 FROM public.meeting_participants WHERE meeting_id = meetings.id AND user_id = auth.uid()) + ); +CREATE POLICY "Users can create meetings" + ON public.meetings FOR INSERT WITH CHECK (auth.uid() = user_id); +CREATE POLICY "Hosts can update meetings" + ON public.meetings FOR UPDATE USING (auth.uid() = user_id); +CREATE POLICY "Hosts can delete meetings" + ON public.meetings FOR DELETE USING (auth.uid() = user_id); + +-- Meeting Participants +CREATE POLICY "Participants can view other participants" + ON public.meeting_participants FOR SELECT USING ( + EXISTS (SELECT 1 FROM public.meetings WHERE id = meeting_participants.meeting_id AND ( + user_id = auth.uid() + OR EXISTS (SELECT 1 FROM public.meeting_participants mp WHERE mp.meeting_id = meeting_participants.meeting_id AND mp.user_id = auth.uid()) + )) + ); +CREATE POLICY "Hosts can manage participants" + ON public.meeting_participants FOR ALL USING ( + EXISTS (SELECT 1 FROM public.meetings WHERE id = meeting_participants.meeting_id AND user_id = auth.uid()) + ); + +-- Processing Jobs +CREATE POLICY "Meeting members can view jobs" + ON public.processing_jobs FOR SELECT USING ( + EXISTS (SELECT 1 FROM public.meetings WHERE id = processing_jobs.meeting_id AND ( + user_id = auth.uid() + OR EXISTS (SELECT 1 FROM public.team_members WHERE team_id = meetings.team_id AND user_id = auth.uid()) + )) + ); + +-- Media Files +CREATE POLICY "Meeting members can view media" + ON public.media_files FOR SELECT USING ( + EXISTS (SELECT 1 FROM public.meetings WHERE id = media_files.meeting_id AND ( + user_id = auth.uid() + OR EXISTS (SELECT 1 FROM public.team_members WHERE team_id = meetings.team_id AND user_id = auth.uid()) + )) + ); +CREATE POLICY "Meeting members can upload media" + ON public.media_files FOR INSERT WITH CHECK ( + EXISTS (SELECT 1 FROM public.meetings WHERE id = media_files.meeting_id AND ( + user_id = auth.uid() + OR EXISTS (SELECT 1 FROM public.team_members WHERE team_id = meetings.team_id AND user_id = auth.uid()) + )) + ); + +-- Transcripts +CREATE POLICY "Meeting members can view transcripts" + ON public.transcripts FOR SELECT USING ( + EXISTS (SELECT 1 FROM public.meetings WHERE id = transcripts.meeting_id AND ( + user_id = auth.uid() + OR EXISTS (SELECT 1 FROM public.team_members WHERE team_id = meetings.team_id AND user_id = auth.uid()) + )) + ); + +-- Transcript Segments +CREATE POLICY "Meeting members can view segments" + ON public.transcript_segments FOR SELECT USING ( + EXISTS ( + SELECT 1 FROM public.transcripts t + JOIN public.meetings m ON t.meeting_id = m.id + WHERE t.id = transcript_segments.transcript_id AND ( + m.user_id = auth.uid() + OR EXISTS (SELECT 1 FROM public.team_members WHERE team_id = m.team_id AND user_id = auth.uid()) + ) + ) + ); + +-- Live Sessions +CREATE POLICY "Meeting members can view live sessions" + ON public.live_sessions FOR SELECT USING ( + EXISTS (SELECT 1 FROM public.meetings WHERE id = live_sessions.meeting_id AND ( + user_id = auth.uid() + OR EXISTS (SELECT 1 FROM public.team_members WHERE team_id = meetings.team_id AND user_id = auth.uid()) + )) + ); + +-- AI Outputs +CREATE POLICY "Meeting members can view AI outputs" + ON public.ai_outputs FOR SELECT USING ( + EXISTS (SELECT 1 FROM public.meetings WHERE id = ai_outputs.meeting_id AND ( + user_id = auth.uid() + OR EXISTS (SELECT 1 FROM public.team_members WHERE team_id = meetings.team_id AND user_id = auth.uid()) + )) + ); + +-- Action Items policies moved to end + +-- Meeting Notes +CREATE POLICY "Meeting members can view notes" + ON public.meeting_notes FOR SELECT USING ( + EXISTS (SELECT 1 FROM public.meetings WHERE id = meeting_notes.meeting_id AND ( + user_id = auth.uid() + OR EXISTS (SELECT 1 FROM public.team_members WHERE team_id = meetings.team_id AND user_id = auth.uid()) + )) + ); +CREATE POLICY "Hosts can manage notes" + ON public.meeting_notes FOR ALL USING ( + EXISTS (SELECT 1 FROM public.meetings WHERE id = meeting_notes.meeting_id AND user_id = auth.uid()) + ); + +-- Preferences & Notifications +CREATE POLICY "Users manage own preferences" + ON public.preferences FOR ALL USING (auth.uid() = user_id); +CREATE POLICY "Users manage own notifications" + ON public.notifications FOR ALL USING (auth.uid() = user_id); + + +-- ============================================= +-- UPDATED ACTION ITEMS & SUMMARIES +-- ============================================= + +-- Create Action Items table +CREATE TABLE IF NOT EXISTS public.action_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + meeting_id UUID NOT NULL REFERENCES public.meetings(id) ON DELETE CASCADE, + transcript_id UUID REFERENCES public.transcripts(id) ON DELETE CASCADE, + task TEXT NOT NULL, + assignee_name TEXT, + assignee_id UUID REFERENCES public.profiles(id) ON DELETE SET NULL, + status TEXT DEFAULT 'open', + due_date TIMESTAMPTZ, + segment_ref UUID REFERENCES public.transcript_segments(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Enable RLS +ALTER TABLE public.action_items ENABLE ROW LEVEL SECURITY; + +-- RLS Policies +-- Users can see action items if they have access to the meeting (assuming meeting RLS allows access) +CREATE POLICY "Users can view action items for their meetings" + ON public.action_items FOR SELECT + USING ( + meeting_id IN ( + SELECT id FROM public.meetings WHERE user_id = auth.uid() + ) + ); + +-- Users can create/update action items for their meetings +CREATE POLICY "Users can manage action items for their meetings" + ON public.action_items FOR ALL + USING ( + meeting_id IN ( + SELECT id FROM public.meetings WHERE user_id = auth.uid() + ) + ); + +-- Create meeting summaries view for the dashboard +CREATE OR REPLACE VIEW public.meeting_summaries AS +SELECT + m.*, + t.duration_secs, + t.speaker_count, + t.status as transcript_status, + ( + SELECT result->>'executive_summary' + FROM public.ai_outputs + WHERE meeting_id = m.id AND output_type = 'summary' + ORDER BY created_at DESC + LIMIT 1 + ) as summary_snippet, + ( + SELECT COUNT(*) + FROM public.action_items + WHERE meeting_id = m.id AND status = 'open' + ) as open_action_items +FROM public.meetings m +LEFT JOIN public.transcripts t ON t.meeting_id = m.id; + + +-- ============================================= +-- DASHBOARD STATS & VIEWS +-- ============================================= + +-- Add the dashboard stats RPC function + +CREATE OR REPLACE FUNCTION public.get_dashboard_stats(p_user_id UUID) +RETURNS JSON AS $$ + SELECT json_build_object( + 'total_meetings', COUNT(DISTINCT m.id), + 'hours_transcribed', COALESCE(SUM(t.duration_secs) / 3600.0, 0), + 'open_action_items', COUNT(DISTINCT ai.id) FILTER (WHERE ai.status = 'open'), + 'people_met', COUNT(DISTINCT ts.speaker_email) + ) + FROM public.meetings m + LEFT JOIN public.transcripts t ON t.meeting_id = m.id + LEFT JOIN public.action_items ai ON ai.meeting_id = m.id + LEFT JOIN public.transcript_segments ts ON ts.transcript_id = t.id + WHERE m.user_id = p_user_id; +$$ LANGUAGE SQL STABLE; + +-- View to easily query action items by meeting owner +CREATE OR REPLACE VIEW public.user_action_items_view AS +SELECT ai.*, m.user_id as meeting_owner_id +FROM public.action_items ai +JOIN public.meetings m ON m.id = ai.meeting_id; diff --git a/react/features/dashboard/components/MeetingDetailPage.tsx b/react/features/dashboard/components/MeetingDetailPage.tsx index cedf551da..6b697c898 100644 --- a/react/features/dashboard/components/MeetingDetailPage.tsx +++ b/react/features/dashboard/components/MeetingDetailPage.tsx @@ -74,13 +74,20 @@ const MeetingDetailPage: React.FC = ({ meetingId, onBack }) => { } // Extract data from insights - const summaryOutput = insights.find(i => i.output_type === 'summary'); - const topicsOutput = insights.find(i => i.output_type === 'key_topics'); - const actionItemsOutput = insights.find(i => i.output_type === 'action_items'); - - const summary = summaryOutput?.result?.summary || meeting.summary || "No executive summary available for this meeting yet."; - const topics = topicsOutput?.result?.topics || []; - const actionItems = actionItemsOutput?.result?.items || []; + // The backend saves all AI results (summary, topics, action items) into a single 'summary' output record + const combinedOutput = insights.find(i => i.output_type === 'summary'); + + let summary = combinedOutput?.result?.executive_summary || meeting.summary; + if (!summary || summary === "") { + // Fallback to reconstruct from transcript if available + if (transcript?.segments?.length > 0) { + summary = transcript.segments.slice(0, 3).map((s: any) => s.text).join(' ') + "..."; + } else { + summary = "No executive summary available for this meeting yet."; + } + } + const topics = combinedOutput?.result?.topics || []; + const actionItems = combinedOutput?.result?.action_items || []; const participants = transcript?.participants || []; const filteredTranscript = transcript?.segments?.filter((seg: any) => @@ -167,7 +174,7 @@ const MeetingDetailPage: React.FC = ({ meetingId, onBack }) => { {topics.length > 0 ? topics.map((topic: string, i: number) => (
  • - {topic} + {typeof topic === 'string' ? topic : (topic as any).title || "Topic"}
  • )) : (
  • Topics are being extracted...
  • @@ -216,7 +223,7 @@ const MeetingDetailPage: React.FC = ({ meetingId, onBack }) => { {filteredTranscript.length > 0 ? filteredTranscript.map((line: any, i: number) => (
    - {new Date(line.start_time * 1000).toISOString().substr(14, 5)} + {new Date((line.start_secs || 0) * 1000).toISOString().substr(14, 5)}
    @@ -252,9 +259,9 @@ const MeetingDetailPage: React.FC = ({ meetingId, onBack }) => { {item.assignee || 'Assigned to team'}
    - {item.due_date && ( - Due: {item.due_date} - )} + {item.due_date || item.deadline ? ( + Due: {item.due_date || item.deadline} + ) : null}
    @@ -272,8 +279,8 @@ const MeetingDetailPage: React.FC = ({ meetingId, onBack }) => { {topics.map((topic: string, i: number) => (
    0{i+1}
    -

    {topic}

    -

    AI extracted topic with detailed context from the meeting discussion.

    +

    {typeof topic === 'string' ? topic : (topic as any).title}

    +

    {typeof topic === 'string' ? "AI extracted topic with detailed context." : (topic as any).summary}

    ))} diff --git a/react/features/transcription/middleware.ts b/react/features/transcription/middleware.ts index d662f0ecf..d366229c8 100644 --- a/react/features/transcription/middleware.ts +++ b/react/features/transcription/middleware.ts @@ -1,6 +1,7 @@ import MiddlewareRegistry from '../base/redux/MiddlewareRegistry'; import { getCurrentConference } from '../base/conference/functions'; import { getLocalParticipant } from '../base/participants/functions'; +import { supabase } from '../supabase-auth/client'; import { START_TRANSCRIPTION, STOP_TRANSCRIPTION, @@ -67,9 +68,15 @@ async function startTranscriptionPipeline(store: any) { try { // 1. Obtain session + Deepgram token from Go backend + const { data: { session } } = await supabase.auth.getSession(); + const headers: Record = { 'Content-Type': 'application/json' }; + if (session) { + headers['Authorization'] = `Bearer ${session.access_token}`; + } + const res = await fetch(`/api/v1/meetings/${meetingId}/transcription/start`, { method: 'POST', - headers: { 'Content-Type': 'application/json' } + headers }); if (!res.ok) { @@ -124,9 +131,20 @@ function stopTranscriptionPipeline(store: any) { const conference = getCurrentConference(state); if (conference) { const meetingId = conference.getName(); - fetch(`/api/v1/meetings/${meetingId}/transcription/end`, { - method: 'POST', - }).catch(err => console.error('[Minutely Transcription] Failed to end session:', err)); + + // Notify backend to close live session + trigger AI pipeline + (async () => { + const { data: { session } } = await supabase.auth.getSession(); + const headers: Record = {}; + if (session) { + headers['Authorization'] = `Bearer ${session.access_token}`; + } + + fetch(`/api/v1/meetings/${meetingId}/transcription/end`, { + method: 'POST', + headers + }).catch(err => console.error('[Minutely Transcription] Failed to end session:', err)); + })(); } store.dispatch(transcriptionStopped());