---
feature: Services catalog, option groups and price ranges
slug: services
surface: admin
consumer: react
version: 2.1.1
status: ready
brd_version: "1.3"
brd_ids: [ADMIN-008, ADMIN-009, ADMIN-010]
business_rules: [BR-05, BR-06, BR-29]
decisions: [TD-03, TD-06, TD-12, TD-14, TD-19, TD-22, TD-27, TD-33, TD-34, TD-37, TD-38, TD-40, TD-41, TD-42, TD-115]
schema_tables: [categories, category_translations, services, service_translations, provider_categories, provider_services, providers, media, packages, package_items, customer_package_items, order_items, order_item_options, service_occasions, finder_searches, audit_logs]
related_docs: [../provider/services-pricing.md, admins-permissions.md, ../API_CONVENTIONS.md, ../database/README.md]
last_updated: 2026-11-08
---

# Services catalog — React Dashboard Integration Guide

> Base URL: `{HOST}/api/v1/admin`. All endpoints require an admin-app Bearer JWT,
> `Accept: application/json` and `Accept-Language: ar|en` (Arabic default).
> Writes are JSON with `Content-Type: application/json`; upload files separately.
> Middleware: `auth:api + app:admin + active + rbac.auto`. No Idempotency-Key required.
> Shared envelope, pagination, errors and TS helpers: [API_CONVENTIONS](../API_CONVENTIONS.md).

All JSON responses below come from passing MySQL HTTP tests in
`CatalogControllerTest`, `CategoryRequestExamplesTest` and `ServiceOptionGroupsTest`.
UUIDs/timestamps are actual test fixture values; substitute your own UUIDs.

## Version history

| Version | Date | Type | Summary | Dashboard action required |
|---------|------|------|---------|---------------------------|
| 2.1.1 | 2026-11-08 | PATCH | Postman examples and field descriptions for the category create / edit forms. | None. |
| 2.1.0 | 2026-10-26 | MINOR | Adds `occasion_ids` to the type-1 service form for active Joshen occasions. | Add an optional occasion multi-select to type-1 service forms; send UUIDs, omit unchanged links, or send `[]` to clear. |
| 2.0.1 | 2026-10-22 | PATCH | Links the implemented provider-owned pricing API and shared completeness rule. Admin contract unchanged. | None. |
| 2.0.0 | 2026-10-21 | MAJOR — Breaking | Category → bookable service → independent option groups → choices; types1/2 change meaning, free choices allowed; existing provider_services reused. | Rebuild service/group forms per §5, use type1 for bookable roots and type2 for unpriced groups; read migration notes and refresh generated examples. |
| 1.2.0 | 2026-10-21 | MINOR | Adds optional unique category slug; clarifies title/description/icon/active form and fixes generated request examples. | Keep the category form simple; optionally submit a top-level slug. UUID routes remain unchanged; refresh the API console collection. |
| 1.1.1 | 2026-10-21 | PATCH | Correct category parent label, name unsupported query/locale keys, distinguish missing/zero/inverted/group prices, and document offsetless dates as UTC. | None to request/response types; treat legacy timestamps as UTC and show the more specific localized messages. |
| 1.1.0 | 2026-10-21 | MINOR | Category and service images use upload-first media hashes through HasMedia; adds the versioned shared upload endpoint. | Optional: add image upload to service forms; upload first and submit data.hash, never the URL. Existing forms keep working. |
| 1.0.0 | 2026-10-21 | MAJOR — Breaking | Replaces broken category tree assumptions with flat main services; adds service tree, ranges, toggles, safe deletion, ancestor visibility and permissions. | Replace the provisional category form/shape, use ar required/en optional and §3 query params; add tree and range forms; handle the documented conflicts. |
| 0.1.0 | 2026-10-21 | Initial (base) | Records that the base `/categories` CRUD fails on the schema table (only delete works). | Do not integrate. |

## Changelog

### 2.1.1 — 2026-11-08

- **Changed:** Postman examples and field descriptions for the category create / edit forms.
- **Breaking:** No. **App action required:** None.

### 2.1.0 — 2026-10-26
- **Added:** Optional `occasion_ids[]` UUIDs on service create/update; only active occasions can be assigned to type-1 bookable services. Links are replaced only when the key is present.
- **Changed:** Service details include the current `occasion_ids` for the form.
- **Fixed:** N/A.
- **Removed / Deprecated:** None.
- **Breaking:** No.
- **Endpoints affected:** `POST /services`, `PUT|PATCH /services/{uuid}`, and service detail responses.
- **Dashboard action required:** Load active occasions from `/joshan/occasions`; type-1 only; send `[]` to clear, omit to preserve.
- **BRD / decisions:** ADMIN-009/012, TD-22, TD-115.

---

### 2.0.1 — 2026-10-22

- **Fixed:** provider pricing is now implemented at [provider/services-pricing.md](../provider/services-pricing.md); removed the stale pending-write note.
- **Added:** reference to the shared BR-06 completeness rule used by provider enable/toggle and onboarding (TD-71).
- **Changed / Removed / Deprecated:** N/A.
- **Breaking:** No. Admin endpoints and price ownership are unchanged.
- **Endpoints affected:** documentation cross-reference only.
- **Dashboard action required:** None; continue defining ranges, never actual provider prices.
- **BRD / decisions:** PROV-009/010, TD-42/70/71.

### 2.0.0 — 2026-10-21
- **Added:** independently required single/multiple groups under each bookable service, with free/paid choices; revised SQL safety nets and same-provider complete-pricing visibility inputs.
- **Changed:** type1 is the bookable root with duration/range; type2 is an unpriced option group; type3 is a choice under a group. Category active_subservices_count now counts active type1 bookable services (the BRD calls them subservices). Existing provider_services holds provider-owned prices for types1/3; no new pricing table or admin provider-price write route.
- **Fixed:** the owner's Simple cut type1/duration15/range10–20 request now succeeds directly under its category. Generated service examples follow the same shape. Groups cannot receive provider price rows, even zero; free choices can receive zero within a 0–0 range.
- **Removed / Deprecated:** the non-bookable grouping level above bookable services; the strictly positive minimum requirement on choices; TD-18 meanings superseded by TD-42.
- **Breaking:** Yes. Existing forms must change their type mapping, parents, required fields and selection controls. No automatic conversion of an existing populated old tree.
- **Endpoints affected:** service CRUD/toggle/tree and category active bookable count; routes and permission names unchanged.
- **Dashboard action required:** apply the §5 type mapping, §8 types and §11 checklist; upgrade schema before writes. Populated legacy catalogs require an explicit reviewed mapping before migration; UUID routes stay unchanged.
- **BRD / decisions:** ADMIN-008/009/010, BR-05/06, TD-42 (owner confirmed after reviewing customer category/detail screens).

### 1.2.0 — 2026-10-21
- **Added:** optional nullable category `slug`, max 255, globally unique with a database index; editable and returned by category list/show/write responses (TD-41).
- **Changed:** the default category request example contains title (`ar.name`/`en.name`), description, icon image (`image` upload hash or null), and active status. `slug`, `sort_order` and `icon_key` remain optional metadata. Category pricing remains absent; price ranges belong to service nodes.
- **Fixed:** generated JSON/multipart examples omit prohibited fields and represent nullable media as null until an actual upload hash is supplied. The SQL source and baseline/upgrade migrations now include slug, resolving the missing-column validation error on existing installs after migration.
- **Removed / Deprecated:** only the previous root-level category slug prohibition is superseded; nested locale slug, parent_id and children remain unsupported.
- **Breaking:** No; existing forms can omit slug.
- **Endpoints affected:** category create/update/list/show/toggle responses and generated console/Postman request examples.
- **Dashboard action required:** use §5.1's form and media flow; replace any old generated example containing parent_id/children or a fake image value. Optional slug handling in §5.1.1; refresh the API console to load the regenerated collection.
- **BRD / decisions:** ADMIN-008/009/010, TD-14/18/37/41 (owner-approved schema addition).

### 1.1.1 — 2026-10-21
- **Fixed (TD-40):** application database sessions enforce UTC for SQL-generated timestamps. Existing stored dates are not rewritten; see §13 deployment/history caveat.
- **Fixed:** category parent_id validation uses the parent-category label; unsupported sort/filter/ar/en keys name their full path; service range failures distinguish required prices, zero minimum, inverted range and nonzero group prices.
- **Changed:** timestamp documentation explicitly says UTC, with parsing guidance (TD-38); the serialized date-time shape and error codes are unchanged.
- **Breaking:** No. **Dashboard action required:** none to API types; parse offsetless timestamps as UTC and display the returned localized message.
- **Endpoints affected:** catalog create/update and list validation; timestamp guidance for all catalog responses.
- **BRD / decisions:** ADMIN-008/009/010, TD-38/40.

### 1.1.0 — 2026-10-21
- **Added:** optional `image` media-hash input on all service node forms; image URL in service list/show/tree/write responses. Categories and services both use HasMedia and its image collection.
- **Fixed:** canonical `POST /api/v1/media` now delegates to the existing package upload controller; previously only `/media` was registered. Category/service attachment, replacement/removal and image auditing share one catalog hook.
- **Changed:** service images are supported (TD-37 supersedes only the image omission in TD-34).
- **Removed / Deprecated:** —
- **Breaking:** No. Existing service requests can omit image.
- **Endpoints affected:** shared POST /api/v1/media; admin POST/PUT/PATCH /services, GET /services and /services/{uuid}, GET category services tree, service toggle response; existing category image flow retained.
- **Dashboard action required:** optional service image field; use §5.3 upload → data.hash → JSON form. Omit unchanged images; send null only to remove.
- **BRD / decisions:** ADMIN-008/009, TD-14/37.

### 1.0.0 — 2026-10-21
- **Added:** service tree CRUD, complete category tree endpoint, category/service toggles, service counts, price-range conflict guard with `affected_count`, deletion guards and transactional audits.
- **Changed:** categories are flat; edit/create responses carry stored `ar` and nullable `en`; names are max 100 for categories / 150 for services; English is optional.
- **Fixed:** category list/show/create/update 500s; category image attachment; Arabic fallback for absent English; exact-name RBAC override lookup; category/group activation in bookable offers and inherited addon eligibility.
- **Removed / Deprecated:** category `parent_id`, `parent`, `children`, `slug`, `has_children`; no legacy category tree editor. Unsupported nonempty category parent/slug input is 422.
- **Breaking:** Yes — 0.1.0 → 1.0.0 (TD-32).
- **Endpoints affected:** all thirteen named routes in §2 (`PUT` also accepts `PATCH`).
- **Dashboard action required:** integrate §2–8; stop sending category nesting/slug fields; send enum values in forms, not response enum objects; show range conflicts without changing provider prices.
- **BRD / decisions:** ADMIN-008/009/010, BR-05/06/29, TD-18/33/34.

### 0.1.0 — 2026-10-21
- **Added:** documentation for `GET|POST /categories`, `GET|PUT|DELETE /categories/{id}` as they behave today. No code change.
- **Changed:** —
- **Fixed:** —
- **Removed / Deprecated:** —
- **Breaking:** No
- **Endpoints affected:** the five above
- **Dashboard action required:** none — wait for 1.0.0.
- **BRD / decisions:** ADMIN-008…010, TD-18, TD-32

## 1. Business summary

Admins define categories, bookable services, their option groups/choices and the allowed
price ranges. Providers select what they offer and enter their own actual prices
inside those ranges using the **existing provider_services table**, created by
`2026_10_20_110012_create_provider_services_table.php`. Categories remain non-bookable.
No admin endpoint creates a provider's price or package.

The approved structure is:

```text
Category: Makeup — title/description/icon/active
└ type1: Evening makeup — duration and admin base-price range
   ├ type2: Eyelashes — required, single choice, no price
   │  ├ type3: Without eyelashes — allowed additional price 0–0
   │  └ type3: Natural eyelashes — allowed additional price range
   └ type2: Hairstyle — required, single choice, no price
      ├ type3: Without hairstyle — allowed additional price 0–0
      └ type3: Simple hairstyle — allowed additional price range
```

Type-1 service forms also accept `occasion_ids: ["<occasion uuid>"]` (TD-115). Load options from `/joshan/occasions`; only active occasions are accepted. Omit `occasion_ids` to preserve the current links, send an empty array to clear them. Type-2 and type-3 nodes cannot be tagged. `service_occasions` has no public UUID (TD-22).

One required group does not satisfy another. Provider-specific final service price
is the provider's base-service price plus her prices for the selected choices.
Admin min/max are limits, not the customer's final price or a provider's price.
Checkout selection/total calculation belongs to the booking feature (§13).

## 2. Pages and permissions

Every route below belongs to section **`services`**. The matrix cell grants all
routes assigned to that action. Hide the sidebar entry without either relevant
list permission, and gate each button using its exact route name.
Sidebar: **Services → Main services / Service tree / Price ranges**.

| Page / component | Endpoint | Route permission | Action |
|------------------|----------|------------------|--------|
| MainServicesPage | `GET /categories` | `admin.categories.index` | view |
| MainServiceForm: load | `GET /categories/{uuid}` | `admin.categories.show` | view |
| MainServiceForm: create | `POST /categories` | `admin.categories.store` | create |
| MainServiceForm: edit | `PUT\|PATCH /categories/{uuid}` | `admin.categories.update` | update |
| DeleteMainServiceDialog | `DELETE /categories/{uuid}` | `admin.categories.destroy` | delete |
| MainServiceVisibilityButton | `PATCH /categories/{uuid}/toggle-active` | `admin.categories.toggle-active` | update |
| ServiceTreePage | `GET /categories/{uuid}/services/tree` | `admin.categories.tree` | view |
| ServicesTable | `GET /services` | `admin.services.index` | view |
| ServiceForm: load | `GET /services/{uuid}` | `admin.services.show` | view |
| ServiceForm: create | `POST /services` | `admin.services.store` | create |
| ServiceForm / PriceRangeForm: edit | `PUT\|PATCH /services/{uuid}` | `admin.services.update` | update |
| DeleteServiceDialog | `DELETE /services/{uuid}` | `admin.services.destroy` | delete |
| CatalogImageField | `POST /api/v1/media` (shared base URL) | `shared.media.upload` — authenticated, no section RBAC | — |
| ServiceVisibilityButton | `PATCH /services/{uuid}/toggle-active` | `admin.services.toggle-active` | update |

Path ids are UUID strings; unknown UUIDs and integer ids return 404. Body/filter
references are also UUIDs, validated before resolving internal foreign keys;
unknown/integer body or filter references return 422. No integer primary key is returned.

## 3. List / table

Common query rules: page integer ≥1, default1; per_page integer1…100, default15;
search nullable string max150. paginate=`true|false|1|0` (strings), default true.
False/0 returns a plain array in data, capped500, without links/meta. Default sorting
is created_at DESC with internal row key DESC for ties; internal IDs are never exposed.
Supported sort directions are asc/desc, applied in supplied order. Unknown nested
filter/sort keys return422 naming the unsupported path; malformed values return422.

| Endpoint / items path | Search fields | filters[...] | sort[...] |
|-----------------------|---------------|--------------|-----------|
| GET /categories / data.categories | Name in either stored locale (partial LIKE) | is_active (0/1) | sort_order, created_at |
| GET /services / data.services | Name in either stored locale (partial LIKE) | category_id UUID, parent_id UUID or null, type1/2/3, is_active (0/1) | sort_order, created_at |

Use `filters[category_id]=<category UUID>&filters[type]=1` for a category's bookable
services. Use parent_id=root UUID and type2 for its groups; parent_id=group UUID and
type3 for its choices. Empty parent_id is normalized to null (root filter).
Lists include inactive rows unless filtered. They are admin definitions, not
customer-eligible provider offers. List names/descriptions localize with Arabic fallback.

| Category column | Field | Notes |
|-----------------|-------|-------|
| Title/description | name, description | Localized values; full stored locales on show |
| Icon image | image | HasMedia URL or placeholder |
| Status/order | is_active, sort_order | Independent flags/order |
| Optional metadata | slug, icon_key | Slug globally unique or null; glyph key separate from uploaded icon |
| All nodes | services_count | Types1+2+3, including inactive |
| Active bookable services | active_subservices_count | Type1 own is_active flag; eligibility also depends on category/provider/prices |
| Created | created_at | UTC Y-m-d H:i:s |

Service columns: localized name, type.label, category/parent UUID, min/max/reference,
duration, selection flags, active/order, children_count, provider_services_count and
created_at. Group prices/duration are zero; counts include inactive definitions/price rows.

Real paginated service list:

```json
{
  "status": "success",
  "message": "Data retrieved successfully",
  "data": {
    "services": [
      {
        "id": "01a15e91-1940-71cd-bd2d-3bc26478382e",
        "name": "Haircut",
        "description": "Cut and style",
        "image": "https://ui-avatars.com/api/?name=N&background=f3f4f6&color=9ca3af&size=200",
        "category_id": "01a15e91-1932-7117-a863-adf73db18b27",
        "parent_id": null,
        "type": {
          "value": 1,
          "label": "Service"
        },
        "children_selection": {
          "value": "single",
          "label": "Single"
        },
        "children_required": true,
        "default_duration_min": 60,
        "reference_price": "150.00",
        "min_price": "50.00",
        "max_price": "300.00",
        "is_active": true,
        "sort_order": 2,
        "children_count": 1,
        "provider_services_count": 0,
        "created_at": "2026-10-21 13:05:29"
      }
    ],
    "links": {
      "first": "http://localhost/api/v1/admin/services?search=%D9%82%D8%B5&filters%5Bcategory_id%5D=01a15e91-1932-7117-a863-adf73db18b27&filters%5Btype%5D=1&filters%5Bis_active%5D=1&sort%5Bsort_order%5D=asc&page=1",
      "last": "http://localhost/api/v1/admin/services?search=%D9%82%D8%B5&filters%5Bcategory_id%5D=01a15e91-1932-7117-a863-adf73db18b27&filters%5Btype%5D=1&filters%5Bis_active%5D=1&sort%5Bsort_order%5D=asc&page=1",
      "prev": null,
      "next": null
    },
    "meta": {
      "current_page": 1,
      "from": 1,
      "last_page": 1,
      "links": [
        {
          "url": null,
          "label": "&laquo; Previous",
          "page": null,
          "active": false
        },
        {
          "url": "http://localhost/api/v1/admin/services?search=%D9%82%D8%B5&filters%5Bcategory_id%5D=01a15e91-1932-7117-a863-adf73db18b27&filters%5Btype%5D=1&filters%5Bis_active%5D=1&sort%5Bsort_order%5D=asc&page=1",
          "label": "1",
          "page": 1,
          "active": true
        },
        {
          "url": null,
          "label": "Next &raquo;",
          "page": null,
          "active": false
        }
      ],
      "path": "http://localhost/api/v1/admin/services",
      "per_page": 15,
      "to": 1,
      "total": 1
    }
  }
}
```

## 4. Details view

GET /categories/{uuid}, GET /services/{uuid}. Both include stored ar and optional en
objects with name/description; missing English is null, never a fabricated translation.
IDs and foreign-reference IDs are UUIDs only. Service show adds updated_at and the
stored locales; show does not automatically expand the whole tree.

```json
{
  "status": "success",
  "message": "Data retrieved successfully",
  "data": {
    "id": "01a15e91-1940-71cd-bd2d-3bc26478382e",
    "name": "Haircut",
    "description": "Cut and style",
    "image": "https://ui-avatars.com/api/?name=N&background=f3f4f6&color=9ca3af&size=200",
    "category_id": "01a15e91-1932-7117-a863-adf73db18b27",
    "parent_id": null,
    "type": {
      "value": 1,
      "label": "Service"
    },
    "children_selection": {
      "value": "single",
      "label": "Single"
    },
    "children_required": true,
    "default_duration_min": 60,
    "reference_price": "150.00",
    "min_price": "50.00",
    "max_price": "300.00",
    "is_active": true,
    "sort_order": 2,
    "children_count": 0,
    "provider_services_count": 0,
    "created_at": "2026-10-21 13:05:29",
    "updated_at": "2026-10-21 13:05:29",
    "ar": {
      "name": "قص الشعر",
      "description": "قص وتصفيف"
    },
    "en": {
      "name": "Haircut",
      "description": "Cut and style"
    }
  }
}
```

GET /categories/{uuid}/services/tree returns the complete ordered tree: type1 roots,
their type2 groups, and type3 choices. Children are ordered sort_order then internal
row key; inactive nodes are included. No pagination/search/filter on this endpoint.
Each group carries its own children_required and children_selection. Leaves have
children:[]; never treat the service's selection flags as the group's settings.

```json
{
  "status": "success",
  "message": "Data retrieved successfully",
  "data": [
    {
      "id": "01a15e91-b250-7387-b3da-2c448ba24075",
      "name": "Evening makeup",
      "description": null,
      "image": "https://ui-avatars.com/api/?name=N&background=f3f4f6&color=9ca3af&size=200",
      "category_id": "01a15e91-b244-7330-b209-5d8782908745",
      "parent_id": null,
      "type": {
        "value": 1,
        "label": "Service"
      },
      "children_selection": {
        "value": "single",
        "label": "Single"
      },
      "children_required": true,
      "default_duration_min": 120,
      "reference_price": null,
      "min_price": "300.00",
      "max_price": "450.00",
      "is_active": true,
      "sort_order": 0,
      "children_count": 2,
      "provider_services_count": 0,
      "created_at": "2026-10-21 13:06:09",
      "updated_at": "2026-10-21 13:06:09",
      "ar": {
        "name": "مكياج سواريه",
        "description": null
      },
      "en": {
        "name": "Evening makeup",
        "description": null
      },
      "children": [
        {
          "id": "01a15e91-b268-731a-b99e-d0252370d25a",
          "name": "Eyelashes",
          "description": null,
          "image": "https://ui-avatars.com/api/?name=N&background=f3f4f6&color=9ca3af&size=200",
          "category_id": "01a15e91-b244-7330-b209-5d8782908745",
          "parent_id": "01a15e91-b250-7387-b3da-2c448ba24075",
          "type": {
            "value": 2,
            "label": "Option group"
          },
          "children_selection": {
            "value": "single",
            "label": "Single"
          },
          "children_required": true,
          "default_duration_min": 0,
          "reference_price": null,
          "min_price": "0.00",
          "max_price": "0.00",
          "is_active": true,
          "sort_order": 0,
          "children_count": 2,
          "provider_services_count": 0,
          "created_at": "2026-10-21 13:06:09",
          "updated_at": "2026-10-21 13:06:09",
          "ar": {
            "name": "الرموش",
            "description": null
          },
          "en": {
            "name": "Eyelashes",
            "description": null
          },
          "children": [
            {
              "id": "01a15e91-b288-719e-bfa0-0da8f291c493",
              "name": "Without eyelashes",
              "description": null,
              "image": "https://ui-avatars.com/api/?name=N&background=f3f4f6&color=9ca3af&size=200",
              "category_id": "01a15e91-b244-7330-b209-5d8782908745",
              "parent_id": "01a15e91-b268-731a-b99e-d0252370d25a",
              "type": {
                "value": 3,
                "label": "Choice"
              },
              "children_selection": {
                "value": "single",
                "label": "Single"
              },
              "children_required": true,
              "default_duration_min": 0,
              "reference_price": "0.00",
              "min_price": "0.00",
              "max_price": "0.00",
              "is_active": true,
              "sort_order": 0,
              "children_count": 0,
              "provider_services_count": 0,
              "created_at": "2026-10-21 13:06:09",
              "updated_at": "2026-10-21 13:06:09",
              "ar": {
                "name": "Without eyelashes",
                "description": null
              },
              "en": {
                "name": "Without eyelashes",
                "description": null
              },
              "children": []
            },
            {
              "id": "01a15e91-b2c3-737c-b7d7-c1b0ab82d14a",
              "name": "Natural eyelashes",
              "description": null,
              "image": "https://ui-avatars.com/api/?name=N&background=f3f4f6&color=9ca3af&size=200",
              "category_id": "01a15e91-b244-7330-b209-5d8782908745",
              "parent_id": "01a15e91-b268-731a-b99e-d0252370d25a",
              "type": {
                "value": 3,
                "label": "Choice"
              },
              "children_selection": {
                "value": "single",
                "label": "Single"
              },
              "children_required": true,
              "default_duration_min": 0,
              "reference_price": "50.00",
              "min_price": "50.00",
              "max_price": "70.00",
              "is_active": true,
              "sort_order": 0,
              "children_count": 0,
              "provider_services_count": 0,
              "created_at": "2026-10-21 13:06:09",
              "updated_at": "2026-10-21 13:06:09",
              "ar": {
                "name": "Natural eyelashes",
                "description": null
              },
              "en": {
                "name": "Natural eyelashes",
                "description": null
              },
              "children": []
            }
          ]
        },
        {
          "id": "01a15e91-b30b-7137-a1c9-5810f3b741a5",
          "name": "Hairstyle",
          "description": null,
          "image": "https://ui-avatars.com/api/?name=N&background=f3f4f6&color=9ca3af&size=200",
          "category_id": "01a15e91-b244-7330-b209-5d8782908745",
          "parent_id": "01a15e91-b250-7387-b3da-2c448ba24075",
          "type": {
            "value": 2,
            "label": "Option group"
          },
          "children_selection": {
            "value": "single",
            "label": "Single"
          },
          "children_required": true,
          "default_duration_min": 0,
          "reference_price": null,
          "min_price": "0.00",
          "max_price": "0.00",
          "is_active": true,
          "sort_order": 0,
          "children_count": 2,
          "provider_services_count": 0,
          "created_at": "2026-10-21 13:06:09",
          "updated_at": "2026-10-21 13:06:09",
          "ar": {
            "name": "تسريحة الشعر",
            "description": null
          },
          "en": {
            "name": "Hairstyle",
            "description": null
          },
          "children": [
            {
              "id": "01a15e91-b37a-722c-9c95-7012ec658562",
              "name": "Without hairstyle",
              "description": null,
              "image": "https://ui-avatars.com/api/?name=N&background=f3f4f6&color=9ca3af&size=200",
              "category_id": "01a15e91-b244-7330-b209-5d8782908745",
              "parent_id": "01a15e91-b30b-7137-a1c9-5810f3b741a5",
              "type": {
                "value": 3,
                "label": "Choice"
              },
              "children_selection": {
                "value": "single",
                "label": "Single"
              },
              "children_required": true,
              "default_duration_min": 0,
              "reference_price": "0.00",
              "min_price": "0.00",
              "max_price": "0.00",
              "is_active": true,
              "sort_order": 0,
              "children_count": 0,
              "provider_services_count": 0,
              "created_at": "2026-10-21 13:06:09",
              "updated_at": "2026-10-21 13:06:09",
              "ar": {
                "name": "Without hairstyle",
                "description": null
              },
              "en": {
                "name": "Without hairstyle",
                "description": null
              },
              "children": []
            },
            {
              "id": "01a15e91-b3a7-7094-aa99-5f6b115a1ac2",
              "name": "Simple hairstyle",
              "description": null,
              "image": "https://ui-avatars.com/api/?name=N&background=f3f4f6&color=9ca3af&size=200",
              "category_id": "01a15e91-b244-7330-b209-5d8782908745",
              "parent_id": "01a15e91-b30b-7137-a1c9-5810f3b741a5",
              "type": {
                "value": 3,
                "label": "Choice"
              },
              "children_selection": {
                "value": "single",
                "label": "Single"
              },
              "children_required": true,
              "default_duration_min": 0,
              "reference_price": "100.00",
              "min_price": "100.00",
              "max_price": "150.00",
              "is_active": true,
              "sort_order": 0,
              "children_count": 0,
              "provider_services_count": 0,
              "created_at": "2026-10-21 13:06:09",
              "updated_at": "2026-10-21 13:06:09",
              "ar": {
                "name": "Simple hairstyle",
                "description": null
              },
              "en": {
                "name": "Simple hairstyle",
                "description": null
              },
              "children": []
            }
          ]
        }
      ]
    }
  ]
}
```

## 5. Forms (create / edit)

All updates are partial: omitted fields retain their values. Use scalar enum values
and public UUIDs, not response enum objects. Date-times remain UTC Y-m-d H:i:s
(TD-38/40), parsed with parseLegacyUtc from API_CONVENTIONS; money is returned
as SAR strings with two decimals. Requests accept exact numeric values/decimal
strings with at most2 decimal places, max9999999999.99; never send formatted currency.

### 5.1 Category — POST /categories; PUT|PATCH /categories/{uuid}

Dashboard form: **Title, Description, Icon image, Active**. Title maps to ar.name/en.name;
there is no root title field. Root slug, sort_order and icon_key are optional metadata.
Categories have no prices and no parent/children fields.

| Field | Validation / behavior |
|-------|-----------------------|
| ar / ar.name | Create required; object allows name/description only; name max100, unique per locale |
| en / en.name | Optional locale; if supplied on create requires name max100, unique per locale |
| ar.description, en.description | Nullable strings max16000; partial description edit does not require resending the existing name |
| image | Optional nullable media hash; omission keeps, null removes |
| is_active | Optional boolean, default true |
| slug | Optional nullable string max255, globally unique under current case/accent-insensitive collation; no auto-generation; omission keeps, null clears |
| sort_order | Optional signed32-bit integer, default0 |
| icon_key | Optional nullable string max40; glyph metadata, not an image hash |
| parent_id, children, locale-level slug or other unknown locale keys | Prohibited/unsupported; nonempty prohibited input or unsupported key returns422 |

```json
{
  "ar": {"name":"مكياج","description":"مكياج بسيط وكحل"},
  "en": {"name":"Makeup","description":"Simple makeup and eyeliner"},
  "image": null,
  "is_active": true
}
```

Real successful category create from the generated example:

```json
{
  "status": "success",
  "message": "Data saved successfully",
  "data": {
    "id": "01a15e91-5594-72b3-812c-d374ef09d62f",
    "name": "Makeup",
    "description": "Simple makeup and eyeliner",
    "slug": null,
    "icon_key": null,
    "image": "https://ui-avatars.com/api/?name=C&background=fef3c7&color=d97706&size=200",
    "is_active": true,
    "sort_order": 0,
    "services_count": 0,
    "active_subservices_count": 0,
    "created_at": "2026-10-21 13:05:45",
    "updated_at": "2026-10-21 13:05:45",
    "ar": {
      "name": "مكياج",
      "description": "مكياج بسيط وكحل"
    },
    "en": {
      "name": "Makeup",
      "description": "Simple makeup and eyeliner"
    }
  }
}
```

### 5.2 Bookable service — POST /services; PUT|PATCH /services/{uuid}

Create type1 directly under a category. The previously rejected request below is now
valid (replace category_id with your category UUID). type:"1" is also accepted by
the integer validator; responses use integer value1.

```json
{
  "ar": {"name":"قصة شعر هادئة","description":"قصة شعر هادئة"},
  "en": {"name":"Simple cut","description":"Simple cut"},
  "category_id":"<category UUID>",
  "type":1,
  "children_required":false,
  "default_duration_min":15,
  "is_active":true,
  "sort_order":1,
  "min_price":10,
  "max_price":20,
  "reference_price":10
}
```

```json
{
  "status": "success",
  "message": "Data saved successfully",
  "data": {
    "id": "01a15e91-b02c-737a-9cad-106bbae3a661",
    "name": "Simple cut",
    "description": "Simple cut",
    "image": "https://ui-avatars.com/api/?name=N&background=f3f4f6&color=9ca3af&size=200",
    "category_id": "01a15e91-b00e-73a8-b83c-77f18e29ee55",
    "parent_id": null,
    "type": {
      "value": 1,
      "label": "Service"
    },
    "children_selection": {
      "value": "single",
      "label": "Single"
    },
    "children_required": false,
    "default_duration_min": 15,
    "reference_price": "10.00",
    "min_price": "10.00",
    "max_price": "20.00",
    "is_active": true,
    "sort_order": 1,
    "children_count": 0,
    "provider_services_count": 0,
    "created_at": "2026-10-21 13:06:08",
    "updated_at": "2026-10-21 13:06:08",
    "ar": {
      "name": "قصة شعر هادئة",
      "description": "قصة شعر هادئة"
    },
    "en": {
      "name": "Simple cut",
      "description": "Simple cut"
    }
  }
}
```

| Field | Create / validation |
|-------|---------------------|
| ar / ar.name | Required; name max150, description nullable max16000; supported keys name/description only |
| en | Optional; same limits; if supplied requires name on create; details return null when absent |
| category_id | Required existing category UUID; numeric/unknown references rejected422 |
| type | Required integer1/2/3; root1, group2, choice3 |
| parent_id | Null/omitted for type1; required existing UUID exactly one level above for types2/3, same category |
| default_duration_min | Nonnegative signed32-bit integer; type1 must be >0, type2 must be0, type3 optional extra minutes≥0 |
| min_price, max_price | Required on new types1/3; type1:0<min≤max, type3:0≤min≤max; type2 both0 (may omit) |
| reference_price | Optional nullable decimal; when supplied must lie within range; group can only have null/0 |
| children_selection | single/multiple, default single; meaningful on type2 groups only |
| children_required | Boolean, defaulttrue; meaningful on type2 groups only; root/leaf values do not impose group requirements |
| is_active | Optional boolean, defaulttrue |
| sort_order | Optional signed32-bit integer, default0 |
| image | Optional nullable uploaded media hash; omit unchanged image or null to clear |

Existing node type/category cannot change once children or provider price rows
reference it:409 INVALID_STATE_TRANSITION. A free leaf can change definition only
when the resulting complete tree/range remains valid. Range edits are checked against
all existing provider prices (including inactive rows), never silently repriced.

### 5.3 Option groups and choices — same /services CRUD

After creating the service, create type2 groups with parent_id=service UUID. Each
active group independently controls its own choices:

| Group flags | Customer rule for future checkout |
|-------------|-----------------------------------|
| single + required | Exactly one choice |
| single + optional | Zero or one |
| multiple + required | At least one |
| multiple + optional | Zero or more |

Empty/inactive groups provide no active choices and are not presented as selectable
controls; the existing no-active-children convention applies. Stored flags are retained.

A real required single-select group response (submitted type2, parent root UUID,
children_required=true and children_selection=single):

```json
{
  "status": "success",
  "message": "Data saved successfully",
  "data": {
    "id": "01a15e91-b268-731a-b99e-d0252370d25a",
    "name": "Eyelashes",
    "description": null,
    "image": "https://ui-avatars.com/api/?name=N&background=f3f4f6&color=9ca3af&size=200",
    "category_id": "01a15e91-b244-7330-b209-5d8782908745",
    "parent_id": "01a15e91-b250-7387-b3da-2c448ba24075",
    "type": {
      "value": 2,
      "label": "Option group"
    },
    "children_selection": {
      "value": "single",
      "label": "Single"
    },
    "children_required": true,
    "default_duration_min": 0,
    "reference_price": null,
    "min_price": "0.00",
    "max_price": "0.00",
    "is_active": true,
    "sort_order": 0,
    "children_count": 0,
    "provider_services_count": 0,
    "created_at": "2026-10-21 13:06:09",
    "updated_at": "2026-10-21 13:06:09",
    "ar": {
      "name": "الرموش",
      "description": null
    },
    "en": {
      "name": "Eyelashes",
      "description": null
    }
  }
}
```

Create type3 with parent_id=group UUID. Use min_price=max_price=0 for a free choice;
providers still record an explicit zero price in provider_services for it.

```json
{
  "status": "success",
  "message": "Data saved successfully",
  "data": {
    "id": "01a15e91-b37a-722c-9c95-7012ec658562",
    "name": "Without hairstyle",
    "description": null,
    "image": "https://ui-avatars.com/api/?name=N&background=f3f4f6&color=9ca3af&size=200",
    "category_id": "01a15e91-b244-7330-b209-5d8782908745",
    "parent_id": "01a15e91-b30b-7137-a1c9-5810f3b741a5",
    "type": {
      "value": 3,
      "label": "Choice"
    },
    "children_selection": {
      "value": "single",
      "label": "Single"
    },
    "children_required": true,
    "default_duration_min": 0,
    "reference_price": "0.00",
    "min_price": "0.00",
    "max_price": "0.00",
    "is_active": true,
    "sort_order": 0,
    "children_count": 0,
    "provider_services_count": 0,
    "created_at": "2026-10-21 13:06:09",
    "updated_at": "2026-10-21 13:06:09",
    "ar": {
      "name": "Without hairstyle",
      "description": null
    },
    "en": {
      "name": "Without hairstyle",
      "description": null
    }
  }
}
```

Paid choices have their own admin range:

```json
{
  "status": "success",
  "message": "Data saved successfully",
  "data": {
    "id": "01a15e91-b3a7-7094-aa99-5f6b115a1ac2",
    "name": "Simple hairstyle",
    "description": null,
    "image": "https://ui-avatars.com/api/?name=N&background=f3f4f6&color=9ca3af&size=200",
    "category_id": "01a15e91-b244-7330-b209-5d8782908745",
    "parent_id": "01a15e91-b30b-7137-a1c9-5810f3b741a5",
    "type": {
      "value": 3,
      "label": "Choice"
    },
    "children_selection": {
      "value": "single",
      "label": "Single"
    },
    "children_required": true,
    "default_duration_min": 0,
    "reference_price": "100.00",
    "min_price": "100.00",
    "max_price": "150.00",
    "is_active": true,
    "sort_order": 0,
    "children_count": 0,
    "provider_services_count": 0,
    "created_at": "2026-10-21 13:06:09",
    "updated_at": "2026-10-21 13:06:09",
    "ar": {
      "name": "Simple hairstyle",
      "description": null
    },
    "en": {
      "name": "Simple hairstyle",
      "description": null
    }
  }
}
```

Do not put provider price on these forms. Admin only defines min_price/max_price
and optional reference_price. Selecting/pricing the offered service and choices
belongs to the provider app's PROV-010 flow; no provider write API is added here.

### 5.4 Image upload for every HasMedia catalog model

1. POST /api/v1/media as multipart: file=<image>, collection=image, admin Bearer JWT.
2. Read data.hash from the upload response.
3. Submit that hash as image on category or service JSON create/update.

Do not submit a URL, local path, multipart file or placeholder such as "value" as
the form image field. Collection allows jpg/jpeg/png/webp up to5120KB; replacement
and removal use HasMedia and are audited. Already-attached media must belong to
this record; missing file/hash, wrong media family or cross-record attachment is422.
The generated no-image example uses image:null. On an edit, omit image to keep it.

```json
{
  "status": "success",
  "message": "Media uploaded successfully",
  "data": {
    "id": "01a15e91-436c-7058-80c3-a962dae04da3",
    "hash": "71469a6d9f1420f7484dfd7ac4acf037",
    "url": "http://localhost/storage/uploads/image/2ff23391-5232-4551-a1e5-9f2bfd5993f8.webp",
    "mime_type": "image/webp",
    "size": 66,
    "original_name": "catalog-cover.png",
    "variants": {
      "thumbnail": "http://localhost/storage/uploads/image/variants/2ff23391-5232-4551-a1e5-9f2bfd5993f8_thumbnail.webp",
      "medium": "http://localhost/storage/uploads/image/variants/2ff23391-5232-4551-a1e5-9f2bfd5993f8_medium.webp",
      "large": "http://localhost/storage/uploads/image/variants/2ff23391-5232-4551-a1e5-9f2bfd5993f8_large.webp"
    }
  }
}
```

## 6. Actions (non-CRUD)

PATCH /categories/{uuid}/toggle-active and PATCH /services/{uuid}/toggle-active:
no body; returns updated details. Category update permission is
admin.categories.toggle-active; service is admin.services.toggle-active.
Confirm and disable duplicate submission; do not blindly retry a toggle after a
network timeout—refetch state first. Only the selected is_active flag changes.

DELETE /categories/{uuid} and DELETE /services/{uuid}: delete permission; success
returns data:null. Referenced categories/services (children, provider rows, package,
order/option/occasion/history links) return409 CATALOG_IN_USE; offer deactivation.
No provider/package/order data is cascaded away by admin deletion.

### Range edits

PUT|PATCH /services/{uuid}: same update permission, with proposed min/max/reference.
If any existing provider price would leave the range, return409 PRICE_RANGE_CONFLICT
with affected_count; actual prices and audit trail stay untouched. Includes inactive
provider prices and zero-priced choices. There is no force-reprice flag.

```json
{
  "status": "fail",
  "message": "The new range excludes 2 existing provider prices. Keep a compatible range until providers reprice.",
  "data": {
    "error_code": "PRICE_RANGE_CONFLICT",
    "correlation_id": "b9373377-8a5e-4963-8291-45bf252a9cde",
    "affected_count": 2
  }
}
```

### Errors (all catalog routes)

| HTTP | error_code | Cause / dashboard action |
|------|------------|--------------------------|
| 401 | UNAUTHENTICATED | Missing/expired/invalid token; shared refresh flow |
| 403 | PERMISSION_DENIED / ACCOUNT_INACTIVE | Missing route permission, wrong app or inactive operator; stop protected action |
| 404 | absent, data:null | Unknown/integer route UUID; show unavailable |
| 422 | absent (`data.errors`) | Form/query shape, invalid UUID reference, duplicate locale name/slug, bad media hash; mark fields from `data.errors` and fall back to the localized message (TD-02) |
| 422 | BUSINESS_RULE_VIOLATED | Invalid tree, duration, required prices, min/max or reference; show specific localized message |
| 409 | INVALID_STATE_TRANSITION | Type/category locked by children/provider prices |
| 409 | PRICE_RANGE_CONFLICT | New range excludes provider prices; show affected_count, coordinate provider repricing |
| 409 | CATALOG_IN_USE | Referenced record cannot be deleted; deactivate |

Business errors expose correlation_id where available. Validation stays the shared
summary envelope; unsupported sort/filter/ar/en keys name their path. No SQL error
should escape a normal admin form validation path.

```json
{
  "status": "fail",
  "message": "The maximum price must be greater than or equal to the minimum price.",
  "data": {
    "error_code": "BUSINESS_RULE_VIOLATED",
    "correlation_id": "4890c0b0-f16d-49f2-a2bb-1e7b9f96a41f"
  }
}
```

## 7. Statuses and enums

Enums return {value,label}; Accept-Language selects labels. is_active is a boolean,
with Active/Inactive UI badges. Dates: UTC Y-m-d H:i:s, never browser-local parsing.

| Enum | Value | English / Arabic | Badge / behavior |
|------|-------|------------------|------------------|
| type | 1 | Service / خدمة قابلة للحجز | blue; bookable root, duration/range |
| type | 2 | Option group / مجموعة خيارات | purple; unpriced selection group |
| type | 3 | Choice / خيار | teal; paid/free additional option |
| children_selection | single | Single / اختيار واحد | neutral; one at most |
| children_selection | multiple | Multiple / اختيارات متعددة | neutral; several allowed |
| is_active | true/false | Active/Inactive | green/gray; independent flag |

Each nonempty active group's own required flag determines whether omission is allowed.
Admin activation does not mean a provider offer is bookable: complete pricing and
provider/category approval/activation gates also apply.

## Data model

| Table / view | Responsibility |
|--------------|----------------|
| categories, category_translations | Admin category title/description/icon/active; optional global slug |
| services, service_translations | Admin bookable roots, groups, choices and ranges; parent/type shape fixed to3 levels |
| media | Existing HasMedia collections on categories and services |
| provider_categories | Provider's category selection/review and enabled flag |
| provider_services | **Existing** provider-owned actual price and optional duration override, unique(provider_id,service_id); types1/3 only |
| providers | Approval status gate |
| package/order/occasion/finder links | Read-only deletion/identity protection; no management writes here |
| audit_logs | Catalog writes, translations, media changes and toggles, same transaction |
| v_bookable_provider_services | Eligible type1 base price after active category/service, approved provider/category and complete same-provider pricing of active choices in active groups |
| v_selectable_provider_addons | Eligible type3 prices through their active type2 group; exposes internal bookable_service_id and option_group_id for downstream joins |

Provider prices remain in migration `2026_10_20_110012_create_provider_services_table.php`;
its structure is unchanged. SQL pricing triggers reject out-of-range prices and group
pricing on both insert/update. Never expose view integer join keys through an API;
resources must resolve related UUIDs. The provider pricing API exposes catalog UUIDs only.

Baseline SQL/migrations and the guarded TD-42 upgrade match. Apply the older ancestor-view
upgrade before the TD-42 upgrade, in migration order. Upgrade/rollback refuses populated
legacy/current catalogs before DDL; use an explicit reviewed mapping for existing data.
No automatic conversion/deletion of old grouping records or order/financial references. Both catalog view/tree upgrades have been applied in order to the local joshen database; the existing provider_services table is retained.

## 8. TypeScript types

Use shared Paginated, Money and EnumValue from API_CONVENTIONS.

```ts
export type UUID = string;
export interface CatalogText { name: string; description: string | null }
export interface CatalogTextInput { name: string; description?: string | null }
export interface CategoryRow {
  id: UUID; name: string; description: string | null; slug: string | null;
  icon_key: string | null; image: string; is_active: boolean; sort_order: number;
  services_count: number; active_subservices_count: number; created_at: string;
}
export interface CategoryDetails extends CategoryRow {
  ar: CatalogText; en: CatalogText | null; updated_at: string;
}
export interface ServiceRow {
  id: UUID; name: string; description: string | null; image: string;
  category_id: UUID; parent_id: UUID | null;
  type: { value: 1 | 2 | 3; label: string };
  children_selection: EnumValue<'single' | 'multiple'>; children_required: boolean;
  default_duration_min: number;
  min_price: Money; max_price: Money; reference_price: Money | null;
  is_active: boolean; sort_order: number; children_count: number;
  provider_services_count: number; created_at: string;
}
export interface ServiceDetails extends ServiceRow {
  ar: CatalogText; en: CatalogText | null; updated_at: string;
}
export interface ServiceTreeNode extends ServiceDetails { children: ServiceTreeNode[] }
export type CategoryList = Paginated<'categories', CategoryRow>;
export type ServiceList = Paginated<'services', ServiceRow>;
export interface CategoryCreate {
  ar: CatalogTextInput; en?: CatalogTextInput; image?: string | null;
  is_active?: boolean; slug?: string | null; icon_key?: string | null; sort_order?: number;
}
export interface ServiceCreate {
  category_id: UUID; type: 1 | 2 | 3; parent_id?: UUID | null;
  ar: CatalogTextInput; en?: CatalogTextInput; image?: string | null;
  default_duration_min?: number;
  min_price?: Money; max_price?: Money; reference_price?: Money | null;
  children_selection?: 'single' | 'multiple'; children_required?: boolean;
  is_active?: boolean; sort_order?: number;
}
export type CategoryUpdate = Partial<Omit<CategoryCreate, 'ar'>> & { ar?: Partial<CatalogTextInput> };
export type ServiceUpdate = Partial<Omit<ServiceCreate, 'ar'>> & { ar?: Partial<CatalogTextInput> };
export interface CatalogQuery {
  page?: number; per_page?: number; paginate?: 'true' | 'false' | '1' | '0';
  search?: string | null; sort?: Partial<Record<'sort_order' | 'created_at', 'asc' | 'desc'>>;
}
export interface CategoryQuery extends CatalogQuery { filters?: { is_active?: 0 | 1 } }
export interface ServiceQuery extends CatalogQuery {
  filters?: { is_active?: 0 | 1; category_id?: UUID; parent_id?: UUID | null; type?: 1 | 2 | 3 };
}
```

For a create form, conditionally require duration/min/max when type1; min/max when
type3; parent when type2/3. Require zero duration/prices on type2. Render independent
required/single/multiple controls for each group. These TS fields are shared; enforce
conditional constraints in the form schema. No provider actual-price field exists.

## 9. Business rules enforced by the backend

| Rule | Enforcement / effect |
|------|----------------------|
| BR-05 / TD-42 | Flat non-bookable categories; bookable type1 root → type2 groups → type3 choices; same category and adjacent parent types, depth≤3 |
| BR-06 / TD-42 | Admin defines ranges; existing provider_services holds actual base/choice prices; group pricing forbidden, free choices allowed |
| TD-34 | Out-of-range admin edits rejected with provider-price count, including inactive rows; referenced deletion/identity changes blocked |
| TD-33/42 | Category/service deactivate hides the offer and all choices without flag cascade; group deactivate hides only its choices; same-provider active pricing required |
| TD-12/19 | Arabic required, English optional; UUID public IDs; no raw integer foreign keys |
| TD-14/37 | Upload hash attachment/replacement/removal through HasMedia; actor-bound audit |
| BR-29 / TD-27 | All routes scoped to services permissions; supervisor denials precede mutation |

Writes and audit are transactional. Provider pricing writes lock the same category/service records and validate node/range rules before SQL; see [provider/services-pricing.md](../provider/services-pricing.md). Checkout must enforce each group's selections before creating an order.

## 10. Notifications triggered

N/A. Catalog edits send no provider/customer notifications. Admin range conflicts
never automatically reprice provider content or change her packages.

## 11. Integration checklist

- [ ] Replace old type mapping: type1 is bookable root, type2 is option group, type3 is choice.
- [ ] Category form remains title/description/icon/active; optional metadata can be omitted.
- [ ] Category's bookable list uses category_id + type1 filters; groups/choices use parent UUID + type2/3.
- [ ] Create bookable service directly under category with positive duration/range; no synthetic parent group.
- [ ] Create each option group beneath that service; independent selection flags on the group.
- [ ] Allow 0–0 choice ranges, showing free; keep base-service minimum strictly positive.
- [ ] Keep provider actual prices outside admin forms; use the implemented [provider pricing contract](../provider/services-pricing.md) for provider-owned prices.
- [ ] Upload image first and submit hash; omit unchanged image; never send a placeholder/URL.
- [ ] Use data.categories/data.services + links/meta, or plain data array for paginate=false.
- [ ] Gate pages/buttons by §2, handle validation/business409s and localized labels in RTL/en.
- [ ] Parse UTC date-times explicitly; show exact two-decimal SAR strings.
- [ ] Refresh regenerated API console/Postman examples; apply migration before writes.

## 12. Acceptance scenarios

Current full MySQL joshen_testing suite: **778 tests passed / 6,837 assertions** (2026-10-22). Baseline SQL/migration parity, upgrade/rollback guards and provider-price safety nets are covered. Catalog migrations applied to local joshen; no production deployment or completed customer/provider-app journey is claimed.

| AC ID | Scenario | Evidence |
|-------|----------|----------|
| AC-ADMIN-008 | Flat category CRUD, locale validation, hashes, counts, optional unique slug | CatalogControllerTest and CategoryRequestExamplesTest |
| AC-ADMIN-009 / TD-42 | Previously rejected Simple cut root payload succeeds with duration15/range10–20 | ServiceOptionGroupsTest root-service test |
| AC-ADMIN-009 | Two independently required groups with free/paid choices | ServiceOptionGroupsTest screen-aligned tree test; actual tree JSON above |
| AC-ADMIN-009/010 | Invalid parent/category/depth, group duration/prices, root duration/minimum, reference and precision | CatalogControllerTest invalid-definition/partial/message cases |
| AC-ADMIN-008/009 | Every route 401/403/allowed supervisor, unknown/integer route IDs404 | CatalogControllerTest endpoint data providers; PermissionCatalogTest |
| AC-ADMIN-010 | Admin repricing conflict preserves provider-owned prices/audit | CatalogControllerTest price-range conflict cases |
| AC-PROV-010 / TD-42 | Existing provider_services rejects group pricing insert/update, enforces ranges and accepts explicit free0 | ServiceOptionGroupsTest + SchemaFoundationTest SQL safety nets; provider HTTP coverage now lives in ServicesPricingTest and [provider/services-pricing.md](../provider/services-pricing.md) |
| AC-ADMIN-008/009 / TD-42 | Category/service visibility + same-provider complete choice pricing, group flag inheritance | CatalogControllerTest ancestor/completeness tests and ServiceOptionGroupsTest |
| TD-42 | SQL/migration metadata/views match; legacy upgrade/current rollback guards preserve records before DDL | SchemaMigrationLifecycleTest |
| TD-37 | Actual upload → hash → create/replace/remove on both models | CatalogControllerTest media data provider |

## 13. Open decisions and known limitations

| ID | Impact / current boundary |
|----|---------------------------|
| TD-42 | Populated old catalogs require an explicit type/parent/reference mapping. Upgrade refuses before DDL; no automatic conversion or record deletion |
| PROV-010 | Provider-owned HTTP pricing is implemented in [provider/services-pricing.md](../provider/services-pricing.md), using this existing table and shared complete-pricing Domain rule (TD-71). Admin continues to own definitions/ranges only |
| CUST-006/007/012 / BR-05 | Customer catalog, provider selection, group validation, totals, scheduling/checkout are future app flows; admin tree and SQL tests do not certify a customer booking journey |
| TD-42 | Empty/inactive groups have no active choices and no selection control; current no-active-children convention retained. Every active nonempty group must enforce its own requirement at checkout |
| TD-34 | Conflicting range edits are rejected with affected_count; no bulk repricing workflow |
| TD-41 | Category slug optional/nullable, max255, no auto-generation; no slug-based route/filter/search |
| TD-12 | No action to delete an existing English translation; omission retains it |
| TD-38/40 | Offsetless dates mean UTC; reload cached config/workers on deployment. Historical naive DATETIME values require provenance audit before normalization; no date rewrite |
| D1 / ADMIN-011 | Admin provider prices/packages editing remains outside this feature; references only protect deletion |

## Postman examples (2026-11-08)

Category form answers, captured by `tests/Feature/Api/V1/Admin/ContentCatalogExamplesTest.php`; every category body key is now described in the collection.

- `admin.categories.store` › "201 Catalog Categories With Image", "201 Catalog Category Simple Form", "201 Catalog Category Store", "201 Catalog Slug Create", "201 Categories Created With Both Locales", "422 Catalog Slug Duplicate", "422 Catalog Unsupported Ar Unknown Ar", "422 Catalog Unsupported En Unknown En", "422 Catalog Validation", "422 Categories Store Missing Arabic Name"
- `admin.categories.update` › "200 Catalog Category Update", "200 Catalog Slug Clear", "200 Catalog Slug Update", "200 Categories Updated", "422 Categories Update Duplicate Slug"
