---
feature: Cities and categories
slug: catalog
surface: admin
consumer: vue
version: 1.1.0
status: ready
brd_version: "Morvia Project Guide"
brd_ids: [ADM-043, ADM-044]
business_rules: [BR-CRS-11, BR-ACC-03]
decisions: [TD-11, TD-14, TD-29]
schema_tables: [cities, city_translations, countries, categories, category_translations]
related_docs: []
last_updated: 2026-11-15
---

# Cities and categories — Admin integration

## Quick reference

| | |
|---|---|
| Version | 1.1.0 (2026-11-15) — categories (below); cities as 1.0.0 with the limitations further down. |
| Status | ADM-043 cities and ADM-044 categories ready (list with usage counts, add, edit, activate / deactivate, guarded delete). |
| Auth | Bearer token; admin route names are permission keys. |

| Method | Path | Permission | Purpose |
|---|---|---|---|
| GET | /cities | admin.cities.index | List cities |
| POST | /cities | admin.cities.store | Create city |
| GET | /cities/{city} | admin.cities.show | Details and translations |
| PUT/PATCH | /cities/{city} | admin.cities.update | Partial update, including activation |
| DELETE | /cities/{city} | admin.cities.destroy | Deactivate and retain links |

**Categories (ADM-044, 1.1.0)** — `GET|POST /categories`, `GET|PUT|DELETE /categories/{id}` (`admin.categories.*`; permissions `categories.view|add|edit|delete`). Body: `en.name`, `ar.name`, `is_active`, `sort_order`, `icon`. Every row and the details carry `courses_count`, `trainers_count` and `job_ads_count`. Deactivate with `PUT {is_active:false}`: past relations stay. `DELETE` is refused with 422 `BUSINESS_RULE_VIOLATED` while any course, trainer profile or job uses the category; an unused one is soft-deleted (TD-29).

Paths are relative to `/api/v1/admin`. Route IDs and country references are UUID strings. 422 means invalid input; 403 means the admin lacks the route's permission (RBAC is enforced in every environment). See known limitations below before integrating.

## 1. Business summary

Administrators manage bilingual city names, country and active status. Deactivation retains existing links.

## 2. Pages and permissions

Use the route names above for the corresponding list, detail, create, edit and deactivate actions.

## 3. List / table

`GET /cities` supports `search` across names and searchable fields, `filters[is_active]`, `filters[country_id]`, `sort[name]`, `sort[sort_order]`, `sort[is_active]`, `sort[country_id]`, `per_page`, `page`, and `paginate=false`. The list resource provides UUID `id`, localized `name`, `country_id`, localized `country`, `is_active`, `sort_order`, and `created_at`. Integer counts are always present: `courses_count`, `trainers_count`, `halls_count`, `training_centers_count`, and `hall_providers_count`. Trainers are counted through their user city.

**Captured response (`GET /cities`):**

```json
{
  "status": "success",
  "message": "Data retrieved successfully",
  "data": {
    "cities": [
      {
        "id": "a33b175a-65be-475b-81d5-60cbdda2139a",
        "name": "الرياض",
        "country_id": "a33b175a-5ddf-4449-acbb-c42d4ebeaa61",
        "country": "المملكة العربية السعودية",
        "is_active": true,
        "sort_order": 1,
        "courses_count": 12,
        "trainers_count": 0,
        "halls_count": 1,
        "training_centers_count": 3,
        "hall_providers_count": 1,
        "created_at": "2026-11-15 11:25:01"
      }
    ],
    "links": {
      "first": "http://localhost/api/v1/admin/cities?page=1",
      "last": "http://localhost/api/v1/admin/cities?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/cities?page=1", "label": "1", "page": 1, "active": true},
        {"url": null, "label": "Next &raquo;", "page": null, "active": false}
      ],
      "path": "http://localhost/api/v1/admin/cities",
      "per_page": 15,
      "to": 1,
      "total": 7
    }
  }
}
```

**Captured response (`GET /categories`):**

```json
{
  "status": "success",
  "message": "Data retrieved successfully",
  "data": {
    "categories": [
      {
        "id": "a33b175f-2536-4635-b25a-c183fb8aca11",
        "name": "المهارات الشخصية والتطوير الذاتي",
        "description": null,
        "icon": "https://ui-avatars.com/api/?name=C&background=fef3c7&color=d97706&size=200",
        "is_active": true,
        "sort_order": 1,
        "courses_count": 12,
        "trainers_count": 0,
        "job_ads_count": 0,
        "created_at": "2026-11-15 11:25:05"
      }
    ],
    "links": {
      "first": "http://localhost/api/v1/admin/categories?page=1",
      "last": "http://localhost/api/v1/admin/categories?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/categories?page=1", "label": "1", "page": 1, "active": true},
        {"url": null, "label": "Next &raquo;", "page": null, "active": false}
      ],
      "path": "http://localhost/api/v1/admin/categories",
      "per_page": 15,
      "to": 1,
      "total": 15
    }
  }
}
```

## 4. Details view

`GET /cities/{city}` returns the list fields, `updated_at` and `en.name` and `ar.name`.

**Captured response (`GET /cities/{city}`):**

```json
{
  "status": "success",
  "message": "Data retrieved successfully",
  "data": {
    "id": "a33b175a-65be-475b-81d5-60cbdda2139a",
    "name": "الرياض",
    "country_id": "a33b175a-5ddf-4449-acbb-c42d4ebeaa61",
    "country": "المملكة العربية السعودية",
    "is_active": true,
    "sort_order": 1,
    "courses_count": 12,
    "trainers_count": 0,
    "halls_count": 1,
    "training_centers_count": 3,
    "hall_providers_count": 1,
    "created_at": "2026-11-15 11:25:01",
    "updated_at": "2026-11-15 11:25:01",
    "en": {
      "name": "Riyadh"
    },
    "ar": {
      "name": "الرياض"
    }
  }
}
```

**Captured response (`GET /categories/{category}`):**

```json
{
  "status": "success",
  "message": "Data retrieved successfully",
  "data": {
    "id": "a33b175f-2536-4635-b25a-c183fb8aca11",
    "name": "المهارات الشخصية والتطوير الذاتي",
    "description": null,
    "icon": "https://ui-avatars.com/api/?name=C&background=fef3c7&color=d97706&size=200",
    "is_active": true,
    "sort_order": 1,
    "courses_count": 12,
    "trainers_count": 0,
    "job_ads_count": 0,
    "created_at": "2026-11-15 11:25:05",
    "updated_at": "2026-11-15 11:25:05",
    "en": {
      "name": "Personal Skills & Growth",
      "description": null
    },
    "ar": {
      "name": "المهارات الشخصية والتطوير الذاتي",
      "description": null
    }
  }
}
```

## 5. Forms (create / edit)

**Request payload (`POST /cities`):**

```json
{
  "country_id": "a33b175a-5ddf-4449-acbb-c42d4ebeaa61",
  "is_active": true,
  "sort_order": 10,
  "ar": {
    "name": "الخبر"
  },
  "en": {
    "name": "Khobar"
  }
}
```

**Request payload (`POST /categories`):**

```json
{
  "is_active": true,
  "sort_order": 1,
  "ar": {
    "name": "الذكاء الاصطناعي وتحليل البيانات"
  },
  "en": {
    "name": "Artificial Intelligence & Data Analytics"
  }
}
```

| Field | Create | Validation |
|---|---|---|
| country_id | required | UUID, existing country UUID |
| en.name, ar.name | required | string, maximum 255 characters |
| is_active | optional | boolean |
| sort_order | optional | integer, minimum 0 |

All fields are optional on update. Create and update return the detail resource, including UUID references, counts and translations.

**Category fields (`POST|PUT /categories`):**

| Field | Create | Validation |
|---|---|---|
| en.name, ar.name | required | string |
| is_active | optional | boolean |
| sort_order | optional | integer or null |
| icon | optional | media hash of an uploaded image, or null |

## 6. Actions (non-CRUD)

Set `is_active` through update; DELETE also sets it to false. Neither removes a city or its links. A later update can reactivate it.

**Categories:** deactivate with `PUT {is_active:false}`. `DELETE /categories/{id}` returns 422 `BUSINESS_RULE_VIOLATED` while any course, trainer profile or job uses the category (deactivate it instead); an unused category is soft-deleted and answers 200.

## 7. Statuses and enums

`is_active` is a boolean. This feature has no enum fields.

## Data model

`cities` and `city_translations` are read and written; `countries` is read. `categories` and `category_translations` are read and written; categories are soft-deleted.

## 8. TypeScript types

List items have UUID `id` and `country_id`, string or null `name` and `country`, boolean `is_active`, numeric `sort_order`, and nullable `created_at`.

## 9. Business rules enforced by the backend

Deactivation preserves the city and historical links. `App\Domain\Catalog\Rules\ActiveCity::exists()` provides a rule for new or changed city selections. Consuming record-creation flows must explicitly use it; it is not automatically enforced across the application.

## 10. Notifications triggered

N/A.

## 11. Integration checklist

- Gate controls using the route permissions and handle 403 and 422.
- Send both translations on create and use partial updates for status changes.
- Respect the limitations below.

## 12. Acceptance scenarios

ADM-043: list, create, edit, deactivate and reactivate cities; retain old links. Feature tests cover these actions, validation, filtering and 403.

## 13. Open decisions and known limitations

- No existing request or service accepts a city selection for a new or changed link; future flows must apply the active-city rule while retaining unchanged inactive links.

## Version history

| Version | Date | Type | Summary | Dashboard action required |
|---|---|---|---|---|
| 1.1.0 | 2026-11-15 | MINOR | Category usage counts; used categories cannot be deleted, unused ones are soft-deleted. | Show the counts; handle the 422 on delete. |
| 1.0.0 | 2026-11-12 | MAJOR | UUID references and usage counts. | Use UUIDs for city routes and country references. |
| 0.1.0 | 2026-11-12 | Initial | City CRUD; catalog incomplete. | Integrate with limitations above. |

## Changelog

### 1.1.0 — 2026-11-15

- **Added:** category usage counts (`courses_count`, `trainers_count`, `job_ads_count`); guarded delete; categories soft-delete. **Breaking:** No. **Dashboard action required:** show the counts and handle the 422 on delete. **BRD / decisions:** ADM-044, TD-29.

### 1.0.0 — 2026-11-12

- City routes and country references use UUIDs; list and detail include linked counts.
- Dashboard action required: replace numeric identifiers with UUID strings.

### 0.1.0 — 2026-11-12

- Added city management and a reusable active-city rule (ADM-043, BR-CRS-11).
- Dashboard action required: integrate the city endpoints and account for the limitations above.
