From 035bb3a2e904bb0b0c0e2099d427766fe894a3df Mon Sep 17 00:00:00 2001 From: drjones Date: Tue, 25 Aug 2026 20:02:23 -0700 Subject: [PATCH] scaffold: schema, compose, contract --- CONTRACT.md | 85 ++++++++++++++++++++++++++ db/schema.sql | 148 +++++++++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 30 +++++++++ 3 files changed, 263 insertions(+) create mode 100644 CONTRACT.md create mode 100644 db/schema.sql create mode 100644 docker-compose.yml diff --git a/CONTRACT.md b/CONTRACT.md new file mode 100644 index 0000000..d74536e --- /dev/null +++ b/CONTRACT.md @@ -0,0 +1,85 @@ +# Polaris — Reseller Platform · Module Contract (agents read this) + +Single source of truth for the 3 parallel build agents. Do NOT drift from these +interfaces — the other agents depend on them exactly as written. + +## Stack (locked) +- Backend: **FastAPI** (Python 3.12) in `/backend`, SQLAlchemy 2.0 ORM, Pydantic v2 +- DB: **PostgreSQL 16** (schema in `db/schema.sql` — DO NOT modify it, build against it) +- Queue: **Celery** + **Redis** (`redis://redis:6379/0`) +- Frontend: **React 18 + Vite + TypeScript + Tailwind** in `/frontend` +- Deploy: Docker Compose (root `docker-compose.yml` already has postgres+redis) + +## Service URLs (inside Docker network) +- postgres: `postgres://reseller:${POSTGRES_PASSWORD}@postgres:5432/reseller` +- redis: `redis://redis:6379/0` +- api listens on `0.0.0.0:8000` + +## BACKEND agent owns: /backend +- `app/main.py` — FastAPI app, CORS allow-all (MVP), mounts routers under `/api` +- `app/models.py` — SQLAlchemy models mirroring `db/schema.sql` EXACTLY (table + column names) +- `app/database.py` — engine from `DATABASE_URL` env, `SessionLocal`, `Base` +- `app/schemas.py` — Pydantic v2 request/response models +- `app/engines/pricing.py` — `calculate_price(cost, shipping, fees)` -> returns retail, + margin, profit using active `price_rules`; `estimate_net_profit(...)`; never below min_price +- `app/engines/suppliers/base.py` — `SupplierAdapter` ABC: + get_products / get_inventory / get_price / create_order / get_order_status / get_tracking / cancel_order +- `app/engines/suppliers/csv_adapter.py` — CSV feed adapter (reads supplier CSV: sku,title,cost,inventory,shipping_cost,shipping_time,category,image_url) +- `app/engines/suppliers/sample.py` — sample supplier seeded with ~25 realistic products ($50-$500, home/gadgets/office niche) +- `app/engines/importer.py` — import feed -> normalize -> validate -> upsert products/supplier_products; never auto-publish invalid +- `app/engines/order_router.py` — `select_supplier(product_id, qty)` highest-score eligible supplier; `route_order(order)` +- `app/engines/scoring.py` — supplier score = 40% price + 20% inventory + 15% speed + 10% fulfillment + 10% returns + 5% history +- `app/engines/inventory.py` — sync supplier_products inventory, mark 0/unavailable, alert flags +- `app/tasks.py` — Celery: sync_inventory (every 5min), sync_tracking, recalc_prices +- `app/routers/` — products.py, suppliers.py, orders.py, customers.py, admin.py, analytics.py, health.py +- `alembic/` — optional; MVP may `Base.metadata.create_all()` instead (schema.sql is canonical) + +## API endpoints (frontend depends on these — do not rename) +- `GET /api/health` -> {status, version} +- `GET /api/products` (filters: ?status=&category=&search=&limit=&offset=) +- `GET /api/products/{id}` +- `POST /api/products/{id}/publish` +- `POST /api/products/{id}/pause` +- `GET /api/products/{id}/price-history` +- `POST /api/products/import` (body: {supplier_id, feed_type, data_or_url}) +- `GET /api/suppliers` / `POST /api/suppliers` +- `GET /api/suppliers/{id}/performance` +- `POST /api/orders` (body: {customer_email, items:[{product_id,qty}]}) -> creates+routes order +- `GET /api/orders` / `GET /api/orders/{id}` +- `POST /api/orders/{id}/tracking` (simulate supplier tracking push) +- `GET /api/analytics/summary` -> today revenue/orders/profit/margin/aov/conversion +- `GET /api/analytics/top-products` +- `GET /api/analytics/supplier-performance` +- `POST /api/auth/login` (admin, simple: env ADMIN_EMAIL/ADMIN_PASSWORD_HASH, returns JWT) +- `POST /api/auth/register` (customer) + +## FRONTEND agent owns: /frontend +React+Vite+TS+Tailwind. Two surfaces, shared API client (`src/lib/api.ts`): +- `/` storefront: home, shop grid, category filter, product page, cart, checkout (mock + Stripe/BTCPay button -> creates order), order-tracking page. ORIGINAL brand "Polaris". + Dark premium aesthetic. BMAC footer link https://buymeacoffee.com/r26xrthzttg +- `/admin` dashboard: today KPI cards (revenue/orders/gross+net profit/margin/aov/refunds), + top products table, supplier performance table, import button, product lifecycle view, + order list with status + tracking. Cache-Control no-store on all API fetches. +- Env: `VITE_API_URL=/api` (nginx proxies). + +## OPS agent owns: root deploy + docs +- Extend `docker-compose.yml` with: api, worker, frontend, nginx services +- `backend/Dockerfile`, `frontend/Dockerfile`, `nginx/nginx.conf` (proxy /api->api:8000, /->frontend) +- `.env.example` (POSTGRES_PASSWORD, DATABASE_URL, REDIS_URL, SECRET_KEY, ADMIN_EMAIL, ADMIN_PASSWORD, BTCPAY_URL/KEY, STRIPE_KEY) +- `README.md` (deploy steps), `docs/api.md` (endpoint reference), `tests/` (pytest smoke tests) +- systemd unit `polaris.service` wrapping `docker compose up -d`, enabled+started + +## Verification (the parent re-runs these — every agent must self-verify first) +1. `docker compose up -d` clean, all containers healthy +2. seed sample supplier + import -> products appear in GET /api/products +3. pricing returns sensible margin for a $80 cost item +4. POST /api/orders creates order, routes to supplier, records profit +5. frontend `/` and `/admin` render with live API data +6. README deploy steps reproducible from scratch + +## Rules +- Secrets ONLY via env vars, never hard-coded. +- Backup any existing file before overwriting (cp x x.bak-). +- Never delete the parent's work; only add/extend. +- All code self-contained; report exact files changed + live curl proof. diff --git a/db/schema.sql b/db/schema.sql new file mode 100644 index 0000000..31c0c46 --- /dev/null +++ b/db/schema.sql @@ -0,0 +1,148 @@ +-- Polaris — canonical PostgreSQL schema (single source of truth) +-- Database: reseller User: reseller + +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + +-- ── SUPPLIERS ────────────────────────────────────────────── +CREATE TABLE suppliers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + adapter_type TEXT NOT NULL, -- csv|xml|json|api|affiliate + api_endpoint TEXT, + account_id TEXT, + credentials_enc TEXT, -- encrypted secret ref (never raw) + fulfillment_caps JSONB DEFAULT '[]', + shipping_regions JSONB DEFAULT '[]', + reliability_score NUMERIC(5,2) DEFAULT 50.0, + status TEXT NOT NULL DEFAULT 'active', -- active|paused|failed + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() +); + +-- ── PRODUCTS ─────────────────────────────────────────────── +CREATE TABLE products ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + sku TEXT UNIQUE NOT NULL, + title TEXT NOT NULL, + description TEXT, + category TEXT, + brand TEXT, + manufacturer TEXT, + upc_ean_gtin TEXT, + images JSONB DEFAULT '[]', + dimensions JSONB, -- {length,width,height,unit} + weight_grams NUMERIC(10,2), + cost NUMERIC(12,2) NOT NULL DEFAULT 0, + retail_price NUMERIC(12,2), + min_price NUMERIC(12,2), + max_price NUMERIC(12,2), + status TEXT NOT NULL DEFAULT 'discovered', -- discovered|analyzing|approved|imported|content_generated|price_calculated|quality_check|published|monitored|paused|sold + lifecycle JSONB DEFAULT '{}', -- opportunity score etc + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() +); + +-- ── SUPPLIER_PRODUCTS ────────────────────────────────────── +CREATE TABLE supplier_products ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + supplier_id UUID NOT NULL REFERENCES suppliers(id) ON DELETE CASCADE, + supplier_sku TEXT NOT NULL, + product_id UUID REFERENCES products(id) ON DELETE SET NULL, + supplier_cost NUMERIC(12,2) NOT NULL DEFAULT 0, + supplier_inventory INTEGER NOT NULL DEFAULT 0, + shipping_cost NUMERIC(12,2) DEFAULT 0, + shipping_time TEXT, -- e.g. '3-5 days' + last_updated TIMESTAMPTZ DEFAULT now(), + UNIQUE (supplier_id, supplier_sku) +); + +-- ── CUSTOMERS ────────────────────────────────────────────── +CREATE TABLE customers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email TEXT UNIQUE NOT NULL, + name TEXT, + shipping_addr JSONB, + created_at TIMESTAMPTZ DEFAULT now() +); + +-- ── ORDERS ───────────────────────────────────────────────── +CREATE TABLE orders ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + order_number TEXT UNIQUE NOT NULL, + customer_id UUID REFERENCES customers(id), + items JSONB NOT NULL, -- [{product_id,qty,unit_price}] + retail_total NUMERIC(12,2) NOT NULL DEFAULT 0, + supplier_id UUID REFERENCES suppliers(id), + supplier_cost NUMERIC(12,2) DEFAULT 0, + shipping_cost NUMERIC(12,2) DEFAULT 0, + fees NUMERIC(12,2) DEFAULT 0, + profit NUMERIC(12,2) DEFAULT 0, + status TEXT NOT NULL DEFAULT 'new', -- new|paid|fraud_check|supplier_order|confirmed|shipped|delivered|cancelled|refunded + tracking TEXT, + carrier TEXT, + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() +); + +-- ── PRICE HISTORY ────────────────────────────────────────── +CREATE TABLE price_history ( + id BIGSERIAL PRIMARY KEY, + product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE, + price NUMERIC(12,2) NOT NULL, + reason TEXT, -- manual|auto|competitor|cost_change + created_at TIMESTAMPTZ DEFAULT now() +); + +-- ── INVENTORY HISTORY ────────────────────────────────────── +CREATE TABLE inventory_history ( + id BIGSERIAL PRIMARY KEY, + supplier_product_id UUID NOT NULL REFERENCES supplier_products(id) ON DELETE CASCADE, + inventory INTEGER NOT NULL, + created_at TIMESTAMPTZ DEFAULT now() +); + +-- ── SUPPLIER PERFORMANCE ─────────────────────────────────── +CREATE TABLE supplier_performance ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + supplier_id UUID NOT NULL REFERENCES suppliers(id) ON DELETE CASCADE, + fulfillment_rate NUMERIC(5,2) DEFAULT 100, + avg_shipping_days NUMERIC(6,2), + cancellation_rate NUMERIC(5,2) DEFAULT 0, + stock_accuracy NUMERIC(5,2) DEFAULT 100, + return_rate NUMERIC(5,2) DEFAULT 0, + updated_at TIMESTAMPTZ DEFAULT now() +); + +-- ── PRICE RULES ──────────────────────────────────────────── +CREATE TABLE price_rules ( + id SERIAL PRIMARY KEY, + min_cost NUMERIC(12,2) NOT NULL DEFAULT 0, + max_cost NUMERIC(12,2), -- null = no upper bound + markup_pct NUMERIC(6,2) NOT NULL, + min_margin_pct NUMERIC(6,2) DEFAULT 0, + active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ DEFAULT now() +); + +-- ── AUDIT LOG ────────────────────────────────────────────── +CREATE TABLE audit_logs ( + id BIGSERIAL PRIMARY KEY, + actor TEXT, + action TEXT NOT NULL, + entity TEXT, + entity_id TEXT, + detail JSONB, + created_at TIMESTAMPTZ DEFAULT now() +); + +-- seed default price rules (cost -> markup) +INSERT INTO price_rules (min_cost, max_cost, markup_pct) VALUES + (0, 25, 50.00), + (25, 100, 35.00), + (100, 500, 25.00), + (500, NULL, 15.00); + +CREATE INDEX idx_supplier_products_product ON supplier_products(product_id); +CREATE INDEX idx_orders_status ON orders(status); +CREATE INDEX idx_products_status ON products(status); +CREATE INDEX idx_price_history_product ON price_history(product_id); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e15c660 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,30 @@ +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: reseller + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: reseller + volumes: + - pgdata:/var/lib/postgresql/data + - ./db/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql:ro + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U reseller"] + interval: 5s + timeout: 3s + retries: 10 + + redis: + image: redis:7-alpine + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 10 + +volumes: + pgdata: