From 9bf759c829f53d1c525ad08f150114ff358e6c2a Mon Sep 17 00:00:00 2001 From: Thibault Pouch Date: Tue, 3 Mar 2026 09:48:28 +0100 Subject: [PATCH] feat: implement comprehensive API structure for authentication, users, forum, bugs, events, and team management --- nest-front/src/utils/api.ts | 190 +++++++++++++++++++++++++++++++++++- 1 file changed, 189 insertions(+), 1 deletion(-) diff --git a/nest-front/src/utils/api.ts b/nest-front/src/utils/api.ts index 7d6b3dc..9a40638 100644 --- a/nest-front/src/utils/api.ts +++ b/nest-front/src/utils/api.ts @@ -1,4 +1,192 @@ +import type { + User, + ForumCategory, + ForumThread, + ForumReply, + BugReport, + BugComment, + BugReportFormData, + BugSeverity, + BugStatus, + EventPost, + TeamMember, +} from '../types'; + // API base URL. // - In Docker (Coolify): nginx proxies /api/* to the backend, so we use a relative path. // - Set VITE_API_URL at build time to call the backend directly (e.g. during local dev without nginx). -export const API_BASE = import.meta.env.VITE_API_URL ?? '/api' +export const API_BASE = import.meta.env.VITE_API_URL ?? '/api'; + +// ── Token storage ───────────────────────────────────────────────────────────── + +const TOKEN_KEY = 'crowmate_auth_token'; + +export function getToken(): string | null { + return localStorage.getItem(TOKEN_KEY); +} + +export function setToken(token: string): void { + localStorage.setItem(TOKEN_KEY, token); +} + +export function clearToken(): void { + localStorage.removeItem(TOKEN_KEY); +} + +// ── Core fetch helper ───────────────────────────────────────────────────────── + +export class ApiError extends Error { + status: number; + constructor(status: number, message: string) { + super(message); + this.status = status; + this.name = 'ApiError'; + } +} + +export async function apiFetch( + path: string, + options: RequestInit = {} +): Promise { + const token = getToken(); + const headers: Record = { + 'Content-Type': 'application/json', + ...(options.headers as Record ?? {}), + }; + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + const res = await fetch(`${API_BASE}${path}`, { ...options, headers }); + + if (!res.ok) { + const body = await res.json().catch(() => ({})); + const message = + typeof body.error === 'string' + ? body.error + : body.error?.message ?? `Request failed (${res.status})`; + throw new ApiError(res.status, message); + } + + // 204 No Content + if (res.status === 204) return undefined as T; + + return res.json() as Promise; +} + +// ── Auth API ────────────────────────────────────────────────────────────────── + +export const authApi = { + login: (email: string, password: string) => + apiFetch<{ token: string; user: User }>('/auth/login', { + method: 'POST', + body: JSON.stringify({ email, password }), + }), + + register: (username: string, email: string, password: string) => + apiFetch<{ token: string; user: User }>('/auth/register', { + method: 'POST', + body: JSON.stringify({ username, email, password }), + }), + + me: () => apiFetch('/auth/me'), +}; + +// ── Users API ───────────────────────────────────────────────────────────────── + +export const usersApi = { + updateUsername: (username: string) => + apiFetch('/users/me/username', { + method: 'PATCH', + body: JSON.stringify({ username }), + }), + + changePassword: (currentPassword: string, newPassword: string) => + apiFetch<{ message: string }>('/users/me/password', { + method: 'PATCH', + body: JSON.stringify({ currentPassword, newPassword }), + }), +}; + +// ── Forum API ───────────────────────────────────────────────────────────────── + +export const forumApi = { + getCategories: () => + apiFetch('/forum/categories'), + + getThreads: (params?: { categoryId?: string; page?: number; limit?: number }) => { + const q = new URLSearchParams(); + if (params?.categoryId) q.set('categoryId', params.categoryId); + q.set('page', String(params?.page ?? 1)); + q.set('limit', String(params?.limit ?? 100)); + return apiFetch<{ data: ForumThread[]; total: number; page: number; pages: number }>( + `/forum/threads?${q}` + ); + }, + + getThread: (id: string) => + apiFetch(`/forum/threads/${id}`), + + createReply: (threadId: string, content: string) => + apiFetch(`/forum/threads/${threadId}/replies`, { + method: 'POST', + body: JSON.stringify({ content }), + }), +}; + +// ── Bugs API ────────────────────────────────────────────────────────────────── + +export const bugsApi = { + getBugs: (params?: { status?: BugStatus | 'all'; severity?: BugSeverity | 'all'; page?: number; limit?: number }) => { + const q = new URLSearchParams(); + if (params?.status && params.status !== 'all') q.set('status', params.status); + if (params?.severity && params.severity !== 'all') q.set('severity', params.severity); + q.set('page', String(params?.page ?? 1)); + q.set('limit', String(params?.limit ?? 100)); + return apiFetch<{ data: BugReport[]; total: number; page: number; pages: number }>( + `/bugs?${q}` + ); + }, + + getBug: (id: string) => + apiFetch(`/bugs/${id}`), + + createBug: (data: BugReportFormData) => + apiFetch('/bugs', { + method: 'POST', + body: JSON.stringify(data), + }), + + toggleMeToo: (bugId: string) => + apiFetch<{ message: string }>(`/bugs/${bugId}/me-too`, { method: 'POST' }), + + addComment: (bugId: string, content: string) => + apiFetch(`/bugs/${bugId}/comments`, { + method: 'POST', + body: JSON.stringify({ content }), + }), +}; + +// ── Events API ──────────────────────────────────────────────────────────────── + +export const eventsApi = { + getEvents: (publicOnly = true) => { + const q = new URLSearchParams({ limit: '50' }); + if (publicOnly) q.set('public', 'true'); + return apiFetch<{ data: EventPost[]; total: number; page: number; pages: number }>( + `/events?${q}` + ); + }, + + vote: (eventId: string, optionIds: string[]) => + apiFetch(`/events/${eventId}/vote`, { + method: 'POST', + body: JSON.stringify({ optionIds }), + }), +}; + +// ── Team API ────────────────────────────────────────────────────────────────── + +export const teamApi = { + getMembers: () => apiFetch('/team'), +};