|
| 1 | +-- 023: prior-level proof CASE must map L3 (L4 mark-sent). |
| 2 | + |
| 3 | +CREATE OR REPLACE FUNCTION public.transition_case( |
| 4 | + p_case_id UUID, |
| 5 | + p_to_status public.case_status, |
| 6 | + p_trigger TEXT, |
| 7 | + p_actor_type public.actor_type DEFAULT 'system', |
| 8 | + p_actor_id TEXT DEFAULT NULL, |
| 9 | + p_payload_json JSONB DEFAULT '{}' |
| 10 | +) |
| 11 | +RETURNS public.cases |
| 12 | +LANGUAGE plpgsql |
| 13 | +SECURITY DEFINER |
| 14 | +SET search_path = public |
| 15 | +AS $$ |
| 16 | +DECLARE |
| 17 | + v_case public.cases; |
| 18 | + v_from public.case_status; |
| 19 | + v_allowed BOOLEAN := FALSE; |
| 20 | +BEGIN |
| 21 | + -- Authorization guard (added in 018). service_role is trusted (route-level auth); |
| 22 | + -- everyone else must hold editor access or be an operator. |
| 23 | + IF COALESCE(auth.jwt() ->> 'role', '') <> 'service_role' |
| 24 | + AND NOT public.is_operator() |
| 25 | + AND NOT public.has_case_access(p_case_id, 'editor') THEN |
| 26 | + RAISE EXCEPTION 'forbidden' |
| 27 | + USING ERRCODE = '42501', |
| 28 | + HINT = '{"error":"forbidden","guard":"case_access"}'; |
| 29 | + END IF; |
| 30 | + |
| 31 | + SELECT * INTO v_case FROM public.cases WHERE id = p_case_id FOR UPDATE; |
| 32 | + IF NOT FOUND THEN |
| 33 | + RAISE EXCEPTION 'case_not_found' USING ERRCODE = 'P0002'; |
| 34 | + END IF; |
| 35 | + |
| 36 | + v_from := v_case.status; |
| 37 | + |
| 38 | + IF v_from = p_to_status THEN |
| 39 | + RETURN v_case; -- idempotent no-op |
| 40 | + END IF; |
| 41 | + |
| 42 | + -- Valid transitions per BUILD_SPEC §4 state machine |
| 43 | + v_allowed := CASE |
| 44 | + WHEN v_from = 'new' AND p_to_status = 'intake_scoping' AND p_trigger = 'evidence.submitted' THEN TRUE |
| 45 | + WHEN v_from = 'intake_scoping' AND p_to_status = 'monitoring' AND p_trigger = 'intake.classified' THEN TRUE |
| 46 | + WHEN v_from = 'monitoring' AND p_to_status = 'evidence_building' AND p_trigger = 'checklist.complete' THEN TRUE |
| 47 | + WHEN v_from = 'monitoring' AND p_to_status = 'closed' AND p_trigger IN ('user.abandon', 'inactive_30d') THEN TRUE |
| 48 | + WHEN v_from = 'evidence_building' AND p_to_status = 'escalation' AND p_trigger = 'bundle.ready' THEN TRUE |
| 49 | + WHEN v_from IN ('new', 'intake_scoping', 'monitoring', 'evidence_building', 'escalation', 'retried') |
| 50 | + AND p_to_status = 'awaiting_response' |
| 51 | + AND p_trigger = 'user.mark_sent' THEN TRUE |
| 52 | + WHEN v_from = 'awaiting_response' AND p_to_status = 'verified' AND p_trigger IN ('response.received', 'user.confirm_unfreeze') THEN TRUE |
| 53 | + WHEN v_from = 'awaiting_response' AND p_to_status = 'escalation' AND p_trigger = 'response.timeout' THEN TRUE |
| 54 | + WHEN v_from = 'awaiting_response' AND p_to_status = 'stalled' AND p_trigger = 'inactive_45d' THEN TRUE |
| 55 | + WHEN v_from = 'verified' AND p_to_status = 'resolved' AND p_trigger = 'resolution.confirmed' THEN TRUE |
| 56 | + WHEN v_from = 'resolved' AND p_to_status = 'public_pressure' AND p_trigger = 'user.opt_in_stats' THEN TRUE |
| 57 | + WHEN v_from = 'resolved' AND p_to_status = 'closed' AND p_trigger = 'bundle.delivered' THEN TRUE |
| 58 | + WHEN v_from = 'stalled' AND p_to_status = 'retried' AND p_trigger = 'user.reopen' THEN TRUE |
| 59 | + WHEN v_from = 'retried' AND p_to_status = 'escalation' AND p_trigger = 'new.strategy' THEN TRUE |
| 60 | + WHEN v_from = 'escalation' AND p_to_status = 'human_escalation' |
| 61 | + AND p_trigger IN ('low_confidence', 'cost_cap', 'user.request') THEN TRUE |
| 62 | + WHEN v_from = 'human_escalation' AND p_to_status = 'closed' AND p_trigger = 'ops.handoff' THEN TRUE |
| 63 | + -- Operator overrides |
| 64 | + WHEN public.is_operator() AND p_trigger LIKE 'ops.%' THEN TRUE |
| 65 | + ELSE FALSE |
| 66 | + END; |
| 67 | + |
| 68 | + IF NOT v_allowed THEN |
| 69 | + RAISE EXCEPTION 'invalid_transition: % -> % via %', v_from, p_to_status, p_trigger |
| 70 | + USING ERRCODE = 'P0001', |
| 71 | + HINT = jsonb_build_object('error', 'guard_failed', 'from', v_from, 'to', p_to_status, 'trigger', p_trigger)::TEXT; |
| 72 | + END IF; |
| 73 | + |
| 74 | + -- Guard: mark_sent requires prior level proof for L2+ |
| 75 | + IF p_trigger = 'user.mark_sent' AND (p_payload_json->>'escalation_level') IN ('L2', 'L3', 'L4') THEN |
| 76 | + IF NOT EXISTS ( |
| 77 | + SELECT 1 FROM public.escalations e |
| 78 | + WHERE e.case_id = p_case_id |
| 79 | + AND e.level = CASE (p_payload_json->>'required_proof') |
| 80 | + WHEN 'L1' THEN 'L1'::public.escalation_level |
| 81 | + WHEN 'L2' THEN 'L2'::public.escalation_level |
| 82 | + WHEN 'L3' THEN 'L3'::public.escalation_level |
| 83 | + ELSE 'L1'::public.escalation_level |
| 84 | + END |
| 85 | + AND e.status IN ('sent', 'response_received', 'timeout') |
| 86 | + ) THEN |
| 87 | + RAISE EXCEPTION 'guard_failed: has_prior_level_proof' |
| 88 | + USING ERRCODE = 'P0001', |
| 89 | + HINT = '{"error":"guard_failed","guard":"has_prior_level_proof"}'; |
| 90 | + END IF; |
| 91 | + END IF; |
| 92 | + |
| 93 | + -- Guard: resolved requires confirmation |
| 94 | + IF p_to_status = 'resolved' THEN |
| 95 | + IF NOT ( |
| 96 | + (p_payload_json->>'resolution_confirmed_by') IS NOT NULL |
| 97 | + OR EXISTS ( |
| 98 | + SELECT 1 FROM public.evidence ev |
| 99 | + WHERE ev.case_id = p_case_id |
| 100 | + AND ev.evidence_type = 'bank_release_letter' |
| 101 | + AND ev.deleted_at IS NULL |
| 102 | + ) |
| 103 | + ) THEN |
| 104 | + RAISE EXCEPTION 'guard_failed: resolution_proof_required' |
| 105 | + USING ERRCODE = 'P0001'; |
| 106 | + END IF; |
| 107 | + END IF; |
| 108 | + |
| 109 | + UPDATE public.cases SET |
| 110 | + status = p_to_status, |
| 111 | + last_activity_at = now(), |
| 112 | + resolved_at = CASE WHEN p_to_status = 'resolved' THEN now() ELSE resolved_at END, |
| 113 | + closed_at = CASE WHEN p_to_status = 'closed' THEN now() ELSE closed_at END, |
| 114 | + stalled_at = CASE WHEN p_to_status = 'stalled' THEN now() ELSE stalled_at END, |
| 115 | + resolution_type = COALESCE((p_payload_json->>'resolution_type')::public.resolution_type, resolution_type), |
| 116 | + resolution_confirmed_by = COALESCE(p_payload_json->>'resolution_confirmed_by', resolution_confirmed_by), |
| 117 | + released_amount_paise = COALESCE((p_payload_json->>'released_amount_paise')::BIGINT, released_amount_paise) |
| 118 | + WHERE id = p_case_id |
| 119 | + RETURNING * INTO v_case; |
| 120 | + |
| 121 | + INSERT INTO public.action_logs (case_id, actor_type, actor_id, action, payload_json) |
| 122 | + VALUES ( |
| 123 | + p_case_id, p_actor_type, p_actor_id, |
| 124 | + 'transition.' || p_to_status, |
| 125 | + jsonb_build_object('from', v_from, 'to', p_to_status, 'trigger', p_trigger) || COALESCE(p_payload_json, '{}') |
| 126 | + ); |
| 127 | + |
| 128 | + PERFORM public.append_swarm_event( |
| 129 | + p_case_id, 'MONITOR', 'status_transition', |
| 130 | + format('Case moved from %s to %s', v_from, p_to_status), |
| 131 | + 'info', NULL, |
| 132 | + jsonb_build_object('from', v_from, 'to', p_to_status, 'trigger', p_trigger) |
| 133 | + ); |
| 134 | + |
| 135 | + RETURN v_case; |
| 136 | +END; |
| 137 | +$$; |
| 138 | + |
| 139 | +COMMENT ON FUNCTION public.transition_case IS |
| 140 | + 'Guarded state machine; mark_sent prep statuses + L3 prior proof (023).'; |
| 141 | + |
0 commit comments