# Category Import / Export API > **Prefix:** `/api/v1/admin/categories/{bundle}` Bulk JSON export and import for the admin **«دسته‌بندی‌ها»** page (`CategoriesPage`), backed by `App\Category\Controller\CategoryImportController`. `{bundle}` is one of: `provinces`, `cities`, `specialties`, `doctor_services`, `insurances`, `tags`. **Permission:** `ROLE_ADMIN` (whole controller). Each bundle maps to one table: | bundle | table | has `slug` | has `weight` | references | |---|---|:---:|:---:|---| | `provinces` | `provinces` | ❌ | ✅ | — | | `cities` | `cities` | ❌ | ✅ | `province_id` → provinces, `representation_id` → representations | | `specialties` | `specialties` | ✅ | ✅ | `parent_id` → specialties (same file) | | `doctor_services` | `doctor_services` | ✅ | ✅ | `specialty_id` → specialties | | `insurances` | `insurances` | ❌ | ❌ | `type` ∈ {`basic`,`supplementary`} | | `tags` | `tags` | ✅ | ❌ | — | --- ## GET `/api/v1/admin/categories/{bundle}/export` Complete export of a bundle — **every** row, no pagination cap. Returns the raw table columns (the same keys an import expects), so export → import is lossless. > ⚠️ Do **not** use the paginated admin list endpoints (e.g. `/api/v1/admin/specialties?limit=9999`) > as an export source for import: those cap at 100 rows, and feeding a truncated > file into the wipe+replace import below deletes every row beyond the first 100. ### Response `200` ```json { "success": true, "data": [ { "id": 1, "uuid": "…", "name": "قلب", "slug": "ghalb", "status": 1, "weight": 0, "parent_id": null } ] } ``` For `cities` the export rows carry every column plus `province_name`, and `social_media` is returned as a JSON **object** — the exact shape `nobat724_front/data/city.json` uses, so the export file can be dropped into the frontend (and re-imported) without any transformation. ### Errors | Code | HTTP | Description | |------|------|-------------| | `ERR_AUTH_001` | 401 | Missing token | | `ERR_AUTH_006` | 403 | Not admin | | `ERR_BUNDLE_UNKNOWN` | 404 | Unknown bundle | --- ## POST `/api/v1/admin/categories/{bundle}/import` **Wipe + full replace** of the bundle's table from the uploaded JSON, inside a single transaction (FK checks disabled during the swap). Raw `id` values from the file are preserved so relations stay intact. Validation is **strict and two-phase**: the whole file is validated first; if **any** row is invalid, nothing is written and every error is returned. Unknown extra keys (e.g. `province_name` produced by export) are ignored. ### Request Body (`application/json`) A non-empty array of records, or `{ "items": [ … ] }`. Max **5000** rows. ```json [ { "id": 1, "name": "قلب", "slug": "ghalb", "status": 1, "weight": 0, "parent_id": null }, { "id": 2, "name": "آریتمی", "slug": "arr", "status": 1, "weight": 1, "parent_id": 1 } ] ``` ### Per-row validation rules | Field | Rule | |---|---| | `id` | required, positive integer, unique within the file | | `name` | required, non-empty string ≤ 255 | | `status` | `0` or `1` (default `1`) | | `weight` | integer ≥ 0 — bundles with a weight column only | | `slug` | required, ≤ 255, unique within file — `specialties`/`doctor_services`/`tags` | | `type` | `basic` or `supplementary` — `insurances` only | | `uuid` | optional; generated if missing | | `parent_id` | `specialties`: null, or an `id` present **in the same file** | | `specialty_id` | `doctor_services`: null, or an existing `specialties.id` | | `province_id` / `representation_id` | `cities`: null, or an existing id in the referenced table | | `title`, `site_name`, `slogan`, `domain`, `keywords`, `description`, `footer_description`, `contact_phone`, `email`, `logo_url` | `cities`: optional SEO/branding strings (`title` = meta title of the city website) | | `social_media` | `cities`: optional; JSON object (e.g. `{"instagram": "…"}`) or its string form — stored as JSON | ### Response `200` ```json { "success": true, "data": { "imported": 128 } } ``` ### Response `422` (validation failed — nothing written) ```json { "success": false, "data": null, "errors": [ { "row": 2, "field": "parent_id", "message": "ردیف 2: والد با شناسه 999 در همین فایل وجود ندارد" } ] } ``` ### Errors | Code | HTTP | Description | |------|------|-------------| | `ERR_AUTH_001` | 401 | Missing token | | `ERR_AUTH_006` | 403 | Not admin | | `ERR_BUNDLE_UNKNOWN` | 404 | Unknown bundle | | `ERR_IMPORT_FORMAT` | 422 | Body is not a non-empty array of records | | `ERR_IMPORT_TOO_LARGE` | 422 | More than 5000 rows | | (field errors) | 422 | One entry per failed row/field; no rows written | | `ERR_IMPORT_FAILED` | 500 | DB error during replace (transaction rolled back) | --- ## Seeding a from-scratch database Canonical category data ships in the repo at `data/seed/*.json` (`provinces.json`, `cities.json`, `specialties.json`, `doctor_services.json`). After creating an empty schema, populate it with: ```bash ddev exec php bin/console app:seed-categories ``` The command (`App\Category\Command\SeedCategoriesCommand`) reads those files and runs the same strict validation + wipe-and-replace as the HTTP import, in dependency order (provinces → cities, specialties → doctor_services). The shared logic lives in `App\Category\Service\CategoryImporter`. Full rebuild: ```bash ddev exec php bin/console doctrine:database:drop --force ddev exec php bin/console doctrine:database:create ddev exec php bin/console doctrine:migrations:migrate --no-interaction ddev exec php bin/console app:seed-categories ddev exec php bin/console app:create-admin 09100000001 'Test@1234' ```