feat: implement comprehensive API structure for authentication, users, forum, bugs, events, and team management
This commit is contained in:
@@ -1,4 +1,192 @@
|
|||||||
|
import type {
|
||||||
|
User,
|
||||||
|
ForumCategory,
|
||||||
|
ForumThread,
|
||||||
|
ForumReply,
|
||||||
|
BugReport,
|
||||||
|
BugComment,
|
||||||
|
BugReportFormData,
|
||||||
|
BugSeverity,
|
||||||
|
BugStatus,
|
||||||
|
EventPost,
|
||||||
|
TeamMember,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
// API base URL.
|
// API base URL.
|
||||||
// - In Docker (Coolify): nginx proxies /api/* to the backend, so we use a relative path.
|
// - 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).
|
// - 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<T = unknown>(
|
||||||
|
path: string,
|
||||||
|
options: RequestInit = {}
|
||||||
|
): Promise<T> {
|
||||||
|
const token = getToken();
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(options.headers as Record<string, string> ?? {}),
|
||||||
|
};
|
||||||
|
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<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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<User>('/auth/me'),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Users API ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const usersApi = {
|
||||||
|
updateUsername: (username: string) =>
|
||||||
|
apiFetch<User>('/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<ForumCategory[]>('/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<ForumThread & { replies: ForumReply[] }>(`/forum/threads/${id}`),
|
||||||
|
|
||||||
|
createReply: (threadId: string, content: string) =>
|
||||||
|
apiFetch<ForumReply>(`/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<BugReport & { comments: BugComment[] }>(`/bugs/${id}`),
|
||||||
|
|
||||||
|
createBug: (data: BugReportFormData) =>
|
||||||
|
apiFetch<BugReport>('/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<BugComment>(`/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<EventPost>(`/events/${eventId}/vote`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ optionIds }),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Team API ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const teamApi = {
|
||||||
|
getMembers: () => apiFetch<TeamMember[]>('/team'),
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user