# POL Marketplace

Phase 1 (auth + dynamic RBAC + designer approval) and Phase 2 (Templett →
Polotno source import pipeline) of the design-marketplace platform.

**Out of scope so far** (stubs only, no logic): the Polotno editor UI,
marketplace listings, orders/payments, and writing to the Magento/production
DB. `commission_rate` / `template.approve` / `template.edit` / `payout.*`
exist as forward-looking stubs for later phases.

## Tech stack

- Laravel 11 (PHP 8.2+)
- Inertia.js + React + TypeScript
- Vite + Tailwind CSS
- spatie/laravel-permission (dynamic roles & permissions)
- spatie/laravel-activitylog (audit trail)
- Laravel Breeze (Inertia + React) as the auth scaffold
- MySQL 8, Redis, Laravel Horizon (queue monitoring)
- S3 (`league/flysystem-aws-s3-v3`) for mirrored fonts/images/design JSON
- A small Node + Playwright sidecar (`auth-sidecar/`) that gets past
  Templett's reCAPTCHA to mint a login session for Laravel to reuse

## Setup

```bash
composer install
npm install

cp .env.example .env
php artisan key:generate
```

Edit `.env` and set:

- `DB_DATABASE`, `DB_USERNAME`, `DB_PASSWORD` — a MySQL 8 database/user you've
  already created.
- `ADMIN_EMAIL`, `ADMIN_PASSWORD` — credentials for the seeded super-admin
  account.
- `REDIS_HOST` / `REDIS_PORT` if not running on defaults.
- **Phase 2:** `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_BUCKET`
  (defaults to `mag-pdev`), `AWS_DEFAULT_REGION` — S3 credentials for the
  mirrored fonts/images/design JSON. `AUTH_SIDECAR_URL` — where Laravel
  reaches the sidecar (`http://127.0.0.1:4000` locally,
  `http://auth-sidecar:4000` under docker-compose). `TEMPLETT_BASE_URL` and
  `IMPORT_SYNC_INTERVAL_HOURS` (default 24) can usually stay at their
  `.env.example` defaults.

Then:

```bash
php artisan migrate --seed
npm run dev      # or: npm run build
php artisan serve
```

Log in with `ADMIN_EMAIL` / `ADMIN_PASSWORD` to reach the RBAC admin UI, or
visit `/register` to sign up as a new designer (lands in the pending-approval
screen until an admin approves you).

### Running the import pipeline locally (Phase 2)

Three more processes, alongside `php artisan serve`:

```bash
# 1. The auth sidecar (only needed to connect/verify a Templett account or
#    when a cached session expires mid-crawl)
cd auth-sidecar && npm install && npm start

# 2. Horizon — runs the `imports` queue worker
php artisan horizon

# 3. The scheduler, so DispatchDueImports actually fires. In production this
#    is one system cron line:
#      * * * * * cd /path/to/app && php artisan schedule:run >> /dev/null 2>&1
#    Locally, `schedule:work` polls every minute for you:
php artisan schedule:work
```

Or run everything (app, mysql, redis, horizon, a scheduler loop, and the
sidecar) via `docker-compose up`.

## Running tests

Tests run against a **separate** MySQL database (no SQLite driver is assumed
to be available). Create it once:

```sql
CREATE DATABASE pol_marketplace_test;
GRANT ALL PRIVILEGES ON pol_marketplace_test.* TO 'your_db_user'@'localhost';
```

Configure `.env.testing` (already present, adjust credentials to match your
local MySQL user), then:

```bash
php artisan test
```

`tests/Pest.php` seeds permissions and roles before every feature test, since
the registration flow assigns the `Designer` role at signup.

Phase 2 adds:
- `tests/Feature/Converter/` — parity tests against the reference converter's
  golden output (no network, no jobs — pure `FabricToPolotno` unit-ish tests).
- `tests/Feature/ConnectionTest.php` — connect/verify (sidecar mocked),
  ownership/RBAC on the Connections page.
- `tests/Feature/Import/JobLifecycleTest.php` — resume/skip-existing,
  pause-at-boundary, rerun-full wipe, the global one-at-a-time lock, and
  RBAC on the job controls — all against a fake `SourceAdapter`, so these
  never touch Templett, S3, or a browser either.

## How authorization is wired

- **Everything is permission-based.** No controller, route, or policy checks
  a role name directly — always a permission, e.g. `$user->can('user.approve')`.
  Roles are just named bundles of permissions, fully editable at runtime.
- **`Gate::before`** (`app/Providers/AppServiceProvider.php`) grants every
  ability to users with the `Admin` role or a `*` permission; everyone else is
  evaluated permission-by-permission.
- **`EnsureUserIsApproved`** middleware (aliased as `approved`) redirects any
  authenticated user whose `approval_status` isn't `approved` to the
  `/approval-pending` screen. It's applied to `/dashboard` and everything
  under `/admin/*`, but not to `/profile`, `/approval-pending` itself, or
  `/logout`.
- **Every admin route** is guarded server-side with `can:<permission>`
  middleware and/or a Policy (`app/Policies/*`) — hiding a sidebar item is
  never the only protection.

## Permission → page map

| Permission            | Gates                                                          |
| ---------------------- | --------------------------------------------------------------- |
| `user.view`            | Users list (`/admin/users`)                                     |
| `user.approve`         | Designer Approvals queue (`/admin/designer-approvals`)          |
| `user.manage`          | Assigning roles to a user                                       |
| `role.manage`          | Roles admin UI (`/admin/roles`)                                 |
| `permission.manage`    | Permissions admin UI (`/admin/permissions`)                     |
| `audit.view`           | Audit Log (`/admin/audit-log`)                                  |
| `template.import`      | Connections page (`/connections`) — connect a source & run imports |
| `template.reimport`    | Re-import UI is shown only if this **and** the connection's `reimport_enabled` flag are both true |
| `import.control`       | Start / Pause / Stop / Resume on a connection's import job      |
| `import.rerun_full`    | The destructive "Rerun Full" wipe-and-recrawl action             |
| `import.view_all`      | See every user's connections/jobs on `/connections`, not just your own |
| `template.approve` / `template.edit` | Reserved for a later phase (Polotno editor / approval workflow) |
| `payout.*`             | Reserved for a later commerce phase                              |

Seeded roles (editable at runtime, no code changes required to add more):

- **Admin** — every permission (plus the `Gate::before` bypass).
- **Designer** — `template.view`, `template.import`, `template.edit`.
- **Approver** — `template.view`, `template.approve`, `user.view`, `import.control`.

## Designer signup → approval flow

1. `POST /register` creates the user, assigns the `Designer` role, and leaves
   `approval_status` at its default of `pending`.
2. A pending or rejected user can log in but is redirected to
   `/approval-pending`, which shows either "awaiting approval" or the
   rejection reason.
3. An admin (anyone with `user.approve`) reviews the queue at
   `/admin/designer-approvals` and either:
   - **Approves**, setting `approval_status = approved`, `approved_at`,
     `approved_by`, and the designer's `commission_rate` (stored now, used in
     a later commerce phase) — firing `DesignerApproved`.
   - **Rejects** with a reason, firing `DesignerRejected`.
4. Both events currently have empty listener stubs
   (`App\Listeners\LogDesignerApproved` / `LogDesignerRejected`) — notification
   sending is a later phase.

## Phase 2: the source import pipeline

A designer connects a Templett account on `/connections`; from there,
importing is entirely automatic and admin-controllable.

### Architecture

```
Connect + verify ──▶ TemplettSessionManager ──▶ auth-sidecar (Playwright)
      │                     (caches the encrypted session; re-mints on expiry)
      ▼
DispatchDueImports (scheduler, everyFifteenMinutes)
      │  skips if any ImportJob is pending/running, or the global lock is held
      ▼
CrawlTemplateList (queue: imports, holds Cache::lock('import:global'))
      │  lists every template, excludes already-imported ones (mode=continue)
      │  loops templates serially, checking control_signal between each
      ▼
ImportTemplate (plain service, called per template — not queued itself)
      │  fetch (TemplettClient) → convert (FabricToPolotno) →
      │  mirror fonts + images to S3 (AssetMirror) → upsert a Design row
      ▼
designs table (source_connection_id + source_template_id is the dedup key)
```

### Job states (`import_jobs.status`)

```
pending ──▶ running ──┬──▶ completed
                       ├──▶ paused ───▶ (Resume) ──▶ running
                       ├──▶ stopped ──▶ (Resume) ──▶ running
                       └──▶ failed
```

- **Pause** / **Stop** just set `control_signal` on the running job —
  cooperative, not a kill signal. `CrawlTemplateList` checks it between
  templates (never mid-download) and transitions to `paused`/`stopped`
  cleanly, releasing the global lock either way.
- **Resume** dispatches a fresh `CrawlTemplateList` with `mode=continue`;
  it re-lists everything but skips designs already `import_status=imported`,
  so it picks up exactly where it left off.
- **Rerun Full** (`import.rerun_full`, destructive, confirm dialog) deletes
  every design this connection has imported and their S3 folders, deletes
  any now-orphaned shared fonts (checked against every *other* design in the
  system first — fonts are hash-deduped globally), then dispatches
  `CrawlTemplateList` with `mode=full`, which re-imports everything.
- **Global one-at-a-time rule:** `Cache::lock('import:global')` is held for
  the entire `CrawlTemplateList` run. `DispatchDueImports` also peeks at the
  lock (acquire-then-immediately-release) before dispatching, so at most one
  crawl runs across the whole system, regardless of how many users have
  connections due.

### Asset mirroring (S3)

- **Fonts** are shared and hash-deduped: `asset/fonts/<family>-<sha1>.<ext>`.
  Two different Templett font ids that happen to be the same file upload
  once.
- **Images** live under the design's own folder:
  `<user_id>/<slug>-<templateId>/images/<filename>`, renamed on collision
  within that folder.
- Every design also gets `<slug>-<templateId>.json` (the final Polotno) and
  `source.json` (the raw Templett response) in its folder.
- A single asset failing to download/upload never fails the whole template —
  it's recorded in `designs.error` and the import continues.

### The converter (`FabricToPolotno` / `FontResolver`)

Ported from a validated Python reference (`reference/templett_to_polotno.py`,
`reference/font_resolver.py`) — see `reference/README.md` for the mapping
rules it follows (coordinate/rotation math, text/shadow/image mapping,
bg-color rects becoming the page background, etc). `tests/Feature/Converter`
has parity tests asserting the PHP port's output matches the reference's
golden fixtures exactly (within float tolerance) for both sample templates,
including the two-page / `i-text` case.

## Proving RBAC is dynamic

Everything under `/admin/permissions` and `/admin/roles` is a plain CRUD UI
over the `permissions` / `roles` tables. You can create a brand-new
permission, create a brand-new role, attach the permission to the role, and
attach the role to a user — all through the UI, with zero code changes — and
that user's access changes immediately. See
`tests/Feature/DynamicRbacTest.php` for an end-to-end test of exactly this.
