- 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
727 lines
26 KiB
TypeScript
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" };
|
|
}
|
|
}
|