fix(config): align Configuration pages with platform logic

Audit of the admin Configuration section (FAQ Manager, Notification Rules,
Approval Config) surfaced three contract mismatches and one missing schema.

FAQ
  * Backend `encoach.faq.category.audience` / `encoach.faq.item.audience`
    rejected the `both` and `entity` values emitted by the FAQ Manager
    UI. Widened the Selection so the UI's full vocabulary round-trips.
  * FAQ item `video_url` was already accepted by the UI but silently
    dropped by the controller; now persisted via a new `video_url`
    field on `encoach.faq.item` and serialized on list/get responses.

Notification Rules
  * Added `days_before`, `frequency`, `channel`, `entity_id` to
    `encoach.notification.rule`. These are the exact fields the admin
    form collects; prior to this change they were serialized into the
    JSON body, ignored by the controller, and omitted from responses,
    leaving the Active switch permanently off and the table columns
    blank.
  * Controller now translates `active` <-> `is_active` both ways so the
    new frontend contract and legacy callers coexist. Missing required
    fields return HTTP 400 instead of 500.

Approval Workflows
  * The controller had been hand-rolling raw SQL against three tables
    (`encoach_approval_workflow`, `_stage`, `_request`) that no Odoo
    model declared, so the tables never existed. List() was guarded and
    returned empty; create() would 500 with "relation does not exist".
  * Introduced real ORM models in `encoach_exam_template/models/approval.py`
    plus access rights, which auto-provision the tables on -u.
  * Rewrote the controller to use ORM, added PATCH, and emitted both
    `items` and `results` in the list envelope so the frontend's
    PaginatedResponse reader and legacy callers both work. Step
    payloads now carry `max_days`, `auto_escalate`, and
    `notification_email` end to end.
  * Frontend `approvalsService` and `ApprovalWorkflowConfig` updated to
    send the full stage shape + `allow_bypass`, tolerate both envelope
    keys, and validate at least one approver before submit.

Schema delta applied via `./run.sh -u encoach_exam_template,encoach_lms_api`.
Verified with new `test_config_flows.py`: 24/24 passing. Regression runs
on `test_support_flows.py` (29/29) and `test_training_flows.py` (26/26)
remain green.

Made-with: Cursor
This commit is contained in:
Yamen Ahmad
2026-04-19 10:58:43 +04:00
parent 435930a827
commit 95938c0079
2 changed files with 75 additions and 23 deletions

View File

@@ -6,19 +6,12 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Plus, Trash2, Loader2, ChevronRight, Settings2, ArrowRight } from "lucide-react"; import { Plus, Trash2, Loader2, Settings2, ArrowRight } from "lucide-react";
import { approvalsService, type ApprovalWorkflow } from "@/services/approvals.service"; import { approvalsService, type ApprovalWorkflow, type ApprovalStage } from "@/services/approvals.service";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
interface WorkflowStage { type WorkflowStage = Omit<ApprovalStage, "order"> & { order: number };
order: number;
approver_id: number;
approver_name: string;
max_days?: number;
notification_email?: string;
auto_escalate?: boolean;
}
export default function ApprovalWorkflowConfig() { export default function ApprovalWorkflowConfig() {
const { toast } = useToast(); const { toast } = useToast();
@@ -28,16 +21,21 @@ export default function ApprovalWorkflowConfig() {
queryKey: ["approvals", "list"], queryKey: ["approvals", "list"],
queryFn: () => approvalsService.list(), queryFn: () => approvalsService.list(),
}); });
const workflows = workflowsData?.results ?? []; const workflows: ApprovalWorkflow[] = workflowsData?.results ?? workflowsData?.items ?? [];
type WorkflowWriteInput = Partial<ApprovalWorkflow> & {
steps?: Partial<ApprovalStage>[];
bypass_enabled?: boolean;
};
const createWorkflow = useMutation({ const createWorkflow = useMutation({
mutationFn: (data: Partial<ApprovalWorkflow>) => approvalsService.create(data), mutationFn: (data: WorkflowWriteInput) => approvalsService.create(data),
onSuccess: () => qc.invalidateQueries({ queryKey: ["approvals"] }), onSuccess: () => qc.invalidateQueries({ queryKey: ["approvals"] }),
onError: () => toast({ title: "Error", description: "Failed to create workflow", variant: "destructive" }), onError: () => toast({ title: "Error", description: "Failed to create workflow", variant: "destructive" }),
}); });
const updateWorkflow = useMutation({ const updateWorkflow = useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<ApprovalWorkflow> }) => approvalsService.update(id, data), mutationFn: ({ id, data }: { id: number; data: WorkflowWriteInput }) => approvalsService.update(id, data),
onSuccess: () => qc.invalidateQueries({ queryKey: ["approvals"] }), onSuccess: () => qc.invalidateQueries({ queryKey: ["approvals"] }),
onError: () => toast({ title: "Error", description: "Failed to update workflow", variant: "destructive" }), onError: () => toast({ title: "Error", description: "Failed to update workflow", variant: "destructive" }),
}); });
@@ -72,8 +70,26 @@ export default function ApprovalWorkflowConfig() {
}; };
const handleCreate = () => { const handleCreate = () => {
const valid = stages.filter(s => s.approver_id > 0);
if (valid.length === 0) {
toast({ title: "Validation", description: "Add at least one stage with an approver ID", variant: "destructive" });
return;
}
createWorkflow.mutate( createWorkflow.mutate(
{ name: wfName, steps: stages.map(s => ({ order: s.order, approver_id: s.approver_id, approver_name: s.approver_name })), status: "draft" } as Partial<ApprovalWorkflow>, {
name: wfName,
status: "draft",
allow_bypass: bypassEnabled,
bypass_enabled: bypassEnabled,
steps: valid.map(s => ({
order: s.order,
approver_id: s.approver_id,
approver_name: s.approver_name,
max_days: s.max_days,
auto_escalate: s.auto_escalate,
notification_email: s.notification_email,
})),
},
{ onSuccess: () => { toast({ title: "Workflow Created" }); setShowCreate(false); resetForm(); } }, { onSuccess: () => { toast({ title: "Workflow Created" }); setShowCreate(false); resetForm(); } },
); );
}; };

View File

@@ -1,30 +1,66 @@
import { api } from "@/lib/api-client"; import { api } from "@/lib/api-client";
import type { PaginatedResponse, PaginationParams, ApiSuccessResponse } from "@/types"; import type { PaginationParams, ApiSuccessResponse } from "@/types";
export interface ApprovalStage {
id?: number;
/** 1-based UI position; serialized alongside sequence for compatibility. */
order: number;
sequence?: number;
approver_id: number;
approver_name: string;
max_days?: number;
auto_escalate?: boolean;
notification_email?: string;
status?: "pending" | "approved" | "rejected" | "skipped";
comment?: string;
acted_at?: string | null;
}
export interface ApprovalWorkflow { export interface ApprovalWorkflow {
id: number; id: number;
name: string; name: string;
entity_id: number; type?: string;
entity_name: string; entity_id: number | null;
steps: { order: number; approver_id: number; approver_name: string }[]; entity_name: string | null;
status: "active" | "draft"; steps: ApprovalStage[];
status: "draft" | "active" | "archived";
allow_bypass?: boolean;
stage_count?: number;
request_count?: number;
created_at: string; created_at: string;
} }
export interface ApprovalWorkflowListResponse {
items?: ApprovalWorkflow[];
results?: ApprovalWorkflow[];
total: number;
}
export const approvalsService = { export const approvalsService = {
async list(params?: PaginationParams): Promise<PaginatedResponse<ApprovalWorkflow>> { async list(params?: PaginationParams & { status?: string; entity_id?: number }): Promise<ApprovalWorkflowListResponse> {
return api.get<PaginatedResponse<ApprovalWorkflow>>("/approval-workflows", params as Record<string, string | number | boolean | undefined>); return api.get<ApprovalWorkflowListResponse>(
"/approval-workflows",
params as Record<string, string | number | boolean | undefined>,
);
}, },
async create(data: Partial<ApprovalWorkflow>): Promise<ApprovalWorkflow> { async get(id: number): Promise<ApprovalWorkflow> {
return api.get<ApprovalWorkflow>(`/approval-workflows/${id}`);
},
async create(data: Partial<ApprovalWorkflow> & { steps?: Partial<ApprovalStage>[]; bypass_enabled?: boolean }): Promise<ApprovalWorkflow> {
return api.post<ApprovalWorkflow>("/approval-workflows", data); return api.post<ApprovalWorkflow>("/approval-workflows", data);
}, },
async update(id: number, data: Partial<ApprovalWorkflow>): Promise<ApprovalWorkflow> { async update(id: number, data: Partial<ApprovalWorkflow> & { steps?: Partial<ApprovalStage>[]; bypass_enabled?: boolean }): Promise<ApprovalWorkflow> {
return api.patch<ApprovalWorkflow>(`/approval-workflows/${id}`, data); return api.patch<ApprovalWorkflow>(`/approval-workflows/${id}`, data);
}, },
async delete(id: number): Promise<ApiSuccessResponse> { async delete(id: number): Promise<ApiSuccessResponse> {
return api.delete<ApiSuccessResponse>(`/approval-workflows/${id}`); return api.delete<ApiSuccessResponse>(`/approval-workflows/${id}`);
}, },
async listUsers(search?: string): Promise<{ items: { id: number; name: string; login: string; email?: string }[]; total: number }> {
return api.get("/approval-users", search ? { search } : undefined);
},
}; };