Skip to main content

backbone_core/
http.rs

1//! HTTP layer and REST endpoint implementations
2//!
3//! This module provides generic HTTP/REST components for the Backbone CRUD system:
4//! - `CrudService` trait: Core async trait for entity CRUD operations
5//! - `BackboneCrudHandler`: Generic Axum router builder for all 11 endpoints
6//! - Response types: `ApiResponse`, `PaginatedResponse`, `BulkResponse`
7
8use crate::extractors::JsonOrForm;
9use serde::{de::DeserializeOwned, Deserialize, Serialize};
10use std::collections::HashMap;
11use std::sync::Arc;
12
13// ============================================================
14// Response Types
15// ============================================================
16
17/// Standard API response wrapper
18#[derive(Debug, Serialize, Deserialize)]
19#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
20pub struct ApiResponse<T> {
21    pub success: bool,
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub data: Option<T>,
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub message: Option<String>,
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub error: Option<String>,
28}
29
30impl<T> ApiResponse<T> {
31    /// Create a success response with data and an optional message
32    pub fn success(data: T, message: Option<String>) -> Self {
33        Self {
34            success: true,
35            data: Some(data),
36            message,
37            error: None,
38        }
39    }
40
41    /// Create a success response without a message (convenience method)
42    pub fn ok(data: T) -> Self {
43        Self {
44            success: true,
45            data: Some(data),
46            message: None,
47            error: None,
48        }
49    }
50
51    pub fn success_with_message(data: T, message: impl Into<String>) -> Self {
52        Self {
53            success: true,
54            data: Some(data),
55            message: Some(message.into()),
56            error: None,
57        }
58    }
59
60    pub fn error(error: impl Into<String>) -> Self {
61        Self {
62            success: false,
63            data: None,
64            message: None,
65            error: Some(error.into()),
66        }
67    }
68
69    pub fn not_found(entity: &str, id: &str) -> Self {
70        Self {
71            success: false,
72            data: None,
73            message: None,
74            error: Some(format!("{} with id '{}' not found", entity, id)),
75        }
76    }
77}
78
79/// Generic list query parameters
80#[derive(Debug, Deserialize, Default, Clone)]
81#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
82pub struct ListQueryParams {
83    #[serde(default = "default_page")]
84    pub page: u32,
85    #[serde(default = "default_limit")]
86    pub limit: u32,
87    #[serde(default)]
88    pub sort_by: Option<String>,
89    #[serde(default)]
90    pub sort_order: Option<String>,
91    #[serde(default)]
92    pub search: Option<String>,
93    #[serde(default)]
94    pub status: Option<String>,
95    #[serde(flatten)]
96    pub filters: HashMap<String, String>,
97}
98
99fn default_page() -> u32 { 1 }
100fn default_limit() -> u32 { 20 }
101
102/// Reserved response-shaping query keys (carved out of the filter grammar).
103const RESERVED_QUERY_KEYS: [&str; 3] = ["fields", "include", "with"];
104
105/// Parse the reserved `fields` key (comma-separated) into trimmed field names.
106/// Absent/empty → empty vec, meaning "no projection — return every field".
107// ─── Per-record history ───────────────────────────────────────────────────────
108
109/// One recorded change to a record.
110///
111/// `changed` is the capture layer's diff shape, carried through verbatim:
112/// `{field: {"from": old, "to": new}}` for an update, and the full image as
113/// `{field: {"to": v}}` / `{field: {"from": v}}` for an insert / delete. The
114/// trail is DIFF-ONLY, so an update entry names only the fields that actually
115/// changed — a consumer wanting the record's state at a point in time must
116/// re-anchor on the nearest full image and replay forward, which is what the
117/// insert/delete images exist for.
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct HistoryEntry {
120    pub occurred_at: String,
121    /// `insert` | `update` | `delete`, or a verb-emitted action.
122    pub action: String,
123    pub actor: String,
124    pub changed: serde_json::Value,
125    pub reason: Option<String>,
126    pub correlation_id: Option<String>,
127}
128
129/// Supplies a record's change history to the generic CRUD router.
130///
131/// This exists so `backbone-core` never learns that an audit module exists.
132/// The composing service builds a provider and installs it as an axum
133/// `Extension`; a composition without one simply has no history to serve, and
134/// says so rather than pretending.
135#[async_trait::async_trait]
136pub trait HistoryProvider: Send + Sync {
137    /// History for one row of `table` (schema-qualified, e.g. `sapiens.users`).
138    ///
139    /// `Ok(None)` means **this table is not audited** — deliberately distinct
140    /// from `Ok(Some(vec![]))`, which means it is audited and genuinely never
141    /// changed. Collapsing the two would answer "nothing ever happened" to a
142    /// question that was never actually asked, which is the same defect as a
143    /// filtered count that quietly returns the whole table.
144    async fn history(
145        &self,
146        table: &str,
147        id: &str,
148        limit: u32,
149        offset: u32,
150    ) -> Result<Option<Vec<HistoryEntry>>, String>;
151}
152
153/// Query keys the aggregate endpoint consumes itself.
154///
155/// They name columns and reductions, so they are grammar rather than
156/// predicates — left in the filter map they would be read as filters on
157/// columns called `sum` or `group_by`.
158const AGGREGATE_QUERY_KEYS: [&str; 7] =
159    ["group_by", "sum", "avg", "min", "max", "group_limit", "group_label"];
160
161/// Read an aggregate request off the query string.
162///
163/// Column names are NOT validated here; they are resolved against the entity's
164/// declared columns down in the repository, which is the only layer that knows
165/// them. Nothing between here and there splices a caller's string into SQL.
166fn aggregate_spec(params: &ListQueryParams) -> backbone_orm::repository::AggregateSpec {
167    use backbone_orm::repository::{AggregateFn, AggregateSpec};
168
169    let mut reductions = Vec::new();
170    for (key, func) in [
171        ("sum", AggregateFn::Sum),
172        ("avg", AggregateFn::Avg),
173        ("min", AggregateFn::Min),
174        ("max", AggregateFn::Max),
175    ] {
176        if let Some(raw) = params.filters.get(key) {
177            for field in raw.split(',').map(str::trim).filter(|f| !f.is_empty()) {
178                reductions.push((func, field.to_string()));
179            }
180        }
181    }
182
183    AggregateSpec {
184        group_by: params
185            .filters
186            .get("group_by")
187            .map(|g| g.trim().to_string())
188            .filter(|g| !g.is_empty()),
189        reductions,
190        group_limit: params
191            .filters
192            .get("group_limit")
193            .and_then(|l| l.trim().parse::<usize>().ok())
194            .unwrap_or(0),
195        label_field: params
196            .filters
197            .get("group_label")
198            .map(|l| l.trim().to_string())
199            .filter(|l| !l.is_empty()),
200        label_relation: None,
201    }
202}
203
204/// Reduce a list-style query into the filters the repository should actually see.
205///
206/// `fields`/`include`/`with` shape the response, not the row set, so they are
207/// dropped before they can be mistaken for column predicates; `search` and
208/// `status` arrive as typed fields and are folded back in under the names the
209/// repository expects.
210///
211/// `/count` answers a question about the same rows `/` returns, so both must
212/// normalize identically. They did not — the count route never read the query
213/// at all — which is why this lives in one function instead of two copies.
214fn repository_filters(params: &ListQueryParams) -> HashMap<String, String> {
215    let mut filters = params.filters.clone();
216    for key in RESERVED_QUERY_KEYS {
217        filters.remove(key);
218    }
219    if let Some(search) = params.search.clone() {
220        filters.insert("search".to_string(), search);
221    }
222    if let Some(status) = params.status.clone() {
223        filters.insert("status".to_string(), status);
224    }
225    filters
226}
227
228/// The filters an aggregate should apply: the same rows `/` would list, with
229/// the aggregate's own grammar removed.
230fn aggregate_filters(params: &ListQueryParams) -> HashMap<String, String> {
231    let mut filters = repository_filters(params);
232    for key in AGGREGATE_QUERY_KEYS {
233        filters.remove(key);
234    }
235    filters
236}
237
238fn sparse_fields(query: &HashMap<String, String>) -> Vec<String> {
239    query
240        .get("fields")
241        .map(|s| {
242            s.split(',')
243                .map(str::trim)
244                .filter(|f| !f.is_empty())
245                .map(str::to_string)
246                .collect()
247        })
248        .unwrap_or_default()
249}
250
251/// Serialize a response DTO to a JSON value (for sparse-field projection).
252/// Serialization is infallible for the generated plain-serde DTOs; fall back to null.
253fn to_response_value<R: Serialize>(r: R) -> serde_json::Value {
254    serde_json::to_value(r).unwrap_or(serde_json::Value::Null)
255}
256
257/// Apply a sparse fieldset to an already-serialized response object: keep only the
258/// requested top-level keys plus the always-on core (`id`). Non-objects and an empty
259/// request are returned unchanged; unknown requested keys are ignored.
260fn project_sparse(mut value: serde_json::Value, fields: &[String]) -> serde_json::Value {
261    if fields.is_empty() {
262        return value;
263    }
264    if let serde_json::Value::Object(map) = &mut value {
265        map.retain(|k, _| k == "id" || fields.iter().any(|f| f == k));
266    }
267    value
268}
269
270/// Per-request access scope, injected by the application's auth middleware as an axum
271/// `Extension`. `Platform` sees everything (a superadmin / root caller); `Company(id)` is
272/// scoped to one tenant.
273///
274/// It governs two boundaries, and they must agree:
275/// - **row visibility** — the SQL fence (`EntityRepoMeta::company_field`), which decides
276///   which rows exist for this caller at all;
277/// - **field visibility** — `@private` fields, gated on the row's `@owner` matching.
278///
279/// # Deriving it
280///
281/// There must be exactly ONE answer to "who is the tenant" per request. Build this
282/// **from** the authenticated principal — `backbone_auth::company::CompanyContext`, whose
283/// `company_id` comes from a signed claim — never populate it independently:
284///
285/// ```rust,ignore
286/// let scope = AccessScope::Company(tenant_ctx.company_id);
287/// ```
288///
289/// Two independently-populated identities can disagree under partial wiring, and the
290/// failure is a silent cross-tenant read. `Uuid` rather than `String` so the fence and
291/// the writer cannot drift on formatting.
292#[derive(Debug, Clone, Copy, PartialEq, Eq)]
293pub enum AccessScope {
294    /// Full visibility — platform/root caller. No company fence is applied.
295    Platform,
296    /// Scoped to a single company (legal entity); only this company's rows are visible.
297    Company(uuid::Uuid),
298}
299
300impl AccessScope {
301    /// The company to fence queries by, or `None` for a platform caller.
302    ///
303    /// Feed this to the ORM's scoped read paths. Note `None` here means "platform, no
304    /// fence" — it is NOT the same as an absent `AccessScope`, which means the request was
305    /// never scoped and must be refused for a company-scoped entity.
306    pub fn company(&self) -> Option<uuid::Uuid> {
307        match self {
308            AccessScope::Platform => None,
309            AccessScope::Company(id) => Some(*id),
310        }
311    }
312}
313
314/// Enforce field-level security on an already-serialized response object: strip the
315/// entity's `@private` fields unless the caller may see them. Visibility rule —
316/// `Platform` → all; `Company(id)` → only when the row's `@owner` field equals `id`;
317/// absent scope → treated as non-owner (fail-closed). Runs BEFORE sparse projection
318/// so the security ceiling always beats a `?fields=` request.
319fn apply_field_security(
320    mut value: serde_json::Value,
321    scope: Option<&AccessScope>,
322    private_fields: &[&str],
323    owner_field: Option<&str>,
324) -> serde_json::Value {
325    if private_fields.is_empty() {
326        return value;
327    }
328    let can_see_private = match scope {
329        Some(AccessScope::Platform) => true,
330        Some(AccessScope::Company(id)) => owner_field
331            .and_then(|f| value.get(f))
332            .and_then(|v| v.as_str())
333            // Parse rather than compare strings: the row's owner key is serialized JSON and
334            // the scope is a Uuid, so a formatting difference (case, braces) would silently
335            // read as "not the owner" and strip fields from their rightful owner.
336            .and_then(|owner| uuid::Uuid::parse_str(owner).ok())
337            .is_some_and(|owner| owner == *id),
338        None => false,
339    };
340    if !can_see_private {
341        if let serde_json::Value::Object(map) = &mut value {
342            for f in private_fields {
343                map.remove(*f);
344            }
345        }
346    }
347    value
348}
349
350/// Parse the reserved `include` (or `with`) key — comma-separated relation names.
351fn include_relations(query: &HashMap<String, String>) -> Vec<String> {
352    query
353        .get("include")
354        .or_else(|| query.get("with"))
355        .map(|s| {
356            s.split(',')
357                .map(str::trim)
358                .filter(|s| !s.is_empty())
359                .map(str::to_string)
360                .collect()
361        })
362        .unwrap_or_default()
363}
364
365fn snake_to_camel(s: &str) -> String {
366    let mut out = String::with_capacity(s.len());
367    let mut upper = false;
368    for c in s.chars() {
369        if c == '_' {
370            upper = true;
371        } else if upper {
372            out.extend(c.to_uppercase());
373            upper = false;
374        } else {
375            out.push(c);
376        }
377    }
378    out
379}
380
381/// Shallow-camelCase an object's top-level keys so an expanded relation (fetched
382/// as a raw `row_to_json`) reads consistently with the camelCase response.
383fn camelize_keys(v: serde_json::Value) -> serde_json::Value {
384    match v {
385        serde_json::Value::Object(m) => serde_json::Value::Object(
386            m.into_iter().map(|(k, val)| (snake_to_camel(&k), val)).collect(),
387        ),
388        other => other,
389    }
390}
391
392/// Expand requested `?include=<rel>` relations into each row's JSON as a sibling
393/// object keyed by the relation name. Batched: one fetch per relation, keyed by
394/// the rows' foreign-key values. Only relations declared by the entity (via
395/// `EntityRepoMeta::relations()`) are honored; unknown names are ignored. Runs
396/// after field-security and before sparse projection.
397///
398/// v1 limitation: the expanded object is the raw related row (camelCased keys),
399/// NOT run through the target's response DTO or `@private` field-security — fine
400/// while no includable target has private fields. Revisit if that changes.
401async fn expand_includes<S, E, C, U>(
402    service: &S,
403    rows: &mut [serde_json::Value],
404    includes: &[String],
405) where
406    S: CrudService<E, C, U>,
407    E: backbone_orm::EntityRepoMeta + Send + Sync + 'static,
408    C: Send + Sync + 'static,
409    U: Send + Sync + 'static,
410{
411    if includes.is_empty() || rows.is_empty() {
412        return;
413    }
414    for (rel_name, table, fk_field) in E::relations() {
415        if !includes.iter().any(|i| i == rel_name) {
416            continue;
417        }
418        let mut ids: Vec<String> = rows
419            .iter()
420            .filter_map(|r| r.get(fk_field).and_then(|v| v.as_str()).map(str::to_string))
421            .collect();
422        ids.sort();
423        ids.dedup();
424        if ids.is_empty() {
425            continue;
426        }
427        let related = service.fetch_related_json(table, &ids).await;
428        let mut by_id: HashMap<String, serde_json::Value> = HashMap::new();
429        for obj in related {
430            if let Some(id) = obj.get("id").and_then(|v| v.as_str()).map(str::to_string) {
431                by_id.insert(id, camelize_keys(obj));
432            }
433        }
434        for r in rows.iter_mut() {
435            let related_obj = r
436                .get(fk_field)
437                .and_then(|v| v.as_str())
438                .and_then(|id| by_id.get(id).cloned())
439                .unwrap_or(serde_json::Value::Null);
440            if let serde_json::Value::Object(m) = r {
441                m.insert((*rel_name).to_string(), related_obj);
442            }
443        }
444    }
445}
446
447/// Hard cap on page size. Mirrors the clamp in
448/// `backbone_orm::PaginationParams::new` (`per_page.clamp(1, 100)`); kept here so
449/// the offset-depth check below computes the same effective offset the DB uses.
450pub const MAX_PER_PAGE: u32 = 100;
451
452/// Maximum number of rows offset pagination is allowed to skip. Requests that
453/// would page deeper than this are rejected with `400` so clients narrow their
454/// query with filters instead of deep-scanning the table (`OFFSET` cost grows
455/// with depth). At `MAX_PER_PAGE` this allows ~100 pages.
456pub const MAX_PAGINATION_OFFSET: u32 = 10_000;
457
458/// Reject list requests that page beyond [`MAX_PAGINATION_OFFSET`].
459///
460/// Returns `Some(message)` describing the violation, or `None` when the request
461/// is within bounds. The page size is clamped to [`MAX_PER_PAGE`] first so the
462/// computed offset matches what the repository actually issues to Postgres.
463fn pagination_depth_error(page: u32, limit: u32) -> Option<String> {
464    let effective_limit = limit.clamp(1, MAX_PER_PAGE);
465    let offset = page.max(1).saturating_sub(1).saturating_mul(effective_limit);
466    if offset > MAX_PAGINATION_OFFSET {
467        Some(format!(
468            "Result set too deep: offset {offset} exceeds the maximum of \
469             {MAX_PAGINATION_OFFSET}. Please add filters to narrow your search."
470        ))
471    } else {
472        None
473    }
474}
475
476/// Maximum number of ids / items a single batch request may contain. Larger
477/// payloads are rejected with `400` to bound memory and transaction size.
478///
479/// Re-exported from the service layer, which enforces the same cap for non-HTTP
480/// callers — see [`crate::service::MAX_BATCH_SIZE`].
481pub use crate::service::MAX_BATCH_SIZE;
482
483/// Reject batch requests larger than [`MAX_BATCH_SIZE`]. Returns `Some(message)`
484/// when over the limit, `None` otherwise.
485fn batch_size_error(count: usize) -> Option<String> {
486    if count > MAX_BATCH_SIZE {
487        Some(format!(
488            "Batch too large: {count} items exceeds the maximum of {MAX_BATCH_SIZE}."
489        ))
490    } else {
491        None
492    }
493}
494
495/// Classify a list/query error as a client fault (bad filter or sort key) vs a
496/// genuine server fault.
497///
498/// Unknown query params flow into the filter map and are injected as column
499/// names, so a typo or a stray param (e.g. camelCase `sortOrder`) surfaces as a
500/// Postgres `column "..." does not exist` (SQLSTATE 42703) or an
501/// `invalid input syntax` cast error. Those are caused by the request, so they
502/// should be a 400, not a 500.
503fn is_bad_query_error(msg: &str) -> bool {
504    let m = msg.to_lowercase();
505    m.contains("does not exist")
506        || m.contains("invalid input syntax")
507        || m.contains("42703")
508        // Aggregate field rejections: the caller named a column this entity
509        // does not have, or asked to sum something that is not a number. Both
510        // are faults in the request, so they must not read as server errors.
511        || m.contains("not a column of this entity")
512        || m.contains("needs a numeric column")
513}
514
515/// Pagination response metadata
516#[derive(Debug, Serialize, Deserialize, Clone)]
517#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
518pub struct PaginationResponse {
519    pub total: u64,
520    pub page: u32,
521    pub limit: u32,
522    pub total_pages: u32,
523    /// Keyset paging: pass back as `after=` to fetch the page that follows.
524    /// Present only when the repository walked a deterministic order and
525    /// more rows follow.
526    #[serde(skip_serializing_if = "Option::is_none")]
527    pub next_cursor: Option<String>,
528    /// Keyset paging: pass back as `before=` to fetch the page that precedes.
529    #[serde(skip_serializing_if = "Option::is_none")]
530    pub prev_cursor: Option<String>,
531    /// Whether another page follows (known without a count on a cursor walk).
532    #[serde(skip_serializing_if = "Option::is_none")]
533    pub has_more: Option<bool>,
534}
535
536impl PaginationResponse {
537    pub fn new(total: u64, page: u32, limit: u32) -> Self {
538        let total_pages = if limit == 0 { 0 } else { ((total as f64) / (limit as f64)).ceil() as u32 };
539        Self { total, page, limit, total_pages, next_cursor: None, prev_cursor: None, has_more: None }
540    }
541
542    /// From the repository's pagination info — the cursor fields ride along
543    /// when the repository produced them (a cursor walk or a filtered list
544    /// with a deterministic order).
545    pub fn from_info(info: &backbone_orm::repository::PaginationInfo) -> Self {
546        Self {
547            total: info.total,
548            page: info.page,
549            limit: info.per_page,
550            total_pages: info.total_pages,
551            next_cursor: info.next_cursor.clone(),
552            prev_cursor: info.prev_cursor.clone(),
553            has_more: info.has_more,
554        }
555    }
556}
557
558/// Generic paginated response (without success wrapper)
559#[derive(Debug, Serialize)]
560#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
561pub struct PaginatedResponse<T> {
562    pub data: Vec<T>,
563    pub meta: PaginationResponse,
564}
565
566/// Paginated API response with success flag, data array, and metadata at top level
567/// This is the preferred response format for list endpoints
568#[derive(Debug, Serialize)]
569#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
570pub struct PaginatedApiResponse<T> {
571    pub success: bool,
572    pub data: Vec<T>,
573    pub meta: PaginationResponse,
574    #[serde(skip_serializing_if = "Option::is_none")]
575    pub error: Option<String>,
576}
577
578impl<T> PaginatedApiResponse<T> {
579    /// A successful response carrying the repository's pagination info —
580    /// the cursor fields surface only when the repository produced them.
581    pub fn ok_with_info(data: Vec<T>, info: &backbone_orm::repository::PaginationInfo) -> Self {
582        Self {
583            success: true,
584            data,
585            meta: PaginationResponse::from_info(info),
586            error: None,
587        }
588    }
589
590    /// Create a successful paginated response
591    pub fn ok(data: Vec<T>, total: u64, page: u32, limit: u32) -> Self {
592        Self {
593            success: true,
594            data,
595            meta: PaginationResponse::new(total, page, limit),
596            error: None,
597        }
598    }
599
600    /// Create from a PaginatedResponse
601    pub fn from_paginated(resp: PaginatedResponse<T>) -> Self {
602        Self {
603            success: true,
604            data: resp.data,
605            meta: resp.meta,
606            error: None,
607        }
608    }
609
610    /// Create an error response
611    pub fn error(error: impl Into<String>) -> Self {
612        Self {
613            success: false,
614            data: Vec::new(),
615            meta: PaginationResponse::new(0, 0, 0),
616            error: Some(error.into()),
617        }
618    }
619}
620
621/// Bulk request for multiple entities
622#[derive(Debug, Serialize, Deserialize)]
623#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
624pub struct BulkCreateRequest<T> {
625    pub items: Vec<T>,
626}
627
628/// Bulk response
629#[derive(Debug, Serialize, Deserialize)]
630#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
631pub struct BulkResponse<T> {
632    pub items: Vec<T>,
633    pub total: usize,
634    pub failed: usize,
635    pub errors: Vec<String>,
636}
637
638/// Upsert request (update or insert)
639#[derive(Debug, Serialize, Deserialize)]
640#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
641pub struct UpsertRequest<T> {
642    pub entity: T,
643    pub create_if_not_exists: bool,
644}
645
646/// Request body carrying a list of entity ids — used by bulk soft-delete, bulk
647/// restore, and bulk permanent-delete endpoints.
648#[derive(Debug, Serialize, Deserialize)]
649#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
650pub struct BatchIdsRequest {
651    pub ids: Vec<String>,
652}
653
654/// One element of the `PUT {resource}/bulk` array: an id plus the (flattened)
655/// full update DTO for that row.
656#[derive(Debug, Deserialize)]
657#[serde(bound = "U: DeserializeOwned")]
658#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
659pub struct BulkUpdateItem<U> {
660    pub id: String,
661    // The flattened DTO is opaque to the schema (it is generic over the concrete
662    // update type); documented as an open object — downstream specs reference the
663    // concrete `Update<Entity>` schema directly.
664    #[serde(flatten)]
665    #[cfg_attr(feature = "openapi", schema(value_type = Object))]
666    pub data: U,
667}
668
669/// One element of the per-id form of a bulk PATCH request.
670#[derive(Debug, Deserialize)]
671#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
672pub struct BulkPatchItem {
673    pub id: String,
674    pub patch: HashMap<String, serde_json::Value>,
675}
676
677/// Body for `PATCH {resource}/bulk`. Accepts either a single patch applied to
678/// many ids, or a distinct patch per id. Shape is auto-detected.
679#[derive(Debug, Deserialize)]
680#[serde(untagged)]
681#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
682pub enum BulkPatchRequest {
683    /// `{ "ids": [..], "patch": { .. } }` — same change applied to every id.
684    Shared {
685        ids: Vec<String>,
686        patch: HashMap<String, serde_json::Value>,
687    },
688    /// `{ "items": [ { "id": .., "patch": { .. } } ] }` — per-id changes.
689    PerItem { items: Vec<BulkPatchItem> },
690}
691
692impl BulkPatchRequest {
693    /// Flatten into `(id, field_map)` pairs the service layer consumes.
694    fn into_items(self) -> Vec<(String, HashMap<String, serde_json::Value>)> {
695        match self {
696            BulkPatchRequest::Shared { ids, patch } => {
697                ids.into_iter().map(|id| (id, patch.clone())).collect()
698            }
699            BulkPatchRequest::PerItem { items } => {
700                items.into_iter().map(|it| (it.id, it.patch)).collect()
701            }
702        }
703    }
704}
705
706/// Filtering and sorting options
707#[derive(Debug, Serialize, Deserialize)]
708#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
709pub struct FilterOptions {
710    pub filters: HashMap<String, String>,
711    pub sort_by: Option<String>,
712    pub sort_order: Option<SortOrder>,
713}
714
715/// Sort order enum
716#[derive(Debug, Serialize, Deserialize, Clone, Default)]
717#[serde(rename_all = "lowercase")]
718#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
719pub enum SortOrder {
720    #[default]
721    Asc,
722    Desc,
723}
724
725/// Extended pagination request with filtering and sorting
726#[derive(Debug, Serialize, Deserialize)]
727#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
728pub struct ListRequest {
729    pub page: Option<u32>,
730    pub limit: Option<u32>,
731    pub sort_by: Option<String>,
732    pub sort_order: Option<SortOrder>,
733    pub filters: Option<HashMap<String, String>>,
734}
735
736impl Default for ListRequest {
737    fn default() -> Self {
738        Self {
739            page: Some(1),
740            limit: Some(20),
741            sort_by: None,
742            sort_order: None,
743            filters: None,
744        }
745    }
746}
747
748// ============================================================
749// CrudService Trait - Core Generic CRUD Interface
750// ============================================================
751
752/// Core trait that application services must implement for generic CRUD operations.
753///
754/// This trait provides the contract for all 11 standard Backbone endpoints.
755/// Module-specific services implement this trait with their entity types.
756///
757/// # Type Parameters
758/// - `Entity`: The domain entity type
759/// - `CreateDto`: DTO for create operations
760/// - `UpdateDto`: DTO for update operations
761///
762/// # Example
763/// ```ignore
764/// #[async_trait]
765/// impl CrudService<User, CreateUserDto, UpdateUserDto> for UserCrudService {
766///     type Error = ApplicationError;
767///     fn entity_name() -> &'static str { "User" }
768///     // ... implement all methods
769/// }
770/// ```
771#[async_trait::async_trait]
772pub trait CrudService<Entity, CreateDto, UpdateDto>: Send + Sync
773where
774    // `'static` is required so the default batch methods (which hold these types
775    // across `.await` points) satisfy async-trait's `'async_trait` bound.
776    Entity: Send + Sync + 'static,
777    CreateDto: Send + Sync + 'static,
778    UpdateDto: Send + Sync + 'static,
779{
780    /// Error type for service operations
781    type Error: std::error::Error + Send + Sync;
782
783    /// Entity name for error messages (e.g., "User", "Role")
784    fn entity_name() -> &'static str;
785
786    /// Hydrate `?include=` relations: fetch rows from `table` by id list, as JSON.
787    /// Default: no expansion. `GenericCrudService` delegates to its repository.
788    async fn fetch_related_json(
789        &self,
790        _table: &str,
791        _ids: &[String],
792    ) -> Vec<serde_json::Value> {
793        Vec::new()
794    }
795
796    /// 1. List entities with pagination and filters
797    async fn list(&self, page: u32, limit: u32, filters: HashMap<String, String>) -> Result<(Vec<Entity>, u64), Self::Error>;
798
799    /// `list`, carrying the pagination info (cursor positions included) so
800    /// the HTTP layer can surface keyset paging. Default: the tuple form —
801    /// generated services override this with the repository's cursors.
802    async fn list_with_info(
803        &self,
804        page: u32,
805        limit: u32,
806        filters: HashMap<String, String>,
807    ) -> Result<(Vec<Entity>, backbone_orm::repository::PaginationInfo), Self::Error> {
808        let (rows, total) = self.list(page, limit, filters).await?;
809        Ok((
810            rows,
811            backbone_orm::repository::PaginationInfo::new(page, limit, total),
812        ))
813    }
814
815    /// 2. Create a new entity
816    async fn create(&self, dto: CreateDto) -> Result<Entity, Self::Error>;
817
818    /// 3. Get entity by ID
819    async fn get_by_id(&self, id: &str) -> Result<Option<Entity>, Self::Error>;
820
821    /// 4. Full update of entity
822    async fn update(&self, id: &str, dto: UpdateDto) -> Result<Option<Entity>, Self::Error>;
823
824    /// 5. Partial update with specific fields
825    async fn partial_update(&self, id: &str, fields: HashMap<String, serde_json::Value>) -> Result<Option<Entity>, Self::Error>;
826
827    /// 6. Soft delete entity
828    async fn soft_delete(&self, id: &str) -> Result<bool, Self::Error>;
829
830    /// 7. Bulk create multiple entities
831    async fn bulk_create(&self, items: Vec<CreateDto>) -> Result<Vec<Entity>, Self::Error>;
832
833    /// 8. Upsert (create or update)
834    async fn upsert(&self, dto: CreateDto) -> Result<Entity, Self::Error>;
835
836    /// 9. List deleted entities (trash)
837    async fn list_deleted(&self, page: u32, limit: u32) -> Result<(Vec<Entity>, u64), Self::Error>;
838
839    /// 10. Restore soft-deleted entity
840    async fn restore(&self, id: &str) -> Result<Option<Entity>, Self::Error>;
841
842    /// 11. Permanently delete all soft-deleted entities
843    async fn empty_trash(&self) -> Result<u64, Self::Error>;
844
845    /// 12. Get deleted entity by ID (from trash)
846    async fn get_deleted_by_id(&self, id: &str) -> Result<Option<Entity>, Self::Error>;
847
848    /// 13. Permanently delete a single soft-deleted entity by ID
849    async fn permanent_delete(&self, id: &str) -> Result<bool, Self::Error>;
850
851    /// 14. List deleted entities with filters
852    async fn list_deleted_filtered(&self, page: u32, limit: u32, filters: HashMap<String, String>) -> Result<(Vec<Entity>, u64), Self::Error>;
853
854    /// 15. Count active (non-deleted) entities
855    async fn count_active(&self) -> Result<u64, Self::Error>;
856
857    /// The schema-qualified table this service reads, when it has one.
858    ///
859    /// Used to key a record's history. Derived from the mount path instead,
860    /// this would silently return an empty history whenever a route segment
861    /// and a Postgres schema disagreed — so it is read from the repository,
862    /// which is the same string the capture trigger writes.
863    fn table_name(&self) -> Option<&str> {
864        None
865    }
866
867    /// Group and reduce the rows `list` would return, under the same filters.
868    ///
869    /// The default refuses rather than inventing an answer — see the repository
870    /// trait for why a zero-filled default would be worse than an error.
871    async fn aggregate(
872        &self,
873        spec: &backbone_orm::repository::AggregateSpec,
874        filters: HashMap<String, String>,
875    ) -> Result<backbone_orm::repository::AggregateResult, Self::Error>;
876
877    /// Count active entities MATCHING the caller's filters.
878    ///
879    /// The generated client has always sent filters to `/count`; the handler
880    /// never read them, so a filtered count silently answered with the whole
881    /// table. Nothing caught it because the wrong number is a plausible one.
882    ///
883    /// The default delegates to `list`, whose total is already built from the
884    /// same where-clause as its data query — so the filtered count is correct
885    /// by construction rather than by a second implementation of the same
886    /// filter parsing, which is exactly how the two drifted apart. It asks for
887    /// a single row because the rows are discarded; an implementor that can
888    /// count without fetching may override.
889    async fn count_active_filtered(
890        &self,
891        filters: HashMap<String, String>,
892    ) -> Result<u64, Self::Error> {
893        self.list(1, 1, filters).await.map(|(_, total)| total)
894    }
895
896    /// 16. Count deleted entities in trash
897    async fn count_deleted(&self) -> Result<u64, Self::Error>;
898
899    // ── Atomic batch operations ───────────────────────────────────────────────
900    //
901    // The default implementations are best-effort loops over the single-row
902    // methods, provided so non-generic implementors keep compiling. The blanket
903    // impl on `GenericCrudService` overrides them with transactional,
904    // all-or-nothing versions (which is what every generated service runs).
905
906    /// 17. Soft-delete many entities by id. Returns the number affected.
907    async fn bulk_soft_delete(&self, ids: Vec<String>) -> Result<u64, Self::Error> {
908        let mut n = 0;
909        for id in ids {
910            if self.soft_delete(&id).await? {
911                n += 1;
912            }
913        }
914        Ok(n)
915    }
916
917    /// 18. Restore many soft-deleted entities by id. Returns the restored rows.
918    async fn bulk_restore(&self, ids: Vec<String>) -> Result<Vec<Entity>, Self::Error> {
919        let mut out = Vec::with_capacity(ids.len());
920        for id in ids {
921            if let Some(e) = self.restore(&id).await? {
922                out.push(e);
923            }
924        }
925        Ok(out)
926    }
927
928    /// 19. Permanently delete many soft-deleted entities by id.
929    async fn bulk_permanent_delete(&self, ids: Vec<String>) -> Result<u64, Self::Error> {
930        let mut n = 0;
931        for id in ids {
932            if self.permanent_delete(&id).await? {
933                n += 1;
934            }
935        }
936        Ok(n)
937    }
938
939    /// 20. Restore every soft-deleted entity. Returns the number restored.
940    ///
941    /// Default is a no-op (`0`) — only the `GenericCrudService` blanket impl can
942    /// perform this without an id list.
943    async fn restore_all(&self) -> Result<u64, Self::Error> {
944        Ok(0)
945    }
946
947    /// 21. Full-update many entities. Each item is `(id, UpdateDto)`.
948    async fn bulk_update(&self, items: Vec<(String, UpdateDto)>) -> Result<Vec<Entity>, Self::Error> {
949        let mut out = Vec::with_capacity(items.len());
950        for (id, dto) in items {
951            if let Some(e) = self.update(&id, dto).await? {
952                out.push(e);
953            }
954        }
955        Ok(out)
956    }
957
958    /// 22. Partial-update many entities. Each item is `(id, field_map)`.
959    async fn bulk_partial_update(
960        &self,
961        items: Vec<(String, HashMap<String, serde_json::Value>)>,
962    ) -> Result<Vec<Entity>, Self::Error> {
963        let mut out = Vec::with_capacity(items.len());
964        for (id, fields) in items {
965            if let Some(e) = self.partial_update(&id, fields).await? {
966                out.push(e);
967            }
968        }
969        Ok(out)
970    }
971}
972
973// ============================================================
974// BackboneCrudHandler - Generic Axum Router Builder
975// ============================================================
976
977/// Generic Backbone CRUD handler that provides all 11 endpoints as an Axum router.
978///
979/// This handler wraps a `CrudService` implementation and generates all standard
980/// Backbone endpoints automatically.
981///
982/// # Type Parameters
983/// - `S`: Service implementing `CrudService`
984/// - `E`: Entity type
985/// - `C`: Create DTO type
986/// - `U`: Update DTO type
987/// - `R`: Response DTO type (must implement `From<E>`)
988///
989/// # Example
990/// ```ignore
991/// let user_crud = UserCrudService::new(user_service);
992/// let routes = BackboneCrudHandler::<UserCrudService, UserAggregate, CreateUserDto, UpdateUserDto, UserResponseDto>
993///     ::routes(Arc::new(user_crud), "/api/v1/users");
994/// ```
995pub struct BackboneCrudHandler<S, E, C, U, R>
996where
997    S: CrudService<E, C, U> + 'static,
998    E: Serialize + Send + Sync + 'static,
999    C: DeserializeOwned + Send + Sync + 'static,
1000    U: DeserializeOwned + Send + Sync + 'static,
1001    R: From<E> + Serialize + Send + Sync + 'static,
1002{
1003    service: Arc<S>,
1004    _phantom: std::marker::PhantomData<(E, C, U, R)>,
1005}
1006
1007impl<S, E, C, U, R> BackboneCrudHandler<S, E, C, U, R>
1008where
1009    S: CrudService<E, C, U> + 'static,
1010    E: Serialize + Send + Sync + Clone + backbone_orm::EntityRepoMeta + 'static,
1011    C: DeserializeOwned + Send + Sync + 'static,
1012    U: DeserializeOwned + Send + Sync + 'static,
1013    R: From<E> + Serialize + Send + Sync + 'static,
1014{
1015    pub fn new(service: Arc<S>) -> Self {
1016        Self {
1017            service,
1018            _phantom: std::marker::PhantomData,
1019        }
1020    }
1021
1022    /// Create Axum router with all 16 Backbone endpoints (reads + writes).
1023    ///
1024    /// Equivalent to `read_routes(...).merge(write_routes(...))`. Use the
1025    /// split variants when reads and writes need different middleware
1026    /// (e.g., public reads + authenticated writes).
1027    ///
1028    /// # Routes Created
1029    /// 1. `GET {base_path}` - List with pagination
1030    /// 2. `POST {base_path}` - Create
1031    /// 3. `GET {base_path}/:id` - Get by ID
1032    /// 4. `PUT {base_path}/:id` - Full update
1033    /// 5. `PATCH {base_path}/:id` - Partial update
1034    /// 6. `DELETE {base_path}/:id` - Soft delete
1035    /// 7. `POST {base_path}/bulk` - Bulk create
1036    /// 8. `POST {base_path}/upsert` - Upsert
1037    /// 9. `GET {base_path}/trash` - List deleted (with filters)
1038    /// 10. `POST {base_path}/:id/restore` - Restore
1039    /// 11. `DELETE {base_path}/empty` - Empty trash
1040    /// 12. `GET {base_path}/:id/deleted` - Get deleted by ID
1041    /// 13. `DELETE {base_path}/trash/:id` - Permanent delete from trash
1042    /// 14. (Uses route 9 with filters)
1043    /// 15. `GET {base_path}/count` - Count active entities
1044    /// 16. `GET {base_path}/trash/count` - Count deleted entities
1045    pub fn routes(service: Arc<S>, base_path: &str) -> axum::Router<()>
1046    where
1047        S: Clone,
1048    {
1049        Self::read_routes(service.clone(), base_path)
1050            .merge(Self::write_routes(service, base_path))
1051    }
1052
1053    /// Create Axum router with only the read (GET) endpoints.
1054    ///
1055    /// Safe to expose publicly (e.g., for reference data like countries or
1056    /// categories) without auth middleware. Pair with `write_routes` under
1057    /// an auth layer when mutations must be restricted.
1058    ///
1059    /// # Routes Created
1060    /// - `GET {base_path}` - List with pagination
1061    /// - `GET {base_path}/:id` - Get by ID
1062    /// - `GET {base_path}/trash` - List deleted (with filters)
1063    /// - `GET {base_path}/:id/deleted` - Get deleted by ID
1064    /// - `GET {base_path}/count` - Count active entities
1065    /// - `GET {base_path}/trash/count` - Count deleted entities
1066    pub fn read_routes(service: Arc<S>, base_path: &str) -> axum::Router<()>
1067    where
1068        S: Clone,
1069    {
1070        use axum::{
1071            extract::{Path, Query},
1072            routing::get,
1073            Extension, Router,
1074        };
1075
1076        let handler = Arc::new(Self::new(service));
1077
1078        Router::new()
1079            // GET /collection - List
1080            .route(base_path, get({
1081                let h = handler.clone();
1082                move |query: Query<ListQueryParams>, access: Option<Extension<AccessScope>>| async move {
1083                    Self::list_handler(h, query, access).await
1084                }
1085            }))
1086            // GET /collection/trash - List deleted
1087            .route(&format!("{}/trash", base_path), get({
1088                let h = handler.clone();
1089                move |query: Query<ListQueryParams>, access: Option<Extension<AccessScope>>| async move {
1090                    Self::list_deleted_handler(h, query, access).await
1091                }
1092            }))
1093            // GET /collection/:id - Get by ID
1094            .route(&format!("{}/:id", base_path), get({
1095                let h = handler.clone();
1096                move |path: Path<String>, query: Query<ListQueryParams>, access: Option<Extension<AccessScope>>| async move {
1097                    Self::get_handler(h, path, query, access).await
1098                }
1099            }))
1100            // GET /collection/:id/deleted - Get deleted by ID
1101            .route(&format!("{}/:id/deleted", base_path), get({
1102                let h = handler.clone();
1103                move |path: Path<String>, query: Query<ListQueryParams>, access: Option<Extension<AccessScope>>| async move {
1104                    Self::get_deleted_handler(h, path, query, access).await
1105                }
1106            }))
1107            // GET /collection/count - Count active entities
1108            // GET /collection/:id/history - This record's recorded changes
1109            .route(&format!("{}/:id/history", base_path), get({
1110                let h = handler.clone();
1111                move |path: axum::extract::Path<String>,
1112                      query: axum::extract::Query<ListQueryParams>,
1113                      provider: Option<axum::Extension<std::sync::Arc<dyn HistoryProvider>>>| async move {
1114                    Self::history_handler(h, path, query, provider).await
1115                }
1116            }))
1117            // GET /collection/aggregate - Group and reduce
1118            .route(&format!("{}/aggregate", base_path), get({
1119                let h = handler.clone();
1120                move |query: axum::extract::Query<ListQueryParams>| async move {
1121                    Self::aggregate_handler(h, query).await
1122                }
1123            }))
1124            .route(&format!("{}/count", base_path), get({
1125                let h = handler.clone();
1126                move |query: axum::extract::Query<ListQueryParams>| async move {
1127                    Self::count_active_handler(h, query).await
1128                }
1129            }))
1130            // GET /collection/trash/count - Count deleted entities
1131            .route(&format!("{}/trash/count", base_path), get({
1132                let h = handler.clone();
1133                move || async move {
1134                    Self::count_deleted_handler(h).await
1135                }
1136            }))
1137    }
1138
1139    /// Create Axum router with only the write (mutation) endpoints.
1140    ///
1141    /// These routes should not be publicly exposed. Wrap them with an auth
1142    /// middleware (see `BackboneCrudHandler` module docs) before nesting
1143    /// into the application router.
1144    ///
1145    /// # Routes Created
1146    /// - `POST {base_path}` - Create
1147    /// - `POST {base_path}/bulk` - Bulk create
1148    /// - `POST {base_path}/upsert` - Upsert
1149    /// - `DELETE {base_path}/empty` - Empty trash
1150    /// - `DELETE {base_path}/trash/:id` - Permanent delete from trash
1151    /// - `PUT {base_path}/:id` - Full update
1152    /// - `PATCH {base_path}/:id` - Partial update
1153    /// - `DELETE {base_path}/:id` - Soft delete
1154    /// - `POST {base_path}/:id/restore` - Restore
1155    pub fn write_routes(service: Arc<S>, base_path: &str) -> axum::Router<()>
1156    where
1157        S: Clone,
1158    {
1159        use axum::{
1160            extract::Path,
1161            routing::{delete, patch, post, put},
1162            Router,
1163        };
1164
1165        let handler = Arc::new(Self::new(service));
1166
1167        Router::new()
1168            // POST /collection - Create
1169            .route(base_path, post({
1170                let h = handler.clone();
1171                move |body: JsonOrForm<C>| async move {
1172                    Self::create_handler(h, body).await
1173                }
1174            }))
1175            // POST /collection/bulk - Bulk create
1176            .route(&format!("{}/bulk", base_path), post({
1177                let h = handler.clone();
1178                move |body: JsonOrForm<Vec<C>>| async move {
1179                    Self::bulk_create_handler(h, body).await
1180                }
1181            }))
1182            // POST /collection/upsert - Upsert
1183            .route(&format!("{}/upsert", base_path), post({
1184                let h = handler.clone();
1185                move |body: JsonOrForm<C>| async move {
1186                    Self::upsert_handler(h, body).await
1187                }
1188            }))
1189            // POST /collection/delete/bulk - Bulk soft delete by ids
1190            .route(&format!("{}/delete/bulk", base_path), post({
1191                let h = handler.clone();
1192                move |body: JsonOrForm<BatchIdsRequest>| async move {
1193                    Self::bulk_delete_handler(h, body).await
1194                }
1195            }))
1196            // POST /collection/restore/bulk - Bulk restore by ids
1197            .route(&format!("{}/restore/bulk", base_path), post({
1198                let h = handler.clone();
1199                move |body: JsonOrForm<BatchIdsRequest>| async move {
1200                    Self::bulk_restore_handler(h, body).await
1201                }
1202            }))
1203            // POST /collection/restore/all - Restore all soft-deleted
1204            .route(&format!("{}/restore/all", base_path), post({
1205                let h = handler.clone();
1206                move || async move {
1207                    Self::restore_all_handler(h).await
1208                }
1209            }))
1210            // DELETE /collection/trash/bulk - Bulk hard delete by ids
1211            // (registered alongside /trash/:id; matchit prioritises the static segment)
1212            .route(&format!("{}/trash/bulk", base_path), delete({
1213                let h = handler.clone();
1214                move |body: JsonOrForm<BatchIdsRequest>| async move {
1215                    Self::bulk_permanent_delete_handler(h, body).await
1216                }
1217            }))
1218            // PUT /collection/bulk - Bulk full update
1219            .route(&format!("{}/bulk", base_path), put({
1220                let h = handler.clone();
1221                move |body: JsonOrForm<Vec<BulkUpdateItem<U>>>| async move {
1222                    Self::bulk_update_handler(h, body).await
1223                }
1224            }))
1225            // PATCH /collection/bulk - Bulk partial update (shared or per-id)
1226            .route(&format!("{}/bulk", base_path), patch({
1227                let h = handler.clone();
1228                move |body: JsonOrForm<BulkPatchRequest>| async move {
1229                    Self::bulk_patch_handler(h, body).await
1230                }
1231            }))
1232            // DELETE /collection/empty - Empty trash
1233            .route(&format!("{}/empty", base_path), delete({
1234                let h = handler.clone();
1235                move || async move {
1236                    Self::empty_trash_handler(h).await
1237                }
1238            }))
1239            // DELETE /collection/trash/:id - Permanent delete from trash
1240            .route(&format!("{}/trash/:id", base_path), delete({
1241                let h = handler.clone();
1242                move |path: Path<String>| async move {
1243                    Self::permanent_delete_handler(h, path).await
1244                }
1245            }))
1246            // PUT /collection/:id - Full update
1247            .route(&format!("{}/:id", base_path), put({
1248                let h = handler.clone();
1249                move |path: Path<String>, body: JsonOrForm<U>| async move {
1250                    Self::update_handler(h, path, body).await
1251                }
1252            }))
1253            // PATCH /collection/:id - Partial update
1254            .route(&format!("{}/:id", base_path), patch({
1255                let h = handler.clone();
1256                move |path: Path<String>, body: JsonOrForm<HashMap<String, serde_json::Value>>| async move {
1257                    Self::partial_update_handler(h, path, body).await
1258                }
1259            }))
1260            // DELETE /collection/:id - Soft delete
1261            .route(&format!("{}/:id", base_path), delete({
1262                let h = handler.clone();
1263                move |path: Path<String>| async move {
1264                    Self::delete_handler(h, path).await
1265                }
1266            }))
1267            // POST /collection/:id/restore - Restore
1268            .route(&format!("{}/:id/restore", base_path), post({
1269                let h = handler.clone();
1270                move |path: Path<String>| async move {
1271                    Self::restore_handler(h, path).await
1272                }
1273            }))
1274    }
1275
1276    // ============================================================
1277    // Handler Implementations
1278    // ============================================================
1279
1280    async fn list_handler(
1281        handler: Arc<Self>,
1282        axum::extract::Query(params): axum::extract::Query<ListQueryParams>,
1283        access: Option<axum::Extension<AccessScope>>,
1284    ) -> impl axum::response::IntoResponse {
1285        use axum::{http::StatusCode, Json};
1286
1287        if let Some(err) = pagination_depth_error(params.page, params.limit) {
1288            return (StatusCode::BAD_REQUEST, Json(PaginatedApiResponse::<serde_json::Value>::error(err)));
1289        }
1290
1291        // Sparse fieldset (`?fields=a,b,c`) is response-shaping, not a filter — read it,
1292        // then drop the reserved keys so they're never passed to the repository.
1293        let fields = sparse_fields(&params.filters);
1294        let includes = include_relations(&params.filters);
1295        let scope = access.map(|axum::Extension(s)| s);
1296
1297        let filters = repository_filters(&params);
1298
1299        match handler
1300            .service
1301            .list_with_info(params.page, params.limit, filters)
1302            .await
1303        {
1304            Ok((entities, info)) => {
1305                // Security ceiling first, then relation expansion (batched across all
1306                // rows), then sparse projection.
1307                let mut rows: Vec<serde_json::Value> = entities
1308                    .into_iter()
1309                    .map(|e| {
1310                        apply_field_security(
1311                            to_response_value(R::from(e)),
1312                            scope.as_ref(),
1313                            E::private_fields(),
1314                            E::owner_field(),
1315                        )
1316                    })
1317                    .collect();
1318                expand_includes::<S, E, C, U>(&*handler.service, &mut rows, &includes).await;
1319                let items: Vec<serde_json::Value> =
1320                    rows.into_iter().map(|r| project_sparse(r, &fields)).collect();
1321                let response = PaginatedApiResponse::ok_with_info(items, &info);
1322                (StatusCode::OK, Json(response))
1323            }
1324            Err(e) => {
1325                let msg = e.to_string();
1326                if is_bad_query_error(&msg) {
1327                    (StatusCode::BAD_REQUEST, Json(PaginatedApiResponse::<serde_json::Value>::error(
1328                        format!("Invalid query parameter or filter: {msg}"),
1329                    )))
1330                } else {
1331                    (StatusCode::INTERNAL_SERVER_ERROR, Json(PaginatedApiResponse::<serde_json::Value>::error(msg)))
1332                }
1333            }
1334        }
1335    }
1336
1337    async fn create_handler(
1338        handler: Arc<Self>,
1339        JsonOrForm(dto): JsonOrForm<C>,
1340    ) -> impl axum::response::IntoResponse {
1341        use axum::{http::StatusCode, Json};
1342
1343        match handler.service.create(dto).await {
1344            Ok(entity) => {
1345                let response: R = entity.into();
1346                (StatusCode::CREATED, Json(ApiResponse::ok(response)))
1347            }
1348            Err(e) => {
1349                let error_str = e.to_string();
1350                if error_str.contains("conflict") || error_str.contains("already exists") {
1351                    (StatusCode::CONFLICT, Json(ApiResponse::<R>::error(error_str)))
1352                } else {
1353                    (StatusCode::BAD_REQUEST, Json(ApiResponse::<R>::error(error_str)))
1354                }
1355            }
1356        }
1357    }
1358
1359    async fn get_handler(
1360        handler: Arc<Self>,
1361        axum::extract::Path(id): axum::extract::Path<String>,
1362        axum::extract::Query(params): axum::extract::Query<ListQueryParams>,
1363        access: Option<axum::Extension<AccessScope>>,
1364    ) -> impl axum::response::IntoResponse {
1365        use axum::{http::StatusCode, Json};
1366
1367        let fields = sparse_fields(&params.filters);
1368        let includes = include_relations(&params.filters);
1369        let scope = access.map(|axum::Extension(s)| s);
1370
1371        match handler.service.get_by_id(&id).await {
1372            Ok(Some(entity)) => {
1373                let secured = apply_field_security(
1374                    to_response_value(R::from(entity)),
1375                    scope.as_ref(),
1376                    E::private_fields(),
1377                    E::owner_field(),
1378                );
1379                let mut rows = [secured];
1380                expand_includes::<S, E, C, U>(&*handler.service, &mut rows, &includes).await;
1381                let [secured] = rows;
1382                let value = project_sparse(secured, &fields);
1383                (StatusCode::OK, Json(ApiResponse::ok(value)))
1384            }
1385            Ok(None) => {
1386                (StatusCode::NOT_FOUND, Json(ApiResponse::<serde_json::Value>::not_found(S::entity_name(), &id)))
1387            }
1388            Err(e) => {
1389                (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<serde_json::Value>::error(e.to_string())))
1390            }
1391        }
1392    }
1393
1394    async fn update_handler(
1395        handler: Arc<Self>,
1396        axum::extract::Path(id): axum::extract::Path<String>,
1397        JsonOrForm(dto): JsonOrForm<U>,
1398    ) -> impl axum::response::IntoResponse {
1399        use axum::{http::StatusCode, Json};
1400
1401        match handler.service.update(&id, dto).await {
1402            Ok(Some(entity)) => {
1403                let response: R = entity.into();
1404                (StatusCode::OK, Json(ApiResponse::ok(response)))
1405            }
1406            Ok(None) => {
1407                (StatusCode::NOT_FOUND, Json(ApiResponse::<R>::not_found(S::entity_name(), &id)))
1408            }
1409            Err(e) => {
1410                (StatusCode::BAD_REQUEST, Json(ApiResponse::<R>::error(e.to_string())))
1411            }
1412        }
1413    }
1414
1415    async fn partial_update_handler(
1416        handler: Arc<Self>,
1417        axum::extract::Path(id): axum::extract::Path<String>,
1418        JsonOrForm(fields): JsonOrForm<HashMap<String, serde_json::Value>>,
1419    ) -> impl axum::response::IntoResponse {
1420        use axum::{http::StatusCode, Json};
1421
1422        // Normalize incoming JSON keys to snake_case before forwarding to the
1423        // service-layer merge. Entities serialize with Rust's snake_case field
1424        // names by default, so a client sending camelCase (e.g. `isVip`) would
1425        // otherwise produce a merged JSON object with both `is_vip` (from the
1426        // existing entity) and `isVip` (from the patch). On deserialize back
1427        // to the entity, the unknown camelCase key is silently dropped and the
1428        // edit is lost. Normalization makes this class of bug impossible.
1429        //
1430        // The conversion is idempotent — already-snake_case keys pass through
1431        // unchanged — so clients that already conform see no behavior change.
1432        let fields: HashMap<String, serde_json::Value> = fields
1433            .into_iter()
1434            .map(|(k, v)| (camel_to_snake_case(&k), v))
1435            .collect();
1436
1437        match handler.service.partial_update(&id, fields).await {
1438            Ok(Some(entity)) => {
1439                let response: R = entity.into();
1440                (StatusCode::OK, Json(ApiResponse::ok(response)))
1441            }
1442            Ok(None) => {
1443                (StatusCode::NOT_FOUND, Json(ApiResponse::<R>::not_found(S::entity_name(), &id)))
1444            }
1445            Err(e) => {
1446                (StatusCode::BAD_REQUEST, Json(ApiResponse::<R>::error(e.to_string())))
1447            }
1448        }
1449    }
1450
1451    async fn delete_handler(
1452        handler: Arc<Self>,
1453        axum::extract::Path(id): axum::extract::Path<String>,
1454    ) -> impl axum::response::IntoResponse {
1455        use axum::{http::StatusCode, Json};
1456
1457        match handler.service.soft_delete(&id).await {
1458            Ok(true) => {
1459                // Return 200 OK with success response instead of 204 No Content
1460                // to ensure proper JSON response handling
1461                (StatusCode::OK, Json(ApiResponse::<()>::success_with_message((), "Entity deleted successfully")))
1462            }
1463            Ok(false) => {
1464                (StatusCode::NOT_FOUND, Json(ApiResponse::<()>::not_found(S::entity_name(), &id)))
1465            }
1466            Err(e) => {
1467                (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<()>::error(e.to_string())))
1468            }
1469        }
1470    }
1471
1472    async fn bulk_create_handler(
1473        handler: Arc<Self>,
1474        JsonOrForm(items): JsonOrForm<Vec<C>>,
1475    ) -> impl axum::response::IntoResponse {
1476        use axum::{http::StatusCode, Json};
1477
1478        match handler.service.bulk_create(items).await {
1479            Ok(entities) => {
1480                let result_items: Vec<R> = entities.into_iter().map(R::from).collect();
1481                let total = result_items.len();
1482                let response = BulkResponse {
1483                    items: result_items,
1484                    total,
1485                    failed: 0,
1486                    errors: vec![],
1487                };
1488                (StatusCode::CREATED, Json(ApiResponse::ok(response)))
1489            }
1490            Err(e) => {
1491                (StatusCode::BAD_REQUEST, Json(ApiResponse::<BulkResponse<R>>::error(e.to_string())))
1492            }
1493        }
1494    }
1495
1496    // ── Batch handlers ────────────────────────────────────────────────────────
1497
1498    async fn bulk_delete_handler(
1499        handler: Arc<Self>,
1500        JsonOrForm(req): JsonOrForm<BatchIdsRequest>,
1501    ) -> impl axum::response::IntoResponse {
1502        use axum::{http::StatusCode, Json};
1503
1504        if let Some(err) = batch_size_error(req.ids.len()) {
1505            return (StatusCode::BAD_REQUEST, Json(ApiResponse::<serde_json::Value>::error(err)));
1506        }
1507        match handler.service.bulk_soft_delete(req.ids).await {
1508            Ok(count) => (StatusCode::OK, Json(ApiResponse::success_with_message(
1509                serde_json::json!({ "soft_deleted": count }),
1510                format!("Soft-deleted {count} item(s)"),
1511            ))),
1512            Err(e) => (StatusCode::BAD_REQUEST, Json(ApiResponse::<serde_json::Value>::error(e.to_string()))),
1513        }
1514    }
1515
1516    async fn bulk_restore_handler(
1517        handler: Arc<Self>,
1518        JsonOrForm(req): JsonOrForm<BatchIdsRequest>,
1519    ) -> impl axum::response::IntoResponse {
1520        use axum::{http::StatusCode, Json};
1521
1522        if let Some(err) = batch_size_error(req.ids.len()) {
1523            return (StatusCode::BAD_REQUEST, Json(ApiResponse::<BulkResponse<R>>::error(err)));
1524        }
1525        match handler.service.bulk_restore(req.ids).await {
1526            Ok(entities) => {
1527                let items: Vec<R> = entities.into_iter().map(R::from).collect();
1528                let total = items.len();
1529                (StatusCode::OK, Json(ApiResponse::ok(BulkResponse { items, total, failed: 0, errors: vec![] })))
1530            }
1531            Err(e) => (StatusCode::BAD_REQUEST, Json(ApiResponse::<BulkResponse<R>>::error(e.to_string()))),
1532        }
1533    }
1534
1535    async fn restore_all_handler(
1536        handler: Arc<Self>,
1537    ) -> impl axum::response::IntoResponse {
1538        use axum::{http::StatusCode, Json};
1539
1540        match handler.service.restore_all().await {
1541            Ok(count) => (StatusCode::OK, Json(ApiResponse::success_with_message(
1542                serde_json::json!({ "restored": count }),
1543                format!("Restored {count} item(s) from trash"),
1544            ))),
1545            Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<serde_json::Value>::error(e.to_string()))),
1546        }
1547    }
1548
1549    async fn bulk_permanent_delete_handler(
1550        handler: Arc<Self>,
1551        JsonOrForm(req): JsonOrForm<BatchIdsRequest>,
1552    ) -> impl axum::response::IntoResponse {
1553        use axum::{http::StatusCode, Json};
1554
1555        if let Some(err) = batch_size_error(req.ids.len()) {
1556            return (StatusCode::BAD_REQUEST, Json(ApiResponse::<serde_json::Value>::error(err)));
1557        }
1558        match handler.service.bulk_permanent_delete(req.ids).await {
1559            Ok(count) => (StatusCode::OK, Json(ApiResponse::success_with_message(
1560                serde_json::json!({ "permanently_deleted": count }),
1561                format!("Permanently deleted {count} item(s)"),
1562            ))),
1563            Err(e) => (StatusCode::BAD_REQUEST, Json(ApiResponse::<serde_json::Value>::error(e.to_string()))),
1564        }
1565    }
1566
1567    async fn bulk_update_handler(
1568        handler: Arc<Self>,
1569        JsonOrForm(items): JsonOrForm<Vec<BulkUpdateItem<U>>>,
1570    ) -> impl axum::response::IntoResponse {
1571        use axum::{http::StatusCode, Json};
1572
1573        if let Some(err) = batch_size_error(items.len()) {
1574            return (StatusCode::BAD_REQUEST, Json(ApiResponse::<BulkResponse<R>>::error(err)));
1575        }
1576        let items: Vec<(String, U)> = items.into_iter().map(|it| (it.id, it.data)).collect();
1577        match handler.service.bulk_update(items).await {
1578            Ok(entities) => {
1579                let items: Vec<R> = entities.into_iter().map(R::from).collect();
1580                let total = items.len();
1581                (StatusCode::OK, Json(ApiResponse::ok(BulkResponse { items, total, failed: 0, errors: vec![] })))
1582            }
1583            Err(e) => (StatusCode::BAD_REQUEST, Json(ApiResponse::<BulkResponse<R>>::error(e.to_string()))),
1584        }
1585    }
1586
1587    async fn bulk_patch_handler(
1588        handler: Arc<Self>,
1589        JsonOrForm(req): JsonOrForm<BulkPatchRequest>,
1590    ) -> impl axum::response::IntoResponse {
1591        use axum::{http::StatusCode, Json};
1592
1593        let items = req.into_items();
1594        if let Some(err) = batch_size_error(items.len()) {
1595            return (StatusCode::BAD_REQUEST, Json(ApiResponse::<BulkResponse<R>>::error(err)));
1596        }
1597        // Normalize patch keys to snake_case (mirrors partial_update_handler).
1598        let items: Vec<(String, HashMap<String, serde_json::Value>)> = items
1599            .into_iter()
1600            .map(|(id, fields)| {
1601                let fields = fields
1602                    .into_iter()
1603                    .map(|(k, v)| (camel_to_snake_case(&k), v))
1604                    .collect();
1605                (id, fields)
1606            })
1607            .collect();
1608        match handler.service.bulk_partial_update(items).await {
1609            Ok(entities) => {
1610                let items: Vec<R> = entities.into_iter().map(R::from).collect();
1611                let total = items.len();
1612                (StatusCode::OK, Json(ApiResponse::ok(BulkResponse { items, total, failed: 0, errors: vec![] })))
1613            }
1614            Err(e) => (StatusCode::BAD_REQUEST, Json(ApiResponse::<BulkResponse<R>>::error(e.to_string()))),
1615        }
1616    }
1617
1618    async fn upsert_handler(
1619        handler: Arc<Self>,
1620        JsonOrForm(dto): JsonOrForm<C>,
1621    ) -> impl axum::response::IntoResponse {
1622        use axum::{http::StatusCode, Json};
1623
1624        match handler.service.upsert(dto).await {
1625            Ok(entity) => {
1626                let response: R = entity.into();
1627                (StatusCode::OK, Json(ApiResponse::ok(response)))
1628            }
1629            Err(e) => {
1630                (StatusCode::BAD_REQUEST, Json(ApiResponse::<R>::error(e.to_string())))
1631            }
1632        }
1633    }
1634
1635    async fn list_deleted_handler(
1636        handler: Arc<Self>,
1637        axum::extract::Query(params): axum::extract::Query<ListQueryParams>,
1638        access: Option<axum::Extension<AccessScope>>,
1639    ) -> impl axum::response::IntoResponse {
1640        use axum::{http::StatusCode, Json};
1641
1642        if let Some(err) = pagination_depth_error(params.page, params.limit) {
1643            return (StatusCode::BAD_REQUEST, Json(PaginatedApiResponse::<serde_json::Value>::error(err)));
1644        }
1645
1646        let fields = sparse_fields(&params.filters);
1647        let includes = include_relations(&params.filters);
1648        let scope = access.map(|axum::Extension(s)| s);
1649
1650        match handler.service.list_deleted(params.page, params.limit).await {
1651            Ok((entities, total)) => {
1652                // Mirror `list_handler`: field-security ceiling, then batched
1653                // relation expansion (`?include=`), then sparse projection — so the
1654                // trash view hydrates relations exactly like the active list does.
1655                let mut rows: Vec<serde_json::Value> = entities
1656                    .into_iter()
1657                    .map(|e| {
1658                        apply_field_security(
1659                            to_response_value(R::from(e)),
1660                            scope.as_ref(),
1661                            E::private_fields(),
1662                            E::owner_field(),
1663                        )
1664                    })
1665                    .collect();
1666                expand_includes::<S, E, C, U>(&*handler.service, &mut rows, &includes).await;
1667                let items: Vec<serde_json::Value> =
1668                    rows.into_iter().map(|r| project_sparse(r, &fields)).collect();
1669                let response = PaginatedApiResponse::ok(items, total, params.page, params.limit);
1670                (StatusCode::OK, Json(response))
1671            }
1672            Err(e) => {
1673                let msg = e.to_string();
1674                if is_bad_query_error(&msg) {
1675                    (StatusCode::BAD_REQUEST, Json(PaginatedApiResponse::<serde_json::Value>::error(
1676                        format!("Invalid query parameter or filter: {msg}"),
1677                    )))
1678                } else {
1679                    (StatusCode::INTERNAL_SERVER_ERROR, Json(PaginatedApiResponse::<serde_json::Value>::error(msg)))
1680                }
1681            }
1682        }
1683    }
1684
1685    async fn restore_handler(
1686        handler: Arc<Self>,
1687        axum::extract::Path(id): axum::extract::Path<String>,
1688    ) -> impl axum::response::IntoResponse {
1689        use axum::{http::StatusCode, Json};
1690
1691        match handler.service.restore(&id).await {
1692            Ok(Some(entity)) => {
1693                let response: R = entity.into();
1694                (StatusCode::OK, Json(ApiResponse::success_with_message(response, "Entity restored successfully")))
1695            }
1696            Ok(None) => {
1697                (StatusCode::NOT_FOUND, Json(ApiResponse::<R>::not_found(S::entity_name(), &id)))
1698            }
1699            Err(e) => {
1700                (StatusCode::BAD_REQUEST, Json(ApiResponse::<R>::error(e.to_string())))
1701            }
1702        }
1703    }
1704
1705    async fn empty_trash_handler(
1706        handler: Arc<Self>,
1707    ) -> impl axum::response::IntoResponse {
1708        use axum::{http::StatusCode, Json};
1709
1710        match handler.service.empty_trash().await {
1711            Ok(count) => {
1712                (StatusCode::OK, Json(ApiResponse::success_with_message(
1713                    serde_json::json!({ "deleted_count": count }),
1714                    format!("Successfully deleted {} items from trash", count)
1715                )))
1716            }
1717            Err(e) => {
1718                (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<serde_json::Value>::error(e.to_string())))
1719            }
1720        }
1721    }
1722
1723    /// 12. GET /collection/:id/deleted - Get deleted entity by ID
1724    async fn get_deleted_handler(
1725        handler: Arc<Self>,
1726        axum::extract::Path(id): axum::extract::Path<String>,
1727        axum::extract::Query(params): axum::extract::Query<ListQueryParams>,
1728        access: Option<axum::Extension<AccessScope>>,
1729    ) -> impl axum::response::IntoResponse {
1730        use axum::{http::StatusCode, Json};
1731
1732        let fields = sparse_fields(&params.filters);
1733        let scope = access.map(|axum::Extension(s)| s);
1734
1735        match handler.service.get_deleted_by_id(&id).await {
1736            Ok(Some(entity)) => {
1737                let secured = apply_field_security(
1738                    to_response_value(R::from(entity)),
1739                    scope.as_ref(),
1740                    E::private_fields(),
1741                    E::owner_field(),
1742                );
1743                let value = project_sparse(secured, &fields);
1744                (StatusCode::OK, Json(ApiResponse::ok(value)))
1745            }
1746            Ok(None) => {
1747                (StatusCode::NOT_FOUND, Json(ApiResponse::<serde_json::Value>::not_found(&format!("Deleted {}", S::entity_name()), &id)))
1748            }
1749            Err(e) => {
1750                (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<serde_json::Value>::error(e.to_string())))
1751            }
1752        }
1753    }
1754
1755    /// 13. DELETE /collection/trash/:id - Permanently delete from trash
1756    async fn permanent_delete_handler(
1757        handler: Arc<Self>,
1758        axum::extract::Path(id): axum::extract::Path<String>,
1759    ) -> axum::response::Response {
1760        use axum::{http::StatusCode, Json, response::IntoResponse};
1761
1762        // First check if the entity exists in trash
1763        match handler.service.get_deleted_by_id(&id).await {
1764            Ok(Some(_)) => {
1765                // Entity exists in trash, proceed with permanent delete
1766                match handler.service.permanent_delete(&id).await {
1767                    Ok(true) => {
1768                        // 204 No Content - successful deletion with no body
1769                        StatusCode::NO_CONTENT.into_response()
1770                    }
1771                    Ok(false) => {
1772                        (StatusCode::NOT_FOUND, Json(ApiResponse::<R>::error(
1773                            format!("Failed to permanently delete {}", S::entity_name())
1774                        ))).into_response()
1775                    }
1776                    Err(e) => {
1777                        (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<R>::error(e.to_string()))).into_response()
1778                    }
1779                }
1780            }
1781            Ok(None) => {
1782                (StatusCode::NOT_FOUND, Json(ApiResponse::<R>::not_found(
1783                    &format!("{} in trash", S::entity_name()), &id
1784                ))).into_response()
1785            }
1786            Err(e) => {
1787                (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<R>::error(e.to_string()))).into_response()
1788            }
1789        }
1790    }
1791
1792    /// 15. GET /collection/count - Count active entities
1793    async fn count_active_handler(
1794        handler: Arc<Self>,
1795        axum::extract::Query(params): axum::extract::Query<ListQueryParams>,
1796    ) -> impl axum::response::IntoResponse {
1797        use axum::{http::StatusCode, Json};
1798
1799        // An unfiltered request still answers the whole-table count, which is
1800        // what every existing caller expects; a filtered one now answers the
1801        // question it actually asked.
1802        match handler.service.count_active_filtered(repository_filters(&params)).await {
1803            Ok(count) => {
1804                (StatusCode::OK, Json(ApiResponse::ok(serde_json::json!({ "count": count }))))
1805            }
1806            Err(e) => {
1807                // Now that the filters are read, a malformed one is the
1808                // caller's fault — the same classification the list path uses.
1809                let msg = e.to_string();
1810                let code = if is_bad_query_error(&msg) {
1811                    StatusCode::BAD_REQUEST
1812                } else {
1813                    StatusCode::INTERNAL_SERVER_ERROR
1814                };
1815                (code, Json(ApiResponse::<serde_json::Value>::error(msg)))
1816            }
1817        }
1818    }
1819
1820    /// GET /collection/aggregate - Group and reduce
1821    ///
1822    /// Answers with one entry per distinct group plus the overall total, so a
1823    /// caller can draw a chart and its headline figure from a single reply.
1824    async fn aggregate_handler(
1825        handler: Arc<Self>,
1826        axum::extract::Query(params): axum::extract::Query<ListQueryParams>,
1827    ) -> impl axum::response::IntoResponse {
1828        use axum::{http::StatusCode, Json};
1829
1830        let spec = aggregate_spec(&params);
1831        match handler.service.aggregate(&spec, aggregate_filters(&params)).await {
1832            Ok(result) => {
1833                let render = |g: &backbone_orm::repository::AggregateGroup| {
1834                    // Flat `"sum:amount"` keys become nested `sum: { amount }`,
1835                    // so a caller reads `groups[i].sum.amount` without having to
1836                    // reassemble a compound key.
1837                    let mut out = serde_json::Map::new();
1838                    out.insert("key".into(), match &g.key {
1839                        Some(k) => serde_json::Value::String(k.clone()),
1840                        None => serde_json::Value::Null,
1841                    });
1842                    if g.label.is_some() {
1843                        out.insert(
1844                            "label".into(),
1845                            serde_json::Value::String(g.label.clone().unwrap()),
1846                        );
1847                    }
1848                    out.insert("count".into(), serde_json::json!(g.count));
1849                    for (compound, value) in &g.values {
1850                        let Some((func, field)) = compound.split_once(':') else { continue };
1851                        let slot = out
1852                            .entry(func.to_string())
1853                            .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
1854                        if let Some(obj) = slot.as_object_mut() {
1855                            obj.insert(field.to_string(), match value {
1856                                // Strings, not numbers: Postgres `numeric` carries
1857                                // more precision than a JSON double, and money
1858                                // columns are exactly where rounding would show.
1859                                Some(v) => serde_json::Value::String(v.clone()),
1860                                None => serde_json::Value::Null,
1861                            });
1862                        }
1863                    }
1864                    serde_json::Value::Object(out)
1865                };
1866
1867                let body = serde_json::json!({
1868                    "groups": result.groups.iter().map(render).collect::<Vec<_>>(),
1869                    "total": render(&result.total),
1870                    // Says plainly that groups were dropped, so a partial chart
1871                    // is never mistaken for a complete one.
1872                    "truncated": result.truncated,
1873                });
1874                (StatusCode::OK, Json(ApiResponse::ok(body)))
1875            }
1876            Err(e) => {
1877                let msg = e.to_string();
1878                let code = if is_bad_query_error(&msg) {
1879                    StatusCode::BAD_REQUEST
1880                } else {
1881                    StatusCode::INTERNAL_SERVER_ERROR
1882                };
1883                (code, Json(ApiResponse::<serde_json::Value>::error(msg)))
1884            }
1885        }
1886    }
1887
1888    /// GET /collection/:id/history - what has been recorded about this record
1889    ///
1890    /// Three outcomes, kept deliberately distinct, because collapsing any two
1891    /// of them would render as "nothing ever happened to this record":
1892    ///
1893    /// - no provider installed -> 501, this service serves no history at all
1894    /// - provider says the table is not audited -> 200, `audited: false`,
1895    ///   `entries: null`
1896    /// - audited -> 200, `audited: true`, `entries: [...]` (possibly empty,
1897    ///   which here genuinely means nothing has changed)
1898    async fn history_handler(
1899        handler: Arc<Self>,
1900        axum::extract::Path(id): axum::extract::Path<String>,
1901        axum::extract::Query(params): axum::extract::Query<ListQueryParams>,
1902        provider: Option<axum::Extension<std::sync::Arc<dyn HistoryProvider>>>,
1903    ) -> impl axum::response::IntoResponse {
1904        use axum::{http::StatusCode, Json};
1905
1906        let Some(axum::Extension(provider)) = provider else {
1907            return (
1908                StatusCode::NOT_IMPLEMENTED,
1909                Json(ApiResponse::<serde_json::Value>::error(
1910                    "history is not configured for this service".to_string(),
1911                )),
1912            );
1913        };
1914
1915        // Keyed on the table the repository actually reads — the same string the
1916        // capture trigger writes as `subject_type`. A service that cannot name
1917        // its table has no history rather than a wrong one.
1918        let Some(table) = handler.service.table_name().map(|t| t.to_string()) else {
1919            return (
1920                StatusCode::NOT_IMPLEMENTED,
1921                Json(ApiResponse::<serde_json::Value>::error(
1922                    "this entity cannot name its table, so its history cannot be keyed".to_string(),
1923                )),
1924            );
1925        };
1926
1927        let limit = params.limit.clamp(1, 200);
1928        let offset = params.page.saturating_sub(1) * limit;
1929
1930        match provider.history(&table, &id, limit, offset).await {
1931            Ok(Some(entries)) => (
1932                StatusCode::OK,
1933                Json(ApiResponse::ok(serde_json::json!({
1934                    "audited": true,
1935                    "entries": entries,
1936                }))),
1937            ),
1938            Ok(None) => (
1939                StatusCode::OK,
1940                Json(ApiResponse::ok(serde_json::json!({
1941                    // Not a mistake and not an empty timeline: this table
1942                    // records nothing, so there is nothing to have missed.
1943                    "audited": false,
1944                    "entries": serde_json::Value::Null,
1945                }))),
1946            ),
1947            Err(e) => (
1948                StatusCode::INTERNAL_SERVER_ERROR,
1949                Json(ApiResponse::<serde_json::Value>::error(e)),
1950            ),
1951        }
1952    }
1953
1954    /// 16. GET /collection/trash/count - Count deleted entities
1955    async fn count_deleted_handler(
1956        handler: Arc<Self>,
1957    ) -> impl axum::response::IntoResponse {
1958        use axum::{http::StatusCode, Json};
1959
1960        match handler.service.count_deleted().await {
1961            Ok(count) => {
1962                (StatusCode::OK, Json(ApiResponse::ok(serde_json::json!({ "count": count }))))
1963            }
1964            Err(e) => {
1965                (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<serde_json::Value>::error(e.to_string())))
1966            }
1967        }
1968    }
1969}
1970
1971// ============================================================
1972// BackboneHttpHandler Trait - Synchronous HTTP Service Interface
1973// ============================================================
1974
1975/// Synchronous trait for HTTP handlers implementing Backbone CRUD operations.
1976///
1977/// This trait provides a synchronous interface for services that need to implement
1978/// the 11 standard Backbone CRUD endpoints. Unlike `CrudService` which is async,
1979/// this trait is designed for simpler use cases where async is not required.
1980///
1981/// # Type Parameters
1982/// - `T`: The entity type
1983///
1984/// # Example
1985/// ```ignore
1986/// impl BackboneHttpHandler<User> for UserService {
1987///     fn list(&self, request: ListRequest) -> Result<ApiResponse<Vec<User>>> {
1988///         let users = self.users.lock().unwrap();
1989///         let filtered = users.iter().filter(|u| !u.is_deleted()).cloned().collect();
1990///         Ok(ApiResponse::success(filtered, Some("Users listed".to_string())))
1991///     }
1992///     // ... implement other methods
1993/// }
1994/// ```
1995pub trait BackboneHttpHandler<T>: Send + Sync {
1996    /// 1. List entities with pagination and filtering
1997    fn list(&self, request: ListRequest) -> anyhow::Result<ApiResponse<Vec<T>>>;
1998
1999    /// 2. Create a new entity
2000    fn create(&self, request: T) -> anyhow::Result<ApiResponse<T>>;
2001
2002    /// 3. Get entity by ID
2003    fn get_by_id(&self, id: &uuid::Uuid) -> anyhow::Result<ApiResponse<T>>;
2004
2005    /// 4. Full update of entity
2006    fn update(&self, id: &uuid::Uuid, request: T) -> anyhow::Result<ApiResponse<T>>;
2007
2008    /// 5. Partial update with specific fields
2009    fn partial_update(&self, id: &uuid::Uuid, fields: HashMap<String, serde_json::Value>) -> anyhow::Result<ApiResponse<T>>;
2010
2011    /// 6. Soft delete entity
2012    fn soft_delete(&self, id: &uuid::Uuid) -> anyhow::Result<ApiResponse<()>>;
2013
2014    /// 7. Bulk create multiple entities
2015    fn bulk_create(&self, request: BulkCreateRequest<T>) -> anyhow::Result<ApiResponse<BulkResponse<T>>>;
2016
2017    /// 8. Upsert (create or update)
2018    fn upsert(&self, request: UpsertRequest<T>) -> anyhow::Result<ApiResponse<T>>;
2019
2020    /// 9. List deleted entities (trash)
2021    fn list_deleted(&self, request: ListRequest) -> anyhow::Result<ApiResponse<Vec<T>>>;
2022
2023    /// 10. Restore soft-deleted entity
2024    fn restore(&self, id: &uuid::Uuid) -> anyhow::Result<ApiResponse<T>>;
2025
2026    /// 11. Permanently delete all soft-deleted entities
2027    fn empty_trash(&self) -> anyhow::Result<ApiResponse<()>>;
2028
2029    /// 12. Get deleted entity by ID (from trash)
2030    fn get_deleted_by_id(&self, id: &uuid::Uuid) -> anyhow::Result<ApiResponse<T>>;
2031}
2032
2033// ============================================================
2034// Legacy Types (Backwards Compatibility)
2035// ============================================================
2036
2037/// Pagination request (legacy)
2038#[derive(Debug, Serialize, Deserialize)]
2039#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
2040pub struct PaginationRequest {
2041    pub page: u32,
2042    pub limit: u32,
2043    pub sort_by: Option<String>,
2044    pub sort_order: Option<String>,
2045}
2046
2047// ============================================================
2048// Internal Helpers
2049// ============================================================
2050
2051/// Convert a single JSON key from `camelCase` / `PascalCase` to `snake_case`.
2052///
2053/// Idempotent on already-`snake_case` input: `is_vip` → `is_vip`.
2054/// Single words pass through unchanged: `name` → `name`.
2055/// Boundary detection inserts an underscore between a lowercase/digit and the
2056/// following uppercase, and between a run of uppercase and a following
2057/// uppercase-then-lowercase pair (so `IOError` → `io_error`, not `i_o_error`).
2058///
2059/// Used by `BackboneCrudHandler::partial_update_handler` to normalize PATCH
2060/// payload keys so the service-layer JSON merge aligns with snake_case entity
2061/// fields regardless of the client's serialization convention.
2062fn camel_to_snake_case(key: &str) -> String {
2063    let chars: Vec<char> = key.chars().collect();
2064    let mut result = String::with_capacity(key.len() + 2);
2065    for (i, &c) in chars.iter().enumerate() {
2066        if c.is_ascii_uppercase() {
2067            let prev = if i > 0 { chars[i - 1] } else { '\0' };
2068            let next = chars.get(i + 1).copied().unwrap_or('\0');
2069            // Insert separator before this uppercase letter when crossing a
2070            // word boundary. Skip if the result is empty or already ends with
2071            // an underscore (e.g. `_Foo` should stay `_foo`, not `__foo`).
2072            let crosses_lower = prev.is_ascii_lowercase() || prev.is_ascii_digit();
2073            let crosses_acronym = prev.is_ascii_uppercase() && next.is_ascii_lowercase();
2074            if (crosses_lower || crosses_acronym)
2075                && !result.is_empty()
2076                && !result.ends_with('_')
2077            {
2078                result.push('_');
2079            }
2080            result.push(c.to_ascii_lowercase());
2081        } else {
2082            result.push(c);
2083        }
2084    }
2085    result
2086}
2087
2088#[cfg(test)]
2089mod tests {
2090    use super::*;
2091
2092    #[test]
2093    fn snake_case_input_passes_through_unchanged() {
2094        assert_eq!(camel_to_snake_case("is_vip"), "is_vip");
2095        assert_eq!(camel_to_snake_case("flag_reason"), "flag_reason");
2096        assert_eq!(camel_to_snake_case("loyalty_tier_v2"), "loyalty_tier_v2");
2097    }
2098
2099    #[test]
2100    fn single_word_unchanged() {
2101        assert_eq!(camel_to_snake_case("name"), "name");
2102        assert_eq!(camel_to_snake_case("id"), "id");
2103        assert_eq!(camel_to_snake_case(""), "");
2104    }
2105
2106    #[test]
2107    fn camel_case_converts() {
2108        assert_eq!(camel_to_snake_case("isVip"), "is_vip");
2109        assert_eq!(camel_to_snake_case("userId"), "user_id");
2110        assert_eq!(camel_to_snake_case("customerSegment"), "customer_segment");
2111        assert_eq!(camel_to_snake_case("blacklistReason"), "blacklist_reason");
2112    }
2113
2114    #[test]
2115    fn pascal_case_converts() {
2116        assert_eq!(camel_to_snake_case("IsVip"), "is_vip");
2117        assert_eq!(camel_to_snake_case("UserId"), "user_id");
2118    }
2119
2120    #[test]
2121    fn acronym_runs_stay_together() {
2122        // The transition from an uppercase run to a lowercase letter starts a
2123        // new word at the last uppercase, not between every pair.
2124        assert_eq!(camel_to_snake_case("IOError"), "io_error");
2125        assert_eq!(camel_to_snake_case("httpURL"), "http_url");
2126        assert_eq!(camel_to_snake_case("ABC"), "abc");
2127        assert_eq!(camel_to_snake_case("XMLHttpRequest"), "xml_http_request");
2128    }
2129
2130    #[test]
2131    fn digits_count_as_lowercase_for_boundary() {
2132        assert_eq!(camel_to_snake_case("v2Field"), "v2_field");
2133        assert_eq!(camel_to_snake_case("field2Name"), "field2_name");
2134    }
2135
2136    #[test]
2137    fn underscores_not_doubled() {
2138        assert_eq!(camel_to_snake_case("_Foo"), "_foo");
2139        assert_eq!(camel_to_snake_case("foo_Bar"), "foo_bar");
2140    }
2141
2142    #[test]
2143    fn shallow_pages_are_allowed() {
2144        // page 1 has zero offset regardless of size
2145        assert!(pagination_depth_error(1, 100).is_none());
2146        // last in-bounds page at max size: offset = 100 * 100 = 10_000 (== cap)
2147        assert!(pagination_depth_error(101, 100).is_none());
2148    }
2149
2150    #[test]
2151    fn pages_past_the_cap_are_rejected() {
2152        // page 102 at size 100 → offset 10_100 > 10_000
2153        assert!(pagination_depth_error(102, 100).is_some());
2154        // small page size still rejected once deep enough: 1001 * 10 = 10_010
2155        assert!(pagination_depth_error(1002, 10).is_some());
2156    }
2157
2158    #[test]
2159    fn oversized_page_size_is_clamped_before_the_check() {
2160        // limit=200 is clamped to 100, so the effective offset is 101*100=10_100
2161        assert!(pagination_depth_error(102, 200).is_some());
2162        // and a request that would only be in-bounds *because* of the clamp passes
2163        assert!(pagination_depth_error(101, 200).is_none());
2164    }
2165
2166    #[test]
2167    fn huge_page_number_does_not_overflow() {
2168        // saturating arithmetic keeps this a clean rejection, not a panic
2169        assert!(pagination_depth_error(u32::MAX, u32::MAX).is_some());
2170    }
2171
2172    // ── Batch operations ──────────────────────────────────────────────────────
2173
2174    #[test]
2175    fn batch_size_within_limit_is_allowed() {
2176        assert!(batch_size_error(0).is_none());
2177        assert!(batch_size_error(MAX_BATCH_SIZE).is_none());
2178    }
2179
2180    #[test]
2181    fn batch_size_over_limit_is_rejected() {
2182        assert!(batch_size_error(MAX_BATCH_SIZE + 1).is_some());
2183    }
2184
2185    #[test]
2186    fn bulk_patch_request_parses_shared_shape() {
2187        let json = r#"{ "ids": ["a", "b"], "patch": { "status": "void" } }"#;
2188        let req: BulkPatchRequest = serde_json::from_str(json).unwrap();
2189        let items = req.into_items();
2190        assert_eq!(items.len(), 2);
2191        // Both ids carry the same patch.
2192        assert_eq!(items[0].1.get("status").unwrap(), "void");
2193        assert_eq!(items[1].1.get("status").unwrap(), "void");
2194        assert_eq!(items[0].0, "a");
2195        assert_eq!(items[1].0, "b");
2196    }
2197
2198    #[test]
2199    fn bulk_patch_request_parses_per_item_shape() {
2200        let json = r#"{ "items": [
2201            { "id": "a", "patch": { "status": "void" } },
2202            { "id": "b", "patch": { "note": "late" } }
2203        ] }"#;
2204        let req: BulkPatchRequest = serde_json::from_str(json).unwrap();
2205        let items = req.into_items();
2206        assert_eq!(items.len(), 2);
2207        assert_eq!(items[0].0, "a");
2208        assert_eq!(items[0].1.get("status").unwrap(), "void");
2209        assert_eq!(items[1].0, "b");
2210        assert_eq!(items[1].1.get("note").unwrap(), "late");
2211    }
2212
2213    #[test]
2214    fn batch_ids_request_parses() {
2215        let req: BatchIdsRequest = serde_json::from_str(r#"{ "ids": ["x", "y", "z"] }"#).unwrap();
2216        assert_eq!(req.ids, vec!["x", "y", "z"]);
2217    }
2218
2219    // ── Sparse fieldsets (?fields=a,b,c) ──
2220
2221    fn fields(q: &[(&str, &str)]) -> Vec<String> {
2222        let map: HashMap<String, String> =
2223            q.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
2224        sparse_fields(&map)
2225    }
2226
2227    #[test]
2228    fn sparse_fields_parses_comma_list_and_trims() {
2229        assert_eq!(fields(&[("fields", "id, name ,basePrice")]), vec!["id", "name", "basePrice"]);
2230    }
2231
2232    #[test]
2233    fn sparse_fields_absent_or_empty_is_no_projection() {
2234        assert!(fields(&[]).is_empty());
2235        assert!(fields(&[("fields", "")]).is_empty());
2236        assert!(fields(&[("fields", " , ")]).is_empty());
2237    }
2238
2239    #[test]
2240    fn project_keeps_requested_keys_plus_id() {
2241        let v = serde_json::json!({ "id": "1", "name": "n", "basePrice": 10, "hppPerUnit": 5 });
2242        let out = project_sparse(v, &["name".into(), "basePrice".into()]);
2243        assert_eq!(out, serde_json::json!({ "id": "1", "name": "n", "basePrice": 10 }));
2244    }
2245
2246    #[test]
2247    fn project_always_includes_id_even_if_not_requested() {
2248        let v = serde_json::json!({ "id": "1", "name": "n" });
2249        let out = project_sparse(v, &["name".into()]);
2250        assert_eq!(out, serde_json::json!({ "id": "1", "name": "n" }));
2251    }
2252
2253    #[test]
2254    fn project_ignores_unknown_keys() {
2255        let v = serde_json::json!({ "id": "1", "name": "n" });
2256        let out = project_sparse(v, &["name".into(), "nope".into()]);
2257        assert_eq!(out, serde_json::json!({ "id": "1", "name": "n" }));
2258    }
2259
2260    #[test]
2261    fn project_empty_fields_returns_full_object() {
2262        let v = serde_json::json!({ "id": "1", "name": "n", "x": 2 });
2263        let out = project_sparse(v.clone(), &[]);
2264        assert_eq!(out, v);
2265    }
2266
2267    #[test]
2268    fn project_non_object_returned_unchanged() {
2269        let v = serde_json::json!("scalar");
2270        assert_eq!(project_sparse(v.clone(), &["name".into()]), v);
2271    }
2272
2273    // ── Field-level security (@private / @owner) ──
2274
2275    /// The row's owner. A real tenant id is a Uuid — the scope carries one so the fence and
2276    /// field security cannot drift on formatting.
2277    fn owner_a() -> uuid::Uuid {
2278        uuid::Uuid::parse_str("11111111-1111-4111-8111-111111111111").unwrap()
2279    }
2280    fn owner_b() -> uuid::Uuid {
2281        uuid::Uuid::parse_str("22222222-2222-4222-8222-222222222222").unwrap()
2282    }
2283
2284    fn row() -> serde_json::Value {
2285        serde_json::json!({ "id": "1", "providerId": owner_a().to_string(), "name": "n", "hppPerUnit": 5 })
2286    }
2287    const PRIV: &[&str] = &["hppPerUnit"];
2288
2289    #[test]
2290    fn security_no_private_fields_is_noop() {
2291        let v = row();
2292        assert_eq!(apply_field_security(v.clone(), None, &[], Some("providerId")), v);
2293    }
2294
2295    #[test]
2296    fn security_platform_sees_private() {
2297        let out = apply_field_security(row(), Some(&AccessScope::Platform), PRIV, Some("providerId"));
2298        assert!(out.get("hppPerUnit").is_some());
2299    }
2300
2301    #[test]
2302    fn security_owner_tenant_sees_private() {
2303        let out = apply_field_security(
2304            row(),
2305            Some(&AccessScope::Company(owner_a())),
2306            PRIV,
2307            Some("providerId"),
2308        );
2309        assert!(out.get("hppPerUnit").is_some());
2310    }
2311
2312    #[test]
2313    fn security_other_tenant_stripped() {
2314        let out = apply_field_security(
2315            row(),
2316            Some(&AccessScope::Company(owner_b())),
2317            PRIV,
2318            Some("providerId"),
2319        );
2320        assert!(out.get("hppPerUnit").is_none());
2321        assert!(out.get("name").is_some());
2322    }
2323
2324    #[test]
2325    fn security_absent_scope_fails_closed() {
2326        let out = apply_field_security(row(), None, PRIV, Some("providerId"));
2327        assert!(out.get("hppPerUnit").is_none());
2328    }
2329
2330    // ── Relation expansion (?include=) helpers ──
2331
2332    #[test]
2333    fn include_relations_parses_include_and_with() {
2334        let mut q = HashMap::new();
2335        q.insert("include".to_string(), "provider, outlet ".to_string());
2336        assert_eq!(include_relations(&q), vec!["provider", "outlet"]);
2337        let mut q2 = HashMap::new();
2338        q2.insert("with".to_string(), "category".to_string());
2339        assert_eq!(include_relations(&q2), vec!["category"]);
2340        assert!(include_relations(&HashMap::new()).is_empty());
2341    }
2342
2343    #[test]
2344    fn snake_to_camel_converts() {
2345        assert_eq!(snake_to_camel("provider_id"), "providerId");
2346        assert_eq!(snake_to_camel("business_name"), "businessName");
2347        assert_eq!(snake_to_camel("id"), "id");
2348    }
2349
2350    #[test]
2351    fn camelize_keys_top_level_only() {
2352        let v = serde_json::json!({ "provider_id": "1", "meta_data": { "created_at": "x" } });
2353        let out = camelize_keys(v);
2354        assert!(out.get("providerId").is_some());
2355        // nested keys are left untouched (per-struct camelCase, shallow).
2356        assert!(out.get("metaData").unwrap().get("created_at").is_some());
2357    }
2358
2359    #[test]
2360    fn security_null_owner_only_platform_sees() {
2361        let v = serde_json::json!({ "id": "1", "providerId": null, "hppPerUnit": 5 });
2362        let tenant = apply_field_security(v.clone(), Some(&AccessScope::Company(owner_a())), PRIV, Some("providerId"));
2363        assert!(tenant.get("hppPerUnit").is_none());
2364        let plat = apply_field_security(v, Some(&AccessScope::Platform), PRIV, Some("providerId"));
2365        assert!(plat.get("hppPerUnit").is_some());
2366    }
2367}