Skip to main content

Service Parent–Child Hierarchy — FE Handoff

Date: 2026-09-23 Backend branch: locate-merchant-v2-api → feat/services/parent-child-hierarchy Audience: web admin panel engineers. This covers the operator API only. The client app's own handling of child services (dropped from the home screen) lives in locate-order-v2-api and is a separate handoff — one line on it in §8, nothing more here.

1. The model​

A service (system_categories row, "Service" in the admin panel) now carries a hierarchy_role:

ValueMeaning
parentCan contain child services. Default for every service, including every row that existed before this feature.
standaloneExplicitly opted out of ever containing children. Rare — most services just stay parent and never get any children assigned, which behaves identically from the admin's point of view. Use standalone only when you want to actively signal "this one must never contain children," not as the default choice.
childLives inside at most one parent. Never appears on the client home screen.

Because parent is the default, nothing changed for any existing service — every row was backfilled to parent and behaves exactly as it did before this feature shipped. You only see new behavior once an admin deliberately sets a service to standalone or child.

Depth is capped at two levels, by construction, not by a counter. Only a service whose role is child can hold a parent_id, and only a service whose role is parent can be the target of the children endpoints below. A single service can't be both roles at once, so a child can never itself be a valid parent target — there is no "grandchild." You don't need to guard against deeper nesting in the UI; the API structurally can't produce it.

A naming detail that applies to every endpoint below​

GET /services (list), GET /services/:id (detail), and GET /services/:id/children all return the service's Arabic/English name as title / titleTranslated, not name_ar / name_en. This isn't new to this feature — it already happens because these three code paths build the JSON response from destructured/spread plain objects rather than the SystemCategory entity instance, which quietly drops the @Expose({ name: 'name_ar' }) renames that ClassSerializerInterceptor would otherwise apply. It's mentioned here because the fields you'll read for this feature (hierarchyRole, isSelected, the candidate list) travel through the same plain-object responses, so don't expect name_ar/name_en to show up on them either — use title / titleTranslated.

Endpoints: GET /api/v1/operator/services (OperatorServicesController_findAll) · GET /api/v1/operator/services/:id (OperatorServicesController_findOne) — in openapi/merchant-v2-operator.json.

2. GET /services/:id/children — candidate children for a parent​

GET /api/v1/operator/services/:id/children
Roles: admin, super_admin only (narrower than GET /services, which also allows
merchant_manager, store_manager, data_entry — those roles get 403 here)

Endpoint: GET /api/v1/operator/services/:id/children (OperatorServicesController_findServiceChildren) — in openapi/merchant-v2-operator.json.

Query parameters​

Only these five are read by this endpoint (it builds its own query, it does not reuse the generic service filters):

ParamTypeNotes
pagenumberDefault 1.
limitnumberDefault 10.
name or searchstringEither name works — both filter on title ILIKE / titleTranslated ILIKE.
statusactive | hiddenExact match.

No ValidationPipe is attached to this route's query. Every other list endpoint in this controller validates and transforms its query params; this one does not. A malformed page/limit won't come back as a 400 — it silently coerces (+value, which produces NaN for garbage input) and you'll get odd pagination instead of an error. Keep the values you send well-formed on the client side.

Selection semantics​

A row is a candidate when its hierarchy_role = 'child' and it is either unassigned (parent_id IS NULL) or already belongs to this parent (parent_id = :id).

A child owned by a different parent does not appear in this list at all — not as a disabled row, not with a "taken" flag, just absent. This is how "one child, one parent" is enforced in the UI: if an admin searches for a service they expect to find and it's missing, the likely reason is it already belongs to someone else. Build the empty/no-match state to say something like "No matching services, or they may already belong to another parent" rather than a bare "not found" — a plain empty result here looks like a bug otherwise.

Response​

{
"data": [
{
"id": 42,
"title": "برجر",
"titleTranslated": "Burgers",
"status": "active",
"logo": "https://cdn.example.com/burgers.png",
"logo_variants": null,
"ordering": 3,
"hierarchyRole": "child",
"isSelected": true
},
{
"id": 57,
"title": "بيتزا",
"titleTranslated": "Pizza",
"status": "active",
"logo": "https://cdn.example.com/pizza.png",
"logo_variants": null,
"ordering": 5,
"hierarchyRole": "child",
"isSelected": false
}
],
"meta": { "totalItems": 2, "page": 1, "itemsPerPage": 10, "totalPages": 1 }
}

isSelected: true means the row's parent_id already equals :id — i.e. it's already attached to this parent. hierarchyRole is always "child" on every row here (that's the filter), included mainly so the same row shape can double as a generic service row elsewhere in the UI. Sorted by ordering ascending. Note the response is the bare { data, meta } object — no message/statusCode envelope.

400 — target is not a parent​

{ "statusCode": 400, "message": "Only a service with hierarchy_role = parent can have child services", "error": "Bad Request" }

Thrown before anything else runs, whenever :id refers to a service whose role is standalone or child. Check the service's own hierarchyRole before offering the "manage children" UI at all — calling this endpoint on a non-parent is a guaranteed 400, not something worth attempting speculatively.

404 — service not found​

{ "statusCode": 404, "message": "Service is not found", "error": "Not Found" }

(Note the exact wording is "Service is not found", present tense — some of this controller's Swagger @ApiResponse descriptions say "was not found," but that's just doc text; the string above is what actually comes back on the wire.)

3. PATCH /services/:id/children — attach / detach children​

PATCH /api/v1/operator/services/:id/children
Roles: admin, super_admin only
Body validated with { transform: true, errorHttpStatusCode: 400, whitelist: true }

Endpoint: PATCH /api/v1/operator/services/:id/children (OperatorServicesController_assignUnassignChildServices) — in openapi/merchant-v2-operator.json.

whitelist: true (no forbidNonWhitelisted) means unknown keys in the body are silently dropped, not rejected — unlike PATCH /services/:id below, which rejects them.

Request​

{
"assignServiceIds": [42, 57], // optional, default []
"unassignServiceIds": [99] // optional, default []
}

Both are arrays of service ids (@IsInt({ each: true }), must be unique within each array). At least one of the two must be non-empty.

Success response​

{ "message": "Child services updated successfully", "statusCode": 200 }

Validation is all-or-nothing​

Every check below runs before the database transaction. If any one of them fails, the call is rejected and nothing is written — not even the valid ids in the same batch. That means the UI can safely send a whole picker's worth of changes (assign a batch, unassign a batch) in a single call without needing to pre-validate each id itself; a bad id anywhere in the batch just means try again with the batch corrected, no partial state to reconcile.

Checks run in this order:

#ConditionStatusExact message
1Both arrays empty400At least one of assignServiceIds or unassignServiceIds must be provided
2Same id appears in both arrays400assignServiceIds and unassignServiceIds must not contain the same service id
3:id itself appears in either array400A service cannot be assigned as a child of itself
4:id does not exist404Service is not found
5:id's hierarchy_role isn't parent400Only a service with hierarchy_role = parent can have child services
6Any id in assignServiceIds doesn't exist, isn't hierarchy_role = child, or already belongs to a different parent400Some services cannot be assigned: they do not exist, are not child services, or already belong to another parent: 5, 9 (the trailing list is the offending ids, comma-separated)

Checks 1–3 run before the parent lookup, so e.g. sending :id's own value inside assignServiceIds against a parent id that doesn't even exist still returns check 3's message, not a 404.

Assigning an id that is already this parent's child is fine (idempotent, not treated as invalid) — only "doesn't exist" / "not a child" / "belongs to someone else" trip check 6.

Unassigning is scoped and silent. unassignServiceIds has no existence/ownership pre-check at all — the write is UPDATE ... WHERE id IN (:ids) AND parent_id = :thisParentId. An id that isn't actually this parent's child (wrong parent, or not a child at all, or doesn't exist) simply matches no row and is quietly ignored — no error, no partial failure. Don't rely on this call to tell you whether an unassign "did anything"; if you need to confirm, re-fetch the children list.

4. Setting the role​

hierarchyRole is a field on POST /services (OperatorCreateServiceDto) and PATCH /services/:id (OperatorUpdateServiceDto, all fields optional via PartialType) — enum parent | standalone | child, optional in both, defaults to parent on create when omitted.

Endpoints: POST /api/v1/operator/services (OperatorServicesController_create) · PATCH /api/v1/operator/services/:id (OperatorServicesController_update) — in openapi/merchant-v2-operator.json.

GET /services (the paginated admin list) accepts an optional hierarchy_role filter. It reads either a repeated query param or a comma-separated list — both work:

GET /services?hierarchy_role=parent&hierarchy_role=child
GET /services?hierarchy_role=parent,child

Omit it to list every role (existing behavior, unchanged). An invalid value in the list gets the default class-validator message: each value in hierarchy_role must be a valid enum value (400). Note: GET /services/all/without-pagination supports the same query-param filter shape, but its response rows do not include hierarchyRole or parentId at all — that variant's column selection wasn't extended for this feature, so don't expect the role to be visible there.

Endpoint: GET /api/v1/operator/services/all/without-pagination (OperatorServicesController_findAllWithPagination) — in openapi/merchant-v2-operator.json.

The consequence to warn about​

Role changes are not refused, they clean up silently, and both directions happen with no confirmation from the backend:

  • parent → anything else: every one of its children is released. Their parent_id is set to null; they keep hierarchy_role = 'child' and sit unassigned, waiting to be picked up by another parent (or the same one again) through the children endpoint. The response to the role-changing PATCH gives no indication this happened — if you flip a parent's role, assume its children list is now empty and refresh any UI showing it.
  • child → anything else: it's detached from its parent (parent_id → null). Nothing else changes.

Both the role write and the detach cleanup happen inside one database transaction, so you won't see a role change "stick" while the detach silently fails, or vice versa — but there is still no dialog on the backend. If you want a confirmation step before an admin flips a parent's role (losing all its children) or a child's role (leaving its parent), that has to live in the UI.

5. What the UI cannot do, by design​

parent_id/parentId is not settable through PATCH /services/:id. The service strips it from the update body unconditionally, before anything else runs (delete body['parentId']), specifically so a client can't self-parent a service, point it at a child (which would produce three levels of nesting), or hand a standalone service a parent that the children picker would never have surfaced. The only way to attach a child to a parent is PATCH /services/:id/children.

One more thing worth knowing, even though it isn't something you need to build against: the entity marks parentId with @Exclude({ toPlainOnly: true }), which is meant to hide it from API responses — but because GET /services and GET /services/:id both serialize through plain objects (see the naming note in §1), that exclusion doesn't actually take effect. If you inspect a raw response, you may see a parentId field on a service (a stringified bigint, e.g. "42", or null) even though it's never valid to send it. Treat it as incidental, not part of the contract — don't build UI logic on its presence, and don't be surprised if a future fix makes it disappear from read responses too.

6. Suggested admin flow: turning an existing service into a child​

  1. Admin opens the service to convert (e.g. "Kids Menu"). Today it's hierarchy_role: parent (the default) or standalone.
  2. Admin sets its role: PATCH /services/:id with { "hierarchyRole": "child" }. If this service used to be a parent with children of its own, those children are released as described in §4 — consider warning before this specific transition if the service currently has children. The moment this call succeeds, the service is hierarchy_role: child and — on the client side, in the other repo — disappears from the client home screen. It is not attached to any parent yet; parent_id is still null.
  3. Admin opens the intended parent's "manage children" screen, which calls GET /services/:parentId/children (optionally filtered by name/status) to list candidates.
  4. The just-converted service should appear in that list, unselected. If it doesn't, the most likely cause is it's already someone else's child — see §2's note on why that shows as "missing," not "disabled."
  5. Admin selects it and confirms. UI calls PATCH /services/:parentId/children with { "assignServiceIds": [id] }. This is the all-or-nothing call from §3 — if the batch includes any invalid id, nothing in the batch is attached.
  6. Re-fetching GET /services/:parentId/children now shows that row with isSelected: true.
  7. To undo, call the same endpoint with { "unassignServiceIds": [id] }. The service stays hierarchy_role: child — it becomes unassigned, not parent again — and waits to be attached elsewhere.

7. Roles​

Both children endpoints (GET and PATCH /services/:id/children) require admin or super_admin. This is narrower than the plain GET /services list, which also allows merchant_manager, store_manager, and data_entry. If your admin panel exposes the hierarchy UI to those other roles, expect a 403 from the children endpoints specifically — either hide the "manage children" action for those roles, or handle the 403 explicitly.

8. Deploy dependency​

The Rails migration adding hierarchy_role (enum, NOT NULL, default 'parent') and parent_id (bigint, nullable) to system_categories must run in an environment before this API is deployed there — without those columns, hierarchyRole/parentId are part of the findAll/findOne/create/ update query and select paths, and the admin services list breaks entirely, not just the new fields.

Dev is already migrated. Staging and production are not yet. Coordinate the migration before this branch reaches either of those environments.

9. One sentence on the client side​

The client app's home-screen listing already stopped surfacing child services and stopped exposing hierarchy_role/parent_id in its own response — that work lives in locate-order-v2-api, is a separate handoff, and is out of scope here.