# Flat Book Model — Design

Date: 2026-09-24
Status: Approved in chat (user: "تمام ماشي")

## 1. Purpose

Flatten the catalog so that **one book = one row = one format**. Every
catalog attribute lives directly on the `books` table. Digital support
(ebook / audiobook files, narrator, duration) is retained as columns on
the same row, and the admin form lets the user choose the format type
(كتاب عادي / ebook / audio) when creating a book, showing only the
relevant fields.

This reverses the earlier decision to manage `book_formats` as the
primary entity and deletes that table.

## 2. Decisions (from clarifying questions)

- All format fields merge into `books`; **`book_formats` is deleted**.
- Digital fields are kept: `file_path`, `sample_file`, `file_format`,
  `file_size`, `duration_seconds`, `narrator_id`.
- `discounts` stays a separate table, keyed by `book_id` (one or more
  discounts per book; overlapping active discounts for the same book
  are rejected).
- One book = one `format_type` and one `language_id`; no multi-format /
  multi-language variants per book.
- Distributions: `migrate:fresh --seed` (no production data yet).

## 3. Schema (fresh-migrate)

### `books` — add
- `title` (string)
- `slug` (string, unique)
- `language_id` (FK `languages`, `restrictOnDelete`)
- `format_type` (string, NOT NULL, default "physical")
- `isbn` (string, unique, NOT NULL)
- `edition_number` (unsignedSmallInteger, NOT NULL)
- `pages` (unsignedInteger, nullable)
- `purchase_price` (decimal 10,2, NOT NULL)
- `selling_price` (decimal 10,2, NOT NULL)
- `stock_quantity` (unsignedInteger, NOT NULL, `default 0`)
- `file_path` (string, nullable)
- `sample_file` (string, nullable)
- `file_format` (string, nullable)
- `file_size` (unsignedBigInteger, nullable)
- `duration_seconds` (unsignedInteger, nullable)
- `narrator_id` (FK `narrators`, nullable, `nullOnDelete`)

### `books` — keep
`sku` (unique), `description`, `publication_year`, `cover_image`,
`category_id`, `subcategory_id`, `publisher_id`, `is_active`, timestamps.

### Deleted tables / migrations
- `2026_09_15_235436_create_book_formats_table.php` — delete file.
- `2026_09_16_003317_create_book_translators_table.php` — rework pivot
  to `book_translator` (`book_id`, `translator_id`, cascade,
  unique(`book_id`,`translator_id`)).

### Reworked FKs (`book_format_id` → `book_id`)
- `discounts` (FK `books`, `cascadeOnDelete`)
- `cart_items` (FK `books`, `cascadeOnDelete`; unique(`user_id`,`book_id`))
- `order_items` (FK `books`, `restrictOnDelete`)

## 4. Models

- **`Book`**: add fillable/casts for all new columns; relations
  `language()`, `narrator()`, `translators()` (BelongsToMany via
  `book_translator`), `discounts()` (HasMany). Remove `formats()` and
  `primaryFormatTitle()` + accessor.
- **Delete** `BookFormat`.
- `Language::books()`, `Narrator::books()`, `Translator::books()`
  (replace `*Formats()`).
- `Discount::book()` (replace `bookFormat()`).
- `CartItem::book()`, `OrderItem::book()` (replace `bookFormat()`).

## 5. Observers

- **`BookObserver`** absorbs `BookFormatObserver`:
  - `creating`: slug via `SlugGenerator::generateUnique(title, 'books', 'slug', modelClass: Book::class)`; SKU if empty; physical → clear file fields; else file rollback cleanup + `FileMetadataExtractor` metadata extraction (same logic as current `BookFormatObserver::creating`).
  - `updating` / `updated` / `deleted`: port file-path / sample-file
    cleanup and format-type change handling.
- **Delete** `BookFormatObserver`; remove `BookFormat::observe(...)` from
  `AppServiceProvider`.
- `OrderObserver`, `OrderItemObserver`: use `Book` + `book_id`; stock
  append/deduct on `book.stock_quantity`; snapshots from `book->title`,
  `book->sku`, `book->isbn`, publisher, authors.
- `ReviewObserver`: `whereHas('book', ...)` (drop the `bookFormat.book`
  hop).

## 6. Services

- `DiscountOverlapValidator::hasOverlap(Book $book, array $discountData, ?int $ignoreDiscountId)` — `where('book_id', $book->id)`. Keep `validateFormatsDiscounts([['discounts' => [...]]])` shape (single-book call site).
- `DiscountCalculationService::calculate(Book $book, float $unitPrice)` — `Discount::where('book_id', ...)->active(...)`.
- `CreateOrderAction`: item key `book_id`; `Book::findOrFail($itemData['book_id'])`; `unitPrice = $book->selling_price`; `order_item.book_id`.

## 7. Filament

- **Delete** `BookFormatResource` + `BookFormats/Pages/*`.
- **Build flat `BookResource`** on `Book`, pages `Books/Pages/{ListBooks,CreateBook,EditBook}`. Sections:
  1. بيانات الكتاب الأساسية: `title`, `language_id` (relationship),
     `cover_image`, `publication_year`, `publisher_id` (relationship),
     category → subcategory cascade, `authors` / `translators` / `tags`
     (relationships, multiple, createOptionForm), `description`, `is_active`.
  2. بيانات الصيغة: `format_type` (select, live, default physical,
     afterStateUpdated clears files), `isbn`, `sku` (readonly,
     `dehydrated(false)`), `edition_number`, `purchase_price`/`selling_price`
     (fixed rule `gte`), `pages` + `stock_quantity` (physical only;
     stock required for physical, DB `default 0` covers digital),
     `file_path`/`sample_file`
     (ebook/audiobook), `file_format`/`file_size` (disabled,
     dehydrated(false)), `duration_seconds`/`narrator_id` (audiobook).
  3. الخصومات: `Repeater::make('discounts')->relationship()` (type,
     value — fixed `lte:../../selling_price`, starts_at, ends_at,
     is_active).
  - Default create/edit save flow (no custom `handleRecord*`); overlap
    validation in `beforeCreate` / `beforeSave`.
- `DiscountResource`: `book_id` select; table `book.title`,
  `book.format_type`, `book.sku`.
- `InventoryWidget`: `Book::query()` (physical), columns `title`,
  `format_type`, `sku`, `stock_quantity`, `selling_price`.
- `TopRatedBooksWidget`, `ReviewResource`: restore `book.title` /
  `book.sku` (drop `primary_format_title`).

## 8. Factories & Seeders

- `BookFactory`: all columns + states (physical/ebook/audiobook);
  unique ISBN; `title`, `language_id`, prices, physical stock.
- `CartItemFactory`, `DiscountFactory`, `OrderItemFactory`: `book_id`.
- Delete `BookFormatFactory`.
- `CatalogSeeder`: create `Book`s directly with all fields, sync
  authors/translators/tags, then discounts with `book_id`.
- `CommerceSeeder`: carts/order items reference `book_id`.

## 9. Tests (rewrite in tests phase)

Affected: `BookResourceTest`, `BookFormatObserverTest` → merge into
`BookObserverTest`, `Tier2FactoryTest`, `Tier3FactoryTest`,
`SeederTest`, `DashboardWidgetTest`, `OrderResourceTest`,
`OrderSnapshotAndIntegrityTest`, `LegacySnapshotUiTest`,
`DiscountOverlapValidatorTest`, `CreateOrderActionTest`,
`BookFormatUniquenessTest` (obsolete — delete).

## 10. Non-goals / notes

- No multi-format or multi-language variants (single row per book).
- `unique(['book_id','format_type'])` constraint disappears with the
  deleted table.
- `FileMetadataExtractor`, `BookFormatType`, `FileFormat`, `SlugGenerator`
  remain unchanged and keep their names.