Files
root-org/src/lib/api/kanban.ts
2026-02-07 01:31:55 +02:00

389 lines
11 KiB
TypeScript

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<Database>,
orgId: string
): Promise<KanbanBoard[]> {
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<Database>,
boardId: string
): Promise<BoardWithColumns | null> {
// 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<string, { id: string; name: string; color: string | null }[]>();
const checklistMap = new Map<string, { total: number; done: number }>();
const assigneeMap = new Map<string, { name: string | null; avatar: string | null }>();
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<string, unknown>) => {
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<string, unknown>) => {
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<string, unknown>) => {
assigneeMap.set(p.id as string, { name: p.full_name as string | null, avatar: p.avatar_url as string | null });
});
}
const cardsByColumn = new Map<string, (KanbanCard & { tags?: { id: string; name: string; color: string | null }[]; checklist_total?: number; checklist_done?: number; assignee_name?: string | null; assignee_avatar?: string | null })[]>();
(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<Database>,
orgId: string,
name: string
): Promise<KanbanBoard> {
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<Database>,
id: string,
name: string
): Promise<void> {
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<Database>,
id: string
): Promise<void> {
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<Database>,
boardId: string,
name: string,
position: number
): Promise<KanbanColumn> {
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<Database>,
id: string,
updates: Partial<Pick<KanbanColumn, 'name' | 'position' | 'color'>>
): Promise<void> {
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<Database>,
id: string
): Promise<void> {
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<Database>,
columnId: string,
title: string,
position: number,
userId: string
): Promise<KanbanCard> {
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<Database>,
id: string,
updates: Partial<Pick<KanbanCard, 'title' | 'description' | 'column_id' | 'position' | 'due_date' | 'color'>>
): Promise<void> {
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<Database>,
id: string
): Promise<void> {
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<Database>,
cardId: string,
newColumnId: string,
newPosition: number
): Promise<void> {
// 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<T = Record<string, unknown>> {
event: 'INSERT' | 'UPDATE' | 'DELETE';
new: T;
old: Partial<T>;
}
export function subscribeToBoard(
supabase: SupabaseClient<Database>,
boardId: string,
columnIds: string[],
onColumnChange: (payload: RealtimeChangePayload<KanbanColumn>) => void,
onCardChange: (payload: RealtimeChangePayload<KanbanCard>) => 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<KanbanColumn>,
})
)
.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<KanbanCard>;
const colId = card.column_id ?? (payload.old as Partial<KanbanCard>)?.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<KanbanCard>,
});
}
)
.subscribe();
return channel;
}