diff --git a/.env.example b/.env.example deleted file mode 100644 index 69a0c3e..0000000 --- a/.env.example +++ /dev/null @@ -1,6 +0,0 @@ -PUBLIC_SUPABASE_URL=https://your-project.supabase.co -PUBLIC_SUPABASE_ANON_KEY=your-anon-key - -# Google Calendar Integration (optional) -VITE_GOOGLE_CLIENT_ID=your-google-client-id -VITE_GOOGLE_CLIENT_SECRET=your-google-client-secret diff --git a/src/lib/api/google-calendar.ts b/src/lib/api/google-calendar.ts index 34044d4..8415e29 100644 --- a/src/lib/api/google-calendar.ts +++ b/src/lib/api/google-calendar.ts @@ -1,264 +1,100 @@ -import type { SupabaseClient } from '@supabase/supabase-js'; -import type { Database } from '$lib/supabase/types'; +// Google Calendar functions for PUBLIC calendars (no OAuth needed) +// Just needs a Google API key and a public calendar ID -const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID; -const GOOGLE_CLIENT_SECRET = import.meta.env.VITE_GOOGLE_CLIENT_SECRET; - -interface GoogleTokens { - access_token: string; - refresh_token: string; - expires_in: number; -} - -interface GoogleCalendarEvent { +export interface GoogleCalendarEvent { id: string; summary: string; description?: string; start: { dateTime?: string; date?: string }; end: { dateTime?: string; date?: string }; colorId?: string; + htmlLink?: string; } -export function getGoogleAuthUrl(redirectUri: string, state: string): string { - const params = new URLSearchParams({ - client_id: GOOGLE_CLIENT_ID, - redirect_uri: redirectUri, - response_type: 'code', - scope: 'https://www.googleapis.com/auth/calendar https://www.googleapis.com/auth/calendar.events', - access_type: 'offline', - prompt: 'consent', - state - }); - return `https://accounts.google.com/o/oauth2/v2/auth?${params}`; -} - -export async function exchangeCodeForTokens(code: string, redirectUri: string): Promise { - const response = await fetch('https://oauth2.googleapis.com/token', { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ - code, - client_id: GOOGLE_CLIENT_ID, - client_secret: GOOGLE_CLIENT_SECRET, - redirect_uri: redirectUri, - grant_type: 'authorization_code' - }) - }); - - if (!response.ok) { - throw new Error('Failed to exchange code for tokens'); - } - - return response.json(); -} - -export async function refreshAccessToken(refreshToken: string): Promise { - const response = await fetch('https://oauth2.googleapis.com/token', { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ - refresh_token: refreshToken, - client_id: GOOGLE_CLIENT_ID, - client_secret: GOOGLE_CLIENT_SECRET, - grant_type: 'refresh_token' - }) - }); - - if (!response.ok) { - throw new Error('Failed to refresh access token'); +/** + * Extract calendar ID from various Google Calendar URL formats + * Supports: + * - Direct calendar ID (email format) + * - Shareable URL with cid parameter (base64 encoded) + * - Public URL with calendar ID in path + */ +export function extractCalendarId(input: string): string | null { + if (!input) return null; + + // If it looks like an email/calendar ID already, return it + if (input.includes('@') && !input.includes('http')) { + return input.trim(); } - return response.json(); -} - -export async function getValidAccessToken( - supabase: SupabaseClient, - userId: string -): Promise { - const { data: connection } = await supabase - .from('google_calendar_connections') - .select('*') - .eq('user_id', userId) - .single(); - - if (!connection) return null; - - const expiresAt = new Date(connection.token_expires_at); - const now = new Date(); + try { + const url = new URL(input); + + // Check for cid parameter (base64 encoded calendar ID) + const cid = url.searchParams.get('cid'); + if (cid) { + // Decode base64 to get calendar ID + try { + return atob(cid); + } catch { + return cid; // Maybe it's not encoded + } + } - // Refresh if expires within 5 minutes - if (expiresAt.getTime() - now.getTime() < 5 * 60 * 1000) { - try { - const tokens = await refreshAccessToken(connection.refresh_token); - const newExpiresAt = new Date(Date.now() + tokens.expires_in * 1000); + // Check for src parameter + const src = url.searchParams.get('src'); + if (src) return src; - await supabase - .from('google_calendar_connections') - .update({ - access_token: tokens.access_token, - token_expires_at: newExpiresAt.toISOString(), - updated_at: new Date().toISOString() - }) - .eq('user_id', userId); + // Check path for calendar ID in ical URL format + const icalMatch = input.match(/calendar\/ical\/([^/]+)/); + if (icalMatch) return decodeURIComponent(icalMatch[1]); - return tokens.access_token; - } catch { - return null; + } catch { + // Not a valid URL, maybe it's just a calendar ID + if (input.includes('@')) { + return input.trim(); } } - return connection.access_token; + return null; +} + +/** + * Generate a subscribe URL for a public Google Calendar + */ +export function getCalendarSubscribeUrl(calendarId: string): string { + const encoded = btoa(calendarId); + return `https://calendar.google.com/calendar/u/0?cid=${encoded}`; } -export async function fetchGoogleCalendarEvents( - accessToken: string, - calendarId: string = 'primary', +/** + * Fetch events from a PUBLIC Google Calendar + * Requires: Calendar must be set to "Make available to public" in Google Calendar settings + */ +export async function fetchPublicCalendarEvents( + calendarId: string, + apiKey: string, timeMin: Date, timeMax: Date ): Promise { const params = new URLSearchParams({ + key: apiKey, timeMin: timeMin.toISOString(), timeMax: timeMax.toISOString(), singleEvents: 'true', - orderBy: 'startTime' + orderBy: 'startTime', + maxResults: '250' }); const response = await fetch( - `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events?${params}`, - { - headers: { Authorization: `Bearer ${accessToken}` } - } + `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events?${params}` ); if (!response.ok) { - throw new Error('Failed to fetch Google Calendar events'); + const error = await response.text(); + console.error('Google Calendar API error:', error); + throw new Error('Failed to fetch calendar events. Make sure the calendar is set to public.'); } const data = await response.json(); return data.items ?? []; } - -export async function createGoogleCalendarEvent( - accessToken: string, - calendarId: string = 'primary', - event: { - title: string; - description?: string; - startTime: string; - endTime: string; - allDay?: boolean; - } -): Promise { - const body: Record = { - summary: event.title, - description: event.description - }; - - if (event.allDay) { - const startDate = event.startTime.split('T')[0]; - const endDate = event.endTime.split('T')[0]; - body.start = { date: startDate }; - body.end = { date: endDate }; - } else { - body.start = { dateTime: event.startTime, timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone }; - body.end = { dateTime: event.endTime, timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone }; - } - - const response = await fetch( - `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events`, - { - method: 'POST', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify(body) - } - ); - - if (!response.ok) { - throw new Error('Failed to create Google Calendar event'); - } - - return response.json(); -} - -export async function updateGoogleCalendarEvent( - accessToken: string, - calendarId: string = 'primary', - eventId: string, - event: { - title: string; - description?: string; - startTime: string; - endTime: string; - allDay?: boolean; - } -): Promise { - const body: Record = { - summary: event.title, - description: event.description - }; - - if (event.allDay) { - const startDate = event.startTime.split('T')[0]; - const endDate = event.endTime.split('T')[0]; - body.start = { date: startDate }; - body.end = { date: endDate }; - } else { - body.start = { dateTime: event.startTime, timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone }; - body.end = { dateTime: event.endTime, timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone }; - } - - const response = await fetch( - `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events/${eventId}`, - { - method: 'PUT', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify(body) - } - ); - - if (!response.ok) { - throw new Error('Failed to update Google Calendar event'); - } - - return response.json(); -} - -export async function deleteGoogleCalendarEvent( - accessToken: string, - calendarId: string = 'primary', - eventId: string -): Promise { - const response = await fetch( - `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events/${eventId}`, - { - method: 'DELETE', - headers: { Authorization: `Bearer ${accessToken}` } - } - ); - - if (!response.ok && response.status !== 404) { - throw new Error('Failed to delete Google Calendar event'); - } -} - -export async function listGoogleCalendars(accessToken: string): Promise<{ id: string; summary: string }[]> { - const response = await fetch('https://www.googleapis.com/calendar/v3/users/me/calendarList', { - headers: { Authorization: `Bearer ${accessToken}` } - }); - - if (!response.ok) { - throw new Error('Failed to list calendars'); - } - - const data = await response.json(); - return (data.items ?? []).map((cal: { id: string; summary: string }) => ({ - id: cal.id, - summary: cal.summary - })); -} diff --git a/src/routes/[orgSlug]/+layout.server.ts b/src/routes/[orgSlug]/+layout.server.ts index 5b4a0c5..e2d4e3a 100644 --- a/src/routes/[orgSlug]/+layout.server.ts +++ b/src/routes/[orgSlug]/+layout.server.ts @@ -31,6 +31,7 @@ export const load: LayoutServerLoad = async ({ params, locals }) => { return { org, - role: membership.role + role: membership.role, + userRole: membership.role }; }; diff --git a/src/routes/[orgSlug]/+layout.svelte b/src/routes/[orgSlug]/+layout.svelte index 223eddb..38368a1 100644 --- a/src/routes/[orgSlug]/+layout.svelte +++ b/src/routes/[orgSlug]/+layout.svelte @@ -6,13 +6,18 @@ data: { org: { id: string; name: string; slug: string }; role: string; + userRole: string; }; children: Snippet; } let { data, children }: Props = $props(); - const navItems = [ + const isAdmin = $derived( + data.userRole === "owner" || data.userRole === "admin", + ); + + const navItems = $derived([ { href: `/${data.org.slug}`, label: "Overview", icon: "home" }, { href: `/${data.org.slug}/documents`, @@ -25,12 +30,17 @@ label: "Calendar", icon: "calendar", }, - { - href: `/${data.org.slug}/settings`, - label: "Settings", - icon: "settings", - }, - ]; + // Only show settings for admins + ...(isAdmin + ? [ + { + href: `/${data.org.slug}/settings`, + label: "Settings", + icon: "settings", + }, + ] + : []), + ]); function isActive(href: string): boolean { return $page.url.pathname === href; diff --git a/src/routes/[orgSlug]/calendar/+page.server.ts b/src/routes/[orgSlug]/calendar/+page.server.ts index 765ac5e..8b805b4 100644 --- a/src/routes/[orgSlug]/calendar/+page.server.ts +++ b/src/routes/[orgSlug]/calendar/+page.server.ts @@ -1,7 +1,7 @@ import type { PageServerLoad } from './$types'; export const load: PageServerLoad = async ({ parent, locals }) => { - const { org } = await parent(); + const { org, userRole } = await parent(); const { supabase } = locals; // Fetch events for current month ± 1 month @@ -18,6 +18,7 @@ export const load: PageServerLoad = async ({ parent, locals }) => { .order('start_time'); return { - events: events ?? [] + events: events ?? [], + userRole }; }; diff --git a/src/routes/[orgSlug]/calendar/+page.svelte b/src/routes/[orgSlug]/calendar/+page.svelte index 02462c4..f9e5421 100644 --- a/src/routes/[orgSlug]/calendar/+page.svelte +++ b/src/routes/[orgSlug]/calendar/+page.svelte @@ -1,8 +1,12 @@
-

Calendar

+
+

Calendar

+ {#if isOrgCalendarConnected} +
+ + + + + {orgCalendarName ?? "Google Calendar"} + {#if isLoadingGoogle} + + {/if} + + +
+ {/if} +
+ + + +
+ + + {#if activeTab === "general"} +
+ +
+

+ Organization Details +

+ +
+
+ + +
+
+ +
+ yoursite.com/ + +
+

+ Changing the slug will update all URLs for this + organization. +

+
+
+ +
+
+
+
+ + {#if isOwner} + +
+

+ Danger Zone +

+

+ Permanently delete this organization and all its + data. +

+
+ +
+
+
+ {/if} +
+ {/if} + + + {#if activeTab === "members"} +
+
+

+ Team Members ({members.length}) +

+ +
+ + + {#if invites.length > 0} + +
+

+ Pending Invites +

+
+ {#each invites as invite} +
+
+

{invite.email}

+

+ Invited as {invite.role} • Expires {new Date( + invite.expires_at, + ).toLocaleDateString()} +

+
+
+ + +
+
+ {/each} +
+
+
+ {/if} + + + +
+ {#each members as member} +
+
+
+ {(member.profiles.full_name || + member.profiles.email || + "?")[0].toUpperCase()} +
+
+

+ {member.profiles.full_name || "No name"} +

+

+ {member.profiles.email} +

+
+
+
+ {member.role} + {#if member.user_id !== data.user?.id && member.role !== "owner"} + + {/if} +
+
+ {/each} +
+
+
+ {/if} + + + {#if activeTab === "roles"} +
+
+
+

Roles

+

+ Create custom roles with specific permissions. +

+
+ +
+ +
+ {#each roles as role} + +
+
+
+
+ {role.name} + {#if role.is_system} + System + {/if} + {#if role.is_default} + Default + {/if} +
+
+ {#if !role.is_system || role.name !== "Owner"} + + {/if} + {#if !role.is_system} + + {/if} +
+
+
+ {#if role.permissions.includes("*")} + All Permissions + {:else} + {#each role.permissions.slice(0, 6) as perm} + {perm} + {/each} + {#if role.permissions.length > 6} + +{role.permissions.length - 6} more + {/if} + {/if} +
+
+
+ {/each} +
+
+ {/if} + + + {#if activeTab === "integrations"} +
+ +
+
+
+ + + + + + +
+
+

+ Google Calendar +

+

+ Share a Google Calendar with all organization + members. +

+ + {#if orgCalendar} +
+
+
+

+ Connected +

+

+ {orgCalendar.calendar_name || + "Google Calendar"} +

+

+ {orgCalendar.calendar_id + .length > 40 + ? orgCalendar.calendar_id.slice( + 0, + 40, + ) + "..." + : orgCalendar.calendar_id} +

+
+ +
+
+ {:else} +
+ +
+ {/if} +
+
+
+
+ + +
+
+
+ + + +
+
+

+ Discord +

+

+ Get notifications in your Discord server. +

+

+ Coming soon +

+
+
+
+
+ + +
+
+
+ + + +
+
+

+ Slack +

+

+ Get notifications in your Slack workspace. +

+

+ Coming soon +

+
+
+
+
+
+ {/if} + + + + (showInviteModal = false)} + title="Invite Member" +> +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + + (showMemberModal = false)} + title="Edit Member" +> + {#if selectedMember} +
+
+
+ {(selectedMember.profiles.full_name || + selectedMember.profiles.email || + "?")[0].toUpperCase()} +
+
+

+ {selectedMember.profiles.full_name || "No name"} +

+

+ {selectedMember.profiles.email} +

+
+
+
+ + +
+
+ +
+ + +
+
+
+ {/if} +
+ + + (showRoleModal = false)} + title={editingRole ? "Edit Role" : "Create Role"} +> +
+
+ + +
+
+ +
+ {#each roleColors as color} + + {/each} +
+
+
+ +
+ {#each permissionGroups as group} +
+

+ {group.name} +

+
+ {#each group.permissions as perm} + + {/each} +
+
+ {/each} +
+
+
+ + +
+
+
+ + + (showConnectModal = false)} + title="Connect Public Google Calendar" +> +
+

+ Paste your Google Calendar's shareable link or calendar ID. The + calendar must be set to public in Google Calendar settings. +

+ +
+

+ How to get your calendar link: +

+
    +
  1. Open Google Calendar
  2. +
  3. Click the 3 dots next to your calendar → Settings
  4. +
  5. + Under "Access permissions", check "Make available to public" +
  6. +
  7. + Scroll to "Integrate calendar" and copy the Calendar ID or + Public URL +
  8. +
+
+ + + + {#if calendarError} +

{calendarError}

+ {/if} + +
+ + +
+
+
diff --git a/src/routes/api/google-calendar/callback/+server.ts b/src/routes/api/google-calendar/callback/+server.ts deleted file mode 100644 index 2ae92eb..0000000 --- a/src/routes/api/google-calendar/callback/+server.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { redirect } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; -import { exchangeCodeForTokens } from '$lib/api/google-calendar'; - -export const GET: RequestHandler = async ({ url, locals }) => { - const code = url.searchParams.get('code'); - const stateParam = url.searchParams.get('state'); - const error = url.searchParams.get('error'); - - if (error || !code || !stateParam) { - redirect(303, '/?error=google_auth_failed'); - } - - let state: { orgSlug: string; userId: string }; - try { - state = JSON.parse(decodeURIComponent(stateParam)); - } catch { - redirect(303, '/?error=invalid_state'); - } - - const redirectUri = `${url.origin}/api/google-calendar/callback`; - - try { - const tokens = await exchangeCodeForTokens(code, redirectUri); - const expiresAt = new Date(Date.now() + tokens.expires_in * 1000); - - // Store tokens in database - await locals.supabase - .from('google_calendar_connections') - .upsert({ - user_id: state.userId, - access_token: tokens.access_token, - refresh_token: tokens.refresh_token, - token_expires_at: expiresAt.toISOString(), - updated_at: new Date().toISOString() - }, { onConflict: 'user_id' }); - - redirect(303, `/${state.orgSlug}/calendar?connected=true`); - } catch (err) { - console.error('Google Calendar OAuth error:', err); - redirect(303, `/${state.orgSlug}/calendar?error=token_exchange_failed`); - } -}; diff --git a/src/routes/api/google-calendar/connect/+server.ts b/src/routes/api/google-calendar/connect/+server.ts deleted file mode 100644 index 76294ce..0000000 --- a/src/routes/api/google-calendar/connect/+server.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { redirect } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; -import { getGoogleAuthUrl } from '$lib/api/google-calendar'; - -export const GET: RequestHandler = async ({ url, locals }) => { - const { session } = await locals.safeGetSession(); - - if (!session) { - redirect(303, '/login'); - } - - const orgSlug = url.searchParams.get('org'); - if (!orgSlug) { - redirect(303, '/'); - } - - const redirectUri = `${url.origin}/api/google-calendar/callback`; - const state = JSON.stringify({ orgSlug, userId: session.user.id }); - const authUrl = getGoogleAuthUrl(redirectUri, encodeURIComponent(state)); - - redirect(303, authUrl); -}; diff --git a/src/routes/api/google-calendar/events/+server.ts b/src/routes/api/google-calendar/events/+server.ts new file mode 100644 index 0000000..22078f4 --- /dev/null +++ b/src/routes/api/google-calendar/events/+server.ts @@ -0,0 +1,60 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { GOOGLE_API_KEY } from '$env/static/private'; +import { fetchPublicCalendarEvents } from '$lib/api/google-calendar'; + +// Fetch events from a public Google Calendar +export const GET: RequestHandler = async ({ url, locals }) => { + const orgId = url.searchParams.get('org_id'); + + if (!orgId) { + return json({ error: 'org_id required' }, { status: 400 }); + } + + if (!GOOGLE_API_KEY) { + return json({ error: 'Google API key not configured' }, { status: 500 }); + } + + try { + // Get the org's calendar ID from database + const { data: orgCal, error: dbError } = await locals.supabase + .from('org_google_calendars') + .select('calendar_id, calendar_name') + .eq('org_id', orgId) + .single(); + + if (dbError) { + console.error('DB error fetching calendar:', dbError); + return json({ error: 'No calendar connected', events: [] }, { status: 404 }); + } + + if (!orgCal) { + return json({ error: 'No calendar connected', events: [] }, { status: 404 }); + } + + console.log('Fetching events for calendar:', (orgCal as any).calendar_id); + + // Fetch events for the next 3 months + const now = new Date(); + const timeMin = new Date(now.getFullYear(), now.getMonth() - 1, 1); + const timeMax = new Date(now.getFullYear(), now.getMonth() + 3, 0); + + const events = await fetchPublicCalendarEvents( + (orgCal as any).calendar_id, + GOOGLE_API_KEY, + timeMin, + timeMax + ); + + console.log('Fetched', events.length, 'events'); + + return json({ + events, + calendar_id: (orgCal as any).calendar_id, + calendar_name: (orgCal as any).calendar_name + }); + } catch (err) { + console.error('Failed to fetch calendar events:', err); + return json({ error: 'Failed to fetch events. Make sure the calendar is public.', events: [] }, { status: 500 }); + } +}; diff --git a/supabase/migrations/004_org_google_calendar.sql b/supabase/migrations/004_org_google_calendar.sql new file mode 100644 index 0000000..1b67e96 --- /dev/null +++ b/supabase/migrations/004_org_google_calendar.sql @@ -0,0 +1,37 @@ +-- Organization-level Google Calendar (shared across all members) + +CREATE TABLE IF NOT EXISTS org_google_calendars ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + org_id UUID REFERENCES organizations(id) ON DELETE CASCADE UNIQUE, + calendar_id TEXT NOT NULL, -- Google Calendar ID (e.g., "abc123@group.calendar.google.com") + calendar_name TEXT, + connected_by UUID REFERENCES auth.users(id), + access_token TEXT NOT NULL, + refresh_token TEXT NOT NULL, + token_expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() +); + +-- Index +CREATE INDEX IF NOT EXISTS idx_org_google_calendars_org ON org_google_calendars(org_id); + +-- RLS +ALTER TABLE org_google_calendars ENABLE ROW LEVEL SECURITY; + +-- All org members can view the org calendar connection +CREATE POLICY "Org members can view org calendar" ON org_google_calendars + FOR SELECT USING (EXISTS ( + SELECT 1 FROM org_members om + WHERE om.org_id = org_google_calendars.org_id + AND om.user_id = auth.uid() + )); + +-- Only admins/owners can manage org calendar +CREATE POLICY "Admins can manage org calendar" ON org_google_calendars + FOR ALL USING (EXISTS ( + SELECT 1 FROM org_members om + WHERE om.org_id = org_google_calendars.org_id + AND om.user_id = auth.uid() + AND om.role IN ('owner', 'admin') + )); diff --git a/supabase/migrations/005_roles_and_invites.sql b/supabase/migrations/005_roles_and_invites.sql new file mode 100644 index 0000000..9ae5467 --- /dev/null +++ b/supabase/migrations/005_roles_and_invites.sql @@ -0,0 +1,177 @@ +-- Custom Roles and Invite System + +-- Permission types (similar to Google Drive) +-- viewer: Can view content +-- commenter: Can view and comment +-- editor: Can view, comment, and edit content +-- admin: Can manage members and settings +-- owner: Full control + +-- Custom roles table (allows vanity roles with custom permissions) +CREATE TABLE IF NOT EXISTS org_roles ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + org_id UUID REFERENCES organizations(id) ON DELETE CASCADE, + name TEXT NOT NULL, + color TEXT DEFAULT '#6366f1', -- For vanity display + permissions JSONB NOT NULL DEFAULT '[]'::jsonb, + is_default BOOLEAN DEFAULT false, -- If this is a default role for new members + is_system BOOLEAN DEFAULT false, -- System roles can't be deleted + position INTEGER DEFAULT 0, -- For ordering + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now(), + UNIQUE(org_id, name) +); + +-- Available permissions +COMMENT ON COLUMN org_roles.permissions IS 'Array of permission strings: + documents.view, documents.create, documents.edit, documents.delete, + kanban.view, kanban.create, kanban.edit, kanban.delete, + calendar.view, calendar.create, calendar.edit, calendar.delete, + members.view, members.invite, members.manage, members.remove, + roles.view, roles.create, roles.edit, roles.delete, + settings.view, settings.edit, + org.delete'; + +-- Update org_members to reference custom roles +ALTER TABLE org_members ADD COLUMN IF NOT EXISTS role_id UUID REFERENCES org_roles(id); + +-- Organization invites +CREATE TABLE IF NOT EXISTS org_invites ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + org_id UUID REFERENCES organizations(id) ON DELETE CASCADE, + email TEXT NOT NULL, + role_id UUID REFERENCES org_roles(id), + role TEXT DEFAULT 'viewer', -- Fallback if no custom role + invited_by UUID REFERENCES auth.users(id), + token TEXT UNIQUE NOT NULL DEFAULT encode(gen_random_bytes(32), 'hex'), + expires_at TIMESTAMPTZ DEFAULT (now() + interval '7 days'), + accepted_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT now(), + UNIQUE(org_id, email) +); + +-- Indexes +CREATE INDEX IF NOT EXISTS idx_org_roles_org ON org_roles(org_id); +CREATE INDEX IF NOT EXISTS idx_org_invites_org ON org_invites(org_id); +CREATE INDEX IF NOT EXISTS idx_org_invites_token ON org_invites(token); +CREATE INDEX IF NOT EXISTS idx_org_invites_email ON org_invites(email); + +-- RLS for org_roles +ALTER TABLE org_roles ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Org members can view roles" ON org_roles FOR SELECT + USING (EXISTS ( + SELECT 1 FROM org_members om + WHERE om.org_id = org_roles.org_id AND om.user_id = auth.uid() + )); + +CREATE POLICY "Admins can manage roles" ON org_roles FOR ALL + USING (EXISTS ( + SELECT 1 FROM org_members om + WHERE om.org_id = org_roles.org_id + AND om.user_id = auth.uid() + AND om.role IN ('owner', 'admin') + )); + +-- RLS for org_invites +ALTER TABLE org_invites ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Org members can view invites" ON org_invites FOR SELECT + USING (EXISTS ( + SELECT 1 FROM org_members om + WHERE om.org_id = org_invites.org_id AND om.user_id = auth.uid() + )); + +CREATE POLICY "Admins can manage invites" ON org_invites FOR ALL + USING (EXISTS ( + SELECT 1 FROM org_members om + WHERE om.org_id = org_invites.org_id + AND om.user_id = auth.uid() + AND om.role IN ('owner', 'admin') + )); + +-- Anyone can view invite by token (for accepting) +CREATE POLICY "Anyone can view invite by token" ON org_invites FOR SELECT + USING (true); + +-- Function to create default roles for new org +CREATE OR REPLACE FUNCTION create_default_org_roles() +RETURNS TRIGGER AS $$ +BEGIN + -- Owner role (full permissions) + INSERT INTO org_roles (org_id, name, color, permissions, is_system, position) VALUES + (NEW.id, 'Owner', '#ef4444', '["*"]'::jsonb, true, 0); + + -- Admin role + INSERT INTO org_roles (org_id, name, color, permissions, is_system, position) VALUES + (NEW.id, 'Admin', '#f59e0b', '[ + "documents.view", "documents.create", "documents.edit", "documents.delete", + "kanban.view", "kanban.create", "kanban.edit", "kanban.delete", + "calendar.view", "calendar.create", "calendar.edit", "calendar.delete", + "members.view", "members.invite", "members.manage", + "roles.view", "settings.view", "settings.edit" + ]'::jsonb, true, 1); + + -- Editor role + INSERT INTO org_roles (org_id, name, color, permissions, is_system, is_default, position) VALUES + (NEW.id, 'Editor', '#10b981', '[ + "documents.view", "documents.create", "documents.edit", + "kanban.view", "kanban.create", "kanban.edit", + "calendar.view", "calendar.create", "calendar.edit", + "members.view" + ]'::jsonb, true, true, 2); + + -- Commenter role + INSERT INTO org_roles (org_id, name, color, permissions, is_system, position) VALUES + (NEW.id, 'Commenter', '#6366f1', '[ + "documents.view", + "kanban.view", + "calendar.view", + "members.view" + ]'::jsonb, true, 3); + + -- Viewer role + INSERT INTO org_roles (org_id, name, color, permissions, is_system, position) VALUES + (NEW.id, 'Viewer', '#8b5cf6', '[ + "documents.view", + "kanban.view", + "calendar.view", + "members.view" + ]'::jsonb, true, 4); + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Trigger to create default roles +DROP TRIGGER IF EXISTS on_org_created_create_roles ON organizations; +CREATE TRIGGER on_org_created_create_roles + AFTER INSERT ON organizations + FOR EACH ROW EXECUTE FUNCTION create_default_org_roles(); + +-- Insert default roles for existing organizations +DO $$ +DECLARE + org RECORD; +BEGIN + FOR org IN SELECT id FROM organizations LOOP + -- Only insert if no roles exist + IF NOT EXISTS (SELECT 1 FROM org_roles WHERE org_id = org.id) THEN + -- Owner role + INSERT INTO org_roles (org_id, name, color, permissions, is_system, position) VALUES + (org.id, 'Owner', '#ef4444', '["*"]'::jsonb, true, 0); + -- Admin role + INSERT INTO org_roles (org_id, name, color, permissions, is_system, position) VALUES + (org.id, 'Admin', '#f59e0b', '["documents.view", "documents.create", "documents.edit", "documents.delete", "kanban.view", "kanban.create", "kanban.edit", "kanban.delete", "calendar.view", "calendar.create", "calendar.edit", "calendar.delete", "members.view", "members.invite", "members.manage", "roles.view", "settings.view", "settings.edit"]'::jsonb, true, 1); + -- Editor role + INSERT INTO org_roles (org_id, name, color, permissions, is_system, is_default, position) VALUES + (org.id, 'Editor', '#10b981', '["documents.view", "documents.create", "documents.edit", "kanban.view", "kanban.create", "kanban.edit", "calendar.view", "calendar.create", "calendar.edit", "members.view"]'::jsonb, true, true, 2); + -- Commenter role + INSERT INTO org_roles (org_id, name, color, permissions, is_system, position) VALUES + (org.id, 'Commenter', '#6366f1', '["documents.view", "kanban.view", "calendar.view", "members.view"]'::jsonb, true, 3); + -- Viewer role + INSERT INTO org_roles (org_id, name, color, permissions, is_system, position) VALUES + (org.id, 'Viewer', '#8b5cf6', '["documents.view", "kanban.view", "calendar.view", "members.view"]'::jsonb, true, 4); + END IF; + END LOOP; +END $$; diff --git a/supabase/migrations/006_simplify_google_calendar.sql b/supabase/migrations/006_simplify_google_calendar.sql new file mode 100644 index 0000000..336f526 --- /dev/null +++ b/supabase/migrations/006_simplify_google_calendar.sql @@ -0,0 +1,44 @@ +-- Simplify Google Calendar integration to use public calendars only +-- No OAuth needed - just store calendar ID and fetch with API key + +-- Drop the old OAuth-based table +DROP TABLE IF EXISTS org_google_calendars CASCADE; +DROP TABLE IF EXISTS google_calendar_connections CASCADE; + +-- Create simplified org calendar table +CREATE TABLE IF NOT EXISTS org_google_calendars ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + org_id UUID REFERENCES organizations(id) ON DELETE CASCADE UNIQUE, + calendar_id TEXT NOT NULL, -- The public calendar ID (email format) + calendar_name TEXT, -- Display name + connected_by UUID REFERENCES auth.users(id), + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() +); + +-- RLS policies +ALTER TABLE org_google_calendars ENABLE ROW LEVEL SECURITY; + +-- Members can view org calendar +CREATE POLICY "Members can view org calendar" ON org_google_calendars + FOR SELECT USING ( + EXISTS ( + SELECT 1 FROM org_members + WHERE org_members.org_id = org_google_calendars.org_id + AND org_members.user_id = auth.uid() + ) + ); + +-- Admins/owners can manage org calendar +CREATE POLICY "Admins can manage org calendar" ON org_google_calendars + FOR ALL USING ( + EXISTS ( + SELECT 1 FROM org_members + WHERE org_members.org_id = org_google_calendars.org_id + AND org_members.user_id = auth.uid() + AND org_members.role IN ('admin', 'owner') + ) + ); + +-- Enable realtime +ALTER PUBLICATION supabase_realtime ADD TABLE org_google_calendars;