# Shopno Books House — Admin API contract (Phase 2)

Base: `{API_BASE}/admin/...` (same front controller as the public API). JSON envelope as the public API:
success `{ "data": ... }`, error `{ "error": "message", "fields"?: { field: "message" }, "code"?: "string" }`.
Status codes: 200, 201, 204, 401 (not signed in), 403 (no permission / bad CSRF), 404, 409 (conflict, e.g. slug taken, media in use),
422 (validation, with `fields`), 423 (account locked), 429, 500.

## Auth and sessions
- Session cookie `sbh_admin` (HttpOnly, Secure, SameSite from config `COOKIE_SAMESITE`, default `Strict`; path `/`).
  Requests use `fetch(..., { credentials: "include" })`; CORS sends `Access-Control-Allow-Credentials: true` for allowed origins.
- CSRF: `GET /admin/me` and `POST /admin/login` return `csrf`. Every non-GET request must send header `X-CSRF-Token`.
- Idle timeout 2 h (30 days with `remember`). Every admin response sends `X-Robots-Tag: noindex, nofollow` and `Cache-Control: no-store`.

| Method | Path | Body → data |
|---|---|---|
| POST | `/admin/login` | `{email, password, remember?, totp?}` → `{user, csrf}`. 401 `code:"2fa_required"` when TOTP is on and missing. 5 failures in 15 min → 423 for 15 min. |
| POST | `/admin/logout` | → `{ok}` |
| POST | `/admin/logout-all` | ends every session of the current user → `{ok}` |
| GET | `/admin/me` | → `{user, csrf}` |
| POST | `/admin/password/forgot` | `{email}` → `{ok}` (always ok) |
| POST | `/admin/password/reset` | `{token, password}` → `{ok}` |
| POST | `/admin/password/change` | `{current, password}` → `{ok}` (signed in; min 10 chars; ends other sessions) |
| POST | `/admin/2fa/setup` | → `{secret, otpauth_url}` |
| POST | `/admin/2fa/enable` | `{code}` → `{ok}` ; `POST /admin/2fa/disable` `{password}` → `{ok}` |

`user = { id, name, email, role, permissions: string[], two_factor: bool }`

## Roles and permissions (enforced server-side on every endpoint)
Permissions: `dashboard.view, read.all, books.edit, books.delete, catalogue.edit, orders.view, orders.edit, quotes.manage, coupons.manage,
reviews.moderate, comments.moderate, messages.manage, subscribers.manage, content.edit, media.manage, seo.manage, settings.manage,
team.manage, system.manage, activity.view, import.run, demo.manage`.
Default roles: `owner` (all), `manager` (all except team.manage, system.manage, demo.manage), `editor` (dashboard.view, books.edit,
books.delete, catalogue.edit, content.edit, media.manage, seo.manage, import.run), `order_manager` (dashboard.view, orders.view,
orders.edit, quotes.manage, messages.manage), `viewer` (dashboard.view, read.all, orders.view, activity.view).
`read.all` grants GET on every resource. Owner is always all permissions and cannot be edited.

| GET | `/admin/roles` | → `{ roles: [{key, label, permissions[]}], permissions: [{key, label, group}] }` |
| PUT | `/admin/roles/{key}` | `{permissions[]}` → role (team.manage) |

## Generic resources `/admin/r/{resource}`
| Method | Path | Notes |
|---|---|---|
| GET | `/admin/r/{res}?page=1&per_page=50&q=&sort=-updated_at&trash=0&f[field]=value` | → `{items, total, page, per_page}`. per_page ∈ 25/50/100. `sort` is a column, `-` prefix = desc. `trash=1` lists soft-deleted rows. |
| GET | `/admin/r/{res}/{id}` | full record (with relations) or 404 |
| POST | `/admin/r/{res}` | create → saved record (201). 422 with `fields` on validation errors. |
| PUT | `/admin/r/{res}/{id}` | partial update → saved record. Optional `_expected_updated_at`: when it differs from the stored `updated_at` → 409 `code:"stale"` (someone else saved first). |
| DELETE | `/admin/r/{res}/{id}` | soft delete when the table has `deleted_at`, else hard delete. `?permanent=1` purges. → `{ok}` |
| POST | `/admin/r/{res}/{id}/restore` | → saved record |
| POST | `/admin/r/{res}/bulk` | `{ids[], action: "delete"|"restore"|"purge"|"update", patch?}` → `{affected}` (transaction) |
| GET | `/admin/r/{res}/export.csv?...same filters` | CSV (UTF-8 with BOM) of the filtered view |

Every write: runs in a transaction, writes `activity_log` (before/after JSON), recalculates counts (books per author/series/category),
bumps the `cache_version` setting, calls `seo_on_change()` for public URLs, creates a 301 in `redirects` (auto=1) when a public slug changes,
and returns the saved record read back from the database.

Resources and record shapes (list rows may be a subset; `is_demo`, `created_at`, `updated_at`, `deleted_at` on content tables):
- `books`: id, title, slug, author_id, author_name, series_id, series_name, volume_number, category_ids[], tags (comma string), summary,
  description (HTML), status (draft|published|scheduled), publish_at, price, sale_price, sale_starts_at, sale_ends_at, stock,
  low_stock_threshold, sku, stock_status_override (null|in_stock|out_of_stock|preorder), purchase_limit, publisher, publish_year, pages,
  paper_quality, binding, language, edition, isbn, dimensions, weight, images [{media_id, url, alt, focal_x, focal_y}] (first = cover),
  cover_url, seo_title, seo_description, canonical_override, noindex, editorial_rating, is_featured, is_bestseller, is_new, related_ids[],
  custom_print_available, url_author_segment (read-only, frozen at first publish), public_url (read-only).
  Extra filters: `f[status]`, `f[category_id]`, `f[author_id]`, `f[series_id]`, `f[stock]=low|out|in`, `f[on_sale]=1`, `f[missing]=image|description|seo`.
  `POST /admin/r/books/{id}/duplicate` → new draft record. `GET /admin/books/preview-token/{id}` → `{url}` storefront preview with token.
- `authors`: id, name, slug, photo_media_id, photo_url, bio, social[], seo_title, seo_description, noindex, book_count (read-only)
- `series`: id, name, slug, description, cover_mode (auto|upload), cover_media_id, cover_url, volumes [{book_id, title, volume_number}]
  (PUT with volumes reorders), whole_series_enabled, whole_series_discount, seo_*, book_count
- `categories`: id, name, slug, parent_id, image_media_id, image_url, intro, description, faq [{q,a}], sort_order, show_on_home, home_order, seo_*, book_count
- `posts`: id, title, slug, excerpt, content, cover_media_id, cover_url, category, tags, author_name, status, publish_at, reading_minutes (auto), related_book_ids[], comments_enabled, seo_*
- `pages`: id, slug (locked, cannot change), title, content, faq [{q,a}], seo_* (about, contact, faq, terms, privacy, credits, custom-print and the five landing pages)
- `coupons`: id, code, type (percent|fixed), value, min_order, max_discount, usage_limit, per_phone_limit, starts_at, ends_at, applies_to (all|books|categories), book_ids[], category_ids[], active, used_count
- `reviews`: id, book_id, book_title, name, phone, rating, text, images[], status (pending|approved|rejected), reply, created_at. Approve/reject recalculates rating_avg/review_count.
- `comments`: id, post_id, post_title, name, text, status (pending|approved|rejected|spam), reply, created_at
- `messages`: id, topic, name, phone, email, message, attachment_url, is_read, handled, notes, created_at
- `subscribers`: id, email, created_at
- `quotes`: id, source (custom_print|contact), name, phone, email, options {size, pages, copies, paper, binding, cover}, files [{name, url}], message,
  status (New|Quoted|Accepted|In production|Ready|Delivered|Cancelled), quoted_price, delivery_estimate, notes, order_id, created_at.
  `POST /admin/quotes/{id}/convert` → `{order_id, order_number}`
- `orders` (see below), `homepage_sections`: id, key, label, enabled, sort_order, settings {} ; `menus`: id, location (header|mobile|footer_books|footer_print|footer_help), items [{label, href}]
- `redirects`: id, from_path, to_path, code (301|410), hits, auto, created_at ; `POST /admin/redirects/import` `{csv}`, `GET /admin/redirects/check` → `{loops[], chains[]}`
- `delivery_zones`: id, district, division, fee, enabled ; `advance_rules`: id, max_total (null = above all), advance
- `users`: id, name, email, role, active, two_factor, last_login_at, password (write-only, min 10 chars) — team.manage
- `media`: id, url, thumb_url, filename, mime, size, width, height, alt, title, caption, folder, tags, in_gallery, gallery_order, usage_count, created_at
- `activity` (read-only): id, admin_name, action, entity, entity_id, summary, before {}, after {}, ip, created_at

## Orders
- `GET /admin/r/orders` filters: `f[status]`, `f[payment_status]`, `f[district]`, `f[coupon]`, `f[from]`, `f[to]`, `q` (number, name, phone), `since_id`.
- Record: id, order_number, created_at, name, phone, district, address, note, items [{book_id, title, cover_url, qty, price}], items_count,
  subtotal, discount, coupon, delivery_fee, total, advance_required, advance_paid, balance_due, payment_method (bkash|nagad),
  transaction_id, payment_screenshot_url, payment_verified, payment_status (Unpaid|Advance paid|Fully paid|Refunded), status
  (Pending|Confirmed|Processing|Shipped|Delivered|Cancelled|Returned), delivery_code, courier, internal_notes [{text, admin, created_at}],
  history [{type: status|payment|edit|note, status?, note, admin, created_at}]
- `POST /admin/orders/{id}/status` `{status, note?, delivery_code?, courier?}`. Rules: Delivered ⇒ payment_status Fully paid, advance_paid = total, balance 0, sales_count += qty.
  Pending→Confirmed (first time) reduces stock; Cancelled/Returned after confirmation restores stock. Only Delivered counts as revenue.
- `POST /admin/orders/{id}/payment` `{payment_status?, advance_paid?, verified?}` ; `POST /admin/orders/{id}/notes` `{text}`
- `PUT /admin/r/orders/{id}` `{items?, delivery_fee?, district?, address?, reason}` (open orders only; reason required, logged)
- `POST /admin/orders/bulk-status` `{ids[], status}` ; `GET /admin/orders/latest` → `{latest_id, new_since?: number}` for 30 s polling (`?since_id=`)

## Dashboard, search, notifications
- `GET /admin/dashboard?from=YYYY-MM-DD&to=YYYY-MM-DD` → `{ cards: {orders_today, pending_orders, revenue_month, low_stock, new_quotes, pending_reviews, unread_messages},
  orders_by_day: [{date, orders, revenue}], by_status: [{status, count}], top_books: [{title, qty}], by_district: [{district, count}],
  recent_orders: Order[] (10), attention: [{label, count, href}] }`. revenue = SUM(total) of Delivered orders only.
- `GET /admin/search?q=` → `{ books: [{id, title, sku, isbn}], orders: [{id, order_number, name, phone}] }`
- `GET /admin/notifications` → `{ counts: {new_orders, new_quotes, pending_reviews, low_stock, unread_messages}, items: [{type, title, href, created_at}] }`

## Media
- `POST /admin/media/upload` multipart `file`, `folder?`, `alt?` → media (201). Max 15 MB. JPEG/PNG/WebP/GIF/AVIF/PDF. Images re-encoded to WebP
  (GD or Imagick, falls back to original when neither), metadata stripped, sizes 320/640/960/1400, unique names.
- Chunked: `POST /admin/media/chunk` multipart `upload_id, index, total, filename, chunk` → `{received}`; last chunk → media.
- `POST /admin/media/{id}/replace` multipart `file` → media (same URL). `GET /admin/media/{id}/usage` → `[{type, id, title, href}]`.
  Deleting media in use → 409 unless `?force=1`.

## CSV import / export (books)
- `GET /admin/import/template.csv`. Columns: `sku,isbn,title,slug,author,series,volume_number,categories,tags,price,sale_price,stock,status,
  summary,description,publisher,publish_year,pages,paper_quality,binding,language,edition,dimensions,weight,images,seo_title,seo_description`
  (`categories`, `tags`, `images` separated by `|`; images are URLs or media-library filenames).
- `POST /admin/import/dry-run` `{csv, mapping: {csvHeader: column}, match_by: sku|isbn|slug}` → `{ batch_id, total, summary: {create, update, error, warning},
  rows: [{row, action: create|update|error, errors[], warnings[], title}] }` (rows capped at 1000 in the response)
- `POST /admin/import/{batch}/run` `{offset, limit: 100}` → `{processed, done, created, updated, failed}` (chunked; each chunk in a transaction,
  remote images fetched server-side with a 10 s timeout each)
- `POST /admin/import/{batch}/cancel`, `POST /admin/import/{batch}/rollback` → `{ok, reverted}`; `GET /admin/import/{batch}/errors.csv`; `GET /admin/import/batches`
- `GET /admin/r/books/export.csv` uses the same columns, so export → import round-trips.

## Settings
- `GET /admin/settings` → `{ key: value }` (JSON values decoded). `PUT /admin/settings` `{ key: value, ... }` → all settings (settings.manage).
Keys: company_name, tagline, logo_url, favicon_url, share_image_url, phone, whatsapp, email (must end with @shopnobookshouse.com), address,
map_query, hours, social[], delivery_estimate, bkash_number, bkash_type, nagad_number, nagad_type, payment_instructions, cod_note,
screenshot_required, print_pricing {}, email_templates {}, sender_name, sender_email, sms_hook_url, per_page, low_stock_threshold,
review_moderation, comments_enabled, cookie_notice, topbar_message, print_banner_text, seo_templates {}, robots_extra, llms_extra, indexnow_enabled.

## SEO center
`GET /admin/seo/status` → `{sitemaps: [{name, count, url}], last_generated, indexnow: {enabled, key, key_ok, queue, failed, last_results[]}, cron_command}`;
`POST /admin/seo/regenerate`; `POST /admin/seo/submit-all`; `POST /admin/seo/submit {urls[]}`; `POST /admin/seo/retry-failed`;
`GET /admin/seo/recent?days=7` → `[{url, updated_at}]`; `GET /admin/seo/log` → `[{url, engine, status, created_at}]`;
`GET /admin/seo/audit` → `{ checks: [{key, label, items: [{title, href, edit_href}]}] }`.

## System and demo data
- `GET /admin/system/check` → `{ checks: [{key, label, ok, detail}] }` ; `POST /admin/system/write-test` → `{ok, steps: [{label, ok, ms}]}` ; `POST /admin/system/mail-test {to}`
- `GET /admin/demo` → `{ counts: {books, authors, series, categories, posts, reviews} }` ; `POST /admin/demo/delete {confirm: "DELETE"}` → `{deleted: {...}}`
  removes every `is_demo = 1` row, its media files and redirects, then regenerates the sitemap. Real rows are never touched.

## Public cache contract
- `GET /cache-version` (public) → `{version}`. Public GET responses send `Cache-Control: public, max-age=0, must-revalidate` and an
  `ETag` that includes `cache_version`, so any admin write is visible on the next storefront view. Logged-in state never changes public output.

## Update 5 endpoints
- **Order money (N5).** Customers pay subtotal + the district delivery charge, locked on the order. `orders.courier_cost_actual` is internal only
  (never in public responses); `POST /admin/orders/{id}/courier-cost {amount|null}` sets it. Profit = order total − book costs − cost entries − actual
  courier charge (the delivery charge collected when not entered). Finance/dashboard figures follow the same rule.
- **Cost entries (N4).** `GET|POST /admin/orders/{id}/costs`, `PUT|DELETE /admin/orders/{id}/costs/{costId}` → `{money}`.
- **Payment status (N12).** `POST /admin/orders/{id}/payment {payment_status}`: Partially paid → paid = required advance; Fully paid → paid = total;
  Unpaid → 0; Cancelled/Refunded keep amounts. Every change writes an order history entry.
- **Badges (A5).** `POST /admin/badges/seen {section: orders|quotes|messages|reviews|comments}` stamps `admin_seen_at`; the dashboard calls it when a list is opened.
- **Track order (N7).** `POST /orders/track {order_number, phone}`: phone normalised (+880/880/0), 404 "not found" vs network errors, 30 lookups and
  8 misses per 10 minutes per IP. `POST /orders` accepts `idempotency_key`; a retry returns the first order instead of a duplicate.
- **Custom scripts (N8, Owner only to change).** `GET|PUT /admin/integrations/scripts {scripts: [{id?, name, placement: head|body_start|body_end, code,
  enabled, scope: all|pages, pages[]}]}` (max 50, 20,000 chars each, order = list order). `GET /settings` returns enabled ones as
  `integrations.custom_scripts` (no names); the storefront server renders them into the raw HTML of matching pages (`/books/*` matches everything under it).
  `POST /admin/integrations/test {key?: "script:{id}"}` fetches the live homepage and reports each as found / not found / null (page-limited).
- **Media (N15).** `GET /admin/media/storage` → `{total_bytes, count, quota_mb, by_type[], by_usage[], largest[20], unused[], unused_count, unused_bytes}`;
  `PUT /admin/media/storage {quota_mb}` (Owner); `POST /admin/media/bulk {ids[] | all: true, q, action: edit|delete, fields {alt,title,tags,folder}, force}`
  → edit `{updated}` / delete `{deleted, blocked: [{id, filename, usage[]}]}`. Files in use are skipped unless the Owner sends `force`. Deletes go to Trash.
- **Analytics (N6).** `GET /admin/analytics` adds `tracking {last_event_at, events_24h, events_total}`. `/track` ignores staff, bots, `/admin` and demo (`/demo-…`) pages.

## Update 6 endpoints
- `POST /admin/books/bulk-edit/preview` `{target:{ids}|{q,f}, ops:[{field,mode,value?,amount?,unit?,find?,replace?,case_sensitive?}]}` → `{total, will_change, invalid, unchanged, sample:[{id,title,changes:[{field,label,before,after}],errors}]}`. Saves nothing. Permission `books.edit`.
- `POST /admin/books/bulk-edit/resolve` `{q,f}` → `{ids}`: every book matching the list search and filters ("select all matching").
- `POST /admin/books/bulk-edit/apply` `{ids (≤100 per call), ops, summary, total, batch?}` → `{batch, updated, unchanged, failed:[{id,title,errors}]}`. Each book is validated with the same rules as the single-book editor; invalid books are skipped, the rest are saved. Pass the returned `batch` on later chunks. Every book's before/after values are stored for undo, and the edit is logged in Activity.
- `GET /admin/books/bulk-edit/history` → recent batches. `POST /admin/books/bulk-edit/{batch}/undo` → `{restored, skipped:[{id,title,reason}]}`; books edited again since are skipped ("Edited again since"); a batch can be undone once (409 afterwards).
- `GET /admin/system/health[?quick=1]` → `{checked_at, overall, counts, items:[{group,key,label,status,detail,fix}]}`, status one of `connected|disconnected|working|warning|error|not_configured`. Permission `system.manage`. Never returns secrets.
- `GET /admin/seo/audit` adds checks: thin descriptions, long SEO titles, duplicate meta descriptions, focus keyword missing.
- Public book responses add `subtitle`, `short_description`, `og_title`, `og_description`, `attributes`. `purchase_cost`, `focus_keyword`, related keywords and search intent are staff-only.
- Customer uploads are ignored everywhere: orders, contact and quote forms and reviews accept no files (old screenshots in Media are kept).
