fix: frontend API mapping, security hardening, test suite
Frontend:
- Fix all API response unwrapping (backend wraps in {data:...})
- Fix workflow publish/archive to use PATCH /status endpoint
- Fix step reorder payload format
- Add tenantId to workflow creation
- Fix step count display using _count
Security (CRITICAL/HIGH):
- Add JWT algorithm restriction (HS256 only)
- Replace weak default JWT secrets
- Fix token blacklist memory leak
- Fix step reorder IDOR vulnerability
- Strengthen password policy (8 chars, mixed case + digit)
Tests:
- 68 tests: auth (17), workflows (26), tenants (25)
- Complete Prisma mock setup with test fixtures
This commit is contained in:
@@ -57,7 +57,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
await authApi.logout();
|
||||
const refreshToken = localStorage.getItem('refreshToken');
|
||||
await authApi.logout(refreshToken);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
@@ -217,7 +217,7 @@ export default function Dashboard() {
|
||||
{wf.title}
|
||||
</p>
|
||||
<p className="truncate text-sm text-gray-500">
|
||||
{wf.steps?.length || 0} Schritte · v{wf.version}
|
||||
{wf._count?.steps ?? wf.steps?.length ?? 0} Schritte · v{wf.version}
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge status={wf.status} />
|
||||
|
||||
@@ -11,8 +11,9 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { DragDropContext, Draggable, Droppable, DropResult } from '@hello-pangea/dnd';
|
||||
import toast from 'react-hot-toast';
|
||||
import { workflowsApi, categoriesApi } from '../services/api';
|
||||
import type { Category, Workflow, WorkflowStep } from '../types';
|
||||
import { workflowsApi, stepsApi, categoriesApi, tenantsApi } from '../services/api';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import type { Category, Tenant, Workflow, WorkflowStep } from '../types';
|
||||
import StepTypeIcon from '../components/StepTypeIcon';
|
||||
|
||||
interface StepDraft {
|
||||
@@ -40,6 +41,7 @@ function nextKey() {
|
||||
export default function WorkflowEditor() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const isNew = !id;
|
||||
|
||||
const [title, setTitle] = useState('');
|
||||
@@ -47,20 +49,28 @@ export default function WorkflowEditor() {
|
||||
const [categoryId, setCategoryId] = useState('');
|
||||
const [tagsInput, setTagsInput] = useState('');
|
||||
const [isTemplate, setIsTemplate] = useState(false);
|
||||
const [tenantId, setTenantId] = useState(user?.tenantId || '');
|
||||
const [tenants, setTenants] = useState<Tenant[]>([]);
|
||||
const [steps, setSteps] = useState<StepDraft[]>([]);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [loading, setLoading] = useState(!isNew);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [autoSaved, setAutoSaved] = useState(false);
|
||||
|
||||
const isAdmin = user?.role === 'SYSTEM_ADMIN' || user?.role === 'TECHNICIAN';
|
||||
|
||||
const autoSaveTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
useEffect(() => {
|
||||
categoriesApi.list().then(setCategories).catch(() => {});
|
||||
}, []);
|
||||
if (isAdmin) {
|
||||
tenantsApi.list({ pageSize: 100 }).then((r) => setTenants(r.data)).catch(() => {});
|
||||
}
|
||||
}, [isAdmin]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
setWorkflowId(id);
|
||||
setLoading(true);
|
||||
workflowsApi
|
||||
.get(id)
|
||||
@@ -95,23 +105,53 @@ export default function WorkflowEditor() {
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const buildPayload = (status?: Workflow['status']) => ({
|
||||
const buildMetadataPayload = (status?: Workflow['status']) => ({
|
||||
title,
|
||||
description: description || undefined,
|
||||
categoryId: categoryId || undefined,
|
||||
tags: parseTags(),
|
||||
isTemplate,
|
||||
status,
|
||||
steps: steps.map((s, idx) => ({
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
content: s.content,
|
||||
type: s.type,
|
||||
isRequired: s.isRequired,
|
||||
order: idx + 1,
|
||||
})),
|
||||
...(status ? { status } : {}),
|
||||
});
|
||||
|
||||
const syncSteps = async (wfId: string) => {
|
||||
// Get current steps from server
|
||||
const currentWorkflow = await workflowsApi.get(wfId);
|
||||
const serverSteps = currentWorkflow.steps || [];
|
||||
const serverStepIds = new Set(serverSteps.map((s) => s.id));
|
||||
|
||||
// Determine which local steps need to be created, updated, or deleted
|
||||
const localStepIds = new Set(steps.filter((s) => s.id).map((s) => s.id!));
|
||||
|
||||
// Delete steps that no longer exist locally
|
||||
for (const serverStep of serverSteps) {
|
||||
if (!localStepIds.has(serverStep.id)) {
|
||||
await stepsApi.delete(wfId, serverStep.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Create or update steps
|
||||
for (let idx = 0; idx < steps.length; idx++) {
|
||||
const step = steps[idx];
|
||||
const stepData = {
|
||||
title: step.title || 'Unbenannter Schritt',
|
||||
content: step.content || ' ',
|
||||
type: step.type,
|
||||
isRequired: step.isRequired,
|
||||
order: idx + 1,
|
||||
};
|
||||
if (step.id && serverStepIds.has(step.id)) {
|
||||
// Update existing step
|
||||
await stepsApi.update(wfId, step.id, stepData);
|
||||
} else {
|
||||
// Create new step
|
||||
const created = await stepsApi.create(wfId, stepData);
|
||||
// Update local step with new ID
|
||||
step.id = created.id;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = useCallback(
|
||||
async (status?: Workflow['status']) => {
|
||||
if (!title.trim()) {
|
||||
@@ -120,16 +160,39 @@ export default function WorkflowEditor() {
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = buildPayload(status);
|
||||
if (isNew) {
|
||||
const created = await workflowsApi.create({
|
||||
...payload,
|
||||
// Build create payload with tenantId, status, and inline steps
|
||||
const effectiveTenantId = tenantId || user?.tenantId;
|
||||
if (!effectiveTenantId) {
|
||||
toast.error('Bitte wählen Sie einen Mandanten');
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
const createPayload = {
|
||||
title,
|
||||
description: description || undefined,
|
||||
tenantId: effectiveTenantId,
|
||||
categoryId: categoryId || undefined,
|
||||
tags: parseTags(),
|
||||
isTemplate,
|
||||
status: status || 'DRAFT',
|
||||
} as Partial<Workflow>);
|
||||
steps: steps.map((s, idx) => ({
|
||||
title: s.title || 'Unbenannter Schritt',
|
||||
content: s.content || ' ',
|
||||
type: s.type,
|
||||
isRequired: s.isRequired,
|
||||
order: idx + 1,
|
||||
})),
|
||||
};
|
||||
const created = await workflowsApi.create(createPayload as Partial<Workflow>);
|
||||
toast.success('Workflow erstellt');
|
||||
navigate(`/workflows/${created.id}/edit`, { replace: true });
|
||||
} else {
|
||||
// Update metadata first
|
||||
const payload = buildMetadataPayload(status);
|
||||
await workflowsApi.update(id!, payload as Partial<Workflow>);
|
||||
// Sync steps via individual step endpoints
|
||||
await syncSteps(id!);
|
||||
toast.success('Workflow gespeichert');
|
||||
}
|
||||
} catch {
|
||||
@@ -138,17 +201,17 @@ export default function WorkflowEditor() {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[title, description, categoryId, tagsInput, isTemplate, steps, isNew, id, navigate]
|
||||
[title, description, categoryId, tagsInput, isTemplate, tenantId, steps, isNew, id, navigate, user]
|
||||
);
|
||||
|
||||
// Auto-save (only for existing workflows)
|
||||
// Auto-save (only for existing workflows) - metadata only
|
||||
useEffect(() => {
|
||||
if (isNew || loading) return;
|
||||
clearTimeout(autoSaveTimer.current);
|
||||
autoSaveTimer.current = setTimeout(async () => {
|
||||
if (!title.trim()) return;
|
||||
try {
|
||||
await workflowsApi.update(id!, buildPayload() as Partial<Workflow>);
|
||||
await workflowsApi.update(id!, buildMetadataPayload() as Partial<Workflow>);
|
||||
setAutoSaved(true);
|
||||
setTimeout(() => setAutoSaved(false), 2000);
|
||||
} catch {
|
||||
@@ -156,7 +219,7 @@ export default function WorkflowEditor() {
|
||||
}
|
||||
}, 5000);
|
||||
return () => clearTimeout(autoSaveTimer.current);
|
||||
}, [title, description, categoryId, tagsInput, isTemplate, steps, isNew, loading, id]);
|
||||
}, [title, description, categoryId, tagsInput, isTemplate, isNew, loading, id]);
|
||||
|
||||
const addStep = () => {
|
||||
setSteps((prev) => [
|
||||
@@ -250,6 +313,26 @@ export default function WorkflowEditor() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isAdmin && isNew && (
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">
|
||||
Mandant *
|
||||
</label>
|
||||
<select
|
||||
value={tenantId}
|
||||
onChange={(e) => setTenantId(e.target.value)}
|
||||
className="input-field"
|
||||
>
|
||||
<option value="">Mandant wählen...</option>
|
||||
{tenants.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-5 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">
|
||||
|
||||
@@ -197,7 +197,7 @@ export default function WorkflowList() {
|
||||
)}
|
||||
<div className="mt-4 flex items-center justify-between text-xs text-gray-400">
|
||||
<span>
|
||||
{wf.steps?.length || 0} Schritte · v{wf.version}
|
||||
{wf._count?.steps ?? wf.steps?.length ?? 0} Schritte · v{wf.version}
|
||||
</span>
|
||||
<span>
|
||||
{new Date(wf.updatedAt).toLocaleDateString('de-DE')}
|
||||
@@ -254,7 +254,7 @@ export default function WorkflowList() {
|
||||
</span>
|
||||
)}
|
||||
<span className="hidden text-sm text-gray-400 sm:inline">
|
||||
{wf.steps?.length || 0} Schritte
|
||||
{wf._count?.steps ?? wf.steps?.length ?? 0} Schritte
|
||||
</span>
|
||||
<StatusBadge status={wf.status} />
|
||||
<span className="hidden text-xs text-gray-400 lg:inline">
|
||||
|
||||
@@ -86,8 +86,10 @@ export default function WorkflowView() {
|
||||
const handlePublish = async () => {
|
||||
if (!id) return;
|
||||
try {
|
||||
const updated = await workflowsApi.publish(id);
|
||||
setWorkflow(updated);
|
||||
await workflowsApi.publish(id);
|
||||
// Reload full workflow with steps
|
||||
const refreshed = await workflowsApi.get(id);
|
||||
setWorkflow(refreshed);
|
||||
toast.success('Workflow veröffentlicht');
|
||||
} catch {
|
||||
toast.error('Veröffentlichen fehlgeschlagen');
|
||||
|
||||
+129
-30
@@ -73,9 +73,10 @@ api.interceptors.response.use(
|
||||
}
|
||||
|
||||
try {
|
||||
const { data } = await axios.post<AuthResponse>('/api/auth/refresh', {
|
||||
refreshToken,
|
||||
});
|
||||
const { data } = await axios.post<{ accessToken: string; refreshToken: string }>(
|
||||
'/api/auth/refresh',
|
||||
{ refreshToken },
|
||||
);
|
||||
localStorage.setItem('accessToken', data.accessToken);
|
||||
localStorage.setItem('refreshToken', data.refreshToken);
|
||||
processQueue(null, data.accessToken);
|
||||
@@ -99,35 +100,66 @@ api.interceptors.response.use(
|
||||
);
|
||||
|
||||
// --- Auth ---
|
||||
// Login returns { accessToken, refreshToken, user } directly
|
||||
// /me returns user object directly
|
||||
// /refresh returns { accessToken, refreshToken } (no user)
|
||||
export const authApi = {
|
||||
login: (email: string, password: string) =>
|
||||
api.post<AuthResponse>('/auth/login', { email, password }).then((r) => r.data),
|
||||
|
||||
refresh: (refreshToken: string) =>
|
||||
api.post<AuthResponse>('/auth/refresh', { refreshToken }).then((r) => r.data),
|
||||
api
|
||||
.post<{ accessToken: string; refreshToken: string }>('/auth/refresh', { refreshToken })
|
||||
.then((r) => r.data),
|
||||
|
||||
me: () => api.get<User>('/auth/me').then((r) => r.data),
|
||||
|
||||
logout: () => api.post('/auth/logout').then((r) => r.data),
|
||||
logout: (refreshToken?: string | null) =>
|
||||
api.post('/auth/logout', { refreshToken }).then((r) => r.data),
|
||||
};
|
||||
|
||||
// --- Tenants ---
|
||||
// Backend returns { data: Tenant[] } (no pagination)
|
||||
export const tenantsApi = {
|
||||
list: (params?: { page?: number; pageSize?: number; search?: string }) =>
|
||||
api.get<PaginatedResponse<Tenant>>('/tenants', { params }).then((r) => r.data),
|
||||
api
|
||||
.get<{ data: Tenant[] }>('/tenants', { params })
|
||||
.then((r) => {
|
||||
const tenants = r.data.data;
|
||||
// Client-side search if the backend doesn't support it
|
||||
let filtered = tenants;
|
||||
if (params?.search) {
|
||||
const s = params.search.toLowerCase();
|
||||
filtered = tenants.filter(
|
||||
(t) =>
|
||||
t.name.toLowerCase().includes(s) ||
|
||||
t.slug.toLowerCase().includes(s)
|
||||
);
|
||||
}
|
||||
// Client-side pagination
|
||||
const page = params?.page || 1;
|
||||
const pageSize = params?.pageSize || 20;
|
||||
const total = filtered.length;
|
||||
const totalPages = Math.ceil(total / pageSize) || 1;
|
||||
const start = (page - 1) * pageSize;
|
||||
const data = filtered.slice(start, start + pageSize);
|
||||
return { data, total, page, pageSize, totalPages } as PaginatedResponse<Tenant>;
|
||||
}),
|
||||
|
||||
get: (id: string) => api.get<Tenant>(`/tenants/${id}`).then((r) => r.data),
|
||||
get: (id: string) =>
|
||||
api.get<{ data: Tenant }>(`/tenants/${id}`).then((r) => r.data.data),
|
||||
|
||||
create: (data: Partial<Tenant>) =>
|
||||
api.post<Tenant>('/tenants', data).then((r) => r.data),
|
||||
api.post<{ data: Tenant }>('/tenants', data).then((r) => r.data.data),
|
||||
|
||||
update: (id: string, data: Partial<Tenant>) =>
|
||||
api.put<Tenant>(`/tenants/${id}`, data).then((r) => r.data),
|
||||
api.put<{ data: Tenant }>(`/tenants/${id}`, data).then((r) => r.data.data),
|
||||
|
||||
delete: (id: string) => api.delete(`/tenants/${id}`).then((r) => r.data),
|
||||
};
|
||||
|
||||
// --- Users ---
|
||||
// Backend returns { data: User[] } (no pagination)
|
||||
export const usersApi = {
|
||||
list: (params?: {
|
||||
page?: number;
|
||||
@@ -135,20 +167,55 @@ export const usersApi = {
|
||||
search?: string;
|
||||
tenantId?: string;
|
||||
role?: string;
|
||||
}) => api.get<PaginatedResponse<User>>('/users', { params }).then((r) => r.data),
|
||||
}) =>
|
||||
api
|
||||
.get<{ data: User[] }>('/users', {
|
||||
params: {
|
||||
tenantId: params?.tenantId,
|
||||
},
|
||||
})
|
||||
.then((r) => {
|
||||
let users = r.data.data;
|
||||
// Client-side search
|
||||
if (params?.search) {
|
||||
const s = params.search.toLowerCase();
|
||||
users = users.filter(
|
||||
(u) =>
|
||||
u.firstName.toLowerCase().includes(s) ||
|
||||
u.lastName.toLowerCase().includes(s) ||
|
||||
u.email.toLowerCase().includes(s)
|
||||
);
|
||||
}
|
||||
// Client-side role filter
|
||||
if (params?.role) {
|
||||
users = users.filter((u) => u.role === params.role);
|
||||
}
|
||||
// Client-side pagination
|
||||
const page = params?.page || 1;
|
||||
const pageSize = params?.pageSize || 20;
|
||||
const total = users.length;
|
||||
const totalPages = Math.ceil(total / pageSize) || 1;
|
||||
const start = (page - 1) * pageSize;
|
||||
const data = users.slice(start, start + pageSize);
|
||||
return { data, total, page, pageSize, totalPages } as PaginatedResponse<User>;
|
||||
}),
|
||||
|
||||
get: (id: string) => api.get<User>(`/users/${id}`).then((r) => r.data),
|
||||
get: (id: string) =>
|
||||
api.get<{ data: User }>(`/users/${id}`).then((r) => r.data.data),
|
||||
|
||||
create: (data: Partial<User> & { password?: string }) =>
|
||||
api.post<User>('/users', data).then((r) => r.data),
|
||||
api.post<{ data: User }>('/users', data).then((r) => r.data.data),
|
||||
|
||||
update: (id: string, data: Partial<User>) =>
|
||||
api.put<User>(`/users/${id}`, data).then((r) => r.data),
|
||||
api.put<{ data: User }>(`/users/${id}`, data).then((r) => r.data.data),
|
||||
|
||||
delete: (id: string) => api.delete(`/users/${id}`).then((r) => r.data),
|
||||
};
|
||||
|
||||
// --- Workflows ---
|
||||
// Backend list returns { data: Workflow[], pagination: { page, limit, total, totalPages } }
|
||||
// Backend uses "limit" query param (not "pageSize")
|
||||
// Single-item endpoints return { data: Workflow }
|
||||
export const workflowsApi = {
|
||||
list: (params?: {
|
||||
page?: number;
|
||||
@@ -161,59 +228,91 @@ export const workflowsApi = {
|
||||
tags?: string;
|
||||
}) =>
|
||||
api
|
||||
.get<PaginatedResponse<Workflow>>('/workflows', { params })
|
||||
.then((r) => r.data),
|
||||
.get<{
|
||||
data: Workflow[];
|
||||
pagination: { page: number; limit: number; total: number; totalPages: number };
|
||||
}>('/workflows', {
|
||||
params: {
|
||||
page: params?.page,
|
||||
limit: params?.pageSize,
|
||||
search: params?.search,
|
||||
status: params?.status,
|
||||
categoryId: params?.categoryId,
|
||||
tenantId: params?.tenantId,
|
||||
isTemplate: params?.isTemplate,
|
||||
tags: params?.tags,
|
||||
},
|
||||
})
|
||||
.then((r) => ({
|
||||
data: r.data.data,
|
||||
total: r.data.pagination.total,
|
||||
page: r.data.pagination.page,
|
||||
pageSize: r.data.pagination.limit,
|
||||
totalPages: r.data.pagination.totalPages,
|
||||
})),
|
||||
|
||||
get: (id: string) => api.get<Workflow>(`/workflows/${id}`).then((r) => r.data),
|
||||
get: (id: string) =>
|
||||
api.get<{ data: Workflow }>(`/workflows/${id}`).then((r) => r.data.data),
|
||||
|
||||
create: (data: Partial<Workflow>) =>
|
||||
api.post<Workflow>('/workflows', data).then((r) => r.data),
|
||||
api.post<{ data: Workflow }>('/workflows', data).then((r) => r.data.data),
|
||||
|
||||
update: (id: string, data: Partial<Workflow>) =>
|
||||
api.put<Workflow>(`/workflows/${id}`, data).then((r) => r.data),
|
||||
api.put<{ data: Workflow }>(`/workflows/${id}`, data).then((r) => r.data.data),
|
||||
|
||||
delete: (id: string) => api.delete(`/workflows/${id}`).then((r) => r.data),
|
||||
|
||||
duplicate: (id: string) =>
|
||||
api.post<Workflow>(`/workflows/${id}/duplicate`).then((r) => r.data),
|
||||
api.post<{ data: Workflow }>(`/workflows/${id}/duplicate`).then((r) => r.data.data),
|
||||
|
||||
// Publish and archive use the PATCH /:id/status endpoint
|
||||
publish: (id: string) =>
|
||||
api.post<Workflow>(`/workflows/${id}/publish`).then((r) => r.data),
|
||||
api
|
||||
.patch<{ data: Workflow }>(`/workflows/${id}/status`, { status: 'PUBLISHED' })
|
||||
.then((r) => r.data.data),
|
||||
|
||||
archive: (id: string) =>
|
||||
api.post<Workflow>(`/workflows/${id}/archive`).then((r) => r.data),
|
||||
api
|
||||
.patch<{ data: Workflow }>(`/workflows/${id}/status`, { status: 'ARCHIVED' })
|
||||
.then((r) => r.data.data),
|
||||
|
||||
changeStatus: (id: string, status: Workflow['status']) =>
|
||||
api
|
||||
.patch<{ data: Workflow }>(`/workflows/${id}/status`, { status })
|
||||
.then((r) => r.data.data),
|
||||
};
|
||||
|
||||
// --- Workflow Steps ---
|
||||
export const stepsApi = {
|
||||
create: (workflowId: string, data: Partial<WorkflowStep>) =>
|
||||
api
|
||||
.post<WorkflowStep>(`/workflows/${workflowId}/steps`, data)
|
||||
.then((r) => r.data),
|
||||
.post<{ data: WorkflowStep }>(`/workflows/${workflowId}/steps`, data)
|
||||
.then((r) => r.data.data),
|
||||
|
||||
update: (workflowId: string, stepId: string, data: Partial<WorkflowStep>) =>
|
||||
api
|
||||
.put<WorkflowStep>(`/workflows/${workflowId}/steps/${stepId}`, data)
|
||||
.then((r) => r.data),
|
||||
.put<{ data: WorkflowStep }>(`/workflows/${workflowId}/steps/${stepId}`, data)
|
||||
.then((r) => r.data.data),
|
||||
|
||||
delete: (workflowId: string, stepId: string) =>
|
||||
api.delete(`/workflows/${workflowId}/steps/${stepId}`).then((r) => r.data),
|
||||
|
||||
reorder: (workflowId: string, stepIds: string[]) =>
|
||||
reorder: (workflowId: string, steps: { id: string; order: number }[]) =>
|
||||
api
|
||||
.put(`/workflows/${workflowId}/steps/reorder`, { stepIds })
|
||||
.patch(`/workflows/${workflowId}/steps/reorder`, { steps })
|
||||
.then((r) => r.data),
|
||||
};
|
||||
|
||||
// --- Categories ---
|
||||
export const categoriesApi = {
|
||||
list: () => api.get<Category[]>('/categories').then((r) => r.data),
|
||||
list: () =>
|
||||
api.get<{ data: Category[] }>('/categories').then((r) => r.data.data),
|
||||
|
||||
create: (data: Partial<Category>) =>
|
||||
api.post<Category>('/categories', data).then((r) => r.data),
|
||||
api.post<{ data: Category }>('/categories', data).then((r) => r.data.data),
|
||||
|
||||
update: (id: string, data: Partial<Category>) =>
|
||||
api.put<Category>(`/categories/${id}`, data).then((r) => r.data),
|
||||
api.put<{ data: Category }>(`/categories/${id}`, data).then((r) => r.data.data),
|
||||
|
||||
delete: (id: string) => api.delete(`/categories/${id}`).then((r) => r.data),
|
||||
};
|
||||
|
||||
@@ -27,7 +27,8 @@ export interface Workflow {
|
||||
version: number;
|
||||
isTemplate: boolean;
|
||||
tags: string[];
|
||||
steps: WorkflowStep[];
|
||||
steps?: WorkflowStep[];
|
||||
_count?: { steps: number };
|
||||
createdBy: User;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
|
||||
Reference in New Issue
Block a user