const API_BASE = process.env.NEXT_PUBLIC_API_URL || ''; interface FetchOptions extends RequestInit { token?: string; } async function apiFetch( endpoint: string, options: FetchOptions = {} ): Promise { const { token, ...fetchOptions } = options; const headers: Record = { 'Content-Type': 'application/json', ...(options.headers as Record || {}), }; if (token) { headers['Authorization'] = `Bearer ${token}`; } const res = await fetch(`${API_BASE}${endpoint}`, { ...fetchOptions, headers, credentials: 'include', }); if (!res.ok) { const error = await res.json().catch(() => ({ error: res.statusText })); throw new Error(error.error || `HTTP ${res.status}`); } return res.json(); } // ── Auth ───────────────────────────────────────────────────────────────────── export const authApi = { register: (data: { email: string; name: string; password: string; inviteToken?: string }) => apiFetch<{ user: User; token: string; acceptedProjects: { projectId: string; projectName: string }[]; userName: string }>('/api/auth/register', { method: 'POST', body: JSON.stringify(data), }), login: (data: { email: string; password: string }) => apiFetch<{ user: User; token: string; acceptedProjects: { projectId: string; projectName: string }[] }>('/api/auth/login', { method: 'POST', body: JSON.stringify(data), }), logout: () => apiFetch('/api/auth/logout', { method: 'POST' }), me: (token: string) => apiFetch<{ user: User }>('/api/auth/me', { token }), }; // ── Projects ───────────────────────────────────────────────────────────────── export const projectsApi = { list: (token: string) => apiFetch<{ projects: Project[] }>('/api/projects', { token }), create: (token: string, data: { name: string; description?: string }) => apiFetch<{ project: Project }>('/api/projects', { method: 'POST', body: JSON.stringify(data), token, }), get: (token: string, id: string) => apiFetch<{ project: Project }>(`/api/projects/${id}`, { token }), update: (token: string, id: string, data: { name?: string; description?: string }) => apiFetch<{ project: Project }>(`/api/projects/${id}`, { method: 'PUT', body: JSON.stringify(data), token, }), delete: (token: string, id: string) => apiFetch(`/api/projects/${id}`, { method: 'DELETE', token }), inviteMember: (token: string, projectId: string, email: string, role: string) => apiFetch(`/api/projects/${projectId}/members`, { method: 'POST', body: JSON.stringify({ email, role }), token, }), removeMember: (token: string, projectId: string, userId: string) => apiFetch(`/api/projects/${projectId}/members/${userId}`, { method: 'DELETE', token }), updateMember: (token: string, projectId: string, userId: string, role: string) => apiFetch(`/api/projects/${projectId}/members/${userId}`, { method: 'PUT', body: JSON.stringify({ role }), token, }), }; // ── Assets ─────────────────────────────────────────────────────────────────── export const assetsApi = { list: (token: string, projectId: string) => apiFetch<{ assets: Asset[] }>(`/api/assets?projectId=${projectId}`, { token }), get: (token: string, id: string) => apiFetch<{ asset: AssetWithComments }>(`/api/assets/${id}`, { token }), getStatus: (token: string, id: string) => apiFetch<{ asset: AssetStatusInfo }>(`/api/assets/${id}/status`, { token }), upload: (token: string, formData: FormData) => fetch(`${API_BASE}/api/assets/upload`, { method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: formData, }).then(r => r.json()), updateStatus: (token: string, id: string, status: string) => apiFetch<{ asset: Asset }>(`/api/assets/${id}/status`, { method: 'PUT', body: JSON.stringify({ status }), token, }), delete: (token: string, id: string) => apiFetch(`/api/assets/${id}`, { method: 'DELETE', token }), cancelTranscode: (token: string, id: string) => apiFetch<{ asset: Asset }>(`/api/assets/${id}/transcode/cancel`, { method: 'POST', token }), }; // ── Comments ───────────────────────────────────────────────────────────────── export const commentsApi = { list: (token: string, assetId: string, resolved?: boolean) => { const q = resolved !== undefined ? `?resolved=${resolved}` : ''; return apiFetch<{ comments: Comment[] }>(`/api/assets/${assetId}/comments${q}`, { token }); }, create: (token: string, assetId: string, data: { content: string; timestamp?: number; annotations?: AnnotationData[]; parentId?: string; }) => apiFetch<{ comment: Comment }>(`/api/assets/${assetId}/comments`, { method: 'POST', body: JSON.stringify(data), token, }), requestResolve: (token: string, id: string) => apiFetch<{ comment: Comment }>(`/api/comments/${id}/resolve/request`, { method: 'POST', token }), resolve: (token: string, id: string, action?: 'approve' | 'reject') => apiFetch<{ comment: Comment }>(`/api/comments/${id}/resolve`, { method: 'PUT', body: JSON.stringify({ action }), token, }), updateAnnotations: (token: string, id: string, annotations: AnnotationData[]) => apiFetch<{ comment: Comment }>(`/api/comments/${id}/annotations`, { method: 'PUT', body: JSON.stringify({ annotations }), token, }), delete: (token: string, id: string) => apiFetch(`/api/comments/${id}`, { method: 'DELETE', token }), }; // ── Users ──────────────────────────────────────────────────────────────────── export const usersApi = { list: (token: string) => apiFetch<{ users: AdminUser[] }>('/api/users', { token }), getMe: (token: string) => apiFetch<{ user: AdminUser }>('/api/users/me', { token }), updateMe: (token: string, data: { name?: string; avatarUrl?: string; currentPassword?: string; newPassword?: string; }) => apiFetch<{ user: AdminUser }>('/api/users/me', { method: 'PUT', body: JSON.stringify(data), token, }), updateRole: (token: string, id: string, role: string) => apiFetch<{ user: AdminUser }>(`/api/users/${id}/role`, { method: 'PUT', body: JSON.stringify({ role }), token, }), updateActive: (token: string, id: string, active: boolean) => apiFetch<{ user: AdminUser }>(`/api/users/${id}/active`, { method: 'PUT', body: JSON.stringify({ active }), token, }), updateQuota: (token: string, id: string, storageQuota: number) => apiFetch<{ user: AdminUser }>(`/api/users/${id}/quota`, { method: 'PUT', body: JSON.stringify({ storageQuota }), token, }), deleteUser: (token: string, id: string) => apiFetch(`/api/users/${id}`, { method: 'DELETE', token }), }; // ── Invitations ──────────────────────────────────────────────────────────────── export const invitationsApi = { // Public: verify an invitation token verify: (token: string) => apiFetch<{ invitation: InvitationInfo }>(`/api/invitations/${token}`), // Public: accept invitation (must be logged in with matching email) accept: (token: string) => apiFetch<{ member: { projectId: string } }>(`/api/invitations/${token}/accept`, { method: 'POST', }), // Project-scoped: list pending invitations list: (token: string, projectId: string) => apiFetch<{ invitations: Invitation[] }>(`/api/invitations/project/${projectId}`, { token }), // Project-scoped: create invitation create: (token: string, projectId: string, email: string, role: string) => apiFetch<{ invitation: Invitation; inviteUrl: string }>(`/api/invitations/project/${projectId}`, { method: 'POST', body: JSON.stringify({ email, role }), token, }), // Admin: invite MEMBER to workspace (email only, no project) inviteMember: (token: string, email: string) => apiFetch<{ invitation: Invitation; inviteUrl: string }>('/api/invitations/workspace', { method: 'POST', body: JSON.stringify({ email }), token, }), // Admin: invite PROJECT_USER to a specific project (requires projectId) adminInvite: (token: string, email: string, projectId: string, role: string) => apiFetch<{ invitation: Invitation; inviteUrl: string }>('/api/invitations', { method: 'POST', body: JSON.stringify({ email, projectId, role }), token, }), // Admin: list all pending workspace invitations listAll: (token: string) => apiFetch<{ invitations: AdminInvitation[] }>('/api/invitations', { token }), // Revoke an invitation revoke: (token: string, invitationId: string) => apiFetch(`/api/invitations/${invitationId}`, { method: 'DELETE', token }), // Resend invitation (new token) resend: (token: string, projectId: string, invitationId: string) => apiFetch<{ invitation: Invitation; inviteUrl: string }>(`/api/invitations/project/${projectId}/resend`, { method: 'POST', body: JSON.stringify({ invitationId }), token, }), }; // ── Site Settings ────────────────────────────────────────────────────────────── export const settingsApi = { getRegistration: (token: string) => apiFetch<{ enabled: boolean }>('/api/settings/registration', { token }), setRegistration: (token: string, enabled: boolean) => apiFetch<{ enabled: boolean }>('/api/settings/registration', { method: 'PUT', body: JSON.stringify({ enabled }), token, }), }; // ── Types ───────────────────────────────────────────────────────────────────── export interface User { id: string; email: string; name: string; globalRole: string; avatarUrl?: string | null; } export interface AdminUser extends User { active: boolean; createdAt: string; storageQuota: number; // bytes storageUsed: number; // bytes — sum of assets in owned projects /** Number of projects this user owns (their storage is counted from these) */ ownedProjects: number; _count?: { memberships: number; // projects they're a member of (including owned) comments: number; }; } export interface Project { id: string; name: string; description?: string | null; ownerId: string; createdAt: string; updatedAt: string; /** Current user's role in this project: 'OWNER' | 'ADMIN' | 'EDITOR' | 'REVIEWER' | 'VIEWER' | null */ myRole: string | null; members: Array<{ id: string; role: string; joinedAt: string; invitedBy?: string | null; user: User }>; _count?: { assets: number }; } export interface Invitation { id: string; email: string; projectId: string; role: string; token: string; status: 'PENDING' | 'ACCEPTED' | 'EXPIRED' | 'REVOKED'; invitedBy?: string | null; expiresAt: string; createdAt: string; } export interface AdminInvitation { id: string; email: string; projectId: string; role: string; token: string; status: 'PENDING' | 'ACCEPTED' | 'EXPIRED' | 'REVOKED'; invitedBy?: string | null; expiresAt: string; createdAt: string; project: { id: string; name: string }; type?: 'WORKSPACE' | 'PROJECT'; } export interface InvitationInfo { id: string; email: string; role: string; projectName?: string | null; projectId?: string | null; /** 'WORKSPACE' = invite MEMBER to workspace (no project); 'PROJECT' = invite PROJECT_USER to a project */ type?: 'WORKSPACE' | 'PROJECT'; expiresAt: string; status: 'PENDING' | 'ACCEPTED' | 'EXPIRED' | 'REVOKED'; isExpired: boolean; isOwnInvitation: boolean; alreadyMember: boolean; isLoggedIn: boolean; /** Whether the invite email already has an account — determines sign-in vs register button */ inviteeExists: boolean; } export type TranscodeStatus = | 'PENDING' | 'UPLOADING' | 'PROCESSING' | 'COMPLETED' | 'FAILED' | 'UNSUPPORTED_CODEC'; export interface Asset { id: string; projectId: string; title: string; filename: string; filePath: string; thumbnail?: string | null; hlsPath?: string | null; duration?: number | null; fps?: number; codec?: string | null; mimeType: string; status: string; transcodeStatus: TranscodeStatus; transcodeProgress: number; transcodeError?: string | null; createdAt: string; uploader?: Pick; _count?: { comments: number }; } export interface AssetStatusInfo { id: string; title: string; hlsPath?: string | null; transcodeStatus: TranscodeStatus; transcodeProgress: number; transcodeError?: string | null; thumbnail?: string | null; duration?: number | null; codec?: string | null; status: string; } export interface AssetWithComments extends Asset { project: Project; comments: Comment[]; } export interface Comment { id: string; assetId: string; userId: string; content: string; timestamp?: number | null; annotations?: AnnotationData[] | null; resolved: boolean; resolveStatus: 'UNRESOLVED' | 'PENDING_APPROVAL' | 'RESOLVED'; resolvedById?: string | null; resolvedByAt?: string | null; requestedById?: string | null; requestedByAt?: string | null; parentId?: string | null; createdAt: string; user: User; replies?: Comment[]; resolvedBy?: Pick; requestedBy?: Pick; } export interface AnnotationData { type: 'pen' | 'arrow' | 'rect' | 'ellipse' | 'text'; color: string; points?: [number, number][]; text?: string; boundingBox?: { x: number; y: number; width: number; height: number }; }