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:

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/auth/callback
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
storesTenant (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 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):

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

  • 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 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

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

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 DashboardOAuth flow completes, store 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 store data deleted after grace period
GDPR: customers/data_requestReturns 200 with “no PII” message
GDPR: shop/redactDeletes 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

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