389 lines
11 KiB
TypeScript
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;
|
|
}
|