Mega push vol 5, working on messaging now

This commit is contained in:
AlacrisDevs
2026-02-07 01:31:55 +02:00
parent d8bbfd9dc3
commit e55881b38b
77 changed files with 8478 additions and 1554 deletions

View File

@@ -34,30 +34,30 @@ export async function fetchBoardWithColumns(
supabase: SupabaseClient<Database>,
boardId: string
): Promise<BoardWithColumns | null> {
const { data: board, error: boardError } = await supabase
.from('kanban_boards')
.select('*')
.eq('id', boardId)
.single();
// 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 (boardError) {
log.error('fetchBoardWithColumns failed (board)', { error: boardError, data: { boardId } });
throw boardError;
if (boardResult.error) {
log.error('fetchBoardWithColumns failed (board)', { error: boardResult.error, data: { boardId } });
throw boardResult.error;
}
if (!board) return null;
if (!boardResult.data) return null;
const { data: columns, error: colError } = await supabase
.from('kanban_columns')
.select('*')
.eq('board_id', boardId)
.order('position');
if (colError) {
log.error('fetchBoardWithColumns failed (columns)', { error: colError, data: { boardId } });
throw colError;
if (columnsResult.error) {
log.error('fetchBoardWithColumns failed (columns)', { error: columnsResult.error, data: { boardId } });
throw columnsResult.error;
}
const columnIds = (columns ?? []).map((c) => c.id);
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')
@@ -70,42 +70,71 @@ export async function fetchBoardWithColumns(
throw cardError;
}
// Fetch tags for all cards in one query
const cardIds = (cards ?? []).map((c) => c.id);
let cardTagsMap = new Map<string, { id: string; name: string; color: string | null }[]>();
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 { data: cardTags } = await supabase
.from('card_tags')
.select('card_id, tags:tag_id (id, name, color)')
.in('card_id', cardIds);
const assigneeIds = [...new Set((cards ?? []).map((c) => c.assignee_id).filter(Boolean))] as string[];
(cardTags ?? []).forEach((ct: any) => {
const tag = Array.isArray(ct.tags) ? ct.tags[0] : ct.tags;
// 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;
if (!cardTagsMap.has(ct.card_id)) {
cardTagsMap.set(ct.card_id, []);
const cardId = ct.card_id as string;
if (!cardTagsMap.has(cardId)) {
cardTagsMap.set(cardId, []);
}
cardTagsMap.get(ct.card_id)!.push(tag);
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 }[] })[]>();
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) ?? []
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) => ({
columns: columns.map((col) => ({
...col,
cards: cardsByColumn.get(col.id) ?? []
}))
@@ -283,39 +312,76 @@ export async function moveCard(
...otherCards.slice(newPosition),
];
// Batch update: move card to column + set position, then update siblings
const updates = reordered.map((c, i) => {
if (c.id === cardId) {
// 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({ column_id: newColumnId, position: i })
.update({ position: i })
.eq('id', c.id);
}
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.error);
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,
onColumnChange: () => void,
onCardChange: () => void
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}` }, onColumnChange)
.on('postgres_changes', { event: '*', schema: 'public', table: 'kanban_cards' }, onCardChange)
.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;