feat(web): automations contracts, API client, atoms and hooks

Foundation for the v1 automations UI. Mirrors backend Pydantic schemas
into Zod and wires the data layer end-to-end so feature surfaces can
be built on top.

contracts/types/automation.types.ts:
- AutomationStatus, TriggerType, RunStatus enums.
- AutomationDefinition envelope (PlanStep, TriggerSpec, Execution,
  Metadata, Inputs).
- AutomationCreate/Update/Detail/Summary/List + listParams.
- TriggerCreate/Update/Detail.
- RunSummary/Detail/List + runListParams.

lib/apis/automations-api.service.ts:
- list/get/create/update/delete automations.
- add/update/remove triggers (sub-resource).
- list/get runs (read-only sub-resource).
- safeParse on every write, 204-safe deletes.

atoms/automations/:
- automationsListAtom (active search space, first page).
- 6 mutation atoms with toast + cache invalidation.

hooks/:
- use-automations.ts wraps the list atom.
- use-automation.ts: parameterized detail by id.
- use-automation-runs.ts: useAutomationRuns + useAutomationRun.

lib/query-client/cache-keys.ts: automations namespace (list, detail,
runs, run) keyed by (id, limit, offset) where relevant.

Smoke: zod round-trip OK on backend-shape payloads (Automation,
AutomationCreate, Trigger, Run); typecheck clean for new files;
biome clean.
This commit is contained in:
CREDO23 2026-05-28 00:55:57 +02:00
parent d48bb2033b
commit b18a5fdca9
8 changed files with 548 additions and 0 deletions

View file

@ -0,0 +1,102 @@
import {
type AutomationCreateRequest,
type AutomationListParams,
type AutomationUpdateRequest,
automation,
automationCreateRequest,
automationListResponse,
automationUpdateRequest,
type RunListParams,
run,
runListResponse,
type TriggerCreateRequest,
type TriggerUpdateRequest,
trigger,
triggerCreateRequest,
triggerUpdateRequest,
} from "@/contracts/types/automation.types";
import { ValidationError } from "../error";
import { baseApiService } from "./base-api.service";
const BASE = "/api/v1/automations";
function rejectIfInvalid<T>(
parsed: { success: true; data: T } | { success: false; error: { issues: { message: string }[] } }
): T {
if (!parsed.success) {
throw new ValidationError(
`Invalid request: ${parsed.error.issues.map((i) => i.message).join(", ")}`
);
}
return parsed.data;
}
class AutomationsApiService {
// ---- Automations ---------------------------------------------------------
listAutomations = async (params: AutomationListParams) => {
const qs = new URLSearchParams({
search_space_id: String(params.search_space_id),
limit: String(params.limit),
offset: String(params.offset),
});
return baseApiService.get(`${BASE}?${qs.toString()}`, automationListResponse);
};
getAutomation = async (automationId: number) => {
return baseApiService.get(`${BASE}/${automationId}`, automation);
};
createAutomation = async (request: AutomationCreateRequest) => {
const data = rejectIfInvalid(automationCreateRequest.safeParse(request));
return baseApiService.post(BASE, automation, { body: data });
};
updateAutomation = async (automationId: number, request: AutomationUpdateRequest) => {
const data = rejectIfInvalid(automationUpdateRequest.safeParse(request));
return baseApiService.patch(`${BASE}/${automationId}`, automation, { body: data });
};
// Server returns 204; baseApiService now resolves to null and skips schema validation.
deleteAutomation = async (automationId: number) => {
return baseApiService.delete(`${BASE}/${automationId}`);
};
// ---- Triggers (sub-resource) --------------------------------------------
addTrigger = async (automationId: number, request: TriggerCreateRequest) => {
const data = rejectIfInvalid(triggerCreateRequest.safeParse(request));
return baseApiService.post(`${BASE}/${automationId}/triggers`, trigger, { body: data });
};
updateTrigger = async (
automationId: number,
triggerId: number,
request: TriggerUpdateRequest
) => {
const data = rejectIfInvalid(triggerUpdateRequest.safeParse(request));
return baseApiService.patch(`${BASE}/${automationId}/triggers/${triggerId}`, trigger, {
body: data,
});
};
removeTrigger = async (automationId: number, triggerId: number) => {
return baseApiService.delete(`${BASE}/${automationId}/triggers/${triggerId}`);
};
// ---- Runs (sub-resource, read-only) -------------------------------------
listRuns = async (automationId: number, params: RunListParams) => {
const qs = new URLSearchParams({
limit: String(params.limit),
offset: String(params.offset),
});
return baseApiService.get(`${BASE}/${automationId}/runs?${qs.toString()}`, runListResponse);
};
getRun = async (automationId: number, runId: number) => {
return baseApiService.get(`${BASE}/${automationId}/runs/${runId}`, run);
};
}
export const automationsApiService = new AutomationsApiService();

View file

@ -126,4 +126,14 @@ export const cacheKeys = {
batchUnreadCounts: (searchSpaceId: number | null) =>
["notifications", "unread-counts-batch", searchSpaceId] as const,
},
automations: {
// list endpoint is keyed by pagination too so distinct pages don't collide
list: (searchSpaceId: number, limit: number, offset: number) =>
["automations", "list", searchSpaceId, limit, offset] as const,
detail: (automationId: number) => ["automations", "detail", automationId] as const,
runs: (automationId: number, limit: number, offset: number) =>
["automations", "runs", automationId, limit, offset] as const,
run: (automationId: number, runId: number) =>
["automations", "runs", automationId, runId] as const,
},
};