Files
tessera/web/src/lib/api.ts
Gjermund Høsøien Wiggen 653139ad0d feat: watcher/CC system, SLA engine, and rich text comments
- Watcher system: ticket_watchers table, watch/unwatch endpoints,
  notifications to watchers on comments and updates, watcher/cc
  recipient sources in SendEmail scrip action, watch toggle and
  watcher avatars in ticket detail UI
- SLA engine: sla_policies table, SLA deadline columns on tickets,
  CRUD routes, OnSlaBreach scrip condition, scheduler SLA calculation,
  deadlines set on create/reply, cleared on resolve, SLA indicators
  on ticket list and detail, SLA Policies tab in admin
- Rich text: marked-based markdown rendering with XSS safety,
  Write/Preview toggle in comment composer, styled prose output
2026-06-15 21:40:18 +02:00

727 lines
26 KiB
TypeScript

import type {
Ticket,
Queue,
Dashboard,
DashboardWidget,
WidgetData,
Team,
User,
Transaction,
SavedView,
Scrip,
Template,
TemplatePreview,
Lifecycle,
LifecycleDefinition,
CustomField,
QueueCustomField,
PreviewResult,
UpdateResult,
Attachment,
AttachmentUploadResult,
TicketLink,
LoginResult,
Watcher,
SlaPolicy,
} from "./types";
const BASE_URL = "/api";
async function request<T>(url: string, options?: RequestInit): Promise<{ data: T | null; error: string | null }> {
try {
const token = typeof window !== "undefined" ? localStorage.getItem("tessera_token") : null;
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
// Merge with options headers if any
const opts = { ...options };
if (opts.headers) {
Object.assign(headers, opts.headers as Record<string, string>);
delete opts.headers;
}
const res = await fetch(`${BASE_URL}${url}`, {
headers,
...opts,
});
if (!res.ok) {
const body = await res.json().catch(() => ({ error: res.statusText }));
return { data: null, error: body.error || body.message || `HTTP ${res.status}` };
}
const data = await res.json();
return { data, error: null };
} catch (err) {
return { data: null, error: err instanceof Error ? err.message : "Unknown error" };
}
}
export async function getTickets(params?: {
queue_id?: string;
status?: string;
q?: string;
owner_id?: string;
team_id?: string;
custom_fields?: Record<string, string>;
subject?: string;
created?: string;
updated?: string;
}): Promise<{ data: Ticket[] | null; error: string | null }> {
const sp = new URLSearchParams();
if (params?.queue_id) sp.set("queue_id", params.queue_id);
if (params?.status) sp.set("status", params.status);
if (params?.q) sp.set("q", params.q);
if (params?.owner_id) sp.set("owner_id", params.owner_id);
if (params?.team_id) sp.set("team_id", params.team_id);
if (params?.subject) sp.set("subject", params.subject);
if (params?.created) sp.set("created", params.created);
if (params?.updated) sp.set("updated", params.updated);
if (params?.custom_fields) {
for (const [fieldId, value] of Object.entries(params.custom_fields)) {
if (value) sp.set(`cf.${fieldId}`, value);
}
}
const qs = sp.toString();
return request<Ticket[]>(`/tickets${qs ? `?${qs}` : ""}`);
}
export async function getTicket(id: number): Promise<{ data: Ticket | null; error: string | null }> {
return request<Ticket>(`/tickets/${id}`);
}
export async function createTicket(data: {
subject: string;
queue_id: string;
description?: string;
custom_fields?: Record<string, string>;
}): Promise<{ data: UpdateResult | null; error: string | null }> {
return request<UpdateResult>("/tickets", { method: "POST", body: JSON.stringify(data) });
}
export async function updateTicket(id: number, data: { subject?: string; status?: string; owner_id?: string | null; team_id?: string | null }): Promise<{ data: UpdateResult | null; error: string | null }> {
return request<UpdateResult>(`/tickets/${id}`, { method: "PATCH", body: JSON.stringify(data) });
}
export async function previewTicket(id: number, data: { status?: string }): Promise<{ data: PreviewResult | null; error: string | null }> {
return request<PreviewResult>(`/tickets/${id}/preview`, { method: "POST", body: JSON.stringify(data) });
}
export async function getTicketTransactions(id: number): Promise<{ data: Transaction[] | null; error: string | null }> {
return request<Transaction[]>(`/tickets/${id}/transactions`);
}
export async function sendComment(id: number, data: { body: string; internal?: boolean; attachment_ids?: string[]; time_worked_minutes?: number }): Promise<{ data: Transaction | null; error: string | null }> {
return request<Transaction>(`/tickets/${id}/comment`, { method: "POST", body: JSON.stringify(data) });
}
export async function batchUpdateTickets(data: {
ticket_ids: number[];
status?: string;
owner_id?: string | null;
team_id?: string | null;
}): Promise<{ data: { results: Array<{ id: number; ok: boolean; error?: string }> } | null; error: string | null }> {
return request<{ results: Array<{ id: number; ok: boolean; error?: string }> }>("/tickets/batch", { method: "POST", body: JSON.stringify(data) });
}
export async function mergeTickets(sourceId: number, targetTicketId: number): Promise<{ data: { ok: boolean; target_id: number } | null; error: string | null }> {
return request<{ ok: boolean; target_id: number }>(`/tickets/${sourceId}/merge`, { method: "POST", body: JSON.stringify({ target_ticket_id: targetTicketId }) });
}
// Notifications
export interface Notification {
id: string;
user_id: string;
ticket_id: number | null;
type: string;
title: string;
body: string | null;
read: boolean;
created_at: string;
}
export async function getNotifications(): Promise<{ data: Notification[] | null; error: string | null }> {
return request<Notification[]>("/notifications");
}
export async function getUnreadCount(): Promise<{ data: { count: number } | null; error: string | null }> {
return request<{ count: number }>("/notifications/unread-count");
}
export async function markNotificationRead(id: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
return request<{ ok: boolean }>(`/notifications/${id}/read`, { method: "PATCH" });
}
export async function markAllNotificationsRead(): Promise<{ data: { ok: boolean } | null; error: string | null }> {
return request<{ ok: boolean }>("/notifications/read-all", { method: "PATCH" });
}
// API Tokens
export interface ApiToken {
id: string;
name: string;
last_used_at: string | null;
created_at: string;
}
export interface ApiTokenCreated {
id: string;
name: string;
token: string;
created_at: string;
}
export async function getApiTokens(): Promise<{ data: ApiToken[] | null; error: string | null }> {
return request<ApiToken[]>("/auth/tokens");
}
export async function createApiToken(name: string): Promise<{ data: ApiTokenCreated | null; error: string | null }> {
return request<ApiTokenCreated>("/auth/tokens", { method: "POST", body: JSON.stringify({ name }) });
}
export async function revokeApiToken(id: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
return request<{ ok: boolean }>(`/auth/tokens/${id}`, { method: "DELETE" });
}
// Watchers
export async function getWatchers(ticketId: number): Promise<{ data: Watcher[] | null; error: string | null }> {
return request<Watcher[]>(`/tickets/${ticketId}/watchers`);
}
export async function addWatcher(ticketId: number, userId?: string): Promise<{ data: Watcher | null; error: string | null }> {
return request<Watcher>(`/tickets/${ticketId}/watchers`, { method: "POST", body: JSON.stringify(userId ? { user_id: userId } : {}) });
}
export async function removeWatcher(ticketId: number, userId: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
return request<{ ok: boolean }>(`/tickets/${ticketId}/watchers/${userId}`, { method: "DELETE" });
}
// SLA Policies
export async function getSlaPolicies(): Promise<{ data: SlaPolicy[] | null; error: string | null }> {
return request<SlaPolicy[]>("/sla-policies");
}
export async function createSlaPolicy(data: {
name: string;
queue_id?: string;
description?: string;
response_time_minutes?: number;
resolution_time_minutes?: number;
disabled?: boolean;
}): Promise<{ data: SlaPolicy | null; error: string | null }> {
return request<SlaPolicy>("/sla-policies", { method: "POST", body: JSON.stringify(data) });
}
export async function updateSlaPolicy(id: string, data: {
name?: string;
queue_id?: string | null;
description?: string;
response_time_minutes?: number | null;
resolution_time_minutes?: number | null;
disabled?: boolean;
}): Promise<{ data: SlaPolicy | null; error: string | null }> {
return request<SlaPolicy>(`/sla-policies/${id}`, { method: "PATCH", body: JSON.stringify(data) });
}
export async function deleteSlaPolicy(id: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
return request<{ ok: boolean }>(`/sla-policies/${id}`, { method: "DELETE" });
}
export async function getQueues(): Promise<{ data: Queue[] | null; error: string | null }> {
return request<Queue[]>("/queues");
}
export async function getUsers(): Promise<{ data: User[] | null; error: string | null }> {
return request<User[]>("/users");
}
export async function createUser(data: {
username: string;
email?: string | null;
role?: string;
password?: string | null;
}): Promise<{ data: User | null; error: string | null }> {
return request<User>("/users", { method: "POST", body: JSON.stringify(data) });
}
export async function updateUser(id: string, data: {
username?: string;
email?: string | null;
role?: string;
password?: string | null;
}): Promise<{ data: User | null; error: string | null }> {
return request<User>(`/users/${id}`, { method: "PATCH", body: JSON.stringify(data) });
}
export async function deleteUser(id: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
return request<{ ok: boolean }>(`/users/${id}`, { method: "DELETE" });
}
export async function createQueue(data: { name: string; description?: string | null; lifecycle_id?: string | null; team_id?: string | null }): Promise<{ data: Queue | null; error: string | null }> {
return request<Queue>("/queues", { method: "POST", body: JSON.stringify(data) });
}
export async function updateQueue(id: string, data: { name?: string; description?: string | null; lifecycle_id?: string | null; team_id?: string | null }): Promise<{ data: Queue | null; error: string | null }> {
return request<Queue>(`/queues/${id}`, { method: "PATCH", body: JSON.stringify(data) });
}
export async function getScrips(): Promise<{ data: Scrip[] | null; error: string | null }> {
return request<Scrip[]>("/scrips");
}
export async function createScrip(data: {
name: string;
description?: string | null;
queue_id?: string | null;
condition_type: string;
condition_config?: Record<string, unknown>;
action_type: string;
action_config?: Record<string, unknown>;
template_id?: string | null;
stage?: string;
sort_order?: number;
disabled?: boolean;
}): Promise<{ data: Scrip | null; error: string | null }> {
return request<Scrip>("/scrips", { method: "POST", body: JSON.stringify(data) });
}
export async function updateScrip(id: string, data: {
name?: string;
description?: string | null;
queue_id?: string | null;
condition_type?: string;
condition_config?: Record<string, unknown>;
action_type?: string;
action_config?: Record<string, unknown>;
template_id?: string | null;
stage?: string;
sort_order?: number;
disabled?: boolean;
}): Promise<{ data: Scrip | null; error: string | null }> {
return request<Scrip>(`/scrips/${id}`, { method: "PATCH", body: JSON.stringify(data) });
}
export async function getTemplates(): Promise<{ data: Template[] | null; error: string | null }> {
return request<Template[]>("/templates");
}
export async function createTemplate(data: {
name: string;
queue_id?: string | null;
subject_template: string;
body_template: string;
}): Promise<{ data: Template | null; error: string | null }> {
return request<Template>("/templates", { method: "POST", body: JSON.stringify(data) });
}
export async function updateTemplate(id: string, data: {
name?: string;
queue_id?: string | null;
subject_template?: string;
body_template?: string;
}): Promise<{ data: Template | null; error: string | null }> {
return request<Template>(`/templates/${id}`, { method: "PATCH", body: JSON.stringify(data) });
}
export async function previewTemplate(data: {
subject_template: string;
body_template: string;
ticket_id?: number | null;
}): Promise<{ data: TemplatePreview | null; error: string | null }> {
return request<TemplatePreview>("/templates/preview", { method: "POST", body: JSON.stringify(data) });
}
export async function deleteTemplate(id: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
return request<{ ok: boolean }>(`/templates/${id}`, { method: "DELETE" });
}
export async function getLifecycles(): Promise<{ data: Lifecycle[] | null; error: string | null }> {
return request<Lifecycle[]>("/lifecycles");
}
export async function createLifecycle(data: {
name: string;
definition: Record<string, unknown> | LifecycleDefinition;
}): Promise<{ data: Lifecycle | null; error: string | null }> {
return request<Lifecycle>("/lifecycles", { method: "POST", body: JSON.stringify(data) });
}
export async function updateLifecycle(id: string, data: {
name?: string;
definition?: Record<string, unknown> | LifecycleDefinition;
}): Promise<{ data: Lifecycle | null; error: string | null }> {
return request<Lifecycle>(`/lifecycles/${id}`, { method: "PATCH", body: JSON.stringify(data) });
}
export async function getCustomFields(): Promise<{ data: CustomField[] | null; error: string | null }> {
return request<CustomField[]>("/custom-fields");
}
export async function getQueueCustomFields(queueId: string): Promise<{ data: QueueCustomField[] | null; error: string | null }> {
return request<QueueCustomField[]>(`/custom-fields/queues/${queueId}`);
}
export async function assignQueueCustomField(queueId: string, customFieldId: string): Promise<{ data: QueueCustomField | null; error: string | null }> {
return request<QueueCustomField>(`/custom-fields/queues/${queueId}`, {
method: "POST",
body: JSON.stringify({ custom_field_id: customFieldId }),
});
}
export async function unassignQueueCustomField(queueId: string, customFieldId: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
return request<{ ok: boolean }>(`/custom-fields/queues/${queueId}/${customFieldId}`, { method: "DELETE" });
}
export async function updateTicketCustomField(ticketId: number, customFieldId: string, value: string): Promise<{ data: Transaction | null; error: string | null }> {
return request<Transaction>(`/tickets/${ticketId}/custom-fields/${customFieldId}`, {
method: "PATCH",
body: JSON.stringify({ value }),
});
}
export async function createCustomField(data: {
key?: string;
name: string;
field_type: string;
values?: unknown | null;
max_values?: number;
pattern?: string | null;
validation_config?: Record<string, unknown> | null;
default_value?: string | null;
}): Promise<{ data: CustomField | null; error: string | null }> {
return request<CustomField>("/custom-fields", { method: "POST", body: JSON.stringify(data) });
}
export async function updateCustomField(id: string, data: {
key?: string;
name?: string;
field_type?: string;
values?: unknown | null;
max_values?: number;
pattern?: string | null;
validation_config?: Record<string, unknown> | null;
default_value?: string | null;
}): Promise<{ data: CustomField | null; error: string | null }> {
return request<CustomField>(`/custom-fields/${id}`, { method: "PATCH", body: JSON.stringify(data) });
}
export async function getViews(): Promise<{ data: SavedView[] | null; error: string | null }> {
return request<SavedView[]>("/views");
}
export async function createView(data: {
name: string;
filters: { field: string; operator: string; value: string }[];
sort_key?: string;
columns?: { key: string; label: string; width: number; display: string }[];
is_public?: boolean;
}): Promise<{ data: SavedView | null; error: string | null }> {
return request<SavedView>("/views", { method: "POST", body: JSON.stringify(data) });
}
export async function updateView(id: string, data: {
name?: string;
filters?: { field: string; operator: string; value: string }[];
sort_key?: string;
columns?: unknown[];
is_public?: boolean;
}): Promise<{ data: SavedView | null; error: string | null }> {
return request<SavedView>(`/views/${id}`, { method: "PATCH", body: JSON.stringify(data) });
}
export async function deleteView(id: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
return request<{ ok: boolean }>(`/views/${id}`, { method: "DELETE" });
}
export async function getDashboards(): Promise<{ data: Dashboard[] | null; error: string | null }> {
return request<Dashboard[]>("/dashboards");
}
export async function getDashboard(id: string): Promise<{ data: Dashboard | null; error: string | null }> {
return request<Dashboard>(`/dashboards/${id}`);
}
export async function createDashboard(data: {
name: string;
description?: string;
team_id?: string | null;
is_default?: boolean;
}): Promise<{ data: Dashboard | null; error: string | null }> {
return request<Dashboard>("/dashboards", { method: "POST", body: JSON.stringify(data) });
}
export async function updateDashboard(id: string, data: {
name?: string;
description?: string | null;
team_id?: string | null;
is_default?: boolean;
layout?: unknown[];
}): Promise<{ data: Dashboard | null; error: string | null }> {
return request<Dashboard>(`/dashboards/${id}`, { method: "PATCH", body: JSON.stringify(data) });
}
export async function deleteDashboard(id: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
return request<{ ok: boolean }>(`/dashboards/${id}`, { method: "DELETE" });
}
export async function getDashboardWidgets(dashboardId: string): Promise<{ data: DashboardWidget[] | null; error: string | null }> {
return request<DashboardWidget[]>(`/dashboards/${dashboardId}/widgets`);
}
export async function createWidget(dashboardId: string, data: {
view_id: string;
title: string;
widget_type: string;
position?: { x: number; y: number; w: number; h: number };
config?: Record<string, unknown>;
}): Promise<{ data: DashboardWidget | null; error: string | null }> {
return request<DashboardWidget>(`/dashboards/${dashboardId}/widgets`, { method: "POST", body: JSON.stringify(data) });
}
export async function updateWidget(dashboardId: string, widgetId: string, data: {
title?: string;
widget_type?: string;
position?: { x: number; y: number; w: number; h: number };
config?: Record<string, unknown>;
}): Promise<{ data: DashboardWidget | null; error: string | null }> {
return request<DashboardWidget>(`/dashboards/${dashboardId}/widgets/${widgetId}`, { method: "PATCH", body: JSON.stringify(data) });
}
export async function deleteWidget(dashboardId: string, widgetId: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
return request<{ ok: boolean }>(`/dashboards/${dashboardId}/widgets/${widgetId}`, { method: "DELETE" });
}
export async function getWidgetData(dashboardId: string, widgetId: string): Promise<{ data: WidgetData | null; error: string | null }> {
return request<WidgetData>(`/dashboards/${dashboardId}/widgets/${widgetId}/data`);
}
export async function getTeams(): Promise<{ data: Team[] | null; error: string | null }> {
return request<Team[]>("/teams");
}
export async function createTeam(data: { name: string; description?: string | null }): Promise<{ data: Team | null; error: string | null }> {
return request<Team>("/teams", { method: "POST", body: JSON.stringify(data) });
}
export async function updateTeam(id: string, data: { name?: string; description?: string | null }): Promise<{ data: Team | null; error: string | null }> {
return request<Team>(`/teams/${id}`, { method: "PATCH", body: JSON.stringify(data) });
}
export async function deleteTeam(id: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
return request<{ ok: boolean }>(`/teams/${id}`, { method: "DELETE" });
}
export async function addTeamMember(teamId: string, userId: string): Promise<{ data: unknown | null; error: string | null }> {
return request<unknown>(`/teams/${teamId}/members`, { method: "POST", body: JSON.stringify({ user_id: userId }) });
}
export async function removeTeamMember(teamId: string, userId: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
return request<{ ok: boolean }>(`/teams/${teamId}/members/${userId}`, { method: "DELETE" });
}
export async function uploadAttachments(
ticketId: number,
files: File[],
): Promise<{ data: { attachments: AttachmentUploadResult[] } | null; error: string | null }> {
try {
const formData = new FormData();
for (const file of files) {
formData.append("files", file);
}
const token = typeof window !== "undefined" ? localStorage.getItem("tessera_token") : null;
const headers: Record<string, string> = {};
if (token) headers["Authorization"] = `Bearer ${token}`;
const res = await fetch(`/api/tickets/${ticketId}/attachments`, {
method: "POST",
headers,
body: formData,
});
if (!res.ok) {
const body = await res.json().catch(() => ({ error: res.statusText }));
return { data: null, error: body.error || body.message || `HTTP ${res.status}` };
}
const data = await res.json();
return { data, error: null };
} catch (err) {
return { data: null, error: err instanceof Error ? err.message : "Unknown error" };
}
}
export function getAttachmentUrl(attachmentId: string): string {
return `/api/attachments/${attachmentId}`;
}
export async function getTicketAttachments(
ticketId: number,
): Promise<{ data: Attachment[] | null; error: string | null }> {
return request<Attachment[]>(`/tickets/${ticketId}/attachments`);
}
export async function getTicketLinks(
ticketId: number,
): Promise<{ data: TicketLink[] | null; error: string | null }> {
return request<TicketLink[]>(`/tickets/${ticketId}/links`);
}
export async function createTicketLink(
ticketId: number,
data: { target_ticket_id: number; link_type: string },
): Promise<{ data: TicketLink | null; error: string | null }> {
return request<TicketLink>(`/tickets/${ticketId}/links`, { method: "POST", body: JSON.stringify(data) });
}
export async function deleteTicketLink(
ticketId: number,
linkId: string,
): Promise<{ data: { ok: boolean } | null; error: string | null }> {
return request<{ ok: boolean }>(`/tickets/${ticketId}/links/${linkId}`, { method: "DELETE" });
}
// Queue Permissions (admin)
export interface QueuePermission {
id: string;
queue_id: string;
team_id: string;
right_name: string;
team_name?: string;
queue_name?: string;
}
export async function getQueuePermissions(): Promise<{ data: QueuePermission[] | null; error: string | null }> {
return request<QueuePermission[]>("/queue-permissions");
}
export async function getTeamsAndQueues(): Promise<{ data: { teams: Team[]; queues: Queue[] } | null; error: string | null }> {
return request<{ teams: Team[]; queues: Queue[] }>("/queue-permissions/teams-and-queues");
}
export async function grantQueuePermission(
queue_id: string,
team_id: string,
right_name: string,
): Promise<{ data: QueuePermission | null; error: string | null }> {
return request<QueuePermission>("/queue-permissions", {
method: "POST",
body: JSON.stringify({ queue_id, team_id, right_name }),
});
}
export async function revokeQueuePermission(id: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
return request<{ ok: boolean }>(`/queue-permissions/${id}`, { method: "DELETE" });
}
// User Permissions (admin)
export interface UserPermission {
id: string;
queue_id: string;
user_id: string;
right_name: string;
username?: string;
queue_name?: string;
}
export async function getUserPermissions(): Promise<{ data: UserPermission[] | null; error: string | null }> {
return request<UserPermission[]>("/user-permissions");
}
export async function grantUserPermission(
queue_id: string,
user_id: string,
right_name: string,
): Promise<{ data: UserPermission | null; error: string | null }> {
return request<UserPermission>("/user-permissions", {
method: "POST",
body: JSON.stringify({ queue_id, user_id, right_name }),
});
}
export async function revokeUserPermission(id: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
return request<{ ok: boolean }>(`/user-permissions/${id}`, { method: "DELETE" });
}
// Auth
function getStoredToken(): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem("tessera_token");
}
export function setStoredToken(token: string | null) {
if (typeof window === "undefined") return;
if (token) {
localStorage.setItem("tessera_token", token);
} else {
localStorage.removeItem("tessera_token");
}
}
export async function login(
username: string,
password: string,
): Promise<{ data: LoginResult | null; error: string | null }> {
const result = await request<LoginResult>("/auth/login", {
method: "POST",
body: JSON.stringify({ username, password }),
});
if (result.data?.token) {
setStoredToken(result.data.token);
}
return result;
}
export function logout() {
setStoredToken(null);
}
export async function getMe(): Promise<{ data: User | null; error: string | null }> {
const token = getStoredToken();
if (!token) return { data: null, error: "Not authenticated" };
try {
const res = await fetch(`/api/auth/me`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
setStoredToken(null);
return { data: null, error: "Session expired" };
}
const data = await res.json();
return { data, error: null };
} catch (err) {
return { data: null, error: err instanceof Error ? err.message : "Unknown error" };
}
}
/**
* Fetch wrapper that includes the auth token.
*/
async function authRequest<T>(url: string, options?: RequestInit): Promise<{ data: T | null; error: string | null }> {
const token = getStoredToken();
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
try {
const res = await fetch(`/api${url}`, { ...options, headers: { ...headers, ...(options?.headers as Record<string, string> ?? {}) } });
if (!res.ok) {
if (res.status === 401 || res.status === 403) {
setStoredToken(null);
}
const body = await res.json().catch(() => ({ error: res.statusText }));
return { data: null, error: body.error || body.message || `HTTP ${res.status}` };
}
const data = await res.json();
return { data, error: null };
} catch (err) {
return { data: null, error: err instanceof Error ? err.message : "Unknown error" };
}
}