Chapter 21: Phase 1 - SaaS Foundation
Goal
Build a working Shopify app that replicates the Phase 0 manual workflow as an installable, multi-tenant application. By the end of Phase 1, a merchant can install AdPriority from the Shopify Admin, import their product catalog, see priority scores, and sync custom labels to a Google Sheet.
Aspirational Timeline: 12 weeks (full Phase 1-3) Realistic Timeline: 6-9 months (solo developer on Synology NAS, part-time) Phase 1 Target: 2-4 weeks
Prerequisites
Phase 1 begins only after Phase 0 validates that priority-based custom labels improve ROAS in Performance Max campaigns. The following must be true:
| Prerequisite | Status |
|---|---|
| Phase 0 ROAS improvement confirmed | Pending (30-day monitoring) |
| Shopify Partner account created | Ready |
| Google Cloud project configured | Ready |
| PostgreSQL database available (postgres16) | Ready |
| Cloudflare Tunnel operational | Ready |
| Development store for testing | Ready (nexus-clothes.myshopify.com) |
Task Breakdown
Task 1: Clone Sales-Page-App Structure
The existing sales-page-app on the NAS provides a proven Shopify embedded app scaffold with session token validation, App Bridge 4.1 integration, and Polaris v13 UI. Rather than starting from scratch, AdPriority reuses this architecture.
Copy and adapt:
| Component | Source (sales-page-app) | AdPriority Target |
|---|---|---|
| Session token validation | backend/app/auth/shopify_session.py | backend/src/auth/session.ts |
| OAuth callback flow | backend/app/routes/embedded.py | backend/src/api/routes/auth.ts |
| App Bridge initialization | admin-ui/src/main.tsx | admin-ui/src/main.tsx |
| Polaris AppProvider | admin-ui/src/App.tsx | admin-ui/src/App.tsx |
| Docker compose structure | docker-compose.yml | docker-compose.yml |
| Vite build config | admin-ui/vite.config.ts | admin-ui/vite.config.ts |
Technology decision: Phase 1 uses Express.js + TypeScript for the backend (per the architecture document) rather than FastAPI. This aligns with the rest of the NAS infrastructure (Task Manager, ShopSyncFlow) and provides a broader pool of Shopify app examples.
Directory structure to create:
/volume1/docker/adpriority/
├── CLAUDE.md
├── README.md
├── docker-compose.yml
├── docker-compose.prod.yml
├── .env.example
├── backend/
│ ├── Dockerfile
│ ├── package.json
│ ├── tsconfig.json
│ └── src/
│ ├── index.ts
│ ├── app.ts
│ ├── api/routes/
│ ├── services/
│ ├── integrations/
│ ├── database/
│ └── utils/
├── admin-ui/
│ ├── Dockerfile
│ ├── package.json
│ ├── vite.config.ts
│ └── src/
│ ├── main.tsx
│ ├── App.tsx
│ ├── pages/
│ ├── components/
│ ├── hooks/
│ └── utils/
└── prisma/
└── schema.prisma
Task 2: Create Shopify App in Partner Dashboard
Register AdPriority as a new app in the Shopify Partner Dashboard.
Configuration:
| Setting | Value |
|---|---|
| App name | AdPriority |
| App URL | https://adpriority.nexusclothing.synology.me (via Cloudflare Tunnel) |
| Allowed redirection URL | https://adpriority.nexusclothing.synology.me (Token Exchange – no callback needed) |
| App type | Public (embedded) |
| Distribution | Development only (until App Store submission) |
Required scopes:
| Scope | Purpose |
|---|---|
read_products | Fetch product titles, types, tags, vendors |
write_products | Store priority data in product metafields |
read_inventory | Check stock levels for priority adjustments |
Outputs: Client ID and Client Secret, stored in .env and encrypted at rest.
Task 3: Set Up Database
Create the AdPriority database in the shared postgres16 container.
-- Execute on postgres16 container
CREATE DATABASE adpriority_db;
CREATE USER adpriority_user WITH PASSWORD 'AdPrioritySecure2026';
GRANT ALL PRIVILEGES ON DATABASE adpriority_db TO adpriority_user;
\c adpriority_db
GRANT ALL ON SCHEMA public TO adpriority_user;
Prisma schema covers the following tables (see Chapter 12 for full definitions):
| Table | Purpose | Phase 1 |
|---|---|---|
tenants | Tenant (shop) records | Yes |
products | Product priorities and metadata | Yes |
rules | Category rule definitions | Yes |
rule_conditions | Rule condition details | Yes |
seasons | Season date boundaries | Yes |
season_rules | Category x season matrix | Yes |
sync_logs | Sync audit trail | Yes |
subscriptions | Billing records | Stub only |
audit_logs | Change tracking | Yes |
Run initial migration:
cd /volume1/docker/adpriority/backend
npx prisma migrate dev --name init
npx prisma generate
Seed default data for Nexus Clothing:
- 4 seasons (Winter Nov-Feb, Spring Mar-Apr, Summer May-Aug, Fall Sep-Oct)
- 20 category group rules with seasonal modifiers
- Tag modifier configuration
Task 4: Implement Token Exchange Authentication
The authentication flow follows Shopify’s token exchange pattern for embedded apps (not the legacy OAuth redirect flow). Token exchange is the recommended approach for embedded apps using App Bridge 4.x.
TOKEN EXCHANGE FLOW
===================
1. Merchant opens app in Shopify Admin
|
v
2. App Bridge obtains a session token (JWT)
from the Shopify Admin iframe context
|
v
3. Frontend sends session token to backend
via Authorization: Bearer <token>
|
v
4. Backend exchanges session token for
an offline access token via POST
/admin/oauth/access_token (token exchange grant)
|
v
5. Store access_token (encrypted) in tenants table
|
v
6. Register mandatory webhooks
|
v
7. Return app data to frontend (embedded)
Note: The legacy OAuth redirect flow (steps 2-6 with redirects and authorization_code) is no longer required for embedded apps. Token exchange eliminates the redirect dance and provides a smoother install experience.
Session token validation (for all subsequent API requests):
// backend/src/auth/session.ts
import jwt from "jsonwebtoken";
interface SessionPayload {
iss: string; // https://store.myshopify.com/admin
dest: string; // https://store.myshopify.com
aud: string; // Client ID
sub: string; // User ID
exp: number;
nbf: number;
iat: number;
jti: string;
sid: string;
}
export function validateSessionToken(token: string): SessionPayload {
return jwt.verify(token, process.env.SHOPIFY_CLIENT_SECRET!, {
algorithms: ["HS256"],
audience: process.env.SHOPIFY_CLIENT_ID,
clockTolerance: 10,
}) as SessionPayload;
}
Every API endpoint uses middleware that extracts the session token from the Authorization: Bearer <token> header, validates it, and resolves the tenant_id from the iss claim.
Task 5: Product Import from Shopify API
On first install (and on demand), AdPriority fetches the merchant’s full product catalog from the Shopify Admin REST API.
Import pipeline:
PRODUCT IMPORT PIPELINE
=======================
1. GET /admin/api/2024-01/products.json?limit=250&status=active
(paginated via Link header)
|
v
2. For each product:
a. Extract: id, title, product_type, vendor, tags, created_at, status
b. For each variant:
- Extract: id, sku, inventory_quantity, price
- Generate GMC ID: shopify_US_{productId}_{variantId}
|
v
3. Upsert into products table
(tenant_id, shopify_product_id, shopify_variant_id)
|
v
4. Apply priority rules (Task 6)
|
v
5. Log import results to sync_logs
Rate limiting: Shopify allows 2 requests per second for REST API. For a 2,425-product catalog at 250 per page, the import requires ~10 requests and completes in under 10 seconds. Larger catalogs use a Bull+Redis background job queue (included from Phase 1).
Webhook registration (for ongoing sync):
| Webhook | Topic | Purpose |
|---|---|---|
products/create | New product added | Apply rules, add to sync queue |
products/update | Product modified | Check for type/tag changes, recalculate |
products/delete | Product removed | Remove from products table |
app/uninstalled | App removed | Delete all tenant data (30-day retention) |
Task 6: Basic Priority Scoring Engine
The scoring engine evaluates each product against the configured rules and produces a priority score from 0 to 5.
Evaluation order (highest precedence first):
PRIORITY CALCULATION
====================
Input: product, current_season, tenant_config
|
v
1. Check manual override (priority_locked = true)
--> If locked, return locked priority
|
v
2. Check exclusion tags (archived, DEAD50)
--> If present, return 0
|
v
3. Check inventory (variant.inventory_quantity = 0)
--> If zero stock, return 0
|
v
4. Find matching category rule
--> Match product_type against rule patterns
--> Get seasonal priority for current season
--> If no match, use tenant default (3)
|
v
5. Apply tag modifiers
--> warning_inv_1: -1
--> warning_inv: -1
--> in-stock: +1
--> NAME BRAND: +1
--> Sale: -1
|
v
6. Apply new arrival boost
--> If created_at within configured days, set minimum priority to 5
|
v
7. Clamp result to [0, 5]
|
v
Output: priority (integer 0-5), priority_source (string)
The engine runs:
- On initial product import (all products)
- When a product webhook fires (single product)
- When a rule or season is modified (affected products)
- On manual “Recalculate All” trigger
Task 7: Google Sheets Write Integration
AdPriority writes the supplemental feed to a Google Sheet using the Google Sheets API v4.
Authentication: Service account with Sheets API scope, or OAuth with the merchant’s Google account. For Phase 1, use a service account owned by AdPriority (simpler setup; the merchant provides only the Sheet URL).
Write process:
SHEET SYNC PROCESS
==================
1. Query all products with needs_sync = true (or all, for full sync)
|
v
2. Build row array:
[ gmcId, "priority-{N}", season, categoryGroup, status, brandTier ]
|
v
3. Prepend header row:
[ "id", "custom_label_0", "custom_label_1", ... "custom_label_4" ]
|
v
4. Clear existing Sheet data (Sheet1!A:F)
|
v
5. Write all rows via sheets.spreadsheets.values.update()
Range: Sheet1!A1
ValueInputOption: RAW
|
v
6. Update products table: sync_status = 'synced', last_synced_at = NOW()
|
v
7. Log to sync_logs: products_total, products_success, products_failed
Sheet size for Nexus: ~15,000-20,000 rows x 6 columns = ~120,000 cells (1.2% of the 10 million cell limit). This approach scales comfortably to stores with up to ~1.5 million variants.
Task 8: Simple Polaris UI
Phase 1 delivers a minimal but functional UI with two screens:
Screen 1: Products List
IndexTablewith columns: Product, Type, Vendor, Priority (badge), Status- Server-side pagination (50 per page)
- Filter by priority level
- Click to view product detail
Screen 2: Product Detail
- Product information card (title, type, vendor, tags)
- Priority breakdown card (base + modifiers = final)
- Manual override form (select + reason field)
- Custom label preview (what GMC will see)
Additional pages (Rules, Seasons, Dashboard) are deferred to Phase 2. Phase 1 includes navigation placeholders that display “Coming Soon” empty states.
Task 9: GDPR Webhooks
Three mandatory GDPR webhook endpoints must be implemented for App Store compliance.
| Endpoint | Webhook | Response |
|---|---|---|
POST /api/webhooks/customers-data-request | customers/data_request | 200 OK with { "message": "No customer PII stored" } |
POST /api/webhooks/customers-redact | customers/redact | 200 OK with { "message": "No customer PII to delete" } |
POST /api/webhooks/shop-redact | shop/redact | Delete all store data, return 200 OK |
AdPriority stores only product data and priority scores. No customer PII is ever collected or stored, so the first two endpoints are no-ops. The shop/redact endpoint triggers a cascade delete of all records associated with the tenant, with a 30-day grace period per Shopify’s data retention guidelines.
All webhook endpoints verify the HMAC signature in the X-Shopify-Hmac-Sha256 header before processing.
Development Environment
Docker Compose
# /volume1/docker/adpriority/docker-compose.yml
services:
backend:
build: ./backend
ports: ["3010:3010"]
volumes:
- ./backend/src:/app/src
environment:
- NODE_ENV=development
- DATABASE_URL=postgresql://adpriority_user:AdPrioritySecure2026@postgres16:5432/adpriority_db
- REDIS_URL=redis://redis:6379
- SHOPIFY_CLIENT_ID=${SHOPIFY_CLIENT_ID}
- SHOPIFY_CLIENT_SECRET=${SHOPIFY_CLIENT_SECRET}
- GOOGLE_SHEETS_CREDENTIALS=${GOOGLE_SHEETS_CREDENTIALS}
networks:
- postgres_default
- adpriority
admin-ui:
build: ./admin-ui
ports: ["3011:3011"]
volumes:
- ./admin-ui/src:/app/src
networks:
- adpriority
redis:
image: redis:7-alpine
ports: ["6379:6379"]
networks:
- adpriority
networks:
postgres_default:
external: true
adpriority:
driver: bridge
Infrastructure
| Component | Host | Port | Notes |
|---|---|---|---|
| Backend API | Synology NAS | 3010 | Express.js + TypeScript |
| Admin UI | Synology NAS | 3011 | React + Vite (dev server) |
| PostgreSQL | postgres16 container | 5432 | Shared instance, adpriority_db |
| Redis | Redis container | 6379 | Session cache + job queue |
| Tunnel | Cloudflare Tunnel | 443 | HTTPS termination for Shopify callbacks |
Cloudflare Tunnel Configuration
Shopify requires HTTPS callback URLs. The Cloudflare Tunnel provides secure external access without exposing ports on the NAS.
TUNNEL ROUTING
==============
Internet Synology NAS
-------- ------------
https://adpriority.nexus... Cloudflare Tunnel
/auth/* --> localhost:3010 (backend)
/api/* --> localhost:3010 (backend)
/webhooks/* --> localhost:3010 (backend)
/* --> localhost:3011 (admin-ui)
Testing
Unit Test Coverage Target
Phase 1 establishes the testing foundation with a target of 80% unit test coverage for the scoring engine. The scoring engine is the core business logic and must be thoroughly tested to ensure priority calculations match Phase 0 manual results.
| Component | Coverage Target | Framework |
|---|---|---|
| Scoring engine | 80% | Vitest |
| Tag modifier logic | 80% | Vitest |
| API route handlers | 60% | Vitest + Supertest |
| Database queries | 50% | Vitest + test DB |
Install on Development Store
Phase 1 testing uses nexus-clothes.myshopify.com as the development store. The app is installed in development mode (not yet submitted to the App Store).
Test plan:
| Test | Expected Result |
|---|---|
| Install app from Partner Dashboard | Token exchange completes, tenant record created |
| Open app in Shopify Admin | Embedded UI loads inside admin iframe |
| Product import triggers | All 2,425 active products imported with variants |
| Priority scores calculated | Distribution matches Phase 0 manual results |
| Manual override | Product priority locked, persists across sessions |
| Google Sheet sync | All variant rows written, GMC matches products |
| Webhook: product created | New product appears in app with correct priority |
| Webhook: product updated | Priority recalculated if type or tags changed |
| Webhook: app uninstalled | All tenant data deleted after grace period |
| GDPR: customers/data_request | Returns 200 with “no PII” message |
| GDPR: shop/redact | Deletes all tenant data, returns 200 |
Comparison with Phase 0
The Phase 1 app should produce identical priority scores to the Phase 0 manual spreadsheet for the same products. Any discrepancy indicates a bug in the scoring engine.
VALIDATION: PHASE 0 vs PHASE 1
===============================
Product Phase 0 Score Phase 1 Score Match?
------- ------------- ------------- ------
New Era Yankees 59FIFTY 4 4 Yes
Jordan Craig Stacked Jeans 5 5 Yes
Rebel Minds Puffer Jacket 5 5 Yes
Ethika Men Go Pac Go 0 0 Yes
G3 Patriots Hoodie 0 0 Yes
...
(all 2,425 products)
Deliverables
| Deliverable | Description | Acceptance Criteria |
|---|---|---|
| App scaffold | Project structure, Docker compose, configs | docker-compose up starts all services |
| OAuth flow | Shopify install and authentication | Merchant can install and access app |
| Database | Schema deployed, seeded with defaults | All tables created, Nexus seasons seeded |
| Product import | Fetch and store products from Shopify | 2,425 products imported in < 30 seconds |
| Scoring engine | Priority calculation (0-5) | Scores match Phase 0 manual results |
| Sheet sync | Write feed to Google Sheet | All variants written, GMC matches 100% |
| Products UI | List and detail screens in Polaris | Products viewable, overrides functional |
| GDPR webhooks | Three mandatory endpoints | All return 200 OK, shop/redact deletes data |
| Webhook handlers | Product create/update/delete, app/uninstalled | Real-time priority updates |