Mega push vol 4

This commit is contained in:
AlacrisDevs
2026-02-06 16:08:40 +02:00
parent b517bb975c
commit d8bbfd9dc3
95 changed files with 8019 additions and 3946 deletions

View File

@@ -1,5 +1,8 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database, CalendarEvent } from '$lib/supabase/types';
import { createLogger } from '$lib/utils/logger';
const log = createLogger('api.calendar');
export async function fetchEvents(
supabase: SupabaseClient<Database>,
@@ -15,7 +18,10 @@ export async function fetchEvents(
.lte('end_time', endDate.toISOString())
.order('start_time');
if (error) throw error;
if (error) {
log.error('fetchEvents failed', { error, data: { orgId } });
throw error;
}
return data ?? [];
}
@@ -47,7 +53,10 @@ export async function createEvent(
.select()
.single();
if (error) throw error;
if (error) {
log.error('createEvent failed', { error, data: { orgId, title: event.title } });
throw error;
}
return data;
}
@@ -57,7 +66,10 @@ export async function updateEvent(
updates: Partial<Pick<CalendarEvent, 'title' | 'description' | 'start_time' | 'end_time' | 'all_day' | 'color'>>
): Promise<void> {
const { error } = await supabase.from('calendar_events').update(updates).eq('id', id);
if (error) throw error;
if (error) {
log.error('updateEvent failed', { error, data: { id, updates } });
throw error;
}
}
export async function deleteEvent(
@@ -65,7 +77,10 @@ export async function deleteEvent(
id: string
): Promise<void> {
const { error } = await supabase.from('calendar_events').delete().eq('id', id);
if (error) throw error;
if (error) {
log.error('deleteEvent failed', { error, data: { id } });
throw error;
}
}
export function subscribeToEvents(
@@ -85,8 +100,11 @@ export function getMonthDays(year: number, month: number): Date[] {
const lastDay = new Date(year, month + 1, 0);
const days: Date[] = [];
// Week starts on Monday (0=Mon, 6=Sun)
let startDayOfWeek = firstDay.getDay() - 1;
if (startDayOfWeek < 0) startDayOfWeek = 6; // Sunday becomes 6
// Add days from previous month to fill first week
const startDayOfWeek = firstDay.getDay();
for (let i = startDayOfWeek - 1; i >= 0; i--) {
days.push(new Date(year, month, -i));
}
@@ -96,8 +114,8 @@ export function getMonthDays(year: number, month: number): Date[] {
days.push(new Date(year, month, i));
}
// Add days from next month to fill last week
const remainingDays = 42 - days.length; // 6 weeks * 7 days
// Add days from next month to fill last week (up to 6 rows)
const remainingDays = 42 - days.length;
for (let i = 1; i <= remainingDays; i++) {
days.push(new Date(year, month + 1, i));
}

View File

@@ -0,0 +1,152 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '$lib/supabase/types';
import { createLogger } from '$lib/utils/logger';
const log = createLogger('api.document-locks');
const LOCK_EXPIRY_SECONDS = 60;
const HEARTBEAT_INTERVAL_MS = 30_000; // 30 seconds
export interface LockInfo {
isLocked: boolean;
lockedBy: string | null;
lockedByName: string | null;
isOwnLock: boolean;
}
/**
* Get the current lock status for a document.
* Only returns active locks (heartbeat within LOCK_EXPIRY_SECONDS).
*/
export async function getLockInfo(
supabase: SupabaseClient<Database>,
documentId: string,
currentUserId: string
): Promise<LockInfo> {
const cutoff = new Date(Date.now() - LOCK_EXPIRY_SECONDS * 1000).toISOString();
const { data: lock } = await supabase
.from('document_locks')
.select(`
id,
document_id,
user_id,
locked_at,
last_heartbeat,
profiles:user_id (full_name, email)
`)
.eq('document_id', documentId)
.gt('last_heartbeat', cutoff)
.single();
if (!lock) {
return { isLocked: false, lockedBy: null, lockedByName: null, isOwnLock: false };
}
const profile = (lock as any).profiles; // join type not inferred by Supabase
return {
isLocked: true,
lockedBy: lock.user_id,
lockedByName: profile?.full_name || profile?.email || 'Someone',
isOwnLock: lock.user_id === currentUserId,
};
}
/**
* Acquire a lock on a document. Cleans up expired locks first.
* Returns true if lock was acquired, false if someone else holds it.
*/
export async function acquireLock(
supabase: SupabaseClient<Database>,
documentId: string,
userId: string
): Promise<boolean> {
const cutoff = new Date(Date.now() - LOCK_EXPIRY_SECONDS * 1000).toISOString();
// Delete expired locks for this document
await supabase
.from('document_locks')
.delete()
.eq('document_id', documentId)
.lt('last_heartbeat', cutoff);
// Try to insert our lock
const { error } = await supabase
.from('document_locks')
.insert({
document_id: documentId,
user_id: userId,
locked_at: new Date().toISOString(),
last_heartbeat: new Date().toISOString(),
});
if (error) {
if (error.code === '23505') {
// Unique constraint violation — someone else holds the lock
log.debug('Lock already held', { data: { documentId } });
return false;
}
log.error('acquireLock failed', { error, data: { documentId } });
return false;
}
log.info('Lock acquired', { data: { documentId, userId } });
return true;
}
/**
* Send a heartbeat to keep the lock alive.
*/
export async function heartbeatLock(
supabase: SupabaseClient<Database>,
documentId: string,
userId: string
): Promise<boolean> {
const { error } = await supabase
.from('document_locks')
.update({ last_heartbeat: new Date().toISOString() })
.eq('document_id', documentId)
.eq('user_id', userId);
if (error) {
log.error('heartbeatLock failed', { error, data: { documentId } });
return false;
}
return true;
}
/**
* Release a lock on a document.
*/
export async function releaseLock(
supabase: SupabaseClient<Database>,
documentId: string,
userId: string
): Promise<void> {
const { error } = await supabase
.from('document_locks')
.delete()
.eq('document_id', documentId)
.eq('user_id', userId);
if (error) {
log.error('releaseLock failed', { error, data: { documentId } });
} else {
log.info('Lock released', { data: { documentId, userId } });
}
}
/**
* Start a heartbeat interval. Returns a cleanup function.
*/
export function startHeartbeat(
supabase: SupabaseClient<Database>,
documentId: string,
userId: string
): () => void {
const interval = setInterval(() => {
heartbeatLock(supabase, documentId, userId);
}, HEARTBEAT_INTERVAL_MS);
return () => clearInterval(interval);
}

View File

@@ -1,9 +1,8 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database, Document } from '$lib/supabase/types';
import { createLogger } from '$lib/utils/logger';
export interface DocumentWithChildren extends Document {
children?: DocumentWithChildren[];
}
const log = createLogger('api.documents');
export async function fetchDocuments(
supabase: SupabaseClient<Database>,
@@ -16,7 +15,11 @@ export async function fetchDocuments(
.order('type', { ascending: false }) // folders first
.order('name');
if (error) throw error;
if (error) {
log.error('fetchDocuments failed', { error, data: { orgId } });
throw error;
}
log.debug('fetchDocuments ok', { data: { count: data?.length ?? 0 } });
return data ?? [];
}
@@ -41,7 +44,11 @@ export async function createDocument(
.select()
.single();
if (error) throw error;
if (error) {
log.error('createDocument failed', { error, data: { orgId, name, type, parentId } });
throw error;
}
log.info('createDocument ok', { data: { id: data.id, name, type } });
return data;
}
@@ -57,7 +64,10 @@ export async function updateDocument(
.select()
.single();
if (error) throw error;
if (error) {
log.error('updateDocument failed', { error, data: { id, updates } });
throw error;
}
return data;
}
@@ -66,7 +76,10 @@ export async function deleteDocument(
id: string
): Promise<void> {
const { error } = await supabase.from('documents').delete().eq('id', id);
if (error) throw error;
if (error) {
log.error('deleteDocument failed', { error, data: { id } });
throw error;
}
}
export async function moveDocument(
@@ -79,30 +92,12 @@ export async function moveDocument(
.update({ parent_id: newParentId, updated_at: new Date().toISOString() })
.eq('id', id);
if (error) throw error;
if (error) {
log.error('moveDocument failed', { error, data: { id, newParentId } });
throw error;
}
}
export function buildDocumentTree(documents: Document[]): DocumentWithChildren[] {
const map = new Map<string, DocumentWithChildren>();
const roots: DocumentWithChildren[] = [];
// First pass: create map
documents.forEach((doc) => {
map.set(doc.id, { ...doc, children: [] });
});
// Second pass: build tree
documents.forEach((doc) => {
const node = map.get(doc.id)!;
if (doc.parent_id && map.has(doc.parent_id)) {
map.get(doc.parent_id)!.children!.push(node);
} else {
roots.push(node);
}
});
return roots;
}
export function subscribeToDocuments(
supabase: SupabaseClient<Database>,

View File

@@ -1,5 +1,8 @@
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[];
@@ -19,7 +22,11 @@ export async function fetchBoards(
.eq('org_id', orgId)
.order('created_at');
if (error) throw error;
if (error) {
log.error('fetchBoards failed', { error, data: { orgId } });
throw error;
}
log.debug('fetchBoards ok', { data: { count: data?.length ?? 0 } });
return data ?? [];
}
@@ -33,7 +40,10 @@ export async function fetchBoardWithColumns(
.eq('id', boardId)
.single();
if (boardError) throw boardError;
if (boardError) {
log.error('fetchBoardWithColumns failed (board)', { error: boardError, data: { boardId } });
throw boardError;
}
if (!board) return null;
const { data: columns, error: colError } = await supabase
@@ -42,22 +52,55 @@ export async function fetchBoardWithColumns(
.eq('board_id', boardId)
.order('position');
if (colError) throw colError;
if (colError) {
log.error('fetchBoardWithColumns failed (columns)', { error: colError, data: { boardId } });
throw colError;
}
const columnIds = (columns ?? []).map((c) => c.id);
const { data: cards, error: cardError } = await supabase
.from('kanban_cards')
.select('*')
.in('column_id', (columns ?? []).map((c) => c.id))
.in('column_id', columnIds)
.order('position');
if (cardError) throw cardError;
if (cardError) {
log.error('fetchBoardWithColumns failed (cards)', { error: cardError, data: { boardId } });
throw cardError;
}
const cardsByColumn = new Map<string, KanbanCard[]>();
// 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 }[]>();
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);
(cardTags ?? []).forEach((ct: any) => {
const tag = Array.isArray(ct.tags) ? ct.tags[0] : ct.tags;
if (!tag) return;
if (!cardTagsMap.has(ct.card_id)) {
cardTagsMap.set(ct.card_id, []);
}
cardTagsMap.get(ct.card_id)!.push(tag);
});
}
const cardsByColumn = new Map<string, (KanbanCard & { tags?: { id: string; name: string; color: string | null }[] })[]>();
(cards ?? []).forEach((card) => {
if (!cardsByColumn.has(card.column_id)) {
cardsByColumn.set(card.column_id, []);
const colId = card.column_id;
if (!colId) return;
if (!cardsByColumn.has(colId)) {
cardsByColumn.set(colId, []);
}
cardsByColumn.get(card.column_id)!.push(card);
cardsByColumn.get(colId)!.push({
...card,
tags: cardTagsMap.get(card.id) ?? []
});
});
return {
@@ -74,13 +117,17 @@ export async function createBoard(
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) throw error;
if (error) {
log.error('createBoard failed', { error, data: { orgId, name } });
throw error;
}
// Create default columns
const defaultColumns = ['To Do', 'In Progress', 'Done'];
@@ -101,7 +148,10 @@ export async function updateBoard(
name: string
): Promise<void> {
const { error } = await supabase.from('kanban_boards').update({ name }).eq('id', id);
if (error) throw error;
if (error) {
log.error('updateBoard failed', { error, data: { id, name } });
throw error;
}
}
export async function deleteBoard(
@@ -109,7 +159,10 @@ export async function deleteBoard(
id: string
): Promise<void> {
const { error } = await supabase.from('kanban_boards').delete().eq('id', id);
if (error) throw error;
if (error) {
log.error('deleteBoard failed', { error, data: { id } });
throw error;
}
}
export async function createColumn(
@@ -124,7 +177,10 @@ export async function createColumn(
.select()
.single();
if (error) throw error;
if (error) {
log.error('createColumn failed', { error, data: { boardId, name, position } });
throw error;
}
return data;
}
@@ -134,7 +190,10 @@ export async function updateColumn(
updates: Partial<Pick<KanbanColumn, 'name' | 'position' | 'color'>>
): Promise<void> {
const { error } = await supabase.from('kanban_columns').update(updates).eq('id', id);
if (error) throw error;
if (error) {
log.error('updateColumn failed', { error, data: { id, updates } });
throw error;
}
}
export async function deleteColumn(
@@ -142,7 +201,10 @@ export async function deleteColumn(
id: string
): Promise<void> {
const { error } = await supabase.from('kanban_columns').delete().eq('id', id);
if (error) throw error;
if (error) {
log.error('deleteColumn failed', { error, data: { id } });
throw error;
}
}
export async function createCard(
@@ -163,7 +225,10 @@ export async function createCard(
.select()
.single();
if (error) throw error;
if (error) {
log.error('createCard failed', { error, data: { columnId, title, position } });
throw error;
}
return data;
}
@@ -173,7 +238,10 @@ export async function updateCard(
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) throw error;
if (error) {
log.error('updateCard failed', { error, data: { id, updates } });
throw error;
}
}
export async function deleteCard(
@@ -181,7 +249,10 @@ export async function deleteCard(
id: string
): Promise<void> {
const { error } = await supabase.from('kanban_cards').delete().eq('id', id);
if (error) throw error;
if (error) {
log.error('deleteCard failed', { error, data: { id } });
throw error;
}
}
export async function moveCard(
@@ -190,12 +261,48 @@ export async function moveCard(
newColumnId: string,
newPosition: number
): Promise<void> {
const { error } = await supabase
// Fetch all cards in the target column (ordered by position)
const { data: targetCards, error: fetchErr } = await supabase
.from('kanban_cards')
.update({ column_id: newColumnId, position: newPosition })
.eq('id', cardId);
.select('id, position')
.eq('column_id', newColumnId)
.order('position');
if (error) throw error;
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),
];
// Batch update: move card to column + set position, then update siblings
const updates = reordered.map((c, i) => {
if (c.id === cardId) {
return supabase
.from('kanban_cards')
.update({ column_id: newColumnId, position: i })
.eq('id', c.id);
}
return supabase
.from('kanban_cards')
.update({ position: i })
.eq('id', c.id);
});
const results = await Promise.all(updates);
const failed = results.find((r) => r.error);
if (failed?.error) {
log.error('moveCard failed', { error: failed.error, data: { cardId, newColumnId, newPosition } });
throw failed.error;
}
}
export function subscribeToBoard(

View File

@@ -1,6 +1,13 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database, Organization, MemberRole } from '$lib/supabase/types';
import type { OrgWithRole } from '$lib/stores/organizations.svelte';
import { createLogger } from '$lib/utils/logger';
export interface OrgWithRole extends Organization {
role: MemberRole;
memberCount?: number;
}
const log = createLogger('api.organizations');
export async function fetchUserOrganizations(
supabase: SupabaseClient<Database>
@@ -20,7 +27,10 @@ export async function fetchUserOrganizations(
`)
.not('joined_at', 'is', null);
if (error) throw error;
if (error) {
log.error('fetchUserOrganizations failed', { error });
throw error;
}
return (data ?? [])
.filter((item) => item.organizations)
@@ -35,13 +45,17 @@ export async function createOrganization(
name: string,
slug: string
): Promise<Organization> {
log.info('createOrganization', { data: { name, slug } });
const { data, error } = await supabase
.from('organizations')
.insert({ name, slug })
.select()
.single();
if (error) throw error;
if (error) {
log.error('createOrganization failed', { error, data: { name, slug } });
throw error;
}
return data;
}
@@ -57,7 +71,10 @@ export async function updateOrganization(
.select()
.single();
if (error) throw error;
if (error) {
log.error('updateOrganization failed', { error, data: { id, updates } });
throw error;
}
return data;
}
@@ -66,7 +83,10 @@ export async function deleteOrganization(
id: string
): Promise<void> {
const { error } = await supabase.from('organizations').delete().eq('id', id);
if (error) throw error;
if (error) {
log.error('deleteOrganization failed', { error, data: { id } });
throw error;
}
}
export async function fetchOrgMembers(
@@ -90,7 +110,10 @@ export async function fetchOrgMembers(
`)
.eq('org_id', orgId);
if (error) throw error;
if (error) {
log.error('fetchOrgMembers failed', { error, data: { orgId } });
throw error;
}
return data ?? [];
}
@@ -108,6 +131,7 @@ export async function inviteMember(
.single();
if (profileError || !profile) {
log.warn('inviteMember: user not found', { data: { email } });
throw new Error('User not found. They need to sign up first.');
}
@@ -120,6 +144,7 @@ export async function inviteMember(
.single();
if (existing) {
log.warn('inviteMember: already a member', { data: { email, orgId } });
throw new Error('User is already a member of this organization.');
}
@@ -131,7 +156,10 @@ export async function inviteMember(
joined_at: new Date().toISOString() // Auto-join for now
});
if (error) throw error;
if (error) {
log.error('inviteMember failed', { error, data: { orgId, email, role } });
throw error;
}
}
export async function updateMemberRole(
@@ -144,7 +172,10 @@ export async function updateMemberRole(
.update({ role })
.eq('id', memberId);
if (error) throw error;
if (error) {
log.error('updateMemberRole failed', { error, data: { memberId, role } });
throw error;
}
}
export async function removeMember(
@@ -152,7 +183,10 @@ export async function removeMember(
memberId: string
): Promise<void> {
const { error } = await supabase.from('org_members').delete().eq('id', memberId);
if (error) throw error;
if (error) {
log.error('removeMember failed', { error, data: { memberId } });
throw error;
}
}
export function generateSlug(name: string): string {