Design & artwork
Crates: platyn-design, platyn-storage · Migration: 0005_design.sql · Status: Schema built API planned
The tables exist and are applied. The HTTP surface and the rendering jobs are planned.
What this domain is for
Section titled “What this domain is for”Art is where decorating jobs go wrong. Not the quote, not the shipping — the art. A customer sends a 400×400 JPEG of a logo and expects it on the front of a hoodie. A designer cleans it up. A proof goes out. Someone approves it. Two weeks later, after 300 shirts are printed, the customer says the blue is wrong.
The job of this domain is to make that conversation short: here is exactly what was approved, here is who approved it, here is the timestamp, and here is the IP it came from.
erDiagram
TENANTS ||--o{ DESIGNS : owns
COMPANIES ||--o{ DESIGNS : "logo library"
DEALS ||--o{ DESIGNS : "art for"
LINE_ITEMS ||--o{ DESIGNS : "art for"
DESIGNS ||--o{ DESIGN_VERSIONS : "revised as"
DESIGN_VERSIONS ||--o{ DESIGN_ASSETS : "files"
DESIGN_VERSIONS ||--o{ IMPRINTS : "placed by"
DESIGN_VERSIONS ||--o{ APPROVALS : "signed off"
APPROVALS ||--o{ APPROVAL_COMMENTS : "discussed in"
LINE_ITEMS ||--o{ IMPRINTS : "decorated with"
CUSTOMERS ||--o{ APPROVALS : decides
app.designs
Section titled “app.designs”The logical design — “Riverside Brewing 2026 logo”. Its identity survives revisions, which is what makes a reorder cheap.
CREATE TYPE app.design_status AS ENUM ('concept','in_progress','proof_ready','in_review','revising', 'approved','production_ready','archived');| Column | Type | Notes |
|---|---|---|
id, tenant_id | UUID | RLS key. |
company_id | UUID nullable | ON DELETE SET NULL. Library art reusable across that company’s jobs. |
deal_id | UUID nullable | ON DELETE SET NULL. |
line_item_id | UUID nullable | ON DELETE SET NULL. Art tied to one specific line. |
name | TEXT NOT NULL | |
code | TEXT | The shop’s own design number. |
status | app.design_status | Default concept. |
designer_id | UUID → app.users | |
created_at, updated_at | TIMESTAMPTZ |
All three parent links are SET NULL rather than CASCADE: deleting a deal must not destroy the
artwork, because the art usually outlives the job that paid for it.
app.design_versions
Section titled “app.design_versions”Immutable revisions. “Approve v3” is unambiguous; “approve the design” is not.
| Column | Type |
|---|---|
id, tenant_id, design_id | UUID |
version_no | INTEGER — UNIQUE (design_id, version_no) |
notes | TEXT — what changed and why |
created_by, created_at | |
superseded_at | TIMESTAMPTZ |
app.design_assets
Section titled “app.design_assets”CREATE TYPE app.asset_kind AS ENUM ('mockup','production_art','separation','proof','logo','spec_sheet','other');| Column | Type | Notes |
|---|---|---|
id, tenant_id, design_version_id | UUID | |
kind | app.asset_kind | Default mockup. |
storage_key | TEXT NOT NULL | An S3 object key. Never a host path, never a public URL. |
filename, mime_type | TEXT NOT NULL | |
byte_size | BIGINT | |
width_px, height_px | INTEGER | Drives the “this is 72 DPI” warning. |
checksum_sha256 | BYTEA | Detects a truncated upload. |
is_primary | BOOLEAN | |
position | INTEGER | |
status | TEXT | pending → ready. See below. |
uploaded_by, created_at, deleted_at | Soft delete. |
CREATE UNIQUE INDEX design_assets_one_primary ON app.design_assets (design_version_id) WHERE is_primary AND deleted_at IS NULL;storage_key being an S3 key is a deliberate correction. The predecessor wrote to a host mount at
/cmp-share, which pinned the entire application to one machine forever. Bytes never pass through
Bun or Rust: uploads and downloads use presigned URLs, so the browser talks to S3 directly and
nothing proxies a 200 MB Illustrator file.
app.imprints
Section titled “app.imprints”Where art physically goes on the garment, with the parameters that drive cost.
| Column | Type | Notes |
|---|---|---|
id, tenant_id | UUID | |
line_item_id | UUID NOT NULL | ON DELETE CASCADE. An imprint belongs to a line. |
design_version_id | UUID nullable | The exact art being printed. |
location | TEXT NOT NULL | full_front, left_chest, back_yoke, sleeve_left. |
decoration_method_id | SMALLINT | |
color_count | SMALLINT | Screen print: the primary price driver. |
ink_colors | TEXT[] | Pantone references. |
stitch_count | INTEGER | Embroidery: the equivalent driver. |
width_in, height_in | NUMERIC(6,2) | |
position, notes |
Approvals
Section titled “Approvals”CREATE TYPE app.approval_status AS ENUM ('pending','approved','rejected','expired','cancelled');CREATE TYPE app.author_kind AS ENUM ('staff','customer','system');app.approvals
Section titled “app.approvals”| Column | Type | Notes |
|---|---|---|
id, tenant_id | UUID | |
design_version_id | UUID NOT NULL | The exact revision. ON DELETE CASCADE. |
status | app.approval_status | Default pending. |
requested_by, requested_at | ||
sent_to_customer_id | UUID → app.customers | |
token_hash | BYTEA UNIQUE | Scoped, account-less approval link. Hash only. |
expires_at, decided_at | TIMESTAMPTZ | |
decided_by_customer_id | UUID | The customer decided. |
decided_by_user_id | UUID | Or a rep decided on their behalf. |
decision_ip | INET | Evidence. |
decision_user_agent, reason | TEXT |
The approval token is the mechanism that lets a customer sign off without an account. Only the hash is stored; the raw token exists solely in the email that was sent. It authorizes exactly one resource and a very small verb set — it cannot list deals and it cannot see pricing.
app.approval_comments
Section titled “app.approval_comments”| Column | Type | Notes |
|---|---|---|
id, tenant_id | UUID | |
approval_id | UUID nullable | |
design_version_id | UUID nullable | |
author_kind | app.author_kind | staff · customer · system |
author_user_id, author_customer_id | UUID | Whichever applies. |
body | TEXT NOT NULL | |
created_at | TIMESTAMPTZ |
CHECK (num_nonnulls(approval_id, design_version_id) >= 1)A comment must attach to something — an approval round or a version — enforced by the database
rather than by hope. system comments are written by the application (“proof sent”, “version
superseded”), so one thread shows both what people said and what the system did.
Workflow
Section titled “Workflow”flowchart LR
I["concept<br/>customer file arrives"] --> D["in_progress<br/>clean up, separate"]
D --> PR["proof_ready<br/>mockup rendered"]
PR --> R["in_review<br/>approval sent"]
R -->|approved| A["approved"]
R -->|rejected| RV["revising<br/>→ new version"]
RV --> D
A --> P["production_ready<br/>separations + specs"]
P --> PRESS["to press"]
style I fill:#00A3D9,stroke:#007AA6,color:#14131A
style R fill:#E4006C,stroke:#B00054,color:#ffffff
style P fill:#FF5B23,stroke:#D9410F,color:#14131A
- Concept. The customer’s file lands via presigned upload as a
logoasset, rowpending. - Assess. Once the storage event flips it to
ready, a job extracts dimensions and checksum. A 72-DPI raster is not going on a hoodie front, and the system flags it before a designer spends an hour discovering it. - In progress. Cleanup, vectorisation, separation or digitising. Each save is a new
design_version. - Proof ready. A
mockupasset — the art composited onto the actual garment colour, at the actual print size — markedis_primary. - In review. An
app.approvalsrow is created with a hashed token and anexpires_at, and the link is emailed tosent_to_customer_id. - Decided. Approve or reject, with the IP, user agent, and timestamp recorded. A rejection
moves the design to
revisingand the next save becomes version n+1. - Production ready. Separations, ink colours, and placement measurements — everything the press needs.
Background jobs (planned)
Section titled “Background jobs (planned)”All enqueued transactionally through app.jobs, so a design write and the job that processes it
either both happen or neither does —
ADR-06.
kind | Does |
|---|---|
design.asset_ready | Handle the storage event, extract metadata, flip pending → ready |
design.thumbnail | Web-sized previews |
design.mockup | Composite art onto the garment colour |
design.separate | Split spot colours into printable separations |
approval.send | Render the email, mint the token, deliver |
approval.expire | Sweep approvals past expires_at into expired |
API surface (planned)
Section titled “API surface (planned)”| Method | Path | Permission |
|---|---|---|
GET | /v1/designs | design.read |
GET | /v1/designs/{id} | design.read |
POST | /v1/designs | design.write |
POST | /v1/designs/{id}/versions | design.write |
POST | /v1/design-versions/{id}/upload-url | design.write — returns a presigned PUT |
GET | /v1/design-assets/{id}/url | design.read — returns a presigned GET |
POST | /v1/line-items/{id}/imprints | design.write |
POST | /v1/design-versions/{id}/approvals | design.write |
GET | /v1/approvals/{id} | design.read |
POST | /v1/approvals/{id}/decide | design.approve — or a valid approval token |
POST | /v1/approvals/{id}/comments | design.read — or a valid approval token |
design.approve is held only by owner and admin. A designer can produce art and send a proof;
committing ink to garments is a separate authority.
The token-authenticated paths are the only routes in the entire API reachable by a principal that is
not a session. They resolve a single approvals.token_hash, authorize exactly that row, and expose
nothing else.
Planned
Section titled “Planned”- Automatic spot-colour separations, with manual override. The hard, valuable part.
- A garment mockup library — real photographs per style colour, so a proof shows the actual heather rather than a flat swatch.
- Ink colour matching against Pantone, with a shop-specific mix book.
imprints.ink_colorsis alreadyTEXT[]and waiting for it. - Reusable screen tracking, so a repeat order can skip the setup fee — which requires knowing the screen still exists.
- A customer art portal, extending the approval token to uploads.