571 lines
14 KiB
TypeScript
571 lines
14 KiB
TypeScript
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: string;
|
|
role_id: string | null;
|
|
notes: string | null;
|
|
assigned_at: string | null;
|
|
}
|
|
|
|
export interface EventRole {
|
|
id: string;
|
|
event_id: string;
|
|
name: string;
|
|
color: string;
|
|
sort_order: number;
|
|
is_default: boolean;
|
|
created_at: string | null;
|
|
}
|
|
|
|
export interface EventDepartment {
|
|
id: string;
|
|
event_id: string;
|
|
name: string;
|
|
color: string;
|
|
description: string | null;
|
|
planned_budget: number;
|
|
sort_order: number;
|
|
created_at: string | null;
|
|
}
|
|
|
|
export interface EventMemberDepartment {
|
|
id: string;
|
|
event_member_id: string;
|
|
department_id: string;
|
|
assigned_at: string | null;
|
|
}
|
|
|
|
export interface EventMemberWithDetails extends EventMember {
|
|
profile?: { id: string; email: string; full_name: string | null; avatar_url: string | null; phone: string | null; discord_handle: string | null; shirt_size: string | null; hoodie_size: string | null };
|
|
event_role?: EventRole;
|
|
departments: EventDepartment[];
|
|
}
|
|
|
|
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<EventMemberWithDetails[]> {
|
|
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, phone, discord_handle, shirt_size, hoodie_size')
|
|
.in('id', userIds);
|
|
|
|
const profileMap = Object.fromEntries((profiles ?? []).map(p => [p.id, p]));
|
|
|
|
// Fetch roles for this event
|
|
const { data: roles } = await supabase
|
|
.from('event_roles')
|
|
.select('*')
|
|
.eq('event_id', eventId);
|
|
const roleMap = Object.fromEntries((roles ?? []).map(r => [r.id, r]));
|
|
|
|
// Fetch member-department assignments
|
|
const memberIds = members.map((m: any) => m.id);
|
|
const { data: memberDepts } = await supabase
|
|
.from('event_member_departments')
|
|
.select('*')
|
|
.in('event_member_id', memberIds);
|
|
|
|
// Fetch departments for this event
|
|
const { data: departments } = await supabase
|
|
.from('event_departments')
|
|
.select('*')
|
|
.eq('event_id', eventId);
|
|
const deptMap = Object.fromEntries((departments ?? []).map(d => [d.id, d]));
|
|
|
|
// Build member-to-departments map
|
|
const memberDeptMap: Record<string, EventDepartment[]> = {};
|
|
for (const md of (memberDepts ?? [])) {
|
|
const dept = deptMap[md.department_id];
|
|
if (dept) {
|
|
if (!memberDeptMap[md.event_member_id]) memberDeptMap[md.event_member_id] = [];
|
|
memberDeptMap[md.event_member_id].push(dept as unknown as EventDepartment);
|
|
}
|
|
}
|
|
|
|
return members.map((m: any) => ({
|
|
...m,
|
|
profile: profileMap[m.user_id] ?? undefined,
|
|
event_role: m.role_id ? (roleMap[m.role_id] as unknown as EventRole) ?? undefined : undefined,
|
|
departments: memberDeptMap[m.id] ?? [],
|
|
}));
|
|
}
|
|
|
|
export async function addEventMember(
|
|
supabase: SupabaseClient<Database>,
|
|
eventId: string,
|
|
userId: string,
|
|
params: { role?: 'lead' | 'manager' | 'member'; role_id?: string; notes?: string } = {}
|
|
): Promise<EventMember> {
|
|
const { data, error } = await supabase
|
|
.from('event_members')
|
|
.upsert({
|
|
event_id: eventId,
|
|
user_id: userId,
|
|
role: params.role ?? 'member',
|
|
role_id: params.role_id ?? null,
|
|
notes: params.notes ?? null,
|
|
}, { 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;
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// Event Roles
|
|
// ============================================================
|
|
|
|
export async function fetchEventRoles(
|
|
supabase: SupabaseClient<Database>,
|
|
eventId: string
|
|
): Promise<EventRole[]> {
|
|
const { data, error } = await supabase
|
|
.from('event_roles')
|
|
.select('*')
|
|
.eq('event_id', eventId)
|
|
.order('sort_order');
|
|
|
|
if (error) {
|
|
log.error('fetchEventRoles failed', { error, data: { eventId } });
|
|
throw error;
|
|
}
|
|
return (data ?? []) as unknown as EventRole[];
|
|
}
|
|
|
|
export async function createEventRole(
|
|
supabase: SupabaseClient<Database>,
|
|
eventId: string,
|
|
params: { name: string; color?: string; sort_order?: number }
|
|
): Promise<EventRole> {
|
|
const { data, error } = await supabase
|
|
.from('event_roles')
|
|
.insert({
|
|
event_id: eventId,
|
|
name: params.name,
|
|
color: params.color ?? '#6366f1',
|
|
sort_order: params.sort_order ?? 0,
|
|
})
|
|
.select()
|
|
.single();
|
|
|
|
if (error) {
|
|
log.error('createEventRole failed', { error, data: { eventId, name: params.name } });
|
|
throw error;
|
|
}
|
|
return data as unknown as EventRole;
|
|
}
|
|
|
|
export async function updateEventRole(
|
|
supabase: SupabaseClient<Database>,
|
|
roleId: string,
|
|
params: Partial<Pick<EventRole, 'name' | 'color' | 'sort_order' | 'is_default'>>
|
|
): Promise<EventRole> {
|
|
const { data, error } = await supabase
|
|
.from('event_roles')
|
|
.update(params)
|
|
.eq('id', roleId)
|
|
.select()
|
|
.single();
|
|
|
|
if (error) {
|
|
log.error('updateEventRole failed', { error, data: { roleId } });
|
|
throw error;
|
|
}
|
|
return data as unknown as EventRole;
|
|
}
|
|
|
|
export async function deleteEventRole(
|
|
supabase: SupabaseClient<Database>,
|
|
roleId: string
|
|
): Promise<void> {
|
|
const { error } = await supabase
|
|
.from('event_roles')
|
|
.delete()
|
|
.eq('id', roleId);
|
|
|
|
if (error) {
|
|
log.error('deleteEventRole failed', { error, data: { roleId } });
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// Event Departments
|
|
// ============================================================
|
|
|
|
export async function fetchEventDepartments(
|
|
supabase: SupabaseClient<Database>,
|
|
eventId: string
|
|
): Promise<EventDepartment[]> {
|
|
const { data, error } = await supabase
|
|
.from('event_departments')
|
|
.select('*')
|
|
.eq('event_id', eventId)
|
|
.order('sort_order');
|
|
|
|
if (error) {
|
|
log.error('fetchEventDepartments failed', { error, data: { eventId } });
|
|
throw error;
|
|
}
|
|
return (data ?? []) as unknown as EventDepartment[];
|
|
}
|
|
|
|
export async function createEventDepartment(
|
|
supabase: SupabaseClient<Database>,
|
|
eventId: string,
|
|
params: { name: string; color?: string; description?: string; sort_order?: number }
|
|
): Promise<EventDepartment> {
|
|
const { data, error } = await supabase
|
|
.from('event_departments')
|
|
.insert({
|
|
event_id: eventId,
|
|
name: params.name,
|
|
color: params.color ?? '#00A3E0',
|
|
description: params.description ?? null,
|
|
sort_order: params.sort_order ?? 0,
|
|
})
|
|
.select()
|
|
.single();
|
|
|
|
if (error) {
|
|
log.error('createEventDepartment failed', { error, data: { eventId, name: params.name } });
|
|
throw error;
|
|
}
|
|
return data as unknown as EventDepartment;
|
|
}
|
|
|
|
export async function updateEventDepartment(
|
|
supabase: SupabaseClient<Database>,
|
|
deptId: string,
|
|
params: Partial<Pick<EventDepartment, 'name' | 'color' | 'description' | 'sort_order'>>
|
|
): Promise<EventDepartment> {
|
|
const { data, error } = await supabase
|
|
.from('event_departments')
|
|
.update(params)
|
|
.eq('id', deptId)
|
|
.select()
|
|
.single();
|
|
|
|
if (error) {
|
|
log.error('updateEventDepartment failed', { error, data: { deptId } });
|
|
throw error;
|
|
}
|
|
return data as unknown as EventDepartment;
|
|
}
|
|
|
|
export async function updateDepartmentPlannedBudget(
|
|
supabase: SupabaseClient<Database>,
|
|
deptId: string,
|
|
plannedBudget: number
|
|
): Promise<EventDepartment> {
|
|
const { data, error } = await (supabase as any)
|
|
.from('event_departments')
|
|
.update({ planned_budget: plannedBudget })
|
|
.eq('id', deptId)
|
|
.select()
|
|
.single();
|
|
|
|
if (error) {
|
|
log.error('updateDepartmentPlannedBudget failed', { error, data: { deptId, plannedBudget } });
|
|
throw error;
|
|
}
|
|
return data as unknown as EventDepartment;
|
|
}
|
|
|
|
export async function deleteEventDepartment(
|
|
supabase: SupabaseClient<Database>,
|
|
deptId: string
|
|
): Promise<void> {
|
|
const { error } = await supabase
|
|
.from('event_departments')
|
|
.delete()
|
|
.eq('id', deptId);
|
|
|
|
if (error) {
|
|
log.error('deleteEventDepartment failed', { error, data: { deptId } });
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// Member-Department Assignments
|
|
// ============================================================
|
|
|
|
export async function assignMemberDepartment(
|
|
supabase: SupabaseClient<Database>,
|
|
eventMemberId: string,
|
|
departmentId: string
|
|
): Promise<EventMemberDepartment> {
|
|
const { data, error } = await supabase
|
|
.from('event_member_departments')
|
|
.upsert(
|
|
{ event_member_id: eventMemberId, department_id: departmentId },
|
|
{ onConflict: 'event_member_id,department_id' }
|
|
)
|
|
.select()
|
|
.single();
|
|
|
|
if (error) {
|
|
log.error('assignMemberDepartment failed', { error, data: { eventMemberId, departmentId } });
|
|
throw error;
|
|
}
|
|
return data as unknown as EventMemberDepartment;
|
|
}
|
|
|
|
export async function unassignMemberDepartment(
|
|
supabase: SupabaseClient<Database>,
|
|
eventMemberId: string,
|
|
departmentId: string
|
|
): Promise<void> {
|
|
const { error } = await supabase
|
|
.from('event_member_departments')
|
|
.delete()
|
|
.eq('event_member_id', eventMemberId)
|
|
.eq('department_id', departmentId);
|
|
|
|
if (error) {
|
|
log.error('unassignMemberDepartment failed', { error, data: { eventMemberId, departmentId } });
|
|
throw error;
|
|
}
|
|
}
|