feat: Phase 1 - Events entity (migration, API, list page, detail layout with module sidebar, overview page)

This commit is contained in:
AlacrisDevs
2026-02-07 10:04:37 +02:00
parent 4f21c89103
commit 556955f349
10 changed files with 1833 additions and 3 deletions

View File

@@ -0,0 +1,48 @@
import { describe, it, expect } from 'vitest';
// Test the slugify logic (extracted inline since it's not exported)
function slugify(text: string): string {
return text
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 60) || 'event';
}
describe('events API - slugify', () => {
it('converts simple name to slug', () => {
expect(slugify('Summer Conference')).toBe('summer-conference');
});
it('handles special characters', () => {
expect(slugify('Music & Arts Festival 2026!')).toBe('music-arts-festival-2026');
});
it('collapses multiple spaces and dashes', () => {
expect(slugify('My Big Event')).toBe('my-big-event');
});
it('trims leading/trailing dashes', () => {
expect(slugify('--hello--')).toBe('hello');
});
it('truncates to 60 characters', () => {
const longName = 'A'.repeat(100);
expect(slugify(longName).length).toBeLessThanOrEqual(60);
});
it('returns "event" for empty string', () => {
expect(slugify('')).toBe('event');
});
it('handles unicode characters', () => {
const result = slugify('Ürituse Korraldamine');
expect(result).toBe('rituse-korraldamine');
});
it('handles numbers', () => {
expect(slugify('Event 2026 Q1')).toBe('event-2026-q1');
});
});

277
src/lib/api/events.ts Normal file
View File

@@ -0,0 +1,277 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '$lib/supabase/types';
import { createLogger } from '$lib/utils/logger';
const log = createLogger('api.events');
export interface Event {
id: string;
org_id: string;
name: string;
slug: string;
description: string | null;
status: 'planning' | 'active' | 'completed' | 'archived';
start_date: string | null;
end_date: string | null;
venue_name: string | null;
venue_address: string | null;
cover_image_url: string | null;
color: string | null;
created_by: string | null;
created_at: string;
updated_at: string;
}
export interface EventMember {
id: string;
event_id: string;
user_id: string;
role: 'lead' | 'manager' | 'member';
assigned_at: string;
}
export interface EventWithCounts extends Event {
member_count: number;
}
function slugify(text: string): string {
return text
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 60) || 'event';
}
export async function fetchEvents(
supabase: SupabaseClient<Database>,
orgId: string,
status?: string
): Promise<EventWithCounts[]> {
let query = supabase
.from('events')
.select('*, event_members(count)')
.eq('org_id', orgId)
.order('start_date', { ascending: true, nullsFirst: false });
if (status && status !== 'all') {
query = query.eq('status', status);
}
const { data, error } = await query;
if (error) {
log.error('fetchEvents failed', { error, data: { orgId } });
throw error;
}
const events: EventWithCounts[] = (data ?? []).map((e: any) => ({
...e,
member_count: e.event_members?.[0]?.count ?? 0,
event_members: undefined,
}));
log.debug('fetchEvents ok', { data: { count: events.length } });
return events;
}
export async function fetchEvent(
supabase: SupabaseClient<Database>,
eventId: string
): Promise<Event | null> {
const { data, error } = await supabase
.from('events')
.select('*')
.eq('id', eventId)
.single();
if (error) {
if (error.code === 'PGRST116') return null;
log.error('fetchEvent failed', { error, data: { eventId } });
throw error;
}
return data as unknown as Event;
}
export async function fetchEventBySlug(
supabase: SupabaseClient<Database>,
orgId: string,
eventSlug: string
): Promise<Event | null> {
const { data, error } = await supabase
.from('events')
.select('*')
.eq('org_id', orgId)
.eq('slug', eventSlug)
.single();
if (error) {
if (error.code === 'PGRST116') return null;
log.error('fetchEventBySlug failed', { error, data: { orgId, eventSlug } });
throw error;
}
return data as unknown as Event;
}
export async function createEvent(
supabase: SupabaseClient<Database>,
orgId: string,
userId: string,
params: {
name: string;
description?: string;
start_date?: string;
end_date?: string;
venue_name?: string;
venue_address?: string;
color?: string;
}
): Promise<Event> {
const baseSlug = slugify(params.name);
// Ensure unique slug within org
const { data: existing } = await supabase
.from('events')
.select('slug')
.eq('org_id', orgId)
.like('slug', `${baseSlug}%`);
let slug = baseSlug;
if (existing && existing.length > 0) {
const existingSlugs = new Set(existing.map((e: any) => e.slug));
if (existingSlugs.has(slug)) {
let i = 2;
while (existingSlugs.has(`${baseSlug}-${i}`)) i++;
slug = `${baseSlug}-${i}`;
}
}
const { data, error } = await supabase
.from('events')
.insert({
org_id: orgId,
name: params.name,
slug,
description: params.description ?? null,
start_date: params.start_date ?? null,
end_date: params.end_date ?? null,
venue_name: params.venue_name ?? null,
venue_address: params.venue_address ?? null,
color: params.color ?? null,
created_by: userId,
})
.select()
.single();
if (error) {
log.error('createEvent failed', { error, data: { orgId, name: params.name } });
throw error;
}
log.info('createEvent ok', { data: { id: data.id, name: params.name, slug } });
return data as unknown as Event;
}
export async function updateEvent(
supabase: SupabaseClient<Database>,
eventId: string,
params: Partial<Pick<Event, 'name' | 'description' | 'status' | 'start_date' | 'end_date' | 'venue_name' | 'venue_address' | 'cover_image_url' | 'color'>>
): Promise<Event> {
const { data, error } = await supabase
.from('events')
.update({ ...params, updated_at: new Date().toISOString() })
.eq('id', eventId)
.select()
.single();
if (error) {
log.error('updateEvent failed', { error, data: { eventId } });
throw error;
}
log.info('updateEvent ok', { data: { id: data.id } });
return data as unknown as Event;
}
export async function deleteEvent(
supabase: SupabaseClient<Database>,
eventId: string
): Promise<void> {
const { error } = await supabase
.from('events')
.delete()
.eq('id', eventId);
if (error) {
log.error('deleteEvent failed', { error, data: { eventId } });
throw error;
}
log.info('deleteEvent ok', { data: { eventId } });
}
export async function fetchEventMembers(
supabase: SupabaseClient<Database>,
eventId: string
): Promise<(EventMember & { profile?: { id: string; email: string; full_name: string | null; avatar_url: string | null } })[]> {
const { data: members, error } = await supabase
.from('event_members')
.select('*')
.eq('event_id', eventId)
.order('assigned_at');
if (error) {
log.error('fetchEventMembers failed', { error, data: { eventId } });
throw error;
}
if (!members || members.length === 0) return [];
// Fetch profiles separately (same pattern as org_members)
const userIds = members.map((m: any) => m.user_id);
const { data: profiles } = await supabase
.from('profiles')
.select('id, email, full_name, avatar_url')
.in('id', userIds);
const profileMap = Object.fromEntries((profiles ?? []).map(p => [p.id, p]));
return members.map((m: any) => ({
...m,
profile: profileMap[m.user_id] ?? undefined,
}));
}
export async function addEventMember(
supabase: SupabaseClient<Database>,
eventId: string,
userId: string,
role: 'lead' | 'manager' | 'member' = 'member'
): Promise<EventMember> {
const { data, error } = await supabase
.from('event_members')
.upsert({ event_id: eventId, user_id: userId, role }, { onConflict: 'event_id,user_id' })
.select()
.single();
if (error) {
log.error('addEventMember failed', { error, data: { eventId, userId } });
throw error;
}
return data as unknown as EventMember;
}
export async function removeEventMember(
supabase: SupabaseClient<Database>,
eventId: string,
userId: string
): Promise<void> {
const { error } = await supabase
.from('event_members')
.delete()
.eq('event_id', eventId)
.eq('user_id', userId);
if (error) {
log.error('removeEventMember failed', { error, data: { eventId, userId } });
throw error;
}
}

View File

@@ -360,6 +360,100 @@ export type Database = {
},
]
}
event_members: {
Row: {
assigned_at: string | null
event_id: string
id: string
role: string
user_id: string
}
Insert: {
assigned_at?: string | null
event_id: string
id?: string
role?: string
user_id: string
}
Update: {
assigned_at?: string | null
event_id?: string
id?: string
role?: string
user_id?: string
}
Relationships: [
{
foreignKeyName: "event_members_event_id_fkey"
columns: ["event_id"]
isOneToOne: false
referencedRelation: "events"
referencedColumns: ["id"]
},
]
}
events: {
Row: {
color: string | null
cover_image_url: string | null
created_at: string | null
created_by: string | null
description: string | null
end_date: string | null
id: string
name: string
org_id: string
slug: string
start_date: string | null
status: string
updated_at: string | null
venue_address: string | null
venue_name: string | null
}
Insert: {
color?: string | null
cover_image_url?: string | null
created_at?: string | null
created_by?: string | null
description?: string | null
end_date?: string | null
id?: string
name: string
org_id: string
slug: string
start_date?: string | null
status?: string
updated_at?: string | null
venue_address?: string | null
venue_name?: string | null
}
Update: {
color?: string | null
cover_image_url?: string | null
created_at?: string | null
created_by?: string | null
description?: string | null
end_date?: string | null
id?: string
name?: string
org_id?: string
slug?: string
start_date?: string | null
status?: string
updated_at?: string | null
venue_address?: string | null
venue_name?: string | null
}
Relationships: [
{
foreignKeyName: "events_org_id_fkey"
columns: ["org_id"]
isOneToOne: false
referencedRelation: "organizations"
referencedColumns: ["id"]
},
]
}
kanban_boards: {
Row: {
created_at: string | null
@@ -1226,3 +1320,5 @@ export type OrgGoogleCalendar = PublicTables['org_google_calendars']['Row']
export type ActivityLog = PublicTables['activity_log']['Row']
export type UserPreferences = PublicTables['user_preferences']['Row']
export type MatrixCredentials = PublicTables['matrix_credentials']['Row']
export type EventRow = PublicTables['events']['Row']
export type EventMemberRow = PublicTables['event_members']['Row']

View File

@@ -123,6 +123,11 @@
},
]
: []),
{
href: `/${data.org.slug}/events`,
label: "Events",
icon: "celebration",
},
{
href: `/${data.org.slug}/chat`,
label: "Chat",
@@ -349,6 +354,8 @@
? "files"
: target.includes("/calendar")
? "calendar"
: target.includes("/events")
? "default"
: target.includes("/settings")
? "settings"
: "default"}

View File

@@ -0,0 +1,29 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { fetchEvents } from '$lib/api/events';
import { createLogger } from '$lib/utils/logger';
const log = createLogger('page.events');
export const load: PageServerLoad = async ({ params, locals, url }) => {
const { session, user } = await locals.safeGetSession();
if (!session || !user) error(401, 'Unauthorized');
const { data: org } = await locals.supabase
.from('organizations')
.select('id')
.eq('slug', params.orgSlug)
.single();
if (!org) error(404, 'Organization not found');
const statusFilter = url.searchParams.get('status') || 'all';
try {
const events = await fetchEvents(locals.supabase, org.id, statusFilter);
return { events, statusFilter };
} catch (e: any) {
log.error('Failed to load events', { error: e, data: { orgId: org.id } });
return { events: [], statusFilter };
}
};

View File

@@ -0,0 +1,499 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { page } from "$app/stores";
import { Avatar } from "$lib/components/ui";
import { getContext } from "svelte";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "$lib/supabase/types";
import { toasts } from "$lib/stores/ui";
interface EventItem {
id: string;
org_id: string;
name: string;
slug: string;
description: string | null;
status: "planning" | "active" | "completed" | "archived";
start_date: string | null;
end_date: string | null;
venue_name: string | null;
color: string | null;
member_count: number;
}
interface Props {
data: {
org: { id: string; name: string; slug: string };
userRole: string;
events: EventItem[];
statusFilter: string;
};
}
let { data }: Props = $props();
const supabase = getContext<SupabaseClient<Database>>("supabase");
const isEditor = $derived(
["owner", "admin", "editor"].includes(data.userRole),
);
// Create event modal
let showCreateModal = $state(false);
let newEventName = $state("");
let newEventDescription = $state("");
let newEventStartDate = $state("");
let newEventEndDate = $state("");
let newEventVenue = $state("");
let newEventColor = $state("#00A3E0");
let creating = $state(false);
const statusTabs = [
{ value: "all", label: "All Events", icon: "apps" },
{ value: "planning", label: "Planning", icon: "edit_note" },
{ value: "active", label: "Active", icon: "play_circle" },
{ value: "completed", label: "Completed", icon: "check_circle" },
{ value: "archived", label: "Archived", icon: "archive" },
];
const presetColors = [
"#00A3E0",
"#8B5CF6",
"#EC4899",
"#F59E0B",
"#10B981",
"#EF4444",
"#6366F1",
"#14B8A6",
];
function getStatusColor(status: string): string {
const map: Record<string, string> = {
planning: "text-amber-400 bg-amber-400/10",
active: "text-emerald-400 bg-emerald-400/10",
completed: "text-blue-400 bg-blue-400/10",
archived: "text-light/40 bg-light/5",
};
return map[status] ?? "text-light/40 bg-light/5";
}
function getStatusIcon(status: string): string {
const map: Record<string, string> = {
planning: "edit_note",
active: "play_circle",
completed: "check_circle",
archived: "archive",
};
return map[status] ?? "help";
}
function formatDate(dateStr: string | null): string {
if (!dateStr) return "";
return new Date(dateStr).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
});
}
function formatDateRange(
start: string | null,
end: string | null,
): string {
if (!start && !end) return "No dates set";
if (start && !end) return formatDate(start);
if (!start && end) return `Until ${formatDate(end)}`;
return `${formatDate(start)}${formatDate(end)}`;
}
async function handleCreate() {
if (!newEventName.trim()) return;
creating = true;
try {
const { data: created, error } = await (supabase as any)
.from("events")
.insert({
org_id: data.org.id,
name: newEventName.trim(),
slug: newEventName
.trim()
.toLowerCase()
.replace(/[^\w\s-]/g, "")
.replace(/[\s_]+/g, "-")
.replace(/-+/g, "-")
.slice(0, 60) || "event",
description: newEventDescription.trim() || null,
start_date: newEventStartDate || null,
end_date: newEventEndDate || null,
venue_name: newEventVenue.trim() || null,
color: newEventColor,
created_by: (await supabase.auth.getUser()).data.user?.id,
})
.select()
.single();
if (error) throw error;
toasts.success(`Event "${created.name}" created`);
showCreateModal = false;
resetForm();
goto(`/${data.org.slug}/events/${created.slug}`);
} catch (e: any) {
toasts.error(e.message || "Failed to create event");
} finally {
creating = false;
}
}
function resetForm() {
newEventName = "";
newEventDescription = "";
newEventStartDate = "";
newEventEndDate = "";
newEventVenue = "";
newEventColor = "#00A3E0";
}
function switchStatus(status: string) {
const url = new URL($page.url);
if (status === "all") {
url.searchParams.delete("status");
} else {
url.searchParams.set("status", status);
}
goto(url.toString(), { replaceState: true, invalidateAll: true });
}
</script>
<svelte:head>
<title>Events | {data.org.name}</title>
</svelte:head>
<div class="flex flex-col h-full">
<!-- Header -->
<header
class="flex items-center justify-between px-6 py-5 border-b border-light/5"
>
<div>
<h1 class="text-h1 font-heading text-white">Events</h1>
<p class="text-body-sm text-light/50 mt-1">
Organize and manage your events
</p>
</div>
{#if isEditor}
<button
type="button"
class="flex items-center gap-2 px-4 py-2 bg-primary text-background rounded-xl font-body text-body-sm hover:bg-primary-hover transition-colors"
onclick={() => (showCreateModal = true)}
>
<span
class="material-symbols-rounded"
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>add</span
>
New Event
</button>
{/if}
</header>
<!-- Status Tabs -->
<div class="flex items-center gap-1 px-6 py-3 border-b border-light/5">
{#each statusTabs as tab}
<button
type="button"
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-body-sm font-body transition-colors {data.statusFilter ===
tab.value
? 'bg-primary text-background'
: 'text-light/60 hover:text-white hover:bg-dark/50'}"
onclick={() => switchStatus(tab.value)}
>
<span
class="material-symbols-rounded"
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
>{tab.icon}</span
>
{tab.label}
</button>
{/each}
</div>
<!-- Events Grid -->
<div class="flex-1 overflow-auto p-6">
{#if data.events.length === 0}
<div
class="flex flex-col items-center justify-center h-full text-light/40"
>
<span
class="material-symbols-rounded mb-4"
style="font-size: 64px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 48;"
>celebration</span
>
<p class="text-h3 font-heading mb-2">No events yet</p>
<p class="text-body text-light/30">
Create your first event to get started
</p>
{#if isEditor}
<button
type="button"
class="mt-4 flex items-center gap-2 px-4 py-2 bg-primary text-background rounded-xl font-body text-body-sm hover:bg-primary-hover transition-colors"
onclick={() => (showCreateModal = true)}
>
<span
class="material-symbols-rounded"
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>add</span
>
Create Event
</button>
{/if}
</div>
{:else}
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{#each data.events as event}
<a
href="/{data.org.slug}/events/{event.slug}"
class="group bg-dark/30 hover:bg-dark/60 border border-light/5 hover:border-light/10 rounded-2xl p-5 flex flex-col gap-3 transition-all"
>
<!-- Color bar + Status -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<div
class="w-3 h-3 rounded-full"
style="background-color: {event.color ||
'#00A3E0'}"
></div>
<h3
class="text-body font-heading text-white group-hover:text-primary transition-colors truncate"
>
{event.name}
</h3>
</div>
<span
class="text-[11px] font-body px-2 py-0.5 rounded-full capitalize {getStatusColor(
event.status,
)}"
>
{event.status}
</span>
</div>
<!-- Description -->
{#if event.description}
<p
class="text-body-sm text-light/50 line-clamp-2"
>
{event.description}
</p>
{/if}
<!-- Meta row -->
<div
class="flex items-center gap-4 text-[12px] text-light/40 mt-auto pt-2"
>
<!-- Date -->
<div class="flex items-center gap-1">
<span
class="material-symbols-rounded"
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
>calendar_today</span
>
<span
>{formatDateRange(
event.start_date,
event.end_date,
)}</span
>
</div>
<!-- Venue -->
{#if event.venue_name}
<div class="flex items-center gap-1">
<span
class="material-symbols-rounded"
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
>location_on</span
>
<span class="truncate max-w-[120px]"
>{event.venue_name}</span
>
</div>
{/if}
<!-- Members -->
<div class="flex items-center gap-1 ml-auto">
<span
class="material-symbols-rounded"
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
>group</span
>
<span>{event.member_count}</span>
</div>
</div>
</a>
{/each}
</div>
{/if}
</div>
</div>
<!-- Create Event Modal -->
{#if showCreateModal}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4"
onkeydown={(e) => e.key === "Escape" && (showCreateModal = false)}
onclick={(e) => e.target === e.currentTarget && (showCreateModal = false)}
role="dialog"
aria-modal="true"
aria-label="Create Event"
>
<div
class="bg-night rounded-2xl w-full max-w-lg shadow-2xl border border-light/10"
>
<div class="flex items-center justify-between p-5 border-b border-light/5">
<h2 class="text-h3 font-heading text-white">Create Event</h2>
<button
type="button"
class="text-light/40 hover:text-white transition-colors"
onclick={() => (showCreateModal = false)}
>
<span
class="material-symbols-rounded"
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
>close</span
>
</button>
</div>
<form
class="p-5 flex flex-col gap-4"
onsubmit={(e) => {
e.preventDefault();
handleCreate();
}}
>
<!-- Name -->
<div class="flex flex-col gap-1.5">
<label
for="event-name"
class="text-body-sm text-light/60 font-body"
>Event Name</label
>
<input
id="event-name"
type="text"
bind:value={newEventName}
placeholder="e.g., Summer Conference 2026"
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body text-white placeholder:text-light/30 focus:outline-none focus:border-primary"
required
/>
</div>
<!-- Description -->
<div class="flex flex-col gap-1.5">
<label
for="event-desc"
class="text-body-sm text-light/60 font-body"
>Description</label
>
<textarea
id="event-desc"
bind:value={newEventDescription}
placeholder="Brief description of the event..."
rows="2"
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body text-white placeholder:text-light/30 focus:outline-none focus:border-primary resize-none"
></textarea>
</div>
<!-- Dates -->
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-1.5">
<label
for="event-start"
class="text-body-sm text-light/60 font-body"
>Start Date</label
>
<input
id="event-start"
type="date"
bind:value={newEventStartDate}
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body text-white focus:outline-none focus:border-primary"
/>
</div>
<div class="flex flex-col gap-1.5">
<label
for="event-end"
class="text-body-sm text-light/60 font-body"
>End Date</label
>
<input
id="event-end"
type="date"
bind:value={newEventEndDate}
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body text-white focus:outline-none focus:border-primary"
/>
</div>
</div>
<!-- Venue -->
<div class="flex flex-col gap-1.5">
<label
for="event-venue"
class="text-body-sm text-light/60 font-body"
>Venue</label
>
<input
id="event-venue"
type="text"
bind:value={newEventVenue}
placeholder="e.g., Convention Center"
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body text-white placeholder:text-light/30 focus:outline-none focus:border-primary"
/>
</div>
<!-- Color -->
<div class="flex flex-col gap-1.5">
<label class="text-body-sm text-light/60 font-body"
>Color</label
>
<div class="flex items-center gap-2">
{#each presetColors as color}
<button
type="button"
class="w-7 h-7 rounded-full border-2 transition-all {newEventColor ===
color
? 'border-white scale-110'
: 'border-transparent hover:border-light/30'}"
style="background-color: {color}"
onclick={() => (newEventColor = color)}
></button>
{/each}
</div>
</div>
<!-- Actions -->
<div
class="flex items-center justify-end gap-3 pt-2 border-t border-light/5"
>
<button
type="button"
class="px-4 py-2 text-body-sm text-light/60 hover:text-white transition-colors"
onclick={() => {
showCreateModal = false;
resetForm();
}}
>
Cancel
</button>
<button
type="submit"
disabled={!newEventName.trim() || creating}
class="px-4 py-2 bg-primary text-background rounded-xl font-body text-body-sm hover:bg-primary-hover transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{creating ? "Creating..." : "Create Event"}
</button>
</div>
</form>
</div>
</div>
{/if}

View File

@@ -0,0 +1,27 @@
import { error } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types';
import { fetchEventBySlug, fetchEventMembers } from '$lib/api/events';
import { createLogger } from '$lib/utils/logger';
const log = createLogger('page.event-detail');
export const load: LayoutServerLoad = async ({ params, locals, parent }) => {
const { session, user } = await locals.safeGetSession();
if (!session || !user) error(401, 'Unauthorized');
const parentData = await parent() as { org: { id: string; name: string; slug: string } };
const orgId = parentData.org.id;
try {
const event = await fetchEventBySlug(locals.supabase, orgId, params.eventSlug);
if (!event) error(404, 'Event not found');
const members = await fetchEventMembers(locals.supabase, event.id);
return { event, eventMembers: members };
} catch (e: any) {
if (e?.status === 404) throw e;
log.error('Failed to load event', { error: e, data: { orgId, eventSlug: params.eventSlug } });
error(500, 'Failed to load event');
}
};

View File

@@ -0,0 +1,200 @@
<script lang="ts">
import { page } from "$app/stores";
import { Avatar } from "$lib/components/ui";
import type { Snippet } from "svelte";
import type { Event, EventMember } from "$lib/api/events";
interface Props {
data: {
org: { id: string; name: string; slug: string };
userRole: string;
event: Event;
eventMembers: (EventMember & {
profile?: {
id: string;
email: string;
full_name: string | null;
avatar_url: string | null;
};
})[];
};
children: Snippet;
}
let { data, children }: Props = $props();
const basePath = $derived(
`/${data.org.slug}/events/${data.event.slug}`,
);
const modules = $derived([
{
href: basePath,
label: "Overview",
icon: "dashboard",
exact: true,
},
{
href: `${basePath}/tasks`,
label: "Tasks",
icon: "task_alt",
},
{
href: `${basePath}/files`,
label: "Files",
icon: "folder",
},
{
href: `${basePath}/schedule`,
label: "Schedule",
icon: "calendar_today",
},
{
href: `${basePath}/budget`,
label: "Budget",
icon: "account_balance_wallet",
},
{
href: `${basePath}/guests`,
label: "Guests",
icon: "groups",
},
{
href: `${basePath}/team`,
label: "Team",
icon: "badge",
},
{
href: `${basePath}/sponsors`,
label: "Sponsors",
icon: "handshake",
},
]);
function isModuleActive(href: string, exact?: boolean): boolean {
if (exact) return $page.url.pathname === href;
return $page.url.pathname.startsWith(href);
}
function getStatusColor(status: string): string {
const map: Record<string, string> = {
planning: "bg-amber-400",
active: "bg-emerald-400",
completed: "bg-blue-400",
archived: "bg-light/40",
};
return map[status] ?? "bg-light/40";
}
function formatDateCompact(dateStr: string | null): string {
if (!dateStr) return "";
return new Date(dateStr).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
});
}
</script>
<div class="flex h-full">
<!-- Event Module Sidebar -->
<aside
class="w-56 shrink-0 bg-dark/30 border-r border-light/5 flex flex-col overflow-hidden"
>
<!-- Event Header -->
<div class="p-4 border-b border-light/5">
<div class="flex items-center gap-2 mb-2">
<div
class="w-2.5 h-2.5 rounded-full shrink-0 {getStatusColor(
data.event.status,
)}"
></div>
<h2
class="text-body font-heading text-white truncate"
title={data.event.name}
>
{data.event.name}
</h2>
</div>
{#if data.event.start_date}
<p class="text-[11px] text-light/40 flex items-center gap-1">
<span
class="material-symbols-rounded"
style="font-size: 12px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 12;"
>calendar_today</span
>
{formatDateCompact(data.event.start_date)}{data.event
.end_date
? ` — ${formatDateCompact(data.event.end_date)}`
: ""}
</p>
{/if}
</div>
<!-- Module Navigation -->
<nav class="flex-1 flex flex-col gap-0.5 p-2 overflow-auto">
{#each modules as mod}
<a
href={mod.href}
class="flex items-center gap-2.5 px-3 py-2 rounded-xl text-body-sm font-body transition-colors {isModuleActive(
mod.href,
mod.exact,
)
? 'bg-primary text-background'
: 'text-light/60 hover:text-white hover:bg-dark/50'}"
>
<span
class="material-symbols-rounded"
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
>{mod.icon}</span
>
{mod.label}
</a>
{/each}
</nav>
<!-- Event Team Preview -->
<div class="p-3 border-t border-light/5">
<p class="text-[11px] text-light/40 mb-2 px-1">
Team ({data.eventMembers.length})
</p>
<div class="flex flex-wrap gap-1 px-1">
{#each data.eventMembers.slice(0, 8) as member}
<div title={member.profile?.full_name || member.profile?.email || "Member"}>
<Avatar
name={member.profile?.full_name ||
member.profile?.email ||
"?"}
src={member.profile?.avatar_url}
size="xs"
/>
</div>
{/each}
{#if data.eventMembers.length > 8}
<div
class="w-6 h-6 rounded-full bg-dark flex items-center justify-center text-[10px] text-light/50"
>
+{data.eventMembers.length - 8}
</div>
{/if}
</div>
</div>
<!-- Back link -->
<a
href="/{data.org.slug}/events"
class="flex items-center gap-2 px-4 py-3 border-t border-light/5 text-body-sm text-light/40 hover:text-white transition-colors"
>
<span
class="material-symbols-rounded"
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
>arrow_back</span
>
All Events
</a>
</aside>
<!-- Module Content -->
<div class="flex-1 overflow-auto">
{@render children()}
</div>
</div>

View File

@@ -0,0 +1,563 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { Avatar } from "$lib/components/ui";
import { getContext } from "svelte";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "$lib/supabase/types";
import { toasts } from "$lib/stores/ui";
import type { Event, EventMember } from "$lib/api/events";
interface Props {
data: {
org: { id: string; name: string; slug: string };
userRole: string;
event: Event;
eventMembers: (EventMember & {
profile?: {
id: string;
email: string;
full_name: string | null;
avatar_url: string | null;
};
})[];
};
}
let { data }: Props = $props();
const supabase = getContext<SupabaseClient<Database>>("supabase");
const isEditor = $derived(
["owner", "admin", "editor"].includes(data.userRole),
);
// Edit mode
let editing = $state(false);
let editName = $state(data.event.name);
let editDescription = $state(data.event.description ?? "");
let editStatus = $state(data.event.status);
let editStartDate = $state(data.event.start_date ?? "");
let editEndDate = $state(data.event.end_date ?? "");
let editVenueName = $state(data.event.venue_name ?? "");
let editVenueAddress = $state(data.event.venue_address ?? "");
let saving = $state(false);
// Delete confirmation
let showDeleteConfirm = $state(false);
let deleting = $state(false);
const basePath = $derived(
`/${data.org.slug}/events/${data.event.slug}`,
);
const statusOptions = [
{ value: "planning", label: "Planning", icon: "edit_note", color: "text-amber-400" },
{ value: "active", label: "Active", icon: "play_circle", color: "text-emerald-400" },
{ value: "completed", label: "Completed", icon: "check_circle", color: "text-blue-400" },
{ value: "archived", label: "Archived", icon: "archive", color: "text-light/40" },
];
const moduleCards = $derived([
{
href: `${basePath}/tasks`,
label: "Tasks",
icon: "task_alt",
description: "Manage tasks, milestones, and progress",
color: "text-emerald-400",
bg: "bg-emerald-400/10",
},
{
href: `${basePath}/files`,
label: "Files",
icon: "folder",
description: "Documents, contracts, and media",
color: "text-blue-400",
bg: "bg-blue-400/10",
},
{
href: `${basePath}/schedule`,
label: "Schedule",
icon: "calendar_today",
description: "Event timeline and program",
color: "text-purple-400",
bg: "bg-purple-400/10",
},
{
href: `${basePath}/budget`,
label: "Budget",
icon: "account_balance_wallet",
description: "Income, expenses, and tracking",
color: "text-amber-400",
bg: "bg-amber-400/10",
},
{
href: `${basePath}/guests`,
label: "Guests",
icon: "groups",
description: "Guest list and registration",
color: "text-pink-400",
bg: "bg-pink-400/10",
},
{
href: `${basePath}/team`,
label: "Team",
icon: "badge",
description: "Team members and shift scheduling",
color: "text-teal-400",
bg: "bg-teal-400/10",
},
{
href: `${basePath}/sponsors`,
label: "Sponsors",
icon: "handshake",
description: "Sponsors, partners, and deliverables",
color: "text-orange-400",
bg: "bg-orange-400/10",
},
]);
function formatDate(dateStr: string | null): string {
if (!dateStr) return "Not set";
return new Date(dateStr).toLocaleDateString(undefined, {
weekday: "short",
month: "long",
day: "numeric",
year: "numeric",
});
}
function daysUntilEvent(): string {
if (!data.event.start_date) return "";
const now = new Date();
const start = new Date(data.event.start_date);
const diff = Math.ceil(
(start.getTime() - now.getTime()) / (1000 * 60 * 60 * 24),
);
if (diff < 0) return `${Math.abs(diff)} days ago`;
if (diff === 0) return "Today!";
if (diff === 1) return "Tomorrow";
return `In ${diff} days`;
}
async function handleSave() {
saving = true;
try {
const { error } = await (supabase as any)
.from("events")
.update({
name: editName.trim(),
description: editDescription.trim() || null,
status: editStatus,
start_date: editStartDate || null,
end_date: editEndDate || null,
venue_name: editVenueName.trim() || null,
venue_address: editVenueAddress.trim() || null,
updated_at: new Date().toISOString(),
})
.eq("id", data.event.id);
if (error) throw error;
toasts.success("Event updated");
editing = false;
// Refresh the page data
goto(`/${data.org.slug}/events/${data.event.slug}`, {
invalidateAll: true,
});
} catch (e: any) {
toasts.error(e.message || "Failed to update event");
} finally {
saving = false;
}
}
async function handleDelete() {
deleting = true;
try {
const { error } = await (supabase as any)
.from("events")
.delete()
.eq("id", data.event.id);
if (error) throw error;
toasts.success("Event deleted");
goto(`/${data.org.slug}/events`);
} catch (e: any) {
toasts.error(e.message || "Failed to delete event");
} finally {
deleting = false;
}
}
</script>
<svelte:head>
<title>{data.event.name} | {data.org.name}</title>
</svelte:head>
<div class="flex flex-col h-full overflow-auto">
<!-- Event Header -->
<header class="px-6 py-5 border-b border-light/5">
<div class="flex items-start justify-between">
<div class="flex-1">
{#if editing}
<input
type="text"
bind:value={editName}
class="text-h1 font-heading text-white bg-transparent border-b border-primary focus:outline-none w-full"
/>
{:else}
<div class="flex items-center gap-3">
<div
class="w-4 h-4 rounded-full shrink-0"
style="background-color: {data.event.color ||
'#00A3E0'}"
></div>
<h1 class="text-h1 font-heading text-white">
{data.event.name}
</h1>
</div>
{/if}
<div
class="flex items-center gap-4 mt-2 text-body-sm text-light/50"
>
{#if editing}
<select
bind:value={editStatus}
class="bg-dark border border-light/10 rounded-lg px-2 py-1 text-body-sm text-white focus:outline-none"
>
{#each statusOptions as opt}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
{:else}
<span
class="capitalize flex items-center gap-1 {statusOptions.find(
(s) => s.value === data.event.status,
)?.color ?? 'text-light/40'}"
>
<span
class="material-symbols-rounded"
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
>{statusOptions.find(
(s) => s.value === data.event.status,
)?.icon ?? "help"}</span
>
{data.event.status}
</span>
{/if}
{#if data.event.start_date && !editing}
<span class="flex items-center gap-1">
<span
class="material-symbols-rounded"
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
>calendar_today</span
>
{formatDate(data.event.start_date)}
</span>
{@const countdown = daysUntilEvent()}
{#if countdown}
<span class="text-primary font-bold"
>{countdown}</span
>
{/if}
{/if}
{#if data.event.venue_name && !editing}
<span class="flex items-center gap-1">
<span
class="material-symbols-rounded"
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
>location_on</span
>
{data.event.venue_name}
</span>
{/if}
</div>
</div>
{#if isEditor}
<div class="flex items-center gap-2">
{#if editing}
<button
type="button"
class="px-3 py-1.5 text-body-sm text-light/60 hover:text-white transition-colors"
onclick={() => (editing = false)}
>
Cancel
</button>
<button
type="button"
class="px-3 py-1.5 bg-primary text-background rounded-lg text-body-sm hover:bg-primary-hover transition-colors disabled:opacity-50"
disabled={saving}
onclick={handleSave}
>
{saving ? "Saving..." : "Save"}
</button>
{:else}
<button
type="button"
class="p-2 text-light/40 hover:text-white transition-colors rounded-lg hover:bg-dark/50"
title="Edit event"
onclick={() => (editing = true)}
>
<span
class="material-symbols-rounded"
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>edit</span
>
</button>
<button
type="button"
class="p-2 text-light/40 hover:text-error transition-colors rounded-lg hover:bg-error/10"
title="Delete event"
onclick={() => (showDeleteConfirm = true)}
>
<span
class="material-symbols-rounded"
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>delete</span
>
</button>
{/if}
</div>
{/if}
</div>
</header>
<!-- Edit fields (when editing) -->
{#if editing}
<div class="px-6 py-4 border-b border-light/5 flex flex-col gap-3">
<textarea
bind:value={editDescription}
placeholder="Event description..."
rows="2"
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body text-white placeholder:text-light/30 focus:outline-none focus:border-primary resize-none w-full"
></textarea>
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3">
<input
type="date"
bind:value={editStartDate}
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body-sm text-white focus:outline-none focus:border-primary"
placeholder="Start date"
/>
<input
type="date"
bind:value={editEndDate}
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body-sm text-white focus:outline-none focus:border-primary"
placeholder="End date"
/>
<input
type="text"
bind:value={editVenueName}
placeholder="Venue name"
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body-sm text-white placeholder:text-light/30 focus:outline-none focus:border-primary"
/>
<input
type="text"
bind:value={editVenueAddress}
placeholder="Venue address"
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body-sm text-white placeholder:text-light/30 focus:outline-none focus:border-primary"
/>
</div>
</div>
{/if}
<!-- Overview Content -->
<div class="flex-1 p-6 overflow-auto">
<!-- Description -->
{#if data.event.description && !editing}
<p class="text-body text-light/60 mb-6 max-w-2xl">
{data.event.description}
</p>
{/if}
<!-- Module Cards Grid -->
<h2 class="text-h3 font-heading text-white mb-4">Modules</h2>
<div
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3"
>
{#each moduleCards as mod}
<a
href={mod.href}
class="group bg-dark/30 hover:bg-dark/60 border border-light/5 hover:border-light/10 rounded-xl p-4 flex flex-col gap-2 transition-all"
>
<div
class="w-10 h-10 rounded-xl {mod.bg} flex items-center justify-center"
>
<span
class="material-symbols-rounded {mod.color}"
style="font-size: 22px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 22;"
>{mod.icon}</span
>
</div>
<h3
class="text-body font-heading text-white group-hover:text-primary transition-colors"
>
{mod.label}
</h3>
<p class="text-[12px] text-light/40">{mod.description}</p>
</a>
{/each}
</div>
<!-- Event Details Section -->
<div class="mt-8 grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Info Card -->
<div class="bg-dark/30 border border-light/5 rounded-xl p-5">
<h3 class="text-body font-heading text-white mb-3">
Event Details
</h3>
<div class="flex flex-col gap-2.5">
<div class="flex items-center gap-3">
<span
class="material-symbols-rounded text-light/30"
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
>calendar_today</span
>
<div>
<p class="text-[11px] text-light/40">
Start Date
</p>
<p class="text-body-sm text-white">
{formatDate(data.event.start_date)}
</p>
</div>
</div>
<div class="flex items-center gap-3">
<span
class="material-symbols-rounded text-light/30"
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
>event</span
>
<div>
<p class="text-[11px] text-light/40">End Date</p>
<p class="text-body-sm text-white">
{formatDate(data.event.end_date)}
</p>
</div>
</div>
{#if data.event.venue_name}
<div class="flex items-center gap-3">
<span
class="material-symbols-rounded text-light/30"
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
>location_on</span
>
<div>
<p class="text-[11px] text-light/40">Venue</p>
<p class="text-body-sm text-white">
{data.event.venue_name}
</p>
{#if data.event.venue_address}
<p class="text-[11px] text-light/40">
{data.event.venue_address}
</p>
{/if}
</div>
</div>
{/if}
</div>
</div>
<!-- Team Card -->
<div class="bg-dark/30 border border-light/5 rounded-xl p-5">
<div class="flex items-center justify-between mb-3">
<h3 class="text-body font-heading text-white">
Team ({data.eventMembers.length})
</h3>
<a
href="{basePath}/team"
class="text-[12px] text-primary hover:underline"
>Manage</a
>
</div>
<div class="flex flex-col gap-2">
{#each data.eventMembers.slice(0, 6) as member}
<div class="flex items-center gap-2.5">
<Avatar
name={member.profile?.full_name ||
member.profile?.email ||
"?"}
src={member.profile?.avatar_url}
size="sm"
/>
<div class="flex-1 min-w-0">
<p
class="text-body-sm text-white truncate"
>
{member.profile?.full_name ||
member.profile?.email ||
"Unknown"}
</p>
<p
class="text-[11px] text-light/40 capitalize"
>
{member.role}
</p>
</div>
</div>
{/each}
{#if data.eventMembers.length > 6}
<a
href="{basePath}/team"
class="text-body-sm text-primary hover:underline text-center pt-1"
>
+{data.eventMembers.length - 6} more
</a>
{/if}
{#if data.eventMembers.length === 0}
<p class="text-body-sm text-light/30 text-center py-4">
No team members assigned yet
</p>
{/if}
</div>
</div>
</div>
</div>
</div>
<!-- Delete Confirmation -->
{#if showDeleteConfirm}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4"
onkeydown={(e) => e.key === "Escape" && (showDeleteConfirm = false)}
onclick={(e) =>
e.target === e.currentTarget && (showDeleteConfirm = false)}
role="dialog"
aria-modal="true"
aria-label="Delete Event"
>
<div
class="bg-night rounded-2xl w-full max-w-sm shadow-2xl border border-light/10 p-6"
>
<h2 class="text-h3 font-heading text-white mb-2">Delete Event?</h2>
<p class="text-body-sm text-light/50 mb-6">
This will permanently delete <strong class="text-white"
>{data.event.name}</strong
>
and all its data. This action cannot be undone.
</p>
<div class="flex items-center justify-end gap-3">
<button
type="button"
class="px-4 py-2 text-body-sm text-light/60 hover:text-white transition-colors"
onclick={() => (showDeleteConfirm = false)}
>
Cancel
</button>
<button
type="button"
class="px-4 py-2 bg-error text-white rounded-xl text-body-sm hover:bg-error/80 transition-colors disabled:opacity-50"
disabled={deleting}
onclick={handleDelete}
>
{deleting ? "Deleting..." : "Delete Event"}
</button>
</div>
</div>
</div>
{/if}

View File

@@ -0,0 +1,84 @@
-- Events: the core project entity within an organization
-- Each event represents a project (conference, festival, meetup, etc.)
CREATE TABLE events (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
name TEXT NOT NULL,
slug TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL DEFAULT 'planning' CHECK (status IN ('planning', 'active', 'completed', 'archived')),
start_date DATE,
end_date DATE,
venue_name TEXT,
venue_address TEXT,
cover_image_url TEXT,
color TEXT,
created_by UUID REFERENCES auth.users(id),
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(org_id, slug)
);
-- Event members: subset of org members assigned to an event
CREATE TABLE event_members (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('lead', 'manager', 'member')),
assigned_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(event_id, user_id)
);
-- Indexes
CREATE INDEX idx_events_org ON events(org_id);
CREATE INDEX idx_events_status ON events(org_id, status);
CREATE INDEX idx_events_dates ON events(start_date, end_date);
CREATE INDEX idx_event_members_event ON event_members(event_id);
CREATE INDEX idx_event_members_user ON event_members(user_id);
-- RLS
ALTER TABLE events ENABLE ROW LEVEL SECURITY;
ALTER TABLE event_members ENABLE ROW LEVEL SECURITY;
-- Events: org members can view, editors+ can manage
CREATE POLICY "Org members can view events" ON events FOR SELECT
USING (EXISTS (SELECT 1 FROM org_members WHERE org_id = events.org_id AND user_id = auth.uid()));
CREATE POLICY "Editors can manage events" ON events FOR ALL
USING (EXISTS (SELECT 1 FROM org_members WHERE org_id = events.org_id AND user_id = auth.uid() AND role IN ('owner', 'admin', 'editor')));
-- Event members: org members can view, editors+ can manage
CREATE POLICY "Org members can view event members" ON event_members FOR SELECT
USING (EXISTS (
SELECT 1 FROM events e
JOIN org_members om ON e.org_id = om.org_id
WHERE e.id = event_members.event_id AND om.user_id = auth.uid()
));
CREATE POLICY "Editors can manage event members" ON event_members FOR ALL
USING (EXISTS (
SELECT 1 FROM events e
JOIN org_members om ON e.org_id = om.org_id
WHERE e.id = event_members.event_id AND om.user_id = auth.uid() AND om.role IN ('owner', 'admin', 'editor')
));
-- Enable realtime
ALTER PUBLICATION supabase_realtime ADD TABLE events;
-- Auto-add creator as event lead
CREATE OR REPLACE FUNCTION public.handle_new_event()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.created_by IS NOT NULL THEN
INSERT INTO public.event_members (event_id, user_id, role)
VALUES (NEW.id, NEW.created_by, 'lead')
ON CONFLICT (event_id, user_id) DO NOTHING;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE TRIGGER on_event_created
AFTER INSERT ON events
FOR EACH ROW EXECUTE FUNCTION public.handle_new_event();