From 556955f349873e1c87afae831028706581d564bd Mon Sep 17 00:00:00 2001 From: AlacrisDevs Date: Sat, 7 Feb 2026 10:04:37 +0200 Subject: [PATCH] feat: Phase 1 - Events entity (migration, API, list page, detail layout with module sidebar, overview page) --- src/lib/api/events.test.ts | 48 ++ src/lib/api/events.ts | 277 +++++++++ src/lib/supabase/types.ts | 96 +++ src/routes/[orgSlug]/+layout.svelte | 13 +- src/routes/[orgSlug]/events/+page.server.ts | 29 + src/routes/[orgSlug]/events/+page.svelte | 499 ++++++++++++++++ .../events/[eventSlug]/+layout.server.ts | 27 + .../events/[eventSlug]/+layout.svelte | 200 +++++++ .../[orgSlug]/events/[eventSlug]/+page.svelte | 563 ++++++++++++++++++ supabase/migrations/022_events.sql | 84 +++ 10 files changed, 1833 insertions(+), 3 deletions(-) create mode 100644 src/lib/api/events.test.ts create mode 100644 src/lib/api/events.ts create mode 100644 src/routes/[orgSlug]/events/+page.server.ts create mode 100644 src/routes/[orgSlug]/events/+page.svelte create mode 100644 src/routes/[orgSlug]/events/[eventSlug]/+layout.server.ts create mode 100644 src/routes/[orgSlug]/events/[eventSlug]/+layout.svelte create mode 100644 src/routes/[orgSlug]/events/[eventSlug]/+page.svelte create mode 100644 supabase/migrations/022_events.sql diff --git a/src/lib/api/events.test.ts b/src/lib/api/events.test.ts new file mode 100644 index 0000000..7365d75 --- /dev/null +++ b/src/lib/api/events.test.ts @@ -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'); + }); +}); diff --git a/src/lib/api/events.ts b/src/lib/api/events.ts new file mode 100644 index 0000000..ed5711f --- /dev/null +++ b/src/lib/api/events.ts @@ -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, + orgId: string, + status?: string +): Promise { + 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, + eventId: string +): Promise { + 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, + orgId: string, + eventSlug: string +): Promise { + 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, + orgId: string, + userId: string, + params: { + name: string; + description?: string; + start_date?: string; + end_date?: string; + venue_name?: string; + venue_address?: string; + color?: string; + } +): Promise { + 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, + eventId: string, + params: Partial> +): Promise { + 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, + eventId: string +): Promise { + 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, + 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, + eventId: string, + userId: string, + role: 'lead' | 'manager' | 'member' = 'member' +): Promise { + 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, + eventId: string, + userId: string +): Promise { + 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; + } +} diff --git a/src/lib/supabase/types.ts b/src/lib/supabase/types.ts index 50bac1e..b7df722 100644 --- a/src/lib/supabase/types.ts +++ b/src/lib/supabase/types.ts @@ -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'] diff --git a/src/routes/[orgSlug]/+layout.svelte b/src/routes/[orgSlug]/+layout.svelte index e091ca9..51a7d42 100644 --- a/src/routes/[orgSlug]/+layout.svelte +++ b/src/routes/[orgSlug]/+layout.svelte @@ -123,6 +123,11 @@ }, ] : []), + { + href: `/${data.org.slug}/events`, + label: "Events", + icon: "celebration", + }, { href: `/${data.org.slug}/chat`, label: "Chat", @@ -349,9 +354,11 @@ ? "files" : target.includes("/calendar") ? "calendar" - : target.includes("/settings") - ? "settings" - : "default"} + : target.includes("/events") + ? "default" + : target.includes("/settings") + ? "settings" + : "default"} {:else} {@render children()} diff --git a/src/routes/[orgSlug]/events/+page.server.ts b/src/routes/[orgSlug]/events/+page.server.ts new file mode 100644 index 0000000..a5f8810 --- /dev/null +++ b/src/routes/[orgSlug]/events/+page.server.ts @@ -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 }; + } +}; diff --git a/src/routes/[orgSlug]/events/+page.svelte b/src/routes/[orgSlug]/events/+page.svelte new file mode 100644 index 0000000..da0ff0a --- /dev/null +++ b/src/routes/[orgSlug]/events/+page.svelte @@ -0,0 +1,499 @@ + + + + Events | {data.org.name} + + +
+ +
+
+

Events

+

+ Organize and manage your events +

+
+ {#if isEditor} + + {/if} +
+ + +
+ {#each statusTabs as tab} + + {/each} +
+ + +
+ {#if data.events.length === 0} +
+ celebration +

No events yet

+

+ Create your first event to get started +

+ {#if isEditor} + + {/if} +
+ {:else} + + {/if} +
+
+ + +{#if showCreateModal} + +
e.key === "Escape" && (showCreateModal = false)} + onclick={(e) => e.target === e.currentTarget && (showCreateModal = false)} + role="dialog" + aria-modal="true" + aria-label="Create Event" + > +
+
+

Create Event

+ +
+ +
{ + e.preventDefault(); + handleCreate(); + }} + > + +
+ + +
+ + +
+ + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ + +
+ +
+ {#each presetColors as color} + + {/each} +
+
+ + +
+ + +
+
+
+
+{/if} diff --git a/src/routes/[orgSlug]/events/[eventSlug]/+layout.server.ts b/src/routes/[orgSlug]/events/[eventSlug]/+layout.server.ts new file mode 100644 index 0000000..2b45be1 --- /dev/null +++ b/src/routes/[orgSlug]/events/[eventSlug]/+layout.server.ts @@ -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'); + } +}; diff --git a/src/routes/[orgSlug]/events/[eventSlug]/+layout.svelte b/src/routes/[orgSlug]/events/[eventSlug]/+layout.svelte new file mode 100644 index 0000000..b7fdfda --- /dev/null +++ b/src/routes/[orgSlug]/events/[eventSlug]/+layout.svelte @@ -0,0 +1,200 @@ + + +
+ + + + +
+ {@render children()} +
+
diff --git a/src/routes/[orgSlug]/events/[eventSlug]/+page.svelte b/src/routes/[orgSlug]/events/[eventSlug]/+page.svelte new file mode 100644 index 0000000..0e3aff2 --- /dev/null +++ b/src/routes/[orgSlug]/events/[eventSlug]/+page.svelte @@ -0,0 +1,563 @@ + + + + {data.event.name} | {data.org.name} + + +
+ +
+
+
+ {#if editing} + + {:else} +
+
+

+ {data.event.name} +

+
+ {/if} + +
+ {#if editing} + + {:else} + + {statusOptions.find( + (s) => s.value === data.event.status, + )?.icon ?? "help"} + {data.event.status} + + {/if} + + {#if data.event.start_date && !editing} + + calendar_today + {formatDate(data.event.start_date)} + + {@const countdown = daysUntilEvent()} + {#if countdown} + {countdown} + {/if} + {/if} + + {#if data.event.venue_name && !editing} + + location_on + {data.event.venue_name} + + {/if} +
+
+ + {#if isEditor} +
+ {#if editing} + + + {:else} + + + {/if} +
+ {/if} +
+
+ + + {#if editing} +
+ +
+ + + + +
+
+ {/if} + + +
+ + {#if data.event.description && !editing} +

+ {data.event.description} +

+ {/if} + + +

Modules

+
+ {#each moduleCards as mod} + +
+ {mod.icon} +
+

+ {mod.label} +

+

{mod.description}

+
+ {/each} +
+ + +
+ +
+

+ Event Details +

+
+
+ calendar_today +
+

+ Start Date +

+

+ {formatDate(data.event.start_date)} +

+
+
+
+ event +
+

End Date

+

+ {formatDate(data.event.end_date)} +

+
+
+ {#if data.event.venue_name} +
+ location_on +
+

Venue

+

+ {data.event.venue_name} +

+ {#if data.event.venue_address} +

+ {data.event.venue_address} +

+ {/if} +
+
+ {/if} +
+
+ + +
+
+

+ Team ({data.eventMembers.length}) +

+ Manage +
+
+ {#each data.eventMembers.slice(0, 6) as member} +
+ +
+

+ {member.profile?.full_name || + member.profile?.email || + "Unknown"} +

+

+ {member.role} +

+
+
+ {/each} + {#if data.eventMembers.length > 6} + + +{data.eventMembers.length - 6} more + + {/if} + {#if data.eventMembers.length === 0} +

+ No team members assigned yet +

+ {/if} +
+
+
+
+
+ + +{#if showDeleteConfirm} + +
e.key === "Escape" && (showDeleteConfirm = false)} + onclick={(e) => + e.target === e.currentTarget && (showDeleteConfirm = false)} + role="dialog" + aria-modal="true" + aria-label="Delete Event" + > +
+

Delete Event?

+

+ This will permanently delete {data.event.name} + and all its data. This action cannot be undone. +

+
+ + +
+
+
+{/if} diff --git a/supabase/migrations/022_events.sql b/supabase/migrations/022_events.sql new file mode 100644 index 0000000..44813e8 --- /dev/null +++ b/supabase/migrations/022_events.sql @@ -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();