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:

PrerequisiteStatus
Phase 0 ROAS improvement confirmedPending (30-day monitoring)
Shopify Partner account createdReady
Google Cloud project configuredReady
PostgreSQL database available (postgres16)Ready
Cloudflare Tunnel operationalReady
Development store for testingReady (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:

ComponentSource (sales-page-app)AdPriority Target
Session token validationbackend/app/auth/shopify_session.pybackend/src/auth/session.ts
OAuth callback flowbackend/app/routes/embedded.pybackend/src/api/routes/auth.ts
App Bridge initializationadmin-ui/src/main.tsxadmin-ui/src/main.tsx
Polaris AppProvideradmin-ui/src/App.tsxadmin-ui/src/App.tsx
Docker compose structuredocker-compose.ymldocker-compose.yml
Vite build configadmin-ui/vite.config.tsadmin-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:

SettingValue
App nameAdPriority
App URLhttps://adpriority.nexusclothing.synology.me (via Cloudflare Tunnel)
Allowed redirection URLhttps://adpriority.nexusclothing.synology.me (Token Exchange – no callback needed)
App typePublic (embedded)
DistributionDevelopment only (until App Store submission)

Required scopes:

ScopePurpose
read_productsFetch product titles, types, tags, vendors
write_productsStore priority data in product metafields
read_inventoryCheck 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):

TablePurposePhase 1
tenantsTenant (shop) recordsYes
productsProduct priorities and metadataYes
rulesCategory rule definitionsYes
rule_conditionsRule condition detailsYes
seasonsSeason date boundariesYes
season_rulesCategory x season matrixYes
sync_logsSync audit trailYes
subscriptionsBilling recordsStub only
audit_logsChange trackingYes

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):

WebhookTopicPurpose
products/createNew product addedApply rules, add to sync queue
products/updateProduct modifiedCheck for type/tag changes, recalculate
products/deleteProduct removedRemove from products table
app/uninstalledApp removedDelete 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

  • IndexTable with 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.

EndpointWebhookResponse
POST /api/webhooks/customers-data-requestcustomers/data_request200 OK with { "message": "No customer PII stored" }
POST /api/webhooks/customers-redactcustomers/redact200 OK with { "message": "No customer PII to delete" }
POST /api/webhooks/shop-redactshop/redactDelete 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

ComponentHostPortNotes
Backend APISynology NAS3010Express.js + TypeScript
Admin UISynology NAS3011React + Vite (dev server)
PostgreSQLpostgres16 container5432Shared instance, adpriority_db
RedisRedis container6379Session cache + job queue
TunnelCloudflare Tunnel443HTTPS 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.

ComponentCoverage TargetFramework
Scoring engine80%Vitest
Tag modifier logic80%Vitest
API route handlers60%Vitest + Supertest
Database queries50%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:

TestExpected Result
Install app from Partner DashboardToken exchange completes, tenant record created
Open app in Shopify AdminEmbedded UI loads inside admin iframe
Product import triggersAll 2,425 active products imported with variants
Priority scores calculatedDistribution matches Phase 0 manual results
Manual overrideProduct priority locked, persists across sessions
Google Sheet syncAll variant rows written, GMC matches products
Webhook: product createdNew product appears in app with correct priority
Webhook: product updatedPriority recalculated if type or tags changed
Webhook: app uninstalledAll tenant data deleted after grace period
GDPR: customers/data_requestReturns 200 with “no PII” message
GDPR: shop/redactDeletes 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

DeliverableDescriptionAcceptance Criteria
App scaffoldProject structure, Docker compose, configsdocker-compose up starts all services
OAuth flowShopify install and authenticationMerchant can install and access app
DatabaseSchema deployed, seeded with defaultsAll tables created, Nexus seasons seeded
Product importFetch and store products from Shopify2,425 products imported in < 30 seconds
Scoring enginePriority calculation (0-5)Scores match Phase 0 manual results
Sheet syncWrite feed to Google SheetAll variants written, GMC matches 100%
Products UIList and detail screens in PolarisProducts viewable, overrides functional
GDPR webhooksThree mandatory endpointsAll return 200 OK, shop/redact deletes data
Webhook handlersProduct create/update/delete, app/uninstalledReal-time priority updates