Initialize backendized SurClaw report system
- Add React/Vite frontend for login, dashboard, reports, templates, users, settings, AI, speech, and media workflows. - Add NestJS/Prisma/PostgreSQL backend with auth, dashboard stats, reports, templates, users, departments, settings, files, AI, speech, audit logs, and HTML sanitization. - Add Prisma schema, migrations, seed data, persistent app sessions, Docker/Nginx deployment files, and upload volume configuration. - Add Vitest, Playwright, backend integration tests, and project documentation for requirements, design, permissions, API contracts, testing, deployment, security, and progress. - Configure production local fallback switch and remove unused Gemini direct dependency/env wiring.
This commit is contained in:
24
src/api/ai.ts
Normal file
24
src/api/ai.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { apiRequest } from './client';
|
||||
|
||||
export interface AiModelsResponse {
|
||||
models: string[];
|
||||
provider: {
|
||||
endpoint: string;
|
||||
modelName: string;
|
||||
};
|
||||
raw?: unknown;
|
||||
}
|
||||
|
||||
export const listAiModels = async () => {
|
||||
const response = await apiRequest<AiModelsResponse>('/api/ai/models');
|
||||
if (!response || !Array.isArray(response.models)) {
|
||||
throw new Error('Invalid AI models response');
|
||||
}
|
||||
return response;
|
||||
};
|
||||
|
||||
export const createAiChatCompletion = async (payload: Record<string, unknown>) =>
|
||||
apiRequest<any>('/api/ai/chat', {
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
});
|
||||
38
src/api/client.test.ts
Normal file
38
src/api/client.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { apiRequest, ApiError } from './client';
|
||||
|
||||
describe('apiRequest', () => {
|
||||
it('unwraps API data envelopes and sends credentials', async () => {
|
||||
const fetchMock = vi.mocked(fetch);
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers({ 'content-type': 'application/json' }),
|
||||
json: () => Promise.resolve({ data: { ok: true } }),
|
||||
text: () => Promise.resolve(''),
|
||||
} as Response);
|
||||
|
||||
await expect(apiRequest<{ ok: boolean }>('/api/health')).resolves.toEqual({ ok: true });
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/health', expect.objectContaining({
|
||||
credentials: 'include',
|
||||
}));
|
||||
});
|
||||
|
||||
it('throws typed errors from backend error envelopes', async () => {
|
||||
const fetchMock = vi.mocked(fetch);
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 401,
|
||||
headers: new Headers({ 'content-type': 'application/json' }),
|
||||
json: () => Promise.resolve({ error: { code: 'UNAUTHORIZED', message: '未登录' } }),
|
||||
text: () => Promise.resolve(''),
|
||||
} as Response);
|
||||
|
||||
await expect(apiRequest('/api/auth/me')).rejects.toMatchObject({
|
||||
name: 'ApiError',
|
||||
status: 401,
|
||||
code: 'UNAUTHORIZED',
|
||||
message: '未登录',
|
||||
});
|
||||
});
|
||||
});
|
||||
80
src/api/client.ts
Normal file
80
src/api/client.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
export interface ApiErrorBody {
|
||||
error?: {
|
||||
code?: string;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number,
|
||||
readonly code?: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
|
||||
type ApiRequestOptions = Omit<RequestInit, 'body'> & {
|
||||
body?: BodyInit | Record<string, unknown>;
|
||||
};
|
||||
|
||||
const getApiBaseUrl = () => (import.meta.env.VITE_API_BASE_URL || '').replace(/\/$/, '');
|
||||
|
||||
const isJsonBody = (body: unknown) =>
|
||||
body !== undefined &&
|
||||
typeof body === 'object' &&
|
||||
!(body instanceof FormData) &&
|
||||
!(body instanceof Blob) &&
|
||||
!(body instanceof URLSearchParams);
|
||||
|
||||
export async function apiRequest<T>(path: string, options: ApiRequestOptions = {}): Promise<T> {
|
||||
const url = `${getApiBaseUrl()}${path.startsWith('/') ? path : `/${path}`}`;
|
||||
const headers = new Headers(options.headers);
|
||||
let body: BodyInit | null | undefined = options.body as BodyInit | null | undefined;
|
||||
|
||||
if (isJsonBody(body)) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
body,
|
||||
headers,
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
const payload = await parsePayload(response);
|
||||
if (!response.ok) {
|
||||
const errorPayload = payload as ApiErrorBody | null;
|
||||
throw new ApiError(
|
||||
errorPayload?.error?.message || `请求失败:${response.status}`,
|
||||
response.status,
|
||||
errorPayload?.error?.code,
|
||||
);
|
||||
}
|
||||
|
||||
if (payload && typeof payload === 'object' && 'data' in payload) {
|
||||
return (payload as { data: T }).data;
|
||||
}
|
||||
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
async function parsePayload(response: Response) {
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
if (contentType.includes('application/json')) {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
if (!text) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
29
src/api/dashboard.test.ts
Normal file
29
src/api/dashboard.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { getDashboardStats } from './dashboard';
|
||||
|
||||
describe('dashboard api', () => {
|
||||
it('loads dashboard stats from backend', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
headers: new Headers({ 'content-type': 'application/json' }),
|
||||
json: async () => ({
|
||||
data: {
|
||||
stats: {
|
||||
totalCount: 1,
|
||||
monthCount: 1,
|
||||
templateCount: 2,
|
||||
userCount: 3,
|
||||
todayCount: 1,
|
||||
trend: [0, 1],
|
||||
trendLabels: ['5/1', '5/2'],
|
||||
trendFullDates: ['2026-05-01', '2026-05-02'],
|
||||
maxTrend: 1,
|
||||
},
|
||||
},
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
await expect(getDashboardStats('7days')).resolves.toMatchObject({ totalCount: 1, userCount: 3 });
|
||||
expect(fetch).toHaveBeenCalledWith('/api/dashboard/stats?range=7days', expect.objectContaining({ credentials: 'include' }));
|
||||
});
|
||||
});
|
||||
21
src/api/dashboard.ts
Normal file
21
src/api/dashboard.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { apiRequest } from './client';
|
||||
|
||||
export interface DashboardStats {
|
||||
totalCount: number;
|
||||
monthCount: number;
|
||||
templateCount: number;
|
||||
userCount: number;
|
||||
todayCount: number;
|
||||
trend: number[];
|
||||
trendLabels: string[];
|
||||
trendFullDates: string[];
|
||||
maxTrend: number;
|
||||
}
|
||||
|
||||
export const getDashboardStats = async (range: '7days' | '1month') => {
|
||||
const response = await apiRequest<{ stats: DashboardStats }>(`/api/dashboard/stats?range=${range}`);
|
||||
if (!response?.stats) {
|
||||
throw new Error('Invalid dashboard stats response');
|
||||
}
|
||||
return response.stats;
|
||||
};
|
||||
37
src/api/files.test.ts
Normal file
37
src/api/files.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { listFiles, uploadFileResource } from './files';
|
||||
|
||||
describe('files API', () => {
|
||||
it('lists file resources by kind', async () => {
|
||||
const fetchMock = vi.mocked(fetch);
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers({ 'content-type': 'application/json' }),
|
||||
json: () => Promise.resolve({ data: { items: [] } }),
|
||||
text: () => Promise.resolve(''),
|
||||
} as Response);
|
||||
|
||||
await expect(listFiles('TEMPLATE_ASSET')).resolves.toEqual([]);
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/files?kind=TEMPLATE_ASSET', expect.objectContaining({
|
||||
credentials: 'include',
|
||||
}));
|
||||
});
|
||||
|
||||
it('uploads a generic file resource', async () => {
|
||||
const fetchMock = vi.mocked(fetch);
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers({ 'content-type': 'application/json' }),
|
||||
json: () => Promise.resolve({ data: { file: { id: 'file_1', filename: 'a.png', mimeType: 'image/png', size: 10, url: '/api/files/file_1/content', createdAt: '2026-05-02T00:00:00.000Z' } } }),
|
||||
text: () => Promise.resolve(''),
|
||||
} as Response);
|
||||
|
||||
await expect(uploadFileResource('data:image/png;base64,AA==', { filename: 'a.png' })).resolves.toMatchObject({ id: 'file_1' });
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/files', expect.objectContaining({
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
}));
|
||||
});
|
||||
});
|
||||
64
src/api/files.ts
Normal file
64
src/api/files.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { apiRequest } from './client';
|
||||
|
||||
export interface FileResourceDto {
|
||||
id: string;
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
url: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export const uploadUserSignature = async (userId: string, dataUrl: string) => {
|
||||
const response = await apiRequest<{ file: FileResourceDto }>(
|
||||
`/api/users/${encodeURIComponent(userId)}/signature`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: {
|
||||
dataUrl,
|
||||
filename: 'signature.jpg',
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!response?.file) {
|
||||
throw new Error('Invalid signature upload response');
|
||||
}
|
||||
return response.file;
|
||||
};
|
||||
|
||||
export const deleteUserSignature = (userId: string) =>
|
||||
apiRequest<null>(`/api/users/${encodeURIComponent(userId)}/signature`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
export type FileKind = 'TEMPLATE_ASSET' | 'VIDEO' | 'FRAME' | 'REPORT_EXPORT';
|
||||
|
||||
export const listFiles = async (kind?: FileKind) => {
|
||||
const query = kind ? `?kind=${encodeURIComponent(kind)}` : '';
|
||||
const response = await apiRequest<{ items: FileResourceDto[] }>(`/api/files${query}`);
|
||||
return response.items || [];
|
||||
};
|
||||
|
||||
export const uploadFileResource = async (
|
||||
dataUrl: string,
|
||||
options: { kind?: FileKind; filename?: string; reportId?: string } = {},
|
||||
) => {
|
||||
const response = await apiRequest<{ file: FileResourceDto }>('/api/files', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
dataUrl,
|
||||
kind: options.kind || 'TEMPLATE_ASSET',
|
||||
filename: options.filename,
|
||||
reportId: options.reportId,
|
||||
},
|
||||
});
|
||||
if (!response?.file) {
|
||||
throw new Error('Invalid file upload response');
|
||||
}
|
||||
return response.file;
|
||||
};
|
||||
|
||||
export const deleteFileResource = (id: string) =>
|
||||
apiRequest<null>(`/api/files/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
33
src/api/library.test.ts
Normal file
33
src/api/library.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { getFieldLibrary, updateFieldLibrary } from './library';
|
||||
|
||||
describe('field library API', () => {
|
||||
it('loads the backend field library envelope', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers({ 'content-type': 'application/json' }),
|
||||
json: () => Promise.resolve({ data: { library: { formFields: [], customTimeFormats: [], multiSelectOptions: {}, anesthesiaOptions: [] } } }),
|
||||
text: () => Promise.resolve(''),
|
||||
} as Response);
|
||||
|
||||
await expect(getFieldLibrary()).resolves.toMatchObject({ formFields: [] });
|
||||
});
|
||||
|
||||
it('patches field library changes', async () => {
|
||||
const fetchMock = vi.mocked(fetch);
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers({ 'content-type': 'application/json' }),
|
||||
json: () => Promise.resolve({ data: { library: { formFields: [], customTimeFormats: ['HH:mm'], multiSelectOptions: {}, anesthesiaOptions: [] } } }),
|
||||
text: () => Promise.resolve(''),
|
||||
} as Response);
|
||||
|
||||
await updateFieldLibrary({ customTimeFormats: ['HH:mm'] });
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/library/fields', expect.objectContaining({
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
}));
|
||||
});
|
||||
});
|
||||
28
src/api/library.ts
Normal file
28
src/api/library.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { FormField } from '../types';
|
||||
import { apiRequest } from './client';
|
||||
|
||||
export interface FieldLibrary {
|
||||
formFields: FormField[];
|
||||
customTimeFormats: string[];
|
||||
multiSelectOptions: Record<string, string[]>;
|
||||
anesthesiaOptions: string[];
|
||||
}
|
||||
|
||||
export const getFieldLibrary = async () => {
|
||||
const response = await apiRequest<{ library: FieldLibrary }>('/api/library/fields');
|
||||
if (!response?.library) {
|
||||
throw new Error('Invalid field library response');
|
||||
}
|
||||
return response.library;
|
||||
};
|
||||
|
||||
export const updateFieldLibrary = async (library: Partial<FieldLibrary>) => {
|
||||
const response = await apiRequest<{ library: FieldLibrary }>('/api/library/fields', {
|
||||
method: 'PATCH',
|
||||
body: library as Record<string, unknown>,
|
||||
});
|
||||
if (!response?.library) {
|
||||
throw new Error('Invalid field library response');
|
||||
}
|
||||
return response.library;
|
||||
};
|
||||
42
src/api/reports.ts
Normal file
42
src/api/reports.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Report } from '../types';
|
||||
import { apiRequest } from './client';
|
||||
|
||||
export interface ReportListResponse {
|
||||
items: Report[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export const listReports = async () => {
|
||||
const response = await apiRequest<ReportListResponse>('/api/reports');
|
||||
if (!response || !Array.isArray(response.items)) {
|
||||
throw new Error('Invalid reports response');
|
||||
}
|
||||
return response;
|
||||
};
|
||||
|
||||
export const getReport = async (id: string) => {
|
||||
const response = await apiRequest<{ report: Report }>(`/api/reports/${encodeURIComponent(id)}`);
|
||||
if (!response?.report) {
|
||||
throw new Error('Invalid report response');
|
||||
}
|
||||
return response.report;
|
||||
};
|
||||
|
||||
export const saveReportToApi = async (report: Report, existingId?: string) => {
|
||||
const response = await apiRequest<{ report: Report }>(
|
||||
existingId ? `/api/reports/${encodeURIComponent(existingId)}` : '/api/reports',
|
||||
{
|
||||
method: existingId ? 'PATCH' : 'POST',
|
||||
body: report as unknown as Record<string, unknown>,
|
||||
},
|
||||
);
|
||||
if (!response?.report) {
|
||||
throw new Error('Invalid report response');
|
||||
}
|
||||
return response.report;
|
||||
};
|
||||
|
||||
export const deleteReportFromApi = (id: string) =>
|
||||
apiRequest<null>(`/api/reports/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||
31
src/api/settings.ts
Normal file
31
src/api/settings.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { SystemSettings } from '../types';
|
||||
import { apiRequest } from './client';
|
||||
|
||||
export const getSystemSettings = async () => {
|
||||
const response = await apiRequest<{ settings: SystemSettings }>('/api/settings/system');
|
||||
if (!response?.settings) {
|
||||
throw new Error('Invalid settings response');
|
||||
}
|
||||
return response.settings;
|
||||
};
|
||||
|
||||
export const updateSystemSettings = async (settings: SystemSettings) => {
|
||||
const response = await apiRequest<{ settings: SystemSettings }>('/api/settings/system', {
|
||||
method: 'PATCH',
|
||||
body: settings as unknown as Record<string, unknown>,
|
||||
});
|
||||
if (!response?.settings) {
|
||||
throw new Error('Invalid settings response');
|
||||
}
|
||||
return response.settings;
|
||||
};
|
||||
|
||||
export const resetSystemSettings = async () => {
|
||||
const response = await apiRequest<{ settings: SystemSettings }>('/api/settings/system/reset', {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!response?.settings) {
|
||||
throw new Error('Invalid settings response');
|
||||
}
|
||||
return response.settings;
|
||||
};
|
||||
22
src/api/speech.test.ts
Normal file
22
src/api/speech.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildSpeechIatWebSocketUrl } from './speech';
|
||||
|
||||
describe('buildSpeechIatWebSocketUrl', () => {
|
||||
it('uses the current host when no explicit API base URL is configured', () => {
|
||||
expect(buildSpeechIatWebSocketUrl({ protocol: 'http:', host: 'localhost:3001' })).toBe(
|
||||
'ws://localhost:3001/api/speech/iat',
|
||||
);
|
||||
expect(buildSpeechIatWebSocketUrl({ protocol: 'https:', host: 'example.com' })).toBe(
|
||||
'wss://example.com/api/speech/iat',
|
||||
);
|
||||
});
|
||||
|
||||
it('converts an explicit API base URL to the matching websocket protocol', () => {
|
||||
expect(buildSpeechIatWebSocketUrl({ protocol: 'http:', host: 'localhost:3001' }, 'http://localhost:3002')).toBe(
|
||||
'ws://localhost:3002/api/speech/iat',
|
||||
);
|
||||
expect(buildSpeechIatWebSocketUrl({ protocol: 'http:', host: 'localhost:3001' }, 'https://api.example.com/')).toBe(
|
||||
'wss://api.example.com/api/speech/iat',
|
||||
);
|
||||
});
|
||||
});
|
||||
14
src/api/speech.ts
Normal file
14
src/api/speech.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export const buildSpeechIatWebSocketUrl = (location: Pick<Location, 'protocol' | 'host'>, explicitBase = '') => {
|
||||
explicitBase = explicitBase.replace(/\/$/, '');
|
||||
if (explicitBase) {
|
||||
const url = new URL('/api/speech/iat', explicitBase);
|
||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
return `${protocol}//${location.host}/api/speech/iat`;
|
||||
};
|
||||
|
||||
export const getSpeechIatWebSocketUrl = () =>
|
||||
buildSpeechIatWebSocketUrl(window.location, import.meta.env.VITE_API_BASE_URL || '');
|
||||
50
src/api/templates.ts
Normal file
50
src/api/templates.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Template } from '../types';
|
||||
import { apiRequest } from './client';
|
||||
|
||||
export interface TemplateListResponse {
|
||||
items: Template[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export type TemplateAccess = 'use' | 'manage';
|
||||
|
||||
export const listTemplates = async (access: TemplateAccess = 'use') => {
|
||||
const response = await apiRequest<TemplateListResponse>(`/api/templates?access=${access}`);
|
||||
if (!response || !Array.isArray(response.items)) {
|
||||
throw new Error('Invalid templates response');
|
||||
}
|
||||
return response;
|
||||
};
|
||||
|
||||
export const getTemplate = async (id: string) => {
|
||||
const response = await apiRequest<{ template: Template }>(`/api/templates/${encodeURIComponent(id)}`);
|
||||
if (!response?.template) {
|
||||
throw new Error('Invalid template response');
|
||||
}
|
||||
return response.template;
|
||||
};
|
||||
|
||||
export const createTemplate = async (template: Template) => {
|
||||
const response = await apiRequest<{ template: Template }>('/api/templates', {
|
||||
method: 'POST',
|
||||
body: template as unknown as Record<string, unknown>,
|
||||
});
|
||||
if (!response?.template) {
|
||||
throw new Error('Invalid template response');
|
||||
}
|
||||
return response.template;
|
||||
};
|
||||
|
||||
export const updateTemplate = async (id: string, template: Partial<Template>) => {
|
||||
const response = await apiRequest<{ template: Template }>(`/api/templates/${encodeURIComponent(id)}`, {
|
||||
method: 'PATCH',
|
||||
body: template as unknown as Record<string, unknown>,
|
||||
});
|
||||
if (!response?.template) {
|
||||
throw new Error('Invalid template response');
|
||||
}
|
||||
return response.template;
|
||||
};
|
||||
|
||||
export const deleteTemplateFromApi = (id: string) =>
|
||||
apiRequest<null>(`/api/templates/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||
80
src/api/users.ts
Normal file
80
src/api/users.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { User } from '../types';
|
||||
import { apiRequest } from './client';
|
||||
|
||||
export interface Department {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
visibleTemplates: string[];
|
||||
manageableTemplates: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface UserListResponse {
|
||||
items: User[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface DepartmentListResponse {
|
||||
items: Department[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export const listUsers = async () => {
|
||||
const response = await apiRequest<UserListResponse>('/api/users');
|
||||
if (!response || !Array.isArray(response.items)) {
|
||||
throw new Error('Invalid users response');
|
||||
}
|
||||
return response;
|
||||
};
|
||||
|
||||
export const createUser = async (user: User) => {
|
||||
const response = await apiRequest<{ user: User }>('/api/users', {
|
||||
method: 'POST',
|
||||
body: user as unknown as Record<string, unknown>,
|
||||
});
|
||||
if (!response?.user) {
|
||||
throw new Error('Invalid user response');
|
||||
}
|
||||
return response.user;
|
||||
};
|
||||
|
||||
export const updateUser = async (id: string, user: Partial<User>) => {
|
||||
const response = await apiRequest<{ user: User }>(`/api/users/${encodeURIComponent(id)}`, {
|
||||
method: 'PATCH',
|
||||
body: user as unknown as Record<string, unknown>,
|
||||
});
|
||||
if (!response?.user) {
|
||||
throw new Error('Invalid user response');
|
||||
}
|
||||
return response.user;
|
||||
};
|
||||
|
||||
export const deleteUserFromApi = (id: string) =>
|
||||
apiRequest<null>(`/api/users/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||
|
||||
export const listDepartments = async () => {
|
||||
const response = await apiRequest<DepartmentListResponse>('/api/departments');
|
||||
if (!response || !Array.isArray(response.items)) {
|
||||
throw new Error('Invalid departments response');
|
||||
}
|
||||
return response;
|
||||
};
|
||||
|
||||
export const updateDepartmentTemplatePermissions = async (
|
||||
id: string,
|
||||
body: Pick<Department, 'visibleTemplates' | 'manageableTemplates'>,
|
||||
) => {
|
||||
const response = await apiRequest<{ department: Department }>(
|
||||
`/api/departments/${encodeURIComponent(id)}/template-permissions`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
body,
|
||||
},
|
||||
);
|
||||
if (!response?.department) {
|
||||
throw new Error('Invalid department response');
|
||||
}
|
||||
return response.department;
|
||||
};
|
||||
Reference in New Issue
Block a user