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.
Timeline: 2 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/auth/callback |
| 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 |
|---|---|---|
stores | 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 Dec-Feb, Spring Mar-May, Summer Jun-Aug, Fall Sep-Nov)
- 20 category group rules with seasonal modifiers
- Tag modifier configuration
Task 4: Implement OAuth and Session Tokens
The authentication flow follows Shopify’s token exchange pattern for embedded apps.
OAUTH FLOW
==========
1. Merchant clicks "Install" in Shopify Admin
|
v
2. Shopify redirects to /auth?shop=store.myshopify.com
|
v
3. AdPriority validates the shop parameter
|
v
4. Redirect to Shopify OAuth authorization URL
with requested scopes
|
v
5. Merchant approves permissions
|
v
6. Shopify redirects to /auth/callback
with authorization_code
|
v
7. AdPriority exchanges code for access_token
|
v
8. Store access_token (encrypted) in stores table
|
v
9. Register mandatory webhooks
|
v
10. Redirect to app dashboard (embedded)
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 store_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
(store_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 background job queue.
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 store 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, store_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 store 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 store, 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
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 | OAuth flow completes, store 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 store data deleted after grace period |
| GDPR: customers/data_request | Returns 200 with “no PII” message |
| GDPR: shop/redact | Deletes all store 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 |