export class ApiError extends Error { status: number; body: Record; constructor(status: number, body: Record) { const msg = (body.error as string) || `API error: ${status}`; super(msg); this.name = "ApiError"; this.status = status; this.body = body; } } async function handleResponse(res: Response, path: string): Promise { if (!res.ok) { let body: Record = {}; try { body = await res.json(); } catch { body = { error: `API error: ${res.status}` }; } // Redirect to login on 401 (except for auth endpoints) if (res.status === 401 && !path.startsWith("/api/auth/")) { const { default: router } = await import("@/router/index"); const currentPath = window.location.pathname; if (currentPath !== "/login" && currentPath !== "/register") { router.push({ name: "login", query: { redirect: currentPath } }); } } throw new ApiError(res.status, body); } // Handle 204 No Content if (res.status === 204) { return undefined as T; } return res.json() as Promise; } export async function apiGet(path: string): Promise { const res = await fetch(path); return handleResponse(res, path); } export async function apiPost(path: string, body: unknown): Promise { const res = await fetch(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); return handleResponse(res, path); } export async function apiPut(path: string, body: unknown): Promise { const res = await fetch(path, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); return handleResponse(res, path); } export async function apiPatch(path: string, body: unknown): Promise { const res = await fetch(path, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); return handleResponse(res, path); } export async function apiDelete(path: string): Promise { const res = await fetch(path, { method: "DELETE" }); return handleResponse(res, path); } // --------------------------------------------------------------------------- // Sharing, Groups, Notifications, User search // --------------------------------------------------------------------------- export interface ShareEntry { id: number permission: string shared_with_user_id: number | null shared_with_group_id: number | null username?: string group_name?: string invited_by: number created_at: string } export interface GroupEntry { id: number name: string description: string | null created_by: number | null member_count: number is_member: boolean created_at: string updated_at: string } export interface GroupMember { id: number group_id: number user_id: number role: string username: string email: string | null created_at: string } export interface NotificationEntry { id: number user_id: number type: string payload: Record read_at: string | null created_at: string } export interface UserSearchResult { id: number username: string } // --- User search --- export const searchUsers = (q: string) => apiGet<{ users: UserSearchResult[] }>(`/api/users/search?q=${encodeURIComponent(q)}`).then(r => r.users) // --- Groups --- export const listGroups = () => apiGet<{ groups: GroupEntry[] }>('/api/groups').then(r => r.groups) export const createGroup = (name: string, description?: string) => apiPost('/api/groups', { name, description }) export const updateGroup = (id: number, data: { name?: string; description?: string }) => apiPatch(`/api/groups/${id}`, data) export const deleteGroup = (id: number) => apiDelete(`/api/groups/${id}`) export const getGroupDetail = (id: number) => apiGet(`/api/groups/${id}`) export const listGroupMembers = (id: number) => apiGet<{ members: GroupMember[] }>(`/api/groups/${id}/members`).then(r => r.members) export const addGroupMember = (groupId: number, userId: number, role: string) => apiPost(`/api/groups/${groupId}/members`, { user_id: userId, role }) export const updateGroupMember = (groupId: number, userId: number, role: string) => apiPatch(`/api/groups/${groupId}/members/${userId}`, { role }) export const removeGroupMember = (groupId: number, userId: number) => apiDelete(`/api/groups/${groupId}/members/${userId}`) // --- Project shares --- export const listProjectShares = (projectId: number) => apiGet<{ shares: ShareEntry[] }>(`/api/projects/${projectId}/shares`).then(r => r.shares) export const createProjectShare = (projectId: number, body: { user_id?: number; group_id?: number; permission: string }) => apiPost(`/api/projects/${projectId}/shares`, body) export const updateProjectShare = (projectId: number, shareId: number, permission: string) => apiPatch(`/api/projects/${projectId}/shares/${shareId}`, { permission }) export const deleteProjectShare = (projectId: number, shareId: number) => apiDelete(`/api/projects/${projectId}/shares/${shareId}`) // --- Note / task shares --- export const listNoteShares = (noteId: number) => apiGet<{ shares: ShareEntry[] }>(`/api/notes/${noteId}/shares`).then(r => r.shares) export const createNoteShare = (noteId: number, body: { user_id?: number; group_id?: number; permission: string }) => apiPost(`/api/notes/${noteId}/shares`, body) export const updateNoteShare = (noteId: number, shareId: number, permission: string) => apiPatch(`/api/notes/${noteId}/shares/${shareId}`, { permission }) export const deleteNoteShare = (noteId: number, shareId: number) => apiDelete(`/api/notes/${noteId}/shares/${shareId}`) // --- Shared-with-me --- export const getSharedWithMe = () => apiGet<{ projects: Record[]; notes: Record[] }>('/api/shared-with-me') // --- In-app notifications --- export const getNotifications = (all = false) => apiGet<{ notifications: NotificationEntry[] }>(`/api/notifications${all ? '?all=true' : ''}`).then(r => r.notifications) export const getNotificationCount = () => apiGet<{ count: number }>('/api/notifications/count').then(r => r.count) export const markNotificationRead = (id: number) => apiPost(`/api/notifications/${id}/read`, {}) export const markAllNotificationsRead = () => apiPost<{ marked: number }>('/api/notifications/read-all', {}) export interface SSEStreamHandle { close(): void; /** Resolves when the stream closes (normally or via error/abort). */ done: Promise; } export interface SSENamedEvent { id: number; event: string; data: Record; } export function apiSSEStream( path: string, onEvent: (event: SSENamedEvent) => void, options?: { lastEventId?: number; signal?: AbortSignal }, ): SSEStreamHandle { const controller = new AbortController(); const combinedSignal = options?.signal ? AbortSignal.any([controller.signal, options.signal]) : controller.signal; const headers: Record = {}; if (options?.lastEventId !== undefined) { headers["Last-Event-ID"] = String(options.lastEventId); } const done = (async () => { const res = await fetch(path, { headers, signal: combinedSignal }); if (!res.ok) { let body: Record = {}; try { body = await res.json(); } catch { body = { error: `API error: ${res.status}` }; } if (res.status === 401 && !path.startsWith("/api/auth/")) { const { default: router } = await import("@/router/index"); router.push({ name: "login" }); } throw new ApiError(res.status, body); } const reader = res.body?.getReader(); if (!reader) throw new Error("No response body"); const decoder = new TextDecoder(); let buffer = ""; // SSE field parsing state let currentId = -1; let currentEvent = "message"; let currentData = ""; function dispatch() { if (!currentData) return; let parsed: Record; try { parsed = JSON.parse(currentData); } catch { return; } onEvent({ id: currentId, event: currentEvent, data: parsed }); // Reset for next event currentEvent = "message"; currentData = ""; } function processLine(line: string) { if (line === "") { // Empty line = end of event dispatch(); return; } if (line.startsWith(":")) return; // comment / keepalive const colonIdx = line.indexOf(":"); let field: string; let value: string; if (colonIdx === -1) { field = line; value = ""; } else { field = line.slice(0, colonIdx); value = line.slice(colonIdx + 1); if (value.startsWith(" ")) value = value.slice(1); } switch (field) { case "id": currentId = parseInt(value, 10); break; case "event": currentEvent = value; break; case "data": currentData += value; break; } } while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n"); buffer = lines.pop() || ""; for (const line of lines) { processLine(line); } } // Process remaining buffer if (buffer) { processLine(buffer); } dispatch(); })().catch(() => { // Stream closed or aborted — handled by caller via reconnection }); return { close: () => controller.abort(), done }; } export async function apiStreamPost( path: string, body: unknown, onChunk: (data: Record) => void ): Promise { const res = await fetch(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (!res.ok) { let errBody: Record = {}; try { errBody = await res.json(); } catch { errBody = { error: `API error: ${res.status}` }; } if (res.status === 401 && !path.startsWith("/api/auth/")) { const { default: router } = await import("@/router/index"); router.push({ name: "login" }); } throw new ApiError(res.status, errBody); } const reader = res.body?.getReader(); if (!reader) throw new Error("No response body"); const decoder = new TextDecoder(); let buffer = ""; function processLine(line: string) { const trimmed = line.trim(); if (trimmed.startsWith("data: ")) { let data; try { data = JSON.parse(trimmed.slice(6)); } catch { return; // Skip malformed JSON lines } onChunk(data); } } function processBuffer() { const lines = buffer.split("\n"); // Keep the last (possibly incomplete) line in the buffer buffer = lines.pop() || ""; for (const line of lines) { processLine(line); } } try { while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); processBuffer(); } } catch { // Stream may close with a network error after all data was sent. } // Flush any remaining complete lines in the buffer if (buffer.trim()) { // The buffer may contain one or more unprocessed lines const remaining = buffer; buffer = ""; for (const line of remaining.split("\n")) { processLine(line); } } } // --------------------------------------------------------------------------- // Calendar events // --------------------------------------------------------------------------- export interface EventEntry { id: number; uid: string; title: string; start_dt: string; end_dt: string | null; all_day: boolean; description: string; location: string; color: string; recurrence: string | null; caldav_uid: string; project_id: number | null; user_id: number; created_at: string | null; updated_at: string | null; } export interface EventCreatePayload { title: string; start_dt: string; end_dt?: string; all_day?: boolean; description?: string; location?: string; color?: string; recurrence?: string; project_id?: number; } export interface EventUpdatePayload { title?: string; start_dt?: string; end_dt?: string; all_day?: boolean; description?: string; location?: string; color?: string; recurrence?: string | null; project_id?: number; } export async function listEvents(from: string, to: string): Promise { return apiGet(`/api/events?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`); } export async function createEvent(payload: EventCreatePayload): Promise { return apiPost('/api/events', payload); } export async function getEvent(id: number): Promise { return apiGet(`/api/events/${id}`); } export async function updateEvent(id: number, payload: EventUpdatePayload): Promise { return apiPatch(`/api/events/${id}`, payload); } export async function deleteEvent(id: number): Promise { return apiDelete(`/api/events/${id}`); } // ─── API Keys ───────────────────────────────────────────────────────────────── export interface ApiKeyEntry { id: number name: string scope: string key_prefix: string last_used_at: string | null } export const listApiKeys = () => apiGet<{ api_keys: ApiKeyEntry[] }>('/api/api-keys').then(r => r.api_keys) export const createApiKey = (name: string, scope: 'read' | 'write') => apiPost<{ key: string; api_key: ApiKeyEntry }>('/api/api-keys', { name, scope }) export const revokeApiKey = (id: number) => apiDelete(`/api/api-keys/${id}`) // ── User Profile ───────────────────────────────────────────────────────────── export interface UserProfile { display_name: string job_title: string industry: string expertise_level: 'novice' | 'intermediate' | 'expert' response_style: 'concise' | 'balanced' | 'detailed' tone: 'casual' | 'professional' | 'technical' interests: string[] work_schedule: { days?: string[]; start?: string; end?: string } } export const getProfile = () => apiGet('/api/profile') export const updateProfile = (data: Partial) => apiPut('/api/profile', data) // ── Note Versions (pinning) ────────────────────────────────────────────────── import type { NoteVersion } from '../types/task' /** Mark a note version as manually pinned, optionally with a commit-note * label. Re-calling with a different label updates the label. */ export const pinNoteVersion = (noteId: number, versionId: number, label?: string | null) => apiPost( `/api/notes/${noteId}/versions/${versionId}/pin`, { label: label ?? null }, ) /** Downgrade a manually-pinned version back to rolling. Does NOT delete * the row — older rows may be FIFO-pruned by the next autosave. */ export const unpinNoteVersion = (noteId: number, versionId: number) => apiDelete(`/api/notes/${noteId}/versions/${versionId}/pin`)