api.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448
  1. const API_BASE = process.env.NEXT_PUBLIC_API_URL || '';
  2. interface FetchOptions extends RequestInit {
  3. token?: string;
  4. }
  5. async function apiFetch<T = unknown>(
  6. endpoint: string,
  7. options: FetchOptions = {}
  8. ): Promise<T> {
  9. const { token, ...fetchOptions } = options;
  10. const headers: Record<string, string> = {
  11. 'Content-Type': 'application/json',
  12. ...(options.headers as Record<string, string> || {}),
  13. };
  14. if (token) {
  15. headers['Authorization'] = `Bearer ${token}`;
  16. }
  17. const res = await fetch(`${API_BASE}${endpoint}`, {
  18. ...fetchOptions,
  19. headers,
  20. credentials: 'include',
  21. });
  22. if (!res.ok) {
  23. const error = await res.json().catch(() => ({ error: res.statusText }));
  24. throw new Error(error.error || `HTTP ${res.status}`);
  25. }
  26. return res.json();
  27. }
  28. // ── Auth ─────────────────────────────────────────────────────────────────────
  29. export const authApi = {
  30. register: (data: { email: string; name: string; password: string; inviteToken?: string }) =>
  31. apiFetch<{ user: User; token: string; acceptedProjects: { projectId: string; projectName: string }[]; userName: string }>('/api/auth/register', {
  32. method: 'POST',
  33. body: JSON.stringify(data),
  34. }),
  35. login: (data: { email: string; password: string }) =>
  36. apiFetch<{ user: User; token: string; acceptedProjects: { projectId: string; projectName: string }[] }>('/api/auth/login', {
  37. method: 'POST',
  38. body: JSON.stringify(data),
  39. }),
  40. logout: () =>
  41. apiFetch('/api/auth/logout', { method: 'POST' }),
  42. me: (token: string) =>
  43. apiFetch<{ user: User }>('/api/auth/me', { token }),
  44. };
  45. // ── Projects ─────────────────────────────────────────────────────────────────
  46. export const projectsApi = {
  47. list: (token: string) =>
  48. apiFetch<{ projects: Project[] }>('/api/projects', { token }),
  49. create: (token: string, data: { name: string; description?: string }) =>
  50. apiFetch<{ project: Project }>('/api/projects', {
  51. method: 'POST',
  52. body: JSON.stringify(data),
  53. token,
  54. }),
  55. get: (token: string, id: string) =>
  56. apiFetch<{ project: Project }>(`/api/projects/${id}`, { token }),
  57. update: (token: string, id: string, data: { name?: string; description?: string }) =>
  58. apiFetch<{ project: Project }>(`/api/projects/${id}`, {
  59. method: 'PUT',
  60. body: JSON.stringify(data),
  61. token,
  62. }),
  63. delete: (token: string, id: string) =>
  64. apiFetch(`/api/projects/${id}`, { method: 'DELETE', token }),
  65. inviteMember: (token: string, projectId: string, email: string, role: string) =>
  66. apiFetch(`/api/projects/${projectId}/members`, {
  67. method: 'POST',
  68. body: JSON.stringify({ email, role }),
  69. token,
  70. }),
  71. removeMember: (token: string, projectId: string, userId: string) =>
  72. apiFetch(`/api/projects/${projectId}/members/${userId}`, { method: 'DELETE', token }),
  73. updateMember: (token: string, projectId: string, userId: string, role: string) =>
  74. apiFetch(`/api/projects/${projectId}/members/${userId}`, {
  75. method: 'PUT',
  76. body: JSON.stringify({ role }),
  77. token,
  78. }),
  79. };
  80. // ── Assets ───────────────────────────────────────────────────────────────────
  81. export const assetsApi = {
  82. list: (token: string, projectId: string) =>
  83. apiFetch<{ assets: Asset[] }>(`/api/assets?projectId=${projectId}`, { token }),
  84. get: (token: string, id: string) =>
  85. apiFetch<{ asset: AssetWithComments }>(`/api/assets/${id}`, { token }),
  86. getStatus: (token: string, id: string) =>
  87. apiFetch<{ asset: AssetStatusInfo }>(`/api/assets/${id}/status`, { token }),
  88. upload: (token: string, formData: FormData) =>
  89. fetch(`${API_BASE}/api/assets/upload`, {
  90. method: 'POST',
  91. headers: { Authorization: `Bearer ${token}` },
  92. body: formData,
  93. }).then(r => r.json()),
  94. updateStatus: (token: string, id: string, status: string) =>
  95. apiFetch<{ asset: Asset }>(`/api/assets/${id}/status`, {
  96. method: 'PUT',
  97. body: JSON.stringify({ status }),
  98. token,
  99. }),
  100. delete: (token: string, id: string) =>
  101. apiFetch(`/api/assets/${id}`, { method: 'DELETE', token }),
  102. cancelTranscode: (token: string, id: string) =>
  103. apiFetch<{ asset: Asset }>(`/api/assets/${id}/transcode/cancel`, { method: 'POST', token }),
  104. };
  105. // ── Comments ─────────────────────────────────────────────────────────────────
  106. export const commentsApi = {
  107. list: (token: string, assetId: string, resolved?: boolean) => {
  108. const q = resolved !== undefined ? `?resolved=${resolved}` : '';
  109. return apiFetch<{ comments: Comment[] }>(`/api/assets/${assetId}/comments${q}`, { token });
  110. },
  111. create: (token: string, assetId: string, data: {
  112. content: string;
  113. timestamp?: number;
  114. annotations?: AnnotationData[];
  115. parentId?: string;
  116. }) =>
  117. apiFetch<{ comment: Comment }>(`/api/assets/${assetId}/comments`, {
  118. method: 'POST',
  119. body: JSON.stringify(data),
  120. token,
  121. }),
  122. requestResolve: (token: string, id: string) =>
  123. apiFetch<{ comment: Comment }>(`/api/comments/${id}/resolve/request`, { method: 'POST', token }),
  124. resolve: (token: string, id: string, action?: 'approve' | 'reject') =>
  125. apiFetch<{ comment: Comment }>(`/api/comments/${id}/resolve`, {
  126. method: 'PUT',
  127. body: JSON.stringify({ action }),
  128. token,
  129. }),
  130. updateAnnotations: (token: string, id: string, annotations: AnnotationData[]) =>
  131. apiFetch<{ comment: Comment }>(`/api/comments/${id}/annotations`, {
  132. method: 'PUT',
  133. body: JSON.stringify({ annotations }),
  134. token,
  135. }),
  136. delete: (token: string, id: string) =>
  137. apiFetch(`/api/comments/${id}`, { method: 'DELETE', token }),
  138. };
  139. // ── Users ────────────────────────────────────────────────────────────────────
  140. export const usersApi = {
  141. list: (token: string) =>
  142. apiFetch<{ users: AdminUser[] }>('/api/users', { token }),
  143. getMe: (token: string) =>
  144. apiFetch<{ user: AdminUser }>('/api/users/me', { token }),
  145. updateMe: (token: string, data: {
  146. name?: string;
  147. avatarUrl?: string;
  148. currentPassword?: string;
  149. newPassword?: string;
  150. }) =>
  151. apiFetch<{ user: AdminUser }>('/api/users/me', {
  152. method: 'PUT',
  153. body: JSON.stringify(data),
  154. token,
  155. }),
  156. updateRole: (token: string, id: string, role: string) =>
  157. apiFetch<{ user: AdminUser }>(`/api/users/${id}/role`, {
  158. method: 'PUT',
  159. body: JSON.stringify({ role }),
  160. token,
  161. }),
  162. updateActive: (token: string, id: string, active: boolean) =>
  163. apiFetch<{ user: AdminUser }>(`/api/users/${id}/active`, {
  164. method: 'PUT',
  165. body: JSON.stringify({ active }),
  166. token,
  167. }),
  168. updateQuota: (token: string, id: string, storageQuota: number) =>
  169. apiFetch<{ user: AdminUser }>(`/api/users/${id}/quota`, {
  170. method: 'PUT',
  171. body: JSON.stringify({ storageQuota }),
  172. token,
  173. }),
  174. deleteUser: (token: string, id: string) =>
  175. apiFetch(`/api/users/${id}`, { method: 'DELETE', token }),
  176. };
  177. // ── Invitations ────────────────────────────────────────────────────────────────
  178. export const invitationsApi = {
  179. // Public: verify an invitation token
  180. verify: (token: string) =>
  181. apiFetch<{ invitation: InvitationInfo }>(`/api/invitations/${token}`),
  182. // Public: accept invitation (must be logged in with matching email)
  183. accept: (token: string) =>
  184. apiFetch<{ member: { projectId: string } }>(`/api/invitations/${token}/accept`, {
  185. method: 'POST',
  186. }),
  187. // Project-scoped: list pending invitations
  188. list: (token: string, projectId: string) =>
  189. apiFetch<{ invitations: Invitation[] }>(`/api/invitations/project/${projectId}`, { token }),
  190. // Project-scoped: create invitation
  191. create: (token: string, projectId: string, email: string, role: string) =>
  192. apiFetch<{ invitation: Invitation; inviteUrl: string }>(`/api/invitations/project/${projectId}`, {
  193. method: 'POST',
  194. body: JSON.stringify({ email, role }),
  195. token,
  196. }),
  197. // Admin: invite MEMBER to workspace (email only, no project)
  198. inviteMember: (token: string, email: string) =>
  199. apiFetch<{ invitation: Invitation; inviteUrl: string }>('/api/invitations/workspace', {
  200. method: 'POST',
  201. body: JSON.stringify({ email }),
  202. token,
  203. }),
  204. // Admin: invite PROJECT_USER to a specific project (requires projectId)
  205. adminInvite: (token: string, email: string, projectId: string, role: string) =>
  206. apiFetch<{ invitation: Invitation; inviteUrl: string }>('/api/invitations', {
  207. method: 'POST',
  208. body: JSON.stringify({ email, projectId, role }),
  209. token,
  210. }),
  211. // Admin: list all pending workspace invitations
  212. listAll: (token: string) =>
  213. apiFetch<{ invitations: AdminInvitation[] }>('/api/invitations', { token }),
  214. // Revoke an invitation
  215. revoke: (token: string, invitationId: string) =>
  216. apiFetch(`/api/invitations/${invitationId}`, { method: 'DELETE', token }),
  217. // Resend invitation (new token)
  218. resend: (token: string, projectId: string, invitationId: string) =>
  219. apiFetch<{ invitation: Invitation; inviteUrl: string }>(`/api/invitations/project/${projectId}/resend`, {
  220. method: 'POST',
  221. body: JSON.stringify({ invitationId }),
  222. token,
  223. }),
  224. };
  225. // ── Site Settings ──────────────────────────────────────────────────────────────
  226. export const settingsApi = {
  227. getRegistration: (token: string) =>
  228. apiFetch<{ enabled: boolean }>('/api/settings/registration', { token }),
  229. setRegistration: (token: string, enabled: boolean) =>
  230. apiFetch<{ enabled: boolean }>('/api/settings/registration', {
  231. method: 'PUT',
  232. body: JSON.stringify({ enabled }),
  233. token,
  234. }),
  235. };
  236. // ── Types ─────────────────────────────────────────────────────────────────────
  237. export interface User {
  238. id: string;
  239. email: string;
  240. name: string;
  241. globalRole: string;
  242. avatarUrl?: string | null;
  243. }
  244. export interface AdminUser extends User {
  245. active: boolean;
  246. createdAt: string;
  247. storageQuota: number; // bytes
  248. storageUsed: number; // bytes — sum of assets in owned projects
  249. /** Number of projects this user owns (their storage is counted from these) */
  250. ownedProjects: number;
  251. _count?: {
  252. memberships: number; // projects they're a member of (including owned)
  253. comments: number;
  254. };
  255. }
  256. export interface Project {
  257. id: string;
  258. name: string;
  259. description?: string | null;
  260. ownerId: string;
  261. createdAt: string;
  262. updatedAt: string;
  263. /** Current user's role in this project: 'OWNER' | 'ADMIN' | 'EDITOR' | 'REVIEWER' | 'VIEWER' | null */
  264. myRole: string | null;
  265. members: Array<{ id: string; role: string; joinedAt: string; invitedBy?: string | null; user: User }>;
  266. _count?: { assets: number };
  267. }
  268. export interface Invitation {
  269. id: string;
  270. email: string;
  271. projectId: string;
  272. role: string;
  273. token: string;
  274. status: 'PENDING' | 'ACCEPTED' | 'EXPIRED' | 'REVOKED';
  275. invitedBy?: string | null;
  276. expiresAt: string;
  277. createdAt: string;
  278. }
  279. export interface AdminInvitation {
  280. id: string;
  281. email: string;
  282. projectId: string;
  283. role: string;
  284. token: string;
  285. status: 'PENDING' | 'ACCEPTED' | 'EXPIRED' | 'REVOKED';
  286. invitedBy?: string | null;
  287. expiresAt: string;
  288. createdAt: string;
  289. project: { id: string; name: string };
  290. type?: 'WORKSPACE' | 'PROJECT';
  291. }
  292. export interface InvitationInfo {
  293. id: string;
  294. email: string;
  295. role: string;
  296. projectName?: string | null;
  297. projectId?: string | null;
  298. /** 'WORKSPACE' = invite MEMBER to workspace (no project); 'PROJECT' = invite PROJECT_USER to a project */
  299. type?: 'WORKSPACE' | 'PROJECT';
  300. expiresAt: string;
  301. status: 'PENDING' | 'ACCEPTED' | 'EXPIRED' | 'REVOKED';
  302. isExpired: boolean;
  303. isOwnInvitation: boolean;
  304. alreadyMember: boolean;
  305. isLoggedIn: boolean;
  306. /** Whether the invite email already has an account — determines sign-in vs register button */
  307. inviteeExists: boolean;
  308. }
  309. export type TranscodeStatus =
  310. | 'PENDING'
  311. | 'UPLOADING'
  312. | 'PROCESSING'
  313. | 'COMPLETED'
  314. | 'FAILED'
  315. | 'UNSUPPORTED_CODEC';
  316. export interface Asset {
  317. id: string;
  318. projectId: string;
  319. title: string;
  320. filename: string;
  321. filePath: string;
  322. thumbnail?: string | null;
  323. hlsPath?: string | null;
  324. duration?: number | null;
  325. fps?: number;
  326. codec?: string | null;
  327. mimeType: string;
  328. status: string;
  329. transcodeStatus: TranscodeStatus;
  330. transcodeProgress: number;
  331. transcodeError?: string | null;
  332. createdAt: string;
  333. uploader?: Pick<User, 'id' | 'name' | 'email' | 'avatarUrl'>;
  334. _count?: { comments: number };
  335. }
  336. export interface AssetStatusInfo {
  337. id: string;
  338. title: string;
  339. hlsPath?: string | null;
  340. transcodeStatus: TranscodeStatus;
  341. transcodeProgress: number;
  342. transcodeError?: string | null;
  343. thumbnail?: string | null;
  344. duration?: number | null;
  345. codec?: string | null;
  346. status: string;
  347. }
  348. export interface AssetWithComments extends Asset {
  349. project: Project;
  350. comments: Comment[];
  351. }
  352. export interface Comment {
  353. id: string;
  354. assetId: string;
  355. userId: string;
  356. content: string;
  357. timestamp?: number | null;
  358. annotations?: AnnotationData[] | null;
  359. resolved: boolean;
  360. resolveStatus: 'UNRESOLVED' | 'PENDING_APPROVAL' | 'RESOLVED';
  361. resolvedById?: string | null;
  362. resolvedByAt?: string | null;
  363. requestedById?: string | null;
  364. requestedByAt?: string | null;
  365. parentId?: string | null;
  366. createdAt: string;
  367. user: User;
  368. replies?: Comment[];
  369. resolvedBy?: Pick<User, 'id' | 'name' | 'email' | 'avatarUrl'>;
  370. requestedBy?: Pick<User, 'id' | 'name' | 'email' | 'avatarUrl'>;
  371. }
  372. export interface AnnotationData {
  373. type: 'pen' | 'arrow' | 'rect' | 'ellipse' | 'text';
  374. color: string;
  375. points?: [number, number][];
  376. text?: string;
  377. boundingBox?: { x: number; y: number; width: number; height: number };
  378. }