import type { SupabaseClient } from '@supabase/supabase-js'; import type { Database, KanbanBoard, KanbanColumn, KanbanCard } from '$lib/supabase/types'; import { createLogger } from '$lib/utils/logger'; const log = createLogger('api.kanban'); export interface ColumnWithCards extends KanbanColumn { cards: KanbanCard[]; } export interface BoardWithColumns extends KanbanBoard { columns: ColumnWithCards[]; } export async function fetchBoards( supabase: SupabaseClient, orgId: string ): Promise { const { data, error } = await supabase .from('kanban_boards') .select('*') .eq('org_id', orgId) .order('created_at'); if (error) { log.error('fetchBoards failed', { error, data: { orgId } }); throw error; } log.debug('fetchBoards ok', { data: { count: data?.length ?? 0 } }); return data ?? []; } export async function fetchBoardWithColumns( supabase: SupabaseClient, boardId: string ): Promise { // Fetch board and columns in parallel const [boardResult, columnsResult] = await Promise.all([ supabase.from('kanban_boards').select('*').eq('id', boardId).single(), supabase.from('kanban_columns').select('*').eq('board_id', boardId).order('position'), ]); if (boardResult.error) { log.error('fetchBoardWithColumns failed (board)', { error: boardResult.error, data: { boardId } }); throw boardResult.error; } if (!boardResult.data) return null; if (columnsResult.error) { log.error('fetchBoardWithColumns failed (columns)', { error: columnsResult.error, data: { boardId } }); throw columnsResult.error; } const board = boardResult.data; const columns = columnsResult.data ?? []; const columnIds = columns.map((c) => c.id); if (columnIds.length === 0) { return { ...board, columns: columns.map((col) => ({ ...col, cards: [] })) }; } const { data: cards, error: cardError } = await supabase .from('kanban_cards') .select('*') .in('column_id', columnIds) .order('position'); if (cardError) { log.error('fetchBoardWithColumns failed (cards)', { error: cardError, data: { boardId } }); throw cardError; } const cardIds = (cards ?? []).map((c) => c.id); const cardTagsMap = new Map(); const checklistMap = new Map(); const assigneeMap = new Map(); if (cardIds.length > 0) { const assigneeIds = [...new Set((cards ?? []).map((c) => c.assignee_id).filter(Boolean))] as string[]; // Fetch tags, checklists, and assignee profiles in parallel const [cardTagsResult, checklistResult, profilesResult] = await Promise.all([ supabase.from('card_tags').select('card_id, tags:tag_id (id, name, color)').in('card_id', cardIds), supabase.from('kanban_checklist_items').select('card_id, completed').in('card_id', cardIds), assigneeIds.length > 0 ? supabase.from('profiles').select('id, full_name, avatar_url').in('id', assigneeIds) : Promise.resolve({ data: null }), ]); (cardTagsResult.data ?? []).forEach((ct: Record) => { const rawTags = ct.tags; const tag = Array.isArray(rawTags) ? rawTags[0] : rawTags; if (!tag) return; const cardId = ct.card_id as string; if (!cardTagsMap.has(cardId)) { cardTagsMap.set(cardId, []); } cardTagsMap.get(cardId)!.push(tag as { id: string; name: string; color: string | null }); }); (checklistResult.data ?? []).forEach((item: Record) => { const cardId = item.card_id as string; if (!checklistMap.has(cardId)) { checklistMap.set(cardId, { total: 0, done: 0 }); } const entry = checklistMap.get(cardId)!; entry.total++; if (item.completed) entry.done++; }); (profilesResult.data ?? []).forEach((p: Record) => { assigneeMap.set(p.id as string, { name: p.full_name as string | null, avatar: p.avatar_url as string | null }); }); } const cardsByColumn = new Map(); (cards ?? []).forEach((card) => { const colId = card.column_id; if (!colId) return; if (!cardsByColumn.has(colId)) { cardsByColumn.set(colId, []); } const cl = checklistMap.get(card.id); const assignee = card.assignee_id ? assigneeMap.get(card.assignee_id) : null; cardsByColumn.get(colId)!.push({ ...card, tags: cardTagsMap.get(card.id) ?? [], checklist_total: cl?.total ?? 0, checklist_done: cl?.done ?? 0, assignee_name: assignee?.name ?? null, assignee_avatar: assignee?.avatar ?? null, }); }); return { ...board, columns: columns.map((col) => ({ ...col, cards: cardsByColumn.get(col.id) ?? [] })) }; } export async function createBoard( supabase: SupabaseClient, orgId: string, name: string ): Promise { log.info('createBoard', { data: { orgId, name } }); const { data, error } = await supabase .from('kanban_boards') .insert({ org_id: orgId, name }) .select() .single(); if (error) { log.error('createBoard failed', { error, data: { orgId, name } }); throw error; } // Create default columns const defaultColumns = ['To Do', 'In Progress', 'Done']; await supabase.from('kanban_columns').insert( defaultColumns.map((name, index) => ({ board_id: data.id, name, position: index })) ); return data; } export async function updateBoard( supabase: SupabaseClient, id: string, name: string ): Promise { const { error } = await supabase.from('kanban_boards').update({ name }).eq('id', id); if (error) { log.error('updateBoard failed', { error, data: { id, name } }); throw error; } } export async function deleteBoard( supabase: SupabaseClient, id: string ): Promise { const { error } = await supabase.from('kanban_boards').delete().eq('id', id); if (error) { log.error('deleteBoard failed', { error, data: { id } }); throw error; } } export async function createColumn( supabase: SupabaseClient, boardId: string, name: string, position: number ): Promise { const { data, error } = await supabase .from('kanban_columns') .insert({ board_id: boardId, name, position }) .select() .single(); if (error) { log.error('createColumn failed', { error, data: { boardId, name, position } }); throw error; } return data; } export async function updateColumn( supabase: SupabaseClient, id: string, updates: Partial> ): Promise { const { error } = await supabase.from('kanban_columns').update(updates).eq('id', id); if (error) { log.error('updateColumn failed', { error, data: { id, updates } }); throw error; } } export async function deleteColumn( supabase: SupabaseClient, id: string ): Promise { const { error } = await supabase.from('kanban_columns').delete().eq('id', id); if (error) { log.error('deleteColumn failed', { error, data: { id } }); throw error; } } export async function createCard( supabase: SupabaseClient, columnId: string, title: string, position: number, userId: string ): Promise { const { data, error } = await supabase .from('kanban_cards') .insert({ column_id: columnId, title, position, created_by: userId }) .select() .single(); if (error) { log.error('createCard failed', { error, data: { columnId, title, position } }); throw error; } return data; } export async function updateCard( supabase: SupabaseClient, id: string, updates: Partial> ): Promise { const { error } = await supabase.from('kanban_cards').update(updates).eq('id', id); if (error) { log.error('updateCard failed', { error, data: { id, updates } }); throw error; } } export async function deleteCard( supabase: SupabaseClient, id: string ): Promise { const { error } = await supabase.from('kanban_cards').delete().eq('id', id); if (error) { log.error('deleteCard failed', { error, data: { id } }); throw error; } } export async function moveCard( supabase: SupabaseClient, cardId: string, newColumnId: string, newPosition: number ): Promise { // Fetch all cards in the target column (ordered by position) const { data: targetCards, error: fetchErr } = await supabase .from('kanban_cards') .select('id, position') .eq('column_id', newColumnId) .order('position'); if (fetchErr) { log.error('moveCard: failed to fetch target column cards', { error: fetchErr }); throw fetchErr; } // Remove the moved card from the list if it's already in this column const otherCards = (targetCards ?? []).filter((c) => c.id !== cardId); // Insert at the new position and reassign sequential positions const reordered = [ ...otherCards.slice(0, newPosition), { id: cardId }, ...otherCards.slice(newPosition), ]; // Build a map of old positions to detect what actually changed const oldPositionMap = new Map((targetCards ?? []).map((c) => [c.id, c.position])); // Only update cards whose position or column actually changed const updates = reordered .map((c, i) => { if (c.id === cardId) { // The moved card always needs updating (column + position) return supabase .from('kanban_cards') .update({ column_id: newColumnId, position: i }) .eq('id', c.id); } // Skip siblings whose position hasn't changed if (oldPositionMap.get(c.id) === i) return null; return supabase .from('kanban_cards') .update({ position: i }) .eq('id', c.id); }) .filter(Boolean); if (updates.length === 0) return; const results = await Promise.all(updates); const failed = results.find((r) => r && r.error); if (failed?.error) { log.error('moveCard failed', { error: failed.error, data: { cardId, newColumnId, newPosition } }); throw failed.error; } } export interface RealtimeChangePayload> { event: 'INSERT' | 'UPDATE' | 'DELETE'; new: T; old: Partial; } export function subscribeToBoard( supabase: SupabaseClient, boardId: string, columnIds: string[], onColumnChange: (payload: RealtimeChangePayload) => void, onCardChange: (payload: RealtimeChangePayload) => void ) { const channel = supabase.channel(`kanban:${boardId}`); const columnIdSet = new Set(columnIds); channel .on('postgres_changes', { event: '*', schema: 'public', table: 'kanban_columns', filter: `board_id=eq.${boardId}` }, (payload) => onColumnChange({ event: payload.eventType as 'INSERT' | 'UPDATE' | 'DELETE', new: payload.new as KanbanColumn, old: payload.old as Partial, }) ) .on('postgres_changes', { event: '*', schema: 'public', table: 'kanban_cards' }, (payload) => { // Client-side filter: only process cards belonging to this board's columns const card = (payload.new ?? payload.old) as Partial; const colId = card.column_id ?? (payload.old as Partial)?.column_id; if (colId && !columnIdSet.has(colId)) return; onCardChange({ event: payload.eventType as 'INSERT' | 'UPDATE' | 'DELETE', new: payload.new as KanbanCard, old: payload.old as Partial, }); } ) .subscribe(); return channel; }