# 03 — Catalog, Search & SEO

> Snapshot of `origin/dev` on 2026-09-29 (branch `docs/codebase-map-2026-09-29`).
> Every `file:line` below was read while writing this page. Claims marked **(unverified)** were reasoned from code but not executed or checked against the live DB.
> Read `CLAUDE.md` first: the price contract (RAW / BASE / SYP), hidden categories and the partner-store notes there are not repeated in full here.

---

## 1. Purpose & scope

This domain covers everything that puts a product in front of a shopper and gets it found:

- **Product lifecycle**: create / edit / duplicate / delete / clone-or-move by admin (web), seller (web), seller (mobile API), bulk Excel import, the unauthenticated-by-session "AI" endpoint `POST /api/v2/product/add/ai`, and partner-catalog sync.
- **Variants & stock**: `product_stocks` rows generated from colors × attribute choices; seller inventory quick-edit.
- **Taxonomy**: categories (tree, hidden flag, `product_categories` pivot, bulk re-filing), brands, attributes/values, colors, size charts / measurement points.
- **Media**: the `uploads` table, `uploaded_asset()`, the AIZ uploader and its copies (web + 5 API copies).
- **Discovery**: web search, mobile search, suggestions, landing, contextual filters, search tracking and the two scheduled aggregation commands.
- **Merchandising**: home sections (Today's Deal, Trending/featured, Best Selling, home categories, newest).
- **Engagement**: reviews, wishlist, compare.
- **SEO / feeds**: product meta tags, `/sitemap.xml`, `/sitemap-products*.xml`, the admin sitemap generator, `/export/product` (Google/Facebook XML feed), `llms.txt`, `robots.txt`.
- **i18n of product fields**: `product_translations`, `category_translations`, `brand_translations`.

Out of scope (other pages): cart/checkout price computation, order stock decrement (only referenced), fake discounts internals (`FakeDiscountService`, see CLAUDE.md), auctions/wholesale/preorder/digital specifics.

---

## 2. Key files

| Path | Role | Notable functions (line) |
|---|---|---|
| `app/Http/Controllers/ProductController.php` | Admin product CRUD + toggles | `__construct` permissions 58-76; `store` 389; `update` 503; `destroy` 566; `bulk_action` 621; `duplicate` 738; `transfer` 781; `updateTodaysDeal` 834; `updatePublished` 843; `updateProductApproval` 870; `updateFeatured` 899; `sku_combination` 910 |
| `app/Http/Controllers/Seller/ProductController.php` | Seller web product CRUD | `index` (product list with the admin list's filter modal: name/SKU search, category = main or pivot among the seller's own categories, publishing, stock — "sold out" = no variant in stock —, approval when `product_approve_by_admin`, date added, sort by `LIST_SORTS`, price = `COALESCE(seller_price, unit_price)`, no profit sort); `store` 98; `update` 180 (ownership check 184); `categoryIdsWithAncestors` 247; `duplicate` 380; `destroy` 435 |
| `app/Http/Controllers/Seller/InventoryController.php` | Seller stock quick-edit | `index` 18; `updateStock` 64 |
| `app/Http/Controllers/Api/V2/ProductController.php` | Mobile catalog read API + AI create | `store` (AI) 77; `categoryProducts` 333; `todaysDeal` 363; `flashDeal` 369; `bestSeller` 395; `search` 416 |
| `app/Http/Controllers/Api/V2/Seller/ProductController.php` | Seller mobile product CRUD | `store` ~100; `update` 163 (no ownership check); `change_status` 213; `duplicate` 254; `destroy` 290 |
| `app/Services/ProductService.php` | Shared product row writer | `store` 18; `saveImageFromUrl` 178; `store1` (AI) 202; `update` 377; `product_duplicate_store` 525; `destroy` 535; `product_search` 551; `setCategoryWiseDiscount` 587 |
| `app/Services/ProductStockService.php` | Variant/stock generation | `store` 11; `product_duplicate_store` 51 |
| `app/Services/ProductTransferService.php` | Clone / move product to another owner | `resolveTarget` 40; `clone` 57; `move` 131; `cloneUploadId` 224 |
| `app/Services/ProductCategoryBulkService.php` | Bulk category change | `query` 88; `pivotProductIds` 147; `apply` 190 (cache forget 204); `applyEach` 219 (one destination per product, replace mode, one transaction + one cache forget) |
| `app/Services/ProductCategoryExcel.php` | Bulk category change by Excel: export the ticked products (`exportRows`, sheet 2 = every category with id and path), upload the edited file → `plan` (writes nothing; rows `change` / `unchanged` / `error`, kept in the cache 2 h for the uploader) → preview page → `apply` (`applyEach`, every destination checked again). File layout in its docblock: A product id, C main category (name or id), D sub-categories separated by ` \| ` as id / name / path `A > B`. Routes `products.bulk_categories.export|import|import.preview|import.apply`; views `backend/product/bulk_category/index` + `import_preview`; exports `app/Exports/ProductCategoriesExport.php` + `ProductCategories/*`. Pinned by `tests/Feature/ProductCategoryExcelTest.php` | `cells` 114; `plan` 158; `resolve` 257 |
| `app/Http/Controllers/Admin/ProductCategoryBulkController.php` | UI for the above | `index` 30; `update` 66 |
| `app/Models/ProductsImport.php` | Excel bulk import (admin + seller) | `collection` 28; `downloadThumbnail` 440 |
| `app/Http/Controllers/ProductBulkUploadController.php`, `Seller/ProductBulkUploadController.php` | Import entry points | `bulk_upload` 75 / 44 |
| `app/Http/Controllers/CategoryController.php` | Admin categories | `store` 95; `update` 184; `destroy` 257; `updateHidden` 293 |
| `app/Http/Controllers/BrandController.php` | Admin brands | `store` 62; `update` 118; `updateOrdering` 189 (cache forget 216-218) |
| `app/Http/Controllers/AttributeController.php`, `SizeChartController.php`, `MeasurementPointsController.php` | Attributes/colors/size charts | permissions in constructors |
| `app/Http/Controllers/HomeSectionProductController.php` | Today's Deal / Trending order | `store` 109; `reorder` 134; `forgetCaches` 186 |
| `app/Http/Controllers/BestSellingProductController.php` | Best Selling picks | `store` 82; `reorder` 106; `clear` 141 |
| `app/Services/CustomHomeSections.php`, `app/Http/Controllers/CustomHomeSectionController.php`, `Api/V2/CustomHomeSectionController.php` | Custom home sections (named by the admin) | map in the service docblock |
| `app/Http/Controllers/HomeController.php` | Storefront home, product page, sitemaps, feed | `productsIndex` 148; `productsXml` 166; `get_product_xml` 192; `get_data_ai` 546; `index` 568; `load_*_section` 580-660; `product` 1562; `todays_deal` 2139 |
| `app/Http/Controllers/SearchController.php` | Web search / category / brand listing | `landing_box` 25; `trackSubmittedEvent` 46; `index` 67; `listingByCategory` 326; `descendantCategoryIds` 340; `listingByBrand` 371; `ajax_search` 381; `store` 484 |
| `app/Utility/SearchUtility.php` | Arabic folding, FULLTEXT query, legacy search log | `ARABIC_FOLDING` 20; `normalizeArabic` 31; `likeNormalized` 63; `fulltextBooleanQuery` 83; `brandIdsWithProducts` 111; `store` 122 |
| `app/Utility/FilterOptionsUtility.php` | Contextual facets from a result query | `fromQuery` 27 |
| `app/Http/Controllers/Api/V2/SearchLandingController.php` | `/search/landing` (+ reused by web) | `index` 19; `trendingSearches` 83; `trendingCategories` 113; `trendingBrands` 147; `mostSearchedProducts` 181 |
| `app/Http/Controllers/Api/V2/SearchSuggestionController.php` | `/search/suggestions`, legacy `/get-search-suggestions` | `suggestions` 20; `keywordSuggestions` 77; `getList` 187 |
| `app/Http/Controllers/Api/V2/SearchTrackingController.php` + `app/Http/Requests/Api/TrackSearchEventsRequest.php` | `/search/track` ingestion | `store` 12 |
| `app/Http/Controllers/Api/V2/FilterController.php` | `/filter/categories`, `/filter/brands`, `/filter/options` | `options` 47; `matchingQuery` 74 |
| `app/Console/Commands/AggregateSearchEvents.php` | `search:aggregate-events` (every 10 min) | `handle`, `aggregateQueries`, `aggregateTargetClicks`, `backfillFromLegacySearches` |
| `app/Console/Commands/RecomputeSearchScores.php` | `search:recompute-scores` (hourly) | `recomputeQueryScores`, `recomputeTargetScores` |
| `app/Models/SearchQuery.php` | Aggregated queries | `normalizeQuery` 21 |
| `app/Services/PartnerCatalog/*` | Partner sync (see CLAUDE.md) | `PartnerCatalogWriter::importOne` 123, `productFields` 170 (the one product field list), `linkBatch` 260 / `linkOne` 282, `applyOne` 454, `shouldPublish` 424, `categoryPlacement` 554, `syncStocks` 574, `storeOptions` 694, `createUpload` 785; `PartnerCatalogVariants::plan` 50; `PartnerCatalogMapping::categoryFor` 67, `unmapped` 98, `matchAttribute` 125; `GenericJsonDriver::fetchProductsPage` 44, `normalizeProduct` 89; `PartnerCatalogPresets::PRESETS/FIELDS/config()`; `PartnerCatalogFetcher::preview` 245; `PartnerCatalogSync::syncAll` 72, `clearStoreCaches` 125; `PartnerCatalogMatcher::suggest` 46, `searchProducts` / `searchRows`, `similarity` |
| `app/Http/Controllers/Admin/PartnerStoreController.php` | Partner admin UI (JSON batch loop, settings + Test connection preview) | `testConnection` 189 / `flashPreview` 200; `fetchStart` 229; `fetchPage` 242; `batch` 263; `bulkProducts` 367; `linkProducts` 442; `validated` 506 |
| `app/Console/Commands/SyncPartnerStores.php` | `partners:sync` (daily 04:00) | `handle` 26 |
| `app/Http/Controllers/AizUploadController.php` | Web uploader (admin, seller, storefront) | `upload` 73; `get_uploaded_files` 277; `destroy` 312; `get_preview_files` 350; `all_file` 367; `attachment_download` 394 |
| `app/Http/Controllers/Api/V2/AizUploadController.php`, `CustomerFileUploadController.php`, `Seller/SellerFileUploadController.php` | Multipart upload copies | `upload` 53 / 57 / 57 |
| `app/Http/Controllers/Api/V2/FileController.php`, `FileUploadController.php`, `ProfileController.php` (199, 313), `ReviewController.php` (~74-176) | base64 / review image upload copies | `imageUpload` 23 |
| `app/Http/Controllers/ReviewController.php`, `Api/V2/ReviewController.php` | Reviews | web `store` 107, `updatePublished` 259; API `submit` 29 |
| `app/Http/Controllers/WishlistController.php`, `Api/V2/WishlistController.php`, `CompareController.php` | Wishlist / compare | web `remove` 53; API `add` 18 |
| `app/Http/Controllers/AdminController.php` | Admin sitemap generator | `SitemapGenerator` 486; `DoSitemapGenerate` 504 |
| `app/Http/Helpers.php` | Global catalog helpers | `filter_products` 174; `get_cached_products` 197; `hidden_category_ids` 226; `forget_category_caches` 277; `verified_sellers_id` 288; `product_price_to_syp` 570; `uploaded_asset` 2621; `my_asset` 2813; `normalize_upload_url` 2896; `get_featured_products` 3495; `home_section_products_query` 3529; `best_selling_products_query` 3585; `get_best_selling_products` 3615; `get_best_sellers` 3944; `get_ranked_brands` 3960; `product_stock_image_ids` 4295; `search_placeholder_keywords` 4969; `unique_product_slug` 5800; `category_ids_with_ancestors` 5831; `sanitize_partner_html` 5855; `strip_4byte_chars` 5902 |
| `app/Models/Product.php` | Product model | `$auditExclude` 17; `$guarded` 20; `$with` 22; `booted` (deleted hook: leaves the home sections); `getTodaysDealAttribute` / `getFeaturedAttribute` (0/1 from `HomeSectionPicks`, setters drop writes); `getTranslation` 53; `scopeWithCurrentStock` 131; `scopeIsApprovedPublished` 171 |
| `app/Models/Category.php` | Category model | `booted` 15 (forget caches); `scopeVisible` 31; `getVisibleChildrenAttribute` 48; `products` 71 |
| `resources/views/frontend/product_details.blade.php` | Product page + SEO meta | meta sections 3-58 |
| `resources/views/frontend/product_listing.blade.php`, `frontend/partials/search_content.blade.php`, `frontend/partials/search_landing_box.blade.php` | Search result / dropdown views | — |
| `resources/views/frontend/*/partials/home_categories_section.blade.php` (5 themes) | Home category rows via `get_cached_products()` | — |
| `Api/V2/CategoryController@homeProducts` (`v2/categories/home/products`) | The same rows for the app, from `home_category_sections()` (admin order, hidden and empty rows dropped, each with `get_cached_products()`); contract `docs/api/home-category-products.md`. `categories/home` keeps its old unordered list | `HomeCategoryPickedProductsTest` |
| `SitemapController@llms`, `public/robots.txt` | AI/crawler hints: `/llms.txt` (generated) and robots | — |

---

## 3. Data model

### Core tables

| Table | Key columns | Notes |
|---|---|---|
| `products` | `id`, `name`, `slug`, `user_id`, `added_by` (`admin`/`seller`), `category_id` (main), `brand_id`, `unit_price` (RAW), `purchase_price`, `exchange_type` (`kapitano` default / `exchange` / `market`), `price_currency` (the currency the RAW prices are in, NULL = USD; CLAUDE.md "Seller price currency"), `real_price` (fake discounts), `discount`, `discount_type`, `discount_start_date`/`_end_date` (unix ts), `thumbnail_img` (upload id), `photos` (comma list of upload ids — sometimes JSON, see `product_photo_ids()`), `meta_title`, `meta_description`, `meta_img`, `tags` (comma list), `colors` (JSON of hex codes), `attributes` (JSON of attribute ids), `choice_options` (JSON `[{attribute_id, values[]}]`), `variant_product`, `current_stock` (**stale**, see below), `published`, `approved` (`featured` / `todays_deal` and their positions were dropped 2026-10-24: `home_section_product_picks`), `num_of_sale`, `rating`, `search_click_count`, `search_score`, `digital`, `auction_product`, `wholesale_product` | `utf8mb3` (4-byte chars fail, CLAUDE.md). FULLTEXT `products_fulltext_search_index` on `(name, tags)` (migration `2026_08_24_000006`). Audited (owen-it) except counters/HTML (`Product.php:17`). |
| `product_stocks` | `product_id`, `variant` (e.g. `Red-XL`, `''` for simple), `sku`, `price` (RAW), `real_price`, `qty`, `image`, `images` (comma list, added `2026_09_10_000006`) | Index on `product_id` added by `2026_08_24_000005`. **Updated in place on every admin/seller/app edit** (`ProductStockService::store()` matches rows by `variant`, since 2026-10-29) → ids stay; new variants inserted, removed ones deleted; `real_price` cleared only when that variant's price changed (FakeDiscountService recovers). Before: `stocks()->delete()` + insert. |
| `product_categories` | `product_id`, `category_id` | Pivot. Indexed only since `2026_10_12_000001`; query it with `whereIn` (`product_ids_in_categories_query()`), never `whereHas('categories')`. Category pages list this pivot, not `products.category_id`. |
| `product_translations` | `product_id`, `lang`, `name`, `unit`, `description` | `product_id` indexed (`2026_08_24_000005`). `Product::getTranslation()` falls back to the base column. Base `products.name/unit/description` hold the default-language text only. |
| `categories` / `category_translations` | `parent_id`, `level`, `order_level`, `slug`, `featured`, `is_hidden` (`2026_09_26_000004`), `commision_rate`, `digital`, `banner`, `icon`, `cover_image`, `search_click_count`, `search_score` | `Category::$with = ['category_translations']`. |
| `brands` / `brand_translations` | `slug`, `logo`, `top`, `order_level` (`2026_09_09_000005`), `search_click_count`, `search_score` | |
| `attributes` / `attribute_translations` / `attribute_values` / `attribute_category` | attribute definitions and which categories expose them as filters | `AttributeCategory` drives web listing filters (`SearchController.php:203`). |
| `colors` | `name`, `code` | products store the **code** in `products.colors`, variant texts the **name** (first value). Since migration `2026_11_03_000002` the CSS names (PapayaWhip, MediumSpringGreen…) are reduced to a small palette of clear names (Arabic in `translations.json`); a colour that shares a product + size with its family keeps a shade name (SoftBlue, DeepNavy…). It rewrote only products with colours (codes, `product_stocks` / carts / order lines / alerts / zeroings / warehouse variant texts, `pricing_manual_prices` keys), keeps one code per name (`Color::where('code')` turns codes back into names), backups `color_palette_*`, `down()` restores. Pinned by `ReduceColorPaletteMigrationTest`. |
| `size_charts` / `size_chart_details` / `measurement_points` | per-category size chart | `Category::sizeChart()` (`Category.php:109`). |
| `uploads` | `file_original_name`, `file_name` (relative to `public/`), `external_link`, `user_id`, `extension`, `type`, `file_size`, `deleted_at` | SoftDeletes. Referenced **by id** from products, stocks, categories, brands, reviews, settings. `external_link` wins over `file_name` in `uploaded_asset()`. |
| `reviews` | `product_id`, `user_id`, `rating`, `comment`, `photos`, `status`, `type` (`custom`), `custom_reviewer_name`, `created_at_is_custom` | `products.rating` recomputed on each write. |
| `wishlists` | `user_id`, `product_id` | |
| `best_selling_products` | `product_id`, `position` | `2026_09_16_000004`. |
| `newest_product_picks` | `product_id` (unique), `position` | `2026_10_17_000004`. Homepage New Products picks. |
| `category_product_positions` | `category_id`, `product_id`, `position` (unique category+product) | `2026_10_17_000003`. Products shown first on a category page; `App\Services\CategoryProductOrder`. Not on `product_categories` because product saves re-insert the pivot. |
| `searches` (legacy) | `query`, `count` | Still written by web and API search. |
| `search_events` | `event_type`, `query` (normalized), `user_id`, `temp_user_id`, `target_id`, `locale`, `platform`, `processed`, `created_at` | Indexes `(event_type, created_at)`, `query`, `(processed, id)`. **Never pruned.** |
| `search_queries` | `query`, `locale`, `search_count`, `click_count`, `score`, `is_curated`, `is_blocked`, `last_searched_at` | unique `(query, locale)`. |
| `partner_sources`, `partner_products`, `partner_categories`, `partner_brands`, `partner_sync_runs` | partner staging (utf8mb4) | see CLAUDE.md. `partner_products.product_id` links the product, `stock_links` (JSON variant key -> `product_stocks.id`, else `product_stock_id` for rows from before variants) its stock rows; `category_slugs`/`variants`/`stock` are the staged lists. `partner_sources.driver` = preset, `config` = JSON overrides, `syp_unit` (new / old pounds, M 11-03, replaced `exchange_rate`), `stock_mode` (M 10-03). |
| `frequently_bought_products`, `product_taxes`, `flash_deal_products`, `discount_products`, `last_viewed_products` | satellites deleted with the product | |

### Relationships (Eloquent)

- `Product`: `product_translations` (hasMany), `main_category` (belongsTo `category_id`), `categories` (belongsToMany via `product_categories`), `product_categories` (hasMany pivot model), `brand`, `user`, `stocks`, `taxes`, `reviews`, `wishlists`, `thumbnail` (belongsTo `Upload` via `thumbnail_img`), `discounts`. `$with = ['product_translations','taxes','thumbnail']` — every product query eager-loads these three.
- **There is no `category` relation on `Product`** — views that use `$product->category` get `null` (see §8).
- `Category`: `products()` (pivot), `childrenCategories` (recursive via `->with('categories')`; both it and `categories` are sorted by `scopeOrdered` — `order_level` desc, then id, since 2026-10-17), `parentCategory`, `attributes`, `sizeChart`, `coverImage`/`bannerImage`/`catIcon` (uploads).
- `Brand`: `products` (hasMany on `brand_id`), `scopeRanked` (`Brand.php:55`).

### Stock truth

The real stock is `SUM(product_stocks.qty)`: use `Product::withCurrentStock()` (`Product.php:131`, aliases the sum as `current_stock`) or the SQL in `best_selling_products_query()`. `products.current_stock` was stale (only the partner sync wrote it; 2026-11-01: 4,160 products wrong on the local copy) and is now kept equal to that sum by `App\Listeners\Stock\SyncProductCurrentStock` on every `ProductStockUpdated` (one guarded query-builder UPDATE: no audit, no `updated_at`, no second event); migration `2026_11_01_000001` corrected the old rows (backup `products_current_stock_backups`, `down()` restores). A model loaded in a long cache still carries the value of its load, so lists keep summing. The app's product lists (`Api/V2` `ProductMiniCollection`) count it themselves in one grouped query and ignore whatever `current_stock` the caller loaded (fixed 2026-11-01: `categories/home/products` sent the column, 0 for perfumes with 10 of each size, and the app's card - whose flag the product page keeps over the variant answer - showed them sold out). Pinned in `ProductionNullRegressionTest`.

**Company warehouse (since 2026-10-31, `App\Services\Warehouse\Warehouse`).** `warehouse_stocks` holds, per product + variant text, the pieces Kapitano keeps in its warehouse: always **part of** that variant's `product_stocks.qty`, never extra (total for sale = in the warehouse + at the seller's shop; the shop part is never stored). It is one more writer of `product_stocks.qty`, always through Eloquent inside `SellerStockUpdates::quietly()`: a warehouse fill above the total raises it, and a take out of damaged / lost pieces lowers it by as many (spread over the variant's rows); a take out "back to the seller's shop" leaves it alone (`Warehouse::reasons()`). Every fill / take out is one `warehouse_movements.batch` with a printable receipt; Warehouse → History (`Warehouse::movementList()`, Excel export) lists every movement and links to those receipts. Warehouse → Count a shop (`App\Services\Warehouse\WarehouseCounts`, tables `warehouse_counts` / `warehouse_count_lines`) counts one shop's variants against the warehouse number: the saved difference is counted − (expected at start + the row's movements since the start, after `warehouse_counts.last_movement_id`), one `count` movement per difference with a reason; a shortfall's reason moves the total like a take out, pieces found raise it only above the total, like a fill. Once orders use the warehouse a count expects every piece in our building, shown apart: free (the warehouse number) + held for orders ready to ship (no longer in the warehouse number, still on the shelf), with the holding orders and their "ready to ship since" (`WarehouseCounts::lines()`); `warehouse_count_lines.held` keeps the held part at the start (migration `2026_11_01_000004`) and its change since the start is added like the movements, so an order marked ready to ship or sent on the way during the count is not corrected back. Only free pieces are a correction: a shortfall beyond them is refused while orders hold pieces of the variant (`refusedShortfall()`, the message names them): a forgotten "في الطريق" is fixed by moving the order. **Product protections**: `ProductStockService::warehouseRefusal()` (→ `Warehouse::formConflicts()` / `formRefusal()`) works out, before anything is saved, what the form would write (`planned()`: variant text → total) and refuses a total below the pieces we hold or a held variant the form no longer has (a rename changes the text, so it is a removal: take the pieces out, change the product, fill again); the admin form, the seller web panel and the three digital forms ask it first and flash the message, and `store()` asks again and throws `WarehouseHoldsPieces`. Texts compare exactly, as `store()` matches its rows (a change of capitals is a rename too). The seller inventory screen, which writes `product_stocks.qty` row by row, asks `Warehouse::stockRowsRefusal()`. The seller app (`Api/V2/Seller/ProductController@update`) instead raises a low total to the pieces we hold with `raiseToWarehouse()` and answers with the note in `message` and an added `warehouse_note` (old app versions must not fail the save); a removed held variant is still refused. A product we hold pieces of cannot be deleted (`Warehouse::deleteRefusal()` on every delete path, `ProductService::destroy()` throws; a seller with such products cannot be deleted, `sellerDeleteRefusal()`); unpublishing stays allowed. **Partner sync and transfer** (`App\Services\Warehouse\WarehouseProductPaths`): `PartnerCatalogWriter::syncStocks()` ends with `keepHeldPieces()`, so after an import, apply or link a held variant's total is the larger of the partner's number and the pieces we hold (in both stock modes, also for a variant the partner stopped listing; the warehouse row is pointed at the variant's new stock row after a link); a value the partner renames carries its warehouse row to the new text (`followRename()`; when we already hold the new text, the stock row keeps its old one); a link is refused while we hold pieces of a variant the partner does not list, texts compared exactly as the forms do (`strandedVariants()`); an owner change (`ProductTransferService::move()`, a partner link) writes one `transfer` movement (change 0) per held variant under the new owner (`ownerChanged()`), and a clone starts with nothing held, since the warehouse rows are keyed by product id. **Orders** (`WarehouseOrders`, pilot shops only, setting `warehouse_order_shops`): marking a Child Order "جاهز للشحن" takes min(ordered, warehouse) of each live line (`order_take` movement, `order_details.warehouse_qty`), listening to `OrderLineStockMoved` inside the stock transaction; the stock total is taken by `OrderStockUtility` as before, so the warehouse only says where the pieces are. A line that gives its stock back puts into the warehouse what is in our building (brief §3.6): its warehouse pieces on cancel / back to pending (`order_give_back`), also its shop pieces at picked up / on the way (`cancel_in`), every piece on a return (`return_in`); a staff variant switch runs in `WarehouseOrders::switchingVariant()` and gives back only the warehouse pieces. Warehouse → To prepare (`WarehouseOrders::toPrepare()`, `?print=1` = the pick list) lists the Child Orders whose lines hold those pieces (confirmed / picked up, the same lines as the stock page's "held"), so an order leaves it at on the way or straight to delivered; one still there more than `warehouse_held_too_long_days` (4) after `orders.confirmed_at` is flagged there and on the stock page (`heldTooLong()`). There is no "put back" list (dropped 2026-11-01): pieces a cancel brings into the building are free warehouse pieces at once. Staff changing a holding line (brief §3.3.8, `quantityChanged()`): a lower quantity gives back the warehouse pieces beyond it (`order_give_back`, shop pieces go first) and treats the dropped pieces like a cancel's (`cameBack()`: at picked up / on the way the captain's shop pieces come in as `cancel_in`; a delivered line brings nothing in), a higher one while confirmed / picked up takes what the warehouse holds, the rest from the shop; stock zeroing (`StockZeroUtility`) stops at the pieces we hold (`Warehouse::held()`), so "zero" empties the seller's shop only; a partial return's `ReturnRequests::splitLine()` gives the returned line min(warehouse pieces, its pieces) and the kept line the rest. The variant text is the key, as for carts and orders. Decisions: `features/company-warehouse-brief.md`; terms: `CONTEXT.md`.

### Translation write rules

- Admin/seller **create** writes one `product_translations` row in `env('DEFAULT_LANGUAGE')` (`ProductController.php:424-427`, `Seller/ProductController.php:138-141`).
- Since 2026-10-14 the admin add product / add category and the seller add product forms have the edit pages' language bar (`backend.partials.create_language_bar`, `style => 'pills'` on the seller form): the tabs only switch inputs, the default language is posted as the plain fields and the others as `translations[<code>][<field>]` (`backend.partials.create_translations`), and `store()` writes one more translation row per language filled in (`posted_translations()` in `Helpers.php`: active non-default languages only, an empty field takes the default-language value). Admin and seller `ProductController@store` must keep `translations` out of what `ProductService::store` mass-assigns; the seller form's own required check calls `kpRevealEmptyDefaultLang()` first. The digital product create forms have no bar yet. Pinned by `tests/Feature/CreateFormTranslationsTest.php`.
- **Update** does `ProductTranslation::updateOrCreate(['lang','product_id'], ['name','unit','description'])`; `ProductService::update` (433-437) **drops** `name/unit/description` from the base row when `lang != DEFAULT_LANGUAGE`, so non-default languages live only in translations.
- `meta_title`/`meta_description` are **not** translated (single column).
- Store/update fill an empty `meta_title` / `meta_description` / `meta_img` from the name / `SeoUtility::text(description)` / thumbnail. Since 2026-10-13 `ProductService::followAutoFilledMeta()` makes those auto-filled values follow later edits: on a default-language save, a meta field that still equals what was derived from the product's current name/description/thumbnail and was posted back unchanged is derived again from the new values; one typed by hand (or changed on the form) is kept. Every product edit screen (admin, seller, seller app, digital) goes through it. Pinned by `tests/Feature/ProductMetaFollowsNameTest.php`.
- Bulk import (`ProductsImport`) creates **no** translation row (grep shows no `ProductTranslation` in the file) — relies on fallback.
- Partner import writes translation in `PartnerCatalogSync::lang()` = `config('app.default_language')` (`sa`).

---

## 4. Flows

### 4.1 Admin product create — `POST /admin/products/store/`

1. `ProductRequest` validates (`app/Http/Requests/ProductRequest.php:28-51`): `name`, `category_ids` required, `category_id` must be in `category_ids`, `unit_price > 0`, `exchange_type ∈ {exchange, market, kapitano}`. `authorize()` always true.
2. `ProductController@store` (389) → `ProductService::store()` (18) with `$request->except([...])` — **almost the whole request is mass-assigned** (`Product::$guarded = ['choice_attributes']` only).
   - owner: seller → self (+ `approved=0` if `product_approve_by_admin`), otherwise first `users.user_type=admin` (29).
   - tags parsed from Tagify JSON (32-37), `date_range` → unix ts (40-44), meta defaults from name/description/thumbnail (47-56).
   - slug: `unique_product_slug(name)` (first free `-2`, `-3`… by exact match; since 2026-10-13, before it was a `LIKE` count that handed out taken slugs).
   - `colors`, `choice_options`, `attributes` JSON built; per-variant `price_*`/`sku_*`/`qty_*`/`img_*` keys stripped (84-97).
   - `published` from `button` (`unpublish`/`draft` → 0) (130-134).
3. `categories()->attach($request->category_ids)` (400) — **admin does not add ancestors** (seller does).
4. Tax (`ProductTaxService`), flash deal, stock (`ProductStockService::store`, see 4.4), frequently-bought, translation.
5. `forget_product_caches()` — drops the product-list caches only (until 2026-10-12 this was `view:clear` + `cache:clear`, the **entire** cache; see §6).
6. `Product::saving` hook (`Product.php:36`) appends to the end of Today's Deal / Trending if the flag was switched on.

### 4.2 Admin product update — `POST /admin/products/update/{product}`

`ProductController@update` (503): `ProductService::update` (377) → `categories()->sync(category_ids)` (514) → **`ProductStockService::store()` updates the variant rows in place** (no delete) → flash deal → taxes (only if `tax_id`) → frequently-bought delete+recreate → translation `updateOrCreate` → full cache clear.
`ProductService::update` side effects: slug via `slugOnUpdate()` — kept as is when the posted slug equals the current one, else `unique_product_slug(posted or name, ignore self)` (since 2026-10-13; before, a plain save could rename the URL and two products sharing a slug grew `-3` on every save); `featured`, `todays_deal`, `cash_on_delivery`, `is_quantity_multiplied`, `refundable` reset to 0 when absent from the request (387-403).

### 4.3 Seller product create/update (web and API)

- Web `Seller/ProductController@store` (98): package check → forces frequently-bought = main category (`frequentlyBoughtFollowsMainCategory` 427) → `ProductService::store` → **`categoryIdsWithAncestors()`** (247) adds every ancestor between a picked sub-category and the main one → stock/tax/fbt/translation → admin notification when approval is required → full cache clear.
- Web `update` (180): ownership check at 184 (added after a real IDOR), same pipeline with `sync()`.
- **The seller form saves over AJAX** (2026-11-05, `seller/product/products/form_layout_script`): the page posts `FormData` (+ `button=publish`, the submit button's value a plain post carried) with `Accept: application/json`, so a refused save never reloads the page or loses what was typed. `store` / `update` answer `{result: true, redirect}` (create → the product list, update → the edit page again, so it shows what was saved, variant prices that followed the product price included); a refusal is 422 `{result: false, message: [...], errors: {field: [...]}}` - `ProductRequest`'s JSON failure gained `errors` next to its old flat `message` (the apps read only `message`), and the package / not-yours / warehouse refusals answer the same shape (`formRefusal()`). The page lists the problems in the "not saved" panel (`form_problems`, each line jumps to its field and opens its fold) and marks each field; a 419 takes a fresh CSRF token from the page and sends once more; leaving with unsaved changes asks first. A plain post still works the old way (redirect with `old()`; the create page now also rebuilds the attribute value rows from `old()`). Layout: name / categories / brand, photos, price + quantity open; the discount behind "Add a discount", Colours & sizes (which now holds the variant table) and More options (`more_options`: unit, minimum quantity, tags, SKU, barcode, weight, video, PDF, external link, SEO, refund, warranty, shipping, stock visibility, delivery, tax) folded until used. Pinned by `tests/Feature/SellerProductFormAjaxTest.php` and the opt-in `SellerWorkflowRuntimeTest`.
- Mobile `Api/V2/Seller/ProductController@store` merges `added_by=seller`, attaches `category_ids` **without ancestors**; `@update` (163) has **no ownership check** (see §8).
- Seller inventory: `POST /seller/inventory/update-stock` → `InventoryController@updateStock` (64) validates `stocks.*.qty` int ≥ 0, loads only stocks whose product belongs to the seller (77-78), saves in a transaction, returns per-product totals. The page (`seller/inventory/index`, "Update Stock" / تحديث المخزون since 2026-11-05, side menu + dashboard button) shows every quantity box open (variants as tiles, stock tabs All / No stock / Has stock with counts) and saves each box itself 0.8 s after typing, when the box is left, one request per product so a warehouse refusal blocks only that product; the status pill offers undo of the last save and retry of a refused one. Keyboard (list behind the "Keyboard shortcuts" button / `?`): Enter and the side arrows = next / previous box, Page Down / Page Up (or Ctrl+↓/↑) = next / previous product, carrying on to the next / previous page once nothing is left unsaved (`#kp-first` / `#kp-last` puts the focus there), ↑/↓ = ±1 (Shift ±10), Esc = put back the saved number, `/` = search. The page listens to `focusin`/`focusout`, not jQuery `focus`/`blur`. Variant names go through the order pages' `backend.sales.partials._variation` / `VariationUtility::label()` (a colour reads in Arabic with its swatch, sizes as written); the product's photos and each variant's photos (up to 3 shown, then +N; uploads preloaded in one query, `App\Support\UploadPhotos`) open one photo pop-up with the others in a strip - the shared `seller/partials/photos/{thumb,strip,gallery}`, also used by the seller prices page (`seller.prices`). This is the only path that edits stock **without** recreating rows.

### 4.4 Variant / stock generation (`ProductStockService::store`, 11)

- Options = colors (if `colors_active`) + each `choice_options_{attribute_id}` (via `ProductUtility::get_attribute_options`).
- If combinations exist: `variant_product = 1`, one `product_stocks` row per combination, reading **`request()` directly** for `price_/sku_/qty_/img_{variant}` (29-36) — the service is not pure; calling it outside an HTTP request (CLI) silently writes nulls **(unverified for current callers; partner sync bypasses it)**.
- `img_*` may hold several ids: first → `image`, all → `images` (34-36).
- Otherwise one row with `variant=''`, `price=unit_price`, `qty=current_stock`.
- **The form's pickers** (admin and seller, create and edit): colours use the seller's `seller/product/products/color_picker` in all four forms; brand, attributes and each attribute's values are `partials/brand_select` / `attribute_select` / the `select.attribute_choice` rows, drawn by `partials/catalog_pickers` as clickable options with search and "add «X»" (`CatalogRequestController` → `CatalogRequests::add*`: staff add approved rows, sellers pending ones, shown on the admin Brands / Attributes pages via `CatalogApprovalController`; the admin approving a product approves the ones it uses, `Product::updated`). The create pages add / drop one attribute row at a time (they used to rebuild all rows and lose the picked values). The admin forms also mark hidden categories in the tree (`partials/category_tree_hidden`, `<li data-str>`), show each price in SYP at the chosen exchange type and refuse a selling price below the purchase price (`partials/price_syp_hint`, plus `ProductRequest` on `products.store/update`).
- **The form's variant grid** (`#sku_combination`) is redrawn by the server (`sku_combination` / `sku_combination_edit` → `sku_combinations[_edit].blade.php`) on every change of colours / attribute values, and the server only knows what is saved. `update_sku()` in all four product forms (admin + seller, create + edit) therefore wraps the redraw in `kpVariantGrid.snapshot()` / `.restore()` (`backend/product/products/partials/variant_grid_keep`): photos (per variant `img_*`, per colour on the new-product grid), and typed quantities / SKUs / prices (prices only while the product price is unchanged) are carried to the rows of the same name, and a new variant of a colour that has photos takes them. Restored values go into the `value` attribute too, because FooTable redraws the rows from their HTML just after (and the previews are drawn again on its `ready.ft.table`). Before this, adding a variant to a new product wiped every photo picked so far.
- **SKUs are built, never typed** (`App\Services\ProductSku`, its docblock is the map; since 2026-11-02). Every product has a random base SKU, `products.base_sku` (8 capitals and digits without 0/O/1/I/L, unique), made once by `Product::creating` (the create forms propose one, posted as `base_sku` and shown read-only by `partials/sku_input`; a duplicate / clone / app / import / partner product gets a new one) and never changed (`Product::updating`, `$guarded`). A stock row's SKU = base + each part of the variant text in English (`K7M4XQ2P-BLACK-38`, `…-20ML`; the base alone without variants; Arabic through a word list, then letter by letter; a SKU two variants share takes `-2`), written by the `ProductStock::saving` hook on **every** path whatever is posted: a new row (or new variant text) is built, **an existing SKU is never changed** (decided 2026-11-02: a changed one is put back, whatever it is, `Black-40` included) **unless it holds Arabic** (`ProductSku::keeps()` / `hasArabic()`, decided 2026-11-10: Arabic SKUs break things; such a row is rebuilt on any save, a quantity write included), only an empty one is built, a quantity-only write on any other row changes nothing. The variant grids show the same SKUs read-only (`gridSkus()`). Partner-store products keep the partner's SKUs and the old editable boxes (`baseFor()` is null for them; `PartnerCatalogWriter::syncStocks()` writes inside `ProductSku::asGiven()`, since an import / link writes its rows before the link row is saved). Migration `2026_11_02_000001` (one `ALTER` on `products`: quiet hour) gave every product a base and built only the rows with no SKU (local copy: 14,919 products, 7,985 rows; old values in `product_sku_backups`, `down()` restores); every existing SKU (AYLEEN-001-7, the old initials default, the variant text) stays. Migration `2026_11_10_000003` then rebuilt every SKU holding Arabic (the old initials default `حرربف–تكأ-40`, the variant text `White-حديثيالولادة(0–3أشهر)`; local copy: 1,972 rows, 3 took `-2` to stay unique across the store; old values in `product_sku_arabic_backups`, `down()` restores); SKUs without Arabic (duplicates included) and partner products' SKUs (Arabic or not) are untouched. Query builder: run `search:reindex` after deploying, or wait for the nightly one. Pinned by `tests/Feature/ProductSkuTest.php` and `SellerWorkflowRuntimeTest::test_variant_skus_are_built_from_the_products_base_sku` (opt-in).
- Order placement decrements `product_stocks.qty` in `OrderController.php:634-639` and `Api/V2/OrderController.php:205-209` (orders domain).

### 4.5 AI product endpoint — `POST /api/v2/product/add/ai`

`routes/api.php:500` (inside `v2` group, only `app_language` middleware, **no Sanctum**) → `Api/V2/ProductController@store` (77). Access is a hard-coded shared token compared in code (`$request->token == "683d***"`, line 79). Then `ProductService::store1` (202): owner = first admin, `published = 0`, tags from `[{value}]`, image fields that are URLs become `uploads` rows with `external_link` (`saveImageFromUrl` 178, 331-358). Categories attached without ancestors; stock + translation created; errors return `$e->getMessage()` to the caller (166-170). Companion read endpoint `GET /ai/data` (`routes/web.php:368` → `HomeController@get_data_ai` 546) dumps all colors, attribute values and brands, unauthenticated.

### 4.6 Bulk import (Excel)

`ProductBulkUploadController@bulk_upload` (admin, `permission:product_bulk_import`) / `Seller/ProductBulkUploadController@bulk_upload` → `Excel::import(new ProductsImport)` → `collection()` (28): package limit check; per row: category by id or exact name (66-87), slug via `unique_product_slug()` (146), `Product::create` (159), stocks, `multi_categories` split on `-` into `product_categories` (370-399, **no ancestors, main category not attached unless listed**), images as `external_link` uploads (`downloadThumbnail` 440 — nothing is downloaded despite the name). An exception inside a row calls `dd()` (409) — aborts the request with partial data committed.

### 4.7 Clone / move / duplicate

- `duplicate` (admin 738, seller 380, seller API 254): `replicate()` + `unique_product_slug(original slug)` (was `slug-<random5>` until 2026-10-13); stocks, taxes, pivot rows copied; upload ids **shared** with the original.
- `transfer` (admin 781, `permission:clone_or_move_product`, move additionally needs `product_edit`): `ProductTransferService::clone` (57) copies translations, stocks (with unique SKU), taxes, pivot, fbt and **physically copies each upload** (`cloneUploadId` 224) so a later delete by one owner does not break the other; `move` (131) re-homes keeping id/slug/SKUs. Slug via `unique_product_slug()`.
- **Bulk Action menu** on the admin product lists (`bulk_action` 621, `POST /admin/products/bulk-action`, `products.bulk_action`): publish / unpublish, approve / unapprove (seller products only; in-house ones are skipped, an unchanged one is not saved or notified again; the menu entry and the per-row approval switch show on the seller list and, for seller rows, on "All products" when `product_approve_by_admin` is on), duplicate, clone / move (destination from the same transfer modal, `open_transfer_modal_bulk()`), delete — on every ticked product. Each action checks the permission of its single button inside the method (no route middleware). The page sends the ids in batches (10 for copy/move, 25 delete and approval, 50 publish; max 200 per call), each product runs on its own (`try`/transaction), and products that cannot be done come back in `skipped` (already owned by the destination, seller package full) without stopping the rest; `forget_product_caches()` once per batch. The single buttons share the helpers `deleteProduct()`, `duplicateProduct()`, `publishBlockedBySubscription()`, `notifyProductApproval()`. "Select all" ticks the current page only (raise "rows per page" to take more). Pinned by `tests/Feature/ProductBulkActionTest.php`.
- **Bulk discount** (2026-11-11, same menu, section "Discount"; all in `App\Services\SellerPricing\BulkDiscount`, pinned by the bulk cases of `DiscountPartsTest`):
  - **"Set a discount"** (`set_discount`, `product_edit`, shown once `DiscountParts::ready()`): the seller's part (% of the seller's own price) and Kapitano's (% of the customer's price) with optional dates, through `DiscountParts::put()`; it **replaces** the products' discount, an empty side is taken off; percentages only (the ticked products may be in different currencies); our own / partner products take Kapitano's part only. One transaction per product, so a refused part leaves it as it was.
  - **"Remove the discount"** (`remove_discount`): every part, and a discount from before the parts (columns only).
  - **"Link to a fake discount"** / **"Unlink from fake discounts"** (`link_fake_discount` with `fake_discount_id`, `unlink_fake_discount`; `edit_fake_discount`): through `FakeDiscountService::attach()` / `resync()`. A product with a real discount running or upcoming is refused (the fake discount would read it as the true price and the real one would come back on top once the fake stops): remove it first.
  - Skipped with their reason: offers-section products, auctions, fake discounts (for the real-discount actions), and whatever `DiscountParts::refusal()` refuses.

### 4.7b Who added a product (أضيف بواسطة)

`products.created_by_type` + `products.created_by_user_id` (migration `2026_10_10_000001`), written once by
`Product::creating` → `App\Services\ProductCreator::stamp()` for **every** creation path above (admin form,
seller panel, seller app, duplicate, Excel import, digital, transfer clone, partner sync, AI API), because they
all go through Eloquent. Types: `seller` (the seller in their own session), `staff_as_seller` (staff/admin
through "Log in as seller": the user is the **staff member** from `impersonator_user()`, the owner stays
`user_id`), `staff_panel` (admin/staff in the admin panel), `partner_sync` / `api` (set by
`ProductCreator::as()` in `PartnerCatalogWriter` and `Api/V2/ProductController@store`; no user), NULL =
unknown. `Product::updating` → `keep()` makes both values permanent, and they are in `Product::$guarded`,
so a form posting them changes nothing; a `replicate()` copy is credited to whoever copied it; a move keeps
it. Never blocks a save (any failure leaves NULL); read through `ProductCreator::ready()` (cached 10 min,
cleared by the migration and in `tests/TestCase.php`). An employee who signs in with the seller's own
phone/OTP is the seller to the server: only "Log in as seller" is recognised. Products that existed before are
filled once by the migration (`ProductCreator::backfill()`, batches of 500, only NULL rows, only these two columns):
a `partner_products` row + a `created` audit from the import page (`/admin/partner-stores/{id}/batch/import`), or
a partner row, no audit (the console sync is not audited) and the product newer than its row = `partner_sync` (a
product entered by hand and linked later has another URL, or is older than its row); else the product's first `created` audit: tag `impersonating:` = `staff_as_seller` (audit user = staff),
no user + AI API url = `api`, a seller = `seller`, admin/staff = `staff_panel`; else unknown (the audit log starts
2026-09-17, and console runs are not audited). The counts are logged as `products.created_by backfilled`. Pinned by
`ProductCreatorTest`, `ProductCreatorBackfillTest`, `ProductCreatorRuntimeTest` (opt-in, real routes on MySQL), `ProductCreatedByMigrationTest`,
and cases in `PartnerStoreSyncTest` / `ProductTransferTest`.
### 4.8 Image upload & serving

Upload (web, `POST /aiz-uploader/upload` → `AizUploadController@upload` 73):
1. Extension whitelist map (75-111; images, video, audio, archives, documents incl. `xml`, `swf`). Extension is the **client-supplied** one; no MIME sniffing.
2. SVG → sanitized in place with `enshrined\svgSanitize` (136-146) — web copy only.
3. Raster images: re-encoded with Intervention (`uploaded_image_format` setting, quality 75), optional watermark, resize >1500px, saved to `public/uploads/all/<random40>.<ext>` (150-237). On exception the error is logged and the `Upload` row is **still saved** pointing at a file that may not exist (238-241, 266-271).
4. Other types: `store('uploads/all','local')` — `local` disk root is `public_path()` (`config/filesystems.php:46-48`).
5. Non-local `FILESYSTEM_DRIVER` → copy to cloud, unlink local.
6. `Upload` row with `user_id = Auth::user()->id` (268).

Serving: `uploaded_asset($id)` (`Helpers.php:2621`) memoizes `Upload::find` per request in `request()->attributes['_kapitano_uploaded_assets']`, returns `external_link` or `my_asset(file_name)`; placeholder for null/missing. `my_asset()` (2813) honours `config('app.asset_path_prefix')` and non-local disks. Docroot is `public/` — never prepend `/public/` (see `normalize_upload_url` 2896, `getFileBaseURL`). Uploader grid gets `preview_url` from `get_image()` (`AizUploadController.php:304-309`). The form previews (`POST /aiz-uploader/get_file_by_ids` → `get_preview_files`) come back **in the order of the ids asked for** (they used to come in id order, so a gallery never showed its saved order).

Product forms (admin + seller, create + edit) include `backend/product/products/partials/product_image_swap`: drag a preview between the gallery (`photos`) and the thumbnail (`thumbnail_img`) to swap them, onto the empty part of the gallery to move the thumbnail out, or inside the gallery to reorder it (the first photo is the main one). It moves the AIZ preview items and rewrites the hidden inputs from their order; mouse drag, or hold-then-drag on touch. They also include `partials/form_image_preview`: a click on any uploader preview photo (thumbnail, gallery, variant, colour) opens it full-size in `#kp-form-image-preview`, with previous/next through the photos of the same box (the products list has its own `#kp-image-preview-modal`); the end of a drag is not a click (`window.kpImageSwapDroppedAt`).

API copies (all write `uploads` rows):
- Multipart: `Api/V2/AizUploadController@upload` (`file/upload`, api.php:403), `CustomerFileUploadController@upload` (`file/upload`, api.php:697 — **same URI registered twice**, the later registration normally wins in Laravel's route collection **(unverified)**), `Seller/SellerFileUploadController@upload` (api_seller.php:138). These store the raw file first, then re-encode images; **no SVG sanitizing, no watermark**.
- base64: `FileController@imageUpload` (`file/image-upload`), `FileUploadController` (no route found), `ProfileController` 180-199 and 295-313: write `public/uploads/all/{$request->filename}` **before** checking the extension (see §8).
- Review photos: `Api/V2/ReviewController@submit` (~74-176) — sanitizes SVG.

### 4.9 Web search → results (`GET /search?keyword=`, `routes/web.php:455`)

`SearchController@index` (67):
1. Preorder branch when `product_type=preorder_product` (84-160).
2. Brand: `brand_id` param or `?brand=slug` → `Product::where(conditions)` (163-170).
3. Category (via `listingByCategory` 326, slug lookup): `$products = $category->products()` (**pivot of that exact category**), attributes from `attribute_category` of the category and its descendants (`descendantCategoryIds` 340). Without category: all attributes and top-level visible categories.
4. Price: raw `unit_price` between `min_price`/`max_price` (211-213) — RAW, no SYP conversion **(unverified which unit the view sends)**.
5. Keyword: legacy `searches` counter (`store` 484), `search_events` insert (`trackSubmittedEvent` 46, swallows errors), `normalizeArabic` on the term, then per word `name|tags|description|meta_description` folded LIKE, `product_translations.name`, `product_stocks.sku` (224-237). Relevance: CASE bucket prefix/infix on `name` (239-244), then FULLTEXT `MATCH(name,tags) AGAINST(? IN BOOLEAN MODE)` only for default sort (249-258).
6. Sort switch (261-276), color filter `colors LIKE '%"#hex"%'`, attribute values `choice_options LIKE '%"value"%'` (OR'ed).
7. `filter_products()` (approved+published, non-auction, verified sellers when vendor system is on, `Helpers.php:174`).
8. **Two-phase**: pluck all matching ids once (304), rebuild `Product::whereIn(id, ids)` keeping order + bindings (309-311), compute `FilterOptionsUtility::fromQuery` facets, paginate 24.

Dropdown: `POST /ajax-search` → `ajax_search` (381): keyword suggestions from `SearchSuggestionController::keywordSuggestions`, 3 products/categories (visible)/brands (only with products)/verified shops, preorder. Empty field: `POST /search/landing-box` → `landing_box` (25) renders the landing partial from `SearchLandingController` methods, cached per locale for 900 s.

### 4.10 Mobile search (`GET /api/v2/products/search`, api.php:525)

`Api/V2/ProductController@search` (416) differs from web:
- categories filter on **`products.category_id`** + `CategoryUtility::children_ids` (441-454), not the pivot;
- no SKU match; legacy `SearchUtility::store` only (no `search_events` — the app posts `/search/track`);
- `min`/`max` go through **`convert_to_usd_from_syp()`** (430-431) whereas `FilterController::matchingQuery` (116-122) compares them raw and `SEARCH_API.md` §4 tells the app to pass `price.min/max` unchanged — contradiction, see §8;
- sizes/colors quote-bounded LIKE on JSON (505-527); default sort `created_at desc` (web: `id desc`);
- returns `ProductMiniCollection` with `withCurrentStock()`, page size 10.

### 4.10b Search engine (Typesense, `app/Services/Search/`, since 2026-10-26)

Off by default (`TYPESENSE_ENABLED`, `config('services.typesense.*')`). When on, the product match of five places comes from the engine instead of the `LIKE` scans: web listing (`SearchController@index`), web search box (`@ajax_search`), app search (`Api/V2/ProductController@search`), its counts (`FilterController@matchingQuery`) and app autocomplete (`SearchSuggestionController@productSuggestions`). Each calls `ProductSearch::apply($query, $text, $ordered, $within)`: it adds `whereIn(products.id, …)` + a relevance `CASE` order (default sort only) to the caller's own query, so `filter_products()`, categories, brands, price bounds, sorts, paging and filter counts stay in MySQL. `$within` (category ids with children, brand ids, a shop's `user_id`) is also sent to the engine so a shop / category is never cut off by `max_hits` (500) of the whole catalogue. Engine off, unreachable or slower than `TYPESENSE_TIMEOUT` (0.8 s, Guzzle float options: Laravel's `timeout()` takes whole seconds) → `apply()` returns false and the old MySQL code runs unchanged. Answers are cached 120 s per prepared text + limits.

- `ArabicText`: normalization (site folding + Arabic-Indic digits + no tashkeel), light stemmer (ال/بال/وال/لل, ات/ه; never ون/ين — it turned ايفون into ايف), filler words (اريد، بدي، مقاس، لون…), synonym groups (Syrian colloquial, spellings, Arabic/English; most from searches MySQL answered with nothing), colour table (codes → nearest base colour, "Black-L" → اسود). Change a rule → `php artisan search:reindex`.
- `ProductSearchDocument`: name (stemmed + as typed), `aliases` (synonyms of the name's words, so "ايفون" meets "iPhone" as a plain word), English name, SKUs, brand, categories (+ parents), tags, colours, sizes, `category_ids`, `brand_id`, `user_id`, `visible` (the product's own part of `filter_products()`; the seller's part stays in MySQL, so verifying or banning a seller needs no reindex). **No description** (a number or word anywhere in it matched unrelated products).
- Ranking: words decide (weights in `ProductSearch`), typos from 5 letters, too few full matches → words dropped **from the right** (noun first: "بوط اسود" keeps بوط), Typesense's default `num_typos` (per-field values made "ايفون 15" drop the noun). The meaning model (`ts/multilingual-e5-small`, inside Typesense) only re-orders the first 24 and adds its own products when the words found < 10 and within 0.14 cosine distance: alone it answered Arabic searches with books and shawls. e5-base was tried: 2.7× slower to index, +1.3 GB, no clear gain.
- Sync: `Product` saved/deleted, `ProductStock` created/deleted/variant-or-SKU changed (not quantity), `ProductTranslation` saved/deleted → `ProductSearchIndex::touch()` → `search_index_queue` (only while on) → `search:sync` every minute. `search:reindex` (03:30 nightly, and after deploy / rule changes) builds `<alias>_<time>`, moves the alias, drops the old one; sync waits meanwhile. A query-builder write to products / stocks is only picked up by the nightly reindex.
- Not covered: the seller shop page search (`HomeController`, every word must match inside one shop), preorder products, categories / brands / shops in the search box (still `LIKE`).
- Trial behind the rules (249 real searches, zero-result 26 → 2, 1.5 s → 0.22 s): `storage/app/search-trial/` (local, not in git). Pinned by `tests/Feature/ProductSearchTest.php`.
- Deploy: install Typesense (binary or .deb, systemd, listen on 127.0.0.1 only, own API key), `php artisan migrate` (queue table), set the `TYPESENSE_*` variables with `TYPESENSE_ENABLED=true`, `php artisan config:cache`, then `php artisan search:reindex` (≈2 min for 13 k products on 4 cores; the model is downloaded once). Until the first reindex has moved the alias, every search falls back to MySQL by itself; the hooks already queue saves meanwhile. Off again = `TYPESENSE_ENABLED=false` + `config:cache`.
- **Live since 2026-10-29** (Typesense 30.2 rpm on the site's own cPanel server, RHEL family; first reindex 14,989 products in 121 s with e5-small). What that install ran into:
  - Install it on the server that runs the site (the first attempt went to the test server by mistake): from the site's account, `curl http://127.0.0.1:8108/health` must answer `{"ok":true}`, and `systemctl show typesense-server -p Restart` must find the unit (the rpm's unit has `Restart=on-failure`).
  - The rpm listens on `0.0.0.0` by default: set `api-address = 127.0.0.1` in `/etc/typesense/typesense-server.ini`, restart, check `ss -ltnp | grep 8108`.
  - `400 Failed to download model file` with "Problem with the SSL CA cert" in `/var/log/typesense/typesense.log`: Typesense reads `/etc/ssl/certs/ca-certificates.crt` (Debian's path), RHEL keeps it in `/etc/pki/tls/certs/ca-bundle.crt`. `ln -s /etc/pki/tls/certs/ca-bundle.crt /etc/ssl/certs/ca-certificates.crt`, restart. Without the model the engine still works on words alone: `TYPESENSE_EMBEDDING_MODEL=` (empty) + `config:cache` + reindex.
  - `401 Forbidden` from `search:reindex` = the `.env` key is not the ini's `api-key`, usually `config:cache` not run after editing `.env`. Compare without printing them: as root `printf '%s' "$(grep -oP '^api-key\s*=\s*\K\S+' /etc/typesense/typesense-server.ini)" | md5sum`, as the site user `php artisan tinker --execute="echo md5((string) config('services.typesense.key')), PHP_EOL;"`.
  - Run `config:cache` and `search:reindex` as the site's user, never root (root-owned files in `storage/` and `bootstrap/cache/` break the site). Run the reindex in the background (`nohup php artisan search:reindex > storage/logs/reindex-first.log 2>&1 &`) and read its progress with `tr '\r' '\n' < storage/logs/reindex-first.log | tail -3`.
  - Check it is used: `php artisan tinker --execute='echo count(App\Services\Search\ProductSearch::ids("ايفون") ?? []), PHP_EOL;'` (0 with the engine failing; a failure also logs `Search engine unavailable, MySQL search used`), and that `crontab -l` has `schedule:run` (`search:sync` every minute, the 03:30 reindex).

Other search endpoints: `/api/v2/search/landing` (cache 900 s per locale+limits), `/api/v2/search/suggestions` (cache 300 s keyed on the **first 10 chars** of the normalized query), `/api/v2/search/track` (`throttle:60,1`, ≤50 events), `/api/v2/filter/options` (cache 300 s), legacy `/api/v2/get-search-suggestions`.

### 4.11 Search analytics pipeline

`search_events` ← web `trackSubmittedEvent` + app `/search/track` → `search:aggregate-events` (every 10 min, `Kernel.php:41`) folds unprocessed rows into `search_queries` counts and increments `search_click_count` on products/categories/brands, marks `processed=1` → `search:recompute-scores` (hourly, `Kernel.php:42`) rewrites `search_queries.score` (log baseline + recent-window events) and runs full-table `UPDATE products SET search_score = search_click_count*2 + num_of_sale*0.5` (and categories/brands). Landing/suggestions order by these scores. `--backfill` seeds from `searches` using `env('DEFAULT_LANGUAGE')` as locale.

### 4.12 Partner catalog sync

Admin page (`/admin/partner-stores`, `permission:manage_partner_stores`) loops JSON calls: `fetchStart` → `fetchPage`… → `fetchFinish` (staging only) → `batch/{import|apply}`. Every partner API is read by `GenericJsonDriver` with the store's settings (`PartnerCatalogPresets`: preset in `partner_sources.driver` - `dawana`, `cloi`, `sool`, `sinbad`, `itel`, `custom` - + JSON overrides in `config`; dot paths, `*` lists, `a|b` alternatives, `!a` negation, `a?b` condition; `PartnerJsonReader` reads texts / images / categories leniently); the settings page has "Save and test connection" → `PartnerCatalogFetcher::preview()` (page 1 raw + as read, nothing written). **Specifications** (a `specs` path, `spec_label` / `spec_value`; Sool's `specs [{label, value}]`) are added by `GenericJsonDriver::withSpecs()` to the end of the description as an escaped list under "المواصفات" (2026-11-11): a stopgap, products have no specifications table yet (section 10). `PartnerCatalogWriter::importOne` (120): row lock, `PartnerCatalogVariants::plan()` (options by name, variant text = values joined by `-` without spaces, the way `CartUtility::create_cart_variant` / `Api/V2` rebuild it), `PartnerCatalogMapping::categoryFor()` (the mapped partner category deepest in our tree) → `categoryPlacement()` (main = top-level ancestor, pivot = mapped + all parents), `storeOptions()` (attributes matched/created + `attribute_values` added; colour is a text option, `colors = '[]'`), `uploadImages` (external_link uploads, nothing downloaded), `Product::create` with an explicit field list (slug via `unique_product_slug`), translation, raw `DB::table('product_categories')->insert`, then `syncStocks()` (574): one `product_stocks` row per variant, linked in `partner_products.stock_links`, updated in place, new ones added, dropped ones set to qty 0 (never deleted); quantities from the partner or the fixed default (`stock_mode`), prices through `priceToRaw($price, $currency, $source)` (old pounds ÷ 100 first when `syp_unit = old`; since 2026-10-25 the result is RAW in the store seller's currency: a price already in it is kept as sent, any other goes through dollars at the currency table - CLAUDE.md "Seller price currency"). A linked product takes the partner shop's currency (`SellerCurrency::writing()`); an imported one gets it from `Product::creating`. Admin page (`/admin/partner-stores`, `permission:manage_partner_stores`) loops JSON calls: `fetchStart` → `fetchPage`… → `fetchFinish` (staging only) → `batch/{import|apply}`. Every partner API is read by `GenericJsonDriver` with the store's settings (`PartnerCatalogPresets`: preset in `partner_sources.driver` - `dawana`, `cloi`, `sool`, `sinbad`, `itel`, `custom` - + JSON overrides in `config`; dot paths, `*` lists, `a|b` alternatives, `!a` negation, `a?b` condition; `PartnerJsonReader` reads texts / images / categories leniently); the settings page has "Save and test connection" → `PartnerCatalogFetcher::preview()` (page 1 raw + as read, nothing written). `PartnerCatalogWriter::importOne` (120): row lock, `PartnerCatalogVariants::plan()` (options by name, variant text = values joined by `-` without spaces, the way `CartUtility::create_cart_variant` / `Api/V2` rebuild it), `PartnerCatalogMapping::categoryFor()` (the mapped partner category deepest in our tree) → `categoryPlacement()` (main = top-level ancestor, pivot = mapped + all parents), `storeOptions()` (attributes matched/created + `attribute_values` added; colour is a text option, `colors = '[]'`), `uploadImages` (external_link uploads, nothing downloaded), `Product::create` with an explicit field list (slug via `unique_product_slug`), translation, raw `DB::table('product_categories')->insert`, then `syncStocks()` (574): one `product_stocks` row per variant, linked in `partner_products.stock_links`, updated in place, new ones added, dropped ones set to qty 0 (never deleted); quantities from the partner or the fixed default (`stock_mode`), prices through `priceToRaw($price, $currency, $source)` (old pounds ÷ 100 first when `syp_unit = old`; since 2026-10-25 the result is RAW in the store seller's currency: a price already in it is kept as sent, any other goes through dollars at the currency table - CLAUDE.md "Seller price currency"). A linked product takes the partner shop's currency (`SellerCurrency::writing()`); an imported one gets it from `Product::creating`. `applyOne` (277) does the same in place (never recreates stocks; locked = quantities only); a product under an active fake discount (or still inflated by an old one) keeps it: `fakeDiscountPlan()` leaves inflated prices alone when the partner's prices are its true ones, else `unInflate()` + the partner's prices as true prices + `FakeDiscountService::resync()` (2026-11-05; before, Apply wrote over the inflated price and the discount came off the true one). **The partner's offer is shown struck through** (2026-11-11): a store whose `compare_price` / `variant_compare_price` reads the price before the offer (Sool `price.regular?price.onSale`, Itel `Price?HasDiscount`; Sinbad `product_price?has_offer`; Dawana once it sends one) gets it as an **amount** discount: `unit_price` and the stock prices = the paid price + the cut, `discount` = the cut, no dates, so the customer pays exactly the partner's price and `purchase_price` still follows the paid price (`PartnerCatalogWriter::offerAmount()` / `withOffer()`). Only when every variant has the same cut, and only where nothing else holds the discount (a fake discount, discount parts, one staff typed, the offers section); `partner_products.offer_discount` (migration `2026_11_11_000003`) records what Apply placed, so the next Apply moves or clears only its own. The partner bears it: `SellerDiscount::stampKapitano()` books none of it as Kapitano's (`carriesPartnerOffer()`), the admin form saved unchanged keeps it (`DiscountParts::formInput()`), and the price button drops it (the price typed is what the customer pays). After batches `PartnerCatalogSync::clearStoreCaches()` = `cache:clear`. CLI: `php artisan partners:sync {source?} {--fetch-only}` (daily 04:00, sources with daily sync on). **Link existing products** (tab `link`, `linkProducts` → `PartnerCatalogWriter::linkBatch`): a staged product takes over a product staff entered by hand before the API (same id and slug, so orders / reviews / wishlists / `num_of_sale` stay) instead of being imported twice. `PartnerCatalogMatcher::suggest($source, $shop_user_id)` (read only) pairs the shop's unlinked products with not-imported rows: SKU / code = stock SKU or barcode (1.0), same folded name (0.98), else Dice of folded words (halved when model numbers disagree), greedy one-to-one; ticked by default from 0.85. Each pair's other side is picked with a search box (`_link_picker`, JS in `_runner`; `linkSearch` → `PartnerCatalogMatcher::searchProducts()` (our unlinked products of the shop: number, stock SKU, barcode, or every typed word in the name / a translation) / `searchRows()` (the store's rows not imported: partner id / code, a variant SKU, every word in the title)), so staff never type ids; the tab posts `{row, product}` only (2026-11-05). `linkOne`: both rows locked, same blockers as import, then `productFields` + `linkResets()` (video, barcode, tax, warranty, seller-pricing columns… only those the table has) overwrite the product, owner = partner shop; old `product_stocks` (+ `wholesale_prices`), translations, `product_taxes`, pivot and `discount_products` links deleted and rebuilt; cart lines whose variant is gone removed; old uploads left in the media library; home-section flags kept. Tests: `PartnerStoreSyncTest`, `PartnerVariantsSyncTest`.

### 4.12b SEO head (title, description, canonical, JSON-LD)

Everything a search engine reads in a storefront `<head>` comes from `App\Utility\SeoUtility` (static, no DB beyond loaded relations + category parents):

- `text($html, 160)`: tags → spaces, entities decoded, `
`/`&nbsp;`/runs of spaces → one space, 4-byte chars stripped, cut on a word. Used for every auto-filled `products.meta_description` (`ProductService` store/update, `PartnerCatalogWriter`) and again at render time, so old rows with junk still print clean.
- Product page (`product_details.blade.php` head): title `{meta_title or name} - {main category} | كابيتانو`, description = stored meta → product description → a sentence from name + category (never empty), all og/twitter titles identical, `og:type=product`, and every price tag is the **SYP number the page shows** (`productPriceRange()` = `home_discounted_price($p, false)`: discount, tax, product rate). The category is `main_category` (`category_id`); `Product` has no `category` relation.
- Category/brand pages (`product_listing.blade.php`): description falls back to «تسوق {name} أونلاين في سوريا …». They set `@section('own_social_meta')` so the layout skips its homepage og/twitter defaults (they used to print twice).
- `<link rel="canonical">` on every storefront page (layout): `@section('canonical')` if the page sets one (product page), else `SeoUtility::canonical()` = the URL without its query (drops `?fbclid`, `?utm_*`), keeping only `?page=N > 1`.
- **Every address a search engine reads is on `APP_URL`'s host** (2026-11-02): `www.kapitano.shop` serves the same pages and `route()` / `url()` follow the request's host, so a visit through www printed www in the canonical, `og:url` and JSON-LD - and wrote it into the sitemap cache that every visitor shares for 6 h. `SeoUtility::url($url)` swaps this site's root for `APP_URL` (other URLs unchanged), `urlsIn($text)` does it inside `llms.txt`; the canonical, every `og:url`, the JSON-LD and every sitemap `<loc>` go through them. A new SEO address must too. The www → bare 301 itself is a Cloudflare Redirect Rule (GET/HEAD, `/api/` left out). Pinned by `SeoTest`.
- JSON-LD via `SeoUtility::jsonLd()` (`JSON_HEX_TAG`, so a value cannot close the script): `siteSchema()` Organization + WebSite/SearchAction on the homepage (megamart `index`), `productSchema()` Product + Offer/AggregateOffer (SYP, InStock/OutOfStock, `aggregateRating` only with approved reviews) and `breadcrumbSchema(breadcrumbItems($category, $product))` (walks `parentCategory`) on product and category pages.
- Twitter cards: every storefront head uses `twitter:card = summary_large_image` and no `twitter:site`/`twitter:creator` (the CMS shipped `@publisher_handle`/`@author_handle` placeholders and the invalid card types `product`/`website`; the store has no X account).
- **Own titles and robots** (2026-11-02): `/brands`, `/categories`, `/flash-deals`, `/todays-deal`, `/sellers` and the 404 page set `meta_title` (`SeoUtility::pageTitle()`, «… | كابيتانو») and `meta_description`; the layout's og/twitter/itemprop tags now follow the page's `meta_title` / `meta_description` (they always printed the homepage's) and `og:url` is the canonical. `<meta name="robots">` is `@yield('meta_robots', 'index, follow')`: `/search` (any keyword or filter, «نتائج البحث عن …» / «كل المنتجات») and the 403/404/500 pages set `SeoUtility::NOINDEX` (`noindex, follow`). Pinned by `SeoPageTitlesTest`. Known: `/sellers` prints no og tags at all (the layout's `isset($shop)` sees the list's loop variable).
- **Shipping and returns in the Product offer** (2026-11-07): `offers.shippingDetails` = one `OfferShippingDetails` per governorate (`SeoUtility::shippingDetails($priceSyp)`): the fee is the governorate's most common `cities.cost` and free-shipping limit `cities.carttotal` (the area-wise tariff `getShippingCost()` reads; BASE → SYP with `convert_price()`), 0 when the product alone reaches the limit; `addressRegion` = the ISO 3166-2 code (`SeoUtility::SYRIA_REGIONS`, by the folded `states.name`; a governorate not in it is left out); delivery 1-2 days in Damascus, 2-5 elsewhere (`DELIVERY_DAYS`, Kapitano's answer). Cached a day per `geo` version (`shippingRegions()`), only for `shipping_type = area_wise_shipping`. `offers.hasMerchantReturnPolicy` (`returnPolicy()`): within `return_request_window_hours` (1 day), free, picked up by the captain (`ReturnByMail`); none while `return_requests_enabled` is 0. Pinned by `SeoTest`.
- **Structured data and images** (2026-11-02): the Organization `telephone` goes through `SeoUtility::internationalPhone()` (the setting `contact_phone` is typed right-to-left, "3329249 - 011" → "+963-11-3329249"). `brand` and `aggregateRating` were already in `productSchema()` (only when the product has a brand / approved reviews). Every image of the product page has an `alt` (gallery and thumbnails: the product name + the variant; review photos, warranty, return sticker, seller logo, the review image modal). Each `sitemap-products-N.xml` in the index carries the newest `updated_at` of its own 1000 products (one store-wide newest made all of them read "now"). `unique_product_slug()` stops at 60 characters on a `-`; `ProductService::slugOnUpdate()` keeps an existing longer slug while the name is unchanged, so old addresses never move. Pinned by `SeoTest` and `ProductSlugTest`.
- One `<h1>` per page: product name (`product_details/details.blade.php`), category name (listing), a `sr-only` site title on the homepage; the footer title and the dynamic popups are `div`s.
- **Homepage product links in the HTML** (2026-11-05): every home section used to come by JavaScript (`/home/sections`), so the homepage's HTML had no product link. `HomeController::inline_home_sections()` (called by `frontend.megamart.partials.home_section_slots`) prints the first product sections straight into their containers (`data-kp-inline`, which the fill script in `frontend.layouts.app` skips) **only when the sections' cached text is already there** (`ApiResponseCache::cachedText()`: reads, never builds or locks, so the page never waits): product sections in order until `app.home_sections_inline_products` (24) products or `app.home_sections_inline` (3) sections; strips are skipped. With the cache cold (a product change moved its versions) the page is as before. On the live site the sections are built in one request (~1.1 s, nothing deferred), so the cache is usually warm. Pinned by `HomeInlineSectionsTest`.
- **Random homepage addresses** (2026-11-02): a bot asks for `/?f=2562090369`-shaped addresses (one parameter, a single letter, 6+ digits) since 2026-07, and Google indexed them: Search Console counted ~740k homepage duplicates, 73k 403s and thousands of 5xx. The global middleware `DropRandomHomeQuery` (before the session) answers exactly that shape with 301 to `/`; any other parameter (`utm_*`, `fbclid`, `gclid`, `referral_code`) passes. After deploying, press "Validate fix" on Search Console's "Duplicate without user-selected canonical" row. Pinned by `DropRandomHomeQueryTest`.

### 4.13 Sitemaps, feed, llms.txt

- All sitemaps are `App\Http\Controllers\SitemapController` (since 2026-10-04): `/sitemap.xml` (index, also answered at the old `/sitemap-products.xml`) → `/sitemap-products-{n}.xml` (1000 per file, `{n}` numeric, past the end = 404), `/sitemap-categories.xml` (`Category::visible()`), `/sitemap-brands.xml`, `/sitemap-blog.xml` (`/blog` + `status=1` posts, listed in the index only when one exists). Each answer is cached 6 h (`sitemap.*` keys, dropped by `forget_product_caches()` and `cache:clear`; the product pages through `catalog_cache_key()`). `public/robots.txt` points at `/sitemap.xml`.
- **Sold out for more than 30 days = left out** (`SitemapController::OUT_OF_STOCK_DAYS`, 2026-11-02; the page stays open). `product_out_of_stock` (product_id, since; its own table, a `products` column would rebuild the FULLTEXT table) is kept by `App\Listeners\Stock\TrackProductOutOfStock` on `ProductStockUpdated` (sum of `product_stocks.qty` ≤ 0, not digital → a row; back in stock → deleted). Migration `2026_11_02_000003` filled in the products sold out at deploy with their variants' newest `updated_at` (errs late). On the local copy (2026-10-31): 1,547 of 10,857 sitemap products sold out, 505 of them 30+ days. Pinned by `ProductOutOfStockTest` and `SeoTest`.
- **A product is listed only when its page opens**: `SitemapController::products()` = `filter_products()` (approved + published, no auction, wholesale only with the addon, seller products only from verified shops and only with the vendor system on) + a seller user that exists and is not banned + a non-empty slug. An unverified shop's products open but stay out of the store lists, so they stay out of the sitemap too. Pinned by `tests/Feature/SeoTest.php`.
- **Deploy**: `sitemap*.xml` is gitignored, and a static `public/sitemap.xml` on the server would be served by the web server before this route — delete it.
- Admin "Sitemap Generator" (`/admin/system/sitemap-generator`, `permission:sitemap_generator`): `DoSitemapGenerate` (504) crawls the site with spatie `SitemapGenerator` into `storage/app/public/sitemap_<ts>.xml` and downloads it; it is **not** wired to `/sitemap.xml`.
- `GET /export/product` (`routes/web.php:366` → `get_product_xml` 192): RSS 2.0 `g:` feed of **all** approved+published products, loaded in one query without cache; per-image `Upload::find` (N+1); image URLs hard-coded as `https://kapitano.shop/public/<file_name>` (~258, ~345); link `https://kapitano.shop/product/<slug>`; `g:price` = RAW `unit_price` + ` USD` (~476-484); availability from summed stock.
- `/llms.txt` (llmstxt.org) is `SitemapController@llms` (since 2026-10-06; the hand-written repo-root file was never served and is gone): fixed bilingual intro, shopping/help links and key facts, plus the `Category::visible()` top level with its first sub-level (hidden children skipped), names as «عربي / English» when an English translation differs. Cached 6 h as `sitemap.llms`. The key facts (payment, apps, phone) are literals in the method - edit them there. Pinned by `tests/Feature/LlmsTxtTest.php`.
- **AI crawlers are blocked at Cloudflare, not by us** (checked 2026-10-06): GPTBot, ClaudeBot, PerplexityBot, CCBot get Cloudflare 403 "Your request was blocked" by user agent, before `robots.txt` is read. Allowing them is a Cloudflare dashboard setting (Security → Bots / AI Crawl Control).

### 4.14 Home sections

| Section | Source | Cache |
|---|---|---|
| Featured categories | `HomeController@index` 573 | `featured_categories` forever |
| Today's Deal | `home_section_products_query('todays_deal')` — section loader 580 is **uncached**, `/todays-deal` page 2141 uses `todays_deal_products` forever | |
| Trending (featured) | `get_featured_products()` Helpers 3495 | `featured_products` 3600 s |
| Best Selling | `best_selling_products_query()` — picks from `best_selling_products` else `num_of_sale`, out-of-stock last | none |
| Newest (web + app) | `get_newest_products()` (Helpers, after `get_best_selling_products`) via `load_newest_product_section`: the admin's picks from `newest_product_picks` that customers may see and are in stock first, in their order, then the newest products fill the section to `HOME_NEWEST_LIMIT` (12); no picks = the newest, as before | `newest_products` 3600 s, forgotten by the picker and `forget_product_caches()` |
| Home categories (per category row) | `get_cached_products($category_id)` in 5 theme views | `products-category-{id}` 86400 s, 6 random products — unless the admin picked products for that row (setting `home_category_products` = `{"<category id>": [ids]}`, picker in Website Setup → Home Page → Products by category, `WebsiteController@home_category_products`): the picked ones on sale come first, in the order picked, and fewer than six are topped up with random other products of the category (`…-picked-<md5>`). The picker lists published + approved products only and marks hidden categories like the product form. Pinned by `tests/Feature/HomeCategoryPickedProductsTest.php` |
| Brands strip | `get_ranked_brands()` | `ranked_brands` 86400 s |
| Custom sections (web + app) | `CustomHomeSections::visible()`: active sections by `custom_home_sections.position`, each its `custom_home_section_products` in order through `filter_products()`, at most `max_products`, empty ones dropped; web `load_custom_section` (key `custom_<id>`, placed by `HomeSectionOrder`), app `GET v2/home-sections` (physical only, + `layout`) | homepage JSON + `api_cache:products`, retired by `forget_product_caches()` on every write |
| App equivalents | `Api/V2/ProductController@todaysDeal/featured/bestSeller`, `Api/V2/CategoryController` | `app.*` keys (§6) |

Order is set at Admin → Products → Home Sections (`HomeSectionProductController`; membership and order in `home_section_product_picks`, `App\Services\HomeSectionPicks`, since 2026-10-24 - migration `2026_10_24_000005` moved them out of the four products columns and dropped those) and Best Selling (`BestSellingProductController`). **New Products** (tab "المنتجات الجديدة" on the same pages, `newest-products.*`, `NewestProductPickController`, permission `manage_home_section_products`, migration `2026_10_17_000004`) uses the same list partial (`home_sections/partials/manager`) with its optional category filter (`$categories`): pick a category and its products are listed with no search text (pivot or `products.category_id`), so staff pick products category by category; the storefront section looks unchanged. On all four tabs the search results can be ticked (select all, shift-click for a range) and added in one go (`product_ids[]`, `ConcernsPostedProductIds`, posted order kept, already-picked skipped). The app's home section "منتجات جديدة" (customer app 6.0.1, `fetchNewArrivalProducts`) is `v2/products/search?sort_key=new_arrival` with no filter: `Api/V2/ProductController@search` then orders with `order_by_newest_picks()` (picks first via `newest_product_pick_ids()`, then `created_at` desc, paginated 10, none twice). Any filter (name, category, brand, shop, price, size, colour) or another sort keeps the old order; an unfiltered default sort (the app's search screen opened empty) gets the picks too. Pinned by `HomeSectionProductsTest`, `BestSellingProductsTest`, `NewestProductPicksTest`.

**Strips as sections** (2026-10-29, migration `2026_10_29_000002`): section types `categories` / `shops` / `brands` hold the categories, shops or brands staff pick in their order (`custom_home_section_items`, page `custom_home_sections/strip`); a new strip starts with what customers see now, then is exactly what staff arrange. The Featured categories, Sellers and Brands strips became such sections (`legacy_key`), so `HomeSectionOrder::fixed()` is empty, there is no "bring back" and the Category page's "featured" switch / featured strip ordering page are gone. App: `strips` of `v2/home-sections` (never `data`), `categories/featured` follows the converted section. Map: `CustomHomeSections`' docblock. Pinned by `HomeStripSectionsTest`.

**Removing a section** (2026-10-25): every section is listed and deleted the same way, with no built-in / custom label - a section staff created is deleted, a built-in one leaves the list and the tabs for a "Deleted sections" card with "Bring back" (it fills itself or is a logo strip, so it cannot be made again) and is removed from the homepage and the app (`home_sections_hidden`, `HomeSectionOrder::setHidden()` / `hiddenKeys()` / `visibleKeys()`), its products kept to bring it back: `build_home_sections()` skips it, `layout` leaves it out, `Api/V2/ProductController@todaysDeal` / `featured` / `bestSeller` answer an empty list. One side-menu link "الأقسام" opens the order page. Pinned by `CustomHomeSectionsTest` and the opt-in `CustomHomeSectionsRuntimeTest`.

**Section types, Best Selling and New Products as sections, View all** (2026-10-26, migration `2026_10_26_000001`): `custom_home_sections.type` (`CustomHomeSections::TYPES`: `manual`, `best_selling`, `newest`, `category` + `category_id`, `brand` + `brand_id`, `on_sale`) and `picks_only`; `CustomHomeSections::query()` is the single query (picks first by position; `manual` / `picks_only` = picks alone; Best Selling keeps out-of-stock last, New Products puts a pick first only while in stock). Best Selling (`picks_only`, 20, picks from `best_selling_products`) and New Products (12, picks from `newest_product_picks`) became sections with `legacy_key` `best_selling` / `newest`, in their place of `home_sections_order`; `best_selling_products_query()` without a seller, `get_newest_products()`, `newest_product_pick_ids()` / `order_by_newest_picks()` read them (the old tables stay, unread); `BestSellingProductController` / `NewestProductPickController` index redirect to the section page. Built-in left: the Sellers and Brands strips. "View all": `/section/{id}` (`home-section.products`, `HomeSectionPageController`, 24 a page) and `api/v2/home-sections/{id}/products` (20 a page); `home-sections` rows carry `type` and `links.all`. The section page has the product search with a category filter and, for a type that fills itself, "Shown to customers now" (`custom_home_sections/preview`). Pinned by `CustomHomeSectionTypesTest` and the opt-in `CustomHomeSectionsRuntimeTest`.

**Today's Deal and Trending are ordinary home sections** (2026-10-25, migration `2026_10_25_000002`): each is a `custom_home_sections` row with `legacy_key` = `todays_deal` / `featured`, made from `home_section_product_picks` (dropped) with its products in order, its name from `todays_deal_title_*` / `featured_title_*` or the old default, its place in `home_sections_order` (`todays_deal` / `featured` → `custom_<id>`) and hidden if it was deleted from the homepage; Today's Deal shows up to 50 on the website (it showed all), Trending 12. Staff rename, size, hide and delete them like any section, and a deleted one is gone for good. `HomeSectionPicks::section()` finds them; `home_section_products_query()` reads them for `products/todays-deal`, `products/featured`, `/todays-deal` and the other themes (empty while hidden or once deleted); `/home/sections` no longer builds the `todays_deal` / `featured` keys (they come as `custom_<id>`); `HomeSectionProductController` only redirects old links; the megamart `todays_deal` partial (its banner carousel) and `featured_products_section` are no longer used by the homepage. Built-in sections left: Best Selling, Best Sellers, Brands, New Products. Pinned by `HomeSectionProductsTest`, `HomeSectionPicksMigrationTest`, the opt-in `CustomHomeSectionsRuntimeTest` / `HomeSectionsRuntimeTest`; `ApiHotPathsRuntimeTest` showed every app answer unchanged.

**Custom sections** (2026-10-24, migration `2026_10_24_000004`): staff create sections with their own Arabic / English name (tab "+ أقسام مخصصة" on the same pages, `custom-home-sections.*`, permission `manage_home_section_products`), fill them with the same list partial, order the products and the sections, hide one (`is_active`) and choose how many products show (`max_products`, 1-50). Everything is in `App\Services\CustomHomeSections` (docblock = map). Each is its own key `custom_<id>` of `/home/sections` (container `#section_custom_<id>`, ids from the cached `visibleIds()`, fallback route `home.section.custom`). **The homepage order** of Today's Deal, Trending, Best Selling, Best Sellers, Brands, New Products and every custom section is one drag list on the tab "ترتيب الأقسام" (`custom-home-sections.index`), stored as the JSON setting `home_sections_order` (`App\Services\HomeSectionOrder`: no row = the old page with the custom sections after Today's Deal; a section created since goes last; saving also writes `custom_home_sections.position`). Only megamart follows it (`megamart/partials/home_section_slots`, the old markup of each section moved there unchanged; since 2026-10-29 the featured categories strip is in the list too, key `featured_categories` in `HomeSectionOrder::FIXED`, moved, deleted and brought back like the Sellers strip, drawn by `megamart/partials/featured_categories_section`, and the banners that sat under it, `megamart/partials/home_banners_under_first_section`, are drawn under the first key of the order; each section is drawn on the background colour staff chose on the order page, `App\Services\HomeSectionBackgrounds`, app `backgrounds`); the other themes keep their layout with the custom containers after Today's Deal. The sections show and in the app through `GET v2/home-sections` (contract `docs/api/custom-home-sections.md`, also the `custom_home_sections` group of `app/data`) - old app versions do not show them. **Today's Deal / Trending membership is set only from their pages since 2026-10-24**, and lives in its own table `home_section_product_picks` (`App\Services\HomeSectionPicks`, docblock = map; migration `2026_10_24_000005` copied the flagged products in the order customers saw them and dropped `products.todays_deal`, `featured`, `todays_deal_position`, `featured_position` in one ALTER). `$product->todays_deal` / `->featured` still answer 0/1 (accessors reading the table once per request), so the app resources (`ProductCollection`, the seller app's product details), the export and the admin list are unchanged; the setters drop any write, so a form, an import or the seller app posting them changes nothing. The product list shows a badge instead of the two switches (their routes `products.todays_deal` / `products.featured`, `ProductController@updateTodaysDeal` / `updateFeatured` and the list JS were removed 2026-10-25), the product form has none, and `ProductService` drops both from every save (`HOME_SECTION_FLAGS`) - before, `update()` reset both to 0 when not posted, so a seller's edit (web or app) took the product out of the section, and a seller could post `todays_deal=1`. A deleted product leaves both tables (`Product::deleted`). Management's views compute the two columns from the table (`sqlupdates/kapitano_reports_readonly_access_2026-10-04.sql`). Pinned by `HomeSectionProductsTest`, `HomeSectionPicksMigrationTest`. Pinned by `CustomHomeSectionsTest`.

**Section names** (Best Selling, Today's Deal, Trending, New Products): the same admin pages have a "Section name" form (Arabic + English, `best-selling-products.title` / `home-section-products.title`). Everything is in `App\Support\HomeSectionTitles`: the names are the `business_settings` rows `best_selling_title_ar|en`, `todays_deal_title_ar|en`, `featured_title_ar|en` and `newest_title_ar|en` (website only) (no `lang`, because `v2/business-settings` drops that column — the app reads them there; it still has to be changed to show them). Storefront headings call `HomeSectionTitles::title($section, <old translate key>)` in every theme copy (`*/partials/best_selling_section`, `*/partials/featured_products_section`, `*/partials/newest_products_section`, `megamart/partials/todays_deal`, `reclassic/index`, `inc/floating_buttons`); an empty name deletes the row and the old `translate()` heading comes back. The seller shop page's own "Featured Products" heading is not this section and keeps `translate()`. Pinned by `HomeSectionTitlesTest`.

---

## 5. Change together (duplicated logic)

**If you change one, grep and change the others.**

1. **Product write pipeline** (service calls + pivot + stock + translation + cache clear) — 6 copies:
   `ProductController@store/update` (admin), `Seller/ProductController@store/update`, `Api/V2/Seller/ProductController@store/update`, `Api/V2/ProductController@store` (AI, uses `store1`), `Seller/DigitalProductController` + `Api/V2/Seller/DigitalProductController`, `WholesaleProductController`s (via `WholesaleService`). Plus the non-service writers: `ProductsImport::collection`, `PartnerCatalogWriter::importOne/linkOne/applyOne` (one list: `productFields`), `ProductTransferService::clone`. A new required `products` column must be added to **all** of them (CLAUDE.md says so for the partner writer explicitly).
2. **`ProductService::store` vs `store1`** (18 vs 202): near-identical copies; `store1` adds URL→upload conversion and forces admin owner/unpublished.
3. **Category attachment rules**: seller web adds ancestors (`categoryIdsWithAncestors` 247); partner uses `category_ids_with_ancestors()` + top-level main; admin web, seller API, AI endpoint, import attach raw ids; bulk service uses its own `destinationIds` (162). Category pages read the **pivot** (`Category::products`), app search/filter read **`products.category_id` + children**, `get_cached_products` reads **both** — a fix to "which products are in category X" touches `SearchController@index`, `Api/V2/ProductController@search/categoryProducts`, `FilterController@matchingQuery`, `get_cached_products`, the admin route closure `routes/admin.php:105`, `ProductCategoryBulkService::whereInCategories` (135).
4. **Slug generation** — every product path uses `unique_product_slug()` (store, store1, update via `slugOnUpdate()`, duplicate, `ProductsImport`, partner, transfer) since 2026-10-13. Existing duplicate slugs from the old `LIKE`-count rule were left in place on purpose. Pinned by `tests/Feature/ProductSlugTest.php`. Category slugs (2026-11-02): `Category::makeSlug()` (Str::slug of the typed slug or the name, Arabic transliterated, max 60 chars cut on a `-`, `-2` on a clash) on create; on update the slug changes only when staff type another (an emptied field or the posted current one keeps it). Every rename keeps the old slug in `category_slug_redirects` (`Category::updated`), and `Category::findBySlug()` (own slug, else an old one) is what `/category/{slug}` (301 to the real address, also for another letter case), the app's `category/info`, `products/category/{slug}`, `categories?parent_id=` and push flow B's weekly category read. Migration `2026_11_02_000001` gave the 96 Arabic-named categories with a random slug (`--5yvfe`) an English one (only rows still on the old slug). Pinned by `tests/Feature/CategorySlugTest.php`.
5. **Keyword matcher** — 5 copies that must fold Arabic identically via `SearchUtility`: `SearchController@index` (222-237, +SKU), `SearchController@ajax_search` (400-447), `Api/V2/ProductController@search` (456-472), `FilterController@matchingQuery` (97-110; comment says it must equal the API search so `product_count` matches), `SearchSuggestionController::productSuggestions/categorySuggestions/brandSuggestions` (118-180). Legacy unfolded LIKE remains in `getList` (187), shops and preorder search, admin lists, `ProductService::product_search` (581).
6. **Search logging**: `SearchUtility::store` (122) and `SearchController::store` (484) are duplicates writing `searches`; `search_events` inserts in `SearchController::trackSubmittedEvent` and `SearchTrackingController`; normalization **must** be `SearchQuery::normalizeQuery` in both and in `AggregateSearchEvents`.
7. **Upload handling** — web `AizUploadController@upload`, `Api/V2/AizUploadController@upload`, `CustomerFileUploadController@upload`, `Seller/SellerFileUploadController@upload` (the "3 copies"), plus base64 writers `FileController@imageUpload`, `FileUploadController`, `ProfileController` ×2, `Api/V2/ReviewController@submit`, and URL→upload writers `ProductService::saveImageFromUrl`, `ProductsImport::downloadThumbnail`, `PartnerCatalogWriter::createUpload`, `ProductTransferService::cloneUploadId`. Extension whitelist, SVG sanitizing, resize/watermark, `normalize_upload_url`, `strip_4byte_chars` must be kept in step manually.
8. **Upload delete** — `AizUploadController@destroy/all_file` and the three API copies each unlink files; shared upload ids from `duplicate()` mean deleting one product's image breaks the other (transfer copies files; duplicate does not).
9. **Image URL building** — `uploaded_asset()`, `get_image()`, `getFileBaseURL()`, `get_preview_files`, product XML feed (hard-coded host + `/public/`), API resources (`ProductMiniCollection`, etc. — **(unverified)** which helper each uses).
10. **Product visibility rules** — `filter_products()` is the reference; divergent copies: `get_cached_products` (no seller verification/auction filter), `productsIndex/productsXml` (published only), `get_product_xml` (approved+published), `HomeController@product` (approved + published + vendor/banned/wholesale checks), `Api/V2/ProductController@search` (`published` then `filter_products`), `routes/admin.php:105` closure.
11. **Price shown for SEO/feeds** — `product_details.blade.php:47` (`single_price(unit_price)`, RAW into a BASE helper), `:51` (`number_format(unit_price)` labelled SYP), feed `g:price` (RAW labelled USD). Any change to the price contract or fake discounts must revisit these.
11b. **Category product order** — `CategoryProductOrder::applyTo()` is called by `SearchController@index` (category, default sort, no keyword), `Api/V2/ProductController@categoryProducts` and `@search` (one category, default sort, no name). A new customer list of one category's products must call it too.
12. **Home section membership** — `home_section_products_query()` and `best_selling_products_query()` are shared by web and app on purpose; do not re-implement in a controller.
13. **Cache invalidation** — see §6; product writers call `forget_product_caches()`; category, brand and home-section writers forget a hand-picked list of keys. A new cached list that shows products goes into `forget_product_caches()` (fixed key) or is read through `catalog_cache_key()` (key built per category / shop / term / page); category lists go into `forget_category_caches()`, brand lists into `BrandController@updateOrdering`, etc.
14. **Stock totals** — `withCurrentStock()`, `best_selling_products_query()` SQL, `SearchLandingController/SearchSuggestionController::refreshCurrentStock` (58/56), `get_product_xml` loop, `product_details.blade.php:11-22`, `InventoryController` totals.
15. **Review rating recompute** — `ReviewController@store` (133), `customReviewUpdate` (204), `updatePublished` (266), `Api/V2/ReviewController@submit` (185), plus seller rating (141/196).
16. **Wishlist** — web `WishlistController` and `Api/V2/WishlistController` (slug-based), deleted in `ProductController@destroy` and `ProductService::destroy`.
17. **Pending brands / attributes / values** (`brands|attributes|attribute_values.approved`, `App\Services\CatalogRequests`) — customer-facing brand lists go through `Brand::approved()` (in `scopeRanked`, `get_all_brands`, `get_brands`, `get_brands_by_products`, `FilterOptionsUtility::brands`, `SearchUtility::brandIdsWithProducts`, `Api/V2/SearchSuggestionController` type=brands) and filters through `FilterOptionsUtility::dropPending`; product forms and the seller app (`Api/V2/Seller/ProductController@getBrands/getAttributes`) through `CatalogRequests::visibleTo()`. A new customer-facing brand or attribute list must do the same.
18b. **Writers of `product_stocks.qty`** — the product forms (`ProductStockService::store()`, in place), orders (`OrderStockUtility`), partner sync (`PartnerCatalogWriter::syncStocks`), Excel import, stock zeroing (down to the warehouse pieces, never below), and the company warehouse (`Warehouse::fill()` raising the total, `Warehouse::takeOut()` lowering it for damaged / lost pieces, `WarehouseCounts::finish()` doing either for a count correction; partner sync never leaves a held variant below its warehouse pieces). Each goes through Eloquent so `ProductStockUpdated` fires; a writer that is not the seller runs inside `SellerStockUpdates::quietly()`. Once the warehouse holds pieces, a new writer must never take a variant's total below its `warehouse_stocks.qty` (the product forms check it through `ProductStockService::warehouseRefusal()`, and `store()` refuses it).
18. **Attribute value options** — `CatalogRequests::valueOptions()` (sorted by `App\Support\AttributeValueOrder`, keeps the product's own values) is used by admin and seller `add_more_choice_option` and both edit pages; the admin Attributes page and `SizeChartController` still list values in insertion order.

---

## 6. Caching keys

| Key | TTL | Set at | Cleared by / when you must clear |
|---|---|---|---|
| *(product lists)* | — | — | `forget_product_caches()` (Helpers.php) after every product write (admin/seller/digital create, update, delete, publish, approve, feature, transfer; seller app store/update/status/feature/destroy; partner batches; fake discounts; seller pricing; bulk category change; seller approve/reject/ban). It forgets the fixed keys (`featured_products`, `todays_deal_products`, `newest_products`, `brand_ids_with_products`, `app.flash_deals`, `cached_graph_data`, `verified_sellers_id`, `best_selers`, brand lists, `sitemap.*`) and changes `catalog_cache_version`, which retires every key read through `catalog_cache_key()`: `products-category-{id}`, `app.{top_selling,featured,new}_products-{shop}`, `filter_options_*`, `search_landing_v2_*`, `sugg_v2_*`, `search_landing_web_{locale}`, `sitemap.products.{n}`. Until 2026-10-12 these writers ran `cache:clear` (whole cache). `cache:prune-expired` (daily) deletes the expired files. |
| `products-category-{id}@{version}` | 86400 | `get_cached_products` Helpers:199 (`catalog_cache_key`) | `forget_product_caches()` |
| `hidden_category_ids` | forever (+ container memo) | Helpers:232 | `forget_category_caches()` via `Category::saved/deleted` |
| `featured_categories` | forever | HomeController:573 | `forget_category_caches`, `CategoryController` 246/273/284 |
| `app.featured_categories`, `app.home_categories`, `app.top_categories` | 86400 | Api/V2/CategoryController 32-46 | `forget_category_caches` |
| `app.filter_categories` | 86400 | FilterController:21 | `forget_category_caches` |
| `app.filter_brands`, `app.top_brands`, `ranked_brands` | 86400 | FilterController:34, Api/V2/BrandController:31, Helpers ~3962 | `forget_brand_caches()`: `Brand` / `BrandTranslation` saved or deleted, brand approval, `forget_product_caches()`; also `BrandController@updateOrdering` |
| `brand_ids_with_products` | 3600 | SearchUtility:113 | never explicitly |
| `featured_products` | 3600 | Helpers:3497 | `HomeSectionProductController::forgetCaches` 189 |
| `todays_deal_products` | forever | HomeController:2141 | `ProductController@updateTodaysDeal` 709, `HomeSectionProductController` 188 |
| `newest_products` | 3600 | HomeController:588 | full clear |
| `app.flash_deals` | 86400 | Api/V2/ProductController:371 | full clear |
| `app.top_selling_products-{id}`, `app.featured_products-{id}`, `app.new_products-{id}` | (see file) | Api/V2/ShopController 52-70 | full clear |
| `best_selers` (sic) | 86400 | Helpers:3946 | `SellerController:591` |
| `verified_sellers_id` | forever | Helpers:290 | `SellerController` 348/367/398 — affects `filter_products()` |
| `search_landing_web_{locale}` (rendered HTML) | 900 | SearchController:29 | expiry only |
| `search_landing_v2_{locale}_{limits}` | 900 | SearchLandingController:28 | expiry only |
| `sugg_v2_{locale}_{md5(first 10 chars)}` | 300 | SearchSuggestionController:39 | expiry only |
| `filter_options_{locale}_{md5(params)}` | 300 | FilterController:52 | expiry only |
| `search_placeholder_{locale}` | 900 | Helpers:4973 | expiry only |
| `business_settings`, `translations-{lang}`, `system_default_currency`, `currency_rates_syp_usd` | 86400 / forever / 86400 / 10 min | Helpers | settings and language controllers; rates → CLAUDE.md |
| request-scoped: `_kapitano_uploaded_assets`, container `hidden_category_ids`, `home_section_picks.<section>` (request_memo) | 1 request | Helpers | — |
| `api_response.<sha1>` (app catalogue answers as text; `.lock`) | profile ttl: products/flash deals 600, brands/shops/banners 3600, categories 86400; capped by the next discount / flash-deal window, flash deals by midnight | `ApiResponseCache` via `api_cache:<profile>` | the key holds the source versions below: never forgotten, superseded |
| `api_cache_version.{stock,categories,brands,shops,settings,currency,geo}` (+ `catalog_cache_version`) | forever | `ApiCacheVersions` | moved by the `forget_*_caches()` helpers, model hooks, `ProductStockUpdated` |
| `api_response.{home_sections,ajax_search,product_feed}.<sha1>` (website text) | 600 / 600 / 3600, capped by price windows | `ApiResponseCache::text()` from `HomeController`, `SearchController` | the source versions in the key |
| `best_selling_ids.{seller}.{limit}.{stock version}@{catalog}`, `fqb_pool.{category}.{owner}@{catalog}` | 600 | `get_best_selling_products()`, `get_frequently_bought_products()` | catalogue / stock versions |
| `api_response.next_price_change@{catalog version}`, `api_response.language_codes` | 300 / 3600 | `ApiResponseCache` | catalogue version / `Language` saves |
| `schema_ready.{version}.{table}...` (only "yes"), `schema_ready_version`, `arabic_language_code` (only `ar`) | 86400 / forever | Helpers `schema_ready()`, `ArabicLanguageCode` | `MigrationsEnded`; `ArabicLanguageCode::forget()` |
| `app_data.since.{group}.{12 hex of the body's sha1}` (when that exact content of a shared `app/data` group was first served: the time half of its stamp) | 30 days | `AppDataSync::stamps()` | expiry only (a lost one = a new stamp, the app downloads that group once) |
| `app_data.own.{group}.{sha1 of "user:id" / "guest:temp id"}` = `{hash, since}` (the account's / guest cart's own groups: when their content last changed) | 30 days | `AppDataSync::stamps()` | rewritten when the content changes; expiry only |

Hidden/visible-category changes are instant for lists in `forget_category_caches()`, but search caches (landing/suggestions/filters) keep a hidden category for up to 15 min (documented at Helpers:274-276).

---

## 7. Permissions & routes

### Admin (`routes/admin.php`, `auth`+`admin` group; spatie permissions in controller constructors)

| Area | Routes | Permission(s) |
|---|---|---|
| Products | `/admin/products/*` (265-291), `/admin/products/export` (261) | `add_new_product`, `show_all_products`, `show_in_house_products`, `show_seller_products`, `product_edit` (also todays_deal/featured/published/approved/discount), `product_duplicate`, `clone_or_move_product`, `product_delete`, `product_bulk_export`, `set_category_wise_discount` |
| Bulk import/export | `/admin/product-bulk-upload/*`, `/admin/bulk-product-upload` (304-313) | `product_bulk_import`, `product_bulk_export` |
| Bulk category change | `/admin/products/bulk-categories` (807-808) | `bulk_change_product_categories` |
| Categories | `categories.*` (211-232), `/category/ordering` (Category Ordering: one group of siblings at a time, `?parent_id`; save refuses ids from two groups and calls `forget_category_caches()` once), `/category/ordering/{category}/products` (+ `/candidates`, `/update`: products shown first in that category, `CategoryProductOrder`) | `view_product_categories` (save: `edit_product_category`), `add_/edit_/delete_product_category`, `set_category_wise_commission`, `products_report` |
| Brands | `brands.*`, `/brand/ordering` (232-251) | `view_all_brands`, `add_brand`, `edit_brand`, `delete_brand` |
| Attributes / colors | 883-899 | `view_/add_/edit_/delete_product_attribute(_value[s])`, `view_colors`, `add_/edit_/delete_color` |
| Size charts / measurement points | 905-914 | `view_measurement_points`, `add_/edit_/delete_size_charts` |
| Home sections / best selling | 790-802 | `manage_home_section_products`, `manage_best_selling_products` |
| Partner stores | 684-700 | `manage_partner_stores` (route middleware) |
| Uploaded files | 970-977 | `manage_uploaded_files` (route middleware; controller is shared) |
| Reviews | 835-836 | `view_product_reviews`, `publish_product_review`, `add_custom_review`, `edit_custom_review` |
| Sitemap generator | 1021-1025, and `/system/sitemap-item-add/{item}` (1028, **outside** the admin group) | `sitemap_generator` |
| **No auth** | `GET /admin/home-category-products/{id}` (105-120) — closure outside every group | none |

### Seller web (`routes/seller.php`, `seller`,`verified`,`user` middleware)
`/seller/products*` (48-66), `/seller/inventory*` (70-73), `/seller/product-bulk-upload/*` (77-80), `/seller/uploads*` (32-37), `/seller/digitalproducts*` (87-93), reviews (130-131).

### Storefront (`routes/web.php`)
`/search`, `/ajax-search`, `/search/landing-box`, `/category/{slug}`, `/brand/{slug}` (455-460); `/product/{slug}` (407); `/todays-deal` (405); `/home/section/*` (389-394); `/compare*` (517-520); `/wishlists*` (583-584, customer group); `/reviews` resource (640, `auth` group); `/aiz-uploader/*` (226-232, **no auth middleware**); `/sitemap.xml` (440), `/sitemap-products*.xml` (300-301), `/export/product` (366), `/ai/data` (368), `/size-charts-show/{id}` (438).

### Mobile API (`routes/api.php`, prefix `api/v2`, `app_language`)
Public: `products/*` (512-530), `search/*` (438-442), `get-search-suggestions` (436), `filter/*` (495-497), `reviews/product/{id}` (541), **`product/add/ai` (500, token in code)**. Sanctum: `reviews/submit` (346), `wishlists*` (360-363), `file/image-upload`, `file-all`, `file/upload` (401-403), customer `file/*` (696-700). Seller API (`routes/api_seller.php`): `products/*` (51-60), `digital-products/*` (80-85), `file/*` (138-142).

---

## 8. Gotchas, known bugs, security issues, performance

### Security (highest first)

1. **Path traversal + arbitrary file write/delete via base64 uploads.** `Api/V2/FileController@imageUpload` (`FileController.php:41-43`), `FileUploadController.php:42-44`, `ProfileController.php:185-188` and `:298-301` build `public_path('uploads/all') . "/$request->filename"` and `file_put_contents` it **before** validating the extension; a non-image extension is then `unlink`ed. A `filename` like `../../index.php` overwrites and then deletes that file. Reachable by any Sanctum user (`file/image-upload`, `profile/image-upload`). SVG is accepted without sanitizing (stored XSS on our origin).
2. **Seller IDOR on product update/delete through the API and digital products.** `Api/V2/Seller/ProductController@update` (163) and `Api/V2/Seller/DigitalProductController@update` (95) / `@destroy` (136, calls `ProductService::destroy` on any id) and web `Seller/DigitalProductController@update` (142) have no `user_id` check — the same hole fixed in web `Seller/ProductController@update` (comment at 182-183).
3. **Mass assignment into `products`.** `Product::$guarded = ['choice_attributes']` (Product.php:20) and `ProductService::store/update` pass `$request->except([...])`. A seller can post `approved=1` on update (store overwrites it, update does not), `featured`, `todays_deal`, `num_of_sale`, `rating`, `real_price`, `user_id`, `added_by`, … **(verified by reading; not exploited)**.
4. **`POST /api/v2/product/add/ai`** is authenticated only by a hard-coded token in source (`Api/V2/ProductController.php:79`, value `683d***`); anyone with repo access can create products; exception messages are returned to the caller.
5. **Unauthenticated web uploader.** `/aiz-uploader/upload` has no auth middleware (`routes/web.php:226-232`); the file is written to `public/uploads/all` before `Auth::user()->id` fails at `AizUploadController.php:268` (non-image types are stored raw). `/aiz-uploader/download/{id}` and `/aiz-uploader/get_file_by_ids` expose any upload by id (seller documents, etc.). CSRF token is freely available at `/refresh-csrf` (web.php:221).
6. ~~Vendor license backdoor `GET /system/sitemap-item-add/{item}`~~: removed 2026-10-12 with the other vendor licence checks (00 §3.3).
7. `Api/V2/FileController@index` (12-13): any non-seller (customers) lists **all** uploads.
8. Web `WishlistController@remove` (53-61) deletes any wishlist row by id (no owner check).
9. Reviews: web `/reviews` store (107) accepts any authenticated user, non-customers create "custom" reviews with arbitrary name and back-dated `created_at`; API `submit` has the purchase check commented out (`Api/V2/ReviewController.php:36-48`). `HomeController@product` `review_status` (1599-1603) uses an eager-load constraint that does not filter the parent row, so any delivered order line by *anyone* enables the review form.
10. Admin GET `/admin/all-file` (`AizUploadController@all_file` 367) deletes every upload and truncates the table — a GET, CSRF-able from a staff member's browser.
11. `GET /admin/home-category-products/{id}` (admin.php:105) is public and runs an unindexed `whereHas('categories')`.

### Functional bugs

- **Feed images 404 (likely)**: `get_product_xml` hard-codes `https://kapitano.shop/public/<file>`, but the docroot is `public/` (see `normalize_upload_url` docblock) **(unverified against live server)**. Feed price is RAW `unit_price` labelled USD, ignoring `exchange_type` and fake discounts (inflated `unit_price`, no `g:sale_price`).
- Product meta: `meta_title` is never translated. (The empty category and the RAW `og:price` were fixed on 2026-10-04, §4.12b; `digital_product_details` and `customer_product_details` still use `single_price(unit_price)`.)
- `ProductService::update` resets `featured` and `todays_deal` to 0 when absent (398-403); the seller form has neither field, so **a seller editing a product removes it from admin-curated Trending / Today's Deal**.
- Slugs: ~~`LIKE` counting renamed URLs on a plain edit and handed out taken slugs~~ — fixed 2026-10-13 (new products only; old duplicate slugs remain, the first match wins on `/product/{slug}`); `Str::slug` of text without transliterable chars gives `''`/`-2`. ~~Category update regenerates a random slug when the slug field is empty and strips all Arabic from auto slugs~~ — fixed 2026-11-02 (`Category::makeSlug()`, old slugs redirect). ~~Brand slugs followed the same CMS rule (all 261 live brands had a random tail, `samsung-bmibv`; HP and XIAOMI were `demo-brand-12` / `demo-brand1`)~~ — fixed 2026-11-05: categories and brands share `App\Models\Concerns\HasOldSlugs` (`makeSlug()`, `findBySlug()`, old slugs kept on every rename in `category_slug_redirects` / `brand_slug_redirects`). Brand slugs are set by `BrandController` and `CatalogRequests::addBrand` (seller "add brand"); `/brand/{slug}` answers 301 from an old or wrong-case address, and the app's `products/brand/{slug}` and every `?brand=` filter read `Brand::findBySlug()`. Migration `2026_11_05_000001` gave all 261 brands their name as slug (list `.scratch/seo/brand-slugs.md`, tried on production's tables). Pinned by `BrandSlugTest` and `CategorySlugTest`.
- Web category page + brand: `SearchController@index` builds `Product::where(['brand_id'=>…])` then **replaces** it with `$category->products()` (184-201), dropping the brand filter **(unverified whether the UI sends both)**.
- Multi-word keyword matching mixes AND/OR precedence (`whereRaw` then `orWhere…` per word in one closure, 224-237): effectively `(w1 cols…) OR … w2name AND …` — results are neither "all words" nor "any word" reliably.
- App price filter mismatch: `/products/search` converts `min/max` from SYP (430-431) but `/filter/options` returns raw `unit_price` and compares raw — passing `price.min/max` back as `SEARCH_API.md` instructs filters on the wrong unit.
- App search/filters use `products.category_id` (+children) while web uses the pivot → different results for multi-category products.
- Suggestion cache keyed by the first 10 chars (SearchSuggestionController:38-39): longer queries get results for their prefix.
- `get_cached_products` shows auction / unverified-seller products (no `filter_products`) and uses `orWhereHas('categories')` on the unindexed pivot.
- Admin create/import/AI/seller-API don't attach category ancestors → product missing from parent category pages.
- `ProductsImport`: `dd()` on row error (409), no transaction, no translation row, `thumbnail_img` never downloaded.
- Upload: failed image encode still saves an `Upload` row pointing at a missing file (AizUploadController:238-271). `duplicate()` shares upload ids; deleting an upload from one product breaks the copy.
- Two routes on `POST api/v2/file/upload` (api.php:403 and 697).
- `routes/api.php:515-516, 524` point to `subCategory`, `subSubCategory`, `newFromSeller`, which do not exist in `Api/V2/ProductController` → 500.
- Fixed 2026-10-12: brand create/update/delete cleared no brand cache (`Brand::booted` now does).
- Fixed 2026-10-22: the app product page `products/{slug}/{user_id}` remembered a guest's view (the app sends `0`, and `"0" != null`) under a shared "user 0" list, and took any `user_id` in the URL. It now goes through `last_viewed_owner_id()` (Helpers.php): the token's account if one is sent, else the URL id only when it is a customer. An unknown slug no longer crashes (`$product[0]`), and the 12-row cap in `lastViewedProducts()` drops the oldest views (it used to drop arbitrary rows). Migration `2026_10_22_000001` deletes the old `user_id = 0` rows. Pinned by `AppLastViewedProductsTest`.
- `llms.txt` is at the repo root, outside `public/` **(unverified whether served)**.

### Performance hotspots

- `product_categories` had **no index** until `2026_10_12_000001`. A correlated `whereHas('categories')` on it took ~58 s per category; `get_cached_products` (homepage category sections: 241 s on a cold cache), the app's category-coupon product list, the admin coupon filter and `/admin/home-category-products/{id}` now use `whereIn('products.id', product_ids_in_categories_query($ids))` (Helpers.php). Never add a `whereHas('categories')`. Pinned by `HomeCategoryProductsTest`.
- The homepage cache went cold on every product save (`cache:clear`) until 2026-10-12; now only the product lists are rebuilt (`forget_product_caches()`). `api/v2/app/version` stopped emptying it on every app launch the same day (00-platform §4).
- Web search keyword matcher: leading-wildcard LIKE over `description`/`meta_description` + folded `REPLACE()` chains + correlated EXISTS on translations/stocks (~10 s measured per code comment at SearchController:298-302); the id-pluck trick limits it to one execution but plucks **every** matching id (whole category on listing pages) into a `whereIn` list.
- Fixed 2026-10-12: product saves ran `cache:clear` + `view:clear` (cold-cache stampede, unrelated caches wiped: settings, currency, OTP codes, scheduler locks). Measured locally: `cache:clear` alone 174 ms vs `forget_product_caches()` 3 ms, before the rebuild of everything else.
- `/export/product` loads the full catalog with stocks, `Upload::find` per image; `/api/v2/filter/options` and `FilterOptionsUtility` run aggregate queries per keyword (5-min cache).
- `search:recompute-scores` updates every row of `products`, `categories`, `brands` hourly; `search_events` grows forever.
- `Product::$with` eager-loads translations, taxes and thumbnail on every query, including counts/plucks built from Eloquent.

---

## 9. Tests covering this domain

All SQLite unless noted "runtime" (opt-in `KAPITANO_RUNTIME_DB_TESTS=1`, real MySQL, rolled back).

| Test | Covers |
|---|---|
| `tests/Feature/CategoryVisibilityAndBulkChangeTest.php` | hidden categories, `Category::visible()`, bulk category service |
| `tests/Feature/CategoryVisibilityRuntimeTest.php` (runtime) | same against live data |
| `tests/Feature/HomeSectionProductsTest.php` | Today's Deal / Trending positions and caches |
| `tests/Feature/BestSellingProductsTest.php` | best-selling picks and ordering |
| `tests/Feature/PartnerStoreSyncTest.php` | fetch/import/apply, categories, pricing, `strip_4byte_chars` (`Http::fake`, Dawana preset) |
| `tests/Feature/PartnerVariantsSyncTest.php` | presets/settings (CLOI-, Sool-, Itel- and WooCommerce-shaped fakes; Sool's specs in the description; the partner's offer struck through and who holds the discount), reader path forms (`!a`, `a?b`, currency signs, PascalCase keys), variants -> stock rows with stable ids, locks, availability flags, inactive products, videos, old / new Syrian pounds, most-specific category, cart variant contract, linking hand-entered products (matcher + `linkBatch`) |
| `tests/Feature/ProductTransferTest.php` | clone/move incl. upload copies |
| `tests/Feature/FakeDiscountTest.php` | `real_price` / resync after product edits |
| `tests/Feature/ExchangeTypePricingTest.php`, `ExchangeTypeRuntimeTest.php` (runtime) | product price contract, product page render |
| `tests/Feature/ProductProfitSortTest.php` | admin product list sort |
| `tests/Feature/NormalizeUploadUrlTest.php` | `normalize_upload_url()` |
| `tests/Unit/ProductImageFallbackTest.php` | image fallback to color/stock images |
| `tests/Feature/ProductionNullRegressionTest.php` | wishlist API contract, `ProductMiniCollection` stock/N+1, `ajax_search` empty input, public seller product visibility; `test_search_works_with_preorder_disabled_and_quote_in_query` **fails by design** (`no such table: search_queries`) |
| `tests/Feature/StockZeroingTest.php` | `StockZeroUtility` zeroing on cancel |
| `tests/Feature/SellerWorkflowRuntimeTest.php` (runtime) | seller product flows (read file for scope) |
| `tests/Feature/AuditLogPageRuntimeTest.php`, `ModelAuditingTest.php` | product auditing |
| `tests/Feature/AppLastViewedProductsTest.php` | app product page "last viewed": guests, URL id vs token, 12 newest, guest-row cleanup migration |

**Not covered**: search relevance / Arabic folding, `/api/v2/search/*`, `/filter/options`, aggregation commands, uploads (any copy), `ProductsImport`, AI endpoint, XML feed, reviews, admin product store/update end-to-end.

---

## 10. Open questions

1. Which `POST api/v2/file/upload` handler actually serves requests in production (AizUploadController vs CustomerFileUploadController)? Check `php artisan route:list` (not run here).
2. Is `llms.txt` served (server alias / deploy step copying it into `public/`)? Is `/sitemap.xml` meant to serve a generated file — where should it come from?
3. Does the live feed consumer (Google Merchant / Meta) accept the `/public/` image URLs and USD RAW prices? Is `real_price` supposed to be the feed price during fake discounts?
4. Is the AI endpoint still used, and by whom? Rotate its token and move it to config / Sanctum.
5. Does the web price filter send SYP or RAW? Which unit should the app send to `/products/search` — the code or `SEARCH_API.md` is wrong.
6. Should admin create / seller API / import / AI attach category ancestors like the seller web form and partner writer do? (Affects category page completeness.)
7. Is `FileUploadController` (API) routed anywhere (no route found by grep)? Can the base64 upload endpoints be removed from the app?
8. Should `search_events` be pruned after aggregation, and should `recompute-scores` update only changed rows?
9. ~~Is the vendor `SitemapItems` route safe to delete outright?~~ Deleted 2026-10-12.
10. Should `ProductService::update` stop resetting `featured`/`todays_deal` for sellers (or should sellers' requests be whitelisted instead of `except()`-ed)?
11. **Product specifications need a table of their own** (e.g. `product_specifications`: product, position, label and value per language), shown on the product page and sent to the app as a list. Until then a partner's specifications (Sool) are written into the description (`GenericJsonDriver::withSpecs()`), so they cannot be filtered on, compared or edited apart from the text, and a description edited by hand is overwritten by the next Apply.

## Back in stock alerts (stock changes)

Every change of `product_stocks.qty` (and of `products.published` / `approved` / `current_stock`) fires `App\Events\ProductStockUpdated` once per product from the model hooks (`App\Services\StockAlerts\StockChangeRecorder`), which queues the waiting "notify me when available" requests. Writing stock with the query builder skips the hooks: call `StockChangeRecorder::touch($productId)`. The whole feature is in CLAUDE.md ("Back in stock alerts").

Whether the WhatsApp message really arrived is read back from BuzzBip by `App\Services\StockAlerts\StockAlertDelivery` (`php artisan stock-alerts:delivery`, every 10 minutes): `product_stock_alerts.whatsapp_state` = sent / delivered / read / failed, a failed one sent once more a day later without a second app push. BuzzBip's API has no error code, only the state.
