# Domain Modeling Guide

How to design and create the **new product's** business models on top of this base codebase.

Read together with: `AGENTS.md` (§ Base Codebase vs New Business Domain) ·
`docs/PROJECT_ARCHITECTURE.md` (the base's real patterns) · `docs/API_WORKFLOW.md` (§ 3–13).

---

## Purpose

This repository is a **base / starter codebase**, not the finished product. It already
contains a working Express 5 + Mongoose 8 + EJS + Socket.IO application with a mature
technical spine: routing classes, validation chains, a response envelope, error handling,
JWT + session auth, file uploads, i18n, notifications, cron, and a dashboard skeleton.

**We keep the spine. We are building a new business domain on top of it.**

That means two different kinds of code live side by side:

| | Base Codebase | New Business Domain |
|---|---|---|
| What it is | The technical framework already in `src/` | Models/modules for the new product |
| Authority | **Source of truth for *how*** | **Source of truth for *what*** |
| Examples | `ApiResponse`, `errorHandler`, `getValidationChain`, route classes, `@returnObj`, passport, upload helpers | `Product`, `ProductVariant`, `Order`, `Auction`, `Wallet`, `Review`, … as defined by the new analysis |
| Rule | Copy the pattern, never the business logic | Design from the analysis, never from an old model |

The old domain models (`harajRequestsModel`, `advertisementModel`, `settlementModel`,
`providerMetaModel`, `otoTokenModel`, …) belong to the **previous** product. They are
reference material for style only. Do not wire a new feature into them.

---

## Before Creating a Model

Answer every question below **in writing**, in the task's Domain Design section, before
touching a file. An unanswered question is a design decision you are about to make by
accident.

| # | Question | Why it matters here |
|---|---|---|
| 1 | What is the entity's name? | Singular PascalCase model, `<entity>Model.js` file — see § Naming |
| 2 | Client-facing, provider-facing, admin-only, or shared? | Decides the auth bucket, the DTO, and whether a dashboard CRUD is needed |
| 3 | Does it need a `status`? | If yes → a frozen enum in `src/helpers/enums/`, never string literals |
| 4 | Does it need soft delete? | Base is **inconsistent** — see § Soft Delete. Pick one and state it |
| 5 | Does it need `timestamps`? | Almost always yes: `{ timestamps: true, versionKey: false }` (64/67 base models do this) |
| 6 | Does it need `createdBy` / `updatedBy`? | Anything an admin edits should carry an actor ref for the audit trail |
| 7 | What are its relations (client / provider / category / order …)? | Decide `ref` targets **and** whether to auto-populate — see § The Populate Trap |
| 8 | Which queries will actually run against it? | Every recurring query filter needs an index. Only 8/67 base models declare any |
| 9 | Any unique fields? | `unique: true` + a partial index if soft-deleted rows must not block reuse |
| 10 | Any sensitive fields? | Base has **zero** `select: false` — see § Sensitive Fields |
| 11 | Any files/images? | Store the filename only; build absolute URLs in `@returnObj` |
| 12 | Any enums? | One frozen enum file per concept, reused by model + validator + DTO |
| 13 | Does it need audit/history? | Prefer a separate `<Entity>StatusHistory` collection over an embedded array |
| 14 | Does it need a human-readable number? | `mongoose-sequence` (`inc_field`), as `Order`/`Product`/`Auction` do in the base |
| 15 | Bilingual text? | `{ type: String, i18n: true }` + `mongoose-i18n-localize` |

---

## What the Base Actually Does (verified, not assumed)

Measured across the 67 models in `src/models/`:

| Convention | Reality | What new models should do |
|---|---|---|
| File name | `<entity>Model.js`, flat directory, camelCase | **Follow it** |
| Schema style | `class XSchema extends Schema` (58/67) | **Follow it** |
| Timestamps | `{ timestamps: true, versionKey: false }` (64/67) | **Follow it — always** |
| Enums | 38 frozen enum files, `Object.values(Enum)` in the schema | **Follow it** |
| Auto-increment | `mongoose-sequence` in 6 models (`Order`, `Product`, `Auction`, `Complaint`, `Settlement`, `FinancialTransaction`) | Follow when a human-facing reference number is required |
| Bilingual fields | `{ i18n: true }` + `mongoose-i18n-localize` in 8 models | Follow for user-visible text |
| Indexes | **only 8/67 models declare any** | **Do better — every new model declares its indexes** |
| Soft delete | **inconsistent**: `isDeleted` boolean (6 models) vs `status: 'delete'` enum (user models). No `deletedAt` anywhere | **Pick one convention for the new domain and apply it uniformly** |
| Sensitive fields | **zero `select: false`, zero `toJSON` transforms** — hidden only by the `@returnObj` DTO layer | **Add `select: false` as well** — defense in depth |
| Auto-populate | `pre(/^find/)` populate in **31/67** models | **Avoid** — see below |

### ⚠️ The Populate Trap

31 of 67 base models auto-populate inside `pre(/^find/)`:

```js
this.pre(/^find/, function (next) {
  this.populate([{ path: 'provider', model: 'Provider' }, { path: 'region', model: 'City' }]);
  next();
});
```

This fires on **every** query — including `countDocuments`, list endpoints, and internal
lookups that need nothing but an `_id`. It cannot be turned off at the call site, it
compounds across nested refs, and it is a primary reason list endpoints in the base are slow.

**New models must not do this.** Populate explicitly, in the service layer, where you know
what the caller needs:

```js
// src/helpers/api/Product.js
const product = await Product.findById(id)
  .populate({ path: 'category', select: 'name slug' })
  .lean();
```

If you genuinely need a default population, document why in the model file.

---

## Naming

| Thing | Convention | Example |
|---|---|---|
| Model file | `src/models/<entity>Model.js` (camelCase, singular) | `productVariantModel.js` |
| Mongoose model name | PascalCase singular | `model('ProductVariant', …)` |
| Schema class | `<Entity>Schema extends Schema` | `class ProductVariantSchema extends Schema` |
| Enum file | `src/helpers/enums/<concept>.enum.js` | `order.status.enum.js` |
| Enum export | `Object.freeze({ KEY: 'value' })`, SCREAMING keys, lowercase values | `Object.freeze({ PENDING: 'pending' })` |
| Route class | `src/routes/api/<Domain>Route/<Domain>Route.js` | `ProductVariantRoute.js` |
| Business logic | `src/helpers/api/<Domain>.js` | `ProductVariant.js` |
| Validator | `src/utils/validations/api/<domain>.js` | `productVariant.js` |
| DTO | `exports.<entity>` in `@returnObj` | `exports.productVariant` |

Never abbreviate (`prodVar`), never pluralize the model name (`Products`), never mix
snake_case into field names.

---

## Mongoose Model Standard

Every new model must include, as applicable:

1. **Clear schema** — explicit types, explicit `required`, explicit `default`. No `Mixed`
   unless genuinely schemaless, and then document why.
2. **Timestamps** — `super(options, { timestamps: true, versionKey: false })`.
3. **Indexes** — declare every index the real query patterns need. At minimum: each `ref`
   used as a filter, each `status` used as a filter, and any compound
   `{ owner: 1, status: 1, createdAt: -1 }` that backs a list endpoint.
4. **Enums/constants** — from `src/helpers/enums/`, referenced via `Object.values(Enum)`
   with an explicit `default`.
5. **Soft delete** — the single convention chosen for the new domain (below).
6. **Safe fields** — `select: false` on anything that must never travel by accident.
7. **No sensitive data in responses** — the DTO in `@returnObj` is the contract; the model
   is the second line of defense, not the only one.
8. **No auto-populate** in `pre(/^find/)`.

### Recommended soft-delete convention for the new domain

Because the base is inconsistent, standardize once and apply it to every new model:

```js
status:    { type: String, enum: Object.values(XStatusEnum), default: XStatusEnum.ACTIVE, index: true },
deletedAt: { type: Date, default: null, select: false },
```

- Entities with a real lifecycle carry that lifecycle in `status`; `deletedAt` records the
  soft-delete moment.
- Every query in the service layer filters `{ deletedAt: null }` (or a scoped helper).
- `unique` fields on soft-deletable entities need a **partial index** so a deleted row does
  not permanently reserve a value:
  ```js
  this.index({ sku: 1 }, { unique: true, partialFilterExpression: { deletedAt: null } });
  ```
- Whichever convention the team picks, **write it down here and never mix the two.**

### Skeleton

```js
// src/models/<entity>Model.js
const { Schema, model } = require('mongoose');
const XStatusEnum       = require('@root/helpers/enums/x.status.enum');

class XSchema extends Schema {
  constructor() {
    const options = {
      name:      { type: String, required: true, trim: true },
      owner:     { type: Schema.Types.ObjectId, ref: 'Provider', required: true },
      status:    { type: String, enum: Object.values(XStatusEnum), default: XStatusEnum.ACTIVE },
      deletedAt: { type: Date, default: null, select: false },
    };

    super(options, { timestamps: true, versionKey: false });

    // Declare the indexes the real queries need — do not skip this step.
    this.index({ owner: 1, status: 1, createdAt: -1 });
    this.index({ deletedAt: 1 });
  }
}

module.exports = model('X', new XSchema());
```

---

## Suggested Core Business Models

A **starting** list from the analysis — adjust it as the analysis evolves. Nothing here is
implemented yet; treat every entry as "design when the task requires it."

| Model | Purpose | Key relations |
|---|---|---|
| `Client` | Buyer account | — |
| `Provider` | Seller/store account | `City` |
| `Category` | Product taxonomy (self-nesting) | `parent → Category` |
| `Product` | Sellable item (the catalogue entry) | `Provider`, `Category` |
| `ProductAttribute` | Attribute definition (Color, Size) + allowed values | `Category` (optional scope) |
| `ProductVariant` | A concrete purchasable combination + its own SKU/price/stock | `Product`, attribute values |
| `Cart` / `CartItem` | Pre-order basket | `Client`, `ProductVariant` |
| `Order` | Placed order + totals + payment state | `Client`, `Provider`, variants |
| `OrderStatusHistory` | Append-only status transitions (who, when, from→to) | `Order`, actor |
| `Auction` | Auction event over a product | `Provider`, `Product` |
| `AuctionBid` | A single bid | `Auction`, `Client` |
| `Wallet` | Per-user balance | `Client` \| `Provider` |
| `WalletTransaction` | Append-only ledger entry (credit/debit + reason + ref) | `Wallet`, polymorphic ref |
| `Notification` | Per-user notification record | user (polymorphic), entity ref |
| `Review` | Rating + comment on a product/provider/order | `Client`, target |
| `Favorite` | Wishlist entry | `Client`, target |
| `City` | Geography | `Country` (if needed) |
| `Settings` | Singleton app configuration | — |
| `CmsPage` | Static content (about, terms, privacy) | — |
| `Complaint` | User complaint + admin reply | user, target |
| `ContactMessage` | Contact-form submission | — |

Design notes worth deciding early:

- **Wallet balance** — the balance must be derivable from `WalletTransaction`. Never let a
  cached `balance` field be the only truth; reconcile against the ledger.
- **Order snapshots** — an order line must store the price/name/variant **at purchase time**.
  Never render an old order from the live product record.
- **`ProductVariant` is the stock/price unit**, not `Product`. Decide this before writing
  either model; retrofitting it later touches cart, order, and auction.
- **`OrderStatusHistory` as a separate collection**, not an embedded array — embedded
  arrays grow unbounded and make querying "all orders that entered state X last week" hard.

---

## Model Creation Workflow

1. **Read the analysis** for this entity (`.cursor/analysis/`, the epic spec, or whatever
   the human provides). If there is no analysis, ask — do not invent the domain.
2. **Extract the fields** — name, type, required, default, constraints.
3. **Define the relationships** — direction, `ref` target, and whether the reverse lookup
   needs an index.
4. **Define the statuses** — create/extend a frozen enum in `src/helpers/enums/`.
5. **Mark required vs optional** — and what `default` an optional field takes.
6. **Define the indexes** — from the actual query patterns of the endpoints you are about
   to build, not from guesswork.
7. **Create the schema** — following § Mongoose Model Standard.
8. **Create the safe DTO** — `exports.<entity>` in `@returnObj`: map `_id → id`, build
   absolute file URLs, localize `*Text` fields, and omit everything sensitive.
9. **Wire the API** — route class → validator → thin controller → `src/helpers/api/<Domain>.js`,
   exactly as `docs/API_WORKFLOW.md` §§ 4–9 describe.
10. **Document it** — Swagger/OpenAPI for the endpoints this task touched only
    (`docs/SWAGGER_GUIDE.md`).

Steps 1–6 belong in the task's **Domain Design** section and must be reviewed before step 7.

---

## Example — `ProductAttribute` + `ProductVariant`

The classic e-commerce pair: attributes define the axes, variants are the concrete
purchasable points. Shown here as a design reference, **not** as code to paste in.

### Enums

```js
// src/helpers/enums/attribute.type.enum.js
module.exports = Object.freeze({
  SELECT: 'select',   // Color, Size — a fixed value list
  NUMBER: 'number',   // Weight, Length
  BOOLEAN: 'boolean', // Waterproof
});

// src/helpers/enums/variant.status.enum.js
module.exports = Object.freeze({
  ACTIVE:       'active',
  OUT_OF_STOCK: 'out_of_stock',
  INACTIVE:     'inactive',
});
```

### `ProductAttribute` — the definition

```js
// src/models/productAttributeModel.js
const { Schema, model } = require('mongoose');
const AttributeTypeEnum = require('@root/helpers/enums/attribute.type.enum');

class ProductAttributeSchema extends Schema {
  constructor() {
    const options = {
      name:  { type: String, i18n: true, required: true },   // "اللون" / "Color"
      slug:  { type: String, required: true, trim: true, lowercase: true },
      type:  { type: String, enum: Object.values(AttributeTypeEnum), default: AttributeTypeEnum.SELECT },

      // Allowed values for SELECT attributes. Each carries a stable _id that variants reference.
      values: [{
        name:  { type: String, i18n: true, required: true },  // "أحمر" / "Red"
        slug:  { type: String, required: true, trim: true, lowercase: true },
        order: { type: Number, default: 0 },
      }],

      categories:  [{ type: Schema.Types.ObjectId, ref: 'Category' }], // scope; empty = global
      isFilterable: { type: Boolean, default: true },                  // show in listing filters
      isRequired:   { type: Boolean, default: false },                 // must every variant set it?
      order:        { type: Number, default: 0 },
      deletedAt:    { type: Date, default: null, select: false },
    };

    super(options, { timestamps: true, versionKey: false });

    this.index({ slug: 1 }, { unique: true, partialFilterExpression: { deletedAt: null } });
    this.index({ categories: 1, isFilterable: 1 });
  }
}

module.exports = model('ProductAttribute', new ProductAttributeSchema());
```

### `ProductVariant` — the purchasable unit

```js
// src/models/productVariantModel.js
const { Schema, model }  = require('mongoose');
const VariantStatusEnum  = require('@root/helpers/enums/variant.status.enum');

class ProductVariantSchema extends Schema {
  constructor() {
    const options = {
      product: { type: Schema.Types.ObjectId, ref: 'Product', required: true },
      sku:     { type: String, required: true, trim: true, uppercase: true },

      // The combination this variant represents: Color=Red, Size=L
      attributes: [{
        attribute: { type: Schema.Types.ObjectId, ref: 'ProductAttribute', required: true },
        valueId:   { type: Schema.Types.ObjectId, required: true }, // _id inside attribute.values
        valueSlug: { type: String, required: true },                // denormalized for fast filtering
      }],

      price:         { type: Number, required: true, min: 0 },
      compareAtPrice:{ type: Number, min: 0 },                      // "was" price, optional
      stock:         { type: Number, default: 0, min: 0 },
      images:        { type: [String], default: [] },               // filenames only — URLs built in @returnObj
      weight:        { type: Number, min: 0 },
      isDefault:     { type: Boolean, default: false },             // exactly one per product

      status:    { type: String, enum: Object.values(VariantStatusEnum), default: VariantStatusEnum.ACTIVE },
      deletedAt: { type: Date, default: null, select: false },
    };

    super(options, { timestamps: true, versionKey: false });

    this.index({ sku: 1 }, { unique: true, partialFilterExpression: { deletedAt: null } });
    this.index({ product: 1, status: 1 });
    this.index({ product: 1, 'attributes.valueSlug': 1 });          // variant lookup by chosen combination
    // NOTE: no pre(/^find/) auto-populate — the service layer populates what it needs.
  }
}

module.exports = model('ProductVariant', new ProductVariantSchema());
```

### Safe DTO

```js
// src/helpers/returnObject/returnObject.js
exports.productVariant = async (variant, lang = 'ar') => ({
  id:             variant._id,
  sku:            variant.sku,
  price:          variant.price,
  compareAtPrice: variant.compareAtPrice || null,
  inStock:        variant.stock > 0,          // expose availability, not the exact stock count
  status:         variant.status,
  statusText:     i18n.__({ phrase: `variant.${variant.status}`, locale: lang }),
  images:         (variant.images || []).map((img) => exports.filePath(`${PRODUCTS}/${variant.product}/${img}`)),
  attributes:     (variant.attributes || []).map((a) => ({
                    id:    a.attribute?._id || a.attribute,
                    name:  a.attribute?.name,
                    value: a.valueSlug,
                  })),
  isDefault:      variant.isDefault,
});
```

Note what the DTO deliberately omits: the raw `stock` number (competitors read it),
`deletedAt`, `__v`, and internal `valueId` references.

### Design decisions this example encodes

- **Variant, not product, owns price and stock** — so the cart, order line, and auction all
  reference a `ProductVariant`.
- **`valueSlug` is denormalized** onto the variant so filtering a listing by
  `color=red&size=l` is one indexed query instead of a join.
- **Partial unique index on `sku`** so a soft-deleted variant does not permanently burn its SKU.
- **An order line must snapshot** `sku`, `name`, `price`, and the attribute pairs at purchase
  time — never re-read them from the live variant.

---

## Definition of Done for a new model

- [ ] The Domain Design section (`docs/TASK_TEMPLATE.md`) was filled and reviewed first
- [ ] Naming matches § Naming exactly
- [ ] `{ timestamps: true, versionKey: false }`
- [ ] Every status value comes from a frozen enum in `src/helpers/enums/`
- [ ] Indexes declared for every real query filter (not zero, not guesses)
- [ ] Unique constraints use a partial index if the entity is soft-deletable
- [ ] Soft-delete convention applied consistently with the rest of the new domain
- [ ] `select: false` on anything sensitive; no sensitive field in the DTO
- [ ] **No `pre(/^find/)` auto-populate**
- [ ] A safe DTO exists in `@returnObj` and is what the API actually returns
- [ ] No old-domain model was reused just because it existed
- [ ] Swagger updated for the touched endpoints only
