---
feature: Package management
slug: packages
surface: admin
consumer: react
version: 1.0.0
status: ready
brd_version: "1.3"
brd_ids: [ADMIN-011]
business_rules: [BR-07, BR-29]
decisions: [D1, TD-02, TD-19, TD-87, TD-88, SCR-32]
schema_tables: [packages, package_items, package_occasions, provider_services, customer_packages]
related_docs: [../provider/packages.md, ../client/catalog.md, ../API_CONVENTIONS.md]
last_updated: 2026-10-25
---

# Package management — React Dashboard Integration Guide

> **Base URL:** `{HOST}/api/v1/admin` · Shared rules (headers, envelope, errors,
> pagination, axios setup, TS types): [API_CONVENTIONS.md](../API_CONVENTIONS.md)

## Version history

Newest first. Never edit an old row.

| Version | Date | Type | Summary | Dashboard action required |
|---------|------|------|---------|---------------------------|
| 1.0.0 | 2026-10-25 | Initial | List, details, approve, reject (with a reason), hide and show again provider-created packages. | Add the Packages page and its review actions. |

## Changelog

### 1.0.0 — 2026-10-25
- **Added:** `GET /packages`, `GET /packages/{id}`, `POST /packages/{id}/approve|reject|hide|unhide` (section `packages`); in-app notice `package_reviewed` to the provider.
- **Changed / Fixed / Removed / Deprecated:** —
- **Breaking:** No.
- **Endpoints affected:** the six above (new).
- **Dashboard action required:** add the page; gate `hide` / `unhide` by their `update` cell and `approve` / `reject` by the `approve` cell.
- **BRD / decisions:** ADMIN-011, D1, BR-07, TD-87.

---

## 1. Business summary

Providers build packages — prepaid multi-session bundles at a fixed price (D1) — and
submit them. Joshen reviews each one before customers see it: **approve** publishes,
**reject** needs a reason the provider sees. After approval the provider switches the
package on or off herself; Joshen can additionally **hide** any package without deleting
it (the provider and her review history stay intact) and show it again. A package
is customer-visible only while it is approved, switched on by its provider, not hidden
by Joshen, its provider is eligible and every included service is still enabled
(BR-07, BR-08). Disabling the provider account is done on the providers page.

## 2. Pages and permissions

| Page / component | Purpose | Endpoint(s) | RBAC permission (route name) |
|------------------|---------|-------------|------------------------------|
| PackagesPage | Table with filters | `GET /packages` | `admin.packages.index` |
| PackageDetailsPage | Services, prices, review trail | `GET /packages/{id}` | `admin.packages.show` |
| ApproveDialog | Publish | `POST /packages/{id}/approve` | `admin.packages.approve` |
| RejectDialog | Reject with a reason | `POST /packages/{id}/reject` | `admin.packages.reject` |
| HideToggle | Hide / show again | `POST /packages/{id}/hide`, `POST /packages/{id}/unhide` | `admin.packages.hide`, `admin.packages.unhide` |

Section `packages` (TD-27): `index`/`show` → view; `approve`, `reject` → approve; `hide`, `unhide` → update.
Sidebar entry **Packages**; hide it without the index permission.

## 3. List / table

`GET /api/v1/admin/packages` — list key in `data`: `packages`

| Column | Field | Sortable (`sort[...]`) | Filter (`filters[...]`) | Notes |
|--------|-------|------------------------|-------------------------|-------|
| Package | `name`, `image` | — | `search` (name or provider) | Provider's own text |
| Provider | `provider.display_name`, phone | — | `filters[provider_id]=<provider uuid>` | |
| Category | `category.name` | — | `filters[category_id]=<category uuid>` | |
| Status | `status.label` | — | `filters[status]` ∈ `draft`, `pending_review`, `approved`, `rejected`, `archived` | Send `pending_review` for the review queue |
| Price | `price`, `compare_at_price` | `sort[price]` | — | SAR strings |
| Services | `services_count`, `sessions_count` | — | — | |
| Shown | `is_visible` (provider) · `hidden_by_admin` (Joshen) | — | `filters[is_visible]`, `filters[hidden_by_admin]` | |
| Submitted | `submitted_at` | `sort[submitted_at]` | `filters[submitted_from]`, `filters[submitted_to]` (`YYYY-MM-DD`) | ISO 8601 |
| Created | `created_at` | `sort[created_at]` (default `desc`) | — | |

`paginate=false` returns a plain array capped at 500 rows. Postman: `admin.packages.index` › "200 Packages List" · "422 Packages Unsupported Filter".

```json
{
  "status": "success",
  "message": "Data retrieved successfully",
  "data": {
    "packages": [
      {
        "id": "01a171fb-cd88-7173-a263-ba29b36a8237",
        "name": "Sample-raek20283318",
        "image": "https://ui-avatars.com/api/?name=N&background=f3f4f6&color=9ca3af&size=200",
        "provider": { "id": "01a171fb-cd83-70a8-a098-86e485dd2d57", "display_name": "Ms. Margret Pfannerstill", "phone_code": "966", "phone": "555953709" },
        "category": { "id": "01a171fb-cd86-73bb-86a3-1623d0907fa1", "name": "Sample-ktle91211105" },
        "status": { "value": "draft", "label": "Draft" },
        "is_visible": true,
        "hidden_by_admin": false,
        "price": "300.00",
        "compare_at_price": "400.00",
        "validity_days": 30,
        "services_count": 2,
        "sessions_count": 2,
        "review_note": null,
        "submitted_at": null,
        "reviewed_at": null,
        "created_at": "2026-10-25T07:34:49+00:00"
      }
    ],
    "links": { "first": "…", "last": "…", "prev": null, "next": null },
    "meta": { "current_page": 1, "per_page": 15, "total": 3 }
  }
}
```

## 4. Details view

`GET /api/v1/admin/packages/{id}` — `id` is the package uuid (an integer id is 404). Postman: `admin.packages.show` › "200 Package Details".

```json
{
  "status": "success",
  "message": "Data retrieved successfully",
  "data": {
    "id": "01a171fb-cefc-7013-ab15-66718c4b9f19",
    "name": "Sample-jfny11593421",
    "image": "https://ui-avatars.com/api/?name=N&background=f3f4f6&color=9ca3af&size=200",
    "provider": { "id": "01a171fb-cef3-70d1-b979-11e0bd630abf", "display_name": "Serena Johns PhD", "phone_code": "966", "phone": "564627740" },
    "category": { "id": "01a171fb-cefa-72bd-b4cc-ae4388fe1060", "name": "Sample-jeao35659175" },
    "status": { "value": "approved", "label": "Published" },
    "is_visible": true,
    "hidden_by_admin": false,
    "price": "300.00",
    "compare_at_price": "400.00",
    "validity_days": 30,
    "services_count": 2,
    "sessions_count": 2,
    "review_note": null,
    "submitted_at": null,
    "reviewed_at": "2026-10-25T07:34:50+00:00",
    "created_at": "2026-10-25T07:34:50+00:00",
    "description": null,
    "services": [
      { "id": "01a171fb-cf03-7361-acd3-19d315ab8da5", "service": { "id": "01a171fb-cefe-70a3-be32-532dbeea18e3", "name": "Sample-glrh49165634" }, "sessions": 1, "duration_min": 60, "current_price": "180.00", "is_enabled": true },
      { "id": "01a171fb-cf04-733d-8590-507461a0a2d9", "service": { "id": "01a171fb-ceff-71e5-ad99-c7379f32b538", "name": "Sample-rotr18640919" }, "sessions": 1, "duration_min": 60, "current_price": null, "is_enabled": false }
    ],
    "occasions": [{ "id": "01a171fb-cf04-733d-8590-50746276fea5", "name": "Sample-ijah50824847" }],
    "purchases_count": 2,
    "reviewed_by": { "id": "01a171fb-cef7-73ae-82de-e461e3ec5275", "name": "Virgil Breitenberg III" }
  }
}
```

| Field | Type | Nullable | Description |
|-------|------|----------|-------------|
| `services[].sessions`, `duration_min` | int | no | What the package promises (snapshot at save time) |
| `services[].current_price`, `is_enabled` | money string / bool | yes / no | The provider's price **now** and whether she still enables the service; a `false` here means the package is not sold to customers (BR-07) |
| `compare_at_price` | money string | yes | Total of her service prices × sessions when she saved |
| `purchases_count` | int | no | Customers who bought it; purchased packages keep their own snapshot when Joshen hides or the provider archives it |
| `review_note` | string | yes | The rejection reason |
| `reviewed_by`, `reviewed_at` | object / ISO 8601 | yes | Last approve / reject decision |

## 5. Forms (create / edit)

N/A — packages are created and edited by providers in their app. The admin decides and hides.

## 6. Actions (non-CRUD)

All: `POST /api/v1/admin/packages/{id}/<action>`; success returns the details payload of §4 with the new state (`Data updated successfully`).

| Action | Route / permission | Body | From status | Effect |
|--------|--------------------|------|-------------|--------|
| Approve | `admin.packages.approve` | none (`reason` refused) | `pending_review` | `approved`; clears `review_note`; stamps `reviewed_by` / `reviewed_at` |
| Reject | `admin.packages.reject` | `reason` required, max 500 | `pending_review` | `rejected`; `review_note` = reason; provider can fix and resubmit |
| Hide | `admin.packages.hide` | `reason` optional, max 500 | `draft`, `pending_review`, `approved`, `rejected` | `hidden_by_admin = true`; status and provider switch untouched |
| Show again | `admin.packages.unhide` | none | same | `hidden_by_admin = false` |

An `archived` package is final: hide / show again answer 409. Each decision writes an audit entry and **one in-app notice to the provider** in her language (event `package_reviewed`; hide reasons travel in the notice, not the package row).

Postman: `admin.packages.approve` › "200 Package Approved" · "409 Package Approval In The Wrong Status"; `…reject` › "200 Package Rejected" · "422 Package Rejection Without Reason"; `…hide` › "200 Package Hidden" · "422 Package Hide Reason Too Long"; `…unhide` › "200 Package Shown Again".

```json
{
  "status": "success",
  "message": "Data updated successfully",
  "data": {
    "id": "01a171fb-d17f-70f4-a804-d8bb67eb0fd4",
    "status": { "value": "rejected", "label": "Rejected" },
    "is_visible": true,
    "hidden_by_admin": false,
    "review_note": "Price is not credible",
    "reviewed_at": "2026-10-25T07:34:51+00:00",
    "reviewed_by": { "id": "01a171fb-d174-71db-be7e-bcfd4db2cab3", "name": "Miss Jazlyn Pouros" }
  }
}
```

(Excerpt: the response is the full details payload of §4; only the fields this action changes are shown.)

| HTTP | `error_code` | When | Dashboard behavior |
|------|--------------|------|--------------------|
| 401 | `UNAUTHENTICATED` | Missing or expired token | Refresh, then retry |
| 403 | `PERMISSION_DENIED` | Missing route permission | Hide/disable the action |
| 404 | — | Unknown or integer id | Toast, reload |
| 409 | `INVALID_STATE_TRANSITION` | Approve/reject a package that is not `pending_review`; hide/unhide an archived one | Refetch the package |
| 422 | — | `reason` missing on reject, too long, or sent on approve/unhide → `data.errors.reason` | Mark the field |

```json
{
  "status": "fail",
  "message": "You cannot approve a package that is Archived.",
  "data": { "error_code": "INVALID_STATE_TRANSITION", "correlation_id": "1579bf76-9734-44fa-824e-a41e58b8bbe2" }
}
```

## 7. Statuses and enums

| Enum | `value` | Label (en) | Badge color | Actions allowed here |
|------|---------|------------|-------------|----------------------|
| `status` | `draft` | Draft | grey | hide, show again |
| | `pending_review` | Pending review | amber | approve, reject, hide, show again |
| | `approved` | Published | green | hide, show again |
| | `rejected` | Rejected | red | hide, show again |
| | `archived` | Archived | dark grey | none |

## Data model

| Table (`db-schema.sql`) | Read / write | Notes |
|-------------------------|--------------|-------|
| `packages` | read; write `status`, `review_note`, `reviewed_by`, `reviewed_at`, `hidden_by_admin` | One row per decision, audited |
| `package_items`, `package_occasions` | read | Included services and occasions |
| `provider_services` | read | Her current price and enabled flag per service |
| `customer_packages` | read (count) | `purchases_count` |
| `notifications` | write | One notice per decision |

## 8. TypeScript types

```ts
export type PackageStatus = EnumValue<'draft' | 'pending_review' | 'approved' | 'rejected' | 'archived'>;

export interface PackageRow {
  id: string; // uuid (TD-19)
  name: string; image: string;
  provider: { id: string; display_name: string | null; phone_code: string; phone: string };
  category: { id: string; name: string | null };
  status: PackageStatus;
  is_visible: boolean; hidden_by_admin: boolean;
  price: string; compare_at_price: string | null; // SAR, 2 decimals
  validity_days: 30 | 45 | 60 | 90;
  services_count: number; sessions_count: number;
  review_note: string | null;
  submitted_at: string | null; reviewed_at: string | null; created_at: string; // ISO 8601
}

export interface PackageDetails extends PackageRow {
  description: string | null;
  services: Array<{
    id: string; service: { id: string; name: string | null };
    sessions: number; duration_min: number;
    current_price: string | null; is_enabled: boolean;
  }>;
  occasions: Array<{ id: string; name: string | null }>;
  purchases_count: number;
  reviewed_by: { id: string; name: string | null } | null;
}

export type PackageList = Paginated<'packages', PackageRow>;
```

## 9. Business rules enforced by the backend

| Rule | What the backend does | What the admin sees |
|------|-----------------------|---------------------|
| BR-29 | `rbac.auto` on every route, section `packages` | 403 `PERMISSION_DENIED` |
| D1 | Only `pending_review` packages are approved or rejected; rejection needs a reason | 409 / 422 |
| ADMIN-011 | Hiding never deletes; ownership and review details are kept | `hidden_by_admin` flag |
| BR-07 | A package is sold only while all its services are active and enabled by the provider | `services[].is_enabled` |
| BR-08 | Customer visibility also depends on the provider's coverage and the customer's address | — |

## 10. Notifications triggered

| Event | Who is notified | Channel |
|-------|-----------------|---------|
| `package_reviewed` (approve, reject, hide, unhide) | The provider | Shared in-app inbox, same transaction; push pending ADMIN-020 |

## 11. Integration checklist

- [ ] Review queue = `filters[status]=pending_review`, oldest first (`sort[submitted_at]=asc`)
- [ ] Reject dialog requires a reason; hide dialog takes an optional one
- [ ] Buttons gated by their route permission
- [ ] Show `services[].is_enabled === false` as a warning
- [ ] Every error code of §6 handled; `data.errors.reason` marks the field
- [ ] RTL (`ar`) and `en` verified

## 12. Acceptance scenarios

| AC ID | Scenario | Test |
|-------|----------|------|
| AC-ADMIN-011 | View all provider packages with their provider, services, price | `test_ac_admin_011_list_shows_every_package…`, `…details_show_services_prices_review_trail_and_purchases` |
| AC-ADMIN-011 | Hide a package without deleting it; remove customer visibility, keep ownership and review details | `test_ac_admin_011_hiding_keeps_the_package…`; customer side: `PackageDetailsTest` |
| D1 | Approve / reject lifecycle with a reason and notices | `…approval_publishes_the_package…`, `…rejection_needs_a_reason…`, `…only_a_pending_package_can_be_approved_or_rejected` |
| BR-29 | Section permissions | `…uses_the_packages_permission_section` |

## 13. Open decisions and known limitations

| ID | Impact on this feature | Current behavior |
|----|------------------------|------------------|
| D1 vs BRD ADMIN-011 | The BRD says no per-package approval "according to settings"; D1 requires Joshen's review | D1 wins: every package is reviewed (no setting) |
| TD-87 | Editing an approved package re-enters review | Accepted, see [provider/packages.md](../provider/packages.md) |
| ADMIN-005 / accounts | Disabling a provider for repeated violations | On the providers page (suspend); suspended providers' packages stop being shown (BR-03) |
