First commit

This commit is contained in:
AlacrisDevs
2026-02-04 23:01:44 +02:00
commit cfec43f7ef
78 changed files with 9509 additions and 0 deletions

118
src/lib/api/calendar.ts Normal file
View File

@@ -0,0 +1,118 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database, CalendarEvent } from '$lib/supabase/types';
export async function fetchEvents(
supabase: SupabaseClient<Database>,
orgId: string,
startDate: Date,
endDate: Date
): Promise<CalendarEvent[]> {
const { data, error } = await supabase
.from('calendar_events')
.select('*')
.eq('org_id', orgId)
.gte('start_time', startDate.toISOString())
.lte('end_time', endDate.toISOString())
.order('start_time');
if (error) throw error;
return data ?? [];
}
export async function createEvent(
supabase: SupabaseClient<Database>,
orgId: string,
event: {
title: string;
description?: string;
start_time: string;
end_time: string;
all_day?: boolean;
color?: string;
},
userId: string
): Promise<CalendarEvent> {
const { data, error } = await supabase
.from('calendar_events')
.insert({
org_id: orgId,
title: event.title,
description: event.description,
start_time: event.start_time,
end_time: event.end_time,
all_day: event.all_day ?? false,
color: event.color,
created_by: userId
})
.select()
.single();
if (error) throw error;
return data;
}
export async function updateEvent(
supabase: SupabaseClient<Database>,
id: string,
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;
}
export async function deleteEvent(
supabase: SupabaseClient<Database>,
id: string
): Promise<void> {
const { error } = await supabase.from('calendar_events').delete().eq('id', id);
if (error) throw error;
}
export function subscribeToEvents(
supabase: SupabaseClient<Database>,
orgId: string,
onChange: () => void
) {
return supabase
.channel(`calendar:${orgId}`)
.on('postgres_changes', { event: '*', schema: 'public', table: 'calendar_events', filter: `org_id=eq.${orgId}` }, onChange)
.subscribe();
}
// Calendar utility functions
export function getMonthDays(year: number, month: number): Date[] {
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
const days: Date[] = [];
// 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));
}
// Add days of current month
for (let i = 1; i <= lastDay.getDate(); i++) {
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
for (let i = 1; i <= remainingDays; i++) {
days.push(new Date(year, month + 1, i));
}
return days;
}
export function isSameDay(a: Date, b: Date): boolean {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
);
}
export function formatTime(date: Date): string {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}

132
src/lib/api/documents.ts Normal file
View File

@@ -0,0 +1,132 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database, Document } from '$lib/supabase/types';
export interface DocumentWithChildren extends Document {
children?: DocumentWithChildren[];
}
export async function fetchDocuments(
supabase: SupabaseClient<Database>,
orgId: string
): Promise<Document[]> {
const { data, error } = await supabase
.from('documents')
.select('*')
.eq('org_id', orgId)
.order('type', { ascending: false }) // folders first
.order('name');
if (error) throw error;
return data ?? [];
}
export async function createDocument(
supabase: SupabaseClient<Database>,
orgId: string,
name: string,
type: 'folder' | 'document',
parentId: string | null = null,
userId: string
): Promise<Document> {
const { data, error } = await supabase
.from('documents')
.insert({
org_id: orgId,
name,
type,
parent_id: parentId,
created_by: userId,
content: type === 'document' ? { type: 'doc', content: [] } : null
})
.select()
.single();
if (error) throw error;
return data;
}
export async function updateDocument(
supabase: SupabaseClient<Database>,
id: string,
updates: Partial<Pick<Document, 'name' | 'content' | 'parent_id'>>
): Promise<Document> {
const { data, error } = await supabase
.from('documents')
.update({ ...updates, updated_at: new Date().toISOString() })
.eq('id', id)
.select()
.single();
if (error) throw error;
return data;
}
export async function deleteDocument(
supabase: SupabaseClient<Database>,
id: string
): Promise<void> {
const { error } = await supabase.from('documents').delete().eq('id', id);
if (error) throw error;
}
export async function moveDocument(
supabase: SupabaseClient<Database>,
id: string,
newParentId: string | null
): Promise<void> {
const { error } = await supabase
.from('documents')
.update({ parent_id: newParentId, updated_at: new Date().toISOString() })
.eq('id', id);
if (error) 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>,
orgId: string,
onInsert: (doc: Document) => void,
onUpdate: (doc: Document) => void,
onDelete: (id: string) => void
) {
return supabase
.channel(`documents:${orgId}`)
.on(
'postgres_changes',
{ event: 'INSERT', schema: 'public', table: 'documents', filter: `org_id=eq.${orgId}` },
(payload) => onInsert(payload.new as Document)
)
.on(
'postgres_changes',
{ event: 'UPDATE', schema: 'public', table: 'documents', filter: `org_id=eq.${orgId}` },
(payload) => onUpdate(payload.new as Document)
)
.on(
'postgres_changes',
{ event: 'DELETE', schema: 'public', table: 'documents', filter: `org_id=eq.${orgId}` },
(payload) => onDelete((payload.old as { id: string }).id)
)
.subscribe();
}

View File

@@ -0,0 +1,264 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '$lib/supabase/types';
const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID;
const GOOGLE_CLIENT_SECRET = import.meta.env.VITE_GOOGLE_CLIENT_SECRET;
interface GoogleTokens {
access_token: string;
refresh_token: string;
expires_in: number;
}
interface GoogleCalendarEvent {
id: string;
summary: string;
description?: string;
start: { dateTime?: string; date?: string };
end: { dateTime?: string; date?: string };
colorId?: string;
}
export function getGoogleAuthUrl(redirectUri: string, state: string): string {
const params = new URLSearchParams({
client_id: GOOGLE_CLIENT_ID,
redirect_uri: redirectUri,
response_type: 'code',
scope: 'https://www.googleapis.com/auth/calendar https://www.googleapis.com/auth/calendar.events',
access_type: 'offline',
prompt: 'consent',
state
});
return `https://accounts.google.com/o/oauth2/v2/auth?${params}`;
}
export async function exchangeCodeForTokens(code: string, redirectUri: string): Promise<GoogleTokens> {
const response = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
code,
client_id: GOOGLE_CLIENT_ID,
client_secret: GOOGLE_CLIENT_SECRET,
redirect_uri: redirectUri,
grant_type: 'authorization_code'
})
});
if (!response.ok) {
throw new Error('Failed to exchange code for tokens');
}
return response.json();
}
export async function refreshAccessToken(refreshToken: string): Promise<GoogleTokens> {
const response = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
refresh_token: refreshToken,
client_id: GOOGLE_CLIENT_ID,
client_secret: GOOGLE_CLIENT_SECRET,
grant_type: 'refresh_token'
})
});
if (!response.ok) {
throw new Error('Failed to refresh access token');
}
return response.json();
}
export async function getValidAccessToken(
supabase: SupabaseClient<Database>,
userId: string
): Promise<string | null> {
const { data: connection } = await supabase
.from('google_calendar_connections')
.select('*')
.eq('user_id', userId)
.single();
if (!connection) return null;
const expiresAt = new Date(connection.token_expires_at);
const now = new Date();
// Refresh if expires within 5 minutes
if (expiresAt.getTime() - now.getTime() < 5 * 60 * 1000) {
try {
const tokens = await refreshAccessToken(connection.refresh_token);
const newExpiresAt = new Date(Date.now() + tokens.expires_in * 1000);
await supabase
.from('google_calendar_connections')
.update({
access_token: tokens.access_token,
token_expires_at: newExpiresAt.toISOString(),
updated_at: new Date().toISOString()
})
.eq('user_id', userId);
return tokens.access_token;
} catch {
return null;
}
}
return connection.access_token;
}
export async function fetchGoogleCalendarEvents(
accessToken: string,
calendarId: string = 'primary',
timeMin: Date,
timeMax: Date
): Promise<GoogleCalendarEvent[]> {
const params = new URLSearchParams({
timeMin: timeMin.toISOString(),
timeMax: timeMax.toISOString(),
singleEvents: 'true',
orderBy: 'startTime'
});
const response = await fetch(
`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events?${params}`,
{
headers: { Authorization: `Bearer ${accessToken}` }
}
);
if (!response.ok) {
throw new Error('Failed to fetch Google Calendar events');
}
const data = await response.json();
return data.items ?? [];
}
export async function createGoogleCalendarEvent(
accessToken: string,
calendarId: string = 'primary',
event: {
title: string;
description?: string;
startTime: string;
endTime: string;
allDay?: boolean;
}
): Promise<GoogleCalendarEvent> {
const body: Record<string, unknown> = {
summary: event.title,
description: event.description
};
if (event.allDay) {
const startDate = event.startTime.split('T')[0];
const endDate = event.endTime.split('T')[0];
body.start = { date: startDate };
body.end = { date: endDate };
} else {
body.start = { dateTime: event.startTime, timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone };
body.end = { dateTime: event.endTime, timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone };
}
const response = await fetch(
`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
}
);
if (!response.ok) {
throw new Error('Failed to create Google Calendar event');
}
return response.json();
}
export async function updateGoogleCalendarEvent(
accessToken: string,
calendarId: string = 'primary',
eventId: string,
event: {
title: string;
description?: string;
startTime: string;
endTime: string;
allDay?: boolean;
}
): Promise<GoogleCalendarEvent> {
const body: Record<string, unknown> = {
summary: event.title,
description: event.description
};
if (event.allDay) {
const startDate = event.startTime.split('T')[0];
const endDate = event.endTime.split('T')[0];
body.start = { date: startDate };
body.end = { date: endDate };
} else {
body.start = { dateTime: event.startTime, timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone };
body.end = { dateTime: event.endTime, timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone };
}
const response = await fetch(
`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events/${eventId}`,
{
method: 'PUT',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
}
);
if (!response.ok) {
throw new Error('Failed to update Google Calendar event');
}
return response.json();
}
export async function deleteGoogleCalendarEvent(
accessToken: string,
calendarId: string = 'primary',
eventId: string
): Promise<void> {
const response = await fetch(
`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events/${eventId}`,
{
method: 'DELETE',
headers: { Authorization: `Bearer ${accessToken}` }
}
);
if (!response.ok && response.status !== 404) {
throw new Error('Failed to delete Google Calendar event');
}
}
export async function listGoogleCalendars(accessToken: string): Promise<{ id: string; summary: string }[]> {
const response = await fetch('https://www.googleapis.com/calendar/v3/users/me/calendarList', {
headers: { Authorization: `Bearer ${accessToken}` }
});
if (!response.ok) {
throw new Error('Failed to list calendars');
}
const data = await response.json();
return (data.items ?? []).map((cal: { id: string; summary: string }) => ({
id: cal.id,
summary: cal.summary
}));
}

215
src/lib/api/kanban.ts Normal file
View File

@@ -0,0 +1,215 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database, KanbanBoard, KanbanColumn, KanbanCard } from '$lib/supabase/types';
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) throw error;
return data ?? [];
}
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();
if (boardError) throw boardError;
if (!board) return null;
const { data: columns, error: colError } = await supabase
.from('kanban_columns')
.select('*')
.eq('board_id', boardId)
.order('position');
if (colError) throw colError;
const { data: cards, error: cardError } = await supabase
.from('kanban_cards')
.select('*')
.in('column_id', (columns ?? []).map((c) => c.id))
.order('position');
if (cardError) throw cardError;
const cardsByColumn = new Map<string, KanbanCard[]>();
(cards ?? []).forEach((card) => {
if (!cardsByColumn.has(card.column_id)) {
cardsByColumn.set(card.column_id, []);
}
cardsByColumn.get(card.column_id)!.push(card);
});
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> {
const { data, error } = await supabase
.from('kanban_boards')
.insert({ org_id: orgId, name })
.select()
.single();
if (error) 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) 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) 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) 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) 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) 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) 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) 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) throw error;
}
export async function moveCard(
supabase: SupabaseClient<Database>,
cardId: string,
newColumnId: string,
newPosition: number
): Promise<void> {
const { error } = await supabase
.from('kanban_cards')
.update({ column_id: newColumnId, position: newPosition })
.eq('id', cardId);
if (error) throw error;
}
export function subscribeToBoard(
supabase: SupabaseClient<Database>,
boardId: string,
onColumnChange: () => void,
onCardChange: () => void
) {
const channel = supabase.channel(`kanban:${boardId}`);
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)
.subscribe();
return channel;
}

View File

@@ -0,0 +1,164 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database, Organization, MemberRole } from '$lib/supabase/types';
import type { OrgWithRole } from '$lib/stores/organizations.svelte';
export async function fetchUserOrganizations(
supabase: SupabaseClient<Database>
): Promise<OrgWithRole[]> {
const { data, error } = await supabase
.from('org_members')
.select(`
role,
organizations (
id,
name,
slug,
avatar_url,
created_at,
updated_at
)
`)
.not('joined_at', 'is', null);
if (error) throw error;
return (data ?? [])
.filter((item) => item.organizations)
.map((item) => ({
...(item.organizations as Organization),
role: item.role as MemberRole
}));
}
export async function createOrganization(
supabase: SupabaseClient<Database>,
name: string,
slug: string
): Promise<Organization> {
const { data, error } = await supabase
.from('organizations')
.insert({ name, slug })
.select()
.single();
if (error) throw error;
return data;
}
export async function updateOrganization(
supabase: SupabaseClient<Database>,
id: string,
updates: Partial<Pick<Organization, 'name' | 'slug' | 'avatar_url'>>
): Promise<Organization> {
const { data, error } = await supabase
.from('organizations')
.update({ ...updates, updated_at: new Date().toISOString() })
.eq('id', id)
.select()
.single();
if (error) throw error;
return data;
}
export async function deleteOrganization(
supabase: SupabaseClient<Database>,
id: string
): Promise<void> {
const { error } = await supabase.from('organizations').delete().eq('id', id);
if (error) throw error;
}
export async function fetchOrgMembers(
supabase: SupabaseClient<Database>,
orgId: string
) {
const { data, error } = await supabase
.from('org_members')
.select(`
id,
org_id,
user_id,
role,
invited_at,
joined_at,
profiles (
email,
full_name,
avatar_url
)
`)
.eq('org_id', orgId);
if (error) throw error;
return data ?? [];
}
export async function inviteMember(
supabase: SupabaseClient<Database>,
orgId: string,
email: string,
role: MemberRole = 'viewer'
): Promise<void> {
// First, find user by email
const { data: profile, error: profileError } = await supabase
.from('profiles')
.select('id')
.eq('email', email)
.single();
if (profileError || !profile) {
throw new Error('User not found. They need to sign up first.');
}
// Check if already a member
const { data: existing } = await supabase
.from('org_members')
.select('id')
.eq('org_id', orgId)
.eq('user_id', profile.id)
.single();
if (existing) {
throw new Error('User is already a member of this organization.');
}
// Create invitation
const { error } = await supabase.from('org_members').insert({
org_id: orgId,
user_id: profile.id,
role,
joined_at: new Date().toISOString() // Auto-join for now
});
if (error) throw error;
}
export async function updateMemberRole(
supabase: SupabaseClient<Database>,
memberId: string,
role: MemberRole
): Promise<void> {
const { error } = await supabase
.from('org_members')
.update({ role })
.eq('id', memberId);
if (error) throw error;
}
export async function removeMember(
supabase: SupabaseClient<Database>,
memberId: string
): Promise<void> {
const { error } = await supabase.from('org_members').delete().eq('id', memberId);
if (error) throw error;
}
export function generateSlug(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 50);
}