The AdPriority Blueprint

Automated Google Ads Priority Scoring for Shopify Merchants

Version: 1.0.0 Created: February 2026 Target Platform: /volume1/docker/adpriority/


+===============================================================================+
|                                                                               |
|           _    ____  ____  ____  ___ ___  ____  ___ _____ __   __             |
|          / \  |  _ \|  _ \|  _ \|_ _/ _ \|  _ \|_ _|_   _|\ \ / /            |
|         / _ \ | | | | |_) | |_) || | | | | |_) || |  | |   \ V /             |
|        / ___ \| |_| |  __/|  _ < | | |_| |  _ < | |  | |    | |              |
|       /_/   \_\____/|_|   |_| \_\___\___/|_| \_\___| |_|    |_|              |
|                                                                               |
|       ____  _     _   _ _____ ____  ____  ___ _   _ _____                    |
|      | __ )| |   | | | | ____|  _ \|  _ \|_ _| \ | |_   _|                   |
|      |  _ \| |   | | | |  _| | |_) | |_) || ||  \| | | |                     |
|      | |_) | |___| |_| | |___|  __/|  _ < | || |\  | | |                     |
|      |____/|_____|\___/|_____|_|   |_| \_\___|_| \_| |_|                     |
|                                                                               |
|              Automated Google Ads Priority Scoring                             |
|                   for Shopify Merchants                                        |
|                                                                               |
|         Priority Scores (0-5) --> GMC Custom Labels --> PMAX Campaigns        |
|                                                                               |
+===============================================================================+

How to Use This Book

This Blueprint is a self-contained guide for building a production-grade Shopify app that automates Google Ads product priority scoring. Every architecture diagram, API specification, scoring algorithm, and deployment procedure is included directly in these pages.

Reading Order

If You Want To…Start With…
Understand the product visionPart I: Foundation
Design the systemPart II: Architecture
Understand external APIsPart III: Integrations
Build the backendPart IV: Backend
Create the Shopify UIPart V: Frontend
Start implementingPart VI: Implementation
Deploy and operatePart VII: Operations
Look up reference dataAppendices

Key Concepts

Throughout this book, the following terms appear frequently:

+----------------------------------------------------------------------+
|  TERM              |  MEANING                                        |
+----------------------------------------------------------------------+
|  Priority Score    |  0-5 integer rating controlling ad spend         |
|  Custom Label      |  GMC product attribute for campaign segmentation |
|  Supplemental Feed |  Secondary GMC feed that adds/overrides labels   |
|  PMAX              |  Google Performance Max campaign type             |
|  Scoring Engine    |  Service that calculates product priorities       |
|  Rules Engine      |  IF-THEN conditions for automatic scoring        |
|  Seasonal Calendar |  Time-based priority rotation system              |
+----------------------------------------------------------------------+

Table of Contents

Front Matter


Part I: Foundation

Understanding the problem, the requirements, and the opportunity


Part II: Architecture

System design and key technical decisions


Part III: Integrations

External API specifications and data flows


Part IV: Backend

Server-side implementation details


Part V: Frontend

Shopify embedded app interface


Part VI: Implementation Guide

Step-by-step building instructions


Part VII: Operations

Deployment, monitoring, and compliance


Appendices


Book Statistics

MetricValue
Total Chapters26
Parts7
Appendices5
Database Tables8 core
API Endpoint Groups7
Integration APIs4 (Shopify, GMC, Google Ads, Sheets)
Priority Levels6 (scores 0-5)
Pricing Tiers4 (Starter, Growth, Pro, Enterprise)
Implementation Phases4 (Phase 0-3)
Target GradeA (Production-Ready Shopify App)

How to Build This Book

Build the mdBook Site

# Navigate to mdbook source
cd /volume1/docker/planning/24-adpriority-saas/blueprint/mdbook-src

# Build the static site
mdbook build

# Output is in: ./book/

Deploy to Cloudflare Pages

# From the mdbook-src directory after building
wrangler pages deploy book --project-name=adpriority-blueprint

Local Preview

# Serve locally with hot-reload
mdbook serve --open

# Default: http://localhost:3000
cd /volume1/docker/planning/24-adpriority-saas/blueprint/mdbook-src/src

pandoc \
  00-BOOK-INDEX.md \
  01-PREFACE.md \
  Part-I-Foundation/*.md \
  Part-II-Architecture/*.md \
  Part-III-Integrations/*.md \
  Part-IV-Backend/*.md \
  Part-V-Frontend/*.md \
  Part-VI-Implementation/*.md \
  Part-VII-Operations/*.md \
  Appendices/*.md \
  -o AdPriority-Blueprint.pdf \
  --toc \
  --toc-depth=3 \
  --pdf-engine=xelatex \
  -V geometry:margin=1in \
  -V fontsize=11pt \
  -V mainfont="DejaVu Sans" \
  -V monofont="DejaVu Sans Mono"

Estimated Print Size

FormatPagesNotes
Full Book~250-300All chapters and appendices
Core (Parts I-IV)~120Foundation through Backend
Quick Reference~30Appendices only

Blueprint Maintenance

How to Update This Blueprint

TaskAction
Edit contentUpdate markdown file in src/
Add chapterCreate file, update this index, update SUMMARY.md
Add appendixCreate file, update this index, update SUMMARY.md
Build siteRun mdbook build from mdbook-src/
DeployRun wrangler pages deploy book

CRITICAL: When adding or removing chapters/appendices, you MUST update:

  1. This file (00-BOOK-INDEX.md)
  2. SUMMARY.md (mdBook table of contents)

Version History

VersionDateChanges
1.0.02026-02-10Initial AdPriority Blueprint

Contributors

RoleContributor
ArchitectClaude Code Architect Agent
AuthorClaude Code Editor Agent
ReviewerClaude Code Engineer Agent
ResearchClaude Code Researcher Agent
CoordinatorClaude Code Orchestrator

Quick Reference: The Priority Scoring Model

+-------+-------------+-------------------------------------------+
| Score | Name        | Campaign Behavior                         |
+-------+-------------+-------------------------------------------+
|   5   | Push Hard   | Maximum budget, aggressive bidding        |
|   4   | Strong      | High budget, balanced approach            |
|   3   | Moderate    | Standard budget, conservative bidding     |
|   2   | Light       | Minimal budget, strict ROAS targets       |
|   1   | Minimal     | Very low budget, highest ROAS only        |
|   0   | Exclude     | No advertising spend                      |
+-------+-------------+-------------------------------------------+
                    THE ADPRIORITY DATA FLOW

  +-----------+      +-----------+      +-----------+      +----------+
  |  Shopify  | ---> | AdPriority| ---> |   Google  | ---> |  Google  |
  |  Product  |      |  Scoring  |      |  Merchant |      |   Ads    |
  |  Catalog  |      |  Engine   |      |  Center   |      |   PMAX   |
  +-----------+      +-----------+      +-----------+      +----------+
    5,582 SKUs        Rules +            Custom             Campaign
    (Nexus)           Seasons +          Labels             Segments
                      Manual             0-4                by Priority

“Score it. Label it. Let PMAX do the rest.”

Preface

Why This Book Exists

Every retailer running Google Ads faces the same quiet problem: not all products deserve the same advertising budget, but managing priorities manually is a losing battle.

Consider a clothing retailer with 5,000 products. Shorts should get maximum ad spend in June and minimum in December. Jackets are the opposite. New arrivals need a visibility boost. Clearance items should not eat budget at all. Bestsellers should always get strong spend. Slow movers should barely appear.

A marketing manager who gets this right can double their return on ad spend. A marketing manager who gets this wrong – or, more commonly, who never gets around to managing it at all – bleeds budget into irrelevant products every single day.

This Blueprint documents a complete system for solving that problem: AdPriority, a Shopify app that automates Google Ads product priority scoring using a 0-5 scale with seasonal automation.


The Problem in Detail

Google Performance Max (PMAX) campaigns are powerful but opaque. Google’s algorithm decides which products to show, when, and to whom. Merchants have one primary lever for influencing product-level spend: campaign segmentation using Google Merchant Center custom labels.

The workflow looks like this:

+-----------------------------------------------------------------+
|                                                                   |
|   1. Assign custom labels to products in GMC                     |
|   2. Create separate PMAX campaigns (or asset groups)            |
|      segmented by those labels                                    |
|   3. Set different budgets and ROAS targets per segment           |
|   4. Google optimizes within each segment independently          |
|                                                                   |
+-----------------------------------------------------------------+

The problem is step 1. Assigning and maintaining custom labels across thousands of products is:

  • Time-consuming: Manual updates for every seasonal change, every new arrival, every clearance event
  • Error-prone: Forgetting to update labels means budget flowing to the wrong products for days or weeks
  • Difficult to scale: What works for 100 products breaks at 1,000 and collapses at 10,000
  • Knowledge-dependent: When the person who manages labels leaves, institutional knowledge walks out the door

Most merchants either ignore custom labels entirely (leaving PMAX fully automated with no guardrails) or set them once and never update them (which is almost as bad).


The Solution Approach

AdPriority introduces a simple but powerful abstraction: the Priority Score.

+=========================================================================+
|                                                                           |
|   PRIORITY SCORING MODEL                                                 |
|                                                                           |
|   Score   Name          Ad Spend Behavior                                |
|   -----   -----------   ------------------------------------------------ |
|     5     Push Hard     Maximum budget, aggressive bidding               |
|     4     Strong        High budget, balanced approach                   |
|     3     Moderate      Standard budget, conservative bidding            |
|     2     Light         Minimal budget, strict ROAS targets              |
|     1     Minimal       Very low budget, only highest-ROAS clicks        |
|     0     Exclude       No advertising -- product is suppressed          |
|                                                                           |
+=========================================================================+

The scoring engine determines each product’s priority through a clear hierarchy:

                    SCORE OVERRIDE HIERARCHY

            +----------------------------------+
            |    1. Manual Override             |   <-- Highest priority
            +----------------------------------+
                          |
            +----------------------------------+
            |    2. Seasonal Calendar           |
            +----------------------------------+
                          |
            +----------------------------------+
            |    3. New Arrival Boost           |
            +----------------------------------+
                          |
            +----------------------------------+
            |    4. Category-Based Rule         |
            +----------------------------------+
                          |
            +----------------------------------+
            |    5. Default Score               |   <-- Lowest priority
            +----------------------------------+

Priority scores flow through a pipeline that ends at Google Ads:

  +----------+     +----------+     +-----------+     +---------+     +--------+
  | Shopify  |     |          |     | Supple-   |     | Google  |     | Google |
  | Products |---->| Scoring  |---->| mental    |---->| Merchant|---->| Ads    |
  | (source) |     | Engine   |     | Feed      |     | Center  |     | PMAX   |
  +----------+     +----------+     +-----------+     +---------+     +--------+
                        |
               +--------+--------+
               |        |        |
            Rules   Seasons   Manual
            Engine  Calendar  Overrides

The Three-Phase Approach

We adopted a deliberate phased approach, starting with a real-world validation before writing a single line of SaaS code:

+=========================================================================+
|                                                                           |
|   PHASE 0: NEXUS MVP                    STATUS: VALIDATED                |
|   ------------------                    -------------------------        |
|   Google Sheets supplemental feed       10/10 products matched           |
|   Manual priority assignment            Zero GMC errors                  |
|   Nexus Clothing as test store          Pipeline proven end-to-end       |
|   Document everything                   124,060 GMC products analyzed    |
|                                                                           |
|                            |                                             |
|                            v                                             |
|                                                                           |
|   PHASE 1: SAAS FOUNDATION              STATUS: PLANNED                 |
|   -------------------------              -------------------------        |
|   Shopify app (OAuth + App Bridge)      Database schema designed         |
|   PostgreSQL database                    API endpoints specified         |
|   Core API (products, rules, sync)      Polaris UI wireframed           |
|   Basic Polaris admin UI                                                 |
|                                                                           |
|                            |                                             |
|                            v                                             |
|                                                                           |
|   PHASE 2: FULL PRODUCT                 STATUS: PLANNED                 |
|   ----------------------                 -------------------------        |
|   Seasonal calendar engine              Category x Season matrix         |
|   Rules engine (IF-THEN)                New arrival auto-boost           |
|   Content API direct sync               Bulk operations                  |
|   Google Ads performance data           ROAS recommendations             |
|                                                                           |
|                            |                                             |
|                            v                                             |
|                                                                           |
|   PHASE 3: APP STORE LAUNCH             STATUS: PLANNED                 |
|   --------------------------             -------------------------        |
|   Shopify App Store listing             Billing integration              |
|   Multi-tenant production               Usage-based tier limits          |
|   Documentation + onboarding            Marketing + landing page         |
|                                                                           |
+=========================================================================+

The critical insight: Phase 0 is not a prototype. It is a real, working pipeline that validates the entire concept with actual GMC data before any engineering investment in the SaaS product. The supplemental feed approach was tested on 2026-02-10 with 10 Nexus products – all 10 matched in GMC with zero issues.


What Makes This Blueprint Different

1. Built on Real Data

This is not a theoretical product specification. The research behind this Blueprint includes:

Data PointValueSource
GMC products analyzed124,060Nexus Clothing GMC export
Shopify products mapped5,582Nexus Clothing Admin API
Supplemental feed test10/10 matchedGMC validation (2026-02-10)
Custom label availability5/5 slots freeGMC product export analysis
Competitor tools analyzed8+Market research (2026-02-06)
Google Ads accountActive (298-086-1126)Nexus Clothing

2. Validated Pipeline

The most risky part of any GMC integration is the product ID matching between Shopify and Merchant Center. We validated this before writing the Blueprint:

    SUPPLEMENTAL FEED VALIDATION RESULTS (2026-02-10)

    +-----------------------------+----------+
    | Metric                      | Result   |
    +-----------------------------+----------+
    | Products submitted          | 10       |
    | Products matched in GMC     | 10       |
    | Match rate                  | 100%     |
    | Label propagation errors    | 0        |
    | Time to reflect in GMC      | < 1 hour |
    | Custom label conflicts      | 0        |
    +-----------------------------+----------+

    Verdict: Pipeline is PROVEN. Build with confidence.

3. Existing Infrastructure

AdPriority does not start from scratch. It builds on proven Shopify app infrastructure:

  • Existing Shopify app template with OAuth flow (/volume1/docker/sales-page-app/)
  • Shared PostgreSQL 16 instance (postgres16 container, production-grade)
  • Docker deployment pipeline with Cloudflare Tunnel for external access
  • Established development workflow on Synology NAS

4. Clear Market Gap

Market research identified a specific gap: no Shopify-native, affordable solution exists that is specifically focused on priority scoring for Google Ads. Competitors offer custom labels as a secondary feature within general-purpose feed management tools. AdPriority makes priority scoring the primary product.


Who Should Read This Book

ReaderFocus AreasKey Chapters
Shopify MerchantsUnderstanding the product, what it does for their adsPart I (Foundation), Chapter 14 (Scoring Engine)
Backend DevelopersBuilding the API, database, and scoring logicParts III-IV (Integrations, Backend), Part VI (Implementation)
Frontend DevelopersBuilding the Shopify embedded app UIPart V (Frontend), Chapter 16 (Polaris UI)
Marketing ManagersUnderstanding how priority scores map to campaignsPart I (Foundation), Chapter 10 (Google Ads)
DevOps EngineersDeploying and operating the applicationPart VII (Operations)
Product ManagersRoadmap, market positioning, pricingPart I (Foundation), Chapter 02 (Market Analysis)

The Technology Stack

+=========================================================================+
|                         TECHNOLOGY STACK                                  |
|                                                                           |
|   BACKEND                              FRONTEND                          |
|   -------                              --------                          |
|   Node.js 20 LTS                       React 18                         |
|   Express.js                           Vite (build tool)                 |
|   TypeScript                           Shopify Polaris v13               |
|   Prisma ORM                           React Query (server state)        |
|   Bull + Redis (job queue)             Chart.js (analytics)              |
|                                                                           |
|   DATABASE                             INTEGRATIONS                      |
|   --------                             ------------                      |
|   PostgreSQL 16                        Shopify Admin API (GraphQL)       |
|   (shared postgres16 container)        Google Sheets API v4              |
|   Redis 7 (cache + queue)             GMC Content API for Shopping       |
|                                        Google Ads API v17                |
|   INFRASTRUCTURE                                                         |
|   --------------                                                         |
|   Docker + Docker Compose              AUTHENTICATION                    |
|   Cloudflare Tunnel                    ---------------                   |
|   GitHub Actions (CI/CD)               Shopify OAuth (App Bridge)        |
|   Synology NAS (host)                  Google OAuth 2.0                  |
|                                        JWT session tokens                |
|                                                                           |
+=========================================================================+

Why This Stack

ChoiceRationale
Express + TypeScriptTeam familiarity, Shopify app ecosystem alignment
PrismaType-safe ORM with excellent migration support
Shopify Polaris v13Native Shopify look and feel, required for App Store
PostgreSQL 16Existing shared infrastructure, battle-tested
Bull + RedisReliable background job processing for sync operations
DockerConsistent deployments across dev and production
Cloudflare TunnelSecure external access without exposing ports

Business Model

TierMonthly PriceProduct LimitKey Features
Starter$29Up to 500Manual scoring, GMC sync, basic dashboard
Growth$79UnlimitedSeasonal automation, rules engine, bulk operations
Pro$199UnlimitedGoogle Ads integration, ROAS recommendations, performance tracking
EnterpriseCustomUnlimitedMulti-store, white-label, API access, dedicated support

Pricing rationale: Positioned between budget tools (Simprosys at $4.99) and enterprise solutions (Feedonomics at $1,000+). The $29 entry point is accessible for small Shopify merchants. The $79 tier captures the core value proposition of seasonal automation. The $199 tier targets performance-focused marketers who want data-driven decisions.


Conventions Used in This Book

Code Samples

// TypeScript code appears in blocks like this
async function calculatePriority(productId: string): Promise<number> {
  // Scoring engine implementation
}

SQL Statements

-- SQL code appears in blocks like this
CREATE TABLE products (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    store_id UUID NOT NULL REFERENCES stores(id),
    priority_score INTEGER DEFAULT 3 CHECK (priority_score BETWEEN 0 AND 5)
);

API Examples

# HTTP requests appear like this
curl -X PATCH /api/products/abc123 \
  -H "Authorization: Bearer <token>" \
  -d '{"priority_score": 5}'

Important Notes

Note: Important information appears in blockquotes like this.

Warning: Critical warnings that could cause data issues or deployment failures.

ASCII Diagrams

Architecture and flow diagrams use ASCII art for portability:

+----------+     +----------+     +----------+
|  Client  |---->|   API    |---->| Database |
+----------+     +----------+     +----------+

Priority Score Indicators

When priority scores appear inline, they follow this convention:

  • P5 = Push Hard (highest)
  • P4 = Strong
  • P3 = Moderate
  • P2 = Light
  • P1 = Minimal
  • P0 = Exclude (no ads)

Acknowledgments

This Blueprint was created through collaboration between:

  • Nexus Clothing – the real-world test store whose 5,582 products and 124,060 GMC listings validated the entire approach
  • Google Merchant Center documentation – for making the supplemental feed specification clear enough to validate in one session
  • The Shopify developer ecosystem – for excellent App Bridge and Polaris documentation
  • Claude Code agents – who helped research, design, implement, and review every chapter

Let’s Build

The supplemental feed works. The product IDs match. The custom labels are available. The market gap is real. The infrastructure is ready.

This Blueprint contains everything needed to go from a validated Google Sheets prototype to a production Shopify app on the App Store.

Turn the page and let’s begin.


February 2026


Document Information

AttributeValue
Book TitleThe AdPriority Blueprint
SubtitleAutomated Google Ads Priority Scoring for Shopify Merchants
Version1.0.0
CreatedFebruary 2026
Total Chapters24
Total Appendices5
Target Platform/volume1/docker/adpriority/
Build Commandcd mdbook-src && mdbook build
Deploy Commandwrangler pages deploy book

Chapter 1: Vision & Goals

The Problem: Retailers Waste Ad Spend on the Wrong Products

Every day, thousands of Shopify merchants pour money into Google Ads without a strategy for which products deserve the most budget. The result is predictable and costly: winter jackets get the same ad spend as shorts in January, dead stock competes with bestsellers for clicks, and new arrivals languish in obscurity while clearance items drain the budget.

The root cause is straightforward. Google Ads Performance Max (PMAX) campaigns treat all products equally unless the merchant manually segments them using custom labels in Google Merchant Center. But managing those labels is a manual, error-prone process that most merchants either skip entirely or configure once and never update.

The Scale of the Problem

Consider a retailer like Nexus Clothing with 5,582 products across 90 product types and 175 vendors. In winter, puffer jackets and hoodies should receive maximum ad spend while shorts and sandals should be excluded entirely. When spring arrives, the priorities must reverse. Multiply this by seasonal transitions four times per year, new product arrivals weekly, and clearance cycles monthly, and the manual management burden becomes untenable.

THE WASTED AD SPEND CYCLE
=========================

  Merchant sets up PMAX campaign
           |
           v
  All products treated equally
           |
           v
  Budget spread across entire catalog
           |
           +---> Shorts get clicks in January (wasted)
           |
           +---> Dead stock gets impressions (wasted)
           |
           +---> New arrivals buried under old products (missed opportunity)
           |
           +---> Seasonal items out of sync (wrong timing)
           |
           v
  Poor ROAS  -->  Merchant blames Google Ads  -->  Reduces budget
           |
           v
  Fewer sales  -->  Competitor with better segmentation wins

Why Manual Management Fails

ChallengeImpact
Time-consumingUpdating labels for thousands of products takes hours per season change
Error-proneManual CSV uploads introduce typos, wrong IDs, stale data
Often forgottenSeason changes happen gradually; merchants miss the transition window
Doesn’t scaleWorks for 50 products, breaks at 500, impossible at 5,000+
No automationNew products sit unlabeled for days or weeks
Siloed knowledgeOnly the person who set it up understands the logic

The Solution: AdPriority

AdPriority automates product priority scoring with a simple, opinionated 0-5 scale. Instead of forcing merchants to build complex feed rules from scratch, AdPriority provides a purpose-built scoring system that maps directly to Google Ads budget allocation through Google Merchant Center custom labels.

The 0-5 Priority Scale

The scoring system is deliberately simple. Every product gets a single number from 0 to 5 that directly controls how aggressively it is advertised.

PRIORITY SCALE
==============

  5  |  PUSH HARD     Maximum budget, aggressive bidding
     |                 Seasonal bestsellers, hot new arrivals, peak demand
     |
  4  |  STRONG         High budget, balanced approach
     |                 Strong performers, seasonal relevance, name brands
     |
  3  |  NORMAL         Standard budget, conservative bidding
     |                 Average products, year-round staples
     |
  2  |  LOW            Minimal budget, strict ROAS targets
     |                 Declining season, overstocked, low margin
     |
  1  |  MINIMAL        Very low budget, highest ROAS threshold only
     |                 End of season, slow movers, clearance
     |
  0  |  EXCLUDE        No advertising spend
     |                 Dead stock, archived, out of stock, non-ad products

Priority Score Definitions

ScoreNameBudget BehaviorTypical Use Cases
5Push HardMaximum spend, aggressive biddingSeasonal peaks (shorts in summer, jackets in winter), hot new arrivals, proven bestsellers with strong margins
4StrongHigh spend, balanced approachCore performers, seasonal relevance, name brand products, rising demand categories
3NormalStandard spend, conservative biddingYear-round staples (jeans, fitted caps), average performers, baseline catalog
2LowMinimal spend, strict ROAS targetsOff-season items still in stock, low-margin products, overstocked items, underwear and basics
1MinimalVery low spend, only highest ROASEnd-of-season clearance, slow movers, items approaching dead stock
0ExcludeZero ad spendArchived products, dead stock (DEAD50 tagged), out-of-stock variants, gift cards, non-shoppable items

How Priority Scores Are Assigned

AdPriority calculates scores automatically using a layered rules engine. The merchant configures high-level rules (category mappings, seasonal calendars, tag modifiers), and the engine handles the per-product calculations.

Rule Hierarchy (highest priority wins):

  1. Manual override – Merchant locks a specific score
  2. Exclusion tagsarchived, DEAD50 force score to 0
  3. Inventory warningswarning_inv_1 reduces score by 1
  4. New arrival boost – Products created within 14 days get score 5
  5. Tag modifiersin-stock adds +1, NAME BRAND adds +1, Sale subtracts -1
  6. Seasonal calendar – Category-specific seasonal adjustments
  7. Category default – Base score from product type mapping

Real-World Example: Nexus Clothing in Winter

Product: "Rebel Minds Puffer Jacket" (active, Winter)
  Category group: Outerwear - Heavy --> seasonal Winter score: 5
  Tags: none relevant
  Final priority: 5 (PUSH HARD)

Product: "Jordan Craig Stacked Jeans" (active, Winter)
  Category group: Jeans & Pants --> seasonal Winter score: 4
  Tags: NAME BRAND --> +1
  Final priority: 5 (PUSH HARD)

Product: "New Era Yankees 59FIFTY" (active, Winter)
  Category group: Headwear - Caps --> seasonal Winter score: 3
  Tags: NAME BRAND --> +1
  Final priority: 4 (STRONG)

Product: "Generic Mesh Shorts" (active, Winter)
  Category group: Shorts --> seasonal Winter score: 0
  Tags: none relevant
  Final priority: 0 (EXCLUDE)

Product: "Old Season Hoodie" (archived, DEAD50)
  Exclusion tag override: archived --> 0
  Final priority: 0 (EXCLUDE)

How It Works: End-to-End Data Flow

AdPriority connects three systems: Shopify (product catalog), Google Merchant Center (product feed), and Google Ads (campaign bidding). The data flows in one direction, from product data to ad spend allocation.

END-TO-END DATA FLOW
=====================

+-------------------+     +------------------------+     +-------------------+
|                   |     |                        |     |                   |
|   SHOPIFY STORE   |     |   AdPriority ENGINE    |     |  GOOGLE MERCHANT  |
|                   |     |                        |     |     CENTER        |
|  - 5,582 products |---->|  1. Fetch products     |---->|                   |
|  - 90 types       |     |  2. Apply category     |     |  Supplemental     |
|  - 175 vendors    |     |     rules              |     |  Feed (Google     |
|  - 2,522 tags     |     |  3. Apply seasonal     |     |  Sheets)          |
|  - Webhooks for   |     |     calendar           |     |                   |
|    product changes|     |  4. Apply tag          |     |  custom_label_0:  |
|                   |     |     modifiers           |     |    priority-5     |
+-------------------+     |  5. Calculate final    |     |  custom_label_1:  |
                          |     priority (0-5)      |     |    winter         |
                          |  6. Generate custom    |     |  custom_label_2:  |
                          |     labels              |     |    outerwear      |
                          |  7. Write to Google    |     |  custom_label_3:  |
                          |     Sheet               |     |    in-stock       |
                          |                        |     |  custom_label_4:  |
                          +------------------------+     |    name-brand     |
                                                         |                   |
                                                         +--------+----------+
                                                                  |
                                                                  | GMC fetches
                                                                  | daily (auto)
                                                                  v
                                                         +-------------------+
                                                         |                   |
                                                         |   GOOGLE ADS      |
                                                         |   PERFORMANCE MAX |
                                                         |                   |
                                                         |  Campaign 1:      |
                                                         |    Priority 5     |
                                                         |    (max budget)   |
                                                         |                   |
                                                         |  Campaign 2:      |
                                                         |    Priority 3-4   |
                                                         |    (normal budget) |
                                                         |                   |
                                                         |  Campaign 3:      |
                                                         |    Priority 1-2   |
                                                         |    (min budget)   |
                                                         |                   |
                                                         |  [Priority 0:     |
                                                         |   excluded]       |
                                                         |                   |
                                                         +-------------------+

Custom Label Schema

AdPriority uses all five available GMC custom label slots to provide rich segmentation data to Google Ads campaigns.

LabelPurposeExample ValuesUnique Values
custom_label_0Priority Scorepriority-0 through priority-56
custom_label_1Seasonwinter, spring, summer, fall4
custom_label_2Category Groupt-shirts, jeans-pants, outerwear-heavy, headwear-caps~20
custom_label_3Product Statusnew-arrival, in-stock, low-inventory, clearance, dead-stock5
custom_label_4Brand Tiername-brand, store-brand, off-brand3

This schema stays well within GMC’s limit of 1,000 unique values per label.

The Sync Pipeline

For the MVP and Starter tier, AdPriority uses a Google Sheets supplemental feed as the transport mechanism. This approach requires zero GMC API authentication from the merchant and auto-syncs daily.

SYNC PIPELINE (Google Sheets MVP)
==================================

  AdPriority App
       |
       |  Google Sheets API
       v
  +---------------------------+
  | Google Sheet              |
  |                           |
  | id                        | custom_label_0 | custom_label_1 | ...
  | shopify_US_8779..._4605.. | priority-5     | winter         | ...
  | shopify_US_9128..._4726.. | priority-4     | winter         | ...
  | shopify_US_9057..._4700.. | priority-0     | winter         | ...
  |                           |
  | (one row per variant)     |
  | (~15,000-20,000 rows)     |
  +---------------------------+
       |
       |  GMC auto-fetches daily
       v
  Google Merchant Center
       |
       |  Labels applied to matching products
       v
  Google Ads PMAX Campaigns
       |
       |  Product groups segmented by custom labels
       v
  SMART BUDGET ALLOCATION

For the Pro and Enterprise tiers, AdPriority uses the Content API for Shopping directly, enabling near-real-time updates (within GMC’s 2x/day per-product update limit).


Target Market

Primary Audience

AdPriority is built for Shopify merchants who are already running Google Ads but not getting the most out of their budget. The ideal customer has enough products that manual management is painful but not so many that they need an enterprise feed management platform.

SegmentDescriptionEstimated SizePriority
Shopify + Google Ads PMAXMerchants actively running Performance Max campaigns~200,000 storesP0
Seasonal catalog retailersFashion, sporting goods, outdoor – products with seasonal demand curves~50,000 storesP0
100+ SKU storesLarge enough catalogs that manual management breaks down~100,000 storesP1
Agencies managing multiple storesDigital marketing agencies running Google Ads for Shopify clients~5,000 agenciesP2

Ideal Customer Profile

IDEAL CUSTOMER
==============

  Store Revenue:       $100K - $5M annually
  Product Count:       100 - 50,000 SKUs
  Google Ads Spend:    $1,000 - $50,000/month
  Team Size:           1-10 people
  Pain Point:          "We know we should segment our products in
                        Google Ads but we don't have time to manage it"
  Current Solution:    Manual CSV uploads (quarterly at best) or
                        no custom labels at all
  Decision Maker:      Store owner or marketing manager
  Platform:            Shopify (required)

Why These Customers

ReasonExplanation
Already spending on adsThey have budget allocated; AdPriority helps them spend it better
Seasonal productsThe value proposition is most compelling when priorities change with seasons
Manual pain is realAt 100+ SKUs, updating custom labels manually becomes a significant time investment
PMAX adoptionGoogle is pushing all Shopping advertisers toward Performance Max, expanding the market
Measurable ROIROAS improvement is directly measurable, making the $29-199/mo price easy to justify

Revenue Model

AdPriority follows a SaaS subscription model with four tiers designed to capture merchants at every stage of growth.

Pricing Tiers

PRICING STRUCTURE
=================

  STARTER         GROWTH           PRO              ENTERPRISE
  $29/mo          $79/mo           $199/mo          Custom
  -----------     -----------      -----------      -----------
  500 products    Unlimited        Unlimited        Unlimited
  Manual scores   Seasonal auto    Google Ads API   Multi-store
  GMC sync        Rules engine     AI suggestions   White-label
  Basic labels    Tag modifiers    ROAS tracking    API access
                  New arrival      Performance      Dedicated
                  boost            dashboard        support
  -----------     -----------      -----------      -----------
  Entry point     Sweet spot       Power users      Agencies

Detailed Tier Comparison

FeatureStarter ($29/mo)Growth ($79/mo)Pro ($199/mo)Enterprise (Custom)
Product limit500UnlimitedUnlimitedUnlimited
Manual priority scoringYesYesYesYes
GMC sync (Google Sheets)YesYesYesYes
GMC sync (Content API)YesYes
Category rulesBasicAdvancedAdvancedAdvanced
Seasonal automationYesYesYes
New arrival boostYesYesYes
Tag-based modifiersYesYesYes
Google Ads integrationYesYes
AI recommendationsYesYes
ROAS trackingYesYes
Performance dashboardYesYes
Multi-store managementYes
White-labelYes
API accessYes
Dedicated supportEmailEmail + ChatPriorityDedicated CSM
Free trial14 days14 days14 daysDemo

Revenue Projections

MilestoneTimelineCustomersMRRARR
Beta launchMonth 1-25 (free)$0$0
Paid launchMonth 310$500$6,000
TractionMonth 650$3,000$36,000
GrowthMonth 12150$10,000$120,000
ScaleMonth 18500$30,000$360,000

Assumptions: Average revenue per customer of $60/mo (mix of Starter and Growth tiers), 5% monthly churn, 15% month-over-month growth after launch.


Project Goals

AdPriority development follows a phased approach, starting with a real-world validation using Nexus Clothing (the developer’s own store) before building the SaaS platform.

Goal 1: Validate with Nexus Clothing (Phase 0)

Objective: Prove the concept works with a real store, real products, and real Google Ads campaigns.

  • Use Nexus Clothing as the test case (5,582 products, 90 product types, 175 vendors)
  • Build the supplemental feed pipeline (Google Sheets to GMC)
  • Restructure PMAX campaigns around priority-based product groups
  • Measure ROAS improvement over 30 days
  • Document the process for replication

Why this matters: Building a SaaS product without first validating the approach on a real store risks building something that sounds good in theory but fails in practice. Nexus Clothing provides a complex, representative test case.

Goal 2: Build the SaaS Platform (Phase 1)

Objective: Transform the validated approach into an installable Shopify app.

  • Shopify app with OAuth authentication
  • Database storing priority rules and product mappings per merchant
  • Automated Google Sheets generation via Sheets API
  • Basic Polaris UI for managing priorities
  • Onboard 5 beta testers

Goal 3: Launch on Shopify App Store (Phase 2-3)

Objective: Make AdPriority available to the 200,000+ Shopify merchants running Google Ads.

  • Complete the seasonal automation engine
  • Build the configurable rules engine UI
  • Pass Shopify App Store review
  • Achieve 100+ installs in the first 90 days
  • Maintain less than 5% monthly churn
  • Accumulate positive reviews

Goal 4: Scale to 100+ Merchants (Phase 3+)

Objective: Reach product-market fit and sustainable growth.

  • 100+ paying customers
  • $10,000+ MRR
  • Proven ROAS improvement metrics from customer data
  • Google Ads API integration (Pro tier)
  • AI-powered recommendations based on aggregate performance data

Phase Timeline

DEVELOPMENT TIMELINE
====================

  Phase 0: Nexus MVP                Phase 1: SaaS Foundation
  Week 1-2                          Week 3-4
  +-----------------------------+   +-----------------------------+
  | - Supplemental feed setup   |   | - Shopify app scaffold      |
  | - Category mapping applied  |   | - OAuth authentication      |
  | - PMAX restructured         |   | - Database schema           |
  | - Monitoring & measurement  |   | - Core API endpoints        |
  | - Process documented        |   | - Basic priority UI         |
  +-----------------------------+   +-----------------------------+
          |                                    |
          v                                    v
  Phase 2: Full Product             Phase 3: App Store Launch
  Week 5-8                          Week 9-12
  +-----------------------------+   +-----------------------------+
  | - Seasonal automation       |   | - App Store submission      |
  | - Rules engine UI           |   | - Marketing materials       |
  | - Tag modifiers             |   | - Onboarding flow           |
  | - New arrival boost         |   | - Billing integration       |
  | - Beta testing (5 stores)   |   | - Scale to 100+ merchants   |
  +-----------------------------+   +-----------------------------+

Summary

AdPriority solves a specific, measurable problem: retailers waste ad spend because they cannot efficiently manage which products deserve the most advertising budget. The solution is a purpose-built priority scoring system that automates the assignment of 0-5 scores based on category, season, product status, and brand, then syncs those scores to Google Merchant Center custom labels where Google Ads campaigns can use them for intelligent budget allocation.

The approach is validated first with a real store (Nexus Clothing, 5,582 products), then productized as a Shopify app, and finally scaled through the Shopify App Store. The revenue model targets $29-199/month per merchant, positioning AdPriority below enterprise feed management tools but above basic feed generators, in a market gap where no competitor offers dedicated priority scoring with seasonal automation.

Chapter 2: Product Requirements Document

2.1 Document Overview

Purpose

This Product Requirements Document (PRD) defines the complete functional, non-functional, integration, and user experience requirements for AdPriority. It serves as the authoritative reference for what the product must do, how it must perform, and what constraints it must operate within.

Every feature described in this document traces back to the core problem statement from Chapter 1: retailers waste ad spend because they cannot efficiently manage which products deserve the most advertising budget. Requirements that do not serve this purpose are out of scope.

Scope

This PRD covers AdPriority version 1.0 through version 2.0, spanning four development phases:

PhaseScopeTimeline
Phase 0Nexus Clothing MVP – manual feed pipeline, validated with real storeWeek 1-2
Phase 1SaaS Foundation – Shopify app with OAuth, database, basic priority UI, automated Sheets syncWeek 3-4
Phase 2Full Product – seasonal automation, rules engine UI, new arrival boost, tag modifiersWeek 5-8
Phase 3App Store Launch – billing integration, onboarding flow, App Store submission, scale to 100+ merchantsWeek 9-12

Requirements marked with a phase tag indicate the earliest phase in which they must be implemented.

Audience

ReaderHow to Use This Document
DeveloperImplementation reference for all features, data formats, and API contracts
Product OwnerPrioritization guide with requirement IDs for backlog management
QA EngineerAcceptance criteria for test case design
DesignerUI/UX requirements and component specifications
StakeholderFeature scope validation and timeline alignment

Version History

VersionDateAuthorChanges
1.02026-02-10BlueprintInitial PRD compiled from research documents

Requirement ID Conventions

All requirements use a prefix that indicates their category:

PrefixCategoryExample
FR-Functional RequirementFR-01: Priority Score Definition
NFR-Non-Functional RequirementNFR-01: Page Load Time
IR-Integration RequirementIR-01: Shopify App Installation
UIR-UI/UX RequirementUIR-01: Dashboard Layout
DR-Data RequirementDR-01: Nexus Catalog Baseline

2.2 Product Description

What AdPriority Is

AdPriority is a Shopify embedded application that automates product priority scoring for Google Ads Performance Max (PMAX) campaigns. It assigns a 0-5 priority score to every product in a merchant’s catalog, then syncs those scores as custom labels to Google Merchant Center (GMC). Merchants use these labels to segment PMAX campaigns so that high-priority products receive more ad budget and low-priority products receive less or none.

Core Value Proposition

BEFORE AdPriority                    AFTER AdPriority
================                     ================

All products treated equally         Products scored 0-5 automatically
  |                                    |
  v                                    v
Budget spread evenly                 Budget allocated by priority
  |                                    |
  v                                    v
Shorts get ads in winter             Shorts excluded in winter (score 0)
Dead stock gets impressions          Dead stock excluded always (score 0)
New arrivals buried                  New arrivals boosted (score 5)
  |                                    |
  v                                    v
Poor ROAS                            15-30% ROAS improvement
Hours of manual label management     Automated, zero-maintenance

Product Boundaries

What AdPriority IS:

  • A priority scoring system with a 0-5 scale
  • A rules engine that calculates scores from product attributes, categories, seasons, and tags
  • A sync pipeline that writes scores as custom labels to Google Merchant Center
  • A seasonal automation engine that adjusts priorities on season boundaries
  • A Shopify embedded app built with Polaris components

What AdPriority IS NOT:

  • Not a bid management tool (it does not set bids or budgets in Google Ads)
  • Not a feed management platform (it only writes custom labels, not full product feeds)
  • Not an analytics dashboard (Pro tier adds read-only performance data, but it is not a reporting tool)
  • Not a campaign builder (merchants still structure their PMAX campaigns manually)
  • Not a multi-channel tool (v1.0 targets Google Ads only, not Facebook or Bing)

2.3 Functional Requirements

2.3.1 Priority Scoring System

FR-01: Priority Score Definition

Phase: 0 | Priority: P0

Every product receives a single integer score from 0 to 5 that directly controls how aggressively it is advertised in Google Ads.

ScoreNameBudget BehaviorTypical Use CasesGMC Label Value
5Push HardMaximum budget, aggressive biddingSeasonal bestsellers (shorts in summer, jackets in winter), hot new arrivals in first 14 days, proven high-margin itemspriority-5
4StrongHigh budget, balanced approachCore performers with solid sales history, products with seasonal relevance, name brand items with strong recognitionpriority-4
3NormalStandard budget, conservative biddingYear-round staples (jeans, fitted caps), average performers, baseline catalog itemspriority-3
2LowMinimal budget, strict ROAS targetsOff-season items still in stock, low-margin products, overstocked items, underwear and basics, accessoriespriority-2
1MinimalVery low budget, highest ROAS threshold onlyEnd-of-season clearance, slow movers approaching dead stock, items with sale tagspriority-1
0ExcludeZero ad spendArchived products, dead stock (DEAD50 tagged), out-of-stock variants, gift cards, non-shoppable items (insurance, shipping protection)priority-0

Constraints:

  • Score must be an integer between 0 and 5 inclusive
  • Every active product must have a score (no null scores)
  • Default score for unclassified products is 2 (Low)
  • Score is stored at the product level; variants inherit the product score unless individually overridden (future feature)

Acceptance Criteria:

  • Every product in the database has a priority_score column with a CHECK (priority_score BETWEEN 0 AND 5) constraint
  • Products without matching rules receive default score of 2
  • Score values map to GMC label values using the format priority-{score}

FR-02: Score Assignment Methods

Phase: 0 (manual), 1 (bulk, rules), 2 (seasonal, performance) | Priority: P0

AdPriority supports five methods of assigning priority scores, each appropriate for different use cases:

MethodDescriptionPhaseUser Action
ManualMerchant sets score for an individual product via the UIPhase 1Click product, select score from dropdown
BulkMerchant selects multiple products and assigns the same scorePhase 1Multi-select products, choose score, apply
RulesCategory-based automatic assignment (product type, collections, tags)Phase 1Configure rules in rules engine, scores auto-calculate
SeasonalCalendar-driven automatic adjustment based on season x category matrixPhase 2Configure seasonal calendar, scores auto-adjust on season boundaries
PerformanceROAS-based recommendations suggesting score changes (Pro tier)Phase 3Review recommendations, accept or dismiss

Acceptance Criteria:

  • Manual scoring updates a single product and marks it as priority_source = 'manual'
  • Bulk scoring updates up to 1,000 products in a single operation
  • Rules-based scoring recalculates on product import and webhook events
  • Seasonal scoring triggers automatically on season transition dates
  • Performance recommendations are read-only suggestions, not automatic changes

FR-03: Score Override Hierarchy

Phase: 1 | Priority: P0

When multiple scoring methods apply to the same product, the highest-priority method wins. The hierarchy is evaluated top-to-bottom; the first matching rule determines the score.

SCORE OVERRIDE HIERARCHY (highest priority wins)
=================================================

  1. Manual override          -- Merchant locks a specific score
     |                           (priority_locked = true)
     v
  2. Exclusion tags           -- "archived" or "DEAD50" force score to 0
     |                           (hard override, cannot be raised)
     v
  3. Inventory warnings       -- "warning_inv_1" reduces score by 1
     |                           (applied as modifier, not override)
     v
  4. New arrival boost        -- Products created within 14 days get score 5
     |                           (configurable duration and target score)
     v
  5. Tag modifiers            -- "in-stock" adds +1, "NAME BRAND" adds +1,
     |                           "Sale" subtracts -1
     v
  6. Seasonal calendar        -- Category-specific seasonal adjustment
     |                           (from the season x category matrix)
     v
  7. Category default         -- Base score from product type group mapping
     |
     v
  8. Global default           -- Score 2 if no rules match

Modifier Stacking Rules:

  • Tag modifiers stack additively (e.g., in-stock +1 and NAME BRAND +1 = +2 total)
  • Final score is clamped to the 0-5 range: Math.min(5, Math.max(0, score))
  • Override tags (archived, DEAD50) short-circuit all other rules and return 0 immediately
  • Manual overrides are respected until the merchant unlocks the product

Real-World Example with Nexus Clothing Data:

Product: "Jordan Craig Stacked Jeans" (active, Winter season)
  Step 1: Manual override?  No (priority_locked = false)
  Step 2: Exclusion tags?   No
  Step 3: Inventory warning? No
  Step 4: New arrival?      No (created > 14 days ago)
  Step 5: Tag modifiers:    NAME BRAND = +1 (store later)
  Step 6: Seasonal calendar: Jeans & Pants, Winter = 4
  Step 7: Category default:  Jeans & Pants = 4 (not needed, seasonal applied)
  Apply modifiers: 4 + 1 (NAME BRAND) = 5
  Final priority: 5 (PUSH HARD)

Product: "Generic Mesh Shorts" (active, Winter season)
  Step 1: Manual override?  No
  Step 2: Exclusion tags?   No
  Step 3: Inventory warning? No
  Step 4: New arrival?      No
  Step 5: Tag modifiers:    None relevant
  Step 6: Seasonal calendar: Shorts, Winter = 0
  Final priority: 0 (EXCLUDE)

Product: "Old Season Hoodie" (archived, DEAD50 tag)
  Step 1: Manual override?  No
  Step 2: Exclusion tags?   Yes -- "archived" found
  Final priority: 0 (EXCLUDE) -- short-circuit, no further processing

Acceptance Criteria:

  • Override hierarchy is enforced in the exact order specified
  • Manual overrides persist until explicitly unlocked by merchant
  • Exclusion tags short-circuit all subsequent rules
  • Tag modifiers stack additively and result is clamped to 0-5
  • priority_source column records which method determined the final score

2.3.2 Category Mapping

FR-10: Category Rules

Phase: 1 | Priority: P0

The category mapping system translates Shopify product types into priority scores. Because product type names can be long and hierarchical (Nexus uses a {Gender}-{Department}-{SubCategory}-{Detail} convention with 90 unique types), AdPriority groups them into manageable category groups.

Rule Capabilities:

CapabilityDescriptionExample
Product type matchExact or partial match on product_type fieldproduct_type = "Men-Tops-T-Shirts"
Product type containsSubstring match for hierarchical typesproduct_type CONTAINS "Shorts"
Collection membershipProduct belongs to a specific collectioncollection = "New Arrivals"
Tag-based rulesProduct has a specific tagtag = "clearance"
AND logicMultiple conditions must all be trueproduct_type = "Shorts" AND season = "Summer"
OR logicAny condition can be truetag = "archived" OR tag = "DEAD50"
Temporal conditionsTime-based rules with expirycollection = "New Arrivals" FOR 14 days

Category Group Structure:

AdPriority organizes 90+ Nexus product types into 20 manageable groups. Each group maps multiple product types to a single set of priority rules.

GroupProduct Types IncludedProduct CountDefault Priority
T-ShirtsMen-Tops-T-Shirts, Boys-Tops-T-Shirts, Men-Tops-Crop-Top, Men-Tops-Tank Tops1,3633
Long Sleeve TopsMen-Tops-T-Shirts-Long Sleeve543
Jeans & PantsMen-Bottoms-Pants-Jeans, Men-Bottoms-Stacked Jeans, Boys-Bottoms-Jeans, Men-Bottoms-Pants-Track Pants, Men-Bottoms-Pants-Cargo, Men-Bottoms-Stacked Track Pants9114
SweatpantsMen-Bottoms-Pants-Sweatpants, Men-Bottoms-Stacked Sweatpants943
ShortsAll *-Shorts-* types (8 types)3153
Swim ShortsMen-Bottoms-Shorts-Swim-Shorts402
Hoodies & SweatshirtsMen-Tops-Hoodies & Sweatshirts, Boys-Tops-Hoodies2643
Outerwear - HeavyPuffer Jackets, Shearling Coats723
Outerwear - MediumDenim Jackets, Varsity Jackets, Fleece, Sports, Vests2323
Outerwear - LightTrack Jackets, Windbreakers393
Headwear - CapsFitted, Dad Hat, Snapback, Low Profile7773
Headwear - Cold WeatherKnit Beanies, Balaclavas1712
Headwear - SummerBucket Hats512
JoggersMen-Bottoms-Joggers, Boys-Bottoms-Joggers863
Footwear - SandalsSandals & Slides582
Footwear - ShoesMen/Women Shoes483
Underwear & SocksMen/Women Underwear, Socks, Boys Underwear5232
AccessoriesBags, Jewelry, Belts, Wallet Chains3502
Women - ApparelWomen-Tops, Leggings, Dresses, Jumpsuits, Sets802
ExcludeBath & Body, Household Supplies, Gift Cards, Insurance, Shipping Protection~500

Acceptance Criteria:

  • Merchant can create category groups that map multiple product types to a single priority
  • Rules support AND/OR logic for combining conditions
  • Unmapped product types receive the global default score (2)
  • Category groups are stored per-store (multi-tenant safe)
  • Changing a category rule triggers recalculation for all affected products

FR-11: Rule Examples with Nexus Data

Phase: 1 | Priority: P1

The rules engine must support the following patterns, all validated against real Nexus Clothing data:

# Seasonal category rules
IF product_type CONTAINS "Shorts" AND season = "Summer"
  THEN priority = 5

IF product_type CONTAINS "Shorts" AND season = "Winter"
  THEN priority = 0

IF product_type CONTAINS "Hoodies" AND season = "Winter"
  THEN priority = 5

IF product_type CONTAINS "Puffer" AND season = "Summer"
  THEN priority = 0

# Collection-based rules
IF collection = "New Arrivals"
  THEN priority = 5 FOR 14 days

# Tag-based rules (overrides)
IF tag = "archived" OR tag = "DEAD50"
  THEN priority = 0 (override)

IF tag = "clearance"
  THEN priority = 1

# Tag-based rules (modifiers)
IF tag = "NAME BRAND"
  THEN priority + 1

IF tag = "in-stock"
  THEN priority + 1

IF tag = "Sale"
  THEN priority - 1

IF tag = "warning_inv_1"
  THEN priority - 1

# Vendor-based rules (future)
IF vendor = "Nexus Clothing"
  THEN priority + 1 (store brand boost)

Acceptance Criteria:

  • All rule patterns above can be expressed in the rules engine
  • Rules can be tested with a “preview” mode that shows affected products without applying changes
  • Rules are evaluated in priority order (see FR-03 hierarchy)

2.3.3 Product Mapping

FR-20: Product Identification

Phase: 0 | Priority: P0

AdPriority must correctly map Shopify product and variant identifiers to Google Merchant Center product IDs. The mapping is the foundation of the supplemental feed pipeline – an incorrect ID means the custom label will not be applied.

GMC Product ID Format (verified from live GMC export, 124,060 products):

Format:    shopify_US_{productId}_{variantId}
Example:   shopify_US_8779355160808_46050142748904

Components:
  shopify    -- Constant prefix (identifies source platform)
  US         -- Country code (ISO 3166-1 alpha-2)
  8779355160808      -- Shopify product ID (13 digits)
  46050142748904     -- Shopify variant ID (14 digits)

Item Group ID (parent product, used for variant grouping in GMC):

Format:    {productId}
Example:   8779355160808

Verified Real Examples from Nexus GMC Export:

Shopify Product IDShopify Variant IDGMC Product ID
877935516080846050142748904shopify_US_8779355160808_46050142748904
912899457047247260097118440shopify_US_9128994570472_47260097118440
905736706480847004004712680shopify_US_9057367064808_47004004712680
923879741872847750439567592shopify_US_9238797418728_47750439567592
760955171658442582395650280shopify_US_7609551716584_42582395650280

Storage Strategy: Hybrid approach (recommended)

LayerRoleRationale
PostgreSQL databasePrimary source of truth for priority scores and sync stateFull query capability, fast bulk operations, no API rate limits
Shopify metafieldsBackup and recovery mechanismPersists with product, survives database issues

Metafield Schema:

{
  "namespace": "adpriority",
  "key": "config",
  "type": "json",
  "value": {
    "priority": 4,
    "priority_source": "seasonal",
    "priority_locked": false,
    "last_synced_at": "2026-02-10T10:00:00Z",
    "custom_labels": {
      "label_0": "priority-4",
      "label_1": "winter",
      "label_2": "jeans-pants",
      "label_3": "in-stock",
      "label_4": "name-brand"
    }
  }
}

Acceptance Criteria:

  • GMC product IDs are generated using the format shopify_US_{productId}_{variantId}
  • All variant-level IDs are stored (GMC uses variant-level IDs exclusively)
  • Product mapping table has a computed gmc_product_id column
  • Mapping handles product ID changes gracefully (e.g., product delete + recreate)

FR-21: Custom Label Structure

Phase: 0 | Priority: P0

AdPriority uses all five Google Merchant Center custom label slots to provide rich segmentation data for Google Ads campaigns. The label schema was designed to stay well within GMC’s limits while providing maximum campaign segmentation flexibility.

LabelPurposeExample ValuesUnique ValuesGMC Limit
custom_label_0Priority Scorepriority-0 through priority-561,000
custom_label_1Seasonwinter, spring, summer, fall41,000
custom_label_2Category Groupt-shirts, jeans-pants, outerwear-heavy, headwear-caps~201,000
custom_label_3Product Statusnew-arrival, in-stock, low-inventory, clearance, dead-stock51,000
custom_label_4Brand Tiername-brand, store-brand, off-brand31,000

Total unique values across all labels: ~38 (well within the 5,000 total limit)

Status Determination Logic:

StatusConditionPriority Order
dead-stockTag archived or DEAD50 present, or product status is archived1 (highest)
low-inventoryTag warning_inv_1 or warning_inv present2
new-arrivalProduct created within last 30 days3
clearanceTag Sale present4
in-stockDefault for active products with no special tags5 (lowest)

Brand Tier Determination Logic:

TierCondition
store-brandVendor is “Nexus Clothing”
name-brandTag NAME BRAND present, or vendor in recognized brand list (New Era, Jordan Craig, Psycho Bunny, LACOSTE, Gstar Raw, Ed Hardy, etc.)
off-brandDefault for all other products

Acceptance Criteria:

  • All five custom labels are populated for every active variant in the supplemental feed
  • Label values use lowercase with hyphens (e.g., jeans-pants, not Jeans & Pants)
  • Label values are within the 100-character maximum per value
  • Total unique values per label do not exceed 1,000

2.3.4 Seasonal Calendar

FR-30: Season Definitions

Phase: 2 | Priority: P1

AdPriority uses a four-season calendar with configurable start/end dates. The default configuration follows the Northern Hemisphere retail calendar.

SeasonDefault StartDefault EndKey Retail Events
WinterDecember 1February 28/29Holidays, New Year, Valentine’s Day
SpringMarch 1May 31Spring Break, Easter, Memorial Day
SummerJune 1August 31Vacation, July 4th, Back-to-school prep
FallSeptember 1November 30Back to School, Thanksgiving, Black Friday, Cyber Monday
ANNUAL SEASON TIMELINE
=======================

JAN | FEB | MAR | APR | MAY | JUN | JUL | AUG | SEP | OCT | NOV | DEC
|___WINTER____|______SPRING______|______SUMMER______|______FALL______|WIN
       ^              ^                  ^                 ^
     Feb 28         May 31             Aug 31           Nov 30

Configuration Options:

SettingDefaultConfigurableNotes
HemisphereNorthernYesInverts season dates for Southern Hemisphere
TimezoneAmerica/New_YorkYesUsed for season boundary calculations
Season datesFixed month boundariesYes (Growth+ tier)Merchants can customize per season
Grace periodNoneYes (Growth+ tier)15-day grace period around transitions

Acceptance Criteria:

  • Season detection correctly identifies the current season based on date and timezone
  • Season transitions trigger automatic priority recalculation
  • Merchants can preview the impact of an upcoming season change before it takes effect
  • Manual season override allows merchants to trigger early or late transitions

FR-31: Seasonal Rules

Phase: 2 | Priority: P1

Each category group has a configurable priority score per season. This forms the Category x Season matrix, which is the core of the seasonal automation engine.

Full Seasonal Priority Matrix (Nexus Clothing default configuration):

Category GroupWinterSpringSummerFallDefault
T-Shirts24533
Long Sleeve Tops43143
Jeans & Pants44354
Sweatpants43143
Shorts03513
Swim Shorts02502
Hoodies & Sweatshirts53153
Outerwear - Heavy51043
Outerwear - Medium43043
Outerwear - Light24133
Headwear - Caps33333
Headwear - Cold Weather51032
Headwear - Summer03422
Joggers43243
Footwear - Sandals02502
Footwear - Shoes33333
Underwear & Socks22222
Accessories22222
Women - Apparel23322
Exclude00000

Holiday Modifiers:

In addition to the seasonal matrix, specific shopping events can temporarily boost priorities:

EventDatesModifierAffected Categories
Valentine’s DayFeb 10-14+1Accessories, Jewelry
EasterVariable+1Spring items
Memorial DayMay (last Monday)+1Summer items
July 4thJul 1-4+1Casual wear
Back to SchoolAug 1 - Sep 15+2Jeans, T-Shirts, Hoodies
Labor DaySep (first Monday)+1All categories
Black FridayNov (last Friday)+2All categories
Cyber MondayMonday after Black Friday+2All categories
ChristmasDec 1-25+2All categories

Holiday Modifier Rules:

  • Holiday modifiers are additive on top of the seasonal base score
  • Final score is clamped to 0-5 after applying holiday modifiers
  • Holiday modifiers do not override manual locks or exclusion tags
  • Merchants can enable/disable individual holidays
  • Custom holiday events can be created (Growth+ tier)

Acceptance Criteria:

  • Seasonal priorities auto-apply on season transition dates
  • Category x Season matrix is editable per merchant
  • Holiday modifiers apply and expire on the configured dates
  • Preview shows exact products and scores that will change on next transition
  • Manual season trigger overrides the date-based automatic transition

2.3.5 New Arrival Automation

FR-40: New Arrival Boost

Phase: 2 | Priority: P1

New products added to the store automatically receive a high priority score for a configurable period, ensuring they get advertising visibility during their launch window.

Configuration Parameters:

ParameterDefaultRangeDescription
enabledtrueBooleanEnable or disable the new arrival boost
days_threshold141-90Number of days a product is considered “new”
boost_priority51-5Priority score during the boost period
decay_enabledfalseBooleanGradually reduce priority over time
decay_schedule[5, 4, 3]Array of scoresScores at each decay interval
decay_interval_days71-30Days between each decay step

Behavior:

WITHOUT DECAY (default):
  Day 0-14:   Priority 5 (Push Hard)
  Day 15+:    Falls back to category/seasonal score

WITH DECAY ENABLED:
  Day 0-7:    Priority 5 (Push Hard)
  Day 8-14:   Priority 4 (Strong)
  Day 15-21:  Priority 3 (Normal)
  Day 22+:    Falls back to category/seasonal score

Interaction with Override Hierarchy:

  • New arrival boost sits at position 4 in the hierarchy (see FR-03)
  • Manual overrides take precedence over new arrival boost
  • Exclusion tags (archived, DEAD50) take precedence over new arrival boost
  • Inventory warnings take precedence over new arrival boost
  • New arrival boost takes precedence over tag modifiers, seasonal rules, and category defaults

Detection Method:

  • Shopify created_at timestamp compared against current date
  • Products detected via products/create webhook for real-time processing
  • Daily reconciliation job catches any missed webhook events

Acceptance Criteria:

  • Products created within days_threshold days automatically receive boost_priority score
  • Boost expires silently after the threshold period, reverting to standard scoring
  • Decay schedule (when enabled) reduces score on the configured interval
  • Configuration is per-store, not global
  • Manual overrides are not affected by new arrival boost

2.3.6 Google Merchant Center Sync

FR-50: Sync Methods

Phase: 0 (Sheets), 2 (Content API), 1 (CSV) | Priority: P0

AdPriority supports three methods of syncing custom labels to Google Merchant Center, available at different subscription tiers:

MethodTierLatencyMerchant SetupTechnical Complexity
Google Sheets supplemental feedAll tiersDaily (GMC fetches automatically)One-time: share Sheet URL, add as supplemental feed in GMCLow – uses Sheets API only
Content API for ShoppingPro, EnterpriseNear-real-time (within 2x/day limit)One-time: connect Google account via OAuthMedium – requires GMC OAuth
Manual CSV exportAll tiers (fallback)Manual upload by merchantDownload CSV, upload to GMCNone – offline process

Google Sheets Pipeline (MVP and primary method):

AdPriority Database
       |
       |  Google Sheets API (googleapis/sheets/v4)
       v
Google Sheet (shared publicly, Viewer access)
  +----------------------------------------------------------------+
  | id                                    | custom_label_0 | ...    |
  | shopify_US_8779355160808_46050142748904 | priority-5   | ...    |
  | shopify_US_9128994570472_47260097118440 | priority-4   | ...    |
  +----------------------------------------------------------------+
       |
       |  GMC auto-fetches daily (or on manual trigger)
       v
Google Merchant Center
       |
       |  Labels applied to matching products
       v
Google Ads PMAX Campaigns

Feed Size Estimates for Nexus:

MetricValue
Active products in Shopify2,425
Estimated active variants in GMC~15,000-20,000
Rows in supplemental feed~15,000-20,000
Columns per row6 (id + 5 custom labels)
Total cells~120,000
Google Sheets cell limit10,000,000
Usage percentage~1.2%

Acceptance Criteria:

  • Google Sheets supplemental feed is generated with correct column names (id, custom_label_0 through custom_label_4)
  • Product IDs in the Sheet exactly match GMC primary feed IDs (case-sensitive)
  • Sheet is shared with “Anyone with the link” at Viewer access
  • CSV export produces a downloadable file with the same structure
  • Content API integration (Pro tier) uses products.custombatch for efficiency

FR-51: Sync Schedule

Phase: 1 | Priority: P0

TriggerBehaviorLatency
Priority changeMark product as needs_sync = true in databaseImmediate (database)
Scheduled bulk syncWrite all pending changes to Google SheetConfigurable: hourly or daily
Manual triggerMerchant clicks “Sync Now” buttonImmediate write to Sheet
Season transitionBulk recalculate and sync all affected productsWithin 1 hour of transition
Product webhookRecalculate single product, queue for next syncNext scheduled sync

Sync Status Dashboard:

The UI must display:

  • Last sync timestamp
  • Number of products pending sync
  • Number of products successfully synced
  • Number of sync errors
  • “Sync Now” button for manual trigger

Acceptance Criteria:

  • Scheduled sync runs at merchant-configured intervals (minimum: daily)
  • Manual sync trigger writes to Sheet within 60 seconds
  • Sync status is visible on the dashboard
  • Products with needs_sync = true are included in the next scheduled sync
  • After successful sync, needs_sync is set to false and last_synced_at is updated

FR-52: Error Handling

Phase: 1 | Priority: P1

Error ScenarioDetectionResolutionMerchant Impact
Google Sheets API quota exceededAPI returns 429Exponential backoff retry (3 attempts, 1s/2s/4s delay)Sync delayed, not lost
Sheet permission revokedAPI returns 403Alert merchant, provide re-authorization linkSync paused until resolved
GMC feed processing errorGMC dashboard shows errorsLog error, alert merchant, provide troubleshooting linkLabels not applied for affected products
Product ID mismatchReconciliation job detects unmatched productsLog warning, exclude from feedAffected products have no labels
Network timeoutRequest times out after 30 secondsRetry with backoffSync delayed, not lost

Retry Configuration:

Max retries:          3
Initial delay:        1,000 ms
Backoff multiplier:   2
Maximum delay:        30,000 ms

Acceptance Criteria:

  • All sync errors are logged with timestamp, error type, and affected product IDs
  • Retry logic uses exponential backoff for transient failures
  • Persistent failures (3+ consecutive) trigger a merchant notification
  • Manual CSV export is always available as a fallback when automated sync fails
  • Error count is displayed on the sync status dashboard

2.3.7 Google Ads Integration (Pro Tier)

FR-60: Performance Data

Phase: 3 | Priority: P2

Pro tier merchants can connect their Google Ads account to view performance metrics segmented by priority tier.

Data PointSourceUpdate Frequency
Impressions per priority tierShopping Performance ViewDaily
Clicks per priority tierShopping Performance ViewDaily
Cost per priority tierShopping Performance ViewDaily
Conversions per priority tierShopping Performance ViewDaily
ROAS per priority tierCalculated (conversion value / cost)Daily
Spend by category groupShopping Performance View + category mappingDaily
Trend data (30/60/90 day)Historical aggregationDaily

GAQL Query Example:

SELECT
  segments.product_custom_attribute0,  -- priority score label
  metrics.impressions,
  metrics.clicks,
  metrics.cost_micros,
  metrics.conversions,
  metrics.conversions_value
FROM shopping_performance_view
WHERE segments.date DURING LAST_30_DAYS

Acceptance Criteria:

  • Performance data is fetched daily and stored in the database
  • Dashboard displays ROAS, spend, and conversion data grouped by priority tier
  • Historical trend charts show 30, 60, and 90-day windows
  • Data is read-only (AdPriority does not modify Google Ads settings)

FR-61: Recommendations

Phase: 3 | Priority: P3

Based on performance data, AdPriority generates actionable recommendations for priority score adjustments.

Recommendation TypeTriggerSuggested Action
Increase priorityProduct has ROAS > 2x average for its tier“Move from priority 3 to priority 4”
Decrease priorityProduct has ROAS < 0.5x average for its tier“Move from priority 4 to priority 2”
Budget waste alertHigh spend, zero conversions for 14+ days“Consider excluding (priority 0)”
Seasonal predictionHistorical data shows seasonal demand pattern“Shorts trending up – consider early Summer transition”

Acceptance Criteria:

  • Recommendations are displayed as dismissible cards in the dashboard
  • Each recommendation shows the current score, suggested score, and supporting data
  • Merchant can accept (applies change) or dismiss (hides recommendation)
  • Dismissed recommendations do not reappear for the same product within 30 days

2.4 Non-Functional Requirements

2.4.1 Performance Requirements

IDRequirementTargetMeasurement Method
NFR-01Page load time< 2 secondsLighthouse performance score
NFR-02Sync latency (Sheet write)< 5 minutes for full catalogTimed Sheets API operation
NFR-03Bulk operations1,000 products per minuteTimed batch update
NFR-04API response time< 500ms (95th percentile)Server-side APM logging
NFR-05Score calculation5,000 products in < 30 secondsTimed batch calculation
NFR-06Product import speed1,000 products per minuteTimed Shopify API pagination
NFR-07Sheet generation20,000 rows in < 2 minutesTimed Sheets API write
NFR-08Webhook processing< 5 seconds per eventEvent timestamp to completion

2.4.2 Scalability Requirements

TierMax ProductsMax Variants (est.)Max Syncs/DayMax UsersMax Rules
Starter ($29/mo)500~2,00024210
Growth ($79/mo)10,000~40,00096550
Pro ($199/mo)100,000~400,00028810Unlimited
Enterprise (Custom)UnlimitedUnlimitedUnlimitedUnlimitedUnlimited

Infrastructure Scaling Approach:

Load LevelStrategy
< 50 merchantsSingle PostgreSQL database, single app server, Google Sheets sync
50-500 merchantsConnection pooling, Redis caching, background job queue (Bull)
500+ merchantsRead replicas, horizontal app scaling, Content API for high-volume stores

2.4.3 Security Requirements

IDRequirementImplementationPhase
NFR-10OAuth 2.0 authenticationShopify token exchange flow + Google OAuthPhase 1
NFR-11Session token validationJWT verification with client secret, audience, expiryPhase 1
NFR-12Webhook HMAC verificationSHA-256 HMAC validation on all webhook payloadsPhase 1
NFR-13Encryption at restAES-256 for stored OAuth tokensPhase 1
NFR-14Encryption in transitHTTPS/TLS everywhere (no mixed content)Phase 1
NFR-15GDPR complianceThree mandatory webhooks, no customer PII storedPhase 1
NFR-16CCPA complianceData export and deletion on requestPhase 1
NFR-17Shop-scoped data isolationEvery database query includes store_id filterPhase 1
NFR-18No customer PII storedAdPriority stores only product data, never customer dataPhase 0
NFR-19Minimal scope requestsOnly read_products, write_products, read_inventoryPhase 1

2.4.4 Availability Requirements

IDRequirementTargetPhase
NFR-20Uptime SLA99.9% (< 8.77 hours downtime/year)Phase 3
NFR-21Graceful degradationManual CSV export always available if automated sync failsPhase 1
NFR-22Automated backupsDaily database backups with 30-day retentionPhase 1
NFR-23Disaster recoveryRestore from backup within 4 hoursPhase 2
NFR-24Zero data lossAll priority scores and rules recoverable after any failurePhase 1

2.5 Integration Requirements

2.5.1 Shopify Integration

IR-01: App Installation

Phase: 1 | Priority: P0

RequirementSpecification
Authentication flowOAuth 2.0 with token exchange (modern flow, no cookies)
App typeEmbedded (mandatory for new apps as of late 2024)
App Bridge versionv4+ (latest)
UI frameworkShopify Polaris v13.9+
Session managementSession tokens (JWTs signed with client secret)

Session Token Claims:

ClaimDescriptionExample
issShop admin URLhttps://nexus-clothes.myshopify.com/admin
destShop URLhttps://nexus-clothes.myshopify.com
audClient IDAdPriority app client ID
subUser IDShopify staff member ID
expExpirationUnix timestamp (1-minute validity)
jtiToken IDUnique identifier
sidSession IDSession identifier

IR-02: Data Access

Phase: 1 | Priority: P0

Required API Scopes:

ScopePurposeJustification (for App Store review)
read_productsFetch product data (titles, types, tags, collections, variants)“AdPriority reads product information to apply priority scoring rules and generate Google Merchant Center custom labels.”
write_productsStore priority scores in product metafields“AdPriority stores priority scores and sync status in product metafields for persistent data across sessions.”
read_inventoryCheck stock levels for inventory-based rules“AdPriority uses inventory levels to adjust priority scores (lower priority for out-of-stock items).”

Data Retrieved from Shopify:

Data TypeAPI EndpointFields Used
ProductsGET /admin/api/2024-01/products.jsonid, title, product_type, vendor, tags, status, created_at, handle
VariantsIncluded in products responseid, sku, inventory_quantity, price
CollectionsGET /admin/api/2024-01/collections.jsonid, title, products
MetafieldsGET /admin/api/2024-01/products/{id}/metafields.jsonnamespace=adpriority

IR-03: Webhooks

Phase: 1 | Priority: P0

Mandatory GDPR Webhooks (required for App Store approval – app will be rejected without these):

WebhookEndpointBehavior
customers/data_requestPOST /api/webhooks/customers-data-requestReturn 200 OK with note “No customer PII stored”
customers/redactPOST /api/webhooks/customers-redactReturn 200 OK (no action needed, no customer data stored)
shop/redactPOST /api/webhooks/shop-redactDelete all store data (rules, products, sync logs, settings)

App Lifecycle Webhooks:

WebhookEndpointBehaviorPriority
app/uninstalledPOST /api/webhooks/app-uninstalledDelete all store data, cancel billing subscriptionP0

Product Webhooks:

WebhookEndpointBehaviorPriority
products/createPOST /api/webhooks/products-createApply rules to new product, calculate priority, queue for syncP1
products/updatePOST /api/webhooks/products-updateCheck for type/tag changes, recalculate priority if changedP2
products/deletePOST /api/webhooks/products-deleteRemove from product mapping, remove from supplemental feedP2

Webhook Security:

All webhooks must verify the X-Shopify-Hmac-Sha256 header using HMAC-SHA256 with the app’s client secret. Unverified webhooks must be rejected with 401 Unauthorized.

2.5.2 Google Merchant Center

IR-10: Authentication

Phase: 0 (Sheets only, no auth), 2 (Content API) | Priority: P0 (Sheets), P2 (API)

MethodTierOAuth ScopeSetup
Google Sheets APIAllhttps://www.googleapis.com/auth/spreadsheetsApp creates and manages Sheet programmatically
Content API for ShoppingPro, Enterprisehttps://www.googleapis.com/auth/contentMerchant connects Google account via OAuth
Service accountsEnterpriseN/A (service account key)Generated in Google Cloud Console

IR-11: API Operations

Phase: 2 (Content API) | Priority: P2

OperationAPI EndpointPurpose
List productsGET /content/v2.1/{merchantId}/productsVerify product existence in GMC
Update labelsPATCH /content/v2.1/{merchantId}/products/{productId}Set custom labels via Content API
Batch updatePOST /content/v2.1/products/batchUpdate multiple products in one request
Product statusGET /content/v2.1/{merchantId}/productstatusesCheck for feed processing errors

Rate Limits:

LimitValueImpact
Product updates per day2x per productDaily sync schedule is within limits
Requests per minuteDynamic (throttling-based)Implement exponential backoff
Batch sizeUp to 10,000 entries per batch requestSufficient for most stores

2.5.3 Google Ads API (Pro Tier)

IR-20: Authentication

Phase: 3 | Priority: P2

RequirementSpecification
OAuth scopehttps://www.googleapis.com/auth/adwords
Developer tokenRequired (apply through Google Ads API Center)
Manager accountsSupported for agency users (Enterprise tier)
Customer IDMerchant provides their Google Ads customer ID

IR-21: API Operations

Phase: 3 | Priority: P2

OperationPurposeFrequency
Shopping Performance ViewProduct-level metrics (impressions, clicks, cost, conversions)Daily fetch
Campaign dataCampaign-level performanceDaily fetch
Asset group metricsPMAX asset group performanceDaily fetch
Listing group filtersVerify custom label-based product groupingOn demand

2.6 UI/UX Requirements

UIR-01: Dashboard

Phase: 1 | Priority: P0

The dashboard is the landing page of the app, providing an at-a-glance view of the store’s priority scoring status.

ComponentContentPolaris Component
Priority distribution chartBar or donut chart showing product count per score (0-5)Custom chart (Chart.js) in Card
Sync status cardLast sync time, pending count, error count, “Sync Now” buttonCard with Badge indicators
Upcoming seasonal changesNext season transition date, affected product count, preview linkCard with Banner (informational)
Quick statsTotal products scored, active rules, last sync, current seasonLayout with Card grid
Recent activityTimeline of recent priority changes, syncs, rule changesCard with list

UIR-02: Product Management

Phase: 1 | Priority: P0

ComponentBehaviorPolaris Component
Product listPaginated table (50-100 per page) showing product title, type, current priority, sync statusIndexTable or DataTable
SearchFilter by product title, SKU, or vendorTextField with search icon
FiltersFilter by priority score, category group, season, sync status, product statusFilters with ChoiceList
Inline priority editClick on priority badge to change score via dropdownSelect in IndexTable cell
Bulk selectCheckbox selection for multi-product actionsIndexTable built-in selection
Bulk editApply priority score to all selected productsBulkActions toolbar
CSV exportDownload current filtered view as CSVButton action
Priority badgeColor-coded badge showing score (red=0, orange=1-2, yellow=3, green=4-5)Badge with tone variants

UIR-03: Rules Engine

Phase: 2 | Priority: P1

ComponentBehaviorPolaris Component
Rule listOrdered list of all active rules with name, type, affected countIndexTable
Visual rule builderIF-THEN interface for creating rules without codeCard with Select, TextField
Rule testing previewShows which products would be affected before applyingModal with DataTable
Rule priority orderingDrag-and-drop to reorder rulesCustom drag handle or up/down buttons
Enable/disable toggleToggle individual rules on/off without deletingToggle switch
Rule templatesPre-built rules for common patterns (seasonal, new arrival, clearance)Card with Button actions

UIR-04: Seasonal Calendar

Phase: 2 | Priority: P1

ComponentBehaviorPolaris Component
Visual calendarTimeline view showing season boundaries across 12 monthsCustom component
Season configurationEdit start/end dates per seasonDatePicker or TextField
Category x Season matrixEditable grid showing priority per category per seasonDataTable with inline Select
Preview future changesShow products affected by next transitionButton to Modal with preview
Current season indicatorHighlight current season with countdown to next transitionBanner (informational)
Manual triggerButton to manually switch to a different seasonButton (destructive styling)

UIR-05: Settings

Phase: 1 | Priority: P0

ComponentBehaviorPolaris Component
Google account connectionOAuth flow to connect Google account (Sheets, GMC, Ads)Card with Button
Sync preferencesConfigure sync frequency (hourly, daily, manual)RadioButton group
Notification settingsToggle email alerts for sync errors, seasonal transitionsCheckbox list
New arrival configurationSet boost duration, priority, decay optionsTextField with Checkbox
Billing managementView current plan, upgrade/downgradeCard with plan comparison
Data exportExport all priority data as CSVButton action
Delete accountRemove all store data from AdPriorityButton (destructive) with Modal confirmation

2.7 Data Requirements

DR-01: Nexus Catalog Baseline

The following data has been verified from live Shopify Admin API and GMC product exports:

MetricValueSourceDate Verified
Total Shopify products5,582Shopify Admin API2026-02-10
Active products2,425Shopify Admin API2026-02-10
Archived products3,121Shopify Admin API2026-02-10
Draft products36Shopify Admin API2026-02-10
Unique product types90Shopify Admin API2026-02-10
Unique vendors175Shopify Admin API2026-02-10
Unique tags2,522Shopify Admin API2026-02-10
GMC total variants124,060GMC TSV export2026-02-10
Active variants (est.)~15,000-20,000Calculated2026-02-10
Avg variants per product~3.5Calculated2026-02-10

DR-02: GMC Custom Label Availability

LabelCurrent StateProducts UsingAvailable
custom_label_0“Argonaut Nations - Converting”7 of 124,060 (0.006%)Yes (safe to overwrite)
custom_label_1Empty0Yes
custom_label_2Empty0Yes
custom_label_3Empty0Yes
custom_label_4Empty0Yes

DR-03: GMC Custom Label Constraints

ConstraintValueAdPriority Impact
Labels available5 (label_0 through label_4)Full schema fits
Max characters per label value100All values under 30 characters
Max unique values per label1,000Max unique values used: ~20 (category group)
Total unique values (all labels)5,000~38 total unique values
Case sensitivityNot case-sensitiveUse lowercase for consistency
Shopper visibilityNot visible (internal only)No customer-facing impact

DR-04: Google Sheets Constraints

ConstraintValueAdPriority Impact
Maximum cells per spreadsheet10,000,0006 columns x 20,000 rows = 120,000 cells (1.2%)
Maximum rows per sheet5,000,000+20,000 rows (well within limit)
Maximum columns per sheet18,2786 columns used
Sheets API write quota300 requests per minuteBatch writes handle full catalog in 1-2 requests

DR-05: Product ID Format Verification

ComponentFormatExampleVerified
Full GMC product IDshopify_US_{productId}_{variantId}shopify_US_8779355160808_46050142748904Yes (124,060 products)
Item Group ID{productId}8779355160808Yes
Country codeUS for all Nexus productsUSYes
All IDs are variant-levelNo product-only IDs exist in GMCYes

2.8 Acceptance Criteria

MVP (Phase 0: Nexus Implementation)

#CriterionVerification Method
AC-01Priority scores assigned to all 2,425 active Nexus productsDatabase query: SELECT COUNT(*) WHERE priority_score IS NOT NULL
AC-02Supplemental feed (Google Sheet) created and connected to GMCGMC dashboard shows feed with 0 errors
AC-03Custom labels populated on all active variants in GMCGMC product search shows labels on random sample of 20 products
AC-04PMAX campaigns restructured by priority scoreGoogle Ads dashboard shows asset groups filtered by custom_label_0
AC-0530-day baseline ROAS documented before changeSpreadsheet with daily ROAS data for 30 days pre-deployment
AC-06Process documented for SaaS replicationStep-by-step guide covers: category mapping, scoring, feed generation, GMC connection

Beta Release (Phase 1)

#CriterionVerification Method
AC-10Shopify app installable via OAuthTest install on development store completes without errors
AC-11OAuth token exchange flow workingSession token validated, API requests succeed
AC-12Product import from Shopify workingImport 5,000 products in < 5 minutes
AC-13Basic priority UI functionalMerchant can view, search, filter, and edit product priorities
AC-14Manual GMC sync (Google Sheets) workingClick “Sync Now” updates Sheet within 60 seconds
AC-15GDPR webhooks implementedAll 3 mandatory endpoints return 200 OK
AC-165 beta testers onboarded5 real merchants have installed and used the app

Production Release (Phase 2-3)

#CriterionVerification Method
AC-20All subscription tiers functionalTest purchase on each tier succeeds
AC-21Automated GMC sync on scheduleSync runs at configured interval without manual intervention
AC-22Seasonal calendar auto-transitionsSimulated season boundary triggers recalculation
AC-23Rules engine UI completeMerchant can create, edit, test, and delete rules without code
AC-24New arrival boost workingProduct created today shows priority 5 in database
AC-25Shopify Billing API integrated14-day trial starts on install, converts to paid plan
AC-26App Store listing approvedPublished and discoverable on Shopify App Store
AC-27Onboarding flow completeNew merchant reaches first sync within 30 minutes

2.9 Constraints and Assumptions

Technical Constraints

ConstraintValueImpact on AdPriority
GMC custom label character limit100 characters per valueAll AdPriority values are < 30 characters
GMC unique values per label1,000 maximumAdPriority uses ~38 total unique values
GMC product update limit2x per day per product via Content APIDaily sync schedule is sufficient
Google Sheets cell limit10,000,000 cellsEven at 124K variants x 6 columns = 744K cells (7.4%)
Shopify API rate limit40 requests/second (REST), 1,000 points/second (GraphQL)Paginated import at 250 products/page stays within limits
Shopify metafield value limit512KB per metafieldAdPriority JSON payload is < 1KB
Shopify webhook deliveryAt-least-once (may deliver duplicates)Idempotent webhook handlers required

Platform Constraints

ConstraintImpact
Shopify requires embedded apps for new App Store submissionsMust use App Bridge v4+ and Polaris UI
Shopify deprecated cookie-based authenticationMust use session tokens (JWT)
GDPR webhooks are mandatoryThree endpoints must exist even if no customer data is stored
Shopify takes 20% revenue share on app subscriptionsPricing must account for this margin

Assumptions

AssumptionRisk if WrongMitigation
Merchants have a Google Merchant Center account with active product feedApp is useless without GMCOnboarding check verifies GMC account exists
Shopify Google Channel creates the primary product feedAdPriority adds supplemental labels onlyDocumentation explains this prerequisite
Merchants understand the concept of priority-based budget allocationLow adoption if concept is unclearOnboarding tutorial, help docs, video walkthrough
ROAS will improve with proper product segmentationCore value proposition failsPhase 0 validates with Nexus before building SaaS
Google Ads PMAX adoption continues growingMarket shrinks if PMAX declinesMonitor Google Ads product announcements

2.10 Out of Scope (v1.0)

The following features are explicitly excluded from version 1.0 and may be considered for future releases:

FeatureReason for ExclusionPotential Future Version
Multi-channel support (Facebook Ads, Microsoft/Bing Ads)Adds complexity without validated demand; Google Ads is the primary channelv2.0+
Automated bid managementAdPriority controls priorities, not bids; bid management is a separate product categoryNot planned
Creative optimization (ad copy, image selection)Outside core scope of product prioritizationNot planned
Inventory-based automation (auto-exclude when stock < N)Partially addressed by tag-based rules; full inventory integration deferredv1.5
Machine learning predictions (demand forecasting, score suggestions)Requires significant training data from multiple merchantsv2.0+
Mobile appEmbedded Polaris app works on mobile Shopify adminNot planned
Variant-level priority overridesProduct-level scoring is sufficient for v1.0; variant overrides add complexityv1.5
Multi-currency supportNexus is US-only; international stores may need localized pricingv2.0+
White-label / API accessEnterprise feature; requires additional infrastructurev2.0+ (Enterprise tier)
Campaign creation/managementAdPriority provides labels, not campaign management; merchants structure PMAX themselvesNot planned

2.11 Open Questions and Risks

Open Questions

#QuestionStatusResolution Path
OQ-01Variant-level vs. product-level priorities for v1.0?Resolved: Product-level for v1.0, variant-level for v1.5Variant-level in GMC feed (required), but priority is set at product level
OQ-02Optimal sync frequency vs. API rate limits?Resolved: Daily for Google Sheets (GMC fetches daily anyway)Content API (Pro) stays within 2x/day/product limit
OQ-03How to handle products not in GMC feed?OpenReconciliation job identifies unmatched IDs; exclude from supplemental feed with logged warning
OQ-04What GMC custom label slots are already in use for Nexus?Resolved: All 5 available (7 products on label_0, negligible)Safe to overwrite
OQ-05Pricing validation with target market?OpenValidate during beta with 5 testers; compare to competitor pricing
OQ-06How to handle stores with > 100K variants in Google Sheets?OpenSheet can handle 744K cells (7.4% of limit); Content API fallback for extreme cases
OQ-07Should seasonal transitions happen at midnight UTC or merchant timezone?Resolved: Merchant timezoneTimezone stored in store settings

Risks

RiskLikelihoodImpactMitigation
ROAS does not improve with priority-based segmentationMediumCritical – invalidates premisePhase 0 validates with real store before SaaS investment
GMC feed processing delays exceed 24 hoursLowMedium – delayed label applicationUse manual “Update” trigger in GMC; monitor processing times
Shopify API rate limits block large importsMediumMedium – slow initial setupImplement pagination, respect rate limit headers, use bulk GraphQL
Google Sheets API quota exceeded during bulk syncLowMedium – sync failureBatch writes, exponential backoff, manual CSV fallback
Competitor copies the priority scoring approachLowMedium – reduced differentiationFirst-mover advantage; build brand; add features competitors cannot easily replicate
Shopify App Store rejectionMediumHigh – blocks launchStudy requirements thoroughly; test against all criteria before submission
Seasonal transition applies wrong prioritiesMediumHigh – wrong products advertisedPreview mode before applying; manual override; monitor 48 hours after transition
Merchants find the rules engine too complexMediumHigh – adoption frictionPre-built templates; wizard-based onboarding; start simple, add complexity gradually

2.12 Requirement Traceability Matrix

This matrix maps each functional requirement to its implementation phase, source document, and related acceptance criteria.

Requirement IDDescriptionPhaseSourceAcceptance Criteria
FR-01Priority Score Definition0requirements.mdAC-01
FR-02Score Assignment Methods0-3requirements.mdAC-13
FR-03Score Override Hierarchy1category-mapping.mdAC-13
FR-10Category Rules1category-mapping.mdAC-13, AC-23
FR-11Rule Examples1category-mapping.mdAC-23
FR-20Product Identification0product-mapping.mdAC-02, AC-03
FR-21Custom Label Structure0product-mapping.md, google-merchant-center-api.mdAC-03
FR-30Season Definitions2seasonal-calendar.mdAC-22
FR-31Seasonal Rules2category-mapping.md, seasonal-calendar.mdAC-22
FR-40New Arrival Boost2requirements.mdAC-24
FR-50Sync Methods0-2google-merchant-center-api.mdAC-02, AC-14, AC-21
FR-51Sync Schedule1requirements.mdAC-14, AC-21
FR-52Error Handling1requirements.mdAC-14
FR-60Performance Data3google-ads-api.md
FR-61Recommendations3google-ads-api.md
IR-01App Installation1shopify-app-requirements.mdAC-10, AC-11
IR-02Data Access1shopify-app-requirements.mdAC-12
IR-03Webhooks1shopify-app-requirements.mdAC-15
IR-10GMC Authentication0-2google-merchant-center-api.mdAC-02
IR-11GMC API Operations2google-merchant-center-api.mdAC-21
IR-20Google Ads Authentication3google-ads-api.md
IR-21Google Ads API Operations3google-ads-api.md

Summary

This Product Requirements Document defines 21 functional requirements, 24 non-functional requirements, 11 integration requirements, and 5 UI/UX requirement areas for AdPriority. Every requirement traces back to the core problem: retailers waste ad spend by treating all products equally in Google Ads.

The requirements are organized by priority and phase:

  • Phase 0 (Week 1-2): Core scoring system (FR-01, FR-20, FR-21, FR-50) validated with Nexus Clothing
  • Phase 1 (Week 3-4): SaaS foundation (FR-02, FR-03, FR-10, FR-51, FR-52, IR-01 through IR-03) with 5 beta testers
  • Phase 2 (Week 5-8): Full product (FR-11, FR-30, FR-31, FR-40) with seasonal automation and rules engine
  • Phase 3 (Week 9-12): App Store launch (FR-60, FR-61, IR-20, IR-21) with billing and scale

Key technical decisions validated during the research phase:

  1. GMC product ID format: shopify_US_{productId}_{variantId} – confirmed from 124,060 product export
  2. All 5 custom labels available: Only 7 products use label_0 (0.006%), labels 1-4 are empty
  3. Supplemental feed pipeline works: 10/10 test products matched in GMC with zero errors
  4. Google Sheets capacity is sufficient: ~120,000 cells for Nexus (1.2% of 10M limit)

The next step is to use these requirements as the implementation guide for Phase 0 (Chapter 18) and the architectural foundation for the system design (Chapter 4).

Chapter 3: User Stories & Personas

3.1 Overview

This chapter defines the people who will use AdPriority, what they need from it, and how every requirement traces back to a real problem experienced by a real user type. The user stories documented here serve as the contract between design and implementation: every feature built must satisfy at least one story, and every story must have acceptance criteria that can be verified in a demo or automated test.

Story Format

All stories follow the standard format:

As a [role],
I want to [capability],
So that [benefit].

Each story includes:

  • Acceptance criteria – specific, testable conditions that must be true for the story to be considered complete
  • Priority – using the MoSCoW framework mapped to P0-P3
  • Effort – estimated implementation complexity (Low, Medium, High)
  • Phase – which development phase delivers this story
  • Dependencies – other stories or infrastructure that must exist first

Priority Definitions

LevelMoSCoWMeaningImplication
P0Must HaveThe product does not function without thisShip in Phase 1 or the product has no value
P1Should HaveCore differentiator; expected by paying customersShip in Phase 2 to justify Growth tier pricing
P2Nice to HaveEnhances the product but not required for core valueShip in Phase 2-3 as competitive advantage
P3FutureStrategic features for long-term growthShip in Phase 3+ as the business scales

Story ID Conventions

RangeRoleExamples
US-01 through US-08Store Owner (primary user)Priority scoring, rules, bulk edit
US-10 through US-12Marketing Manager (analytics-focused)Performance metrics, ROAS recommendations
US-20 through US-22Agency User (multi-store)Multi-store dashboard, rule cloning
US-30 through US-39Discovered Stories (implied by requirements)Onboarding, alerts, CSV import/export

3.2 User Personas

Each persona represents a distinct user type with different goals, constraints, and technical comfort levels. These are not hypothetical archetypes; they are composites of real Shopify merchants drawn from market research and, in one case, the actual store that will validate the MVP.

3.2.1 Persona: Small Store Owner (“Sarah”)

PERSONA PROFILE: SARAH
=======================

  Role:           Store owner and sole operator
  Store size:     200 - 1,000 products
  Monthly revenue: $15,000 - $80,000
  Google Ads spend: $500 - $3,000/month
  Team size:      1 (herself)
  Tech comfort:   Moderate -- comfortable with Shopify admin, intimidated by feed tools
  AdPriority tier: Starter ($29/mo)

Background: Sarah runs a women’s fashion boutique on Shopify. She launched Google Ads six months ago and is spending $1,500/month on a Performance Max campaign. She knows the ads are working – she can see the sales attribution in Shopify – but she suspects she is wasting money advertising products that are out of season or overstocked. She tried DataFeedWatch during a free trial but abandoned it after 30 minutes because the interface was overwhelming.

Pain Points:

Pain PointFrequencySeverity
Manually updating product labelsEvery season change (4x/year)High – skips it, wastes budget
No visibility into which products get ad spendDailyMedium – cannot optimize what she cannot see
New arrivals do not get advertising quicklyWeeklyHigh – misses the launch window
Dead stock still appearing in adsOngoingHigh – direct waste of budget
Cannot justify time to learn complex feed toolsOngoingMedium – stays with the status quo

Goals:

  1. Automate ad spend allocation so she can focus on buying and customer service
  2. Stop advertising out-of-season products without manual intervention
  3. Give new arrivals a promotional boost automatically
  4. Improve ROAS enough to justify increasing her Google Ads budget

How Sarah Uses AdPriority:

  • Installs the app and connects her Google account in under 5 minutes
  • Reviews the auto-suggested category mappings and makes minor adjustments
  • Sets up the seasonal calendar (defaults work for her climate zone)
  • Checks the dashboard weekly to see priority distribution
  • Occasionally overrides a priority for a product she wants to promote or suppress

Key Stories: US-01, US-02, US-05, US-06, US-30, US-31, US-34

3.2.2 Persona: Growing Brand Manager (“Marcus”)

PERSONA PROFILE: MARCUS
========================

  Role:           E-commerce manager at a growing streetwear brand
  Store size:     1,000 - 10,000 products
  Monthly revenue: $100,000 - $500,000
  Google Ads spend: $5,000 - $20,000/month
  Team size:      3-5 (marketing, warehouse, customer service)
  Tech comfort:   High -- uses Shopify APIs, comfortable with spreadsheets and automation
  AdPriority tier: Growth ($79/mo)

Background: Marcus manages the online store for a mid-sized streetwear brand with 4,200 active products. He runs three PMAX campaigns segmented by product category, but his custom labels are six months out of date because updating them requires exporting a CSV from their product database, manually adding label columns, and uploading it to Google Merchant Center. The process takes him a full day each time he does it, so it only happens when performance drops noticeably.

Pain Points:

Pain PointFrequencySeverity
Seasonal transitions require manual CSV work4x/yearVery High – loses 2+ weeks of optimized spend per transition
New arrivals sit unlabeled for 1-2 weeksWeeklyHigh – misses the critical launch window
Bulk updates are error-proneMonthlyMedium – wrong products get wrong labels
No way to preview changes before they go liveEach updateMedium – has pushed wrong labels twice
Inconsistent labeling between team membersOngoingMedium – no single source of truth

Goals:

  1. Systematic, repeatable approach to product prioritization that the whole team can follow
  2. Seasonal automation that eliminates the quarterly manual update grind
  3. Confidence that new arrivals are automatically promoted on day one
  4. Ability to bulk-edit priorities when running promotions or clearance events
  5. Preview mode so he can verify changes before they sync to GMC

How Marcus Uses AdPriority:

  • Spends 30 minutes on initial setup: category mappings, seasonal calendar, tag rules
  • Relies on seasonal automation to handle the quarterly transitions
  • Uses bulk edit for clearance events and promotional pushes
  • Checks the preview before each sync to catch mistakes
  • Delegates day-to-day monitoring to a junior team member via the dashboard

Key Stories: US-01, US-02, US-03, US-04, US-06, US-07, US-08, US-32, US-36

3.2.3 Persona: Agency Account Manager (“Diana”)

PERSONA PROFILE: DIANA
=======================

  Role:           Senior account manager at a digital marketing agency
  Stores managed: 12 Shopify stores across 8 clients
  Combined catalog: 30,000+ products
  Combined ad spend: $80,000+/month
  Team size:      4 (herself + 3 account executives)
  Tech comfort:   Expert -- uses APIs, scripts, and automation daily
  AdPriority tier: Pro/Enterprise ($199+/mo)

Background: Diana manages Google Ads for 8 retail clients, all on Shopify. Each client has unique product categories, seasonal patterns, and brand priorities. She currently maintains a master spreadsheet that tracks which custom labels each client should have, but the spreadsheet is always behind reality. Her account executives spend 6-8 hours per client per quarter updating labels manually. She has pitched the idea of a centralized tool to her director, who approved a $200-300/month budget for any tool that can cut this work in half.

Pain Points:

Pain PointFrequencySeverity
Managing labels across 12 stores manuallyContinuouslyVery High – 72+ hours/quarter of labor
No standardized approach across clientsOngoingHigh – inconsistent results, hard to train new hires
Recreating rules from scratch for each new clientEach onboardingHigh – 4-6 hours per new client
Client reporting on label effectivenessMonthlyMedium – manual data aggregation
Cannot demonstrate ROI of label management to clientsQuarterlyHigh – clients question the billable hours

Goals:

  1. Manage all client stores from a single dashboard
  2. Clone proven rule sets from one store to another to accelerate onboarding
  3. Generate per-client usage and performance reports for billing justification
  4. Demonstrate measurable ROAS improvement to retain and upsell clients
  5. Reduce per-client management time from 6-8 hours to under 1 hour per quarter

How Diana Uses AdPriority:

  • Sets up each client store once with category mappings and seasonal rules
  • Clones her “fashion retail” rule template to new fashion clients
  • Checks the multi-store dashboard daily for sync failures or alerts
  • Generates monthly per-store performance reports for client meetings
  • Uses ROAS tracking to demonstrate the value of the service

Key Stories: US-20, US-21, US-22, US-37, US-10, US-12

3.2.4 Persona: Nexus Clothing Owner (“Will”) – Validation User

PERSONA PROFILE: WILL
======================

  Role:           Owner and operator of Nexus Clothing
  Store:          nexus-clothes.myshopify.com
  Store size:     5,582 products (2,425 active), 90 product types, 175 vendors
  GMC catalog:    124,060 variants
  Google Ads spend: Active PMAX campaigns
  Team size:      1 for digital operations
  Tech comfort:   High -- runs Docker infrastructure, builds custom tools
  AdPriority tier: Phase 0 (MVP validation)

Background: Will is the developer building AdPriority and the owner of the first store that will use it. Nexus Clothing is a multi-location streetwear retailer with a complex product catalog spanning t-shirts, jeans, outerwear, headwear, footwear, and accessories. The store runs Google Ads Performance Max campaigns but has only 7 out of 124,060 GMC variants with any custom label set (0.006% coverage). This means Google’s AI is treating every product equally, regardless of season, margin, or demand.

Pain Points:

Pain PointFrequencySeverity
124K GMC variants with no priority labelingPersistentCritical – entire ad budget is unoptimized
Seasonal clothing needs different priorities by quarter4x/yearHigh – shorts advertised in winter
90 product types make manual labeling impracticalOngoingHigh – too many categories to manage by hand
No way to validate if priority scoring actually improves ROASOngoingCritical – need proof before building a SaaS product
3,121 archived products still present in catalog dataOngoingMedium – must be excluded from ads

Goals:

  1. Validate the AdPriority concept with real data before investing in SaaS development
  2. Score all 2,425 active products with appropriate priorities
  3. Deploy a supplemental feed covering all ~15,000-20,000 active variants
  4. Restructure PMAX campaigns around priority-based asset groups
  5. Measure a 15%+ ROAS improvement within 30 days
  6. Document the entire process for replication in the SaaS product

How Will Uses AdPriority:

  • Phase 0 is a semi-automated process: category mapping spreadsheet, Python scripts, Google Sheets API
  • Validates the 20 category groups against real product data
  • Tests the seasonal matrix with winter priorities first
  • Monitors ROAS daily during the 30-day measurement window
  • Documents every decision and outcome for the blueprint book

Key Stories: US-01, US-02, US-05, US-03, US-31, US-33, US-34

Why This Persona Matters: Will is not a future hypothetical user. He is the first user, and his store provides the real-world complexity (5,582 products, 90 types, 124K GMC variants) that validates whether AdPriority’s approach works at scale. Every design decision in Phase 0 is tested against Nexus Clothing data.


3.3 Epic: Product Priority Management

This epic covers the core functionality that makes AdPriority useful: assigning, managing, and automating priority scores on products.

Story US-01: Assign Priority Scores

As a store owner, I want to assign priority scores (0-5) to my products, So that I can control how much ad budget is allocated to each product.

Acceptance Criteria:

  • User can view a paginated product list showing product name, image thumbnail, product type, current priority score, and sync status
  • Each priority score displays with a color-coded badge (0=gray, 1=red, 2=orange, 3=yellow, 4=blue, 5=green)
  • User can click on a product row to open a detail panel showing all product attributes and priority history
  • User can change a product’s priority score via a dropdown or slider in the detail panel
  • Priority change is saved to the database immediately on selection (no separate save button)
  • Saving a priority change creates an audit trail entry recording the old score, new score, user, timestamp, and source (“manual”)
  • Saving a priority change adds a GMC sync queue entry for the next sync cycle
  • A toast notification confirms the priority change was saved
  • If the save fails, the UI reverts to the previous score and shows an error message
  • Score labels are visible alongside the numeric value (“5 - Push Hard”, “0 - Exclude”)

Priority: P0 (Must Have) Effort: Medium Phase: Phase 1 Dependencies: Product import from Shopify (database must have products to score)

Nexus Validation: In Phase 0, this is done via the category mapping spreadsheet and Python scripts. The SaaS version replaces that manual process with the UI described above.


Story US-02: Category-Based Rules

As a store owner, I want to set category-based rules so new products automatically inherit priority scores, So that I do not have to manually score every new product that arrives in my catalog.

Acceptance Criteria:

  • User can view a list of all Shopify product types found in their catalog, grouped by AdPriority category groups
  • User can assign a default priority score (0-5) to each category group
  • When a new product is created in Shopify (via webhook), the system looks up its product type, matches it to a category group, and assigns the default priority
  • When a product’s product type changes in Shopify (via webhook), the system re-evaluates its category-based priority
  • Category rules are lower priority than manual overrides (a manually-set score is not overwritten by a category rule)
  • User can create custom category groups by combining multiple Shopify product types
  • User can edit the name and default priority of any category group
  • User can see how many products are in each category group
  • A “Preview” button shows which products would be affected by a rule change before it is applied
  • Changes to category rules trigger a bulk recalculation of affected product priorities

Priority: P0 (Must Have) Effort: High Phase: Phase 1 Dependencies: US-01 (priority scoring must exist), product import (product types must be available)

Nexus Validation: The 20 category groups designed during research (t-shirts, jeans-pants, outerwear-heavy, headwear-caps, etc.) were derived from grouping Nexus Clothing’s 90 product types. Phase 0 validates these groupings against real inventory.


Story US-06: Manual Override

As a store owner, I want to manually override any automatic priority assignment, So that I maintain final control over which products are advertised and at what level.

Acceptance Criteria:

  • User can set a priority score on any individual product that overrides all automatic rules (category, seasonal, new arrival)
  • A manual override is visually indicated on the product list (e.g., a lock icon or “Override” badge next to the score)
  • The override persists across seasonal transitions and rule recalculations
  • User can remove the override to return the product to automatic rule-based scoring
  • Removing an override immediately recalculates the product’s priority based on current active rules
  • The audit trail records override creation and removal as distinct events
  • Override reason is optionally recordable (free text field, e.g., “CEO wants this promoted for store event”)
  • Bulk override is supported: user can select multiple products and apply a manual override to all of them
  • A filter option allows viewing only products with active overrides
  • Override count is displayed on the dashboard summary

Priority: P0 (Must Have) Effort: Medium Phase: Phase 1 Dependencies: US-01 (priority scoring), US-02 (category rules must exist for override to be meaningful)

Design Note: The override hierarchy is fundamental to the scoring engine. The precedence order is: (1) manual override, (2) exclusion tags, (3) inventory warnings, (4) new arrival boost, (5) tag modifiers, (6) seasonal calendar, (7) category default. Manual override sits at the top because the store owner must always have the final word.


Story US-07: Bulk Edit Priorities

As a store owner, I want to bulk-edit priorities for multiple products at once, So that I can efficiently manage large catalog changes like clearance events or seasonal promotions.

Acceptance Criteria:

  • User can select multiple products via checkboxes in the product list
  • A “Select All” option selects all products on the current page; a “Select All Matching” option selects all products matching the current filter (even across pages)
  • With products selected, a bulk action toolbar appears showing the count of selected products
  • Bulk action: “Set Priority” – assigns a chosen score (0-5) to all selected products as manual overrides
  • Bulk action: “Remove Override” – removes manual overrides from all selected products, returning them to rule-based scoring
  • Bulk action: “Apply Rule” – recalculates priorities for selected products based on current rules
  • A confirmation dialog shows the number of products affected and the action to be taken before execution
  • Bulk operations process at 1,000+ products per minute
  • Progress indicator displays during bulk operations that take longer than 2 seconds
  • Bulk operations create individual audit trail entries for each affected product
  • Bulk operations add affected products to the GMC sync queue

Priority: P1 (Should Have) Effort: Medium Phase: Phase 2 Dependencies: US-01 (priority scoring), US-06 (manual override)

Example Use Cases:

ScenarioActionProducts Affected
End-of-season clearanceFilter by “Shorts”, set all to priority 0~200 products
New vendor launchFilter by vendor “Jordan Craig”, set all to priority 5~150 products
Remove all overrides after promotion endsFilter by “Has Override”, remove overridesVariable
Pre-holiday boostFilter by “Outerwear” + “Hoodies”, set to priority 5~400 products

3.4 Epic: Seasonal Automation

This epic covers the features that differentiate AdPriority from manual label management: automatic priority adjustments based on a configurable seasonal calendar.

Story US-03: Seasonal Priority Adjustment

As a store owner, I want seasonal automation so my priorities adjust automatically throughout the year, So that I do not have to remember to update labels four times per year and my ad spend always matches the current season.

Acceptance Criteria:

  • User can define season boundaries (default: Winter Dec 1 - Feb 28, Spring Mar 1 - May 31, Summer Jun 1 - Aug 31, Fall Sep 1 - Nov 30)
  • User can customize season start and end dates to match their business cycle
  • User can configure a Category x Season priority matrix defining the base score for each category in each season
  • The matrix displays as an editable table with categories as rows and seasons as columns
  • When the current date crosses a season boundary, the system automatically recalculates priorities for all products affected by seasonal rules
  • Seasonal recalculation respects the override hierarchy (manual overrides are not changed)
  • User can manually trigger a season change early or late (e.g., “Switch to Summer now” if the weather turns early)
  • A preview panel shows what will change at the next season boundary, including product counts per category and the old vs. new scores
  • Seasonal transitions are logged in the audit trail with the trigger source (“automatic” or “manual”)
  • A notification is sent to the user when a seasonal transition completes, summarizing the number of products affected

Priority: P1 (Should Have) Effort: High Phase: Phase 2 Dependencies: US-01 (priority scoring), US-02 (category rules provide the category groups)

Seasonal Matrix Example (from Nexus Clothing research):

CATEGORY x SEASON PRIORITY MATRIX
===================================

                          Winter   Spring   Summer   Fall
                          ------   ------   ------   ----
  T-Shirts                  2        4        5       3
  Shorts                    0        3        5       1
  Jeans & Pants             4        3        3       4
  Hoodies & Sweatshirts     5        3        1       5
  Outerwear - Heavy         5        1        0       4
  Outerwear - Light         3        4        3       4
  Headwear - Caps           3        3        3       3
  Headwear - Cold Weather   5        1        0       4
  Footwear - Sneakers       3        3        4       3
  Footwear - Sandals        0        3        5       1
  Underwear & Basics        2        2        2       2
  Accessories               3        3        3       3
  Jerseys & Sports          2        3        4       3
  Sets & Matching           3        3        4       3
  Suits & Formal            3        3        3       3
  Swimwear                  0        3        5       0
  Bags & Luggage            3        3        3       3
  Jewelry                   3        3        3       3
  Sleepwear                 3        2        2       3
  Kids                      3        3        3       3

Design Note: The matrix is the heart of seasonal automation. A store owner fills this in once (or accepts the defaults for their industry), and the system handles every transition automatically. This single feature eliminates the most common reason merchants let their custom labels go stale.


Story US-04: New Arrival Boost

As a store owner, I want new arrivals to automatically get high priority for a configurable period, So that new products get immediate advertising exposure during their critical launch window.

Acceptance Criteria:

  • User can enable or disable the new arrival boost feature
  • User can configure the boost duration (default: 14 days, range: 1-90 days)
  • User can configure the boost priority score (default: 5, range: 1-5)
  • When a new product is created in Shopify (detected via webhook or import), the system assigns the configured boost priority
  • The boost is applied only if the resulting score is higher than the score from other rules (boost does not reduce a score)
  • User can enable gradual decay: the priority decreases by 1 level at configurable intervals during the boost period
  • Example decay: Day 1-5 = priority 5, Day 6-10 = priority 4, Day 11-14 = priority 3, then reverts to rule-based score
  • Products with active boosts are visually indicated in the product list (e.g., “New Arrival” badge with countdown)
  • User can manually end a boost early on any individual product
  • Manual overrides take precedence over new arrival boosts
  • When the boost expires, the product’s priority reverts to its rule-calculated score (seasonal + category)
  • Boost expiration triggers a recalculation and GMC sync queue entry

Priority: P1 (Should Have) Effort: Medium Phase: Phase 2 Dependencies: US-01 (priority scoring), Shopify webhook for products/create

Gradual Decay Configuration Example:

NEW ARRIVAL BOOST: GRADUAL DECAY
=================================

  Boost Duration: 14 days
  Starting Score: 5 (Push Hard)
  Decay Interval: Every 5 days

  Timeline:
  Day  1 -----> Day  5:  Priority 5  (Push Hard)
  Day  6 -----> Day 10:  Priority 4  (Strong)
  Day 11 -----> Day 14:  Priority 3  (Normal)
  Day 15 onwards:        Reverts to rule-based score

  Product Example: "New Jordan Craig Stacked Jeans"
  - Created: Feb 1
  - Category default: 4 (Jeans & Pants, Winter)
  - Boost: Feb 1-5 = 5, Feb 6-10 = 4, Feb 11-14 = 3*
  - Feb 15 onwards: 4 (category rule takes over)

  * Note: Day 11-14 the decay score (3) is LOWER than the
    category score (4), so category score wins. Boost only
    elevates, never reduces.

3.5 Epic: GMC Synchronization

This epic covers the connection between AdPriority and Google Merchant Center – the mechanism that makes priority scores actionable in Google Ads campaigns.

Story US-05: Sync Status Visibility

As a store owner, I want to see which products are synced to GMC and their current custom labels, So that I can verify my priority settings are actually reaching Google Ads.

Acceptance Criteria:

  • The product list includes a “Sync Status” column showing one of: Synced, Pending, Error, Not in GMC
  • “Synced” means the product’s current priority score matches what was last written to the supplemental feed
  • “Pending” means the priority score has changed since the last sync and is queued for the next sync cycle
  • “Error” means the last sync attempt for this product failed (with a tooltip showing the error message)
  • “Not in GMC” means the product could not be matched to a GMC product ID
  • A sync summary panel on the dashboard shows: total synced, total pending, total errors, last sync timestamp, next scheduled sync
  • User can click “Sync Now” to trigger an immediate sync cycle for all pending products
  • User can click on an individual product to see its sync history (last 10 sync events with timestamps and statuses)
  • Filtering the product list by sync status is supported (e.g., show only “Error” products)
  • A warning banner appears if more than 5% of products are in Error status
  • The dashboard shows the custom label values currently written to GMC for each synced product

Priority: P0 (Must Have) Effort: High Phase: Phase 1 Dependencies: US-01 (products must have scores to sync), Google Sheets API integration, GMC supplemental feed connection

Sync Status Flow:

SYNC STATUS LIFECYCLE
======================

  Product priority changed (manual, rule, or seasonal)
       |
       v
  Status: PENDING
  (queued for next sync cycle)
       |
       |  Sync cycle runs (scheduled or manual)
       v
  Write to Google Sheet
       |
       +---> Success --> Status: SYNCED
       |                 (timestamp updated)
       |
       +---> Failure --> Status: ERROR
                         (error message recorded, retry queued)

Story US-08: Preview Before Sync

As a store owner, I want to preview priority changes before they sync to GMC, So that I can verify changes are correct and avoid pushing wrong labels to my live ads.

Acceptance Criteria:

  • Before any sync cycle executes, a preview screen shows all products with pending changes
  • The preview displays: product name, current GMC label value, proposed new label value, and the reason for the change (manual, rule, seasonal)
  • User can approve the sync to proceed or cancel to hold the changes
  • User can remove individual products from the pending sync while keeping others
  • For automatic syncs (scheduled), the preview is recorded in a sync log that the user can review after the fact
  • For rule changes and seasonal transitions that affect many products, a summary view shows affected product counts per category and per score change direction (increases vs. decreases)
  • A diff view highlights products whose score is changing by more than 2 levels (potential large impact)
  • The preview includes a “Download CSV” option to export the pending changes for offline review
  • Preview data refreshes in real-time as the user makes changes on other pages

Priority: P2 (Nice to Have) Effort: Medium Phase: Phase 2 Dependencies: US-01 (priority scoring), US-05 (sync pipeline must exist for preview to be meaningful)

Why This Matters: Marcus (the growing brand manager persona) has pushed wrong labels to GMC twice due to errors in CSV exports. A preview step prevents this class of error entirely. For Sarah (small store owner), the preview provides reassurance that the system is doing the right thing.


3.6 Epic: Performance Analytics

This epic covers the analytics features available in the Pro tier that connect priority scoring to actual advertising performance. These features require Google Ads API integration.

Story US-10: Priority Tier Metrics

As a marketing manager, I want to see performance metrics by priority tier, So that I can understand whether my priority scoring strategy is actually improving ad performance.

Acceptance Criteria:

  • A performance dashboard displays key metrics broken down by priority tier (0-5)
  • Metrics shown per tier: impressions, clicks, cost, conversions, revenue, ROAS, CPC, conversion rate
  • Data is pulled from Google Ads API and aggregated by the priority score assigned in AdPriority
  • Metrics are available for configurable date ranges (last 7 days, 30 days, 90 days, custom)
  • A bar chart shows spend distribution across priority tiers
  • A table shows the top 10 performing products within each priority tier
  • Data refreshes at least once daily (with timestamp of last refresh)
  • A summary card highlights the ROAS for priority 5 products vs. the overall catalog ROAS
  • Export to CSV is available for all metrics tables
  • If Google Ads is not connected, the dashboard shows a clear prompt to connect with an explanation of what metrics will become available

Priority: P2 (Nice to Have) Effort: High Phase: Phase 2-3 Dependencies: Google Ads API integration, US-01 (products must have scores to aggregate against)

Example Dashboard Output:

PERFORMANCE BY PRIORITY TIER (Last 30 Days)
=============================================

  Tier  | Products | Impressions |  Clicks  |   Cost   | Revenue  |  ROAS
  ------+----------+-------------+----------+----------+----------+-------
    5   |      312 |    142,000  |   4,260  |  $8,520  | $34,080  |  4.0x
    4   |      589 |     98,000  |   2,450  |  $3,675  | $12,862  |  3.5x
    3   |      724 |     56,000  |   1,120  |  $1,344  |  $3,628  |  2.7x
    2   |      398 |     12,000  |     240  |    $192  |    $384  |  2.0x
    1   |      201 |      3,000  |      45  |     $27  |     $40  |  1.5x
    0   |      201 |          0  |       0  |      $0  |      $0  |   --
  ------+----------+-------------+----------+----------+----------+-------
  Total |    2,425 |    311,000  |   8,115  | $13,758  | $50,994  |  3.7x

  Insight: Priority 5 products generate 67% of revenue with 62% of spend.
           Priority 0-1 products correctly excluded from budget waste.

Story US-11: ROAS-Based Recommendations

As a marketing manager, I want recommendations for priority adjustments based on ROAS data, So that I can continuously optimize my scoring strategy with data-driven decisions instead of guesswork.

Acceptance Criteria:

  • The system analyzes Google Ads performance data and identifies products whose priority score does not match their actual performance
  • Recommendations fall into three categories: “Increase Priority” (high ROAS, low priority), “Decrease Priority” (low ROAS, high priority), “Review” (anomalous patterns)
  • Each recommendation shows: product name, current priority, suggested priority, reason, supporting metrics (ROAS, spend, revenue)
  • Recommendations require a minimum data threshold (e.g., 100+ impressions, 14+ days of data) to avoid noise
  • User can accept a recommendation with one click, which applies the suggested priority as a manual override
  • User can dismiss a recommendation, which hides it for 30 days
  • A recommendation quality score tracks how often accepted recommendations lead to improved ROAS
  • Recommendations are refreshed weekly (not real-time, to allow for statistical significance)
  • A notification badge shows the count of new recommendations since last review

Priority: P3 (Future) Effort: High Phase: Phase 3+ Dependencies: US-10 (performance metrics must exist first), sufficient data history (minimum 30 days)

Example Recommendations:

ProductCurrentSuggestedReason
Jordan Craig Cargo Shorts2 (Low)4 (Strong)ROAS 6.2x over last 30 days, outperforming all other priority-2 products
Generic No-Brand Tee #474 (Strong)2 (Low)ROAS 0.8x, spending $45/month with only $36 revenue
New Era Yankees Cap3 (Normal)4 (Strong)Consistent 4.5x ROAS for 60 days, strong conversion rate

Story US-12: Before/After Comparison

As a marketing manager, I want to compare performance before and after priority changes, So that I can measure the impact of my optimization decisions and report results to stakeholders.

Acceptance Criteria:

  • When a priority change is deployed, the system records a “change event” with the date and affected products
  • A comparison report shows key metrics for a configurable period before the change vs. after the change
  • Metrics compared: ROAS, total spend, total revenue, CPC, conversion rate, impressions
  • The comparison isolates the products that were affected by the change (not the entire catalog)
  • A chart overlays before and after trendlines for visual comparison
  • Statistical confidence indicator shows whether the improvement is statistically significant or within normal variance
  • Comparison reports can be exported as PDF for sharing with stakeholders
  • Historical comparisons are stored and accessible from a “Change History” view
  • The system auto-generates a comparison report 30 days after any major change event (10+ products affected)

Priority: P2 (Nice to Have) Effort: High Phase: Phase 2-3 Dependencies: US-10 (performance metrics), US-01 (change events recorded in audit trail)


3.7 Epic: Multi-Store Management

This epic covers the Enterprise tier features that allow agencies and multi-brand operators to manage multiple Shopify stores from a single AdPriority account.

Story US-20: Multi-Store Dashboard

As an agency user, I want to manage multiple stores from one dashboard, So that I can efficiently oversee all my clients’ priority scoring without logging in and out of separate accounts.

Acceptance Criteria:

  • User can link multiple Shopify stores to a single AdPriority account
  • A store switcher in the navigation allows instant switching between stores
  • An aggregate dashboard shows key metrics across all stores: total products scored, total pending syncs, total errors, total stores active
  • Each store card on the aggregate dashboard shows: store name, product count, last sync status, overall ROAS (if Google Ads connected)
  • Clicking a store card navigates to that store’s individual dashboard (identical to single-store experience)
  • User can assign team members to specific stores with role-based access (viewer, editor, admin)
  • Store-specific settings (seasons, rules, overrides) are independent between stores
  • Adding a new store follows the same OAuth flow as initial setup
  • Removing a store requires confirmation and offers a data export option before deletion
  • The aggregate dashboard highlights stores with issues (sync errors, stale rules, no recent activity)

Priority: P3 (Future) Effort: Very High Phase: Phase 3+ Dependencies: Complete single-store feature set (US-01 through US-08), multi-tenant database architecture


Story US-21: Rule Cloning Between Stores

As an agency user, I want to clone rules between stores, So that I can quickly onboard new clients by reusing proven configurations from similar stores.

Acceptance Criteria:

  • User can select a source store and export its rule configuration as a “rule template”
  • Rule templates include: category group definitions, category-to-priority mappings, seasonal matrix, tag modifier rules, new arrival boost settings
  • Rule templates do NOT include: product-specific overrides, sync settings, Google account credentials
  • User can apply a rule template to a destination store
  • When applying a template, the system shows a preview of what will change in the destination store
  • The system identifies unmappable categories (product types in the destination store that do not exist in the template) and prompts the user to map them manually
  • User can save named templates for reuse (e.g., “Fashion Retail - US” or “Sporting Goods - Seasonal”)
  • Templates are versioned; updating a template does not retroactively change stores that previously applied it
  • A template library shows all saved templates with descriptions, creation dates, and the number of stores using each

Priority: P3 (Future) Effort: High Phase: Phase 3+ Dependencies: US-20 (multi-store management), US-02 (category rules), US-03 (seasonal rules)


Story US-22: Per-Store Usage Reports

As an agency user, I want per-store usage reports for billing, So that I can accurately bill clients for the management services I provide using AdPriority.

Acceptance Criteria:

  • A reporting page shows per-store usage metrics for a configurable date range
  • Metrics include: number of priority changes made, number of sync cycles completed, number of products managed, rule changes applied, overrides set
  • Reports can be filtered by date range (monthly is the default for billing cycles)
  • Reports can be exported as CSV or PDF
  • Each report includes the store name, AdPriority plan tier, and billing period
  • An optional notes field allows the agency to add client-facing context to each report
  • Automated monthly report generation can be scheduled with email delivery
  • Reports include a visual summary chart suitable for inclusion in client presentations

Priority: P3 (Future) Effort: Medium Phase: Phase 3+ Dependencies: US-20 (multi-store management), audit trail data


3.8 Discovered Stories

During research, requirements analysis, and persona development, the following stories emerged as necessary for a complete product experience. These stories fill gaps between the explicit requirements and the actual user workflows.

Story US-30: Google Account Connection

As a store owner, I want to connect my Google account so that my custom labels sync automatically, So that I do not have to manually export and upload data to Google Merchant Center.

Acceptance Criteria:

  • A settings page provides a “Connect Google Account” button that initiates Google OAuth 2.0
  • OAuth requests only the scopes needed: Google Sheets API (for supplemental feed), and optionally Google Ads API (for Pro tier)
  • After successful authentication, the system displays the connected Google account email
  • The system automatically creates a Google Sheet in the user’s Google Drive for the supplemental feed
  • The Sheet is pre-formatted with the correct columns (id, custom_label_0 through custom_label_4)
  • Setup instructions guide the user to add the Sheet URL as a supplemental feed in GMC
  • A “Test Connection” button verifies the Sheet is accessible and writable
  • User can disconnect the Google account at any time (with warning about sync implications)
  • Token refresh is handled automatically; the user does not need to re-authenticate unless they revoke access
  • If the token expires or is revoked, a prominent warning appears on the dashboard

Priority: P0 (Must Have) Effort: High Phase: Phase 1 Dependencies: None (this is a foundational integration)


Story US-31: Dashboard Overview

As a store owner, I want to see a dashboard overview so I can quickly understand my priority distribution, So that I know at a glance whether my catalog is well-optimized and if anything needs attention.

Acceptance Criteria:

  • The dashboard is the default landing page when the app opens
  • A priority distribution chart (bar or pie) shows the count of products at each priority level (0-5)
  • A sync status summary shows: synced count, pending count, error count, last sync time
  • An upcoming changes panel lists the next seasonal transition with date and affected product count
  • Quick stats cards show: total products, products with overrides, new arrivals with active boost, products excluded (priority 0)
  • A recent activity feed shows the last 10 priority changes with timestamps and sources
  • If the user has not completed initial setup, the dashboard shows an onboarding checklist instead of metrics
  • Dashboard data loads in under 2 seconds

Priority: P0 (Must Have) Effort: Medium Phase: Phase 1 Dependencies: US-01 (products must have scores to display)


Story US-32: CSV Import/Export

As a store owner, I want to import and export priorities via CSV, So that I can make bulk changes offline or integrate with other tools in my workflow.

Acceptance Criteria:

  • An “Export” button downloads a CSV file with columns: product_id, product_title, product_type, current_priority, override_status, sync_status
  • Export respects current filters (if the user has filtered the product list, only filtered products are exported)
  • An “Import” button accepts a CSV file with at minimum: product_id and priority columns
  • Import validates the file before applying: checks for valid product IDs, valid priority values (0-5), and correct column headers
  • Import shows a preview of changes before applying, including: products found, products not found (invalid IDs), and score changes
  • User confirms the import after reviewing the preview
  • Import applies all changes as manual overrides
  • Import creates audit trail entries for each changed product
  • Import adds all changed products to the sync queue
  • Error rows are reported in a downloadable error file with reasons

Priority: P1 (Should Have) Effort: Medium Phase: Phase 2 Dependencies: US-01 (priority scoring), US-06 (manual override)


Story US-33: Sync Failure Alerts

As a store owner, I want to receive alerts when sync fails, So that I can take action before my ads run with stale or incorrect labels.

Acceptance Criteria:

  • When a sync cycle fails (Google Sheets API error, authentication issue, or Sheet access revoked), the system sends an email notification to the store owner
  • The email includes: error type, number of products affected, last successful sync timestamp, and a link to the app’s sync status page
  • An in-app banner appears on the dashboard when the last sync attempt failed
  • If sync has been failing for more than 24 hours, the banner escalates to a persistent warning
  • User can configure notification preferences: email, in-app only, or both
  • A webhook URL option allows integration with Slack or other notification tools (Pro tier)
  • Alert frequency is rate-limited (maximum one email per hour for the same error type)
  • When the sync recovers, a resolution notification is sent

Priority: P1 (Should Have) Effort: Medium Phase: Phase 2 Dependencies: US-05 (sync pipeline must exist)


Story US-34: Quick Setup (Under 5 Minutes)

As a store owner, I want to set up my store in under 5 minutes, So that I can start optimizing my ad spend immediately without a lengthy configuration process.

Acceptance Criteria:

  • After app installation, a guided onboarding flow walks the user through setup in 4 steps or fewer
  • Step 1: Connect Google account (OAuth popup, < 30 seconds)
  • Step 2: Review auto-detected category groups (system groups product types automatically, user confirms or adjusts)
  • Step 3: Choose a scoring template (e.g., “Fashion - Seasonal”, “General Retail”, “Custom”) which pre-fills the seasonal matrix and default priorities
  • Step 4: Confirm and trigger first sync
  • The entire flow is completable in under 5 minutes for a store with 500+ products
  • Progress indicators show which steps are complete
  • Users can skip steps and complete them later (dashboard shows incomplete setup items)
  • A “Quick Start” video or walkthrough is available at each step
  • If the user abandons setup mid-flow, they can resume where they left off
  • Time to first sync is tracked as a product metric

Priority: P0 (Must Have) Effort: High Phase: Phase 1 Dependencies: US-30 (Google account connection), US-02 (category rules), product import


Story US-35: Unscored Product Detection

As a marketing manager, I want to see which products have no priority assigned, So that nothing falls through the cracks and every product has a deliberate priority decision.

Acceptance Criteria:

  • A filter option shows products with no priority score (null or unassigned, distinct from priority 0 which is a deliberate exclusion)
  • The dashboard shows a count of unscored products prominently if the count is greater than zero
  • New products that arrive via webhook and do not match any category rule are flagged as “Unscored”
  • An optional setting auto-assigns unscored products a default score (configurable, default: 3)
  • A periodic check (daily) identifies any products that slipped through without scoring and flags them
  • The unscored product list includes the product type, so the user can quickly create a category rule to cover the gap

Priority: P1 (Should Have) Effort: Low Phase: Phase 2 Dependencies: US-01 (priority scoring), US-02 (category rules)


Story US-36: Tag-Based Priority Rules

As a store owner, I want tag-based rules (e.g., “clearance” = priority 0) so that existing Shopify tags drive priorities, So that I can leverage the tagging I already do in Shopify without duplicating effort in AdPriority.

Acceptance Criteria:

  • User can create rules that map Shopify product tags to priority modifiers or overrides
  • Two rule types supported: “Set to” (absolute, e.g., tag “DEAD50” sets priority to 0) and “Adjust by” (relative, e.g., tag “NAME BRAND” adds +1)
  • “Set to” rules function as overrides at the exclusion/tag level in the hierarchy
  • “Adjust by” rules are additive modifiers that apply after category and seasonal scores
  • User can set the priority order of tag rules (which tag rule wins if a product has multiple matching tags)
  • Tag rules are applied automatically when products are imported or updated
  • User can preview which products match a tag rule before activating it
  • Common tag rules are suggested during onboarding based on detected tags in the catalog (e.g., if the system finds an “archived” tag on 3,000+ products, it suggests a rule to set those to priority 0)
  • Tag rules respect the overall override hierarchy (manual overrides still win)

Priority: P1 (Should Have) Effort: Medium Phase: Phase 2 Dependencies: US-01 (priority scoring), US-02 (category rules), product import (tags must be available)

Nexus Clothing Tag Examples:

TagProductsSuggested RuleRule Type
archived3,130Set priority to 0Exclusion override
DEAD50615Set priority to 0Exclusion override
NAME BRAND2,328Adjust +1Modifier
Sale1,471Adjust -1Modifier
in-stock930Adjust +1Modifier
warning_inv_13,619Adjust -1Modifier

Story US-37: Agency Client Performance Dashboards

As an agency user, I want per-client performance dashboards, So that I can report ROI to clients and justify the value of priority management services.

Acceptance Criteria:

  • Each store in a multi-store account has its own performance dashboard (separate from the agency aggregate view)
  • Dashboards can be shared via a read-only link that does not require an AdPriority login
  • Shared links are branded with the agency’s name (or white-labeled on Enterprise tier)
  • Dashboard includes: priority distribution, ROAS by tier, before/after comparisons, and key metrics
  • Data on shared dashboards updates automatically (no manual refresh needed)
  • Shared links can be deactivated at any time
  • Dashboard includes a “Powered by AdPriority” footer (removable on white-label plans)

Priority: P3 (Future) Effort: High Phase: Phase 3+ Dependencies: US-20 (multi-store), US-10 (performance metrics), US-12 (before/after comparison)


Story US-38: Undo Priority Change

As a store owner, I want to undo a priority change, So that I can quickly revert mistakes without having to remember the previous value.

Acceptance Criteria:

  • After changing a product’s priority, an “Undo” action is available for 30 seconds (toast notification with undo button)
  • Clicking “Undo” reverts the product to its previous priority score
  • The undo action is also recorded in the audit trail
  • For bulk changes, the undo reverts the entire bulk operation (all affected products)
  • Beyond the 30-second undo window, the user can view the audit trail and manually revert by clicking on a previous score entry
  • The audit trail’s “Revert to this” action works for any historical score, not just the most recent

Priority: P2 (Nice to Have) Effort: Low Phase: Phase 2 Dependencies: US-01 (priority scoring), audit trail


Story US-39: Inventory-Aware Priority Adjustment

As a store owner, I want priorities to automatically adjust when inventory is critically low, So that I do not waste ad spend driving traffic to products that will sell out before the ads stop running.

Acceptance Criteria:

  • User can enable inventory-aware priority adjustments
  • User can set a low-inventory threshold (default: 3 units across all locations)
  • When a product’s total inventory drops below the threshold, the system reduces its priority by a configurable amount (default: -2)
  • When a product reaches zero inventory, the system sets its priority to 0 (Exclude)
  • When inventory is restocked above the threshold, the system restores the product’s rule-based priority
  • Inventory checks run at a configurable interval (default: every 6 hours, using Shopify inventory API)
  • Inventory-based adjustments respect manual overrides (if the user has overridden a product, low inventory does not change it unless the user opts in)
  • A dashboard indicator shows how many products have reduced priorities due to low inventory

Priority: P2 (Nice to Have) Effort: Medium Phase: Phase 2-3 Dependencies: US-01 (priority scoring), Shopify inventory API access (read_inventory scope)


3.9 Story Map

The story map organizes all stories by user activity (rows) and delivery phase (columns), showing the progressive expansion of functionality from MVP to full product.

STORY MAP: ACTIVITIES x PHASES
================================

                    Phase 0          Phase 1              Phase 2                Phase 3+
                    (Nexus MVP)      (SaaS Foundation)    (Full Product)         (Scale)
                    ============     ================     ================       ================

  SETUP &           Manual           US-30 Google         US-32 CSV              US-20 Multi-Store
  ONBOARDING        spreadsheet      US-34 Quick Setup    Import/Export          US-21 Rule Cloning
                    & scripts        US-31 Dashboard                             US-37 Client
                                                                                 Dashboards

  DAILY             Manual           US-01 Assign         US-07 Bulk Edit        US-11 ROAS
  PRIORITY          scoring          US-06 Manual         US-36 Tag Rules        Recommendations
  MANAGEMENT        via scripts      Override             US-35 Unscored
                                     US-38 Undo           Detection
                                                          US-39 Inventory-Aware

  RULES &           Category         US-02 Category       US-03 Seasonal         US-21 Rule Cloning
  AUTOMATION        mapping          Rules                US-04 New Arrival      (cross-store)
                    document                              Boost

  SYNC &            Manual Sheet     US-05 Sync Status    US-08 Preview          US-22 Per-Store
  MONITORING        creation         US-30 Google         US-33 Sync Alerts      Reports
                                     Connection

  ANALYTICS &       Manual ROAS      (basic sync stats    US-10 Tier Metrics     US-11 ROAS
  REPORTING         measurement      in dashboard)        US-12 Before/After     Recommendations
                    in Google Ads                                                US-37 Client
                                                                                 Dashboards

Reading the Story Map

  • Left to right: Each column represents increasing product maturity. Phase 0 is a manual validation; Phase 3+ is a fully automated, multi-tenant SaaS product.
  • Top to bottom: Each row represents a distinct user activity. The most critical activities (Setup, Daily Management) are at the top.
  • Phase 1 stories are the minimum viable product for the SaaS launch. Without these, the app cannot function.
  • Phase 2 stories are the differentiators that justify the Growth tier pricing and set AdPriority apart from manual CSV workflows.
  • Phase 3+ stories are the scale features that justify Pro/Enterprise pricing and agency adoption.

3.10 Priority Matrix

By Priority Level

PriorityLabelStory IDsCountPhase
P0 (Must Have)Core functionalityUS-01, US-02, US-05, US-06, US-30, US-31, US-347Phase 1
P1 (Should Have)Key differentiatorsUS-03, US-04, US-07, US-32, US-33, US-35, US-367Phase 2
P2 (Nice to Have)Competitive advantagesUS-08, US-10, US-12, US-38, US-395Phase 2-3
P3 (Future)Scale featuresUS-11, US-20, US-21, US-22, US-375Phase 3+

By Effort Level

EffortStory IDsImplication
LowUS-35, US-38Can be shipped as quick wins during any phase
MediumUS-01, US-06, US-07, US-04, US-08, US-31, US-32, US-33, US-36, US-22, US-39Standard sprint work, 1-2 weeks each
HighUS-02, US-03, US-05, US-10, US-11, US-12, US-20, US-21, US-30, US-34, US-37Require dedicated sprint focus, 2-4 weeks each

Priority vs. Effort Matrix

PRIORITY vs. EFFORT MATRIX
============================

              Low Effort          Medium Effort           High Effort
              ===========         =============           ===========

  P0          --                  US-01 Assign            US-02 Category Rules
  (Must)                          US-06 Override          US-05 Sync Status
                                  US-31 Dashboard         US-30 Google Connect
                                                          US-34 Quick Setup

  P1          US-35 Unscored     US-07 Bulk Edit         US-03 Seasonal Auto
  (Should)                        US-04 New Arrival
                                  US-32 CSV Import
                                  US-33 Sync Alerts
                                  US-36 Tag Rules

  P2          US-38 Undo         US-08 Preview           US-10 Tier Metrics
  (Nice)                          US-39 Inventory         US-12 Before/After

  P3          --                  US-22 Reports           US-11 Recommendations
  (Future)                                                US-20 Multi-Store
                                                          US-21 Rule Cloning
                                                          US-37 Client Dashboards

Strategic Insight: The P0 stories are a mix of medium and high effort, which is expected for foundational features. The two low-effort stories (US-35, US-38) are quick wins that can be added to any sprint without significant schedule impact. The P3 stories are predominantly high-effort, which aligns with their “future” classification – they require the mature platform that earlier phases build.


3.11 Story Dependencies

Dependency Graph

STORY DEPENDENCY GRAPH
=======================

  [Product Import from Shopify]  <-- Infrastructure prerequisite (not a story)
         |
         v
  US-01 Assign Priority Scores
         |
         +----------+-----------+-----------+
         |          |           |           |
         v          v           v           v
  US-02 Category   US-06      US-05       US-31
  Rules            Override    Sync        Dashboard
         |          |          Status
         |          |           |
         +----+-----+           +----+
         |    |                 |    |
         v    v                 v    v
  US-03 Seasonal    US-07      US-08     US-33
  Automation        Bulk Edit  Preview   Sync Alerts
         |
         v
  US-04 New Arrival Boost


  [Google OAuth]  <-- Infrastructure prerequisite
         |
         v
  US-30 Google Account Connection
         |
         v
  US-34 Quick Setup (Onboarding)


  [Google Ads API]  <-- Infrastructure prerequisite
         |
         v
  US-10 Priority Tier Metrics
         |
         +----------+
         |          |
         v          v
  US-11 ROAS       US-12 Before/After
  Recommendations  Comparison


  US-20 Multi-Store Dashboard
         |
         +----------+-----------+
         |          |           |
         v          v           v
  US-21 Rule       US-22       US-37
  Cloning          Reports     Client Dashboards

Critical Path

The critical path for the Phase 1 launch follows this sequence:

CRITICAL PATH (Phase 1)
========================

  Product Import --> US-01 --> US-02 --> US-05 --> US-30 --> US-34 --> US-31
                     Assign    Category   Sync      Google    Quick     Dashboard
                     Scores    Rules      Status    Connect   Setup

  Estimated Duration: 4-6 weeks (parallel work possible on US-30)

Parallel Tracks: US-30 (Google account connection) can be developed in parallel with US-01 and US-02 because it depends on infrastructure (OAuth), not on the scoring features. US-06 (manual override) can be built alongside US-02. US-31 (dashboard) can be started once US-01 provides data to display.

Dependency Rules

  1. No story can be deployed without US-01 – priority scoring is the foundation of every other feature
  2. No sync feature can work without US-30 – Google account connection is required for the supplemental feed pipeline
  3. No analytics feature can work without US-10 – performance tier metrics require Google Ads API data
  4. No multi-store feature can work without US-20 – the multi-store dashboard is the prerequisite for all agency features
  5. Seasonal automation (US-03) requires category rules (US-02) – seasonal scores are applied per category group

3.12 Validation with Nexus Clothing

Phase 0 uses Nexus Clothing as a live test case to validate the core stories before investing in SaaS infrastructure. Not all stories are validated in Phase 0 – only those that prove the fundamental value proposition.

Stories Validated in Phase 0

StoryPhase 0 Validation MethodSuccess Metric
US-01 Assign PrioritiesPython script assigns scores based on category mappingAll 2,425 active products scored
US-02 Category Rules20 category groups mapped from 90 product typesStore owner confirms groupings are accurate
US-03 Seasonal CalendarWinter matrix applied manually; summer matrix preparedSeasonal scores match business expectations
US-05 Sync StatusGoogle Sheet supplemental feed with manual verification100% of products matched in GMC (already 10/10 achieved)
US-36 Tag RulesTag modifiers (DEAD50, NAME BRAND, Sale) applied via scriptTag-based adjustments align with business logic

Stories Deferred to Phase 1+

StoryWhy DeferredPhase 0 Workaround
US-06 Manual OverrideNo UI in Phase 0Edit the spreadsheet directly
US-07 Bulk EditNo UI in Phase 0Python script handles bulk operations
US-30 Google ConnectSingle-user, direct Sheet accessManually created Google Sheet
US-31 DashboardNo UI in Phase 0Manual verification in GMC and Google Ads
US-34 Quick SetupSingle-user, no onboarding neededDeveloper runs scripts directly

Nexus-Specific Validation Criteria

CriterionTargetMeasurement
Products correctly scored100% of 2,425 active productsCompare script output to business expectations
Category groups accurate20 groups cover all 90 product typesStore owner review and confirmation
Seasonal matrix reasonableWinter priorities match current demandCompare to actual sales velocity data
GMC labels appliedAll active variants in supplemental feedGMC feed processing report shows 0 errors
ROAS improvement15%+ improvement within 30 daysGoogle Ads before/after comparison
Dead stock excludedAll archived/DEAD50 products at priority 0Filter verification: 3,745 products at priority 0

Success Metrics Per Story

PHASE 0 STORY VALIDATION TARGETS
==================================

  US-01 (Assign Priorities)
  -------------------------
  Target: Every active product has a score between 0 and 5
  Measurement: COUNT(*) WHERE priority IS NOT NULL = 2,425
  Validation: Distribution looks reasonable (not all products at one score)

  Expected Distribution:
    Priority 5: ~300 products  (12%)  -- Seasonal peaks, new arrivals
    Priority 4: ~500 products  (21%)  -- Strong performers, brand names
    Priority 3: ~800 products  (33%)  -- Year-round staples
    Priority 2: ~400 products  (16%)  -- Low-season, low-margin
    Priority 1: ~200 products  (8%)   -- End of season, slow movers
    Priority 0: ~225 products  (10%)  -- Out of stock, discontinued (active only)
    Note: 3,121 archived + 36 draft products also get priority 0 but are separate

  US-02 (Category Rules)
  ----------------------
  Target: 20 category groups cover all 90 product types with zero unmapped types
  Measurement: Every product_type in the catalog maps to exactly one category group
  Validation: Store owner reviews each group and confirms the mapping

  US-05 (Sync Status)
  -------------------
  Target: 100% of active variants appear in supplemental feed
  Measurement: Row count in Google Sheet matches active variant count
  Validation: GMC processing report shows matched products = total rows, errors = 0

Summary

This chapter defined four user personas, 24 user stories, and the relationships between them.

Personas: Sarah (small store owner, Starter tier), Marcus (growing brand manager, Growth tier), Diana (agency account manager, Pro/Enterprise tier), and Will (Nexus Clothing owner, Phase 0 validation). Each persona has distinct pain points and uses AdPriority differently, but all share the same core need: automating the assignment of Google Ads product priorities.

Stories: 7 P0 stories form the minimum viable product (Phase 1). 7 P1 stories deliver the seasonal automation and rules engine that differentiate AdPriority (Phase 2). 5 P2 stories add analytics and quality-of-life features (Phase 2-3). 5 P3 stories enable multi-store agency management (Phase 3+). An additional 10 discovered stories (US-30 through US-39) fill gaps in onboarding, monitoring, and workflow efficiency.

Critical Path: Product import feeds US-01 (assign priorities), which feeds US-02 (category rules), which feeds US-05 (sync status). In parallel, US-30 (Google account connection) enables the sync pipeline. Together, these form the minimum viable product that Phase 1 must deliver.

Validation: Phase 0 validates the five most critical stories (US-01, US-02, US-03, US-05, US-36) using Nexus Clothing as a live test case with 5,582 products, 90 product types, and 124,060 GMC variants. Success is defined as 100% product scoring, accurate category mapping, and a 15%+ ROAS improvement within 30 days.

Chapter 4: Market Analysis

Market Size and Opportunity

The addressable market for AdPriority sits at the intersection of two large ecosystems: Shopify merchants and Google Ads advertisers. Understanding the size and dynamics of this intersection is essential for validating the business opportunity.

Market Funnel

MARKET SIZING FUNNEL
=====================

  Total Shopify Stores Worldwide
  ~4,000,000+ stores
         |
         |  ~25% have meaningful product catalogs
         v
  Stores with 50+ Products
  ~1,000,000 stores
         |
         |  ~20% run Google Ads
         v
  Shopify Stores Using Google Ads
  ~200,000 stores
         |
         |  ~50% use Performance Max
         v
  Shopify Stores Using PMAX
  ~100,000 stores
         |
         |  ~50% have seasonal products or 100+ SKUs
         v
  ADDRESSABLE MARKET
  ~50,000 stores
         |
         |  Realistic capture rate: 0.2-1.0% in Year 1
         v
  YEAR 1 TARGET: 100-500 customers

Market Segments

SegmentSize EstimateWillingness to PayFit for AdPriority
Fashion & apparel~15,000 storesHigh ($50-200/mo)Excellent – strong seasonality
Sporting goods & outdoor~5,000 storesHigh ($50-200/mo)Excellent – seasonal demand
Home & garden~8,000 storesMedium ($30-100/mo)Good – seasonal patterns
Electronics & gadgets~10,000 storesMedium ($30-100/mo)Moderate – less seasonal
Health & beauty~7,000 storesMedium ($30-80/mo)Moderate – some seasonality
Agencies~5,000 agenciesHigh ($199+/mo per client)Excellent – manage multiple stores

Why Now

Three market forces make this the right time for AdPriority:

  1. PMAX adoption is accelerating. Google is actively migrating advertisers from legacy Shopping campaigns to Performance Max. PMAX relies on custom labels for product segmentation, creating a new and growing need for label management tools.

  2. Shopify’s ecosystem is maturing. The Shopify App Store has become the primary distribution channel for e-commerce tools, and merchants increasingly expect embedded, native app experiences rather than external platforms.

  3. Feed management is overpriced. Existing solutions start at $64/month for basic functionality and quickly escalate to $200-500/month. There is a clear pricing gap for a focused tool that does one thing well at a lower price point.


Competitor Analysis

The competitive landscape consists of two categories: enterprise feed management platforms (Feedonomics, DataFeedWatch, Channable) and Shopify-native feed apps (AdNabu, Simprosys, GoDataFeed). None are purpose-built for priority scoring.

Detailed Competitor Profiles

Feedonomics

AspectDetails
Websitefeedonomics.com
Pricing$1,000 - $5,000+/month (enterprise only)
TargetEnterprise retailers with massive catalogs (millions of SKUs)
Key featuresFull-service managed feeds, dedicated account managers, 24/7 support, 60+ countries
Custom labelsPrice segmentation, margin-based, seasonality, profitability scoring
Priority scoringNo automated scoring; manual rule configuration by their team
Seasonal automationNo built-in calendar; implemented manually per client
Shopify integrationVia feed export, not a native embedded app
StrengthsWhite-glove service, enterprise-grade reliability, handles massive catalogs
WeaknessesExtremely expensive, no free trial, steep learning curve, overkill for SMBs
Gap AdPriority fillsPrice excludes 95% of Shopify merchants; no self-service option

DataFeedWatch

AspectDetails
Websitedatafeedwatch.com
PricingShop: $64/mo (5K SKUs), Merchant: $84/mo (30K SKUs), Agency: $239/mo
TargetSMB to enterprise, agencies
Key features2,000+ channel support, AI-powered listings, rule-based custom labels, competitive price tracking
Custom labelsIF-THEN rule builder, margin-based rules, best seller identification
Priority scoringNo automated scoring; merchants must build their own rules from scratch
Seasonal automationNo built-in seasonal calendar; rules must be manually updated per season
Shopify integrationPlugin/connector, not a native embedded Shopify app
StrengthsExcellent support (86% positive reviews), 2,000+ integrations, comprehensive rule engine
WeaknessesComplex UI with steep learning curve, slow feed downloads (20-30 min reported), WooCommerce sync issues
Gap AdPriority fillsNot Shopify-native, no automated scoring algorithm, requires manual rule creation

GoDataFeed

AspectDetails
Websitegodatafeed.com
PricingLite: $39/mo (1K SKUs), Plus: ~$99/mo (5K SKUs), Pro: ~$199/mo (20K SKUs)
TargetSMB, Shopify/BigCommerce merchants
Key featuresSmart catalog management, auto inventory sync, 200+ channels
Custom labelsManual criteria-based rules through product/feeds interface
Priority scoringNo automated scoring; basic manual label assignment
Seasonal automationNo seasonal calendar or automated adjustments
Shopify integrationApp/plugin, partially native
StrengthsIntuitive interface, quick support response, 14-day guided trial
WeaknessesAggressive upselling ($299 setup fee), inconsistent documentation, billing issues reported
Gap AdPriority fillsNo automated priority scoring, manual label setup, no scoring algorithm

Channable

AspectDetails
Websitechannable.com
PricingStarting ~$119/mo, based on items/projects/channels
TargetSMB to mid-market, European focus, agencies
Key features2,500+ channels, PPC automation, AI categorization, performance segmentation
Custom labelsIF-THEN rules, performance-based segmentation, Google Analytics integration
Priority scoringNo automated scoring; rules-based manual configuration
Seasonal automationNo built-in seasonal calendar
Shopify integrationVia feed import, not a native Shopify app
StrengthsPowerful rule engine, multi-language support, responsive support team
WeaknessesExpensive at scale, lacks advanced filtering, 3-4 day email response times reported
Gap AdPriority fillsEuropean-focused, general-purpose tool, overly complex for priority use case

Shopify-Native Competitors

CompetitorPricingFocusCustom LabelsGap AdPriority Fills
AdNabuFree - $249/moAI-powered feed optimization, “Built for Shopify” certifiedBasic rule-basedNo priority scoring algorithm, feed-focused not priority-focused
Simprosys$4.99 - $8.99/moBudget-friendly Google Shopping feedMinimalExtremely basic features, no automation
MulwiVariesMulti-channel feed managementBasicFeed generation focus, not scoring

Competitive Comparison Matrix

FEATURE COMPARISON
==================

                          AdPriority  Feedonomics  DataFeedWatch  GoDataFeed  Channable  AdNabu
                          ----------  -----------  -------------  ----------  ---------  ------
Priority scoring (0-5)    AUTO        manual       manual         manual      manual     none
Seasonal automation       BUILT-IN    manual       manual         manual      manual     none
New arrival boost         AUTO        manual       manual         manual      manual     none
Category rules            yes         yes          yes            yes         yes        basic
GMC custom labels         yes         yes          yes            yes         yes        yes
PMAX optimization         PURPOSE     generic      generic        generic     generic    generic
Shopify native app        YES         no           no             partial     no         yes
Multi-channel feeds       no (v1)     60+          2,000+         200+        2,500+     multi
Managed service           no          yes          setup          $299        no         no
Starting price            $29/mo      ~$1,000/mo   $64/mo         $39/mo      $119/mo    free
Free trial                14 days     no           yes            yes         yes        yes

The Market Gap

What Competitors Are

Every competitor in this space is fundamentally a feed management tool. Their core value proposition is taking product data from one platform and formatting it for another: Shopify to Google, Shopify to Facebook, Shopify to Amazon, and so on. Custom labels are a secondary feature, one of dozens of attributes they can manipulate.

What AdPriority Is

AdPriority is a priority optimization tool. Its core value proposition is determining which products deserve the most ad spend and automatically communicating that decision to Google Ads through custom labels. Feed management is not the goal; intelligent budget allocation is.

MARKET POSITIONING
==================

  FEED MANAGEMENT TOOLS              PRIORITY OPTIMIZATION (AdPriority)
  -------------------------          ----------------------------------
  "Get your products to              "Get the RIGHT products the
   Google correctly"                  RIGHT amount of ad spend"

  Focus: Data formatting             Focus: Budget allocation
  Scope: Multi-channel               Scope: Google Ads PMAX
  Labels: One feature of many        Labels: The entire product
  Rules: Build from scratch          Rules: Pre-built scoring system
  Seasonal: Manual                   Seasonal: Automated calendar
  Audience: Feed managers            Audience: Store owners & marketers

Five Gaps AdPriority Fills

Gap 1: No Dedicated Priority Scoring Tool Exists

All competitors treat custom labels as one feature among many. Merchants must figure out their own scoring logic, build their own rules, and maintain them manually. AdPriority provides an opinionated, pre-built scoring system (0-5) that works out of the box.

Gap 2: Seasonal Automation Is Missing Everywhere

Not a single competitor offers a built-in seasonal calendar that automatically adjusts product priorities. Every existing tool requires the merchant to manually update rules when seasons change. AdPriority includes a seasonal calendar engine that transitions priorities automatically based on configurable season dates.

Gap 3: The Price Point Gap for Shopify SMBs

The market has a clear pricing gap:

PRICE POSITIONING
=================

  $1,000+/mo  |  Feedonomics (enterprise, managed service)
              |
  $500/mo     |
              |
  $200-300/mo |  DataFeedWatch Agency ($239)
              |  GoDataFeed Pro ($199)              <-- AdPriority Pro ($199)
              |                                          Undercuts with focused features
  $100-200/mo |  Channable ($119+)
              |  DataFeedWatch Merchant ($84)        <-- AdPriority Growth ($79)
              |                                          Better value for priority use case
  $50-100/mo  |  DataFeedWatch Shop ($64)
              |
  $25-50/mo   |  GoDataFeed Lite ($39)              <-- AdPriority Starter ($29)
              |                                          Lowest entry point with real features
  $0-25/mo    |  Simprosys ($4.99)
              |  AdNabu (free tier)
              |

Budget tools ($5-40/mo) offer basic feed generation with no intelligent features. Mid-tier tools ($64-239/mo) are powerful but complex and not Shopify-optimized. Enterprise tools ($1,000+/mo) are overkill for most Shopify stores. AdPriority fills the $29-199/mo range with Shopify-native, purpose-built priority intelligence.

Gap 4: PMAX-Specific Optimization

Existing tools were built for legacy Google Shopping campaigns and adapted for Performance Max. Their custom label support is generic. AdPriority is designed from the ground up for PMAX, where custom labels are the primary mechanism for product grouping and bid strategy segmentation.

Gap 5: Simplicity vs. Complexity

DataFeedWatch and Channable offer powerful rule builders, but they require significant learning curves and hours of manual configuration. A store owner who wants to “boost winter jackets and exclude summer shorts” should not need to learn a rule syntax. AdPriority provides opinionated defaults that work immediately, with customization available for those who want it.


AdPriority Differentiation

Five Competitive Advantages

#AdvantageHow AdPriority WinsCompetitor Approach
1Purpose-built for priorityPriority scoring is the entire product, not a feature buried in a menuPriority labels are one option among thousands of feed attributes
2Seasonal automationBuilt-in seasonal calendar with automatic priority rotationMerchants must manually update rules 4x per year (most do not)
3Shopify-nativeEmbedded app using Polaris UI, feels like part of Shopify adminExternal platforms with separate logins, or basic Shopify plugins
4Simple 0-5 scoringOne number per product, intuitive for non-technical usersComplex IF-THEN rule builders requiring feed management expertise
5Lower price point$29/mo starting vs $64+/mo for comparable featuresEnterprise pricing or basic-feature-only budget options

Unique Value Proposition

AdPriority is the only Shopify app that automatically scores your products for Google Ads priority, turning complex feed optimization into a one-click solution.

Supporting value propositions:

  • Profit-first scoring: Prioritize high-margin products without writing manual rules
  • PMAX-optimized: Custom labels designed specifically for Performance Max campaign success
  • Shopify-native: Seamless integration that feels like part of the Shopify admin
  • Accessible pricing: Enterprise-grade scoring intelligence at SMB prices ($29-199/month)

Positioning Statement

For Shopify merchants running Google Ads Performance Max campaigns who struggle to prioritize products for maximum profitability, AdPriority is an intelligent product scoring app that automatically calculates and applies priority scores to Google Shopping custom labels. Unlike general feed management tools that require manual rule configuration, AdPriority uses a proven scoring algorithm with seasonal automation to optimize custom labels for Performance Max success.


Understanding how PMAX campaigns use custom labels is essential to understanding why AdPriority exists.

What Performance Max Is

Performance Max (PMAX) is Google’s AI-driven campaign type that serves ads across all Google inventory: Search, Display, YouTube, Discover, Gmail, and Maps. For e-commerce, PMAX replaces legacy Smart Shopping campaigns and is now Google’s recommended campaign type for product advertising.

How PMAX Uses Custom Labels

In a PMAX campaign, the merchant creates asset groups that contain collections of products. Products are assigned to asset groups using listing group filters, which can filter on:

  • Product category (Google taxonomy)
  • Brand
  • Item ID
  • Condition
  • Custom labels (0-4)

Custom labels are the most flexible segmentation mechanism because the merchant controls what values they contain. This is where AdPriority creates value.

PMAX CAMPAIGN STRUCTURE WITH AdPriority LABELS
===============================================

  PMAX Campaign: "All Products"
  |
  +-- Asset Group: "Priority 5 - Push Hard"
  |   Filter: custom_label_0 = "priority-5"
  |   Budget: $100/day (maximum)
  |   Products: Winter jackets, trending hoodies, new arrivals
  |
  +-- Asset Group: "Priority 4 - Strong Performers"
  |   Filter: custom_label_0 = "priority-4"
  |   Budget: $60/day (high)
  |   Products: Core jeans, name-brand caps, seasonal staples
  |
  +-- Asset Group: "Priority 3 - Standard"
  |   Filter: custom_label_0 = "priority-3"
  |   Budget: $30/day (normal)
  |   Products: Year-round basics, mid-tier items
  |
  +-- Asset Group: "Priority 1-2 - Low/Minimal"
  |   Filter: custom_label_0 IN ("priority-1", "priority-2")
  |   Budget: $10/day (minimum)
  |   Products: Off-season items, low margin, slow movers
  |
  +-- [Priority 0 products EXCLUDED from all asset groups]
      Products: Dead stock, archived, out of stock

The Custom Label Advantage

Without custom labels, all products compete equally for budget within a PMAX campaign. Google’s AI optimizes for conversions, but it has no concept of seasonality, margin, or strategic priority. A pair of shorts might get clicked in January (because someone in Florida is searching), consuming budget that would generate higher returns on a winter jacket.

Custom labels give the merchant control over budget allocation without micromanaging individual products. By grouping products into priority tiers, the merchant tells Google’s AI: “Spend most of your budget on these products, some on these, and none on those.”

Why This Matters for AdPriority

The effectiveness of PMAX campaigns is directly tied to the quality of product segmentation. AdPriority automates the most impactful segmentation decision – which products should get the most budget – and delivers it through the mechanism PMAX is designed to use: custom labels.

WITHOUT AdPriority                  WITH AdPriority
==================                  ===============

  All products in one group           Products segmented by priority
  Equal budget allocation             Budget weighted to best performers
  Seasonal items get wrong budget     Seasonal priorities auto-adjust
  Dead stock wastes impressions       Dead stock excluded automatically
  New arrivals buried                 New arrivals boosted for 14 days
  Manual updates (if any)             Automated daily sync

  Result: Average ROAS                Result: Improved ROAS

Go-to-Market Strategy

Primary Distribution Channel: Shopify App Store

The Shopify App Store is the primary acquisition channel. Competitors underinvest in this channel because they are multi-platform tools. As a Shopify-native app, AdPriority has a natural advantage.

ChannelStrategyPriorityCost
Shopify App StoreSEO-optimized listing, “Google Ads priority” keywordsP0Free
Content marketingBlog posts on “product priority for Google Ads,” case studiesP1Time only
Shopify communityForums, partner directory, merchant groupsP1Time only
Integration partnershipsCo-marketing with analytics/profit tracking appsP2Revenue share
Direct salesOutreach to agencies for Pro/Enterprise tierP3Sales time

Messaging Themes

  1. “Stop guessing which products to promote”
  2. “Automatic product scoring for Google Ads”
  3. “One-click PMAX optimization”
  4. “Built for Shopify, optimized for profit”

Launch Sequence

LAUNCH SEQUENCE
===============

  Month 1-2: Nexus MVP
  - Validate on own store
  - Document results (ROAS improvement)
  - Build case study

  Month 3: Beta Launch
  - 5 beta testers (free)
  - Collect feedback
  - Iterate on UI and rules

  Month 4: Paid Launch
  - App Store submission
  - Starter + Growth tiers
  - Content marketing begins

  Month 6: Growth Phase
  - Pro tier with Google Ads integration
  - First case studies published
  - Partner outreach begins

  Month 12: Scale
  - 100+ customers target
  - Enterprise tier
  - Agency partnerships

Summary

The market for Google Ads product priority optimization is large (~50,000 addressable Shopify stores), growing (PMAX adoption accelerating), and underserved (no competitor offers purpose-built priority scoring with seasonal automation). Existing competitors are feed management tools priced for enterprises or basic feed generators without intelligent features.

AdPriority occupies a clear market gap: a Shopify-native, affordable ($29-199/month) priority scoring tool designed specifically for Performance Max campaign optimization. The competitive advantages – automated scoring, seasonal calendars, simple 0-5 scale, and native Shopify integration – address real pain points that no existing product solves.

Chapter 5: Success Criteria

Overview

Success criteria define the measurable outcomes that determine whether each phase of AdPriority development has achieved its objectives. These criteria are structured around four phases, each building on the validated results of the previous one.

This chapter also documents validation milestones that have already been achieved during the research phase, providing a foundation of confirmed technical feasibility.


Phase 0 Success: Nexus MVP

Phase 0 validates the core concept using Nexus Clothing as a live test case. The goal is to prove that priority-based custom labels measurably improve Google Ads ROAS before investing in SaaS infrastructure.

Success Criteria

#CriterionMetricStatus
P0-01Supplemental feed pipeline workingGoogle Sheet connected to GMC, products matchedACHIEVED
P0-02Custom labels appearing in GMCAll 5 labels populated on test productsACHIEVED
P0-03All active products scored2,425 active products have priority 0-5 assignedPending
P0-04Full supplemental feed deployed~15,000-20,000 variant rows in Google SheetPending
P0-05PMAX campaigns restructuredAsset groups segmented by priority scorePending
P0-06Baseline ROAS recorded30-day pre-change ROAS documented by priority tierPending
P0-07Post-change ROAS measured30-day post-change ROAS documented by priority tierPending
P0-08Measurable ROAS improvementOverall ROAS improvement of 15%+ within 30 daysPending
P0-09Process documentedComplete step-by-step guide for replicationPending
P0-10Category mapping validatedStore owner confirms 20 category groups are accuratePending

Phase 0 Definition of Done

PHASE 0 CHECKLIST
=================

  TECHNICAL
  [x] Google Sheets supplemental feed connected to GMC
  [x] Product ID format verified (shopify_US_{productId}_{variantId})
  [x] Custom labels recognized by GMC (10/10 test products matched)
  [ ] Full catalog scored (2,425 active products, ~15K-20K variants)
  [ ] Supplemental feed processing without errors

  CAMPAIGN STRUCTURE
  [ ] PMAX campaigns restructured with priority-based asset groups
  [ ] Budget allocation aligned to priority tiers
  [ ] Priority 0 products excluded from all campaigns

  MEASUREMENT
  [ ] Baseline ROAS captured (30-day pre-change period)
  [ ] Post-change ROAS captured (30-day post-change period)
  [ ] Comparison report generated

  DOCUMENTATION
  [ ] Category mapping validated with store owner
  [ ] Seasonal calendar confirmed
  [ ] Tag modifier rules confirmed
  [ ] End-to-end process documented for SaaS replication

Expected Outcomes

MetricBefore AdPriorityAfter AdPriority (Target)
Overall ROASBaseline (to be measured)+15-30% improvement
Wasted spend on priority-0 productsUnknown % of budget0% (excluded)
Seasonal product alignmentManual, infrequentAutomated, real-time
Time to update prioritiesHours per season changeZero (automated)
Products with custom labels7 (0.006% of catalog)~20,000 (all active variants)

Phase 1 Success: SaaS Foundation

Phase 1 transforms the validated Nexus MVP into an installable Shopify app that other merchants can use. The focus is on core infrastructure: authentication, data storage, and automated feed generation.

Success Criteria

#CriterionMetricTarget
P1-01Shopify app installableOAuth flow completes without errors100% success rate
P1-02App embedded in Shopify adminPolaris UI renders inside Shopify admin panelAll major browsers
P1-03Product import workingShopify products fetched and stored in database< 5 minutes for 5K products
P1-04Priority rules configurableMerchant can create/edit category rules via UIMinimum 5 rule types
P1-05Priority scores calculatedEngine calculates scores for all imported products< 30 seconds for 5K products
P1-06Google Sheet auto-generatedSheets API creates and populates supplemental feed< 2 minutes for 5K products
P1-07GMC sync confirmedLabels appear in GMC within 24 hours of Sheet update95%+ match rate
P1-08Database operationalPostgreSQL storing rules, mappings, sync stateZero data loss
P1-09Webhook processingProduct create/update/delete events handled< 5 second processing time
P1-10Beta testers onboardedReal merchants using the app5 beta testers

Phase 1 Definition of Done

PHASE 1 CHECKLIST
=================

  AUTHENTICATION
  [ ] Shopify OAuth 2.0 flow working
  [ ] Session tokens stored securely
  [ ] App installs and uninstalls cleanly
  [ ] GDPR webhooks implemented (mandatory for App Store)

  DATA LAYER
  [ ] PostgreSQL database provisioned (postgres16 container)
  [ ] Prisma schema deployed (stores, rules, products, sync_logs)
  [ ] Product import pipeline (paginated Shopify API fetch)
  [ ] Webhook handlers (products/create, products/update, products/delete)

  RULES ENGINE
  [ ] Category-to-priority mapping configurable
  [ ] Tag modifier rules configurable
  [ ] Manual override per product
  [ ] Score calculation pipeline (hierarchy: manual > tag > seasonal > category)

  SYNC PIPELINE
  [ ] Google Sheets API integration
  [ ] Automated Sheet generation with correct column structure
  [ ] Sheet sharing (public viewer access)
  [ ] Sync status tracking in database

  USER INTERFACE
  [ ] Dashboard: priority distribution, sync status, recent activity
  [ ] Product list: sortable, filterable, inline priority editing
  [ ] Rules page: category mapping, tag modifiers
  [ ] Settings: Google account connection, sync preferences

  QUALITY
  [ ] 5 beta testers actively using the app
  [ ] Zero critical bugs in production
  [ ] < 2 second page load times
  [ ] Error logging and alerting functional

Technical Targets

ComponentTargetMeasurement
API response time< 500ms (95th percentile)Server-side logging
Page load time< 2 secondsLighthouse performance score
Product import speed1,000 products/minuteTimed bulk import
Score calculation5,000 products in < 30 secondsTimed batch run
Sheet generation20,000 rows in < 2 minutesSheets API timing
Uptime99.5%Monitoring service
Error rate< 1% of API requestsError logging

Phase 2 Success: Full Product

Phase 2 adds the features that differentiate AdPriority from manual feed management: seasonal automation, a configurable rules engine UI, and the new arrival boost system. This phase also expands from beta to paid customers.

Success Criteria

#CriterionMetricTarget
P2-01Seasonal automation workingPriorities auto-adjust on season boundariesCorrect within 24 hours of season change
P2-02Seasonal calendar configurableMerchant can define custom season dates4 default seasons + custom
P2-03Rules engine UI completeVisual rule builder for category and tag rulesNo code required
P2-04New arrival boost functionalProducts < 14 days old auto-boosted to priority 5Configurable duration and target score
P2-05Bulk operations workingMerchant can select and update multiple products100+ products in one action
P2-06Priority previewMerchant can see what scores would change before applyingShows affected product count
P2-07Sync reliabilityGMC labels match expected values98%+ accuracy on reconciliation
P2-08Paying customersMerchants on paid Starter or Growth plans50+ paying customers
P2-09Customer retentionMonthly churn rate< 8%
P2-10Customer satisfactionApp Store rating4.0+ stars

Phase 2 Feature Matrix

PHASE 2 FEATURES
=================

  SEASONAL AUTOMATION
  +-----------------------------------------------+
  | Season Calendar                                |
  |                                                |
  | Winter: Dec 1 - Feb 28    Spring: Mar 1 - May 31
  | Summer: Jun 1 - Aug 31    Fall:   Sep 1 - Nov 30
  |                                                |
  | Category x Season Priority Matrix:             |
  |                                                |
  |              Winter  Spring  Summer  Fall       |
  | T-Shirts       2       4       5      3        |
  | Shorts          0       3       5      1        |
  | Hoodies        5       3       1      5        |
  | Outerwear      5       1       0      4        |
  | Headwear-Caps  3       3       3      3        |
  | Beanies        5       1       0      3        |
  |                                                |
  | [Auto-transitions on season boundaries]        |
  +-----------------------------------------------+

  RULES ENGINE
  +-----------------------------------------------+
  | Rule Builder                                   |
  |                                                |
  | IF product_type CONTAINS "Shorts"              |
  |    AND current_season = "Summer"               |
  | THEN priority = 5                              |
  |                                                |
  | IF tag INCLUDES "DEAD50"                       |
  | THEN priority = 0 (override)                   |
  |                                                |
  | IF tag INCLUDES "NAME BRAND"                   |
  | THEN priority + 1 (modifier)                   |
  |                                                |
  | IF created_at > 14 days ago                    |
  | THEN priority = 5 (new arrival boost)          |
  +-----------------------------------------------+

  NEW ARRIVAL BOOST
  +-----------------------------------------------+
  | Configuration                                  |
  |                                                |
  | Boost duration:  14 days (configurable)        |
  | Boost priority:  5 (configurable)              |
  | Decay option:    5 -> 4 -> 3 over time         |
  | Override manual: No (manual always wins)        |
  +-----------------------------------------------+

Phase 3 Success: App Store Launch

Phase 3 takes AdPriority from a working product to a publicly available Shopify app. Success at this stage is defined by App Store approval, install velocity, and sustainable growth metrics.

Success Criteria

#CriterionMetricTarget
P3-01App Store approvedPasses Shopify app reviewFirst submission or with minor revisions
P3-02App Store listing livePublished and discoverableListed under Google Ads / Marketing categories
P3-03Install velocityNew installs in first 90 days100+ installs
P3-04Conversion to paidFree trial to paid conversion20%+
P3-05Monthly churn ratePercentage of customers canceling< 5%
P3-06App Store ratingAverage review score4.5+ stars
P3-07Review countNumber of published reviews10+ in first 90 days
P3-08Support response timeTime to first response on support tickets< 4 hours business hours
P3-09Zero critical incidentsNo data loss, no incorrect label syncZero incidents
P3-10Revenue milestoneMonthly recurring revenue$5,000+ MRR

App Store Requirements Checklist

Shopify has specific requirements for app approval. Meeting these is a hard gate for Phase 3 success.

SHOPIFY APP STORE REQUIREMENTS
===============================

  MANDATORY (will not approve without these)
  [ ] OAuth 2.0 authentication (no API keys)
  [ ] GDPR webhooks: customers/data_request, customers/redact, shop/redact
  [ ] App uninstall webhook handled (cleanup user data)
  [ ] Embedded app experience (App Bridge + Polaris)
  [ ] Session token authentication (not cookie-based)
  [ ] HTTPS everywhere
  [ ] Privacy policy URL
  [ ] Clear value proposition in listing

  QUALITY REQUIREMENTS
  [ ] No broken links or dead pages
  [ ] Responsive design (works on all screen sizes)
  [ ] Loading states for async operations
  [ ] Error handling with user-friendly messages
  [ ] Onboarding flow for new installs

  LISTING REQUIREMENTS
  [ ] App name (AdPriority)
  [ ] Tagline (< 80 characters)
  [ ] Detailed description
  [ ] Screenshots (minimum 3)
  [ ] Demo video (recommended)
  [ ] Pricing displayed clearly
  [ ] Support contact information
  [ ] Category selection

Key Metrics Dashboard

These are the ongoing metrics that define AdPriority’s health and growth trajectory across all phases.

Business Metrics

MetricPhase 0Phase 1Phase 2Phase 3Measurement
Paying customers00-5 (beta)50+150+Billing system
MRR$0$0$3,000+$10,000+Stripe dashboard
ARR$0$0$36,000+$120,000+Calculated
ARPU (avg revenue per user)$60/mo$67/moMRR / customers
Monthly churn< 8%< 5%Cancellation tracking
CAC (customer acquisition cost)< $50< $40Marketing spend / new customers
LTV (lifetime value)$750+$1,340+ARPU / churn rate
LTV:CAC ratio> 10:1> 30:1Calculated
Trial conversion rate15%+20%+Trials / paid
Net Promoter Score30+50+Customer survey

Product Metrics

MetricPhase 0Phase 1Phase 2Phase 3Measurement
Products scored2,42525,000+250,000+1,000,000+Database count
Sync success rate100%95%+98%+99%+Sync logs
Score calculation timeManual< 30s/5K< 30s/5K< 30s/5KPerformance logs
API uptime99.5%99.9%99.9%Monitoring
API response time (p95)< 500ms< 500ms< 300msAPM dashboard
Webhook processing time< 5s< 3s< 2sEvent logs

Customer Impact Metrics

MetricTargetMeasurement Method
ROAS improvement15-30% within 30 days of setupBefore/after comparison in Google Ads
Time saved4+ hours per season changeCustomer survey
Products correctly labeled98%+ accuracyGMC reconciliation
Setup time< 30 minutes to first syncOnboarding funnel tracking
Time to value< 24 hours (labels in GMC)First sync timestamp

Validation Milestones Already Achieved

Before any code was written for the SaaS platform, the research phase validated several critical technical assumptions. These milestones de-risk the implementation by confirming that the core pipeline works with real data.

Milestone 1: GMC Product ID Format Verified

Date: 2026-02-10 Status: ACHIEVED

The Shopify-to-GMC product ID format was confirmed by analyzing a live GMC export of 124,060 products from the Nexus Clothing account.

VERIFIED ID FORMAT
==================

  Format:   shopify_US_{productId}_{variantId}
  Example:  shopify_US_8779355160808_46050142748904

  Components:
  - Prefix:      "shopify_US_" (constant for all US Shopify stores)
  - Product ID:  13-digit Shopify product ID
  - Separator:   "_"
  - Variant ID:  14-digit Shopify variant ID

  Verification:
  - Source: GMC TSV export (124,060 rows)
  - All IDs follow this format consistently
  - All IDs are variant-level (no product-only IDs found)
  - Country code is "US" for all Nexus products

Why this matters: The supplemental feed must use IDs that exactly match GMC’s primary feed. An incorrect format would cause zero products to match. This was the single highest-risk technical assumption.

Milestone 2: All 5 Custom Labels Available

Date: 2026-02-10 Status: ACHIEVED

Analysis of the GMC product export confirmed that all five custom label slots are effectively available for AdPriority use.

LabelCurrent StateProducts UsingAvailable for AdPriority
custom_label_0“Argonaut Nations - Converting” on 7 products7 of 124,060 (0.006%)Yes (safe to overwrite)
custom_label_1Empty0Yes
custom_label_2Empty0Yes
custom_label_3Empty0Yes
custom_label_4Empty0Yes

Why this matters: If custom labels were already in use for other purposes (e.g., another app or manual feed rules), AdPriority would need to share or negotiate label slots. Having all 5 available means the full label schema can be implemented without conflict.

Milestone 3: Supplemental Feed Pipeline Confirmed

Date: 2026-02-10 Status: ACHIEVED

A test supplemental feed of 10 real Nexus products was created as a Google Sheet, connected to GMC, and processed successfully.

SUPPLEMENTAL FEED TEST RESULTS
===============================

  Test Parameters:
  - Feed type:     Google Sheets
  - Format:        6 columns (id + 5 custom labels)
  - Sample size:   10 active Nexus products
  - Data sources:  Content API - US, Content API - Local, Local Feed Partnership

  Results:
  +----------------------------------+-----------+
  | Metric                           | Result    |
  +----------------------------------+-----------+
  | Total products in feed           | 10        |
  | Products matched in GMC          | 10 (100%) |
  | Attribute names recognized       | All       |
  | Processing errors                | None      |
  | Time to process                  | < 1 hour  |
  | Feed accepted by GMC             | Immediate |
  +----------------------------------+-----------+

  Sample Products Tested:
  +-----------------------------------+----------+--------+----------------------+
  | Product                           | Priority | Season | Category             |
  +-----------------------------------+----------+--------+----------------------+
  | New Era Colts Knit 2015           | 4        | winter | headwear-cold-weather |
  | New Era Yankees 59FIFTY           | 4        | winter | headwear-caps        |
  | G3 Patriots Hoodie                | 0        | winter | hoodies-sweatshirts  |
  | Rebel Minds Puffer Jacket         | 5        | winter | outerwear-heavy      |
  +-----------------------------------+----------+--------+----------------------+

  Conclusion: Pipeline is PRODUCTION-READY for MVP deployment.

Why this matters: This test confirmed the entire data flow from Google Sheet to GMC custom labels. The 100% match rate with zero errors validates that the ID format, column naming, and sheet sharing approach all work correctly.

Milestone 4: Nexus Catalog Analyzed

Date: 2026-02-10 Status: ACHIEVED

The complete Nexus Clothing catalog was pulled from the live Shopify Admin API, providing the data needed to design the category mapping and rules engine.

NEXUS CATALOG ANALYSIS
======================

  Overall:
  - Total products:        5,582
  - Active products:       2,425
  - Archived products:     3,121
  - Draft products:        36
  - GMC variants:          124,060

  Product Types:
  - Unique types:          90
  - Grouped into:          20 category groups
  - Top type:              Men-Tops-T-Shirts (1,101 products, 19.7%)
  - Naming convention:     {Gender}-{Department}-{SubCategory}-{Detail}

  Vendors:
  - Unique vendors:        175
  - Top vendor:            New Era (576 products)
  - Name brands:           2,328 products tagged "NAME BRAND"

  Tags:
  - Unique tags:           2,522
  - Key priority tags:     archived (3,130), DEAD50 (615),
                           NAME BRAND (2,328), Sale (1,471),
                           in-stock (930), warning_inv_1 (3,619)

  Seasonal Distribution:
  - Year-round products:   ~60% (jeans, headwear-caps, underwear, accessories)
  - Seasonal products:     ~40% (shorts, outerwear, hoodies, beanies, sandals)

Why this matters: The catalog analysis informed the design of the 20 category groups, the seasonal priority matrix, and the tag modifier rules. These are the building blocks of the rules engine and have been designed from real data rather than theoretical assumptions.

Milestone 5: GMC Specifications Documented

Date: 2026-02-10 Status: ACHIEVED

All GMC custom label specifications and constraints were researched and documented.

SpecificationValueImpact on AdPriority
Custom labels available5 (label_0 through label_4)Full schema can be implemented
Max characters per label100More than sufficient for our values
Max unique values per label1,000AdPriority uses 6 + 4 + ~20 + 5 + 3 = ~38 values total
Case sensitivityNot case-sensitiveCan use lowercase for consistency
Visibility to shoppersNot visible (internal only)No customer-facing impact
API update limit2x per day per productDaily sync schedule is within limits
Google Sheets row limit10 million cells~20K rows x 6 columns = 120K cells (1.2% of limit)

Risk Mitigation

Each phase has identified risks with planned mitigations.

Phase 0 Risks

RiskLikelihoodImpactMitigation
ROAS does not improveMediumHigh – invalidates premiseMeasure for 30 days minimum; analyze by product group not just overall
GMC feed processing delaysLowMedium – delays measurementUse manual “Update” trigger in GMC; document processing times
Category mappings inaccurateMediumMedium – wrong priorities assignedValidate with store owner before full deployment

Phase 1 Risks

RiskLikelihoodImpactMitigation
Shopify API rate limitsMediumMedium – slow importsImplement pagination, respect rate limit headers, use bulk operations
Google Sheets API quotasLowMedium – sync failuresMonitor quota usage; implement retry with exponential backoff
Beta testers unresponsiveMediumMedium – limited feedbackRecruit 8-10 to ensure 5 active; offer extended free access

Phase 2 Risks

RiskLikelihoodImpactMitigation
Seasonal transitions cause errorsMediumHigh – wrong priorities liveImplement preview mode; allow manual season override; monitor for 48 hours after transition
Rules engine too complex for usersMediumHigh – adoption frictionStart with pre-built templates; add complexity gradually
Churn exceeds 10%MediumHigh – unsustainable growthImplement onboarding flow; add in-app guidance; monthly check-in emails

Phase 3 Risks

RiskLikelihoodImpactMitigation
App Store rejectionMediumHigh – blocks launchStudy requirements in detail; test against all criteria before submission
Low install velocityMediumMedium – slow growthInvest in App Store SEO; build case studies; explore paid acquisition
Competitor copies approachLowMedium – reduced differentiationMove fast; build brand; add features competitors cannot easily replicate

Summary

Success for AdPriority is defined in concrete, measurable terms at each phase:

  • Phase 0 proves the concept works with a real store and real Google Ads campaigns. The supplemental feed pipeline is already validated (10/10 products matched). Remaining work is full catalog deployment, PMAX restructuring, and ROAS measurement.

  • Phase 1 proves the concept can be delivered as a self-service Shopify app. Success means 5 beta testers are using the app with working OAuth, product import, rules engine, and automated Google Sheets sync.

  • Phase 2 proves the product differentiators work at scale. Seasonal automation, the rules engine UI, and new arrival boost must function reliably with 50+ paying customers and less than 8% monthly churn.

  • Phase 3 proves the product can grow through the Shopify App Store. 100+ installs in 90 days, a 4.5+ star rating, less than 5% churn, and $5,000+ MRR define a successful launch.

The research phase has already de-risked the three highest-uncertainty technical questions: the GMC product ID format works, all 5 custom labels are available, and the supplemental feed pipeline processes correctly. The remaining work is execution.

Chapter 6: System Architecture

This chapter defines the high-level architecture of AdPriority, covering component decomposition, deployment topology, security boundaries, and communication patterns. Every design decision maps back to the constraints established in Part I: a single-developer build targeting the Shopify App Store, deployed on existing Synology NAS infrastructure.


4.1 High-Level Architecture

The system is composed of five primary subsystems connected by REST APIs, webhooks, and scheduled jobs. The following diagram traces the complete path from a merchant’s Shopify store through to a live Google Ads campaign.

                           MERCHANT BROWSER
                          (Shopify Admin iFrame)
                                  |
                                  | App Bridge 4.x
                                  | Session Token
                                  v
 +-----------------------------------------------------------------+
 |                      AdPriority Application                     |
 |                                                                 |
 |  +---------------------+        +----------------------------+ |
 |  |   Frontend (SPA)    |  REST  |      Backend API           | |
 |  |                     | -----> |                            | |
 |  |  React 18 + Vite    |  JSON  |  Express.js + TypeScript   | |
 |  |  Polaris v13        |        |  Prisma ORM                | |
 |  |  React Query        |        |  Zod Validation            | |
 |  +---------------------+        +---+--------+--------+------+ |
 |                                     |        |        |        |
 |                              +------+   +----+----+   |        |
 |                              |          |         |   |        |
 |                              v          v         v   v        |
 |                      +----------+ +----------+ +----------+   |
 |                      |PostgreSQL| |  Worker  | |Scheduler |   |
 |                      |    16    | | (inline) | |  (cron)  |   |
 |                      |adpriority| |          | |          |   |
 |                      |   _db    | +----+-----+ +----+-----+   |
 |                      +----------+      |            |          |
 +-----------------------------------------------------------------+
        |              |                  |            |
        |              |                  |            |
        v              v                  v            v
 +-----------+  +-----------+  +------------------+  +----------+
 |  Shopify  |  |  Shopify  |  | Google Sheets    |  |  Google  |
 |  Admin    |  | Webhooks  |  | API (v4)         |  |   Ads    |
 |  API      |  |           |  |                  |  |   API    |
 | (GraphQL) |  | products/ |  | Writes priority  |  | (future) |
 |           |  | update    |  | data to Sheet    |  |          |
 +-----------+  | app/      |  +--------+---------+  +----------+
                | uninstall |           |
                +-----------+           v
                              +------------------+
                              | Google Merchant  |
                              | Center           |
                              |                  |
                              | Fetches Sheet    |
                              | daily as         |
                              | supplemental     |
                              | feed             |
                              +--------+---------+
                                       |
                                       v
                              +------------------+
                              | Custom Labels    |
                              | Applied to       |
                              | Product Listings |
                              |                  |
                              | label_0: priority|
                              | label_1: season  |
                              | label_2: type    |
                              | label_3: status  |
                              | label_4: brand   |
                              +--------+---------+
                                       |
                                       v
                              +------------------+
                              | PMAX Campaigns   |
                              | Listing Groups   |
                              | filtered by      |
                              | custom_label_0   |
                              +------------------+

End-to-end latency budget: A priority change made in the AdPriority UI reaches a live Google Ads campaign within 24 hours (bounded by GMC’s daily supplemental feed fetch cycle).


4.2 Component Breakdown

4.2.1 Shopify Embedded App (Frontend)

The frontend runs as an embedded iFrame inside the Shopify Admin, using App Bridge 4.x for authentication and navigation.

AttributeValue
FrameworkReact 18
Build toolVite
UI libraryShopify Polaris v13
State mgmtReact Query (TanStack Query v5)
RoutingReact Router v6
ChartsChart.js (lightweight)
AuthShopify App Bridge session tokens
Bundle target< 200 KB gzipped

Key pages:

PageRoutePurpose
Dashboard/Priority distribution, sync status
Products/productsProduct list with inline priority edit
Rules/rulesRule builder and management
Calendar/calendarSeasonal priority matrix
Sync/syncSync status, history, manual trigger
Settings/settingsGoogle connection, defaults, billing

The frontend makes no direct calls to Google APIs. All external integrations are mediated by the backend.

4.2.2 Backend API (Express.js + TypeScript)

The backend serves three roles: API server for the frontend, webhook receiver for Shopify, and host for the inline worker and scheduler.

AttributeValue
RuntimeNode.js 20 LTS
FrameworkExpress.js 4.x
LanguageTypeScript 5.x (strict mode)
ORMPrisma 5.x
ValidationZod
LoggingPino (structured JSON)
Port3010 (configurable via PORT env)

Route groups:

/auth/shopify/*          Shopify OAuth install + callback
/auth/google/*           Google OAuth connect + callback
/api/v1/products/*       Product CRUD, bulk ops, import
/api/v1/rules/*          Rule CRUD, reorder, test, apply
/api/v1/seasons/*        Season CRUD, preview, transition
/api/v1/sync/*           Trigger, status, history, export
/api/v1/settings/*       Store config, label config
/api/v1/dashboard/*      Stats, activity feed
/webhooks/shopify/*      products/create, update, delete, app/uninstalled
/health                  Liveness probe

4.2.3 Database (PostgreSQL 16)

AdPriority uses the shared postgres16 container already running on the Synology NAS. A dedicated database and user provide logical isolation.

AttributeValue
Containerpostgres16 (PostgreSQL 16 Alpine)
Databaseadpriority_db
Useradpriority_user
Schemapublic
Internal port5432 (container-to-container)
External port5433 (NAS host)
Networkpostgres_default (shared bridge)
ORMPrisma (migrations, type-safe client)

Core tables (9 total):

stores              Tenant registry (one row per Shopify shop)
products            Product priority scores and GMC mapping
rules               Priority rule definitions
rule_conditions     AND/OR conditions per rule
seasons             Season date ranges per store
season_rules        Category x Season priority matrix
sync_logs           GMC sync audit trail
subscriptions       Shopify billing records
audit_logs          Change tracking for compliance

See Chapter 12 for the complete schema definition and Prisma model.

4.2.4 Worker (Inline Background Jobs)

For the MVP, background jobs run inline within the Express process using node-cron for scheduling and a simple in-memory queue for webhook-triggered work. This eliminates the need for Redis and Bull in Phase 0-2, reducing infrastructure complexity.

JobTriggerFrequency
Shopify product importManual / app installOn demand
Priority recalculationWebhook / rule changeEvent-driven
Google Sheet updatePriority change / cronHourly batch
Season transition checkCronDaily at 00:00 UTC
New arrival expiryCronDaily at 01:00 UTC
ReconciliationCronDaily at 03:00 UTC
Sync log cleanupCronWeekly (Sunday)

Future (Phase 3+): When scaling to multi-tenant SaaS, the worker will be extracted into a separate process backed by Bull + Redis for reliable job queuing, retry semantics, and concurrency control.

4.2.5 External Integrations

+---------------------+---------------------------+---------------------------+
| Integration         | MVP (Phase 0-2)           | SaaS (Phase 3+)           |
+---------------------+---------------------------+---------------------------+
| Shopify Admin API   | GraphQL for product fetch | Same                      |
| Shopify Webhooks    | products/*, app/*         | Same + orders/* (Pro)     |
| Google Sheets API   | Write priority data       | Same (Starter tier)       |
| GMC Content API     | Not used                  | Direct label updates      |
| Google Ads API      | Not used                  | Performance data (Pro)    |
| Shopify Billing API | Not used (Nexus only)     | Subscription management   |
+---------------------+---------------------------+---------------------------+

4.3 Deployment Architecture

4.3.1 Infrastructure Topology

AdPriority deploys as Docker containers on a Synology DS920+ NAS (192.168.1.26), joining the existing postgres_default network for database access. Cloudflare Tunnel provides a secure HTTPS endpoint for Shopify to reach the application without port forwarding or a static IP.

                    INTERNET
                       |
             +-------------------+
             | Cloudflare Edge   |
             | TLS termination   |
             | DDoS protection   |
             +--------+----------+
                      |
             Cloudflare Tunnel
             (encrypted tunnel)
                      |
  +-------------------------------------------------+
  |            Synology NAS (192.168.1.26)          |
  |                                                 |
  |  +-------------------------------------------+ |
  |  |        Docker Engine                       | |
  |  |                                            | |
  |  |  +----------------+   +----------------+  | |
  |  |  | adpriority     |   | postgres16     |  | |
  |  |  | (backend +     |   | (PostgreSQL 16)|  | |
  |  |  |  frontend +    |   |                |  | |
  |  |  |  worker)       |   | adpriority_db  |  | |
  |  |  |                |   | salessight_db  |  | |
  |  |  | Port: 3010     |   | shopsyncflow_db|  | |
  |  |  +-------+--------+   | stanly_db      |  | |
  |  |          |             | taskmanager_db |  | |
  |  |          |             +-------+--------+  | |
  |  |          |                     |           | |
  |  |          +----------+----------+           | |
  |  |                     |                      | |
  |  |            postgres_default                | |
  |  |            (bridge network)                | |
  |  |                                            | |
  |  |  +----------------+                       | |
  |  |  | cloudflared    |                       | |
  |  |  | (tunnel agent) |                       | |
  |  |  | Routes:        |                       | |
  |  |  | app.adpriority |                       | |
  |  |  |  .com -> :3010 |                       | |
  |  |  +----------------+                       | |
  |  +-------------------------------------------+ |
  +-------------------------------------------------+

4.3.2 Container Configuration

Development (docker-compose.yml):

services:
  adpriority:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "3010:3010"
    volumes:
      - ./backend/src:/app/backend/src
      - ./admin-ui/src:/app/admin-ui/src
    environment:
      NODE_ENV: development
      DATABASE_URL: postgresql://adpriority_user:AdPrioritySecure2026@postgres16:5432/adpriority_db
      SHOPIFY_API_KEY: ${SHOPIFY_API_KEY}
      SHOPIFY_API_SECRET: ${SHOPIFY_API_SECRET}
      GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID}
      GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET}
    networks:
      - postgres_default

networks:
  postgres_default:
    external: true

Production (docker-compose.prod.yml):

services:
  adpriority:
    image: adpriority:${VERSION:-latest}
    restart: always
    environment:
      NODE_ENV: production
      DATABASE_URL: ${DATABASE_URL}
      ENCRYPTION_KEY: ${ENCRYPTION_KEY}
    networks:
      - postgres_default
      - adpriority_internal
    healthcheck:
      test: ["CMD", "wget", "--spider", "-q", "http://localhost:3010/health"]
      interval: 30s
      timeout: 5s
      retries: 3

networks:
  postgres_default:
    external: true
  adpriority_internal:
    driver: bridge

4.3.3 Single-Container Strategy

For the MVP, the backend serves the compiled frontend as static files from a single Express process. This simplifies deployment, networking, and resource usage:

adpriority container (Node.js 20)
|
+-- Express server (:3010)
|   +-- /api/v1/*          --> API routes
|   +-- /auth/*            --> OAuth routes
|   +-- /webhooks/*        --> Shopify webhooks
|   +-- /health            --> Liveness probe
|   +-- /*                 --> Static files (React build)
|
+-- Inline worker
|   +-- node-cron jobs
|   +-- Webhook event queue
|
+-- Prisma Client
    +-- postgres16:5432/adpriority_db

This mirrors the architecture of the existing sales-page-app reference implementation at /volume1/docker/sales-page-app/, which also serves a React + Polaris frontend from a single backend process.


4.4 Security Architecture

4.4.1 Authentication Layers

+---------------------+-------------------+-------------------------------+
| Layer               | Mechanism         | Implementation                |
+---------------------+-------------------+-------------------------------+
| Shopify Merchant    | App Bridge        | Session token verification    |
| (embedded app)      | Session Token     | via Shopify library           |
+---------------------+-------------------+-------------------------------+
| Shopify API         | OAuth 2.0         | Access token stored           |
| (product data)      | Access Token      | encrypted in stores table     |
+---------------------+-------------------+-------------------------------+
| Google APIs         | OAuth 2.0         | Refresh token stored          |
| (Sheets, GMC, Ads)  | Refresh Token     | encrypted in stores table     |
+---------------------+-------------------+-------------------------------+
| Shopify Webhooks    | HMAC-SHA256       | Signature in                  |
| (event ingestion)   | Signature         | X-Shopify-Hmac-SHA256 header  |
+---------------------+-------------------+-------------------------------+
| Cloudflare Tunnel   | Tunnel Token      | Only Cloudflare can reach     |
| (network)           |                   | the backend; no open ports    |
+---------------------+-------------------+-------------------------------+

4.4.2 Credential Storage

All sensitive credentials are encrypted at rest using AES-256-GCM with a master key stored in an environment variable (ENCRYPTION_KEY), never in the database or code repository.

CredentialStorage LocationEncryption
Shopify access tokenstores.shopify_access_tokenAES-256-GCM
Google refresh tokenstores.google_refresh_tokenAES-256-GCM
Shopify API secretEnvironment variableNot in DB
Google client secretEnvironment variableNot in DB
Database passwordEnvironment variableNot in DB
Encryption master keyEnvironment variableNot in DB

4.4.3 Data Protection

ConcernMitigation
SQL injectionPrisma parameterized queries (no raw SQL)
XSSReact auto-escaping + Polaris components
CSRFShopify session tokens (stateless, per-request)
Rate limitingExpress middleware: 100 req/min API, 10 req/min sync
Webhook spoofingHMAC-SHA256 verification on every webhook
Token leakageTokens never logged; Pino redaction filters
Network exposureCloudflare Tunnel (no open ports on NAS)
Multi-tenant data leakAll queries scoped by store_id (Prisma middleware)

4.4.4 Rate Limiting

+--------------------------+----------+---------+
| Endpoint Group           | Limit    | Window  |
+--------------------------+----------+---------+
| API (authenticated)      | 100 req  | 1 min   |
| Sync trigger             | 10 req   | 1 min   |
| Bulk operations          | 5 req    | 1 min   |
| Export / download        | 5 req    | 1 min   |
| Webhooks (Shopify-signed)| No limit | --      |
+--------------------------+----------+---------+

4.5 Communication Patterns

4.5.1 Frontend to Backend (REST API)

The React frontend communicates with the Express backend via JSON REST APIs. Authentication is handled by Shopify App Bridge session tokens, which are attached as Bearer tokens to every request.

Browser (iFrame)
    |
    |  GET /api/v1/products?priority=5&page=1
    |  Authorization: Bearer <session-token>
    |
    v
Express API
    |
    |  1. Verify session token (Shopify library)
    |  2. Extract store_id from token
    |  3. Query database (scoped by store_id)
    |  4. Return JSON response
    |
    v
Browser renders Polaris components

4.5.2 Shopify to AdPriority (Webhooks)

Shopify delivers event payloads over HTTPS to registered webhook endpoints. Cloudflare Tunnel ensures the backend is reachable from Shopify’s servers.

Shopify Platform
    |
    |  POST /webhooks/shopify/products/update
    |  X-Shopify-Hmac-SHA256: <signature>
    |  Content-Type: application/json
    |
    v
Express Webhook Handler
    |
    |  1. Verify HMAC signature
    |  2. Parse product payload
    |  3. Queue for processing (inline worker)
    |  4. Return 200 OK immediately
    |
    v
Inline Worker
    |
    |  1. Look up product in database
    |  2. Check for type/tag changes
    |  3. Recalculate priority (if not locked)
    |  4. Mark product as needs_sync
    |
    v
Database updated (async Sheet sync on next hourly batch)

Registered webhooks:

TopicEndpointPurpose
products/create/webhooks/shopify/products/createApply initial priority
products/update/webhooks/shopify/products/updateRecalculate if type changes
products/delete/webhooks/shopify/products/deleteRemove from database
app/uninstalled/webhooks/shopify/app/uninstalledClean up tenant data

4.5.3 AdPriority to Google Sheets (Scheduled Sync)

The hourly sync job collects all products with pending changes and writes them to the tenant’s Google Sheet via the Sheets API (v4).

Scheduler (node-cron, hourly)
    |
    |  1. Query: SELECT * FROM products WHERE needs_sync = true
    |  2. Group by store_id
    |
    v
For each tenant:
    |
    |  3. Build row data: [gmc_id, label_0, label_1, label_2, label_3, label_4]
    |  4. Authenticate with tenant's Google OAuth refresh token
    |  5. Call sheets.spreadsheets.values.update()
    |     - Clear existing data
    |     - Write header + all product rows
    |  6. Mark products as synced in database
    |  7. Create sync_log entry
    |
    v
Google Sheet updated
    |
    |  GMC fetches automatically (daily schedule)
    |
    v
Custom labels applied to GMC products within 24 hours

4.5.4 Google Sheets to GMC (Supplemental Feed)

This is a passive integration. Once a Google Sheet is registered as a supplemental feed in GMC, Google fetches it automatically on a daily schedule. AdPriority does not interact with GMC directly in the MVP.

+------------------+        +-------------------+        +------------------+
| Google Sheet     |  PULL  | Google Merchant   |  AUTO  | Google Ads       |
| (AdPriority-     | -----> | Center            | -----> | (PMAX Campaigns) |
|  managed)        | daily  |                   |        |                  |
|                  |        | Matches id column |        | Listing groups   |
| Rows:            |        | to primary feed   |        | filter by        |
|  id              |        | products          |        | custom_label_0   |
|  custom_label_0  |        |                   |        |                  |
|  custom_label_1  |        | Applies labels    |        | Budget allocated |
|  custom_label_2  |        |                   |        | by priority tier |
|  custom_label_3  |        |                   |        |                  |
|  custom_label_4  |        |                   |        |                  |
+------------------+        +-------------------+        +------------------+

4.5.5 Cron Job Schedule

All scheduled jobs run within the backend process via node-cron.

MINUTE  HOUR  DAY  DESCRIPTION
------  ----  ---  ----------------------------------------
  0      *     *   Hourly: Sync pending products to Google Sheet
  0      0     *   Daily:  Check for season transition
  0      1     *   Daily:  Expire new arrival boosts
  0      3     *   Daily:  Reconcile Shopify products
  0      4     0   Weekly: Clean up old sync_logs (> 90 days)

4.6 Technology Stack Summary

+-------------------+---------------------+------------------------------------+
| Layer             | Technology          | Rationale                          |
+-------------------+---------------------+------------------------------------+
| Frontend          | React 18 + Vite     | Industry standard, fast builds     |
| UI Components     | Shopify Polaris v13  | Native Shopify look and feel       |
| Server State      | React Query v5       | Caching, refetching, pagination    |
| Backend           | Express.js 4.x      | Simple, mature, Shopify ecosystem  |
| Language          | TypeScript 5.x      | Type safety across full stack      |
| ORM               | Prisma 5.x          | Type-safe queries, migrations      |
| Validation        | Zod                 | Schema-first, TypeScript-native    |
| Database          | PostgreSQL 16       | Shared instance, proven, JSONB     |
| Scheduling        | node-cron           | Lightweight, no Redis dependency   |
| Logging           | Pino                | Structured JSON, fast              |
| Deployment        | Docker              | Consistent builds, easy rollback   |
| Ingress           | Cloudflare Tunnel   | Existing infra, TLS, DDoS shield   |
| Version Control   | Git + GitHub        | Standard workflow                  |
+-------------------+---------------------+------------------------------------+

4.7 Scaling Considerations

The MVP architecture is designed for a single-tenant Nexus deployment and early multi-tenant usage (up to approximately 50 tenants). The following table maps growth milestones to architectural changes.

+---------------+--------+----------------------------------------------+
| Tenants       | Phase  | Architecture Change                          |
+---------------+--------+----------------------------------------------+
| 1 (Nexus)     | 0      | Single container, inline worker, node-cron   |
+---------------+--------+----------------------------------------------+
| 2-50          | 1-2    | Same architecture, monitor DB query times    |
+---------------+--------+----------------------------------------------+
| 50-200        | 3      | Extract worker to separate container          |
|               |        | Add Redis for job queue (Bull)                |
|               |        | Add connection pooling (PgBouncer)            |
+---------------+--------+----------------------------------------------+
| 200-1000      | 4+     | Move to managed PostgreSQL (RDS/Supabase)     |
|               |        | Horizontal scaling with load balancer         |
|               |        | Content API replaces Sheets for Pro/Ent tiers |
+---------------+--------+----------------------------------------------+
| 1000+         | Future | Multi-region deployment                       |
|               |        | Database read replicas                        |
|               |        | CDN for static assets                         |
+---------------+--------+----------------------------------------------+

The key insight is that complexity is deferred, not designed away. Every scaling change listed above is additive – the core application code does not need rewriting, only the infrastructure around it.


4.8 Chapter Summary

AspectDecision
Architecture styleMonolithic backend + embedded SPA frontend
Deployment targetSingle Docker container on Synology NAS
DatabaseShared PostgreSQL 16 (postgres16 container)
External accessCloudflare Tunnel (no port forwarding)
Auth (merchants)Shopify App Bridge session tokens
Auth (Google)OAuth 2.0 with encrypted refresh tokens
GMC sync (MVP)Google Sheets supplemental feed (daily fetch)
Background jobs (MVP)Inline node-cron (no Redis/Bull)
Scaling pathExtract worker, add Redis, managed DB, multi-region

Chapter 7: Data Flow & Pipeline

This chapter traces the complete lifecycle of product data as it moves through the AdPriority system – from Shopify catalog, through priority scoring, into Google Merchant Center, and ultimately into Google Ads campaign targeting. Each pipeline stage is documented with its trigger, latency, and failure mode.


5.1 End-to-End Data Flow

The following diagram shows the full pipeline from product creation in Shopify to budget allocation in Google Ads.

+-------------+     +--------------+     +--------------+     +---------------+
|   Shopify   |     |  AdPriority  |     |   Priority   |     |    Google     |
|   Store     | --> |   Database   | --> |   Scoring    | --> |    Sheet      |
|             |     |              |     |   Engine     |     |               |
| 5,582 prods |     | products     |     | Rules +      |     | id +          |
| 124k GMC    |     | rules        |     | Seasons +    |     | custom_label_ |
| variants    |     | seasons      |     | Overrides    |     | 0 through 4   |
+------+------+     +------+-------+     +------+-------+     +-------+-------+
       |                   |                    |                      |
       |  Webhook/         |  On product        |  Hourly              |  Daily
       |  Import           |  change            |  batch               |  fetch
       v                   v                    v                      v
+-------------+     +--------------+     +--------------+     +---------------+
|  Product    |     |  Store in    |     |  Write to    |     |    GMC        |
|  payload    |     |  database    |     |  Google      |     | Supplemental  |
|  received   |     |  with rules  |     |  Sheet via   |     |    Feed       |
|             |     |  applied     |     |  Sheets API  |     |               |
+-------------+     +--------------+     +--------------+     +-------+-------+
                                                                      |
                                                              Custom labels
                                                              applied to
                                                              GMC products
                                                                      |
                                                                      v
                                                              +---------------+
                                                              |  Google Ads   |
                                                              |  PMAX         |
                                                              |  Campaigns    |
                                                              |               |
                                                              | Listing group |
                                                              | filters by    |
                                                              | custom_label_0|
                                                              +---------------+

Total pipeline latency (worst case): ~25 hours

  • Webhook delivery: seconds
  • Priority calculation: < 1 second
  • Sheet update: up to 1 hour (next hourly batch)
  • GMC fetch: up to 24 hours (daily schedule)
  • Google Ads applies labels: near-instant after GMC update

Total pipeline latency (best case): ~1 hour

  • Manual sync trigger writes to Sheet immediately
  • GMC manual fetch or recently-scheduled fetch picks up changes

5.2 MVP Flow: Google Sheets Pipeline

The MVP uses Google Sheets as the transport layer between AdPriority and GMC. This approach was validated with a 10-product test batch (10/10 matched, zero issues). See ADR-001 in Chapter 7 for the rationale.

                        AdPriority Backend
                               |
                    +----------+----------+
                    |                     |
            Priority Engine        Sheets Writer
                    |                     |
                    v                     v
            +-------------+      +----------------+
            |  products   |      | Google Sheets  |
            |  table      | ---> | API v4         |
            |             |      |                |
            | priority: 4 |      | sheets.values  |
            | needs_sync  |      | .update()      |
            +-------------+      +-------+--------+
                                         |
                    +--------------------+
                    |
                    v
            +----------------+
            | Google Sheet   |
            |                |
            | Row format:    |
            | id             | = shopify_US_{prodId}_{varId}
            | custom_label_0 | = priority-{0-5}
            | custom_label_1 | = {season}
            | custom_label_2 | = {category_group}
            | custom_label_3 | = {product_status}
            | custom_label_4 | = {brand_tier}
            +-------+--------+
                    |
                    | GMC fetches daily (pull model)
                    v
            +----------------+
            | Google         |
            | Merchant       |
            | Center         |
            |                |
            | Matches rows   |
            | by id column   |
            | to primary     |
            | feed products  |
            +----------------+

Sheet sizing for Nexus:

MetricValue
Active Shopify products~2,425
Estimated active GMC variants~15,000-20,000
Columns per row6
Total cells~120,000
Google Sheets cell limit10,000,000
Usage percentage1.2%

The Sheet comfortably handles the Nexus catalog. Even at full 124,060 variants, usage would be only 7.4% of the limit.


5.3 Future Flow: Content API Pipeline

For the SaaS product (Growth tier and above), a direct Content API integration removes the 24-hour GMC fetch delay and eliminates the Google Sheet dependency.

                        AdPriority Backend
                               |
                    +----------+----------+
                    |                     |
            Priority Engine        Content API Writer
                    |                     |
                    v                     v
            +-------------+      +---------------------+
            |  products   |      | GMC Content API     |
            |  table      | ---> | v2.1                |
            |             |      |                     |
            | priority: 4 |      | products.update()   |
            | needs_sync  |      | or custombatch()    |
            +-------------+      +----------+----------+
                                            |
                                  Direct API call
                                  (near real-time)
                                            |
                                            v
                                 +---------------------+
                                 | Google Merchant     |
                                 | Center              |
                                 |                     |
                                 | Labels applied      |
                                 | within minutes      |
                                 +---------------------+

Content API constraints:

ConstraintValue
Updates per product per dayMaximum 2
Batch sizeUp to 10,000 entries per custombatch
Rate limitingDynamic (exponential backoff required)
OAuth scope requiredhttps://www.googleapis.com/auth/content
Auth per tenantEach tenant connects their own GMC

Hybrid approach (recommended for production SaaS):

  • Google Sheets for Starter tier (simpler setup, daily sync)
  • Content API for Growth/Pro/Enterprise tiers (faster updates)

5.4 Product Sync Pipeline

5.4.1 Initial Import (App Install)

When a merchant installs AdPriority, all active products are imported from Shopify and scored.

App Install
    |
    v
1. Create tenant record in stores table
    |
    v
2. Fetch products from Shopify GraphQL API
   (paginated, 250 per page)
   Nexus: 5,582 products = ~23 pages
    |
    v
3. For each product + variant:
   +--------------------------------------------------+
   | a. Generate GMC ID:                               |
   |    shopify_US_{productId}_{variantId}              |
   |                                                    |
   | b. Determine product type mapping                  |
   |    (from category-mapping rules)                   |
   |                                                    |
   | c. Calculate initial priority:                     |
   |    - Check manual override          (priority: 1)  |
   |    - Check seasonal calendar        (priority: 2)  |
   |    - Check new arrival status       (priority: 3)  |
   |    - Check category rules           (priority: 4)  |
   |    - Apply store default            (priority: 5)  |
   |                                                    |
   | d. Determine custom labels:                        |
   |    label_0 = priority-{score}                      |
   |    label_1 = {current_season}                      |
   |    label_2 = {category_group}                      |
   |    label_3 = {product_status}                      |
   |    label_4 = {brand_tier}                          |
   |                                                    |
   | e. Insert into products table                      |
   |    (needs_sync = true)                             |
   +--------------------------------------------------+
    |
    v
4. Trigger immediate Sheet sync
   (write all products to Google Sheet)
    |
    v
5. Create sync_log entry
   (products_total, products_success, products_failed)
    |
    v
6. Return import summary to UI

Estimated import time for Nexus: 2-5 minutes (network-bound by Shopify API pagination rate limits).

5.4.2 Webhook-Driven Updates

After initial import, Shopify webhooks keep the database current.

Shopify Platform                      AdPriority Backend
      |                                      |
      |  products/create                     |
      | -----------------------------------> |
      |                                      |  1. Verify HMAC
      |                                      |  2. Parse product payload
      |                                      |  3. Generate GMC IDs for all variants
      |                                      |  4. Apply rules -> calculate priority
      |                                      |  5. INSERT into products table
      |                                      |  6. Mark needs_sync = true
      |                                      |  7. Return 200 OK
      |                                      |
      |  products/update                     |
      | -----------------------------------> |
      |                                      |  1. Verify HMAC
      |                                      |  2. Parse product payload
      |                                      |  3. Check: is priority_locked?
      |                                      |     YES -> skip recalculation
      |                                      |     NO  -> continue
      |                                      |  4. Check: did product_type change?
      |                                      |     YES -> recalculate priority
      |                                      |  5. Check: did tags change?
      |                                      |     YES -> recalculate status label
      |                                      |  6. Check: did inventory change?
      |                                      |     YES -> check for out-of-stock (priority 0)
      |                                      |  7. UPDATE products table
      |                                      |  8. Mark needs_sync = true (if changed)
      |                                      |  9. Return 200 OK
      |                                      |
      |  products/delete                     |
      | -----------------------------------> |
      |                                      |  1. Verify HMAC
      |                                      |  2. Soft-delete from products table
      |                                      |  3. Remove from next Sheet sync
      |                                      |  4. Return 200 OK
      |                                      |
      |  app/uninstalled                     |
      | -----------------------------------> |
      |                                      |  1. Verify HMAC
      |                                      |  2. Mark store as inactive
      |                                      |  3. Retain data for 30 days
      |                                      |  4. Schedule cleanup job
      |                                      |  5. Return 200 OK

5.5 Priority Recalculation Triggers

Priority scores are not static. The following events trigger a recalculation for affected products.

+---------------------------+-------------------+---------------------------+
| Trigger                   | Scope             | Mechanism                 |
+---------------------------+-------------------+---------------------------+
| Manual override           | Single product    | API call from UI          |
|                           |                   | Sets priority_locked=true |
+---------------------------+-------------------+---------------------------+
| Bulk priority update      | Selected products | API call from UI          |
|                           |                   | Batch UPDATE              |
+---------------------------+-------------------+---------------------------+
| Rule created/modified     | All matching      | Rule engine evaluates     |
|                           | products          | all products against      |
|                           |                   | new/changed rule          |
+---------------------------+-------------------+---------------------------+
| Rule deleted              | Previously        | Fallback to next          |
|                           | matched products  | applicable rule or        |
|                           |                   | store default             |
+---------------------------+-------------------+---------------------------+
| Season transition         | All products with | Cron job (daily check)    |
| (calendar date reached)   | season_rules for  | or manual trigger from UI |
|                           | that category     |                           |
+---------------------------+-------------------+---------------------------+
| New product arrives       | New product       | Webhook: products/create  |
| (Shopify webhook)         |                   | Apply new arrival boost   |
+---------------------------+-------------------+---------------------------+
| Product type/tag change   | Changed product   | Webhook: products/update  |
| (Shopify webhook)         |                   | Re-evaluate rules         |
+---------------------------+-------------------+---------------------------+
| New arrival expires       | Products past     | Cron job (daily)          |
| (boost duration ended)    | boost duration    | Revert to rule/default    |
+---------------------------+-------------------+---------------------------+
| Inventory hits zero       | Out-of-stock      | Webhook: products/update  |
|                           | product           | Set priority to 0         |
+---------------------------+-------------------+---------------------------+

5.5.1 Priority Resolution Order

When multiple sources assign a priority, the following hierarchy determines which value wins.

Priority 1 (Highest):  Manual override (priority_locked = true)
    |
    v
Priority 2:            Seasonal calendar rule
    |                  (category x current season)
    v
Priority 3:            New arrival boost
    |                  (product created within N days)
    v
Priority 4:            Category-based rule
    |                  (first matching rule by order_index)
    v
Priority 5 (Lowest):   Store default
                       (stores.default_priority, typically 3)

Resolution algorithm:

function calculatePriority(product, store, currentSeason):
    // 1. Manual override always wins
    if product.priority_locked:
        return { score: product.priority, source: "manual" }

    // 2. Check seasonal calendar
    seasonRule = findSeasonRule(product.product_type, currentSeason, store.id)
    if seasonRule exists:
        return { score: seasonRule.priority, source: "seasonal" }

    // 3. Check new arrival boost
    daysSinceCreation = daysBetween(product.created_at, now())
    if daysSinceCreation <= store.new_arrival_days:
        return { score: store.new_arrival_priority, source: "new_arrival" }

    // 4. Check category-based rules (ordered by order_index)
    matchingRule = evaluateRules(product, store.rules)
    if matchingRule exists:
        return { score: matchingRule.priority, source: "rule" }

    // 5. Fall back to store default
    return { score: store.default_priority, source: "default" }

5.6 Data Freshness Requirements

Each stage of the pipeline has different latency expectations. The table below defines the target and maximum acceptable delay for each.

+----------------------------+-----------+-------------+--------------------+
| Pipeline Stage             | Target    | Max         | Bottleneck         |
+----------------------------+-----------+-------------+--------------------+
| Shopify webhook delivery   | < 5 sec   | < 60 sec    | Shopify platform   |
+----------------------------+-----------+-------------+--------------------+
| AdPriority DB update       | < 1 sec   | < 5 sec     | Database query     |
+----------------------------+-----------+-------------+--------------------+
| Priority recalculation     | < 1 sec   | < 10 sec    | Rule engine eval   |
+----------------------------+-----------+-------------+--------------------+
| Google Sheet update        | < 1 hour  | < 2 hours   | Hourly batch job   |
| (hourly batch)             |           |             |                    |
+----------------------------+-----------+-------------+--------------------+
| GMC supplemental feed      | < 24 hrs  | < 48 hrs    | GMC daily fetch    |
| fetch (daily)              |           |             | schedule           |
+----------------------------+-----------+-------------+--------------------+
| Google Ads label impact    | < 1 hour  | < 4 hours   | Google Ads sync    |
| (after GMC update)         |           |             | with GMC           |
+----------------------------+-----------+-------------+--------------------+

Implication: Priority changes are not real-time in Google Ads. The system is designed for strategic decisions (seasonal shifts, new arrivals, rule changes) where a 24-hour propagation window is acceptable. This is consistent with how merchants manage their advertising – priorities change weekly or seasonally, not minute-by-minute.


5.7 Batch Sync Process (Detailed)

The hourly batch sync is the primary mechanism for moving data from AdPriority to Google Sheets. This section describes the process in detail.

Hourly Cron Job Fires (minute 0 of each hour)
    |
    v
1. Query pending products
   SELECT p.*, s.google_refresh_token, s.sheet_id
   FROM products p
   JOIN stores s ON p.store_id = s.id
   WHERE p.needs_sync = true
   AND s.is_active = true
   ORDER BY s.id, p.updated_at
    |
    v
2. Group by store_id
   { store_abc: [product1, product2, ...],
     store_def: [product3, product4, ...] }
    |
    v
3. For each store (sequential to respect rate limits):
   |
   +-- a. Authenticate with Google
   |      (use store's refresh token -> access token)
   |
   +-- b. Fetch ALL products for store (not just pending)
   |      (full sheet rewrite for consistency)
   |
   +-- c. Build sheet data
   |      Header: [id, custom_label_0, ..., custom_label_4]
   |      Rows:   one per active variant
   |
   +-- d. Write to Google Sheet
   |      sheets.spreadsheets.values.clear()   // clear old data
   |      sheets.spreadsheets.values.update()  // write new data
   |
   +-- e. Update database
   |      UPDATE products SET needs_sync=false, sync_status='synced',
   |                         last_synced_at=now()
   |      WHERE store_id = ? AND needs_sync = true
   |
   +-- f. Create sync_log entry
          INSERT INTO sync_logs (store_id, sync_type, status,
                                 products_total, products_success, ...)
    |
    v
4. Log summary
   "Sync complete: 3 stores, 15,234 products, 2 errors"

Error handling during sync:

ErrorResponse
Google auth token expiredRefresh token, retry once
Google Sheets API rate limitExponential backoff (1s, 2s, 4s, max 30s)
Google Sheets quota exceededSkip store, retry next cycle
Database connection lostAbort sync, alert, retry next cycle
Individual product format errorSkip product, log error, continue batch

5.8 Reconciliation Pipeline

A daily reconciliation job ensures the AdPriority database stays in sync with the Shopify catalog, catching any missed webhooks or data drift.

Daily Reconciliation (03:00 UTC)
    |
    v
1. For each active store:
   |
   +-- a. Fetch all active products from Shopify
   |      (paginated GraphQL query)
   |
   +-- b. Fetch all products from AdPriority database
   |
   +-- c. Compare:
   |      +--------------------------------------------------+
   |      | In Shopify, NOT in DB  -> New product             |
   |      |   Action: Insert, apply rules, mark needs_sync   |
   |      +--------------------------------------------------+
   |      | In DB, NOT in Shopify  -> Deleted product         |
   |      |   Action: Soft-delete, remove from next Sheet     |
   |      +--------------------------------------------------+
   |      | In both, type changed  -> Product updated         |
   |      |   Action: Recalculate priority, mark needs_sync   |
   |      +--------------------------------------------------+
   |      | In both, no changes    -> No action               |
   |      +--------------------------------------------------+
   |
   +-- d. Generate reconciliation report
   |      { new: 5, deleted: 2, updated: 8, unchanged: 2410 }
   |
   +-- e. Alert if discrepancy > 5% of catalog
          (unusual, may indicate missed webhooks)
    |
    v
2. Log reconciliation results in sync_logs

5.9 Season Transition Flow

When the calendar date crosses a season boundary, the system automatically recalculates priorities for all affected products.

Season Transition Check (daily at 00:00 UTC)
    |
    v
1. Determine current date
    |
    v
2. For each active store:
   |
   +-- a. Load store's season definitions
   |      (seasons table: name, start_month, start_day, end_month, end_day)
   |
   +-- b. Determine current season from today's date
   |
   +-- c. Compare with last known season
   |      (stored in store settings or derived from last transition log)
   |
   +-- d. If season changed:
          |
          +-- i.   Load season_rules for new season
          |        (category x priority mappings)
          |
          +-- ii.  For each product (not priority_locked):
          |        - Find matching season_rule by product_type
          |        - Update priority and priority_source='seasonal'
          |        - Mark needs_sync = true
          |
          +-- iii. Create audit_log entries for all changes
          |
          +-- iv.  Create sync_log entry:
          |        "Season transition: Winter -> Spring, 1,847 products updated"
          |
          +-- v.   Trigger immediate Sheet sync (don't wait for hourly)
          |
          +-- vi.  Send notification to merchant:
                   "Season changed to Spring. 1,847 products updated."

Example transition (Nexus, Winter to Spring on March 1):

Product Type     | Winter Priority | Spring Priority | Products Affected
-----------------+-----------------+-----------------+------------------
Jackets          |       5         |       3         |      ~120
Hoodies          |       5         |       3         |      ~85
Sweaters         |       5         |       2         |      ~40
Shorts           |       0         |       2         |      ~150
Tank Tops        |       0         |       2         |      ~60
T-Shirts         |       2         |       3         |      ~350
Jeans            |       4         |       4         |      ~200 (no change)

5.10 Data Flow Diagram: Label Values

This section documents exactly what values flow into each custom label slot.

+------------------+--------------------+------------------------------------+
| Label            | Source Function     | Possible Values                   |
+------------------+--------------------+------------------------------------+
| custom_label_0   | Priority engine     | priority-0                        |
| (Priority Score) |                     | priority-1                        |
|                  |                     | priority-2                        |
|                  |                     | priority-3                        |
|                  |                     | priority-4                        |
|                  |                     | priority-5                        |
+------------------+--------------------+------------------------------------+
| custom_label_1   | Season calendar     | winter                            |
| (Season)         |                     | spring                            |
|                  |                     | summer                            |
|                  |                     | fall                              |
+------------------+--------------------+------------------------------------+
| custom_label_2   | Category mapping    | t-shirts                          |
| (Category Group) | from product_type   | jeans-pants                       |
|                  |                     | shorts                            |
|                  |                     | outerwear-heavy                   |
|                  |                     | outerwear-light                   |
|                  |                     | hoodies-sweatshirts               |
|                  |                     | headwear-caps                     |
|                  |                     | headwear-cold-weather             |
|                  |                     | accessories                       |
|                  |                     | footwear                          |
|                  |                     | (store-configurable)              |
+------------------+--------------------+------------------------------------+
| custom_label_3   | Tags + age logic    | new-arrival                       |
| (Product Status) |                     | in-stock                          |
|                  |                     | low-inventory                     |
|                  |                     | clearance                         |
|                  |                     | dead-stock                        |
+------------------+--------------------+------------------------------------+
| custom_label_4   | Vendor + tags       | name-brand                        |
| (Brand Tier)     |                     | store-brand                       |
|                  |                     | off-brand                         |
+------------------+--------------------+------------------------------------+

GMC constraint: Each label supports a maximum of 1,000 unique values. All label schemas above are well within this limit (6 values for label_0, 4 for label_1, ~10-20 for label_2, 5 for label_3, 3 for label_4).


5.11 Chapter Summary

Pipeline StageTriggerLatencyTransport
Product ingestionInstall / WebhookSecondsShopify API/Webhook
Priority calculationEvent-driven< 1 secondIn-process
Sheet updateHourly cron< 1 hourGoogle Sheets API
GMC label applicationDaily GMC fetch< 24 hoursSupplemental feed
Google Ads impactGMC sync< 4 hoursInternal Google
ReconciliationDaily cronBackgroundShopify API + DB
Season transitionDaily cron / manualBackgroundIn-process + Sheet

The pipeline is designed around the principle that priority changes are strategic, not tactical. A 24-hour propagation window is acceptable because merchants adjust priorities on a weekly or seasonal basis, not in response to minute-by-minute market conditions. This constraint allows the MVP to use the simple, validated Google Sheets pipeline rather than the more complex Content API integration.

Chapter 8: Multi-Tenant Design

AdPriority is a Shopify app. Every Shopify app is inherently multi-tenant: each merchant who installs the app operates within their own isolated data context. This chapter defines how AdPriority implements tenant isolation, provisioning, lifecycle management, and tier-based feature gating – all within a single shared PostgreSQL database.


6.1 Isolation Model

6.1.1 Strategy: Shared Database, Tenant-Scoped Tables

AdPriority uses a single adpriority_db database on the shared postgres16 container. Every table that stores tenant data includes a store_id column referencing the stores table. There are no per-tenant databases or schemas.

+-----------------------------------------------------------------------+
|                        adpriority_db                                  |
|                                                                       |
|  stores (tenant registry)                                             |
|  +----+----------------------------+----------+-----+                 |
|  | id | shopify_shop               | plan_tier| ... |                 |
|  +----+----------------------------+----------+-----+                 |
|  | A  | nexus-clothes.myshopify.com| growth   | ... |                 |
|  | B  | acme-store.myshopify.com   | starter  | ... |                 |
|  | C  | brand-co.myshopify.com     | pro      | ... |                 |
|  +----+----------------------------+----------+-----+                 |
|                                                                       |
|  products (all tenants, scoped by store_id)                           |
|  +----+----------+----------------------+----------+--------+         |
|  | id | store_id | shopify_product_id   | priority | ...    |         |
|  +----+----------+----------------------+----------+--------+         |
|  | 1  | A        | 8779355160808        | 4        | ...    |         |
|  | 2  | A        | 9128994570472        | 5        | ...    |         |
|  | 3  | B        | 7712345678901        | 3        | ...    |         |
|  | 4  | C        | 8834567890123        | 2        | ...    |         |
|  +----+----------+----------------------+----------+--------+         |
|                                                                       |
|  rules, seasons, sync_logs, audit_logs, subscriptions ...             |
|  (all follow same store_id scoping pattern)                           |
+-----------------------------------------------------------------------+

Why shared database, not per-tenant databases?

ApproachProsCons
Per-tenant databaseStrong isolation, easy backupConnection overhead, complex migrations
Per-tenant schemaGood isolationSchema migration complexity
Shared + store_idSimple, single migration path, efficient connectionsRequires discipline in queries

For a single-developer project targeting up to 1,000 tenants in Year 1, the shared database approach provides the best trade-off between simplicity and isolation. The store_id column is indexed on every table, and Prisma middleware enforces automatic scoping.

6.1.2 Tables with Tenant Scope

Every table that stores tenant-specific data includes a store_id foreign key. The only exception is the stores table itself, which serves as the tenant registry.

+---------------------+----------+------------------------------------------+
| Table               | store_id | Description                              |
+---------------------+----------+------------------------------------------+
| stores              | (is PK)  | Tenant registry (one row per shop)       |
| products            | FK       | Product priority scores and GMC mapping  |
| rules               | FK       | Priority rule definitions                |
| rule_conditions     | via rule | Conditions (joined through rules.id)     |
| seasons             | FK       | Season date ranges                       |
| season_rules        | via season| Category x season mappings               |
| sync_logs           | FK       | Sync audit trail                         |
| subscriptions       | FK (1:1) | Billing records                          |
| audit_logs          | FK       | Change tracking                          |
+---------------------+----------+------------------------------------------+

6.1.3 Query Scoping with Prisma Middleware

All database queries are automatically scoped to the authenticated tenant using Prisma client extensions. This prevents accidental cross-tenant data access.

// database/client.ts

import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

/**
 * Creates a tenant-scoped Prisma client.
 * All queries through this client are automatically filtered by store_id.
 */
export function getTenantClient(storeId: string) {
  return prisma.$extends({
    query: {
      $allModels: {
        async findMany({ args, query }) {
          args.where = { ...args.where, storeId };
          return query(args);
        },
        async findFirst({ args, query }) {
          args.where = { ...args.where, storeId };
          return query(args);
        },
        async findUnique({ args, query }) {
          // findUnique uses unique fields; add storeId check post-query
          const result = await query(args);
          if (result && (result as any).storeId !== storeId) {
            return null; // Tenant mismatch
          }
          return result;
        },
        async create({ args, query }) {
          args.data = { ...args.data, storeId };
          return query(args);
        },
        async update({ args, query }) {
          args.where = { ...args.where, storeId } as any;
          return query(args);
        },
        async delete({ args, query }) {
          args.where = { ...args.where, storeId } as any;
          return query(args);
        },
        async updateMany({ args, query }) {
          args.where = { ...args.where, storeId };
          return query(args);
        },
        async deleteMany({ args, query }) {
          args.where = { ...args.where, storeId };
          return query(args);
        },
      },
    },
  });
}

Usage in route handlers:

// api/routes/products.ts

router.get('/products', async (req, res) => {
  const storeId = req.session.storeId; // From Shopify session token
  const db = getTenantClient(storeId);

  // This query is automatically scoped to the tenant.
  // No need to manually add WHERE store_id = ?
  const products = await db.product.findMany({
    where: { syncStatus: 'pending' },
    orderBy: { priority: 'desc' },
    take: 50,
  });

  res.json({ products });
});

6.2 Shopify as Natural Multi-Tenancy

The Shopify app model provides built-in multi-tenancy primitives that AdPriority leverages.

Shopify App Store
    |
    | Merchant clicks "Add app"
    v
+-------------------+
| Shopify OAuth     |
| Install Flow      |
|                   |
| 1. Consent screen |
| 2. Permissions    |
| 3. Callback       |
+--------+----------+
         |
         | shop = acme-store.myshopify.com
         | access_token = shpat_xxxxx
         v
+-------------------+
| AdPriority        |
| /auth/callback    |
|                   |
| UPSERT stores     |
| SET shop, token   |
+-------------------+

How Shopify enforces tenant boundaries:

MechanismWhat It Provides
OAuth install flowEach shop gets a unique access token
Session tokensApp Bridge embeds shop identity in every request
Webhook headersX-Shopify-Shop-Domain identifies the source shop
App scopesToken permissions are per-shop
Billing APICharges are per-shop

AdPriority maps the Shopify shop domain (e.g., nexus-clothes.myshopify.com) to a row in the stores table. The stores.id UUID then serves as the store_id foreign key across all other tables.


6.3 Tenant Provisioning

6.3.1 Install Flow

When a merchant installs AdPriority from the Shopify App Store, the following provisioning sequence executes automatically.

Step  Action                                   Table/System
----  ---------------------------------------- -------------------
  1   Merchant clicks "Add app" in Shopify     Shopify App Store
      |
  2   Shopify redirects to /auth/shopify       AdPriority backend
      with shop domain + auth code
      |
  3   Exchange auth code for access token      Shopify OAuth API
      |
  4   UPSERT store record:                     stores table
      - shopify_shop = 'acme.myshopify.com'
      - shopify_access_token = encrypted(token)
      - plan_tier = 'starter' (default)
      - default_priority = 3
      - new_arrival_priority = 5
      - new_arrival_days = 14
      - is_active = true
      - installed_at = now()
      |
  5   Register Shopify webhooks:               Shopify Webhooks API
      - products/create
      - products/update
      - products/delete
      - app/uninstalled
      |
  6   Create default seasons:                  seasons table
      - Winter (Dec 1 - Feb 28)
      - Spring (Mar 1 - May 31)
      - Summer (Jun 1 - Aug 31)
      - Fall (Sep 1 - Nov 30)
      |
  7   Queue product import job:                Inline worker
      - Fetch all products from Shopify
      - Apply default priority (3)
      - Store in products table
      |
  8   Redirect to app dashboard:               Frontend (React)
      - Show onboarding wizard
      - Step 1: Connect Google account
      - Step 2: Configure category rules
      - Step 3: Review priorities
      - Step 4: Connect Google Sheet to GMC

6.3.2 Provisioning Timing

StepDurationBlocking?
OAuth exchange< 2 secondsYes
Store record creation< 100 msYes
Webhook registration< 3 secondsYes
Default season creation< 100 msYes
Product import2-5 min (Nexus)No (async)
Total (blocking)< 6 seconds

The merchant sees the dashboard within 6 seconds of completing OAuth. Product import runs in the background, with a progress indicator on the dashboard.


6.4 Google Integration Per Tenant

Each tenant connects their own Google account. AdPriority never shares Google credentials or Sheet access between tenants.

+------------------+     +------------------+     +------------------+
|  Tenant A        |     |  Tenant B        |     |  Tenant C        |
|  (Nexus)         |     |  (Acme)          |     |  (BrandCo)       |
+------------------+     +------------------+     +------------------+
| Google Account:  |     | Google Account:  |     | Google Account:  |
| will@nexus.com   |     | ads@acme.com     |     | team@brandco.com |
|                  |     |                  |     |                  |
| GMC Merchant ID: |     | GMC Merchant ID: |     | GMC Merchant ID: |
| 123456789        |     | 987654321        |     | 456789123        |
|                  |     |                  |     |                  |
| Google Sheet:    |     | Google Sheet:    |     | Google Sheet:    |
| (own sheet)      |     | (own sheet)      |     | (own sheet)      |
|                  |     |                  |     |                  |
| Refresh Token:   |     | Refresh Token:   |     | Refresh Token:   |
| encrypted(xxx)   |     | encrypted(yyy)   |     | encrypted(zzz)   |
+------------------+     +------------------+     +------------------+

Per-tenant Google configuration (stored in stores table):

FieldPurpose
gmc_merchant_idIdentifies their Merchant Center account
google_refresh_tokenLong-lived OAuth refresh token (encrypted)
google_token_expiryWhen the current access token expires
sheet_idGoogle Sheet spreadsheet ID for their feed

Google OAuth flow per tenant:

Tenant clicks "Connect Google" in Settings
    |
    v
Redirect to Google OAuth consent
(scope: spreadsheets + content API)
    |
    v
Google returns auth code to /auth/google/callback
    |
    v
Exchange for refresh token + access token
    |
    v
Store encrypted refresh token in stores table
    |
    v
Tenant selects or creates Google Sheet
    |
    v
Store sheet_id in stores table
    |
    v
Google connection complete

6.5 Tier Limits and Feature Gating

6.5.1 Tier Definitions

+------------------+----------+-----------+----------+--------------+
| Feature          | Starter  | Growth    | Pro      | Enterprise   |
|                  | $29/mo   | $79/mo    | $199/mo  | Custom       |
+------------------+----------+-----------+----------+--------------+
| Product limit    | 500      | Unlimited | Unlimited| Unlimited    |
| Priority scoring | Yes      | Yes       | Yes      | Yes          |
| GMC sync         | Daily    | Hourly    | 15 min   | Real-time    |
| Category rules   | Basic    | Advanced  | Advanced | Advanced     |
| Seasonal calendar| No       | Yes       | Yes      | Yes          |
| New arrival boost| No       | Yes       | Yes      | Yes          |
| Google Ads data  | No       | No        | Yes      | Yes          |
| AI recommends    | No       | No        | Yes      | Yes          |
| Multi-store      | No       | No        | No       | Yes          |
| API access       | No       | No        | No       | Yes          |
+------------------+----------+-----------+----------+--------------+

6.5.2 Feature Gating Implementation

Feature availability is checked at the API layer using middleware.

// api/middleware/tierGate.ts

const TIER_FEATURES: Record<string, string[]> = {
  starter:    ['products', 'rules_basic', 'sync_daily', 'export'],
  growth:     ['products', 'rules_advanced', 'sync_hourly', 'export',
               'seasonal', 'new_arrival'],
  pro:        ['products', 'rules_advanced', 'sync_frequent', 'export',
               'seasonal', 'new_arrival', 'google_ads', 'ai_recommendations'],
  enterprise: ['products', 'rules_advanced', 'sync_realtime', 'export',
               'seasonal', 'new_arrival', 'google_ads', 'ai_recommendations',
               'multi_store', 'api_access'],
};

export function requireFeature(feature: string) {
  return (req: Request, res: Response, next: NextFunction) => {
    const store = req.session.store;
    const allowed = TIER_FEATURES[store.planTier] || [];

    if (!allowed.includes(feature)) {
      return res.status(403).json({
        error: {
          code: 'PLAN_LIMIT',
          message: `This feature requires the Growth plan or higher.`,
          currentPlan: store.planTier,
          requiredFeature: feature,
        },
      });
    }

    next();
  };
}

// Usage in routes:
router.get('/seasons', requireFeature('seasonal'), seasonsController.list);
router.post('/sync/trigger', requireFeature('sync_hourly'), syncController.trigger);

6.5.3 Product Limit Enforcement

The Starter tier limits tenants to 500 products. This is enforced during product import and webhook processing.

// services/products/importService.ts

async function importProduct(storeId: string, shopifyProduct: any) {
  const store = await db.store.findUnique({ where: { id: storeId } });

  // Check product limit for Starter tier
  if (store.planTier === 'starter') {
    const count = await db.product.count({ where: { storeId } });
    if (count >= store.productsLimit) {
      return {
        success: false,
        error: 'PRODUCT_LIMIT_REACHED',
        message: `Starter plan is limited to ${store.productsLimit} products. Upgrade to Growth for unlimited.`,
      };
    }
  }

  // Continue with import...
}

6.5.4 Sync Frequency by Tier

+------------------+---------------------+------------------------------+
| Tier             | Sync Mechanism      | Cron Schedule                |
+------------------+---------------------+------------------------------+
| Starter          | Google Sheet, daily  | 0 3 * * *   (once at 3 AM)  |
| Growth           | Google Sheet, hourly | 0 * * * *   (every hour)    |
| Pro              | Content API, 15 min  | */15 * * * * (every 15 min) |
| Enterprise       | Content API, on-demand| Event-driven (real-time)    |
+------------------+---------------------+------------------------------+

6.6 Tenant Lifecycle

6.6.1 State Machine

                    +------------+
                    |            |
          install   |  INSTALL   |
       +----------->|            |
       |            +-----+------+
       |                  |
       |            provision tenant
       |                  |
       |            +-----v------+
       |            |            |
       |            |   SETUP    |  Onboarding wizard
       |            |            |  (connect Google, configure rules)
       |            +-----+------+
       |                  |
       |            complete setup
       |                  |
       |            +-----v------+
       |            |            |
       |            |   TRIAL    |  14-day free trial (Growth features)
       |            |            |
       |            +--+---+--+--+
       |               |   |  |
       |         subscribe |  | trial expires
       |               |   |  | (no payment)
       |               v   |  v
       |      +--------+   |  +-----------+
       |      |        |   |  |           |
       |      | ACTIVE |   |  |  LIMITED  |  View-only, no sync
       |      |        |   |  |           |
       |      +--+--+--+   |  +-----+-----+
       |         |  |       |        |
       |  upgrade|  |downgrade       | subscribe
       |         |  |       |        |
       |         v  v       |        v
       |      (tier change) |   +----+------+
       |                    |   |           |
       |                    +-->|  ACTIVE   |
       |                        |           |
       |                        +-----+-----+
       |                              |
       |                        uninstall
       |                              |
       |                        +-----v------+
       |                        |            |
       |                        | UNINSTALLED|  Data retained 30 days
       |                        |            |
       |                        +-----+------+
       |                              |
       |                        30 days
       |                              |
       |                        +-----v------+
       |                        |            |
       |          reinstall     |   PURGED   |  Data permanently deleted
       +------------------------            |
                                +------------+

6.6.2 Lifecycle Events

EventAction
InstallCreate store record, register webhooks, import products, start trial
Setup completeMark onboarding_complete=true, begin trial timer
Trial expiresIf subscribed: activate plan. If not: enter Limited mode
SubscribeCreate Shopify recurring charge, activate plan tier
UpgradeUpdate plan_tier, unlock features immediately
DowngradeUpdate plan_tier at end of billing cycle
UninstallMark is_active=false, stop sync, retain data 30 days
ReinstallIf within 30 days: restore data. If after: fresh start
PurgeDELETE all tenant data (products, rules, seasons, logs)

6.6.3 Uninstall and Data Retention

When a merchant uninstalls AdPriority, data is retained for 30 days to allow easy restoration if they reinstall. After 30 days, a cleanup job permanently deletes all tenant data.

app/uninstalled webhook received
    |
    v
1. Mark store as inactive
   UPDATE stores SET is_active = false, uninstalled_at = now()
   WHERE shopify_shop = 'acme.myshopify.com'
    |
    v
2. Stop all sync jobs for this tenant
   (hourly sync skips inactive stores)
    |
    v
3. Revoke Google OAuth tokens (if connected)
    |
    v
4. Clear Google Sheet data (remove rows)
   (GMC will drop labels on next fetch)
    |
    v
5. Schedule purge job for 30 days from now
    |
    v
--- 30 days later ---
    |
    v
6. Purge job executes:
   DELETE FROM audit_logs   WHERE store_id = ?;
   DELETE FROM sync_logs    WHERE store_id = ?;
   DELETE FROM products     WHERE store_id = ?;
   DELETE FROM rule_conditions WHERE rule_id IN
     (SELECT id FROM rules WHERE store_id = ?);
   DELETE FROM rules        WHERE store_id = ?;
   DELETE FROM season_rules WHERE season_id IN
     (SELECT id FROM seasons WHERE store_id = ?);
   DELETE FROM seasons      WHERE store_id = ?;
   DELETE FROM subscriptions WHERE store_id = ?;
   DELETE FROM stores       WHERE id = ?;
    |
    v
7. Log purge completion

Reinstall within 30 days:

Merchant reinstalls app
    |
    v
OAuth callback detects existing store record (is_active = false)
    |
    v
1. Reactivate store:
   UPDATE stores SET is_active = true, uninstalled_at = null
    |
    v
2. Update access token (new OAuth token)
    |
    v
3. Re-register webhooks
    |
    v
4. Cancel scheduled purge job
    |
    v
5. Trigger product reconciliation
   (sync with current Shopify catalog)
    |
    v
6. Resume sync jobs
    |
    v
All previous rules, seasons, and configuration restored

6.7 Data Isolation Verification

6.7.1 Automated Tests

The test suite includes multi-tenant isolation tests that verify no cross-tenant data leakage occurs.

// tests/integration/tenant-isolation.test.ts

describe('Tenant Isolation', () => {
  const storeA = 'store-a-uuid';
  const storeB = 'store-b-uuid';

  beforeAll(async () => {
    // Seed products for both tenants
    await db.product.create({ data: { storeId: storeA, title: 'A-Product', priority: 5, ... } });
    await db.product.create({ data: { storeId: storeB, title: 'B-Product', priority: 3, ... } });
  });

  test('Tenant A cannot see Tenant B products', async () => {
    const clientA = getTenantClient(storeA);
    const products = await clientA.product.findMany();

    expect(products).toHaveLength(1);
    expect(products[0].title).toBe('A-Product');
    expect(products.some(p => p.title === 'B-Product')).toBe(false);
  });

  test('Tenant B cannot update Tenant A products', async () => {
    const clientB = getTenantClient(storeB);
    const productA = await db.product.findFirst({ where: { storeId: storeA } });

    const result = await clientB.product.updateMany({
      where: { id: productA.id },
      data: { priority: 0 },
    });

    expect(result.count).toBe(0); // No rows updated (store_id mismatch)
  });

  test('Bulk operations respect tenant scope', async () => {
    const clientA = getTenantClient(storeA);
    const deleted = await clientA.product.deleteMany({});

    // Should only delete Tenant A's products
    expect(deleted.count).toBe(1);

    // Tenant B's product should still exist
    const bProducts = await db.product.findMany({ where: { storeId: storeB } });
    expect(bProducts).toHaveLength(1);
  });
});

6.7.2 Database-Level Safeguards

In addition to application-level scoping, the database schema provides structural safeguards.

-- Foreign key CASCADE ensures tenant deletion is complete
-- (no orphaned records from other tenants)
ALTER TABLE products
  ADD CONSTRAINT fk_products_store
  FOREIGN KEY (store_id) REFERENCES stores(id)
  ON DELETE CASCADE;

-- Composite unique constraints prevent cross-tenant collisions
-- (same Shopify product ID can exist in multiple tenants)
CREATE UNIQUE INDEX idx_products_tenant_shopify
  ON products(store_id, shopify_product_id, shopify_variant_id);

-- All indexed queries include store_id for partition-like performance
CREATE INDEX idx_products_store_priority
  ON products(store_id, priority);

CREATE INDEX idx_products_store_sync
  ON products(store_id, sync_status);

6.8 Chapter Summary

AspectDesign Decision
Isolation modelShared database, store_id column on all tables
Tenant identityShopify shop domain, mapped to stores.id UUID
Query scopingPrisma client extension (automatic store_id filter)
Google credentialsPer-tenant OAuth, encrypted refresh tokens
Feature gatingMiddleware checks plan_tier against feature map
Product limitsEnforced at import and webhook processing
Sync frequencyTied to tier (daily, hourly, 15-min, real-time)
Uninstall handlingSoft delete, 30-day retention, then purge
Reinstall handlingRestore data if within 30-day window
Isolation testingAutomated integration tests for cross-tenant leaks

Chapter 9: Architecture Decision Records

This chapter documents the key architectural decisions made during AdPriority’s design phase. Each decision follows a standardized format: Status, Context, Decision, and Consequences. These records serve as a permanent log of why the system is built the way it is, preventing future re-litigation of settled questions and providing onboarding context for new contributors.


ADR Index

ADRTitleStatus
001Google Sheets for MVP sync (not Content API)Accepted
002Express.js over FastAPIAccepted
003Prisma ORMAccepted
004Supplemental feed (not primary feed)Accepted
0050-5 scoring scaleAccepted
006Cloudflare Tunnel for deploymentAccepted
007Variant-level IDs in GMCAccepted

ADR-001: Google Sheets for MVP Sync (Not Content API)

Status: Accepted

Date: 2026-02-10

Context

AdPriority needs to write custom label data to Google Merchant Center so that Google Ads campaigns can use those labels for product segmentation and bid optimization. There are two mechanisms to deliver custom label data to GMC:

  1. Google Sheets supplemental feed: AdPriority writes data to a Google Sheet via the Sheets API. GMC is configured to fetch that Sheet on a daily schedule as a supplemental feed. Labels are applied to matching products automatically.

  2. Content API for Shopping (v2.1): AdPriority calls the GMC Content API directly to update the customLabel0 through customLabel4 fields on each product. Updates take effect within minutes.

The Content API offers faster propagation but introduces significant complexity: OAuth consent for GMC access, per-product rate limits (2 updates/day/product), error handling for 124,060 variants, and merchant-side GMC configuration.

Decision

Use Google Sheets as the sole transport mechanism for the MVP (Phase 0-2).

Consequences

Positive:

  • No GMC API authentication required from the merchant (simpler onboarding)
  • Sheet is human-readable and debuggable (merchants can inspect the data)
  • Proven with live Nexus data: 10/10 test products matched, zero issues
  • Google Sheets API is well-documented and has generous quotas
  • Merchants can manually edit individual rows in emergencies
  • No rate limit concerns (Sheet can contain all 124,060 variants)

Negative:

  • Labels propagate within 24 hours (not real-time)
  • Merchant must manually register the Sheet as a supplemental feed in GMC (one-time setup, documented in onboarding wizard)
  • Full sheet rewrite on each sync (acceptable for < 200k rows)
  • Requires Google Sheets API OAuth (lighter than Content API OAuth)

Mitigations:

  • Content API will be added in Phase 3+ for Growth/Pro tiers
  • 24-hour latency is acceptable because priority changes are strategic (seasonal, weekly) not tactical (minute-by-minute)
  • Onboarding wizard provides step-by-step GMC feed setup instructions

Validation

TestResult
Sample size10 active Nexus products
Match rate10/10 (100%)
Attribute recognitionAll 5 custom labels recognized
Processing time< 1 hour after manual GMC fetch trigger
Issues foundNone
Test date2026-02-10

ADR-002: Express.js Over FastAPI

Status: Accepted

Date: 2026-02-06

Context

The backend framework must serve the REST API, handle Shopify OAuth, process webhooks, run scheduled jobs, and serve the compiled React frontend. Two candidates were evaluated:

  1. Express.js + TypeScript: The dominant Node.js web framework. Matches Shopify’s official app templates, which use Node.js. The existing reference app at /volume1/docker/sales-page-app/ uses FastAPI (Python), but its Shopify integration required custom adapter code.

  2. FastAPI (Python): High-performance async framework. Strong typing via Pydantic. The existing sales-page-app uses this stack, providing a reference implementation.

Decision

Use Express.js with TypeScript for the AdPriority backend.

Consequences

Positive:

  • Shopify’s official @shopify/shopify-app-express package provides production-ready OAuth, session management, and webhook verification
  • Single language (TypeScript) across frontend and backend reduces context switching
  • Prisma ORM has first-class TypeScript support with generated types
  • React frontend build outputs static files that Express serves natively
  • node-cron and Bull (future) are mature job scheduling solutions
  • NPM ecosystem has packages for Google Sheets API, Google OAuth, and Content API
  • Shopify Polaris tooling (CLI, React bindings) assumes a JavaScript ecosystem

Negative:

  • Cannot reuse FastAPI code from the existing sales-page-app
  • Node.js single-threaded model requires careful handling of CPU-intensive work (not a concern for this I/O-bound application)
  • Express.js 4.x requires manual async error handling (mitigated by express-async-errors package)

Alternatives Rejected:

AlternativeReason for Rejection
FastAPIShopify ecosystem friction, separate language from FE
NestJSUnnecessary abstraction for a single-developer project
HonoNewer, smaller ecosystem, less Shopify community
Remix/Next.jsFull-stack frameworks add complexity without benefit

ADR-003: Prisma ORM

Status: Accepted

Date: 2026-02-06

Context

AdPriority requires a database access layer that provides:

  1. Type-safe queries (prevent runtime SQL errors)
  2. Migration management (versioned schema changes)
  3. Multi-tenant middleware support (automatic store_id scoping)
  4. PostgreSQL compatibility
  5. Reasonable learning curve for a single developer

Three ORMs were evaluated: Prisma, Drizzle, and Knex.

Decision

Use Prisma 5.x as the ORM for AdPriority.

Consequences

Positive:

  • Type safety: Generated TypeScript types from schema definition. Query results are fully typed; field name typos are compile-time errors.
  • Schema-as-code: schema.prisma serves as the single source of truth for the database schema. Human-readable, version-controlled.
  • Migrations: prisma migrate dev generates SQL migration files automatically from schema changes. Migration history is tracked.
  • Multi-tenant middleware: Prisma Client Extensions support the getTenantClient() pattern described in Chapter 6, enabling automatic store_id scoping on all queries.
  • Tooling: prisma studio provides a GUI database browser for debugging. prisma generate regenerates the client after schema changes.
  • Ecosystem: Widely adopted, extensive documentation, active community.

Negative:

  • Prisma’s query engine adds a binary dependency (~20 MB) to the Docker image.
  • Complex raw SQL queries (e.g., window functions, CTEs) require prisma.$queryRaw, losing some type safety.
  • N+1 query patterns require explicit include statements (not automatically optimized).
  • Schema changes require running prisma migrate dev, which may conflict with prisma db push if used inconsistently (project will use migrations exclusively).

Alternatives Rejected:

AlternativeReason for Rejection
DrizzleBetter SQL-level control but weaker migration tooling; less mature middleware extension API
KnexQuery builder only (no ORM layer), manual type definitions, no middleware pattern
TypeORMDecorator-heavy, historically buggy, declining community
Raw SQLNo type safety, manual migration management, high maintenance burden

Schema Example

model Product {
  id                  String   @id @default(uuid())
  storeId             String   @map("store_id")
  store               Store    @relation(fields: [storeId], references: [id], onDelete: Cascade)
  shopifyProductId    BigInt   @map("shopify_product_id")
  shopifyVariantId    BigInt?  @map("shopify_variant_id")
  title               String?
  productType         String?  @map("product_type")
  priority            Int      @default(3)
  prioritySource      String   @default("default") @map("priority_source")
  priorityLocked      Boolean  @default(false) @map("priority_locked")
  syncStatus          String   @default("pending") @map("sync_status")
  createdAt           DateTime @default(now()) @map("created_at")
  updatedAt           DateTime @updatedAt @map("updated_at")

  @@unique([storeId, shopifyProductId, shopifyVariantId])
  @@index([storeId])
  @@index([storeId, priority])
  @@map("products")
}

ADR-004: Supplemental Feed (Not Primary Feed Replacement)

Status: Accepted

Date: 2026-02-06

Context

Google Merchant Center supports two feed types:

  1. Primary feed: The authoritative source of product data (title, price, availability, images, etc.). For Shopify stores, this is typically managed by the Google & YouTube Shopify channel app, which syncs product data automatically.

  2. Supplemental feed: An additive feed that overlays additional attributes onto products already present in the primary feed. It can add or overwrite specific fields (like custom labels) without affecting the core product data.

AdPriority needs to set custom_label_0 through custom_label_4 on GMC products. This can be done by either replacing the primary feed (taking over all product data management) or by adding a supplemental feed (only touching the custom label fields).

Decision

Use a supplemental feed exclusively. Never replace or interfere with the primary feed.

Consequences

Positive:

  • Non-destructive: AdPriority cannot accidentally break product titles, prices, images, or availability in GMC. The primary feed remains untouched.
  • Additive only: Custom labels are layered on top of existing product data. If AdPriority is uninstalled, labels simply revert to empty on the next primary feed update.
  • Coexistence: Merchants can use AdPriority alongside other feed management tools without conflict.
  • Simpler scope: AdPriority only needs to manage 6 columns (id + 5 labels), not the full product catalog schema (30+ fields).
  • Lower risk: A bug in AdPriority cannot cause products to disappear from GMC or display incorrect prices.

Negative:

  • Products must already exist in the primary feed before supplemental labels can be applied. If a product is not in GMC (e.g., not synced by the Shopify Google channel), AdPriority cannot add it.
  • The id column in the supplemental feed must exactly match the primary feed’s product IDs. Any mismatch (case-sensitive) results in the label being silently ignored.

Mitigations:

  • Reconciliation job (daily) checks for products in the AdPriority database that have no match in GMC, alerting the merchant.
  • Onboarding wizard verifies that the Shopify Google channel is active before proceeding with feed setup.

ADR-005: 0-5 Scoring Scale

Status: Accepted

Date: 2026-02-06

Context

AdPriority needs a priority scoring system that:

  1. Is simple enough for non-technical merchants to understand immediately
  2. Maps cleanly to Google Ads campaign segmentation (listing group filters)
  3. Provides enough granularity for meaningful budget differentiation
  4. Works with GMC custom label constraints (max 1,000 unique values per label)

Scales considered:

ScaleGranularitySimplicityLabel Values
0-1LowHigh2
0-5MediumHigh6
0-10HighMedium11
0-100Very highLow101
A/B/C/D/FMediumHigh5

Decision

Use an integer scale from 0 to 5, where 0 means “exclude from advertising” and 5 means “push hard with maximum budget.”

Consequences

Score definitions:

+-------+-------------+------------------------------------------+
| Score | Label       | Google Ads Behavior                      |
+-------+-------------+------------------------------------------+
|   5   | Push Hard   | Maximum budget, aggressive bidding       |
|   4   | Strong      | High budget, balanced approach           |
|   3   | Moderate    | Standard budget, conservative bidding    |
|   2   | Light       | Minimal budget, strict ROAS targets      |
|   1   | Minimal     | Very low budget, highest ROAS only       |
|   0   | Exclude     | No advertising spend                     |
+-------+-------------+------------------------------------------+

Positive:

  • Six values (0-5) map perfectly to 6 PMAX asset groups or listing group subdivisions, each with its own budget allocation.
  • Score 0 has a clear, unambiguous meaning: do not advertise this product. This handles out-of-stock, discontinued, and deliberately excluded items.
  • The scale is intuitive. Merchants think in terms of “high priority” vs. “low priority,” not percentages or letter grades.
  • Only 6 unique values in custom_label_0, well within GMC’s 1,000 limit.
  • Integer type in the database with a CHECK constraint (0-5) prevents invalid values.

Negative:

  • Six tiers may feel coarse for large catalogs (10,000+ products). Merchants may want finer control within a tier.
  • No decimal values (e.g., 3.5 for “between moderate and strong”).

Mitigations:

  • The other four custom labels (custom_label_1 through custom_label_4) provide additional segmentation dimensions (season, category, status, brand tier). Combined with the priority score, this gives merchants 6 x 4 x ~15 x 5 x 3 = ~5,400 possible product segments.
  • Future enhancement: sub-tiers within a score (e.g., 4a, 4b) if merchant feedback indicates a need.

ADR-006: Cloudflare Tunnel for Deployment

Status: Accepted

Date: 2026-02-06

Context

Shopify apps must be accessible over HTTPS from Shopify’s servers for:

  1. OAuth callback URLs
  2. Webhook delivery
  3. App Bridge iFrame loading

The AdPriority backend runs on a Synology NAS on a local network (192.168.1.26). Making it accessible from the internet requires one of:

OptionComplexityCostSecurity
Port forwarding + DDNSLowFreeExposes port
Reverse proxy (Nginx)MediumFreeRequires static IP
Cloudflare TunnelLowFreeNo open ports
Cloud VM (AWS/GCP)High$20+/moFull control
NgrokLow$10+/moEphemeral URLs

Decision

Use Cloudflare Tunnel (via the existing cloudflared service at /volume1/docker/services/cloudflared/) to expose the AdPriority backend over HTTPS.

Consequences

Positive:

  • Existing infrastructure: The cloudflared container is already running on the NAS for other services. Adding a route for AdPriority requires only a configuration change, not a new deployment.
  • No open ports: The NAS firewall does not need any inbound port rules. The tunnel agent initiates an outbound connection to Cloudflare’s edge, which then proxies inbound traffic through the tunnel.
  • Free TLS: Cloudflare provides TLS certificates automatically. No need to manage Let’s Encrypt or self-signed certificates.
  • DDoS protection: Cloudflare’s edge network provides basic DDoS mitigation, rate limiting, and bot detection.
  • Stable URLs: The tunnel provides a permanent hostname (e.g., app.adpriority.com) that does not change on NAS reboot or IP change.
  • Zero cost: Cloudflare Tunnels are free for the traffic volumes AdPriority will generate.

Negative:

  • Dependency on Cloudflare’s infrastructure. If Cloudflare has an outage, the app is unreachable (rare, but possible).
  • Added latency: traffic routes through Cloudflare’s nearest edge node before reaching the NAS. Adds ~10-30ms per request (negligible for this use case).
  • Tunnel token must be stored securely (already managed via .env file).

Configuration:

# cloudflared tunnel configuration (addition)
ingress:
  - hostname: app.adpriority.com
    service: http://adpriority:3010
  # ... existing routes ...
  - service: http_status:404

ADR-007: Variant-Level IDs in GMC

Status: Accepted

Date: 2026-02-10

Context

Google Merchant Center identifies Shopify products using a composite ID format. Analysis of the Nexus GMC export (124,060 products, exported 2026-02-10) revealed that all products use variant-level IDs, not product-level IDs.

Observed format:

shopify_US_{productId}_{variantId}

Real examples from the Nexus GMC export:

shopify_US_8779355160808_46050142748904
shopify_US_9128994570472_47260097118440
shopify_US_9057367064808_47004004712680
shopify_US_9238797418728_47750439567592
shopify_US_7609551716584_42582395650280

No product-level IDs (without the variant suffix) were found in the export. This means that even if a Shopify product has only one variant, GMC still represents it with the variant-level ID.

Decision

AdPriority will generate and match GMC product IDs at the variant level exclusively, using the format shopify_US_{productId}_{variantId}.

Consequences

Positive:

  • 100% match rate with Nexus GMC products (validated with 10/10 test products)
  • Future-proof: supports products with multiple variants (size x color), where each variant can have a different inventory status
  • Enables variant-level priority overrides in future versions (e.g., exclude out-of-stock sizes while keeping in-stock sizes at priority 5)

Negative:

  • More rows in the Google Sheet: Nexus has ~5,582 products but ~20,000 active variants, and 124,060 total GMC entries. The supplemental feed must include one row per variant, not one row per product.
  • Priority is set at the product level in the MVP, meaning all variants of a product share the same priority score. This results in duplicate label values across variants (acceptable, not wasteful).
  • Slightly more complex ID generation: must fetch variant IDs from Shopify, not just product IDs.

Key data points:

+--------------------------------+--------+
| Metric                         | Value  |
+--------------------------------+--------+
| Nexus products in Shopify      | 5,582  |
| Nexus active products          | 2,425  |
| Nexus variants in GMC          | 124,060|
| Estimated active variants      | ~20,000|
| ID format confirmed            | Yes    |
| Product-only IDs found         | 0      |
| Country code (Nexus)           | US     |
| Product ID length              | 13 digits |
| Variant ID length              | 14 digits |
+--------------------------------+--------+

GMC ID construction:

function buildGmcProductId(
  countryCode: string,   // "US"
  productId: bigint,     // 8779355160808
  variantId: bigint      // 46050142748904
): string {
  return `shopify_${countryCode}_${productId}_${variantId}`;
  // Result: "shopify_US_8779355160808_46050142748904"
}

Item Group ID (used for variant grouping in GMC): Just the Shopify product ID without prefix or variant suffix.

Item Group ID: 8779355160808

This groups all variants of a product together in GMC reporting and campaign structure while allowing individual variant targeting via the full shopify_US_{productId}_{variantId} offer ID.


ADR Template

Future architecture decisions should follow this template:

## ADR-NNN: [Title]

**Status**: Proposed | Accepted | Deprecated | Superseded by ADR-NNN

**Date**: YYYY-MM-DD

### Context

[Describe the situation, constraints, and forces at play.
What problem are we solving? What options exist?]

### Decision

[State the decision clearly in one or two sentences.]

### Consequences

**Positive**:
- [Benefit 1]
- [Benefit 2]

**Negative**:
- [Trade-off 1]
- [Trade-off 2]

**Mitigations**:
- [How negative consequences are addressed]

Chapter Summary

These seven ADRs establish the architectural foundation for AdPriority:

ADRCore Principle
001Simple transport first; optimize later
002Match the ecosystem; do not fight the platform
003Type safety and developer experience over raw performance
004Be additive, not destructive; minimize blast radius
005Optimize for merchant comprehension, not engineer precision
006Reuse existing infrastructure; minimize operational burden
007Match the platform’s data model exactly; do not abstract it

The common thread across all decisions is pragmatism over purity. Every choice prioritizes shipping a working MVP on existing infrastructure over theoretical architectural elegance. Complexity is deferred to later phases where it can be justified by real usage data and paying customers.

Chapter 10: Shopify Integration

AdPriority is an embedded Shopify app that reads product data, applies priority scoring rules, and stores sync metadata via metafields. This chapter covers every touchpoint between AdPriority and the Shopify platform: authentication, API usage, webhooks, billing, and the App Bridge embedded experience.


8.1 Authentication: Token Exchange Flow

Shopify deprecated the legacy authorization code flow for embedded apps. All new apps must use Token Exchange, where the frontend obtains a session token from App Bridge and the backend exchanges it for an offline access token.

+-------------------+        +-------------------+        +-------------------+
|                   |        |                   |        |                   |
|   Shopify Admin   |        |   AdPriority UI   |        | AdPriority Backend|
|   (App Bridge)    |        |   (React/Polaris) |        |   (Express/TS)    |
|                   |        |                   |        |                   |
+--------+----------+        +--------+----------+        +--------+----------+
         |                            |                            |
         |  1. Merchant opens app     |                            |
         +--------------------------->|                            |
         |                            |                            |
         |  2. App Bridge issues      |                            |
         |     session token (JWT)    |                            |
         |<---------------------------+                            |
         |                            |                            |
         |                            |  3. Frontend sends session |
         |                            |     token in Authorization |
         |                            |     header                 |
         |                            +--------------------------->|
         |                            |                            |
         |                            |  4. Backend validates JWT  |
         |                            |     (signature, audience,  |
         |                            |      expiry)               |
         |                            |                            |
         |                            |  5. If first install:      |
         |                            |     POST /oauth/token with |
         |                            |     session token to get   |
         |                            |     offline access token   |
         |                            |                            |
         |                            |  6. Store encrypted access |
         |                            |     token in database      |
         |                            |<---------------------------+
         |                            |                            |
         |                            |  7. Return app data        |
         |                            |<---------------------------+

Session Token Structure

App Bridge 4.1 automatically issues session tokens as JWTs signed with the app’s client secret. Each token is valid for 60 seconds and is auto-refreshed by App Bridge before expiry.

JWT Claims:

ClaimDescriptionExample
issShop admin URLhttps://nexus-clothes.myshopify.com/admin
destShop URLhttps://nexus-clothes.myshopify.com
audApp client IDa1b2c3d4e5f6...
subShopify user ID12345678
expExpiry timestamp1707500000
nbfNot before timestamp1707499940
iatIssued at timestamp1707499940
jtiUnique token IDabc-123-def
sidSession IDsession_xyz

Server-Side Validation

// backend/src/integrations/shopify/session.ts
import jwt from 'jsonwebtoken';

interface ShopifySessionToken {
  iss: string;   // https://{shop}.myshopify.com/admin
  dest: string;  // https://{shop}.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): ShopifySessionToken {
  const decoded = jwt.verify(token, process.env.SHOPIFY_CLIENT_SECRET!, {
    algorithms: ['HS256'],
    audience: process.env.SHOPIFY_CLIENT_ID!,
    clockTolerance: 10, // seconds of clock skew tolerance
  }) as ShopifySessionToken;

  // Extract shop domain from iss claim
  const shopDomain = decoded.iss.replace('https://', '').replace('/admin', '');

  if (!shopDomain.endsWith('.myshopify.com')) {
    throw new Error('Invalid shop domain in session token');
  }

  return decoded;
}

Token Exchange for Offline Access

On first install, the backend exchanges the session token for a long-lived offline access token:

// backend/src/integrations/shopify/oauth.ts
import axios from 'axios';

interface TokenExchangeResponse {
  access_token: string;
  scope: string;
}

export async function exchangeToken(
  shop: string,
  sessionToken: string
): Promise<TokenExchangeResponse> {
  const response = await axios.post(
    `https://${shop}/admin/oauth/access_token`,
    {
      client_id: process.env.SHOPIFY_CLIENT_ID,
      client_secret: process.env.SHOPIFY_CLIENT_SECRET,
      grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
      subject_token: sessionToken,
      subject_token_type: 'urn:ietf:params:oauth:token-type:id-token',
      requested_token_type:
        'urn:shopify:params:oauth:token-type:offline-access-token',
    }
  );

  return response.data;
}

The offline access token does not expire. It persists until the merchant uninstalls the app. Store it encrypted at rest (AES-256) in the database, never in logs or client-side code.


8.2 Required API Scopes

AdPriority requests the minimum scopes necessary. Shopify reviewers reject apps that over-request permissions.

ScopePurposeTierJustification for App Store Review
read_productsFetch product catalog (titles, types, tags, collections, variants)All“AdPriority reads product information to apply priority scoring rules and generate Google Merchant Center custom labels.”
write_productsWrite metafields for priority storage and sync statusAll“AdPriority stores priority scores and sync status in product metafields for persistent data across sessions.”
read_inventoryCheck stock levels per locationGrowth+“AdPriority uses inventory levels to adjust priority scores, lowering priority for out-of-stock items.”

Scopes are declared in the app configuration (TOML) and requested during OAuth:

# shopify.app.toml
[access_scopes]
scopes = "read_products,write_products,read_inventory"

8.3 Shopify Admin REST API Usage

AdPriority uses the Admin REST API (version 2024-10 or latest stable) for product data retrieval. GraphQL is used selectively for billing and metafield operations.

Products Endpoint

Endpoint: GET /admin/api/2024-10/products.json

ParameterValuePurpose
limit250Maximum products per page
since_id{last_product_id}Cursor-based pagination
fieldsid,title,product_type,vendor,tags,status,variants,created_atMinimize payload
statusactiveOnly fetch active products

Response structure (relevant fields):

{
  "products": [
    {
      "id": 8779355160808,
      "title": "Jordan Craig Stacked Jeans - Dark Blue",
      "product_type": "Men-Bottoms-Pants-Jeans",
      "vendor": "Jordan Craig",
      "tags": "jordan-craig, NAME BRAND, in-stock, Men",
      "status": "active",
      "created_at": "2025-11-15T10:30:00-05:00",
      "variants": [
        {
          "id": 46050142748904,
          "title": "30 / Dark Blue",
          "sku": "107438",
          "inventory_quantity": 12
        },
        {
          "id": 46050142781672,
          "title": "32 / Dark Blue",
          "sku": "107439",
          "inventory_quantity": 8
        }
      ]
    }
  ]
}

Product Data We Extract

For each product and variant, AdPriority captures:

FieldSourceUsage
product.idproducts[].idGMC ID construction, database key
variant.idproducts[].variants[].idGMC ID construction (variant-level)
titleproducts[].titleDisplay in dashboard
product_typeproducts[].product_typeCategory rule matching (90 types at Nexus)
vendorproducts[].vendorBrand tier determination (175 vendors at Nexus)
tagsproducts[].tagsTag modifiers (NAME BRAND, DEAD50, Sale, etc.)
statusproducts[].statusFilter to active only
created_atproducts[].created_atNew arrival detection (14-day threshold)
inventory_quantityvariants[].inventory_quantityInventory status label

Variants Endpoint (Supplementary)

For products with many variants, use the dedicated endpoint:

Endpoint: GET /admin/api/2024-10/products/{product_id}/variants.json

This is used during reconciliation when variant data may be incomplete from the products endpoint.

Pagination Strategy: since_id

AdPriority uses Shopify’s since_id pagination for full catalog sync. This is the most efficient approach for bulk retrieval, avoiding the overhead of cursor-based Link headers.

Full Catalog Sync Flow (Nexus: 2,425 active products)
======================================================

Page 1:  GET /products.json?limit=250&status=active&since_id=0
         -> Returns products 1-250, note last product.id

Page 2:  GET /products.json?limit=250&status=active&since_id={last_id}
         -> Returns products 251-500

...

Page 10: GET /products.json?limit=250&status=active&since_id={last_id}
         -> Returns products 2,251-2,425 (final page, <250 results)

Total API calls: ceil(2,425 / 250) = 10 requests
Time estimate:   ~5-10 seconds (with rate limit compliance)

Implementation

// backend/src/integrations/shopify/products.ts
import { ShopifyClient } from './client';

interface ShopifyProduct {
  id: number;
  title: string;
  product_type: string;
  vendor: string;
  tags: string;
  status: string;
  created_at: string;
  variants: ShopifyVariant[];
}

interface ShopifyVariant {
  id: number;
  title: string;
  sku: string;
  inventory_quantity: number;
}

export async function fetchAllProducts(
  client: ShopifyClient
): Promise<ShopifyProduct[]> {
  const allProducts: ShopifyProduct[] = [];
  let sinceId = 0;
  const fields = 'id,title,product_type,vendor,tags,status,created_at,variants';

  while (true) {
    const response = await client.get('/products.json', {
      params: {
        limit: 250,
        since_id: sinceId,
        status: 'active',
        fields,
      },
    });

    const products: ShopifyProduct[] = response.data.products;

    if (products.length === 0) break;

    allProducts.push(...products);
    sinceId = products[products.length - 1].id;

    // Respect Shopify rate limits (40 requests/second bucket)
    await sleep(100);
  }

  return allProducts;
}

API Rate Limits

Shopify uses a leaky bucket algorithm:

PlanBucket SizeLeak Rate
Standard40 requests2/second
Shopify Plus80 requests4/second

AdPriority monitors the X-Shopify-Shop-Api-Call-Limit response header (e.g., 32/40) and throttles when the bucket is above 80% capacity.


8.4 Webhooks

Webhooks keep AdPriority synchronized with product changes in real time. All webhooks are verified via HMAC-SHA256 before processing.

Webhook Registration

Webhooks are registered during app installation via the REST API:

// backend/src/integrations/shopify/webhooks.ts
const WEBHOOK_TOPICS = [
  { topic: 'products/create',          address: '/webhooks/products-create' },
  { topic: 'products/update',          address: '/webhooks/products-update' },
  { topic: 'products/delete',          address: '/webhooks/products-delete' },
  { topic: 'app/uninstalled',          address: '/webhooks/app-uninstalled' },
];

export async function registerWebhooks(
  client: ShopifyClient,
  appUrl: string
): Promise<void> {
  for (const webhook of WEBHOOK_TOPICS) {
    await client.post('/webhooks.json', {
      webhook: {
        topic: webhook.topic,
        address: `${appUrl}${webhook.address}`,
        format: 'json',
      },
    });
  }
}

Product Webhooks

WebhookTriggerAdPriority Action
products/createNew product added in ShopifyApply category rules, calculate initial priority, add to sync queue
products/updateProduct edited (title, type, tags, etc.)Re-evaluate priority if product_type or tags changed; skip if only price/description changed
products/deleteProduct deleted from ShopifyMark as deleted in database, remove from Google Sheet on next sync

App Lifecycle Webhook

WebhookTriggerAdPriority Action
app/uninstalledMerchant removes the appDelete all store data: products, rules, calendars, sync logs. Cancel subscription. Revoke tokens. Mandatory.

GDPR Mandatory Webhooks

These three webhooks are required for App Store approval. Apps are rejected without them. Since AdPriority stores no customer PII (only product data), these return 200 OK with appropriate responses.

WebhookEndpointResponse
customers/data_requestPOST /webhooks/customers-data-requestReturn 200 OK with { "message": "AdPriority does not store customer personal data" }
customers/redactPOST /webhooks/customers-redactReturn 200 OK – no customer data to delete
shop/redactPOST /webhooks/shop-redactDelete all data for the shop (products, rules, tokens, sync logs). Return 200 OK.

HMAC Verification

Every incoming webhook must be verified. The X-Shopify-Hmac-Sha256 header contains a Base64-encoded HMAC-SHA256 digest of the raw request body, computed with the app’s client secret.

// backend/src/api/middleware/shopify.ts
import crypto from 'crypto';

export function verifyWebhookHmac(
  rawBody: Buffer,
  hmacHeader: string,
  secret: string
): boolean {
  const calculated = crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('base64');

  return crypto.timingSafeEqual(
    Buffer.from(calculated),
    Buffer.from(hmacHeader)
  );
}

// Express middleware
export function shopifyWebhookAuth(req: Request, res: Response, next: Function) {
  const hmac = req.headers['x-shopify-hmac-sha256'] as string;
  const topic = req.headers['x-shopify-topic'] as string;
  const shop = req.headers['x-shopify-shop-domain'] as string;

  if (!hmac || !topic || !shop) {
    return res.status(401).json({ error: 'Missing Shopify headers' });
  }

  if (!verifyWebhookHmac(req.rawBody, hmac, process.env.SHOPIFY_CLIENT_SECRET!)) {
    return res.status(401).json({ error: 'Invalid HMAC signature' });
  }

  req.shopifyTopic = topic;
  req.shopifyShop = shop;
  next();
}

Webhook Processing Flow

+------------------+     +-------------------+     +------------------+
| Shopify Platform |     | AdPriority API    |     | Background Queue |
+--------+---------+     +--------+----------+     +--------+---------+
         |                        |                         |
         | POST /webhooks/...     |                         |
         +----------------------->|                         |
         |                        |                         |
         |                 Verify HMAC                      |
         |                 Extract topic + shop             |
         |                 Return 200 OK immediately        |
         |<-----------------------+                         |
         |                        |                         |
         |                 Enqueue job for                  |
         |                 async processing                 |
         |                        +------------------------>|
         |                        |                         |
         |                        |              Process webhook:
         |                        |              - Fetch full product
         |                        |              - Recalculate priority
         |                        |              - Update database
         |                        |              - Flag for GMC sync

Key principle: Return 200 OK immediately, then process asynchronously via Bull queue. Shopify retries webhooks that do not receive a 2xx response within 5 seconds.


8.5 App Bridge 4.1: Embedded Experience

AdPriority runs as an embedded app inside the Shopify Admin. App Bridge 4.1 provides the shell: navigation, session tokens, and native UI primitives.

Frontend Setup

// admin-ui/src/main.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { AppProvider } from '@shopify/polaris';
import enTranslations from '@shopify/polaris/locales/en.json';
import App from './App';

// App Bridge 4.1 initializes automatically when loaded
// inside Shopify Admin iframe. No manual init required.

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <AppProvider i18n={enTranslations}>
      <App />
    </AppProvider>
  </React.StrictMode>
);

Authenticated Fetch

App Bridge 4.1 provides an authenticatedFetch wrapper that automatically attaches the session token to every request:

// admin-ui/src/utils/api.ts

// App Bridge 4.1 injects shopify global
declare const shopify: {
  idToken(): Promise<string>;
};

export async function apiFetch(path: string, options: RequestInit = {}) {
  const token = await shopify.idToken();

  const response = await fetch(`/api${path}`, {
    ...options,
    headers: {
      ...options.headers,
      'Authorization': `Bearer ${token}`,
      'Content-Type': 'application/json',
    },
  });

  if (!response.ok) {
    throw new Error(`API error: ${response.status}`);
  }

  return response.json();
}

Key App Bridge Features Used

FeatureUsage in AdPriority
Session tokensAuthentication for all API calls
NavigationApp pages within Shopify Admin sidebar
Toast notificationsSuccess/error messages after sync operations
ModalConfirmation dialogs for bulk priority changes
Loading barProgress indication during catalog sync
RedirectNavigate to Shopify billing confirmation page

App Routing Within Shopify Admin

Shopify Admin
+----------------------------------------------------+
|  [Sidebar]           [Main Content Area]            |
|                                                     |
|  Home                +-----------------------------+|
|  Orders              | AdPriority App (iframe)     ||
|  Products            |                             ||
|  ...                 |  /app/dashboard             ||
|  Apps >              |  /app/products              ||
|    AdPriority        |  /app/rules                 ||
|                      |  /app/calendar              ||
|                      |  /app/sync                  ||
|                      |  /app/settings              ||
|                      +-----------------------------+|
+----------------------------------------------------+

8.6 Billing API

AdPriority uses Shopify’s Billing API (GraphQL) for subscription management. Shopify handles payment collection, invoicing, and the merchant-facing subscription UI.

Creating a Subscription

mutation AppSubscriptionCreate {
  appSubscriptionCreate(
    name: "AdPriority Growth"
    lineItems: [{
      plan: {
        appRecurringPricingDetails: {
          price: { amount: "79.00", currencyCode: USD }
          interval: EVERY_30_DAYS
        }
      }
    }]
    returnUrl: "https://app.adpriority.com/billing/callback?shop=nexus-clothes.myshopify.com"
    test: false
    trialDays: 14
  ) {
    appSubscription {
      id
      status
    }
    confirmationUrl
    userErrors {
      field
      message
    }
  }
}

Subscription Flow

1. Merchant selects plan in AdPriority settings
2. Backend creates appSubscription via GraphQL
3. Shopify returns confirmationUrl
4. App Bridge redirects merchant to confirmationUrl
5. Merchant approves charge in Shopify admin
6. Shopify redirects to returnUrl with charge_id
7. Backend verifies subscription status
8. Backend activates tier features for the store

Pricing Tiers

TierMonthlyAnnualTrialProductsFeatures
Starter$29$29014 daysUp to 500Basic rules, manual sync
Growth$79$79014 daysUp to 5,000Seasonal automation, daily sync
Pro$199$1,99014 daysUnlimitedGoogle Ads integration, hourly sync

Handling Uninstall and Cancellation

When app/uninstalled fires, the subscription is automatically cancelled by Shopify. AdPriority must:

  1. Stop all background jobs for the store
  2. Delete the offline access token
  3. Archive (not immediately delete) product data for 30 days
  4. Execute shop/redact cleanup when received

8.7 Metafield Storage

AdPriority uses Shopify product metafields to persist priority data directly on the product. This ensures data survives app reinstallation and provides a backup to the database.

Metafield Namespace and Keys

NamespaceKeyTypeExample Value
adpriorityscorenumber_integer4
adprioritysourcesingle_line_text_fieldseasonal
adprioritylockedbooleanfalse
adprioritylast_synceddate_time2026-02-10T10:00:00Z
adprioritylabelsjson{"l0":"priority-4","l1":"winter",...}

Writing Metafields (GraphQL)

mutation SetPriorityMetafield($input: ProductInput!) {
  productUpdate(input: $input) {
    product {
      id
      metafields(first: 5, namespace: "adpriority") {
        edges {
          node {
            key
            value
          }
        }
      }
    }
    userErrors {
      field
      message
    }
  }
}

Variables:

{
  "input": {
    "id": "gid://shopify/Product/8779355160808",
    "metafields": [
      {
        "namespace": "adpriority",
        "key": "score",
        "value": "4",
        "type": "number_integer"
      },
      {
        "namespace": "adpriority",
        "key": "source",
        "value": "seasonal",
        "type": "single_line_text_field"
      }
    ]
  }
}

8.8 Full Catalog Sync: Install-Time Workflow

When a merchant installs AdPriority, the app performs a complete product import. For the Nexus store, this involves 2,425 active products across approximately 10 paginated API requests.

Install-Time Catalog Sync
==========================

Step 1: Token Exchange
  Session token --> offline access token (stored encrypted)

Step 2: Register Webhooks
  products/create, products/update, products/delete,
  app/uninstalled, GDPR webhooks (3)

Step 3: Fetch Product Catalog
  GET /products.json (since_id pagination, 250/page)
  Nexus: ~10 requests, ~5,582 products total, 2,425 active

Step 4: Process Each Product
  For each active product:
    +-- Extract: id, variants, product_type, vendor, tags, created_at
    +-- Match category group (from 90 types into 20 groups)
    +-- Calculate initial priority (seasonal rules + tag modifiers)
    +-- Generate GMC IDs: shopify_US_{productId}_{variantId}
    +-- Store in product_mappings table
    +-- Write metafield (batch, async)

Step 5: Generate Supplemental Feed
  Write all rows to Google Sheet via Sheets API
  Nexus: ~15,000-20,000 variant rows

Step 6: Dashboard Ready
  Display product list with assigned priorities
  Show sync status and next steps

Nexus Store Reference Data

MetricValue
Shopify storenexus-clothes.myshopify.com
Total products5,582
Active products2,425
Archived products3,121
Draft products36
Unique product types90
Unique vendors175
Unique tags2,522
Estimated active variants~15,000-20,000
GMC variants (total)124,060
API pages needed (active, 250/page)10

8.9 Error Handling and Resilience

Common Shopify API Errors

HTTP StatusMeaningAdPriority Response
401Invalid or expired tokenRe-authenticate; if persistent, prompt reinstall
402Store frozen (unpaid)Pause sync, notify via dashboard
403Scope not grantedPrompt merchant to re-approve scopes
404Product deleted between fetch and processSkip gracefully, log warning
429Rate limit exceededExponential backoff starting at 1 second
500/502/503Shopify server errorRetry up to 3 times with backoff

Retry Configuration

const SHOPIFY_RETRY_CONFIG = {
  maxRetries: 3,
  initialDelayMs: 1000,
  backoffMultiplier: 2,
  maxDelayMs: 10000,
  retryableStatuses: [429, 500, 502, 503],
};

Idempotency

All webhook handlers are idempotent. Processing the same products/update webhook twice produces the same result. The database uses UPSERT (insert on conflict update) keyed on (store_id, shopify_product_id, shopify_variant_id).


8.10 Security Checklist

RequirementImplementationStatus
Session token validationJWT verify with client secret, audience, expiryRequired
Webhook HMAC verificationHMAC-SHA256 with timingSafeEqualRequired
Token encryption at restAES-256-GCM for offline access tokensRequired
No tokens in logsRedact all tokens from application logsRequired
HTTPS onlyAll endpoints served over TLSRequired
Scope minimizationOnly read_products, write_products, read_inventoryRequired
GDPR webhooksThree mandatory endpoints implementedRequired
Shop-scoped queriesAll database queries filtered by store_idRequired

8.11 Chapter Summary

AdPriority’s Shopify integration is built on the modern Token Exchange authentication flow, embedded via App Bridge 4.1, and uses Polaris v13.9 for a native admin experience. The app reads the full product catalog via paginated REST API calls, maintains real-time sync through webhooks, stores priority metadata in metafields, and manages subscriptions through Shopify’s Billing API. All operations are secured with HMAC verification, encrypted token storage, and shop-scoped data isolation.

Key numbers for Nexus Clothing:

  • 10 API calls to sync the full active catalog (2,425 products)
  • 7 webhooks registered (4 product/app + 3 GDPR)
  • 3 API scopes requested (minimum necessary)
  • 60-second session token lifetime (auto-refreshed by App Bridge)

Chapter 11: Google Merchant Center Integration

Google Merchant Center (GMC) is the bridge between AdPriority’s scoring engine and Google Ads campaigns. AdPriority writes custom labels to GMC products, which Google Ads then uses for product group segmentation and bid optimization. This chapter covers the custom label specification, the supplemental feed pipeline, the Content API for future scaling, and all verified data from the Nexus Clothing GMC account.


9.1 Custom Label Specifications

GMC provides five custom labels (custom_label_0 through custom_label_4) that merchants can populate with arbitrary values. These labels are invisible to shoppers but available for campaign segmentation in Google Ads.

Specification Reference

PropertyValue
Number of labels5 (custom_label_0 through custom_label_4)
Maximum characters per label100
Maximum unique values per label1,000
Total unique values (all labels)5,000
Case sensitivityNot case-sensitive (Winter = winter = WINTER)
Visible to shoppersNo (internal only)
Available in Google AdsYes (product group filters, reports)
Update frequencyProcessed within 24 hours of feed fetch

Important constraint: If a label exceeds 1,000 unique values, additional values are silently ignored for reporting and bidding. AdPriority’s label schema is designed to stay well within this limit.

Unique Value Budget

Label Capacity Planning
========================

custom_label_0 (Priority):    6 unique values   [  0.6% of 1,000 limit ]
custom_label_1 (Season):      4 unique values   [  0.4% of 1,000 limit ]
custom_label_2 (Category):   ~20 unique values   [  2.0% of 1,000 limit ]
custom_label_3 (Status):      5 unique values   [  0.5% of 1,000 limit ]
custom_label_4 (Brand Tier):  3 unique values   [  0.3% of 1,000 limit ]
                             ----
Total:                       ~38 unique values   [  0.8% of 5,000 limit ]

AdPriority uses less than 1% of the available unique value budget, leaving substantial headroom for future expansion.


9.2 AdPriority Label Schema

Each of the five custom labels serves a distinct purpose in the campaign segmentation strategy:

Label Allocation

+------------------+--------------------------------------------------+
| Label            | Purpose and Values                               |
+------------------+--------------------------------------------------+
|                  |                                                  |
| custom_label_0   | PRIORITY SCORE                                  |
|                  | Values: priority-0, priority-1, priority-2,     |
|                  |         priority-3, priority-4, priority-5      |
|                  | Used for: Primary PMAX campaign segmentation    |
|                  |                                                  |
+------------------+--------------------------------------------------+
|                  |                                                  |
| custom_label_1   | SEASON                                          |
|                  | Values: winter, spring, summer, fall             |
|                  | Used for: Seasonal performance analysis,        |
|                  |           seasonal campaign adjustments          |
|                  |                                                  |
+------------------+--------------------------------------------------+
|                  |                                                  |
| custom_label_2   | PRODUCT CATEGORY                                |
|                  | Values: t-shirts, jeans-pants, shorts,          |
|                  |         hoodies-sweatshirts, outerwear-heavy,   |
|                  |         outerwear-medium, outerwear-light,      |
|                  |         headwear-caps, headwear-cold-weather,   |
|                  |         headwear-summer, joggers, underwear-    |
|                  |         socks, footwear-sandals, footwear-shoes,|
|                  |         accessories, women-apparel, long-sleeve,|
|                  |         sweatpants, swim-shorts, other          |
|                  | Used for: Category-level ROAS reporting         |
|                  |                                                  |
+------------------+--------------------------------------------------+
|                  |                                                  |
| custom_label_3   | INVENTORY STATUS                                |
|                  | Values: in-stock, low-inventory, new-arrival,   |
|                  |         clearance, dead-stock                   |
|                  | Used for: Exclude dead-stock, boost new arrivals|
|                  |                                                  |
+------------------+--------------------------------------------------+
|                  |                                                  |
| custom_label_4   | BRAND TIER                                      |
|                  | Values: name-brand, store-brand, off-brand      |
|                  | Used for: Brand-level bidding strategy,         |
|                  |           ROAS analysis by brand tier            |
|                  |                                                  |
+------------------+--------------------------------------------------+

Label Value Details

custom_label_0 – Priority Score:

ValueMeaningGoogle Ads Treatment
priority-5Maximum push – seasonal peaks, new arrivals, high marginHighest budget, aggressive tROAS
priority-4Strong performers – name brands in seasonAbove-average budget
priority-3Moderate – standard year-round productsStandard budget
priority-2Light – low-margin or low-demand itemsBelow-average budget
priority-1Minimal – near end-of-life or off-seasonMinimal spend
priority-0Excluded – dead stock, archived, out of stockPaused or excluded

custom_label_1 – Season:

ValueActive PeriodExample Categories Boosted
winterDec 1 – Feb 28Puffer jackets, hoodies, beanies, balaclavas
springMar 1 – May 31Windbreakers, t-shirts, light jackets
summerJun 1 – Aug 31Shorts, tank tops, swim shorts, sandals
fallSep 1 – Nov 30Jeans, hoodies, denim jackets, varsity jackets

custom_label_2 – Product Category:

Normalized from the 90 Shopify product types into approximately 20 groups (see Chapter 15 for the full category mapping table). Examples:

Shopify Product TypeLabel Value
Men-Tops-T-Shirtst-shirts
Men-Bottoms-Pants-Jeansjeans-pants
Men-Tops-Outerwear-Jackets-Puffer Jacketsouterwear-heavy
Headwear-Baseball-Fittedheadwear-caps
Men-Footwear-Sandals & Slidesfootwear-sandals
Bath & Bodyother

custom_label_3 – Inventory Status:

ValueDetermination Logic
new-arrivalProduct created_at within last 14 days
in-stockDefault for active products with inventory
low-inventoryShopify tag warning_inv_1 or warning_inv present
clearanceShopify tag Sale present
dead-stockShopify tag DEAD50 or archived present, or status = archived

custom_label_4 – Brand Tier:

ValueDetermination LogicNexus Examples
name-brandTag NAME BRAND or known vendor listNew Era, Jordan Craig, Psycho Bunny, Lacoste
store-brandVendor = “Nexus Clothing”Nexus Clothing (126 products)
off-brandTag OFF BRAND or unrecognized vendorRebel Minds, Black Keys, generic vendors

9.3 Nexus GMC Account: Verified Data

The following data was verified from a live GMC product export conducted on 2026-02-10 (124,060 products, TSV format).

Account Overview

MetricValue
Total products in GMC124,060 (variant-level)
Active products in Shopify2,425 (estimated ~15,000-20,000 active variants)
Primary feed sourceShopify Google Channel (automatic)
Feed data sourcesContent API - US, Content API - Local, Local Feed Partnership
Product ID formatshopify_US_{productId}_{variantId}
Country codeUS (all products)

Current Custom Label Usage

LabelCurrent StateProducts UsingSafe to Use?
custom_label_0"Argonaut Nations - Converting" on 7 products7 (0.006%)Yes – overwrite is safe
custom_label_1EMPTY0Yes
custom_label_2EMPTY0Yes
custom_label_3EMPTY0Yes
custom_label_4EMPTY0Yes

Conclusion: All five labels are effectively available. The 7 products on custom_label_0 represent a negligible 0.006% of inventory from an inactive campaign and are safe to overwrite.

Verified Product ID Examples

GMC Product IDs (from live export):

  shopify_US_8779355160808_46050142748904
  shopify_US_9128994570472_47260097118440
  shopify_US_9057367064808_47004004712680
  shopify_US_9238797418728_47750439567592
  shopify_US_7609551716584_42582395650280
  shopify_US_8631136518376_45680298983656
  shopify_US_9208152621288_47635854262504
  shopify_US_8361353412840_44841122955496

Format:  shopify_{country}_{shopifyProductId}_{shopifyVariantId}
Country: US (for all Nexus products)
Product ID: 13 digits
Variant ID: 14 digits

Item Group ID

The item_group_id in GMC is the Shopify product ID without the prefix:

Product ID (GMC):       shopify_US_8779355160808_46050142748904
Item Group ID (GMC):    8779355160808
Shopify Product ID:     8779355160808
Shopify Variant ID:     46050142748904

This is important because supplemental feeds operate at the variant level (full product ID), while Google Ads reporting can aggregate at the item group level (parent product).


9.4 Supplemental Feeds

A supplemental feed adds or overrides attributes on products already present in the primary feed. The primary feed (from Shopify Google Channel) provides core product data. AdPriority’s supplemental feed adds only the five custom labels.

How Supplemental Feeds Work

Primary Feed                    Supplemental Feed
(Shopify Google Channel)        (AdPriority Google Sheet)
+---------------------------+   +---------------------------+
| id (required)             |   | id (required - must match)|
| title                     |   | custom_label_0            |
| description               |   | custom_label_1            |
| price                     |   | custom_label_2            |
| image_link                |   | custom_label_3            |
| availability              |   | custom_label_4            |
| brand                     |   +---------------------------+
| gtin                      |
| product_type              |        ||
| ... (30+ attributes)     |        || GMC merges by ID
+---------------------------+        ||
                                     \/
                            +---------------------------+
                            | Merged Product in GMC     |
                            +---------------------------+
                            | id                        |
                            | title                     |
                            | description               |
                            | price                     |
                            | custom_label_0  <-- NEW   |
                            | custom_label_1  <-- NEW   |
                            | custom_label_2  <-- NEW   |
                            | custom_label_3  <-- NEW   |
                            | custom_label_4  <-- NEW   |
                            | ... (all other attributes)|
                            +---------------------------+

Critical Requirements

RequirementDetail
id column must be firstMust exactly match the primary feed’s product ID
IDs are case-sensitiveshopify_US_123_456 is not Shopify_US_123_456
Header row requiredColumn names must match GMC attribute names exactly
Only override columns includedOmit columns you do not want to change
Linked to data sourceSupplemental feed must be linked to one or more primary data sources

Supported Feed Formats

FormatProsConsAdPriority Usage
Google SheetsAuto-syncs, easy to debug, freeDaily sync onlyMVP
CSV/TSV fileSimple to generateRequires manual upload or scheduled fetchNot used
Content APIReal-time, programmaticComplex auth, rate limitsFuture (Pro tier)

MVP Approach: Google Sheets

For the MVP and Starter/Growth tiers, AdPriority uses Google Sheets as the supplemental feed format. This approach was validated with 10 Nexus products on 2026-02-10 with zero issues.

Test MetricResult
Products tested10
Products matched10 (100%)
Attribute names recognizedAll 6 columns
Issues foundNone
Processing time< 1 hour (manual trigger)
Feed acceptedImmediately

9.5 Content API v2.1 (Future: Pro Tier)

For merchants who need faster-than-daily label updates, the Content API provides programmatic access to GMC product data.

Authentication

OAuth 2.0 for Google APIs

Scope:  https://www.googleapis.com/auth/content

Flow:
  1. Merchant connects Google account in AdPriority settings
  2. OAuth consent screen requests Content API scope
  3. Google returns authorization code
  4. Backend exchanges code for refresh token
  5. Refresh token stored encrypted in database
  6. Access tokens generated on demand (1-hour lifetime)

Updating Custom Labels (PATCH)

Endpoint: PATCH https://shoppingcontent.googleapis.com/content/v2.1/{merchantId}/products/{productId}

{
  "customLabel0": "priority-5",
  "customLabel1": "winter",
  "customLabel2": "jeans-pants",
  "customLabel3": "in-stock",
  "customLabel4": "name-brand"
}

Use the updateMask query parameter to specify which fields to update:

?updateMask=customLabel0,customLabel1,customLabel2,customLabel3,customLabel4

Batch Operations (custombatch)

For bulk updates, the products.custombatch endpoint processes multiple products in a single HTTP request:

// backend/src/integrations/google/merchant.ts
import { google } from 'googleapis';

const content = google.content('v2.1');

interface LabelUpdate {
  gmcProductId: string;  // e.g., "shopify_US_8779355160808_46050142748904"
  labels: {
    customLabel0: string;
    customLabel1: string;
    customLabel2: string;
    customLabel3: string;
    customLabel4: string;
  };
}

export async function batchUpdateLabels(
  auth: any,
  merchantId: string,
  updates: LabelUpdate[]
): Promise<void> {
  const entries = updates.map((update, index) => ({
    batchId: index,
    merchantId: merchantId,
    method: 'update',
    productId: `online:en:US:${update.gmcProductId}`,
    product: {
      customLabel0: update.labels.customLabel0,
      customLabel1: update.labels.customLabel1,
      customLabel2: update.labels.customLabel2,
      customLabel3: update.labels.customLabel3,
      customLabel4: update.labels.customLabel4,
    },
    updateMask: 'customLabel0,customLabel1,customLabel2,customLabel3,customLabel4',
  }));

  const response = await content.products.custombatch({
    auth,
    requestBody: { entries },
  });

  // Process response for errors
  for (const entry of response.data.entries || []) {
    if (entry.errors) {
      console.error(`Batch ${entry.batchId} failed:`, entry.errors);
    }
  }
}

Rate Limits and Quotas

LimitValueAdPriority Strategy
Product updates per day per product2Sync at most twice daily per product
Requests per minuteDynamic (throttle-based)Monitor for 429 responses
custombatch entries per request~1,000 (practical limit)Chunk updates into batches of 500
Daily aggregate limitVaries by accountTrack usage, alert at 80%

Error Handling

Error CodeMeaningResponse
quota/request_rate_too_highPer-minute rate exceededExponential backoff, starting at 2 seconds
quota/daily_limit_exceededDaily quota consumedHalt updates, retry next day, alert merchant
not_foundProduct not in primary feedSkip product, log for reconciliation
invalid_valueLabel exceeds 100 chars or invalidTruncate and retry

Exponential Backoff Implementation

async function withBackoff<T>(
  fn: () => Promise<T>,
  maxRetries: number = 5
): Promise<T> {
  let delay = 1000; // Start at 1 second

  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await fn();
    } catch (error: any) {
      if (attempt === maxRetries) throw error;

      const isRetryable =
        error.code === 429 ||
        error.code === 503 ||
        error.message?.includes('rate');

      if (!isRetryable) throw error;

      await new Promise(resolve => setTimeout(resolve, delay));
      delay = Math.min(delay * 2, 60000); // Cap at 60 seconds
    }
  }

  throw new Error('Max retries exceeded');
}

9.6 Feed Processing Timeline

Understanding the end-to-end timeline from priority change to Google Ads effect:

Timeline: Priority Change to Google Ads Effect
================================================

T+0h    AdPriority calculates new priority score
        |
T+0m    Score written to database, flagged for sync
        |
T+1m    Google Sheet updated via Sheets API (MVP)
        |  -- OR --
        Content API PATCH sent (Pro tier)
        |
T+1h    GMC begins processing supplemental feed
to      (Google Sheets: fetched on schedule, typically daily)
T+24h   (Content API: processed within minutes to hours)
        |
T+24h   Custom labels visible in GMC product data
to      Labels available in Google Ads for
T+48h   product group filtering and bid optimization
        |
        Google Ads campaigns use new labels
        for next ad serving cycle

MVP (Google Sheets):  ~24-48 hours end-to-end
Pro (Content API):    ~1-6 hours end-to-end

For priority scoring, this latency is acceptable. Priority changes are strategic decisions (seasonal shifts, new arrivals) rather than real-time price adjustments.


9.7 Feed Size Planning

Nexus Store Estimates

MetricCountFeed Rows
Active Shopify products2,425
Estimated active variants~15,000-20,000~15,000-20,000
Total GMC variants (incl. archived)124,060124,060 (if syncing all)
Columns per row6 (id + 5 labels)
Cells needed (active only)~120,000
Cells needed (all variants)~744,360
Google Sheets cell limit10,000,000
Usage percentage (active only)1.2%
Usage percentage (all variants)7.4%

Multi-Tenant Scaling

For the SaaS product, each tenant gets their own Google Sheet:

ScenarioVariants per TenantSheets per TenantTotal Cells
Small store50013,000
Medium store5,000130,000
Large store (Nexus-sized)20,0001120,000
Enterprise100,0001600,000

All scenarios are comfortably within Google Sheets limits. Even the largest stores use less than 10% of the 10-million-cell capacity.


9.8 Supplemental Feed Test Results (Verified 2026-02-10)

A live test was conducted on the Nexus GMC account to validate the supplemental feed pipeline end-to-end.

Test Configuration

ParameterValue
Feed typeGoogle Sheets
Format6 columns (id + 5 custom labels)
Sample size10 active Nexus products
Sheet sharing“Anyone with the link” (Viewer)
Data sources linkedContent API - US, Content API - Local, Local Feed Partnership

Sample Products Tested

ProductPrioritySeasonCategoryStatusBrand Tier
New Era Colts Knit 2015priority-4winterheadwear-cold-weatherlow-inventoryname-brand
New Era Yankees 59FIFTYpriority-4winterheadwear-capsin-stockname-brand
G3 Patriots Hoodiepriority-0winterhoodies-sweatshirtsdead-stockoff-brand
Rebel Minds Puffer Jacketpriority-5winterouterwear-heavylow-inventoryoff-brand

Results

MetricResult
Products matched10/10 (100%)
Attribute names recognizedAll recognized
Issues foundZero
Processing time< 1 hour
Feed acceptedImmediately

Verification Steps Completed

  1. Created Google Sheet with 10 sample products and 6 columns
  2. Shared sheet publicly (Viewer access)
  3. Added as supplemental feed in GMC
  4. Linked to all 3 primary data sources
  5. Triggered manual update
  6. All 10 products matched within 1 hour
  7. All attribute names recognized with zero issues

Conclusion: The Google Sheets supplemental feed pipeline is confirmed working. The shopify_US_{productId}_{variantId} ID format matches correctly. This validates the MVP approach.


9.9 GMC ID Construction

AdPriority constructs GMC product IDs from Shopify product and variant IDs. The construction is deterministic and requires no GMC API lookup.

// backend/src/services/sync/gmc.ts

/**
 * Construct the Google Merchant Center product ID from Shopify IDs.
 *
 * Format: shopify_{country}_{productId}_{variantId}
 *
 * Verified against 124,060 products in Nexus GMC export (2026-02-10).
 * All products follow this format with country code "US".
 */
export function buildGmcProductId(
  shopifyProductId: number | string,
  shopifyVariantId: number | string,
  countryCode: string = 'US'
): string {
  return `shopify_${countryCode}_${shopifyProductId}_${shopifyVariantId}`;
}

// Examples:
// buildGmcProductId(8779355160808, 46050142748904)
//   => "shopify_US_8779355160808_46050142748904"
//
// buildGmcProductId(9128994570472, 47260097118440)
//   => "shopify_US_9128994570472_47260097118440"

Multi-Country Considerations

For SaaS merchants selling in multiple countries, the country code in the GMC ID varies:

CountryCodeID Example
United StatesUSshopify_US_123_456
CanadaCAshopify_CA_123_456
United KingdomGBshopify_GB_123_456
AustraliaAUshopify_AU_123_456

AdPriority stores the country code per tenant and uses it in ID construction. For the Nexus MVP, this is hardcoded to US.


9.10 Reconciliation Strategy

Data drift can occur when products are added or removed from GMC’s primary feed. AdPriority runs periodic reconciliation to ensure labels remain accurate.

Daily Reconciliation (Automated)

Daily Reconciliation Job (runs at 3:00 AM)
============================================

1. Fetch all active products from Shopify API
2. Compare with product_mappings database table:
   a. New products (in Shopify, not in DB)
      -> Create mapping, calculate priority, add to Sheet
   b. Removed products (in DB, not in Shopify)
      -> Mark deleted, remove from Sheet on next sync
   c. Changed products (type, tags, or vendor changed)
      -> Recalculate priority, update Sheet if changed
3. Regenerate complete Google Sheet
4. Log reconciliation report:
   - Products added: N
   - Products removed: N
   - Priorities changed: N
   - Errors: N
5. Alert if > 5% of products changed (unusual, may indicate
   bulk import or data issue)

Weekly GMC Verification (Pro Tier)

For Pro tier merchants with Content API access:

Weekly GMC Verification (runs Sunday 2:00 AM)
===============================================

1. Fetch product list from Content API
2. Compare custom labels with expected values:
   a. Missing labels -> Product may not be in Sheet
   b. Wrong labels   -> Stale data, queue update
   c. Unknown products -> New in GMC, investigate
3. Generate verification report
4. Alert on > 1% label mismatch rate

9.11 Chapter Summary

Google Merchant Center integration is the critical output layer of AdPriority. The app writes five custom labels to GMC products using a supplemental feed (Google Sheets for MVP, Content API for Pro tier). These labels encode priority score, season, category, inventory status, and brand tier – enabling Google Ads campaigns to segment products by business-relevant attributes.

Key verified facts for Nexus Clothing:

  • 124,060 variant-level products in GMC
  • All 5 custom labels effectively available (only 7 products had label_0 values)
  • Product ID format confirmed: shopify_US_{productId}_{variantId}
  • Supplemental feed test: 10/10 matched, zero issues
  • Google Sheets capacity: 1.2% of cell limit for active variants
  • Content API rate limit: 2 updates per day per product
  • End-to-end label propagation: 24-48 hours (Sheets), 1-6 hours (API)

Chapter 12: Google Ads Integration (Pro Tier)

Google Ads is the ultimate consumer of AdPriority’s custom labels. While AdPriority does not directly create or modify Google Ads campaigns in the MVP, understanding how custom labels drive Performance Max (PMAX) campaign structure is essential. This chapter covers the recommended campaign architecture, how PMAX uses custom labels for product segmentation, and the future Google Ads API integration planned for the Pro tier.


10.1 How PMAX Uses Custom Labels

Performance Max campaigns use listing groups (formerly product groups) to segment a merchant’s product catalog. Custom labels are one of the most powerful segmentation dimensions because they are the only attributes fully controlled by the merchant.

Product Group Hierarchy

Performance Max Campaign
|
+-- Asset Group: "All Products"
    |
    +-- Listing Group (root)
        |
        +-- Filter: custom_label_0 = "priority-5"
        |   (Products scored as maximum push)
        |
        +-- Filter: custom_label_0 = "priority-4"
        |   (Strong performers)
        |
        +-- Filter: custom_label_0 = "priority-3"
        |   (Standard products)
        |
        +-- Filter: custom_label_0 IN ("priority-2", "priority-1")
        |   (Low priority, minimal spend)
        |
        +-- Filter: custom_label_0 = "priority-0"
            (Excluded from advertising)

Why Custom Labels Matter for PMAX

Google’s PMAX algorithm optimizes bid strategy across all products in an asset group. Without segmentation, high-margin seasonal items compete for budget with dead stock and low-performers. Custom labels solve this by letting the merchant tell Google which products deserve more budget.

Without AdPriority Labels          With AdPriority Labels
===========================        ==========================

  Single PMAX Campaign               4 Focused Campaigns
  +---------------------+            +--------------------+
  |  ALL 20,000 items   |            | Priority 5 (800)   | --> Aggressive
  |  compete equally    |            +--------------------+
  |  for budget         |            | Priority 4-3 (8K)  | --> Standard
  |                     |            +--------------------+
  |  Google guesses     |            | Priority 2-1 (6K)  | --> Minimal
  |  which to show      |            +--------------------+
  +---------------------+            | Priority 0 (5.2K)  | --> Excluded
                                     +--------------------+

  Result: Budget wasted             Result: Budget focused
  on dead stock and                 on seasonal winners
  off-season items                  and high-margin products

AdPriority recommends a multi-campaign structure where each campaign targets a priority tier with its own budget and bidding strategy. This provides maximum control over spend allocation.

Campaign Architecture

+-----------------------------------------------------------------------+
|                     Google Ads Account: 298-086-1126                    |
|                         (Nexus Clothing)                               |
+-----------------------------------------------------------------------+
|                                                                        |
|  Campaign 1: "PMAX - Priority 5 - Maximum Push"                       |
|  +------------------------------------------------------------------+ |
|  | Budget:    $80/day (40% of total)                                 | |
|  | tROAS:     200% (aggressive, prioritize volume)                   | |
|  | Filter:    custom_label_0 = "priority-5"                          | |
|  | Products:  Seasonal peaks, new arrivals, high-margin items        | |
|  | Nexus est: ~800 variants (winter puffers, hoodies, new arrivals) | |
|  +------------------------------------------------------------------+ |
|                                                                        |
|  Campaign 2: "PMAX - Priority 4-3 - Active Products"                  |
|  +------------------------------------------------------------------+ |
|  | Budget:    $80/day (40% of total)                                 | |
|  | tROAS:     350% (standard, balanced)                              | |
|  | Filter:    custom_label_0 IN ("priority-4", "priority-3")         | |
|  | Products:  Steady sellers, year-round staples, name brands        | |
|  | Nexus est: ~8,000 variants (jeans, caps, standard t-shirts)      | |
|  +------------------------------------------------------------------+ |
|                                                                        |
|  Campaign 3: "PMAX - Priority 2-1 - Low Priority"                     |
|  +------------------------------------------------------------------+ |
|  | Budget:    $30/day (15% of total)                                 | |
|  | tROAS:     500% (high threshold, only show if very profitable)    | |
|  | Filter:    custom_label_0 IN ("priority-2", "priority-1")         | |
|  | Products:  Low-margin, off-season, accessories                    | |
|  | Nexus est: ~6,000 variants (underwear, socks, off-season items)  | |
|  +------------------------------------------------------------------+ |
|                                                                        |
|  Campaign 4: "PMAX - Priority 0 - Excluded"                           |
|  +------------------------------------------------------------------+ |
|  | Budget:    $0/day (PAUSED)                                        | |
|  | Status:    Paused                                                  | |
|  | Filter:    custom_label_0 = "priority-0"                          | |
|  | Products:  Dead stock, archived, out of stock                     | |
|  | Nexus est: ~5,200 variants (DEAD50 tagged, archived products)    | |
|  +------------------------------------------------------------------+ |
|                                                                        |
|  Total daily budget: ~$190/day ($5,700/month)                          |
+-----------------------------------------------------------------------+

Budget Allocation by Priority

PriorityCampaignBudget ShareDaily BudgettROAS TargetStrategy
5Maximum Push40%$80200%Aggressive growth – maximize impressions and conversions
4-3Active Products40%$80350%Balanced – steady ROAS with broad reach
2-1Low Priority15%$30500%Conservative – only serve when highly likely to convert
0Excluded0%$0N/APaused campaign, no spend
Unallocated5%$10Reserve for testing and catch-all

Why Multiple Campaigns (Not Asset Groups)

ApproachBudget ControltROAS ControlComplexity
Single campaign, multiple asset groupsShared budget (Google decides allocation)Single tROAS for allLow
Multiple campaigns (recommended)Independent budgets per tierIndependent tROAS per tierMedium
Single campaign, no segmentationNo controlNo controlLowest

Multiple campaigns give the merchant explicit budget and tROAS control per priority tier. With asset groups in a single campaign, Google’s algorithm decides how to split the budget, which often means high-volume low-margin products consume disproportionate spend.


10.3 Secondary Label Segmentation

Beyond the primary custom_label_0 (priority) segmentation, merchants can create additional product groups using the other labels for analysis and fine-tuning.

Seasonal Analysis

Using custom_label_1 (season) within each campaign:

Campaign: "PMAX - Priority 5 - Maximum Push"
|
+-- Asset Group: "Priority 5 - Winter Items"
|   Filter: custom_label_0 = "priority-5" AND custom_label_1 = "winter"
|
+-- Asset Group: "Priority 5 - Year-Round Items"
    Filter: custom_label_0 = "priority-5" AND custom_label_1 != "winter"

Category-Level Reporting

Using custom_label_2 (category) for performance reports:

Category (label_2)ImpressionsClicksConversionsROAS
jeans-pants45,0001,20085420%
hoodies-sweatshirts38,00098072380%
outerwear-heavy22,00065055510%
headwear-caps31,00075045290%
t-shirts52,0001,40060210%

This data, when fed back into AdPriority (Pro tier), enables data-driven priority adjustments.

Brand Tier Analysis

Using custom_label_4 (brand tier):

Brand Tier (label_4)Avg CPCConv RateROAS
name-brand$0.853.2%450%
store-brand$0.622.8%380%
off-brand$0.481.9%280%

10.4 Nexus Google Ads Account

PropertyValue
Google Ads Customer ID298-086-1126
Account typeStandard
Primary campaign typePerformance Max
Linked GMC accountYes (same Google account)
Current PMAX structureSingle campaign, minimal segmentation
Custom label usage in adsNone (labels are empty in GMC)

Current State vs. Target State

Current State (Feb 2026)           Target State (With AdPriority)
============================       ================================

1 PMAX Campaign                    4 PMAX Campaigns (by priority)
  - All products in one group        - Priority 5: Aggressive
  - Single budget                    - Priority 4-3: Standard
  - Single tROAS                     - Priority 2-1: Conservative
  - No custom label filtering        - Priority 0: Paused

No segmentation by:                Full segmentation by:
  - Season                           - Priority (label_0)
  - Category                         - Season (label_1)
  - Inventory status                 - Category (label_2)
  - Brand tier                       - Inventory status (label_3)
                                     - Brand tier (label_4)

Manual priority decisions          Automated priority scoring
  - Guesswork                        - Rules engine
  - Infrequent updates               - Seasonal calendar
  - No data backing                  - Tag-based modifiers

10.5 Google Ads API (Future Pro Tier)

The Google Ads API integration is planned for the Pro tier ($199/month). It enables AdPriority to read campaign performance data and provide automated recommendations.

Planned Capabilities

CapabilityAPI ResourcePhase
Read campaign performanceCampaignPerformanceViewPro v1
Read product-level metricsShoppingPerformanceViewPro v1
ROAS by priority tierCustom report aggregationPro v1
Automated priority recommendationsInternal scoring + API dataPro v2
Budget allocation suggestionsCampaign budget analysisPro v2
Campaign creation assistanceCampaignService.mutatePro v3 (future)

Authentication Requirements

RequirementValue
OAuth 2.0 scopehttps://www.googleapis.com/auth/adwords
Developer tokenRequired (apply via Google Ads API Center)
Token approvalBasic access initially, then Standard
Customer ID formatXXX-XXX-XXXX (e.g., 298-086-1126)
Manager accountOptional but recommended for multi-tenant

GAQL Queries (Google Ads Query Language)

Performance by product (Shopping Performance View):

SELECT
  segments.product_item_id,
  segments.product_custom_attribute0,
  metrics.impressions,
  metrics.clicks,
  metrics.cost_micros,
  metrics.conversions,
  metrics.conversions_value
FROM shopping_performance_view
WHERE segments.date DURING LAST_30_DAYS
ORDER BY metrics.conversions_value DESC
LIMIT 1000

Performance by priority tier (aggregated):

SELECT
  segments.product_custom_attribute0,
  metrics.impressions,
  metrics.clicks,
  metrics.cost_micros,
  metrics.conversions,
  metrics.conversions_value
FROM shopping_performance_view
WHERE segments.date DURING LAST_30_DAYS
  AND segments.product_custom_attribute0 IS NOT NULL

Campaign-level performance:

SELECT
  campaign.name,
  campaign.status,
  metrics.impressions,
  metrics.clicks,
  metrics.cost_micros,
  metrics.conversions,
  metrics.conversions_value,
  metrics.average_cpc
FROM campaign
WHERE campaign.advertising_channel_type = 'PERFORMANCE_MAX'
  AND segments.date DURING LAST_30_DAYS

Performance Tracking Dashboard (Pro Tier)

The Pro tier dashboard will display ROAS and key metrics segmented by priority tier:

+-----------------------------------------------------------------------+
|  AdPriority Pro - Performance by Priority Tier (Last 30 Days)          |
+-----------------------------------------------------------------------+
|                                                                        |
|  Priority 5 - Maximum Push                                             |
|  +---------+----------+--------+--------+----------+--------+         |
|  | Impress | Clicks   | Cost   | Conv   | Revenue  | ROAS   |         |
|  | 85,000  | 2,400    | $1,920 | 168    | $12,600  | 656%   |         |
|  +---------+----------+--------+--------+----------+--------+         |
|                                                                        |
|  Priority 4-3 - Active Products                                       |
|  +---------+----------+--------+--------+----------+--------+         |
|  | Impress | Clicks   | Cost   | Conv   | Revenue  | ROAS   |         |
|  | 120,000 | 3,100    | $1,860 | 195    | $9,750   | 524%   |         |
|  +---------+----------+--------+--------+----------+--------+         |
|                                                                        |
|  Priority 2-1 - Low Priority                                          |
|  +---------+----------+--------+--------+----------+--------+         |
|  | Impress | Clicks   | Cost   | Conv   | Revenue  | ROAS   |         |
|  | 45,000  | 850      | $510   | 38     | $1,520   | 298%   |         |
|  +---------+----------+--------+--------+----------+--------+         |
|                                                                        |
|  Priority 0 - Excluded                                                 |
|  +---------+----------+--------+--------+----------+--------+         |
|  | Impress | Clicks   | Cost   | Conv   | Revenue  | ROAS   |         |
|  | 0       | 0        | $0     | 0      | $0       | N/A    |         |
|  +---------+----------+--------+--------+----------+--------+         |
|                                                                        |
|  TOTAL                                                                 |
|  +---------+----------+--------+--------+----------+--------+         |
|  | 250,000 | 6,350    | $4,290 | 401    | $23,870  | 556%   |         |
|  +---------+----------+--------+--------+----------+--------+         |
|                                                                        |
+-----------------------------------------------------------------------+

Key Performance Indicators

KPIDefinitionTarget
ROAS by priority tierRevenue / Cost per tierPriority 5 > 400%, Priority 2-1 > 200%
Cost per conversionAd spend / ConversionsLower for higher priority tiers
Impression shareOur impressions / Total eligible impressionsPriority 5 > 80%
Click-through rateClicks / ImpressionsPriority 5 > 3%, overall > 2%
Conversion rateConversions / ClicksPriority 5 > 5%
Wasted spendSpend on priority-0 products (should be $0)$0

10.6 Automated Recommendations (Pro v2)

In the second phase of Pro tier development, AdPriority will use Google Ads performance data to generate priority adjustment recommendations.

Recommendation Logic

Recommendation Engine Flow
===========================

1. Fetch last 30 days of shopping performance data
   (product-level: impressions, clicks, conversions, revenue, cost)

2. Calculate per-product metrics:
   - ROAS = conversions_value / (cost_micros / 1_000_000)
   - Conversion rate = conversions / clicks
   - Cost efficiency = cost_micros / conversions

3. Compare actual performance vs. expected for priority tier:

   Product at Priority 3 with ROAS = 650%?
   --> RECOMMEND: Increase to Priority 5
       Reason: "ROAS of 650% is 86% above the tier average.
                This product is undervalued at Priority 3."

   Product at Priority 5 with ROAS = 120%?
   --> RECOMMEND: Decrease to Priority 2
       Reason: "ROAS of 120% is 70% below the tier average.
                Budget is being wasted on this product."

   Product at Priority 4 with 0 conversions in 30 days?
   --> RECOMMEND: Decrease to Priority 1
       Reason: "Zero conversions in 30 days despite 2,400
                impressions. Move budget to better performers."

4. Present recommendations in dashboard with one-click accept

Safety Guardrails

GuardrailRule
Maximum change per cycle+/- 2 priority levels per recommendation
Minimum data threshold100+ impressions before recommending decrease
Manual lock respectedNever recommend changes to locked products
Seasonal awarenessDo not downgrade seasonal items approaching their peak
Recommendation frequencyWeekly (not daily) to avoid churn

10.7 Implementation Timeline

PhaseFeatureTierTimeline
MVPLabels in GMC, campaign structure guide (manual setup)StarterPhase 0
Pro v1Read-only Google Ads performance dataProPhase 2
Pro v1ROAS by priority tier dashboardProPhase 2
Pro v2Automated priority recommendationsProPhase 3
Pro v2Budget allocation suggestionsProPhase 3
Pro v3Campaign creation assistantProFuture
Pro v3Automated bid adjustmentsProFuture

10.8 Chapter Summary

Google Ads is the downstream beneficiary of AdPriority’s custom labels. The recommended campaign structure uses four PMAX campaigns segmented by custom_label_0 (priority), with independent budgets and tROAS targets per tier. Priority 5 products receive aggressive spend, while Priority 0 products are excluded entirely.

For the MVP and Starter/Growth tiers, AdPriority provides a campaign setup guide and the labels in GMC. Merchants configure their Google Ads campaigns manually. The Pro tier adds Google Ads API integration for performance tracking, ROAS analysis by tier, and automated priority recommendations.

Key takeaways for Nexus Clothing (Account 298-086-1126):

  • Current state: Single PMAX campaign, no custom label segmentation
  • Target state: 4 campaigns segmented by priority tier
  • Estimated active variant distribution: ~800 at P5, ~8,000 at P4-3, ~6,000 at P2-1, ~5,200 at P0
  • Pro tier adds: Performance data, ROAS by tier, automated recommendations
  • No Google Ads API access needed for MVP – labels in GMC are sufficient

Chapter 13: Google Sheets API (MVP Feed Delivery)

Google Sheets is AdPriority’s MVP delivery mechanism for getting custom labels into Google Merchant Center. Instead of authenticating against the Content API for each merchant’s GMC account, AdPriority writes priority data to a Google Sheet, which GMC fetches automatically as a supplemental feed. This chapter covers the full Sheets API integration: authentication, sheet structure, write operations, quota management, and the tenant-per-sheet architecture.


11.1 Why Sheets for MVP

The supplemental feed pipeline must get priority labels from AdPriority’s database into Google Merchant Center. There are two paths: the Content API (direct GMC writes) and Google Sheets (indirect, GMC pulls from the Sheet). For the MVP, Sheets wins decisively.

Decision Matrix

FactorGoogle SheetsContent API
GMC authentication neededNo (GMC fetches the Sheet)Yes (OAuth per merchant)
Setup complexityLow (create Sheet, link in GMC)High (OAuth consent, credentials)
Merchant onboarding steps1 (link Sheet to GMC feed)5+ (OAuth flow, permissions, account linking)
Update latency~24 hours (daily fetch)~1-6 hours (near real-time)
DebuggingOpen the Sheet and lookAPI logs, response parsing
CostFreeFree (but quota-limited)
Manual override possibleYes (edit cell directly)No (requires API call)
Rate limitsSheets API: 100 req/100 secContent API: 2 updates/day/product
Suitable for MVPYesOverkill
Suitable for enterprise scaleNo (single-sheet limits)Yes

The Key Insight

The merchant must manually add the supplemental feed URL in GMC regardless of approach. With Sheets, that one-time setup is the only GMC configuration needed. With the Content API, the merchant must also complete a Google OAuth flow and grant API access to their GMC account – a significant friction point during onboarding.


11.2 End-to-End Data Flow

+------------------+     +------------------+     +------------------+
|                  |     |                  |     |                  |
|   AdPriority     |     |  Google Sheets   |     |  Google Merchant |
|   Backend        |     |  API v4          |     |  Center          |
|                  |     |                  |     |                  |
+--------+---------+     +--------+---------+     +--------+---------+
         |                        |                        |
   1. Calculate priority          |                        |
      scores for all              |                        |
      active variants             |                        |
         |                        |                        |
   2. Build row data:             |                        |
      [gmcId, label0..4]          |                        |
         |                        |                        |
   3. POST to Sheets API          |                        |
      values.update               |                        |
      (batch write)               |                        |
         +----------------------->|                        |
         |                        |                        |
         |               4. Sheet updated                  |
         |                  with all rows                   |
         |                        |                        |
         |                        |  5. GMC fetches Sheet  |
         |                        |     on schedule (daily)|
         |                        |<-----------------------+
         |                        |                        |
         |                        |  6. GMC matches IDs    |
         |                        |     and applies labels |
         |                        +----------------------->|
         |                        |                        |
         |                        |               7. Labels visible
         |                        |                  in GMC and
         |                        |                  Google Ads

11.3 Google Sheets API v4

Authentication

AdPriority uses a service account for Sheets API access. This avoids per-merchant OAuth for the Sheets side – the service account owns and manages all tenant sheets.

Authentication Setup
=====================

1. Create service account in Google Cloud Console
   - Project: adpriority-production
   - Role: Editor (for Sheets the account creates)

2. Download JSON key file
   - Store encrypted, never commit to repository
   - Load via GOOGLE_SERVICE_ACCOUNT_KEY env variable

3. Enable Google Sheets API
   - APIs & Services -> Enable Google Sheets API

4. Service account email (for sharing)
   - e.g., adpriority@adpriority-prod.iam.gserviceaccount.com

Required OAuth scope:

https://www.googleapis.com/auth/spreadsheets

This grants read/write access to Google Sheets owned by or shared with the service account.

Client Initialization

// backend/src/integrations/google/sheets.ts
import { google } from 'googleapis';
import { JWT } from 'google-auth-library';

const SCOPES = ['https://www.googleapis.com/auth/spreadsheets'];

function getAuthClient(): JWT {
  const credentials = JSON.parse(process.env.GOOGLE_SERVICE_ACCOUNT_KEY!);

  return new JWT({
    email: credentials.client_email,
    key: credentials.private_key,
    scopes: SCOPES,
  });
}

export function getSheetsClient() {
  const auth = getAuthClient();
  return google.sheets({ version: 'v4', auth });
}

11.4 Sheet Structure

Each tenant (Shopify store) gets its own Google Sheet. The sheet follows a strict structure that GMC expects for supplemental feeds.

Header Row

+----+----------------+----------------+----------------+----------------+----------------+
| A  | B              | C              | D              | E              | F              |
+----+----------------+----------------+----------------+----------------+----------------+
| id | custom_label_0 | custom_label_1 | custom_label_2 | custom_label_3 | custom_label_4 |
+----+----------------+----------------+----------------+----------------+----------------+

Column requirements:

  • Column A (id) must be the first column
  • Column names must exactly match GMC attribute names
  • Header row is row 1 (required by GMC)

Data Rows

One row per variant. For Nexus Clothing with ~20,000 active variants:

Row 1 (header):
id                                          | custom_label_0 | custom_label_1 | custom_label_2      | custom_label_3 | custom_label_4

Row 2:
shopify_US_8779355160808_46050142748904     | priority-4     | winter         | jeans-pants         | in-stock       | name-brand

Row 3:
shopify_US_9128994570472_47260097118440     | priority-5     | winter         | jeans-pants         | new-arrival    | name-brand

Row 4:
shopify_US_9057367064808_47004004712680     | priority-5     | winter         | outerwear-heavy     | low-inventory  | off-brand

Row 5:
shopify_US_9238797418728_47750439567592     | priority-3     | winter         | headwear-caps       | in-stock       | name-brand

Row 6:
shopify_US_7609551716584_42582395650280     | priority-0     | winter         | hoodies-sweatshirts | dead-stock     | off-brand

...

Row 20,001:
shopify_US_8361353412840_44841122955496     | priority-2     | winter         | underwear-socks     | in-stock       | name-brand

Sheet Size Calculations

MetricValue
Columns per row6
Rows for Nexus (active variants)~20,000
Total cells (Nexus)~120,000
Google Sheets cell limit10,000,000
Usage percentage1.2%
Maximum store size at 6 columns~1,666,666 variants

Even the largest Shopify stores (100,000+ variants) would use only 600,000 cells (6% of the limit). The Google Sheets approach is viable for the vast majority of merchants.


11.5 Write Operations

Full Sheet Write (Sync Operation)

The primary write pattern is a full replacement: clear the sheet and write all current data. This ensures the sheet always reflects the current state of priorities without complex diff logic.

// backend/src/services/sync/sheets-writer.ts
import { getSheetsClient } from '../../integrations/google/sheets';

const HEADER_ROW = [
  'id',
  'custom_label_0',
  'custom_label_1',
  'custom_label_2',
  'custom_label_3',
  'custom_label_4',
];

interface ProductLabelRow {
  gmcProductId: string;   // shopify_US_{productId}_{variantId}
  priority: number;       // 0-5
  season: string;         // winter, spring, summer, fall
  category: string;       // normalized category group
  status: string;         // in-stock, low-inventory, etc.
  brandTier: string;      // name-brand, store-brand, off-brand
}

export async function writePrioritySheet(
  spreadsheetId: string,
  products: ProductLabelRow[]
): Promise<{ rowsWritten: number }> {
  const sheets = getSheetsClient();

  // Build all rows: header + data
  const dataRows = products.map(p => [
    p.gmcProductId,
    `priority-${p.priority}`,
    p.season,
    p.category,
    p.status,
    p.brandTier,
  ]);

  const allRows = [HEADER_ROW, ...dataRows];

  // Step 1: Clear existing data
  await sheets.spreadsheets.values.clear({
    spreadsheetId,
    range: 'Sheet1',
  });

  // Step 2: Write all rows
  await sheets.spreadsheets.values.update({
    spreadsheetId,
    range: 'Sheet1!A1',
    valueInputOption: 'RAW',
    requestBody: {
      values: allRows,
    },
  });

  return { rowsWritten: dataRows.length };
}

Chunked Write for Large Catalogs

For stores with more than 50,000 variants, a single values.update call may exceed payload limits. Use batch updates:

export async function writePrioritySheetChunked(
  spreadsheetId: string,
  products: ProductLabelRow[],
  chunkSize: number = 10000
): Promise<{ rowsWritten: number }> {
  const sheets = getSheetsClient();

  // Clear sheet first
  await sheets.spreadsheets.values.clear({
    spreadsheetId,
    range: 'Sheet1',
  });

  // Write header
  await sheets.spreadsheets.values.update({
    spreadsheetId,
    range: 'Sheet1!A1',
    valueInputOption: 'RAW',
    requestBody: {
      values: [HEADER_ROW],
    },
  });

  // Write data in chunks
  let rowsWritten = 0;

  for (let i = 0; i < products.length; i += chunkSize) {
    const chunk = products.slice(i, i + chunkSize);
    const startRow = i + 2; // +1 for header, +1 for 1-indexed

    const rows = chunk.map(p => [
      p.gmcProductId,
      `priority-${p.priority}`,
      p.season,
      p.category,
      p.status,
      p.brandTier,
    ]);

    await sheets.spreadsheets.values.update({
      spreadsheetId,
      range: `Sheet1!A${startRow}`,
      valueInputOption: 'RAW',
      requestBody: { values: rows },
    });

    rowsWritten += rows.length;

    // Respect rate limits between chunks
    await sleep(1000);
  }

  return { rowsWritten };
}

11.6 Sheet Lifecycle Management

Creating a Sheet for a New Tenant

When a merchant installs AdPriority and completes setup, the backend creates a new Google Sheet:

// backend/src/services/sync/sheets-manager.ts
import { getSheetsClient } from '../../integrations/google/sheets';

export async function createTenantSheet(
  storeName: string
): Promise<{ spreadsheetId: string; spreadsheetUrl: string }> {
  const sheets = getSheetsClient();

  // Create new spreadsheet
  const response = await sheets.spreadsheets.create({
    requestBody: {
      properties: {
        title: `AdPriority - ${storeName} - Custom Labels`,
      },
      sheets: [
        {
          properties: {
            title: 'Sheet1',
            gridProperties: {
              frozenRowCount: 1, // Freeze header row
            },
          },
        },
      ],
    },
  });

  const spreadsheetId = response.data.spreadsheetId!;
  const spreadsheetUrl = response.data.spreadsheetUrl!;

  // Share publicly (Viewer) so GMC can fetch it
  const drive = google.drive({ version: 'v3', auth: getAuthClient() });
  await drive.permissions.create({
    fileId: spreadsheetId,
    requestBody: {
      role: 'reader',
      type: 'anyone',
    },
  });

  // Write header row
  await sheets.spreadsheets.values.update({
    spreadsheetId,
    range: 'Sheet1!A1',
    valueInputOption: 'RAW',
    requestBody: {
      values: [HEADER_ROW],
    },
  });

  return { spreadsheetId, spreadsheetUrl };
}

Storing the Sheet Reference

The spreadsheet ID and URL are stored in the stores database table:

-- In the stores table
ALTER TABLE stores ADD COLUMN gmc_sheet_id VARCHAR(255);
ALTER TABLE stores ADD COLUMN gmc_sheet_url TEXT;

-- Example record for Nexus
UPDATE stores
SET gmc_sheet_id = '1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms',
    gmc_sheet_url = 'https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit'
WHERE shop_domain = 'nexus-clothes.myshopify.com';

Sheet Lifecycle

Tenant Sheet Lifecycle
=======================

1. INSTALL
   Merchant installs app
   -> Create new Google Sheet
   -> Write header row
   -> Share publicly (Viewer)
   -> Store spreadsheet ID in database
   -> Show URL to merchant for GMC setup

2. INITIAL SYNC
   Full product catalog imported from Shopify
   -> Calculate all priorities
   -> Write all rows to Sheet (~20,000 for Nexus)
   -> Merchant adds Sheet URL as supplemental feed in GMC

3. ONGOING SYNC (daily at 2:00 AM per tenant timezone)
   -> Recalculate priorities (seasonal changes, new products)
   -> Full Sheet rewrite with current data
   -> GMC fetches updated Sheet on its schedule

4. ON-DEMAND SYNC (triggered by merchant or webhook)
   -> Recalculate affected products only
   -> Full Sheet rewrite (simpler than partial updates)

5. UNINSTALL
   Merchant removes app
   -> Delete Sheet via Drive API
   -> Remove spreadsheet ID from database
   -> Merchant must manually remove supplemental feed from GMC

11.7 Merchant Onboarding: GMC Setup Instructions

After AdPriority creates the Sheet, the merchant must perform a one-time setup in Google Merchant Center. The app displays these instructions in the Settings page:

+-----------------------------------------------------------------------+
|  AdPriority Settings > Google Merchant Center Setup                     |
+-----------------------------------------------------------------------+
|                                                                        |
|  Your supplemental feed is ready!                                      |
|                                                                        |
|  Sheet URL:                                                            |
|  https://docs.google.com/spreadsheets/d/1BxiMVs.../edit               |
|  [Copy URL]                                                            |
|                                                                        |
|  Follow these steps to connect it to Google Merchant Center:           |
|                                                                        |
|  1. Open Google Merchant Center (merchants.google.com)                 |
|  2. Go to Products > Feeds                                             |
|  3. Click "Add supplemental feed"                                      |
|  4. Choose "Google Sheets" as the source                               |
|  5. Paste the Sheet URL above                                          |
|  6. Select "Sheet1" as the tab                                         |
|  7. Set fetch schedule to "Daily"                                      |
|  8. Click "Create feed"                                                |
|  9. Link to your primary data source(s)                                |
|                                                                        |
|  That is it! GMC will fetch your priority labels daily.                |
|                                                                        |
|  [Verify Connection]  [View Sheet]  [Need Help?]                       |
|                                                                        |
+-----------------------------------------------------------------------+

11.8 Quota and Rate Limits

Google Sheets API Quotas

QuotaLimitAdPriority Impact
Read requests per minute per project60Minimal reads (only for verification)
Write requests per minute per project60Primary concern during bulk sync
Read requests per minute per user60N/A (service account is the user)
Write requests per minute per user60Shared across all tenants
Requests per 100 seconds per project100Primary rate limit to respect

Rate Limit Strategy

Rate Limit Budget (per 100-second window)
==========================================

100 requests available per 100 seconds

Sync operations per tenant:
  - 1 clear request
  - 1 write request (small stores, < 50K rows)
  - OR 1 clear + N chunk writes (large stores)

Maximum concurrent tenant syncs:
  - Small stores (1 clear + 1 write = 2 requests): 50 tenants per window
  - Large stores (1 clear + 5 chunks = 6 requests): 16 tenants per window

Strategy:
  - Queue syncs with 2-second spacing between tenants
  - Small stores: ~50 tenants per 100-second window
  - Stagger sync times across timezones
  - Peak: 2:00 AM in each timezone

Retry with Backoff

// backend/src/integrations/google/sheets-retry.ts

const SHEETS_RETRY_CONFIG = {
  maxRetries: 5,
  initialDelayMs: 2000,     // Start at 2 seconds
  backoffMultiplier: 2,     // Double each retry
  maxDelayMs: 60000,        // Cap at 60 seconds
  retryableCodes: [429, 500, 503],
};

export async function sheetsApiWithRetry<T>(
  operation: () => Promise<T>
): Promise<T> {
  let delay = SHEETS_RETRY_CONFIG.initialDelayMs;

  for (let attempt = 0; attempt <= SHEETS_RETRY_CONFIG.maxRetries; attempt++) {
    try {
      return await operation();
    } catch (error: any) {
      const statusCode = error.response?.status || error.code;

      if (
        attempt === SHEETS_RETRY_CONFIG.maxRetries ||
        !SHEETS_RETRY_CONFIG.retryableCodes.includes(statusCode)
      ) {
        throw error;
      }

      console.warn(
        `Sheets API attempt ${attempt + 1} failed (${statusCode}). ` +
        `Retrying in ${delay}ms...`
      );

      await new Promise(resolve => setTimeout(resolve, delay));
      delay = Math.min(
        delay * SHEETS_RETRY_CONFIG.backoffMultiplier,
        SHEETS_RETRY_CONFIG.maxDelayMs
      );
    }
  }

  throw new Error('Unreachable');
}

11.9 Sync Scheduling

Scheduled Sync (Daily)

Each tenant’s Sheet is updated daily. The sync job runs as a Bull queue worker:

// backend/src/scheduler/sync.ts
import { Queue, Worker } from 'bullmq';

const syncQueue = new Queue('sheet-sync', { connection: redisConfig });

// Schedule daily sync for each active tenant
export async function scheduleDailySyncs(): Promise<void> {
  const stores = await db.store.findMany({
    where: { status: 'active', gmc_sheet_id: { not: null } },
  });

  for (const store of stores) {
    await syncQueue.add(
      'daily-sync',
      { storeId: store.id },
      {
        repeat: {
          pattern: '0 2 * * *', // 2:00 AM daily
        },
        jobId: `daily-sync-${store.id}`,
      }
    );
  }
}

// Worker processes sync jobs
const syncWorker = new Worker('sheet-sync', async (job) => {
  const { storeId } = job.data;

  // 1. Fetch current priorities from database
  const products = await db.productMapping.findMany({
    where: { storeId, shopifyStatus: 'active' },
  });

  // 2. Build label rows
  const rows: ProductLabelRow[] = products.map(p => ({
    gmcProductId: p.gmcProductId,
    priority: p.priorityScore,
    season: p.customLabel1,
    category: p.customLabel2,
    status: p.customLabel3,
    brandTier: p.customLabel4,
  }));

  // 3. Write to Sheet
  const store = await db.store.findUnique({ where: { id: storeId } });
  const result = await writePrioritySheet(store.gmcSheetId, rows);

  // 4. Log sync result
  await db.syncLog.create({
    data: {
      storeId,
      type: 'sheet_write',
      status: 'success',
      rowsWritten: result.rowsWritten,
      completedAt: new Date(),
    },
  });

  return result;
}, { connection: redisConfig });

Event-Triggered Sync

Certain events trigger an immediate Sheet update rather than waiting for the daily schedule:

EventTriggerSync Behavior
Seasonal transitionCalendar date boundary crossedFull rewrite (all priorities change)
Bulk rule changeMerchant modifies scoring rulesFull rewrite
Manual sync buttonMerchant clicks “Sync Now”Full rewrite
Product webhookNew product createdDebounced – wait 5 min, then rewrite

Sync Status Tracking

The dashboard displays sync status for the merchant:

+-----------------------------------------------------------------------+
|  Last Sync: 2026-02-10 02:00:14 AM                                    |
|  Status: Success                                                       |
|  Rows Written: 18,472                                                  |
|  Duration: 4.2 seconds                                                 |
|  Next Sync: 2026-02-11 02:00 AM                                       |
|                                                                        |
|  [Sync Now]  [View Sheet]  [Sync History]                              |
+-----------------------------------------------------------------------+

11.10 Error Handling

Common Failure Modes

ErrorCauseResolution
429 Too Many RequestsSheets API quota exceededBackoff and retry (see 11.8)
403 ForbiddenService account lost access to SheetRe-share Sheet with service account
404 Not FoundSheet was deleted externallyCreate new Sheet, update database, notify merchant
400 Invalid RangeSheet structure corruptedClear and rewrite from scratch
503 Service UnavailableGoogle Sheets temporary outageRetry with backoff
TimeoutLarge payload or slow networkChunk writes into smaller batches

Sheet Health Check

A periodic job verifies that each tenant’s Sheet is accessible and correctly structured:

export async function verifySheetHealth(
  spreadsheetId: string
): Promise<{ healthy: boolean; issue?: string }> {
  try {
    const sheets = getSheetsClient();

    // Read first row to verify header
    const response = await sheets.spreadsheets.values.get({
      spreadsheetId,
      range: 'Sheet1!A1:F1',
    });

    const header = response.data.values?.[0];

    if (!header || header[0] !== 'id') {
      return { healthy: false, issue: 'Missing or incorrect header row' };
    }

    if (header.length !== 6) {
      return { healthy: false, issue: `Expected 6 columns, found ${header.length}` };
    }

    return { healthy: true };
  } catch (error: any) {
    return { healthy: false, issue: error.message };
  }
}

11.11 Transition to Content API (Growth Path)

As AdPriority scales, some merchants will outgrow the Sheets approach. The transition path is:

Tier Progression
=================

Starter ($29/mo)     -->  Google Sheets (daily sync)
                          - One Sheet per tenant
                          - Full rewrite daily
                          - ~24-48h label propagation

Growth ($79/mo)      -->  Google Sheets (daily sync + on-demand)
                          - Same Sheet approach
                          - Manual "Sync Now" button
                          - Seasonal auto-transitions

Pro ($199/mo)        -->  Content API (near real-time)
                          - Direct GMC API writes
                          - 1-6h label propagation
                          - Google Ads performance data
                          - Falls back to Sheets if API fails

The Sheet remains as a fallback and debugging tool even for Pro tier merchants. If the Content API encounters quota issues, the system degrades gracefully to Sheet-based sync.


11.12 Security Considerations

ConcernMitigation
Sheet contains product IDs (not sensitive)IDs are already public in GMC; no PII in the Sheet
Service account keyStored encrypted in environment variable, never in repository
Sheet publicly readableRequired for GMC to fetch; contains only IDs and label strings
Merchant cannot edit the SheetService account is the owner; merchant has no write access. Accidental edits are overwritten on next sync.
Sheet deletion by merchantNot possible (service account owns it). If GMC supplemental feed is removed, labels stop updating but no data is lost.

11.13 Chapter Summary

Google Sheets is the ideal MVP delivery mechanism for AdPriority’s custom labels. The Sheets API v4 provides a simple, reliable, and free way to maintain supplemental feeds that GMC fetches daily. Each tenant gets a dedicated Sheet created by AdPriority’s service account, with the spreadsheet ID stored in the database and the URL provided to the merchant for one-time GMC setup.

Key specifications:

  • Sheet structure: 6 columns (id + 5 custom labels), one row per variant
  • Nexus store: ~20,000 rows, 120,000 cells (1.2% of Sheet limit)
  • Write pattern: Full clear-and-rewrite on each sync
  • Schedule: Daily at 2:00 AM + event-triggered syncs
  • Rate limit: 100 requests per 100 seconds (supports ~50 small-store syncs per window)
  • Retry: Exponential backoff starting at 2 seconds, up to 60 seconds, 5 max retries
  • One Sheet per tenant, created automatically on app setup
  • Service account owns all Sheets (no per-merchant Google OAuth needed)
  • Fallback mechanism for Pro tier when Content API hits quota limits

Chapter 14: Database Schema

Database Strategy

AdPriority stores all tenant data in a single PostgreSQL 16 database running on the shared postgres16 container. The schema follows a multi-tenant design with row-level isolation: every table includes a tenant_id foreign key, and every query is scoped to the authenticated tenant. This approach balances simplicity (one database to manage) with security (tenants cannot see each other’s data).

The data model captures eight core concerns: tenant identity, product catalog, variant-level scoring, priority rules, seasonal calendars, sync audit trails, billing state, and category mappings. Each concern maps to a dedicated table with clear foreign key relationships.

DATABASE: adpriority_db
USER:     adpriority_user
HOST:     postgres16:5432 (internal) / localhost:5433 (external)
SCHEMA:   public
ORM:      Prisma 5.x with PostgreSQL provider

Entity Relationship Diagram

                        +-------------------+
                        |     tenants       |
                        +-------------------+
                        | id (PK, UUID)     |
                        | shopify_shop_domain (UNIQUE)
                        | shopify_access_token (encrypted)
                        | plan_tier         |
                        | gmc_merchant_id   |
                        | google_sheet_id   |
                        | google_sheet_url  |
                        | status            |
                        | created_at        |
                        | updated_at        |
                        +--------+----------+
                                 |
              +------------------+------------------+------------------+
              |                  |                  |                  |
              v                  v                  v                  v
    +---------+------+  +-------+--------+  +------+---------+  +----+----------+
    |   products     |  | category_rules |  | seasonal_      |  |   billing     |
    +----------------+  +----------------+  | calendars      |  +---------------+
    | id (PK)        |  | id (PK)        |  +----------------+  | id (PK)       |
    | tenant_id (FK) |  | tenant_id (FK) |  | id (PK)        |  | tenant_id (FK)|
    | shopify_       |  | product_type_  |  | tenant_id (FK) |  | shopify_      |
    |  product_id    |  |  pattern       |  | name           |  |  charge_id    |
    | title          |  | season         |  | season         |  | plan          |
    | product_type   |  | base_priority  |  | start_month    |  | status        |
    | vendor         |  | modifiers      |  | end_month      |  | activated_at  |
    | tags           |  +----------------+  | category_      |  +---------------+
    | status         |                      |  overrides     |
    +-------+--------+                      +----------------+
            |
            v
    +-------+--------+
    |    variants     |
    +----------------+
    | id (PK)        |
    | tenant_id (FK) |
    | product_id (FK)|
    | shopify_       |
    |  variant_id    |
    | sku            |
    | price          |
    | inventory_     |
    |  quantity      |
    | gmc_product_id |  <-- generated: shopify_US_{product.shopify_product_id}_{shopify_variant_id}
    +-------+--------+
            |
            v
    +-------+-----------+
    |  priority_scores   |
    +--------------------+
    | id (PK)            |
    | tenant_id (FK)     |
    | variant_id (FK)    |
    | priority (0-5)     |
    | custom_label_0-4   |
    | calculated_at      |
    | override           |
    | override_reason    |
    +--------------------+

    +--------------------+
    |    sync_logs       |
    +--------------------+
    | id (PK)            |
    | tenant_id (FK)     |
    | sync_type          |
    | status             |
    | products_synced    |
    | errors             |
    | started_at         |
    | completed_at       |
    +--------------------+

Complete Prisma Schema

The following schema defines every model, relationship, index, and constraint used in AdPriority. Each model maps to a snake_case PostgreSQL table via @@map(), while TypeScript fields use camelCase.

// prisma/schema.prisma

generator client {
  provider        = "prisma-client-js"
  previewFeatures = ["fullTextSearch"]
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

// ===========================================================================
// TENANTS - One row per installed Shopify store
// ===========================================================================

model Tenant {
  id                  String   @id @default(uuid()) @db.Uuid
  shopifyShopDomain   String   @unique @map("shopify_shop_domain") @db.VarChar(255)
  shopifyAccessToken  String?  @map("shopify_access_token") @db.Text
  planTier            PlanTier @default(starter) @map("plan_tier")
  gmcMerchantId       String?  @map("gmc_merchant_id") @db.VarChar(50)
  googleSheetId       String?  @map("google_sheet_id") @db.VarChar(255)
  googleSheetUrl      String?  @map("google_sheet_url") @db.Text
  status              TenantStatus @default(active)
  createdAt           DateTime @default(now()) @map("created_at")
  updatedAt           DateTime @updatedAt @map("updated_at")

  // Relations
  products            Product[]
  variants            Variant[]
  priorityScores      PriorityScore[]
  categoryRules       CategoryRule[]
  seasonalCalendars   SeasonalCalendar[]
  syncLogs            SyncLog[]
  billing             Billing?

  @@index([status])
  @@map("tenants")
}

enum PlanTier {
  starter
  growth
  pro
  enterprise
}

enum TenantStatus {
  active
  inactive
  suspended
  uninstalled
}

// ===========================================================================
// PRODUCTS - Shopify products cached locally
// ===========================================================================

model Product {
  id                String        @id @default(uuid()) @db.Uuid
  tenantId          String        @map("tenant_id") @db.Uuid
  tenant            Tenant        @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  shopifyProductId  BigInt        @map("shopify_product_id")
  title             String        @db.VarChar(500)
  productType       String?       @map("product_type") @db.VarChar(255)
  vendor            String?       @db.VarChar(255)
  tags              String[]      @default([])
  status            ProductStatus @default(active)
  createdAt         DateTime      @default(now()) @map("created_at")
  updatedAt         DateTime      @updatedAt @map("updated_at")

  // Relations
  variants          Variant[]

  @@unique([tenantId, shopifyProductId], map: "uq_tenant_shopify_product")
  @@index([tenantId])
  @@index([tenantId, productType], map: "idx_products_tenant_type")
  @@index([tenantId, status], map: "idx_products_tenant_status")
  @@index([tenantId, shopifyProductId], map: "idx_products_tenant_shopify_id")
  @@map("products")
}

enum ProductStatus {
  active
  archived
  draft
}

// ===========================================================================
// VARIANTS - Shopify variants (the unit synced to GMC)
// ===========================================================================

model Variant {
  id                String   @id @default(uuid()) @db.Uuid
  tenantId          String   @map("tenant_id") @db.Uuid
  tenant            Tenant   @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  productId         String   @map("product_id") @db.Uuid
  product           Product  @relation(fields: [productId], references: [id], onDelete: Cascade)
  shopifyVariantId  BigInt   @map("shopify_variant_id")
  sku               String?  @db.VarChar(255)
  price             Decimal  @default(0) @db.Decimal(10, 2)
  inventoryQuantity Int      @default(0) @map("inventory_quantity")

  // GMC product ID is generated at the application layer because Prisma
  // does not support computed columns that reference joined tables.
  // Format: shopify_US_{product.shopifyProductId}_{shopifyVariantId}
  gmcProductId      String?  @map("gmc_product_id") @db.VarChar(255)

  createdAt         DateTime @default(now()) @map("created_at")
  updatedAt         DateTime @updatedAt @map("updated_at")

  // Relations
  priorityScore     PriorityScore?

  @@unique([tenantId, shopifyVariantId], map: "uq_tenant_shopify_variant")
  @@index([tenantId])
  @@index([tenantId, productId], map: "idx_variants_tenant_product")
  @@index([tenantId, inventoryQuantity], map: "idx_variants_tenant_inventory")
  @@index([gmcProductId], map: "idx_variants_gmc_product_id")
  @@map("variants")
}

// ===========================================================================
// PRIORITY SCORES - The core scoring output per variant
// ===========================================================================

model PriorityScore {
  id              String   @id @default(uuid()) @db.Uuid
  tenantId        String   @map("tenant_id") @db.Uuid
  tenant          Tenant   @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  variantId       String   @unique @map("variant_id") @db.Uuid
  variant         Variant  @relation(fields: [variantId], references: [id], onDelete: Cascade)
  priority        Int      @default(3) @db.SmallInt
  customLabel0    String?  @map("custom_label_0") @db.VarChar(100)
  customLabel1    String?  @map("custom_label_1") @db.VarChar(100)
  customLabel2    String?  @map("custom_label_2") @db.VarChar(100)
  customLabel3    String?  @map("custom_label_3") @db.VarChar(100)
  customLabel4    String?  @map("custom_label_4") @db.VarChar(100)
  calculatedAt    DateTime @default(now()) @map("calculated_at")
  override        Boolean  @default(false)
  overrideReason  String?  @map("override_reason") @db.Text

  @@index([tenantId])
  @@index([tenantId, priority], map: "idx_priority_scores_tenant_priority")
  @@index([tenantId, calculatedAt], map: "idx_priority_scores_tenant_calc")

  // Constraint: priority must be 0-5
  // Enforced at application layer and via CHECK constraint in migration SQL
  @@map("priority_scores")
}

// ===========================================================================
// CATEGORY RULES - Maps product types to base priorities per season
// ===========================================================================

model CategoryRule {
  id                 String   @id @default(uuid()) @db.Uuid
  tenantId           String   @map("tenant_id") @db.Uuid
  tenant             Tenant   @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  productTypePattern String   @map("product_type_pattern") @db.VarChar(255)
  season             Season?
  basePriority       Int      @map("base_priority") @db.SmallInt
  modifiers          Json?    @default("{}")

  createdAt          DateTime @default(now()) @map("created_at")
  updatedAt          DateTime @updatedAt @map("updated_at")

  @@unique([tenantId, productTypePattern, season], map: "uq_tenant_pattern_season")
  @@index([tenantId])
  @@index([tenantId, season], map: "idx_category_rules_tenant_season")
  @@map("category_rules")
}

enum Season {
  winter
  spring
  summer
  fall
}

// ===========================================================================
// SEASONAL CALENDARS - Defines when each season starts and ends
// ===========================================================================

model SeasonalCalendar {
  id                 String   @id @default(uuid()) @db.Uuid
  tenantId           String   @map("tenant_id") @db.Uuid
  tenant             Tenant   @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  name               String   @db.VarChar(100)
  season             Season
  startMonth         Int      @map("start_month") @db.SmallInt
  endMonth           Int      @map("end_month") @db.SmallInt
  categoryOverrides  Json?    @default("{}") @map("category_overrides")

  createdAt          DateTime @default(now()) @map("created_at")
  updatedAt          DateTime @updatedAt @map("updated_at")

  @@unique([tenantId, season], map: "uq_tenant_season")
  @@index([tenantId])
  @@map("seasonal_calendars")
}

// ===========================================================================
// SYNC LOGS - Audit trail for every sync operation
// ===========================================================================

model SyncLog {
  id             String     @id @default(uuid()) @db.Uuid
  tenantId       String     @map("tenant_id") @db.Uuid
  tenant         Tenant     @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  syncType       SyncType   @map("sync_type")
  status         SyncStatus
  productsSynced Int        @default(0) @map("products_synced")
  errors         Json?      @default("[]")
  startedAt      DateTime   @default(now()) @map("started_at")
  completedAt    DateTime?  @map("completed_at")

  @@index([tenantId])
  @@index([tenantId, syncType], map: "idx_sync_logs_tenant_type")
  @@index([startedAt(sort: Desc)], map: "idx_sync_logs_started_desc")
  @@map("sync_logs")
}

enum SyncType {
  shopify
  gmc
  sheet
}

enum SyncStatus {
  started
  completed
  failed
  cancelled
}

// ===========================================================================
// BILLING - Shopify subscription state
// ===========================================================================

model Billing {
  id              String        @id @default(uuid()) @db.Uuid
  tenantId        String        @unique @map("tenant_id") @db.Uuid
  tenant          Tenant        @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  shopifyChargeId BigInt?       @map("shopify_charge_id")
  plan            PlanTier
  status          BillingStatus
  activatedAt     DateTime?     @map("activated_at")

  createdAt       DateTime      @default(now()) @map("created_at")
  updatedAt       DateTime      @updatedAt @map("updated_at")

  @@index([status], map: "idx_billing_status")
  @@map("billing")
}

enum BillingStatus {
  pending
  active
  frozen
  cancelled
  expired
}

Table Reference

tenants

The tenant table is the root of the multi-tenant hierarchy. Every other table references tenants.id as a foreign key. The shopify_shop_domain column is the canonical identifier, using the format store-name.myshopify.com.

ColumnTypeConstraintsDescription
idUUIDPK, default uuidInternal tenant identifier
shopify_shop_domainVARCHAR(255)UNIQUE, NOT NULLe.g. nexus-clothes.myshopify.com
shopify_access_tokenTEXTnullableAES-256 encrypted at rest
plan_tierENUMNOT NULL, default starterstarter, growth, pro, enterprise
gmc_merchant_idVARCHAR(50)nullableGoogle Merchant Center account ID
google_sheet_idVARCHAR(255)nullableGoogle Sheets spreadsheet ID
google_sheet_urlTEXTnullableFull URL for admin reference
statusENUMNOT NULL, default activeactive, inactive, suspended, uninstalled
created_atTIMESTAMPNOT NULL, default now()Installation timestamp
updated_atTIMESTAMPNOT NULL, auto-updatedLast modification

products

Products are cached from the Shopify Admin API. Each product belongs to exactly one tenant and may have multiple variants. The product_type field is the key input to the category rules engine.

ColumnTypeConstraintsDescription
idUUIDPKInternal product identifier
tenant_idUUIDFK -> tenants.id, CASCADEOwning tenant
shopify_product_idBIGINTNOT NULLShopify numeric product ID
titleVARCHAR(500)NOT NULLProduct display name
product_typeVARCHAR(255)nullablee.g. Men-Tops-Hoodies & Sweatshirts
vendorVARCHAR(255)nullablee.g. Jordan Craig
tagsTEXT[]default {}Array of tag strings
statusENUMNOT NULL, default activeactive, archived, draft
created_atTIMESTAMPNOT NULLProduct creation time
updated_atTIMESTAMPNOT NULLLast update

Unique constraint: (tenant_id, shopify_product_id) – a product cannot be duplicated within a tenant.

variants

Variants are the atomic unit of the priority system. Every variant maps to exactly one GMC product via the generated gmc_product_id. The inventory_quantity field drives inventory-based scoring modifiers.

ColumnTypeConstraintsDescription
idUUIDPKInternal variant identifier
tenant_idUUIDFK -> tenants.id, CASCADEOwning tenant
product_idUUIDFK -> products.id, CASCADEParent product
shopify_variant_idBIGINTNOT NULLShopify numeric variant ID
skuVARCHAR(255)nullableMerchant SKU (e.g. 107438)
priceDECIMAL(10,2)default 0Variant price
inventory_quantityINTdefault 0Current stock level
gmc_product_idVARCHAR(255)nullableGenerated: shopify_US_{shopifyProductId}_{shopifyVariantId}
created_atTIMESTAMPNOT NULLRecord creation
updated_atTIMESTAMPNOT NULLLast update

Unique constraint: (tenant_id, shopify_variant_id) – variant IDs are unique within a tenant.

GMC Product ID Generation: The gmc_product_id is computed at the application layer when a variant is created or updated. The format matches what Shopify’s Google channel writes to GMC.

// src/utils/gmc.ts

export function generateGmcProductId(
  shopifyProductId: bigint,
  shopifyVariantId: bigint,
  countryCode: string = 'US'
): string {
  return `shopify_${countryCode}_${shopifyProductId}_${shopifyVariantId}`;
}

// Example:
// generateGmcProductId(8779355160808n, 46050142748904n)
// => "shopify_US_8779355160808_46050142748904"

priority_scores

This table stores the calculated priority output for each variant. It has a one-to-one relationship with variants (via the UNIQUE constraint on variant_id). The five custom label columns hold the exact strings written to the Google Sheets supplemental feed.

ColumnTypeConstraintsDescription
idUUIDPKScore record identifier
tenant_idUUIDFK -> tenants.id, CASCADEOwning tenant
variant_idUUIDFK -> variants.id, CASCADE, UNIQUEScored variant
prioritySMALLINTNOT NULL, default 3Calculated score 0-5
custom_label_0VARCHAR(100)nullablepriority-{0..5}
custom_label_1VARCHAR(100)nullableSeason: winter, spring, summer, fall
custom_label_2VARCHAR(100)nullableCategory group: outerwear-heavy, t-shirts, etc.
custom_label_3VARCHAR(100)nullableStatus: new-arrival, in-stock, low-inventory, etc.
custom_label_4VARCHAR(100)nullableBrand tier: name-brand, store-brand, off-brand
calculated_atTIMESTAMPNOT NULL, default now()When score was last computed
overrideBOOLEANNOT NULL, default falseTrue if merchant manually set this score
override_reasonTEXTnullableWhy the override was applied

Score constraint: The priority column must be between 0 and 5 inclusive. This is enforced at the application layer via Zod validation and at the database layer via a CHECK constraint added in the migration SQL.

category_rules

Category rules map product type patterns to base priority scores. Each rule can optionally be scoped to a specific season. The modifiers JSON column stores tag-based adjustments and other conditional logic.

ColumnTypeConstraintsDescription
idUUIDPKRule identifier
tenant_idUUIDFK -> tenants.id, CASCADEOwning tenant
product_type_patternVARCHAR(255)NOT NULLPattern to match against product types
seasonENUMnullablewinter, spring, summer, fall, or NULL for all seasons
base_prioritySMALLINTNOT NULLDefault score when this rule matches
modifiersJSONBdefault {}Tag adjustments, brand boosts, etc.
created_atTIMESTAMPNOT NULLRule creation
updated_atTIMESTAMPNOT NULLLast modification

Unique constraint: (tenant_id, product_type_pattern, season) – prevents duplicate rules for the same pattern and season.

Modifiers JSON structure:

{
  "tagAdjustments": {
    "NAME BRAND": { "adjustment": 1, "reason": "Premium brand boost" },
    "Sale": { "adjustment": -1, "reason": "Already discounted" },
    "DEAD50": { "override": 0, "reason": "Dead stock excluded" },
    "archived": { "override": 0, "reason": "Archived product excluded" }
  },
  "inventoryModifiers": {
    "zeroStock": { "override": 0, "reason": "Out of stock" },
    "lowStock": { "adjustment": -1, "threshold": 5 },
    "overstock": { "adjustment": -1, "threshold": 50 }
  },
  "newArrival": {
    "daysThreshold": 30,
    "minimumPriority": 5
  }
}

seasonal_calendars

Each tenant defines four seasonal calendars (or more if using micro-seasons). The category_overrides JSON column stores per-category priority overrides for that season.

ColumnTypeConstraintsDescription
idUUIDPKCalendar identifier
tenant_idUUIDFK -> tenants.id, CASCADEOwning tenant
nameVARCHAR(100)NOT NULLDisplay name (e.g. Winter 2026)
seasonENUMNOT NULLwinter, spring, summer, fall
start_monthSMALLINTNOT NULL1-12
end_monthSMALLINTNOT NULL1-12
category_overridesJSONBdefault {}Per-category seasonal priorities
created_atTIMESTAMPNOT NULLRecord creation
updated_atTIMESTAMPNOT NULLLast modification

Unique constraint: (tenant_id, season) – one calendar entry per season per tenant.

Category overrides JSON structure (Nexus example):

{
  "outerwear-heavy": 5,
  "hoodies-sweatshirts": 5,
  "headwear-cold": 5,
  "jeans-pants": 4,
  "joggers": 4,
  "long-sleeve-tops": 4,
  "headwear-caps": 3,
  "t-shirts": 2,
  "shorts": 0,
  "footwear-sandals": 0,
  "swim-shorts": 0
}

sync_logs

Every sync operation (Shopify product import, Google Sheets push, GMC Content API update) creates a log entry. This table serves as both an audit trail and a debugging tool.

ColumnTypeConstraintsDescription
idUUIDPKLog entry identifier
tenant_idUUIDFK -> tenants.id, CASCADEOwning tenant
sync_typeENUMNOT NULLshopify, gmc, sheet
statusENUMNOT NULLstarted, completed, failed, cancelled
products_syncedINTdefault 0Number of products successfully processed
errorsJSONBdefault []Array of error objects
started_atTIMESTAMPNOT NULL, default now()When the sync began
completed_atTIMESTAMPnullableWhen the sync finished

Errors JSON structure:

[
  {
    "variantId": "uuid-here",
    "shopifyVariantId": 46050142748904,
    "error": "GMC product not found",
    "timestamp": "2026-02-10T09:02:15Z"
  }
]

billing

One-to-one relationship with tenants. Tracks the Shopify recurring application charge state.

ColumnTypeConstraintsDescription
idUUIDPKBilling record identifier
tenant_idUUIDFK -> tenants.id, CASCADE, UNIQUEOwning tenant
shopify_charge_idBIGINTnullableShopify RecurringApplicationCharge ID
planENUMNOT NULLstarter, growth, pro, enterprise
statusENUMNOT NULLpending, active, frozen, cancelled, expired
activated_atTIMESTAMPnullableWhen the subscription became active
created_atTIMESTAMPNOT NULLRecord creation
updated_atTIMESTAMPNOT NULLLast modification

Indexing Strategy

Indexes are designed around the most common query patterns. Every table is indexed on tenant_id because every query is tenant-scoped. Composite indexes support the specific access patterns of each feature.

INDEX STRATEGY BY ACCESS PATTERN
=================================

  Products page (paginated, filtered):
    idx_products_tenant_type       --> WHERE tenant_id = ? AND product_type = ?
    idx_products_tenant_status     --> WHERE tenant_id = ? AND status = 'active'
    idx_products_tenant_shopify_id --> WHERE tenant_id = ? AND shopify_product_id = ?

  Priority dashboard (distribution chart):
    idx_priority_scores_tenant_priority --> WHERE tenant_id = ? GROUP BY priority

  Sync operations (find pending variants):
    idx_variants_tenant_inventory  --> WHERE tenant_id = ? AND inventory_quantity > 0
    idx_variants_gmc_product_id    --> WHERE gmc_product_id = ? (reconciliation)

  Sync history (recent first):
    idx_sync_logs_started_desc     --> ORDER BY started_at DESC

  Category rules lookup (scoring engine):
    idx_category_rules_tenant_season --> WHERE tenant_id = ? AND season = ?

  Score recalculation (find stale scores):
    idx_priority_scores_tenant_calc --> WHERE tenant_id = ? ORDER BY calculated_at

Prisma Middleware for Tenant Scoping

Every database query must be filtered by tenant_id to prevent data leakage between tenants. Rather than relying on developers to remember this in every query, AdPriority uses Prisma middleware that automatically injects the tenant scope.

// src/database/middleware/tenantScope.ts

import { Prisma, PrismaClient } from '@prisma/client';

/**
 * Prisma middleware that automatically scopes all queries to the
 * current tenant. The tenantId is injected into the Prisma client
 * extension context by the auth middleware.
 *
 * This prevents accidental cross-tenant data access.
 */
export function createTenantScopedClient(
  prisma: PrismaClient,
  tenantId: string
) {
  return prisma.$extends({
    query: {
      // Apply to all models that have a tenantId field
      $allModels: {
        async findMany({ model, args, query }) {
          if (hasTenantId(model)) {
            args.where = { ...args.where, tenantId };
          }
          return query(args);
        },

        async findFirst({ model, args, query }) {
          if (hasTenantId(model)) {
            args.where = { ...args.where, tenantId };
          }
          return query(args);
        },

        async findUnique({ model, args, query }) {
          // findUnique does not support adding arbitrary where clauses,
          // so we validate after fetch
          const result = await query(args);
          if (result && hasTenantId(model) && (result as any).tenantId !== tenantId) {
            return null; // Deny cross-tenant access
          }
          return result;
        },

        async create({ model, args, query }) {
          if (hasTenantId(model)) {
            args.data = { ...args.data, tenantId };
          }
          return query(args);
        },

        async update({ model, args, query }) {
          if (hasTenantId(model)) {
            args.where = { ...args.where, tenantId } as any;
          }
          return query(args);
        },

        async updateMany({ model, args, query }) {
          if (hasTenantId(model)) {
            args.where = { ...args.where, tenantId };
          }
          return query(args);
        },

        async delete({ model, args, query }) {
          if (hasTenantId(model)) {
            args.where = { ...args.where, tenantId } as any;
          }
          return query(args);
        },

        async deleteMany({ model, args, query }) {
          if (hasTenantId(model)) {
            args.where = { ...args.where, tenantId };
          }
          return query(args);
        },

        async count({ model, args, query }) {
          if (hasTenantId(model)) {
            args.where = { ...args.where, tenantId };
          }
          return query(args);
        },
      },
    },
  });
}

// Models that require tenant scoping (all except Tenant itself)
const TENANT_SCOPED_MODELS = new Set([
  'Product',
  'Variant',
  'PriorityScore',
  'CategoryRule',
  'SeasonalCalendar',
  'SyncLog',
  'Billing',
]);

function hasTenantId(model: string): boolean {
  return TENANT_SCOPED_MODELS.has(model);
}

Usage in the request lifecycle:

// src/api/middleware/auth.ts

import { createTenantScopedClient } from '../../database/middleware/tenantScope';

export async function authMiddleware(req: Request, res: Response, next: NextFunction) {
  const session = await validateShopifySession(req);
  if (!session) {
    return res.status(401).json({ error: { code: 'UNAUTHORIZED', message: 'Invalid session' } });
  }

  const tenant = await prisma.tenant.findUnique({
    where: { shopifyShopDomain: session.shop },
  });

  if (!tenant || tenant.status !== 'active') {
    return res.status(403).json({ error: { code: 'FORBIDDEN', message: 'Tenant not active' } });
  }

  // Create a tenant-scoped Prisma client for this request
  req.tenantId = tenant.id;
  req.db = createTenantScopedClient(prisma, tenant.id);

  next();
}

Token Encryption

Shopify access tokens are encrypted at rest using AES-256-GCM before being stored in the database. The encryption key is stored in the ENCRYPTION_KEY environment variable and never logged or exposed.

// src/utils/encryption.ts

import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';

const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 16;
const TAG_LENGTH = 16;

export function encrypt(plaintext: string): string {
  const key = Buffer.from(process.env.ENCRYPTION_KEY!, 'hex');
  const iv = randomBytes(IV_LENGTH);
  const cipher = createCipheriv(ALGORITHM, key, iv);

  let encrypted = cipher.update(plaintext, 'utf8', 'hex');
  encrypted += cipher.final('hex');

  const tag = cipher.getAuthTag();

  // Format: iv:tag:ciphertext (all hex-encoded)
  return `${iv.toString('hex')}:${tag.toString('hex')}:${encrypted}`;
}

export function decrypt(encryptedString: string): string {
  const key = Buffer.from(process.env.ENCRYPTION_KEY!, 'hex');
  const [ivHex, tagHex, ciphertext] = encryptedString.split(':');

  const iv = Buffer.from(ivHex, 'hex');
  const tag = Buffer.from(tagHex, 'hex');
  const decipher = createDecipheriv(ALGORITHM, key, iv);
  decipher.setAuthTag(tag);

  let decrypted = decipher.update(ciphertext, 'hex', 'utf8');
  decrypted += decipher.final('utf8');

  return decrypted;
}

Migration Strategy

AdPriority uses Prisma Migrate for schema evolution. Unlike the push-based approach used in some other projects on this NAS, migrations provide a reversible audit trail of every schema change.

Initial Setup

# Create the database and user on the shared postgres16 container
docker exec -it postgres16 psql -U postgres << 'EOF'
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;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO adpriority_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO adpriority_user;
EOF

Running Migrations

# Development: create and apply migration
cd /volume1/docker/adpriority/backend
npx prisma migrate dev --name init

# Production: apply pending migrations only
npx prisma migrate deploy

# Generate the Prisma client after schema changes
npx prisma generate

Adding CHECK Constraints

Prisma does not natively support CHECK constraints, so they are added via a custom migration SQL file after the initial schema migration.

-- prisma/migrations/YYYYMMDDHHMMSS_add_check_constraints/migration.sql

-- Enforce priority score range 0-5
ALTER TABLE priority_scores
  ADD CONSTRAINT chk_priority_range
  CHECK (priority >= 0 AND priority <= 5);

-- Enforce month ranges 1-12
ALTER TABLE seasonal_calendars
  ADD CONSTRAINT chk_start_month_range
  CHECK (start_month >= 1 AND start_month <= 12);

ALTER TABLE seasonal_calendars
  ADD CONSTRAINT chk_end_month_range
  CHECK (end_month >= 1 AND end_month <= 12);

-- Enforce base_priority range 0-5
ALTER TABLE category_rules
  ADD CONSTRAINT chk_base_priority_range
  CHECK (base_priority >= 0 AND base_priority <= 5);

Data Retention Policy

TableRetentionCleanup Strategy
tenantsForeverSoft-delete via status = 'uninstalled'
productsWhile tenant activeCascade delete on tenant removal
variantsWhile tenant activeCascade delete on product removal
priority_scoresWhile tenant activeCascade delete on variant removal
category_rulesWhile tenant activeCascade delete on tenant removal
seasonal_calendarsWhile tenant activeCascade delete on tenant removal
sync_logs90 daysCron job deletes logs older than 90 days
billingForeverRetained for accounting and compliance

The sync log cleanup job runs daily:

// src/scheduler/cleanup.ts

import cron from 'node-cron';
import { prisma } from '../database/client';

// Run daily at 3:00 AM
cron.schedule('0 3 * * *', async () => {
  const cutoff = new Date();
  cutoff.setDate(cutoff.getDate() - 90);

  const { count } = await prisma.syncLog.deleteMany({
    where: {
      startedAt: { lt: cutoff },
    },
  });

  console.log(`[cleanup] Deleted ${count} sync logs older than 90 days`);
});

Sample Seed Data

The following seed script creates a complete test environment using Nexus Clothing data.

// prisma/seed.ts

import { PrismaClient, PlanTier, Season } from '@prisma/client';

const prisma = new PrismaClient();

async function main() {
  // 1. Create Nexus tenant
  const tenant = await prisma.tenant.create({
    data: {
      shopifyShopDomain: 'nexus-clothes.myshopify.com',
      planTier: PlanTier.growth,
      status: 'active',
    },
  });

  // 2. Create seasonal calendars
  const seasons = [
    { name: 'Winter', season: Season.winter, startMonth: 11, endMonth: 2 },
    { name: 'Spring', season: Season.spring, startMonth: 3,  endMonth: 4 },
    { name: 'Summer', season: Season.summer, startMonth: 5,  endMonth: 8 },
    { name: 'Fall',   season: Season.fall,   startMonth: 9,  endMonth: 10 },
  ];

  for (const s of seasons) {
    await prisma.seasonalCalendar.create({
      data: {
        tenantId: tenant.id,
        name: s.name,
        season: s.season,
        startMonth: s.startMonth,
        endMonth: s.endMonth,
        categoryOverrides: getSeasonOverrides(s.season),
      },
    });
  }

  // 3. Create category rules (season-agnostic defaults)
  const defaults: Array<{ pattern: string; priority: number }> = [
    { pattern: 'outerwear-heavy',       priority: 3 },
    { pattern: 'outerwear-medium',      priority: 3 },
    { pattern: 'outerwear-light',       priority: 3 },
    { pattern: 'hoodies-sweatshirts',   priority: 3 },
    { pattern: 'jeans-pants',           priority: 4 },
    { pattern: 't-shirts',             priority: 3 },
    { pattern: 'shorts',               priority: 3 },
    { pattern: 'headwear-caps',        priority: 3 },
    { pattern: 'headwear-cold',        priority: 2 },
    { pattern: 'headwear-summer',      priority: 2 },
    { pattern: 'underwear-socks',      priority: 2 },
    { pattern: 'accessories',          priority: 2 },
    { pattern: 'joggers',             priority: 3 },
    { pattern: 'footwear-sandals',     priority: 2 },
    { pattern: 'footwear-shoes',       priority: 3 },
    { pattern: 'sweatpants',          priority: 3 },
    { pattern: 'long-sleeve-tops',     priority: 3 },
    { pattern: 'swim-shorts',         priority: 2 },
    { pattern: 'women-apparel',        priority: 2 },
    { pattern: 'exclude',             priority: 0 },
  ];

  for (const rule of defaults) {
    await prisma.categoryRule.create({
      data: {
        tenantId: tenant.id,
        productTypePattern: rule.pattern,
        basePriority: rule.priority,
        modifiers: {
          tagAdjustments: {
            'NAME BRAND': { adjustment: 1 },
            'Sale': { adjustment: -1 },
            'DEAD50': { override: 0 },
            'archived': { override: 0 },
            'warning_inv_1': { adjustment: -1 },
            'in-stock': { adjustment: 1 },
          },
        },
      },
    });
  }

  console.log('Seed complete: Nexus tenant with 4 seasons and 20 category rules');
}

function getSeasonOverrides(season: Season): Record<string, number> {
  const matrix: Record<Season, Record<string, number>> = {
    winter: {
      'outerwear-heavy': 5, 'hoodies-sweatshirts': 5, 'headwear-cold': 5,
      'jeans-pants': 4, 'joggers': 4, 'sweatpants': 4, 'long-sleeve-tops': 4,
      'outerwear-medium': 4, 'headwear-caps': 3, 't-shirts': 2,
      'shorts': 0, 'swim-shorts': 0, 'footwear-sandals': 0, 'headwear-summer': 0,
    },
    spring: {
      'jeans-pants': 4, 't-shirts': 4, 'outerwear-light': 4,
      'headwear-caps': 3, 'joggers': 3, 'hoodies-sweatshirts': 3, 'shorts': 3,
      'outerwear-heavy': 1, 'headwear-cold': 1,
    },
    summer: {
      'shorts': 5, 't-shirts': 5, 'swim-shorts': 5, 'footwear-sandals': 5,
      'headwear-summer': 4, 'headwear-caps': 3, 'jeans-pants': 3,
      'hoodies-sweatshirts': 1, 'outerwear-heavy': 0, 'headwear-cold': 0,
    },
    fall: {
      'jeans-pants': 5, 'hoodies-sweatshirts': 5,
      'outerwear-medium': 4, 'long-sleeve-tops': 4, 'joggers': 4,
      'headwear-cold': 3, 't-shirts': 3,
      'shorts': 1, 'footwear-sandals': 0,
    },
  };

  return matrix[season];
}

main()
  .catch(console.error)
  .finally(() => prisma.$disconnect());

Performance Considerations

Query Performance at Scale

The schema is designed to handle the Growth tier target of 10,000+ products per tenant (roughly 30,000-50,000 variants including size and color permutations). Key optimizations include:

ConcernSolution
Large product listsCursor-based pagination using id as cursor, indexed
Priority distributionPre-computed in priority_scores table, single COUNT + GROUP BY
Sync operationsBatch upserts using createMany with skipDuplicates
Category rule matchingRules loaded once per scoring run, matched in-memory
Seasonal lookupsCalendars cached in Redis with 1-hour TTL

Estimated Table Sizes (Nexus Baseline)

TableRows (Nexus)Rows (100 tenants)Row SizeTotal Size
tenants1100~500 B~50 KB
products5,582500,000~300 B~150 MB
variants~20,0002,000,000~200 B~400 MB
priority_scores~20,0002,000,000~250 B~500 MB
category_rules808,000~500 B~4 MB
seasonal_calendars4400~1 KB~400 KB
sync_logs~2,000200,000~500 B~100 MB
billing1100~200 B~20 KB

At 100 tenants averaging 5,000 products each, the total database size is approximately 1.2 GB – well within the capacity of the shared postgres16 container.


Summary

The database schema captures eight tables with clear ownership chains rooted in the tenants table. Every query is tenant-scoped through Prisma middleware, preventing cross-tenant data leakage. The Prisma ORM provides type-safe access with migration-based schema evolution, and the indexing strategy supports the key access patterns of the product listing, priority scoring, and sync pipeline features.

The schema is deliberately normalized at the variant level because variants are the atomic unit synced to Google Merchant Center. The priority_scores table maintains a one-to-one relationship with variants, keeping the scoring output separate from the product catalog cache. This separation allows the scoring engine to recalculate priorities without modifying the product data, and the sync pipeline to read scores without joining through the full product hierarchy.

Chapter 15: API Design

API Architecture

AdPriority exposes a RESTful JSON API served by Express.js with TypeScript. All endpoints except authentication callbacks and Shopify webhooks require a valid Shopify session. The API is designed for consumption by the embedded Shopify Polaris frontend via App Bridge authenticated fetch.

API ARCHITECTURE
================

  Shopify Admin (iframe)
       |
       |  App Bridge authenticated fetch
       v
  +---------------------------+
  |  Express.js Server        |
  |  Port 3010                |
  |                           |
  |  Middleware Stack:         |
  |  1. CORS                  |
  |  2. JSON body parser      |
  |  3. Rate limiter          |
  |  4. Shopify auth verify   |
  |  5. Tenant scoping        |
  |  6. Request logging       |
  +---------------------------+
       |
       +----> /auth/*           Shopify OAuth, session validation
       +----> /api/products/*   Product listing, sync triggers
       +----> /api/priorities/* Score management, overrides
       +----> /api/rules/*      Category rule CRUD
       +----> /api/seasons/*    Seasonal calendar management
       +----> /api/sync/*       Google Sheets push, sync status
       +----> /api/settings/*   Tenant configuration
       +----> /api/billing/*    Subscription management
       +----> /api/gdpr/*       Mandatory GDPR compliance webhooks
       +----> /webhooks/*       Shopify product webhooks (HMAC verified)

Base Configuration

Base URL (Development):  http://localhost:3010
Base URL (Production):   https://app.adpriority.com
API Prefix:              /api
Content-Type:            application/json
Authentication:          Shopify App Bridge session token
Rate Limit:              100 requests/minute (general), 10/minute (sync)

Authentication Endpoints

Authentication uses Shopify’s OAuth 2.0 flow. The app is embedded in the Shopify Admin, so most API requests use session tokens verified via Shopify’s App Bridge library. The OAuth callback installs the app and creates the tenant record.

POST /auth/callback

Handles the Shopify OAuth callback after the merchant approves the app installation.

PropertyValue
MethodPOST
Path/auth/callback
Auth RequiredNo (this creates the session)
Rate Limit10/minute

Request Body:

{
  "code": "shopify_oauth_code_here",
  "shop": "nexus-clothes.myshopify.com",
  "hmac": "sha256_hmac_signature",
  "timestamp": "1707220800",
  "state": "nonce_from_install_request"
}

Response (200 OK):

{
  "success": true,
  "tenant": {
    "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "shopifyShopDomain": "nexus-clothes.myshopify.com",
    "planTier": "starter",
    "status": "active"
  },
  "redirectUrl": "/app/dashboard"
}

Error Responses:

StatusCodeWhen
400INVALID_HMACHMAC signature verification failed
400INVALID_NONCEState parameter does not match stored nonce
500TOKEN_EXCHANGE_FAILEDShopify rejected the authorization code

GET /auth/session

Validates the current session and returns tenant information. Called on every page load by the frontend to confirm the session is still valid.

PropertyValue
MethodGET
Path/auth/session
Auth RequiredYes (session token)
Rate Limit100/minute

Response (200 OK):

{
  "tenant": {
    "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "shopifyShopDomain": "nexus-clothes.myshopify.com",
    "planTier": "growth",
    "status": "active",
    "gmcMerchantId": "123456789",
    "googleSheetId": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms"
  },
  "billing": {
    "plan": "growth",
    "status": "active",
    "activatedAt": "2026-01-15T10:00:00Z"
  },
  "features": {
    "seasonalAutomation": true,
    "tagModifiers": true,
    "newArrivalBoost": true,
    "googleAdsIntegration": false,
    "maxProducts": null
  }
}

Error Responses:

StatusCodeWhen
401UNAUTHORIZEDSession token invalid or expired
403TENANT_SUSPENDEDTenant account suspended

Products Endpoints

Product endpoints provide paginated access to the local product cache and trigger Shopify sync operations. Products are read from the AdPriority database, not from Shopify directly (the sync operation refreshes the cache).

GET /api/products

Returns a paginated, filterable list of products with their current priority scores.

PropertyValue
MethodGET
Path/api/products
Auth RequiredYes
Rate Limit100/minute

Query Parameters:

ParamTypeDefaultDescription
pageint1Page number (1-indexed)
limitint50Items per page (max 250)
statusstringactiveFilter: active, archived, draft, all
productTypestringFilter by product type
vendorstringFilter by vendor
priorityintFilter by priority score (0-5)
searchstringFull-text search on title
sortBystringtitleSort field: title, priority, updatedAt, productType
sortOrderstringascSort direction: asc, desc

Response (200 OK):

{
  "products": [
    {
      "id": "prod-uuid-001",
      "shopifyProductId": "8779355160808",
      "title": "Jordan Craig Stacked Jeans - Jet Black",
      "productType": "Men-Bottoms-Stacked Jeans",
      "vendor": "Jordan Craig",
      "tags": ["NAME BRAND", "Men", "in-stock"],
      "status": "active",
      "variantCount": 6,
      "priority": {
        "score": 5,
        "label": "Push Hard",
        "override": false,
        "calculatedAt": "2026-02-10T08:00:00Z"
      },
      "updatedAt": "2026-02-10T08:00:00Z"
    },
    {
      "id": "prod-uuid-002",
      "shopifyProductId": "9128994570472",
      "title": "New Era NY Yankees 59FIFTY Fitted",
      "productType": "Headwear-Baseball-Fitted",
      "vendor": "New Era",
      "tags": ["NAME BRAND", "Men"],
      "status": "active",
      "variantCount": 8,
      "priority": {
        "score": 4,
        "label": "Strong",
        "override": false,
        "calculatedAt": "2026-02-10T08:00:00Z"
      },
      "updatedAt": "2026-02-10T08:00:00Z"
    }
  ],
  "pagination": {
    "page": 1,
    "limit": 50,
    "total": 2425,
    "totalPages": 49,
    "hasNext": true,
    "hasPrev": false
  }
}

GET /api/products/:id

Returns full detail for a single product, including all variants and their individual priority scores.

PropertyValue
MethodGET
Path/api/products/:id
Auth RequiredYes
Rate Limit100/minute

Response (200 OK):

{
  "id": "prod-uuid-001",
  "shopifyProductId": "8779355160808",
  "title": "Jordan Craig Stacked Jeans - Jet Black",
  "productType": "Men-Bottoms-Stacked Jeans",
  "vendor": "Jordan Craig",
  "tags": ["NAME BRAND", "Men", "in-stock", "jordan-craig"],
  "status": "active",
  "createdAt": "2025-06-15T10:00:00Z",
  "updatedAt": "2026-02-10T08:00:00Z",
  "variants": [
    {
      "id": "var-uuid-001",
      "shopifyVariantId": "46050142748904",
      "sku": "107438",
      "price": "89.99",
      "inventoryQuantity": 12,
      "gmcProductId": "shopify_US_8779355160808_46050142748904",
      "priorityScore": {
        "priority": 5,
        "customLabel0": "priority-5",
        "customLabel1": "winter",
        "customLabel2": "jeans-pants",
        "customLabel3": "in-stock",
        "customLabel4": "name-brand",
        "override": false,
        "calculatedAt": "2026-02-10T08:00:00Z"
      }
    },
    {
      "id": "var-uuid-002",
      "shopifyVariantId": "46050142781672",
      "sku": "107439",
      "price": "89.99",
      "inventoryQuantity": 0,
      "gmcProductId": "shopify_US_8779355160808_46050142781672",
      "priorityScore": {
        "priority": 0,
        "customLabel0": "priority-0",
        "customLabel1": "winter",
        "customLabel2": "jeans-pants",
        "customLabel3": "dead-stock",
        "customLabel4": "name-brand",
        "override": false,
        "calculatedAt": "2026-02-10T08:00:00Z"
      }
    }
  ]
}

Error Responses:

StatusCodeWhen
404NOT_FOUNDProduct ID does not exist or belongs to another tenant

POST /api/products/sync

Triggers a Shopify product sync that fetches all products from the Shopify Admin API and updates the local cache. This is a long-running operation that returns immediately with a job reference.

PropertyValue
MethodPOST
Path/api/products/sync
Auth RequiredYes
Rate Limit5/minute

Request Body:

{
  "mode": "incremental",
  "since": "2026-02-09T00:00:00Z"
}
FieldTypeRequiredDescription
modestringNofull (all products) or incremental (since date). Default: incremental
sinceISO 8601NoOnly fetch products updated after this time. Default: last sync time

Response (202 Accepted):

{
  "syncLogId": "sync-uuid-001",
  "status": "started",
  "estimatedProducts": 2425,
  "message": "Shopify product sync started"
}

Error Responses:

StatusCodeWhen
409SYNC_IN_PROGRESSAnother sync is already running for this tenant
429RATE_LIMITEDToo many sync requests

Priorities Endpoints

Priority endpoints read and modify the calculated priority scores. The listing endpoint supports the same filtering as products but returns score-centric data. The override endpoint allows merchants to manually lock a score.

GET /api/priorities

Returns priority scores with filtering and aggregation options.

PropertyValue
MethodGET
Path/api/priorities
Auth RequiredYes
Rate Limit100/minute

Query Parameters:

ParamTypeDefaultDescription
pageint1Page number
limitint50Items per page (max 250)
priorityintFilter by exact score (0-5)
minPriorityintFilter: score >= value
maxPriorityintFilter: score <= value
overridebooleanFilter: only manual overrides
productTypestringFilter by product type
categoryGroupstringFilter by category group (label_2)
needsSyncbooleanFilter: scores calculated after last sync

Response (200 OK):

{
  "priorities": [
    {
      "variantId": "var-uuid-001",
      "shopifyVariantId": "46050142748904",
      "gmcProductId": "shopify_US_8779355160808_46050142748904",
      "productTitle": "Jordan Craig Stacked Jeans - Jet Black",
      "sku": "107438",
      "priority": 5,
      "customLabels": {
        "label0": "priority-5",
        "label1": "winter",
        "label2": "jeans-pants",
        "label3": "in-stock",
        "label4": "name-brand"
      },
      "override": false,
      "calculatedAt": "2026-02-10T08:00:00Z"
    }
  ],
  "summary": {
    "distribution": { "0": 615, "1": 45, "2": 380, "3": 820, "4": 350, "5": 215 },
    "totalScored": 2425,
    "totalOverrides": 12,
    "lastCalculated": "2026-02-10T08:00:00Z"
  },
  "pagination": {
    "page": 1,
    "limit": 50,
    "total": 2425,
    "totalPages": 49
  }
}

PUT /api/priorities/:variantId

Manually overrides the priority score for a specific variant. Setting override: true locks the score so it is not changed by automatic recalculation.

PropertyValue
MethodPUT
Path/api/priorities/:variantId
Auth RequiredYes
Rate Limit100/minute

Request Body:

{
  "priority": 5,
  "override": true,
  "overrideReason": "Bestseller - always push hard"
}
FieldTypeRequiredConstraints
priorityintYes0-5 inclusive
overridebooleanNoDefault: true when manually setting
overrideReasonstringNoFree text explanation

Response (200 OK):

{
  "variantId": "var-uuid-001",
  "priority": 5,
  "previousPriority": 4,
  "override": true,
  "overrideReason": "Bestseller - always push hard",
  "customLabels": {
    "label0": "priority-5",
    "label1": "winter",
    "label2": "jeans-pants",
    "label3": "in-stock",
    "label4": "name-brand"
  },
  "calculatedAt": "2026-02-10T14:30:00Z"
}

Error Responses:

StatusCodeWhen
400VALIDATION_ERRORPriority not in range 0-5
404NOT_FOUNDVariant ID not found

POST /api/priorities/recalculate

Triggers a bulk recalculation of all priority scores for the tenant. This re-evaluates every variant against the current category rules, seasonal calendar, tag modifiers, and inventory levels. Manually overridden scores are skipped unless includeOverrides is set to true.

PropertyValue
MethodPOST
Path/api/priorities/recalculate
Auth RequiredYes
Rate Limit5/minute

Request Body:

{
  "includeOverrides": false,
  "productTypes": ["Men-Bottoms-Stacked Jeans", "Men-Tops-Hoodies & Sweatshirts"],
  "dryRun": false
}
FieldTypeRequiredDescription
includeOverridesbooleanNoIf true, recalculate even manually overridden scores. Default: false
productTypesstring[]NoLimit recalculation to specific product types. Default: all types
dryRunbooleanNoIf true, return preview without saving. Default: false

Response (200 OK):

{
  "recalculated": 2413,
  "skippedOverrides": 12,
  "changes": {
    "increased": 145,
    "decreased": 89,
    "unchanged": 2179
  },
  "distribution": {
    "0": 630,
    "1": 42,
    "2": 375,
    "3": 815,
    "4": 345,
    "5": 218
  },
  "calculatedAt": "2026-02-10T14:35:00Z"
}

Rules Endpoints

Category rules define how product types map to base priorities. Each rule matches a product type pattern and assigns a base priority, optionally scoped to a specific season. Rules also carry tag-modifier configuration in the modifiers JSON field.

GET /api/rules

Returns all category rules for the tenant, sorted by product type pattern.

PropertyValue
MethodGET
Path/api/rules
Auth RequiredYes
Rate Limit100/minute

Response (200 OK):

{
  "rules": [
    {
      "id": "rule-uuid-001",
      "productTypePattern": "outerwear-heavy",
      "season": null,
      "basePriority": 3,
      "modifiers": {
        "tagAdjustments": {
          "NAME BRAND": { "adjustment": 1 },
          "DEAD50": { "override": 0 }
        }
      },
      "matchingProducts": 72,
      "createdAt": "2026-01-15T10:00:00Z",
      "updatedAt": "2026-01-15T10:00:00Z"
    },
    {
      "id": "rule-uuid-002",
      "productTypePattern": "outerwear-heavy",
      "season": "winter",
      "basePriority": 5,
      "modifiers": {},
      "matchingProducts": 72,
      "createdAt": "2026-01-15T10:00:00Z",
      "updatedAt": "2026-01-15T10:00:00Z"
    }
  ],
  "totalRules": 80
}

POST /api/rules

Creates a new category rule. If a rule with the same pattern and season already exists, the request is rejected with a 409 Conflict.

PropertyValue
MethodPOST
Path/api/rules
Auth RequiredYes
Rate Limit100/minute

Request Body:

{
  "productTypePattern": "outerwear-heavy",
  "season": "winter",
  "basePriority": 5,
  "modifiers": {
    "tagAdjustments": {
      "NAME BRAND": { "adjustment": 1 },
      "DEAD50": { "override": 0 }
    }
  }
}
FieldTypeRequiredConstraints
productTypePatternstringYesMax 255 chars
seasonstringNowinter, spring, summer, fall, or null for all-season default
basePriorityintYes0-5 inclusive
modifiersobjectNoTag adjustments and inventory modifiers

Response (201 Created):

{
  "id": "rule-uuid-new",
  "productTypePattern": "outerwear-heavy",
  "season": "winter",
  "basePriority": 5,
  "modifiers": { "tagAdjustments": { "NAME BRAND": { "adjustment": 1 } } },
  "createdAt": "2026-02-10T14:30:00Z"
}

Error Responses:

StatusCodeWhen
400VALIDATION_ERRORMissing required fields or invalid priority range
409DUPLICATE_RULEA rule with this pattern and season already exists

PUT /api/rules/:id

Updates an existing category rule. Any field not included in the request body is left unchanged.

PropertyValue
MethodPUT
Path/api/rules/:id
Auth RequiredYes
Rate Limit100/minute

Request Body:

{
  "basePriority": 4,
  "modifiers": {
    "tagAdjustments": {
      "NAME BRAND": { "adjustment": 1 },
      "Sale": { "adjustment": -1 }
    }
  }
}

Response (200 OK):

{
  "id": "rule-uuid-001",
  "productTypePattern": "outerwear-heavy",
  "season": null,
  "basePriority": 4,
  "modifiers": { "tagAdjustments": { "NAME BRAND": { "adjustment": 1 }, "Sale": { "adjustment": -1 } } },
  "updatedAt": "2026-02-10T14:35:00Z"
}

DELETE /api/rules/:id

Deletes a category rule. Products previously scored by this rule will retain their current scores until the next recalculation.

PropertyValue
MethodDELETE
Path/api/rules/:id
Auth RequiredYes
Rate Limit100/minute

Response (200 OK):

{
  "deleted": true,
  "id": "rule-uuid-001",
  "affectedProducts": 72,
  "message": "Rule deleted. Run recalculation to update affected products."
}

Seasons Endpoints

Season endpoints manage the seasonal calendar. Each tenant has four seasons by default. The Growth tier and above can customize dates and add micro-seasons.

GET /api/seasons

Returns all seasonal calendars for the tenant, including which season is currently active.

PropertyValue
MethodGET
Path/api/seasons
Auth RequiredYes
Rate Limit100/minute

Response (200 OK):

{
  "currentSeason": "winter",
  "currentSeasonCalendar": {
    "id": "cal-uuid-winter",
    "name": "Winter",
    "season": "winter",
    "startMonth": 11,
    "endMonth": 2
  },
  "nextTransition": {
    "toSeason": "spring",
    "date": "2026-03-01",
    "daysUntil": 19
  },
  "seasons": [
    {
      "id": "cal-uuid-winter",
      "name": "Winter",
      "season": "winter",
      "startMonth": 11,
      "endMonth": 2,
      "isCurrent": true,
      "categoryOverrides": {
        "outerwear-heavy": 5,
        "hoodies-sweatshirts": 5,
        "shorts": 0,
        "swim-shorts": 0
      }
    },
    {
      "id": "cal-uuid-spring",
      "name": "Spring",
      "season": "spring",
      "startMonth": 3,
      "endMonth": 4,
      "isCurrent": false,
      "categoryOverrides": {
        "jeans-pants": 4,
        "t-shirts": 4,
        "outerwear-heavy": 1
      }
    }
  ]
}

PUT /api/seasons/:id

Updates a seasonal calendar’s dates or category overrides.

PropertyValue
MethodPUT
Path/api/seasons/:id
Auth RequiredYes (Growth tier+)
Rate Limit100/minute

Request Body:

{
  "startMonth": 12,
  "endMonth": 2,
  "categoryOverrides": {
    "outerwear-heavy": 5,
    "hoodies-sweatshirts": 5,
    "headwear-cold": 5,
    "jeans-pants": 4,
    "shorts": 0,
    "swim-shorts": 0,
    "footwear-sandals": 0
  }
}

Response (200 OK):

{
  "id": "cal-uuid-winter",
  "name": "Winter",
  "season": "winter",
  "startMonth": 12,
  "endMonth": 2,
  "categoryOverrides": { "outerwear-heavy": 5, "hoodies-sweatshirts": 5, "headwear-cold": 5 },
  "updatedAt": "2026-02-10T14:40:00Z"
}

Error Responses:

StatusCodeWhen
403PLAN_LIMITStarter tier cannot modify seasonal calendars
400VALIDATION_ERRORInvalid month values

Sync Endpoints

Sync endpoints manage the Google Sheets supplemental feed pipeline and provide visibility into sync history and status.

POST /api/sync/sheet

Pushes current priority scores to the configured Google Sheet. The Sheet is then automatically fetched by Google Merchant Center on its daily schedule.

PropertyValue
MethodPOST
Path/api/sync/sheet
Auth RequiredYes
Rate Limit5/minute

Request Body:

{
  "mode": "full",
  "activeOnly": true
}
FieldTypeRequiredDescription
modestringNofull (rewrite entire sheet) or delta (update changed rows). Default: full
activeOnlybooleanNoOnly sync variants with inventory_quantity > 0. Default: true

Response (202 Accepted):

{
  "syncLogId": "sync-uuid-sheet-001",
  "status": "started",
  "sheetId": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms",
  "sheetUrl": "https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms",
  "estimatedRows": 15000,
  "message": "Google Sheet sync started"
}

GET /api/sync/status

Returns the current sync status including last successful sync, pending changes, and next scheduled sync.

PropertyValue
MethodGET
Path/api/sync/status
Auth RequiredYes
Rate Limit100/minute

Response (200 OK):

{
  "lastSync": {
    "id": "sync-uuid-001",
    "syncType": "sheet",
    "status": "completed",
    "productsSynced": 14892,
    "errors": [],
    "startedAt": "2026-02-10T08:00:00Z",
    "completedAt": "2026-02-10T08:02:30Z",
    "durationSeconds": 150
  },
  "pendingChanges": 45,
  "sheetConfigured": true,
  "gmcMerchantId": "123456789",
  "nextScheduledSync": "2026-02-10T20:00:00Z",
  "syncFrequency": "daily"
}

GET /api/sync/logs

Returns paginated sync history with filtering by type and status.

PropertyValue
MethodGET
Path/api/sync/logs
Auth RequiredYes
Rate Limit100/minute

Query Parameters:

ParamTypeDefaultDescription
pageint1Page number
limitint20Items per page (max 100)
syncTypestringFilter: shopify, gmc, sheet
statusstringFilter: started, completed, failed

Response (200 OK):

{
  "logs": [
    {
      "id": "sync-uuid-001",
      "syncType": "sheet",
      "status": "completed",
      "productsSynced": 14892,
      "errors": [],
      "startedAt": "2026-02-10T08:00:00Z",
      "completedAt": "2026-02-10T08:02:30Z"
    },
    {
      "id": "sync-uuid-002",
      "syncType": "shopify",
      "status": "completed",
      "productsSynced": 2425,
      "errors": [],
      "startedAt": "2026-02-10T07:00:00Z",
      "completedAt": "2026-02-10T07:05:15Z"
    }
  ],
  "pagination": {
    "page": 1,
    "limit": 20,
    "total": 156,
    "totalPages": 8
  }
}

Settings Endpoints

Settings endpoints provide read and write access to tenant configuration including GMC connection details, Google Sheet URL, and scoring defaults.

GET /api/settings

Returns the full tenant configuration.

PropertyValue
MethodGET
Path/api/settings
Auth RequiredYes
Rate Limit100/minute

Response (200 OK):

{
  "store": {
    "shopifyShopDomain": "nexus-clothes.myshopify.com",
    "planTier": "growth",
    "status": "active",
    "installedAt": "2026-01-15T10:00:00Z"
  },
  "googleMerchantCenter": {
    "merchantId": "123456789",
    "connected": true
  },
  "googleSheet": {
    "sheetId": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms",
    "sheetUrl": "https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms",
    "configured": true
  },
  "scoring": {
    "defaultPriority": 3,
    "newArrivalDays": 14,
    "newArrivalPriority": 5
  },
  "sync": {
    "frequency": "daily",
    "lastSyncAt": "2026-02-10T08:00:00Z"
  }
}

PUT /api/settings

Updates tenant configuration. Only the fields included in the request body are modified.

PropertyValue
MethodPUT
Path/api/settings
Auth RequiredYes
Rate Limit100/minute

Request Body:

{
  "googleMerchantCenter": {
    "merchantId": "123456789"
  },
  "googleSheet": {
    "sheetUrl": "https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms"
  },
  "scoring": {
    "newArrivalDays": 30
  }
}

Response (200 OK):

{
  "updated": true,
  "settings": {
    "googleMerchantCenter": { "merchantId": "123456789", "connected": true },
    "googleSheet": { "sheetId": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms", "configured": true },
    "scoring": { "defaultPriority": 3, "newArrivalDays": 30, "newArrivalPriority": 5 }
  }
}

Billing Endpoints

Billing endpoints manage the Shopify recurring application charge lifecycle. All billing is handled through Shopify’s Billing API, so AdPriority never collects payment information directly.

POST /api/billing/subscribe

Creates or updates a Shopify recurring application charge. Returns a confirmation URL that the merchant must visit to approve the charge.

PropertyValue
MethodPOST
Path/api/billing/subscribe
Auth RequiredYes
Rate Limit10/minute

Request Body:

{
  "plan": "growth"
}
FieldTypeRequiredConstraints
planstringYesstarter, growth, pro

Response (200 OK):

{
  "confirmationUrl": "https://nexus-clothes.myshopify.com/admin/charges/confirm?id=123456&signature=abc",
  "chargeId": 123456789,
  "plan": "growth",
  "price": "79.00",
  "trialDays": 14
}

GET /api/billing/status

Returns the current billing status and plan details.

PropertyValue
MethodGET
Path/api/billing/status
Auth RequiredYes
Rate Limit100/minute

Response (200 OK):

{
  "plan": "growth",
  "status": "active",
  "price": "79.00",
  "billingCycle": "monthly",
  "activatedAt": "2026-01-15T10:00:00Z",
  "trialEndsAt": null,
  "features": {
    "maxProducts": null,
    "seasonalAutomation": true,
    "tagModifiers": true,
    "newArrivalBoost": true,
    "googleAdsIntegration": false,
    "syncFrequency": "hourly"
  }
}

GDPR Compliance Endpoints

Shopify requires all apps to implement three GDPR webhook endpoints. These are called by Shopify when a customer or shop requests data handling. They are verified using HMAC-SHA256 signatures.

POST /api/gdpr/customers-data-request

Called when a customer requests their data. AdPriority does not store customer-identifiable data (only product and variant data), so this endpoint acknowledges the request and returns an empty payload.

PropertyValue
MethodPOST
Path/api/gdpr/customers-data-request
Auth RequiredNo (HMAC verified)

Request Body (from Shopify):

{
  "shop_id": 123456789,
  "shop_domain": "nexus-clothes.myshopify.com",
  "customer": { "id": 987654321, "email": "customer@example.com" },
  "orders_requested": [123, 456]
}

Response (200 OK):

{
  "message": "AdPriority does not store customer-identifiable data. No data to report."
}

POST /api/gdpr/customers-redact

Called when a customer requests deletion of their data. Since AdPriority stores no customer data, this is a no-op acknowledgment.

PropertyValue
MethodPOST
Path/api/gdpr/customers-redact
Auth RequiredNo (HMAC verified)

Response (200 OK):

{
  "message": "No customer data stored. Nothing to redact."
}

POST /api/gdpr/shop-redact

Called 48 hours after a shop uninstalls the app. Triggers full deletion of the tenant and all associated data.

PropertyValue
MethodPOST
Path/api/gdpr/shop-redact
Auth RequiredNo (HMAC verified)

Request Body (from Shopify):

{
  "shop_id": 123456789,
  "shop_domain": "nexus-clothes.myshopify.com"
}

Response (200 OK):

{
  "message": "Tenant data for nexus-clothes.myshopify.com has been permanently deleted.",
  "deletedRecords": {
    "products": 5582,
    "variants": 19537,
    "priorityScores": 19537,
    "categoryRules": 80,
    "seasonalCalendars": 4,
    "syncLogs": 2340,
    "billing": 1
  }
}

Shopify Webhooks

Shopify webhooks notify AdPriority of product changes in real time. All webhooks are verified using HMAC-SHA256 signatures with the app’s client secret.

WEBHOOK VERIFICATION
====================

  Shopify sends:
    X-Shopify-Hmac-SHA256: base64_encoded_hmac
    X-Shopify-Shop-Domain: nexus-clothes.myshopify.com
    X-Shopify-Topic: products/update

  AdPriority verifies:
    1. Compute HMAC-SHA256 of raw body using SHOPIFY_API_SECRET
    2. Compare with X-Shopify-Hmac-SHA256 header
    3. Reject if mismatch (return 401)
    4. Process if valid (return 200 immediately, queue async work)

Registered Webhooks

TopicPathBehavior
products/create/webhooks/shopify/products/createCreate product + variants in DB, calculate priority
products/update/webhooks/shopify/products/updateUpdate cached data, recalculate if type/tags changed
products/delete/webhooks/shopify/products/deleteSoft-delete product and variants
app/uninstalled/webhooks/shopify/app/uninstalledMark tenant as uninstalled, schedule data deletion

Error Response Format

All error responses follow a consistent JSON structure.

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Priority must be between 0 and 5",
    "details": {
      "field": "priority",
      "value": 7,
      "constraint": "range:0-5"
    }
  }
}

Error Code Reference

CodeHTTP StatusDescription
UNAUTHORIZED401Invalid or expired session token
FORBIDDEN403Insufficient permissions for this action
TENANT_SUSPENDED403Tenant account is suspended
PLAN_LIMIT403Feature not available on current plan tier
NOT_FOUND404Requested resource does not exist
VALIDATION_ERROR400Request body failed validation
DUPLICATE_RULE409Rule with same pattern and season exists
SYNC_IN_PROGRESS409Another sync operation is running
RATE_LIMITED429Too many requests
INTERNAL_ERROR500Unhandled server error

Rate Limit Headers

Every response includes rate limit information.

X-RateLimit-Limit: 100
X-RateLimit-Remaining: 94
X-RateLimit-Reset: 1707220860

Request Validation

All request bodies are validated using Zod schemas before reaching the route handler. Invalid requests are rejected with a 400 status and detailed error messages.

// src/api/validators/schemas.ts

import { z } from 'zod';

export const PriorityOverrideSchema = z.object({
  priority: z.number().int().min(0).max(5),
  override: z.boolean().optional().default(true),
  overrideReason: z.string().max(500).optional(),
});

export const CategoryRuleSchema = z.object({
  productTypePattern: z.string().min(1).max(255),
  season: z.enum(['winter', 'spring', 'summer', 'fall']).nullable().optional(),
  basePriority: z.number().int().min(0).max(5),
  modifiers: z.record(z.unknown()).optional().default({}),
});

export const SyncTriggerSchema = z.object({
  mode: z.enum(['full', 'delta']).optional().default('full'),
  activeOnly: z.boolean().optional().default(true),
});

export const BillingSubscribeSchema = z.object({
  plan: z.enum(['starter', 'growth', 'pro']),
});

export const RecalculateSchema = z.object({
  includeOverrides: z.boolean().optional().default(false),
  productTypes: z.array(z.string()).optional(),
  dryRun: z.boolean().optional().default(false),
});

Endpoint Summary

MethodPathAuthRate LimitTierDescription
POST/auth/callbackNo10/minAllShopify OAuth callback
GET/auth/sessionYes100/minAllValidate session
GET/api/productsYes100/minAllList products (paginated)
GET/api/products/:idYes100/minAllProduct detail with variants
POST/api/products/syncYes5/minAllTrigger Shopify sync
GET/api/prioritiesYes100/minAllList priority scores
PUT/api/priorities/:variantIdYes100/minAllManual override
POST/api/priorities/recalculateYes5/minAllBulk recalculation
GET/api/rulesYes100/minAllList category rules
POST/api/rulesYes100/minAllCreate rule
PUT/api/rules/:idYes100/minAllUpdate rule
DELETE/api/rules/:idYes100/minAllDelete rule
GET/api/seasonsYes100/minAllList seasonal calendars
PUT/api/seasons/:idYes100/minGrowth+Update calendar
POST/api/sync/sheetYes5/minAllPush to Google Sheet
GET/api/sync/statusYes100/minAllCurrent sync status
GET/api/sync/logsYes100/minAllSync history
GET/api/settingsYes100/minAllTenant settings
PUT/api/settingsYes100/minAllUpdate settings
POST/api/billing/subscribeYes10/minAllCreate subscription
GET/api/billing/statusYes100/minAllBilling status
POST/api/gdpr/customers-data-requestHMACAllGDPR data request
POST/api/gdpr/customers-redactHMACAllGDPR customer redact
POST/api/gdpr/shop-redactHMACAllGDPR shop redact

Chapter 16: Priority Scoring Engine

Scoring Architecture

The priority scoring engine is the core business logic of AdPriority. It takes a product variant as input and produces a single integer from 0 to 5, along with five custom label strings for the Google Merchant Center supplemental feed. The engine evaluates a layered stack of rules in a fixed order, where higher-priority layers can override lower ones.

SCORING PIPELINE
================

  Input: Product + Variant + Tenant Config
       |
       v
  +-----------------------------+
  |  Layer 1: CATEGORY RULES    |  Base priority from product type mapping
  |  "outerwear-heavy" --> 3    |  (20 category groups from 90 product types)
  +-----------------------------+
       |
       v
  +-----------------------------+
  |  Layer 2: SEASON MODIFIER   |  Override base with seasonal value
  |  Winter + outerwear --> 5   |  (from seasonal_calendars.category_overrides)
  +-----------------------------+
       |
       v
  +-----------------------------+
  |  Layer 3: INVENTORY MOD     |  Adjust based on stock levels
  |  0 qty --> force 0          |  (dead stock, low stock, overstock)
  |  <5 qty --> -1              |
  +-----------------------------+
       |
       v
  +-----------------------------+
  |  Layer 4: TAG MODIFIERS     |  Adjust based on product tags
  |  "DEAD50" --> force 0       |  (overrides and adjustments)
  |  "NAME BRAND" --> +1        |
  |  "Sale" --> -1              |
  +-----------------------------+
       |
       v
  +-----------------------------+
  |  Layer 5: NEW ARRIVAL       |  Boost recent products
  |  <30 days --> min score 5   |  (configurable threshold)
  +-----------------------------+
       |
       v
  +-----------------------------+
  |  Layer 6: MANUAL OVERRIDE   |  Merchant locked score
  |  If override=true, skip     |  (highest priority, never recalculated)
  |  all layers above           |
  +-----------------------------+
       |
       v
  +-----------------------------+
  |  CLAMP: 0-5                 |  Floor at 0, cap at 5
  +-----------------------------+
       |
       v
  +-----------------------------+
  |  GENERATE CUSTOM LABELS     |  5 labels for GMC feed
  |  label_0: priority-{score}  |
  |  label_1: {season}          |
  |  label_2: {category_group}  |
  |  label_3: {inventory_status}|
  |  label_4: {brand_tier}      |
  +-----------------------------+
       |
       v
  Output: PriorityResult {
    priority: 5,
    customLabels: { label0..label4 },
    source: "seasonal",
    appliedRules: [...]
  }

Category Groups

Nexus Clothing has 90 unique product types following a hierarchical naming convention (Gender-Department-SubCategory-Detail). Managing 90 individual rules would be unworkable, so AdPriority consolidates them into 20 category groups. Each group maps one or more product type strings to a single scoring profile.

Category Group Mapping

#Category GroupProduct Types IncludedProduct CountDefault Priority
1t-shirtsMen-Tops-T-Shirts, Boys-Tops-T-Shirts, Men-Tops-Crop-Top, Men-Tops-Tank Tops, T-Shirt1,3633
2long-sleeve-topsMen-Tops-T-Shirts-Long Sleeve543
3jeans-pantsMen-Bottoms-Pants-Jeans, Men-Bottoms-Stacked Jeans, Boys-Bottoms-Jeans, Men-Bottoms-Pants-Track Pants, Men-Bottoms-Pants-Cargo, Men-Bottoms-Stacked Track Pants9114
4sweatpantsMen-Bottoms-Pants-Sweatpants, Men-Bottoms-Stacked Sweatpants943
5shortsMen-Bottoms-Shorts, Men-Bottoms-Shorts-Denim, Men-Bottoms-Shorts-Cargo, Men-Bottoms-Shorts-Mesh, Men-Bottoms-Shorts-Basic-Fleece, Men-Bottoms-Shorts-Fashion-Shorts, Boys-Bottoms-Shorts, Women-Shorts, Shorts-Swimming-Trunks3153
6swim-shortsMen-Bottoms-Shorts-Swim-Shorts402
7hoodies-sweatshirtsMen-Tops-Hoodies & Sweatshirts, Boys-Tops-Hoodies, Sweatshirts & Hoodies2643
8outerwear-heavyMen-Tops-Outerwear-Jackets-Puffer Jackets, Men-Tops-Outerwear-Jackets-Coats-Shearling723
9outerwear-mediumMen-Tops-Outerwear-Jackets-Denim Jackets, Men-Tops-Outerwear-Jackets-Varsity Jackets, Men-Tops-Outerwear-Jackets-Fleece, Men-Tops-Outerwear-Jackets-Sports Jackets, Men-Tops-Outerwear-Vests, Boys-Tops-Jackets-Coats, Boys-Tops-Denim-Jackets2293
10outerwear-lightMen-Tops-Outerwear-Jackets-Track Jackets, Men-Tops-Outerwear-Jackets-Windbreaker393
11headwear-capsHeadwear-Baseball-Fitted, Headwear-Baseball-Dad Hat, Headwear-Baseball-Snapback, Headwear-Baseball-Low Profile Fitted7773
12headwear-coldHeadwear-Knit Beanies, Balaclavas1712
13headwear-summerHeadwear-Bucket Hat512
14joggersMen-Bottoms-Joggers, Boys-Bottoms-Joggers863
15footwear-sandalsMen-Footwear-Sandals & Slides582
16footwear-shoesMen-Footwear-Shoes, Women-Footwear-Shoes483
17underwear-socksMen-Underwear, Women-Underwear, Socks, Boys-Bottoms-Underwear5232
18accessoriesAccessories, Accessories-Bags, Accessories-Bags-Duffle Bags, Accessories-Bags-Smell Proof Bags, Accessories-Bags-Crossbody bags, Accessories-Jewelry, Accessories-Wallet Chains, Belts3502
19women-apparelWomen-Tops, Women-Leggings, Women-Dresses-Mini Dresses, Women-Dresses-Maxi Dresses, Women-Dresses-Midi Dresses, Women-Jumpsuits & Rompers-Rompers, Women-Jumpsuits & Rompers-Jumpsuits, Women-Tops-Outerwear-Jackets, Women-Bottoms-Skinny-Jeans, Women-Bottoms-Pants, Women-Sets-Pant Sets802
20excludeBath & Body, Household Cleaning Supplies-Cloth, Household Cleaning Supplies-Steel, Household Cleaning Supplies-Sponge, Gift Cards, Insurance, UpCart - Shipping Protection, Test Category, Sample~500

Category Group Resolver

The resolver function matches a Shopify product type string to one of the 20 category groups. If no match is found, the product falls into a default group with priority 2.

// src/services/priority/categoryResolver.ts

/**
 * Map of category group names to the Shopify product types they contain.
 * Loaded from the database (category_rules table) at scoring time,
 * but shown here as a static reference for clarity.
 */
const CATEGORY_GROUP_MAP: Record<string, string[]> = {
  't-shirts': [
    'Men-Tops-T-Shirts', 'Boys-Tops-T-Shirts',
    'Men-Tops-Crop-Top', 'Men-Tops-Tank Tops', 'T-Shirt',
  ],
  'long-sleeve-tops': [
    'Men-Tops-T-Shirts-Long Sleeve',
  ],
  'jeans-pants': [
    'Men-Bottoms-Pants-Jeans', 'Men-Bottoms-Stacked Jeans',
    'Boys-Bottoms-Jeans', 'Men-Bottoms-Pants-Track Pants',
    'Men-Bottoms-Pants-Cargo', 'Men-Bottoms-Stacked Track Pants',
  ],
  'sweatpants': [
    'Men-Bottoms-Pants-Sweatpants', 'Men-Bottoms-Stacked Sweatpants',
  ],
  'shorts': [
    'Men-Bottoms-Shorts', 'Men-Bottoms-Shorts-Denim',
    'Men-Bottoms-Shorts-Cargo', 'Men-Bottoms-Shorts-Mesh',
    'Men-Bottoms-Shorts-Basic-Fleece', 'Men-Bottoms-Shorts-Fashion-Shorts',
    'Boys-Bottoms-Shorts', 'Women-Shorts', 'Shorts-Swimming-Trunks',
  ],
  'swim-shorts': [
    'Men-Bottoms-Shorts-Swim-Shorts',
  ],
  'hoodies-sweatshirts': [
    'Men-Tops-Hoodies & Sweatshirts', 'Boys-Tops-Hoodies',
    'Sweatshirts & Hoodies',
  ],
  'outerwear-heavy': [
    'Men-Tops-Outerwear-Jackets-Puffer Jackets',
    'Men-Tops-Outerwear-Jackets-Coats-Shearling',
  ],
  'outerwear-medium': [
    'Men-Tops-Outerwear-Jackets-Denim Jackets',
    'Men-Tops-Outerwear-Jackets-Varsity Jackets',
    'Men-Tops-Outerwear-Jackets-Fleece',
    'Men-Tops-Outerwear-Jackets-Sports Jackets',
    'Men-Tops-Outerwear-Vests',
    'Boys-Tops-Jackets-Coats',
    'Boys-Tops-Denim-Jackets',
  ],
  'outerwear-light': [
    'Men-Tops-Outerwear-Jackets-Track Jackets',
    'Men-Tops-Outerwear-Jackets-Windbreaker',
  ],
  'headwear-caps': [
    'Headwear-Baseball-Fitted', 'Headwear-Baseball-Dad Hat',
    'Headwear-Baseball-Snapback', 'Headwear-Baseball-Low Profile Fitted',
  ],
  'headwear-cold': [
    'Headwear-Knit Beanies', 'Balaclavas',
  ],
  'headwear-summer': [
    'Headwear-Bucket Hat',
  ],
  'joggers': [
    'Men-Bottoms-Joggers', 'Boys-Bottoms-Joggers',
  ],
  'footwear-sandals': [
    'Men-Footwear-Sandals & Slides',
  ],
  'footwear-shoes': [
    'Men-Footwear-Shoes', 'Women-Footwear-Shoes',
  ],
  'underwear-socks': [
    'Men-Underwear', 'Women-Underwear', 'Socks', 'Boys-Bottoms-Underwear',
  ],
  'accessories': [
    'Accessories', 'Accessories-Bags', 'Accessories-Bags-Duffle Bags',
    'Accessories-Bags-Smell Proof Bags', 'Accessories-Bags-Crossbody bags',
    'Accessories-Jewelry', 'Accessories-Wallet Chains', 'Belts',
  ],
  'women-apparel': [
    'Women-Tops', 'Women-Leggings', 'Women-Dresses-Mini Dresses',
    'Women-Dresses-Maxi Dresses', 'Women-Dresses-Midi Dresses',
    'Women-Jumpsuits & Rompers-Rompers', 'Women-Jumpsuits & Rompers-Jumpsuits',
    'Women-Tops-Outerwear-Jackets', 'Women-Bottoms-Skinny-Jeans',
    'Women-Bottoms-Pants', 'Women-Sets-Pant Sets',
  ],
  'exclude': [
    'Bath & Body', 'Household Cleaning Supplies-Cloth',
    'Household Cleaning Supplies-Steel', 'Household Cleaning Supplies-Sponge',
    'Gift Cards', 'Insurance', 'UpCart - Shipping Protection',
    'Test Category', 'Sample',
  ],
};

/**
 * Resolve a Shopify product type to a category group name.
 * Returns 'unknown' if no group matches.
 */
export function resolveCategoryGroup(productType: string | null): string {
  if (!productType) return 'unknown';

  for (const [group, types] of Object.entries(CATEGORY_GROUP_MAP)) {
    if (types.includes(productType)) {
      return group;
    }
  }

  return 'unknown';
}

Season Modifiers

Each category group has a priority value per season, defined in the seasonal_calendars.category_overrides JSON column. When a season is active, the seasonal value replaces the base priority from the category rule.

Category-Season Priority Matrix

CATEGORY-SEASON PRIORITY MATRIX
================================

Category Group       | Winter | Spring | Summer | Fall | Default
---------------------+--------+--------+--------+------+--------
outerwear-heavy      |   5    |   1    |   0    |   4  |   3
outerwear-medium     |   4    |   3    |   0    |   4  |   3
outerwear-light      |   2    |   4    |   1    |   3  |   3
hoodies-sweatshirts  |   5    |   3    |   1    |   5  |   3
headwear-cold        |   5    |   1    |   0    |   3  |   2
jeans-pants          |   4    |   4    |   3    |   5  |   4
sweatpants           |   4    |   3    |   1    |   4  |   3
joggers              |   4    |   3    |   2    |   4  |   3
long-sleeve-tops     |   4    |   3    |   1    |   4  |   3
headwear-caps        |   3    |   3    |   3    |   3  |   3
footwear-shoes       |   3    |   3    |   3    |   3  |   3
t-shirts             |   2    |   4    |   5    |   3  |   3
shorts               |   0    |   3    |   5    |   1  |   3
swim-shorts          |   0    |   2    |   5    |   0  |   2
footwear-sandals     |   0    |   2    |   5    |   0  |   2
headwear-summer      |   0    |   3    |   4    |   2  |   2
underwear-socks      |   2    |   2    |   2    |   2  |   2
accessories          |   2    |   2    |   2    |   2  |   2
women-apparel        |   2    |   3    |   3    |   2  |   2
exclude              |   0    |   0    |   0    |   0  |   0

The matrix above shows the seasonal priority for each category group. During winter, heavy outerwear and hoodies get the maximum score (5) while shorts, swim shorts, and sandals are excluded (0). In summer, the pattern reverses completely.


Inventory Modifiers

Inventory levels directly affect whether a variant should be advertised. There is no point paying for clicks on a variant that is out of stock.

ConditionThresholdEffectReason
Dead stockinventory_quantity = 0Override to 0Cannot fulfill orders
Low inventoryinventory_quantity < 5Adjustment: -1Reduce spend, risk of stockout
In stockinventory_quantity >= 5No changeNormal operation
Overstockinventory_quantity > 50Adjustment: -1Already have plenty, reduce spend to preserve margin

The overstock modifier is intentionally conservative. A store with 50+ units of a variant does not need aggressive advertising to move that specific item unless it is a seasonal push. If the seasonal layer already assigns a high priority, the -1 from overstock is appropriate to moderate spend slightly.


Tag Modifiers

Nexus Clothing uses a rich tagging system with 2,522 unique tags. AdPriority evaluates specific tags in priority order. Tag modifiers come in two flavors: overrides (force a specific score regardless of other layers) and adjustments (add or subtract from the current score).

Tag Modifier Rules

TagTypeValueReasonProducts Affected
archivedOverride0Archived products must not be advertised3,130
DEAD50Override0Dead stock at 50% off, exclude from ads615
warning_inv_1Adjustment-1Low inventory warning from Shopify3,619
warning_invAdjustment-1General inventory warning1,372
in-stockAdjustment+1Available and ready to ship930
NAME BRANDAdjustment+1Premium brand, higher expected ROAS2,328
SaleAdjustment-1Already discounted, lower margin1,471

Tag Evaluation Order

Tags are evaluated in a specific order because overrides short-circuit the pipeline:

TAG EVALUATION ORDER
====================

  1. Check for override tags (processed first, any match stops evaluation)
     - "archived"  --> return 0 immediately
     - "DEAD50"    --> return 0 immediately

  2. Check for adjustment tags (all matching tags apply cumulatively)
     - "warning_inv_1" --> score -= 1
     - "warning_inv"   --> score -= 1
     - "in-stock"      --> score += 1
     - "NAME BRAND"    --> score += 1
     - "Sale"          --> score -= 1

  3. Clamp result to 0-5 range

A product can have multiple adjustment tags, and they stack. For example, a product tagged with both NAME BRAND (+1) and Sale (-1) sees no net adjustment. A product tagged with NAME BRAND (+1) and in-stock (+1) gains +2.


New Arrival Boost

Products created within a configurable number of days (default: 30) receive an automatic priority boost. The new arrival boost sets a floor rather than adding to the score: if the product already has a score of 5, the boost has no effect. If the product has a score of 2, it is raised to the configured minimum (default: 5).

SettingDefaultDescription
newArrivalDays30Days since product creation to qualify
newArrivalPriority5Minimum priority for qualifying products

The new arrival boost evaluates after tag modifiers but before manual overrides. This means a new arrival tagged with DEAD50 will still be excluded (tag override takes precedence because it returns immediately), but a new arrival with no override tags will be boosted to at least priority 5.


Score Clamping

After all layers have been applied, the final score is clamped to the 0-5 range. This is a simple floor/ceiling operation:

  • If the accumulated score is less than 0, it becomes 0.
  • If the accumulated score is greater than 5, it becomes 5.

This guarantees that the output is always a valid priority value regardless of how many adjustments stacked.


Custom Label Generation

After the priority score is calculated, five custom labels are generated for the Google Merchant Center supplemental feed. Each label encodes a different dimension of the product’s classification.

LabelFormatSourceExample Values
custom_label_0priority-{score}Calculated prioritypriority-0 through priority-5
custom_label_1{season}Current active seasonwinter, spring, summer, fall
custom_label_2{category_group}Category resolverouterwear-heavy, t-shirts, jeans-pants
custom_label_3{inventory_status}Inventory + tags analysisnew-arrival, in-stock, low-inventory, clearance, dead-stock
custom_label_4{brand_tier}Vendor + tag analysisname-brand, store-brand, off-brand

Inventory Status Determination

function getInventoryStatus(
  product: { tags: string[]; status: string; createdAt: Date },
  variant: { inventoryQuantity: number },
  newArrivalDays: number
): string {
  // Override statuses (checked first)
  if (product.tags.includes('archived') || product.status === 'archived') return 'dead-stock';
  if (product.tags.includes('DEAD50')) return 'dead-stock';
  if (variant.inventoryQuantity === 0) return 'dead-stock';

  // Warning statuses
  if (product.tags.includes('warning_inv_1')) return 'low-inventory';
  if (product.tags.includes('warning_inv')) return 'low-inventory';

  // Positive statuses
  const daysSinceCreated = daysBetween(product.createdAt, new Date());
  if (daysSinceCreated <= newArrivalDays) return 'new-arrival';

  // Clearance
  if (product.tags.includes('Sale')) return 'clearance';

  // Default
  return 'in-stock';
}

Brand Tier Determination

function getBrandTier(product: { vendor: string | null; tags: string[] }): string {
  // Check store brand first
  if (product.vendor === 'Nexus Clothing') return 'store-brand';

  // Check tags
  if (product.tags.includes('NAME BRAND')) return 'name-brand';
  if (product.tags.includes('OFF BRAND')) return 'off-brand';

  // Fallback: known premium vendors
  const KNOWN_NAME_BRANDS = [
    'New Era', 'Jordan Craig', 'Psycho Bunny', 'LACOSTE',
    'Gstar Raw', 'Ed Hardy', 'Kappa', 'RVCA', "Levi's",
  ];
  if (product.vendor && KNOWN_NAME_BRANDS.includes(product.vendor)) {
    return 'name-brand';
  }

  return 'off-brand';
}

Core Scoring Function

The following TypeScript function implements the complete scoring pipeline. It is called once per variant and returns the priority score along with all five custom labels and metadata about which rules were applied.

// src/services/priority/scorer.ts

import { resolveCategoryGroup } from './categoryResolver';

// ---- Types ----

interface ProductInput {
  id: string;
  productType: string | null;
  vendor: string | null;
  tags: string[];
  status: string;
  createdAt: Date;
}

interface VariantInput {
  id: string;
  shopifyVariantId: bigint;
  inventoryQuantity: number;
}

interface ScoringConfig {
  currentSeason: 'winter' | 'spring' | 'summer' | 'fall';
  seasonOverrides: Record<string, number>;  // category_group -> priority
  defaultPriority: number;
  newArrivalDays: number;
  newArrivalPriority: number;
  tagModifiers: Record<string, { override?: number; adjustment?: number }>;
  inventoryModifiers: {
    zeroStockOverride: number;
    lowStockThreshold: number;
    lowStockAdjustment: number;
    overstockThreshold: number;
    overstockAdjustment: number;
  };
}

interface ExistingScore {
  override: boolean;
  priority: number;
  overrideReason: string | null;
}

interface PriorityResult {
  priority: number;
  source: string;
  categoryGroup: string;
  customLabel0: string;
  customLabel1: string;
  customLabel2: string;
  customLabel3: string;
  customLabel4: string;
  appliedRules: string[];
}

// ---- Main Scoring Function ----

export function calculatePriority(
  product: ProductInput,
  variant: VariantInput,
  config: ScoringConfig,
  existingScore?: ExistingScore | null
): PriorityResult {

  const appliedRules: string[] = [];
  const categoryGroup = resolveCategoryGroup(product.productType);

  // ------------------------------------------------------------------
  // Layer 0: Check for manual override (highest priority)
  // ------------------------------------------------------------------
  if (existingScore?.override) {
    return buildResult({
      priority: existingScore.priority,
      source: 'manual',
      categoryGroup,
      product,
      variant,
      config,
      appliedRules: [`manual-override: ${existingScore.overrideReason || 'no reason'}`],
    });
  }

  // ------------------------------------------------------------------
  // Layer 1: Base priority from category rules
  // ------------------------------------------------------------------
  let priority = config.defaultPriority;
  let source = 'default';

  // Look up the default (season-agnostic) base priority for this group
  // In practice, this comes from category_rules WHERE season IS NULL
  // For now, the config.defaultPriority serves as fallback
  if (categoryGroup === 'exclude') {
    priority = 0;
    source = 'category';
    appliedRules.push(`category: ${categoryGroup} -> 0 (excluded)`);
    // Short-circuit: excluded categories skip all other layers
    return buildResult({ priority, source, categoryGroup, product, variant, config, appliedRules });
  }

  appliedRules.push(`category-default: ${categoryGroup} -> ${priority}`);

  // ------------------------------------------------------------------
  // Layer 2: Season modifier (overrides base)
  // ------------------------------------------------------------------
  const seasonalPriority = config.seasonOverrides[categoryGroup];
  if (seasonalPriority !== undefined) {
    priority = seasonalPriority;
    source = 'seasonal';
    appliedRules.push(`season-${config.currentSeason}: ${categoryGroup} -> ${priority}`);
  }

  // ------------------------------------------------------------------
  // Layer 3: Inventory modifiers
  // ------------------------------------------------------------------
  if (variant.inventoryQuantity === 0) {
    priority = config.inventoryModifiers.zeroStockOverride;
    source = 'inventory';
    appliedRules.push('inventory: zero stock -> 0');
    // Zero stock is a hard override, return immediately
    return buildResult({ priority, source, categoryGroup, product, variant, config, appliedRules });
  }

  if (variant.inventoryQuantity < config.inventoryModifiers.lowStockThreshold) {
    priority += config.inventoryModifiers.lowStockAdjustment;
    appliedRules.push(
      `inventory: low stock (${variant.inventoryQuantity} < ${config.inventoryModifiers.lowStockThreshold}) -> ${config.inventoryModifiers.lowStockAdjustment}`
    );
  }

  if (variant.inventoryQuantity > config.inventoryModifiers.overstockThreshold) {
    priority += config.inventoryModifiers.overstockAdjustment;
    appliedRules.push(
      `inventory: overstock (${variant.inventoryQuantity} > ${config.inventoryModifiers.overstockThreshold}) -> ${config.inventoryModifiers.overstockAdjustment}`
    );
  }

  // ------------------------------------------------------------------
  // Layer 4: Tag modifiers
  // ------------------------------------------------------------------
  for (const tag of product.tags) {
    const modifier = config.tagModifiers[tag];
    if (!modifier) continue;

    // Override tags short-circuit
    if (modifier.override !== undefined) {
      priority = modifier.override;
      source = 'tag-override';
      appliedRules.push(`tag-override: "${tag}" -> ${modifier.override}`);
      return buildResult({ priority, source, categoryGroup, product, variant, config, appliedRules });
    }

    // Adjustment tags accumulate
    if (modifier.adjustment !== undefined) {
      priority += modifier.adjustment;
      appliedRules.push(`tag-adjust: "${tag}" -> ${modifier.adjustment > 0 ? '+' : ''}${modifier.adjustment}`);
    }
  }

  // ------------------------------------------------------------------
  // Layer 5: New arrival boost
  // ------------------------------------------------------------------
  const daysSinceCreated = daysBetween(product.createdAt, new Date());
  if (daysSinceCreated <= config.newArrivalDays) {
    if (priority < config.newArrivalPriority) {
      const previousPriority = priority;
      priority = config.newArrivalPriority;
      source = 'new-arrival';
      appliedRules.push(
        `new-arrival: ${daysSinceCreated} days old, boosted from ${previousPriority} to ${priority}`
      );
    }
  }

  // ------------------------------------------------------------------
  // Clamp to 0-5
  // ------------------------------------------------------------------
  priority = clamp(priority, 0, 5);

  return buildResult({ priority, source, categoryGroup, product, variant, config, appliedRules });
}

// ---- Helper Functions ----

function clamp(value: number, min: number, max: number): number {
  return Math.min(max, Math.max(min, value));
}

function daysBetween(date1: Date, date2: Date): number {
  const ms = Math.abs(date2.getTime() - date1.getTime());
  return Math.floor(ms / (1000 * 60 * 60 * 24));
}

interface BuildResultInput {
  priority: number;
  source: string;
  categoryGroup: string;
  product: ProductInput;
  variant: VariantInput;
  config: ScoringConfig;
  appliedRules: string[];
}

function buildResult(input: BuildResultInput): PriorityResult {
  const { priority, source, categoryGroup, product, variant, config, appliedRules } = input;

  const inventoryStatus = getInventoryStatus(product, variant, config.newArrivalDays);
  const brandTier = getBrandTier(product);

  return {
    priority: clamp(priority, 0, 5),
    source,
    categoryGroup,
    customLabel0: `priority-${clamp(priority, 0, 5)}`,
    customLabel1: config.currentSeason,
    customLabel2: categoryGroup,
    customLabel3: inventoryStatus,
    customLabel4: brandTier,
    appliedRules,
  };
}

function getInventoryStatus(
  product: ProductInput,
  variant: VariantInput,
  newArrivalDays: number
): string {
  if (product.tags.includes('archived') || product.status === 'archived') return 'dead-stock';
  if (product.tags.includes('DEAD50')) return 'dead-stock';
  if (variant.inventoryQuantity === 0) return 'dead-stock';
  if (product.tags.includes('warning_inv_1')) return 'low-inventory';
  if (product.tags.includes('warning_inv')) return 'low-inventory';
  const daysSinceCreated = daysBetween(product.createdAt, new Date());
  if (daysSinceCreated <= newArrivalDays) return 'new-arrival';
  if (product.tags.includes('Sale')) return 'clearance';
  return 'in-stock';
}

function getBrandTier(product: ProductInput): string {
  if (product.vendor === 'Nexus Clothing') return 'store-brand';
  if (product.tags.includes('NAME BRAND')) return 'name-brand';
  if (product.tags.includes('OFF BRAND')) return 'off-brand';
  const KNOWN_NAME_BRANDS = [
    'New Era', 'Jordan Craig', 'Psycho Bunny', 'LACOSTE',
    'Gstar Raw', 'Ed Hardy', 'Kappa', 'RVCA', "Levi's",
  ];
  if (product.vendor && KNOWN_NAME_BRANDS.includes(product.vendor)) return 'name-brand';
  return 'off-brand';
}

Recalculation Triggers

The scoring engine is invoked under four circumstances. Each trigger type has different scope and performance characteristics.

RECALCULATION TRIGGERS
======================

  1. WEBHOOK (real-time, single product)
     Trigger:   Shopify products/create or products/update webhook
     Scope:     One product + all its variants
     Latency:   < 2 seconds
     When:      Product type changed, tags changed, new product created

  2. SCHEDULED (daily, full catalog)
     Trigger:   Cron job at 2:00 AM tenant local time
     Scope:     All non-overridden variants for the tenant
     Latency:   2-5 minutes for 20,000 variants
     When:      Every day (catches inventory changes, date-based rules)

  3. MANUAL (on-demand, configurable scope)
     Trigger:   Merchant clicks "Recalculate" in the UI
     Scope:     All variants, or filtered by product type
     Latency:   2-5 minutes for 20,000 variants
     When:      After rule changes, after season edits

  4. SEASON CHANGE (automatic, full catalog)
     Trigger:   Cron job detects new active season
     Scope:     All non-overridden variants for the tenant
     Latency:   2-5 minutes for 20,000 variants
     When:      First day of new season (or within transition window)

Batch Recalculation

For scheduled, manual, and seasonal triggers, the engine processes variants in batches to avoid holding a database transaction for too long.

// src/services/priority/recalculator.ts

const BATCH_SIZE = 500;

export async function recalculateAllPriorities(
  db: PrismaClient,
  tenantId: string,
  config: ScoringConfig,
  options: { includeOverrides: boolean; productTypes?: string[] }
): Promise<RecalculationResult> {
  let cursor: string | undefined;
  let processed = 0;
  let changed = 0;

  while (true) {
    // Fetch a batch of variants with their products and existing scores
    const variants = await db.variant.findMany({
      where: {
        tenantId,
        ...(options.productTypes && {
          product: { productType: { in: options.productTypes } },
        }),
        ...(!options.includeOverrides && {
          priorityScore: {
            OR: [
              { override: false },
              { is: null }, // No score yet
            ],
          },
        }),
      },
      include: {
        product: true,
        priorityScore: true,
      },
      take: BATCH_SIZE,
      ...(cursor && { skip: 1, cursor: { id: cursor } }),
      orderBy: { id: 'asc' },
    });

    if (variants.length === 0) break;

    // Calculate new priorities for the batch
    const updates: Array<{ variantId: string; result: PriorityResult }> = [];

    for (const variant of variants) {
      const result = calculatePriority(
        {
          id: variant.product.id,
          productType: variant.product.productType,
          vendor: variant.product.vendor,
          tags: variant.product.tags,
          status: variant.product.status,
          createdAt: variant.product.createdAt,
        },
        {
          id: variant.id,
          shopifyVariantId: variant.shopifyVariantId,
          inventoryQuantity: variant.inventoryQuantity,
        },
        config,
        variant.priorityScore
      );

      // Only update if the score actually changed
      if (!variant.priorityScore || variant.priorityScore.priority !== result.priority) {
        updates.push({ variantId: variant.id, result });
        changed++;
      }

      processed++;
    }

    // Batch upsert the changed scores
    for (const { variantId, result } of updates) {
      await db.priorityScore.upsert({
        where: { variantId },
        create: {
          tenantId,
          variantId,
          priority: result.priority,
          customLabel0: result.customLabel0,
          customLabel1: result.customLabel1,
          customLabel2: result.customLabel2,
          customLabel3: result.customLabel3,
          customLabel4: result.customLabel4,
          calculatedAt: new Date(),
        },
        update: {
          priority: result.priority,
          customLabel0: result.customLabel0,
          customLabel1: result.customLabel1,
          customLabel2: result.customLabel2,
          customLabel3: result.customLabel3,
          customLabel4: result.customLabel4,
          calculatedAt: new Date(),
        },
      });
    }

    cursor = variants[variants.length - 1].id;
  }

  return { processed, changed, unchanged: processed - changed };
}

Worked Examples with Real Nexus Data

Example 1: Winter Bestseller

Product:  "Rebel Minds Puffer Jacket - Black"
Type:     Men-Tops-Outerwear-Jackets-Puffer Jackets
Vendor:   Rebel Minds
Tags:     ["Men", "in-stock", "rebel-minds"]
Status:   active
Created:  2025-09-15 (148 days ago)
Variant:  Size L, inventory_quantity = 8

Layer 1 - Category:     outerwear-heavy -> default 3
Layer 2 - Season:       Winter + outerwear-heavy -> 5
Layer 3 - Inventory:    8 units (in stock, no modifier) -> no change
Layer 4 - Tags:         "in-stock" -> +1 = 6
Layer 5 - New arrival:  148 days > 30 -> no boost
Clamp:                  6 -> 5 (capped at 5)

RESULT: priority=5, source=seasonal
Labels: priority-5 | winter | outerwear-heavy | in-stock | off-brand

Example 2: Summer Clearance Item

Product:  "Generic Mesh Shorts - Red"
Type:     Men-Bottoms-Shorts-Mesh
Vendor:   Black Keys
Tags:     ["Men", "Sale", "SEMI25SALE"]
Status:   active
Created:  2025-03-01 (346 days ago)
Variant:  Size M, inventory_quantity = 3

Layer 1 - Category:     shorts -> default 3
Layer 2 - Season:       Winter + shorts -> 0
Layer 3 - Inventory:    3 units (< 5, low stock) -> -1 = -1
Layer 4 - Tags:         "Sale" -> -1 = -2
Layer 5 - New arrival:  346 days > 30 -> no boost
Clamp:                  -2 -> 0 (floored at 0)

RESULT: priority=0, source=seasonal
Labels: priority-0 | winter | shorts | clearance | off-brand

Example 3: New Arrival Name Brand

Product:  "Psycho Bunny Classic Polo - Navy"
Type:     Men-Tops-T-Shirts
Vendor:   Psycho Bunny
Tags:     ["Men", "NAME BRAND", "in-stock", "psycho-bunny"]
Status:   active
Created:  2026-01-28 (13 days ago)
Variant:  Size M, inventory_quantity = 15

Layer 1 - Category:     t-shirts -> default 3
Layer 2 - Season:       Winter + t-shirts -> 2
Layer 3 - Inventory:    15 units (in stock, no modifier) -> no change
Layer 4 - Tags:         "NAME BRAND" -> +1 = 3, "in-stock" -> +1 = 4
Layer 5 - New arrival:  13 days <= 30 -> boost to min 5 = 5
Clamp:                  5 (within range)

RESULT: priority=5, source=new-arrival
Labels: priority-5 | winter | t-shirts | new-arrival | name-brand

Example 4: Dead Stock Override

Product:  "Old Season Graphic Tee"
Type:     Men-Tops-T-Shirts
Vendor:   Nexus Clothing
Tags:     ["Men", "DEAD50", "archived", "Sale"]
Status:   archived
Variant:  Size XL, inventory_quantity = 2

Layer 1 - Category:     t-shirts -> default 3
Layer 2 - Season:       Winter + t-shirts -> 2
Layer 3 - Inventory:    2 units (< 5, low stock) -> -1 = 1
Layer 4 - Tags:         "DEAD50" -> OVERRIDE to 0 (immediate return)

RESULT: priority=0, source=tag-override
Labels: priority-0 | winter | t-shirts | dead-stock | store-brand

Example 5: Merchant Manual Override

Product:  "Ethika Boxer Brief - Signature"
Type:     Men-Underwear
Vendor:   Ethika
Tags:     ["Men", "NAME BRAND"]
Status:   active
Existing: override=true, priority=4, reason="Top seller in store"
Variant:  Size M, inventory_quantity = 45

Layer 0 - Manual override detected -> return existing score immediately

RESULT: priority=4, source=manual
Labels: priority-4 | winter | underwear-socks | in-stock | name-brand

Summary

The priority scoring engine evaluates six layers in a fixed order: category rules, season modifiers, inventory modifiers, tag modifiers, new arrival boost, and manual override. The engine produces a single 0-5 score and five custom label strings per variant, ready for the Google Sheets supplemental feed.

The design is deliberately deterministic: given the same product data, variant inventory, tags, season, and rules configuration, the engine always produces the same output. This makes the system predictable, testable, and debuggable. The appliedRules array in the result provides a complete audit trail of every layer that influenced the final score, which is invaluable for answering the question “why does this product have priority 3?”

Chapter 17: Seasonal Automation

Why Seasonal Automation Matters

A retailer like Nexus Clothing sells products with dramatically different demand curves throughout the year. Puffer jackets that fly off shelves in December become dead weight in June. Mesh shorts that deserve every advertising dollar in July should receive zero spend in January. Without seasonal automation, a merchant must manually adjust priorities four times per year for every product category – a process that is easy to forget, hard to get right, and impossible to optimize at scale.

AdPriority’s seasonal automation (available on the Growth tier and above) eliminates this manual work. The system detects the current season, looks up the category-season priority matrix, and recalculates every variant’s score automatically. Merchants can customize the calendar, add micro-seasons, and preview changes before they take effect.

SEASONAL IMPACT ON NEXUS CATALOG
==================================

  WINTER (Nov-Feb)                       SUMMER (May-Aug)
  ==================                     ==================
  Puffer Jackets:  5 (PUSH HARD)        Puffer Jackets:  0 (EXCLUDE)
  Hoodies:         5 (PUSH HARD)        Hoodies:         1 (MINIMAL)
  Beanies:         5 (PUSH HARD)        Beanies:         0 (EXCLUDE)
  Jeans:           4 (STRONG)           Jeans:           3 (NORMAL)
  T-Shirts:        2 (LOW)             T-Shirts:        5 (PUSH HARD)
  Shorts:          0 (EXCLUDE)         Shorts:          5 (PUSH HARD)
  Sandals:         0 (EXCLUDE)         Sandals:         5 (PUSH HARD)
  Swim Shorts:     0 (EXCLUDE)         Swim Shorts:     5 (PUSH HARD)

  Budget impact: ~40% of catalog priorities change per season transition
  Products affected: ~1,500 of 2,425 active products

Default Season Calendar

Every new tenant starts with a default four-season calendar based on Northern Hemisphere retail patterns. The calendar defines month ranges for each season, which the automation engine uses to determine the currently active season.

Default Season Definitions

SeasonStart MonthEnd MonthDurationKey Retail Events
WinterNovember (11)February (2)4 monthsHolidays, New Year, Valentine’s Day
SpringMarch (3)April (4)2 monthsSpring Break, Easter
SummerMay (5)August (8)4 monthsMemorial Day, July 4th, Back-to-School prep
FallSeptember (9)October (10)2 monthsBack-to-School, Labor Day, early holiday prep

Annual Timeline

ANNUAL SEASON CALENDAR
=======================

  JAN    FEB    MAR    APR    MAY    JUN    JUL    AUG    SEP    OCT    NOV    DEC
  |______|______|______|______|______|______|______|______|______|______|______|______|
  |   WINTER    |  SPRING   |         SUMMER          |   FALL   |   WINTER    |
  |             |           |                          |          |             |
  |  Jackets 5  | Trans-    |  Shorts 5                | Jeans 5  |  Jackets 5  |
  |  Hoodies 5  | ition     |  T-Shirts 5              | Hoodies 5|  Hoodies 5  |
  |  Shorts 0   | period    |  Sandals 5               | Shorts 1 |  Shorts 0   |
  |             |           |  Jackets 0               |          |             |
  |_____________|___________|__________________________|__________|_____________|
       ^             ^              ^              ^          ^           ^
     peak          gradual        peak          gradual    gradual      peak
    winter         warm-up       summer         cool-down   prep       winter

Why These Dates

The default dates are based on when retail demand shifts, not astronomical seasons. Winter starts in November (not December 21) because holiday shopping begins before Thanksgiving. Summer starts in May because warm-weather products begin selling with Memorial Day sales. Fall is compressed to two months because the transition from summer to winter prep is fast in streetwear retail.


Category-Season Priority Matrix

The full priority matrix defines the exact score for each of the 20 category groups in each of the four seasons. This matrix is stored in the seasonal_calendars.category_overrides JSON column and loaded by the scoring engine during recalculation.

Complete Matrix

Category GroupWinterSpringSummerFallDefaultRationale
outerwear-heavy51043Peak in cold months, excluded in summer
outerwear-medium43043Denim/varsity jackets relevant fall through winter
outerwear-light24133Windbreakers and track jackets peak in spring
hoodies-sweatshirts53153Strong in cold months, minimal in summer
headwear-cold51032Beanies and balaclavas peak in winter
headwear-caps33333Year-round demand, no seasonal adjustment
headwear-summer03422Bucket hats peak in summer
jeans-pants44354Year-round staple, peak in back-to-school fall
sweatpants43143Loungewear peaks in cold months
joggers43243Active + loungewear, cold-weather bias
long-sleeve-tops43143Layering piece, cold-weather demand
t-shirts24533Peak in summer, lowest in winter
shorts03513Excluded in winter, peak in summer
swim-shorts02502Pure summer product
footwear-sandals02502Pure summer product
footwear-shoes33333Year-round, no seasonal adjustment
underwear-socks22222Year-round basics, always low priority
accessories22222Year-round, no seasonal adjustment
women-apparel23322Slight spring/summer bump
exclude00000Always excluded regardless of season

Visual Heat Map

SEASONAL PRIORITY HEAT MAP
===========================
(0 = excluded, 5 = push hard)

                    WINTER  SPRING  SUMMER  FALL
                    ------  ------  ------  ----
outerwear-heavy       5       1       0      4     ████░    ░      .     ███
outerwear-medium      4       3       0      4     ███     ██      .     ███
outerwear-light       2       4       1      3     █      ███      ░     ██
hoodies-sweatshirts   5       3       1      5     ████░  ██       ░     ████░
headwear-cold         5       1       0      3     ████░   ░       .     ██
jeans-pants           4       4       3      5     ███    ███      ██    ████░
sweatpants            4       3       1      4     ███     ██      ░     ███
joggers               4       3       2      4     ███     ██      █     ███
long-sleeve-tops      4       3       1      4     ███     ██      ░     ███
headwear-caps         3       3       3      3     ██      ██      ██    ██
t-shirts              2       4       5      3     █      ███     ████░  ██
shorts                0       3       5      1     .       ██     ████░   ░
swim-shorts           0       2       5      0     .       █      ████░   .
footwear-sandals      0       2       5      0     .       █      ████░   .
headwear-summer       0       3       4      2     .       ██      ███    █
underwear-socks       2       2       2      2     █       █       █      █
accessories           2       2       2      2     █       █       █      █

Legend: . = 0 (exclude)  ░ = 1 (minimal)  █ = 2-3  ██ = 3-4  ████░ = 5

Season Transition Logic

Season Detection

The active season is determined by checking the current date against the tenant’s seasonal calendar entries. The algorithm handles wrap-around months (winter spans November through February, crossing the year boundary).

// src/services/priority/seasonal.ts

import { Season, SeasonalCalendar } from '@prisma/client';

interface SeasonInfo {
  currentSeason: Season;
  calendarId: string;
  daysRemaining: number;
  nextSeason: Season;
  nextTransitionDate: Date;
}

/**
 * Determine the currently active season for a tenant.
 * Handles month wrap-around (e.g., winter = Nov-Feb).
 */
export function detectCurrentSeason(
  calendars: SeasonalCalendar[],
  today: Date = new Date()
): SeasonInfo {
  const currentMonth = today.getMonth() + 1; // 1-12

  for (const cal of calendars) {
    if (isMonthInRange(currentMonth, cal.startMonth, cal.endMonth)) {
      const nextCal = getNextSeason(calendars, cal.season);
      const transitionDate = getTransitionDate(nextCal, today);
      const daysRemaining = daysBetween(today, transitionDate);

      return {
        currentSeason: cal.season,
        calendarId: cal.id,
        daysRemaining,
        nextSeason: nextCal.season,
        nextTransitionDate: transitionDate,
      };
    }
  }

  // Fallback: if no season matches (should not happen with valid data)
  throw new Error('No season matches the current date. Check calendar configuration.');
}

/**
 * Check if a month falls within a range, handling wrap-around.
 * Example: isMonthInRange(1, 11, 2) => true (January is in Nov-Feb)
 */
function isMonthInRange(month: number, start: number, end: number): boolean {
  if (start <= end) {
    // Simple range: Mar(3) to Apr(4)
    return month >= start && month <= end;
  } else {
    // Wrap-around range: Nov(11) to Feb(2)
    return month >= start || month <= end;
  }
}

/**
 * Get the next season in the cycle.
 */
function getNextSeason(
  calendars: SeasonalCalendar[],
  current: Season
): SeasonalCalendar {
  const order: Season[] = ['winter', 'spring', 'summer', 'fall'];
  const currentIdx = order.indexOf(current);
  const nextSeason = order[(currentIdx + 1) % 4];
  return calendars.find(c => c.season === nextSeason)!;
}

/**
 * Calculate the date of the next season transition.
 */
function getTransitionDate(nextCal: SeasonalCalendar, today: Date): Date {
  const year = today.getFullYear();
  let transitionDate = new Date(year, nextCal.startMonth - 1, 1);

  // If the transition date is in the past, it must be next year
  if (transitionDate <= today) {
    transitionDate = new Date(year + 1, nextCal.startMonth - 1, 1);
  }

  return transitionDate;
}

function daysBetween(d1: Date, d2: Date): number {
  return Math.ceil((d2.getTime() - d1.getTime()) / (1000 * 60 * 60 * 24));
}

Transition Detection Cron Job

A cron job runs daily at midnight and checks whether a season transition has occurred. When a new season is detected, it triggers a full priority recalculation for the tenant.

// src/scheduler/seasonalTransition.ts

import cron from 'node-cron';
import { prisma } from '../database/client';
import { detectCurrentSeason } from '../services/priority/seasonal';
import { recalculateAllPriorities } from '../services/priority/recalculator';
import { buildScoringConfig } from '../services/priority/configBuilder';

// Store the last known season per tenant to detect transitions
const lastKnownSeason = new Map<string, string>();

/**
 * Daily season transition check.
 * Runs at midnight UTC. For each active tenant, checks if the season
 * has changed since the last check and triggers recalculation if so.
 */
cron.schedule('0 0 * * *', async () => {
  console.log('[seasonal] Running daily season transition check');

  const tenants = await prisma.tenant.findMany({
    where: { status: 'active' },
    include: { seasonalCalendars: true },
  });

  for (const tenant of tenants) {
    try {
      const seasonInfo = detectCurrentSeason(tenant.seasonalCalendars);
      const previousSeason = lastKnownSeason.get(tenant.id);

      if (previousSeason && previousSeason !== seasonInfo.currentSeason) {
        console.log(
          `[seasonal] Season transition detected for ${tenant.shopifyShopDomain}: ` +
          `${previousSeason} -> ${seasonInfo.currentSeason}`
        );

        // Log the transition
        await prisma.syncLog.create({
          data: {
            tenantId: tenant.id,
            syncType: 'gmc',
            status: 'started',
            startedAt: new Date(),
          },
        });

        // Build scoring config with the new season's overrides
        const config = await buildScoringConfig(tenant.id, seasonInfo.currentSeason);

        // Recalculate all priorities
        const result = await recalculateAllPriorities(
          prisma,
          tenant.id,
          config,
          { includeOverrides: false }
        );

        console.log(
          `[seasonal] Recalculation complete for ${tenant.shopifyShopDomain}: ` +
          `${result.changed} changed out of ${result.processed} processed`
        );
      }

      lastKnownSeason.set(tenant.id, seasonInfo.currentSeason);

    } catch (error) {
      console.error(
        `[seasonal] Error checking season for ${tenant.shopifyShopDomain}:`,
        error
      );
    }
  }
});

Gradual Season Transitions

Season changes do not happen abruptly in retail. The demand curve for puffer jackets does not drop from peak to zero on March 1. AdPriority supports gradual transitions with a configurable window before and after the official season boundary. During the transition window, products receive blended priorities.

Transition Window

GRADUAL TRANSITION: Winter -> Spring
======================================

  Feb 15          Mar 1            Mar 15
    |               |                |
    |  TRANSITION   |  TRANSITION    |
    |  WINDOW START | (official)     | WINDOW END
    |               |                |
    v               v                v
  +---------------+----------------+
  |  Ramp Down    |   Ramp Up      |
  |  Winter items |   Spring items |
  +---------------+----------------+

  Outerwear-heavy during transition:
    Feb 15:  priority = 5 (full winter)
    Feb 22:  priority = 4 (blended: 75% winter + 25% spring)
    Mar 1:   priority = 3 (blended: 50% winter + 50% spring)
    Mar 8:   priority = 2 (blended: 25% winter + 75% spring)
    Mar 15:  priority = 1 (full spring value)

  Shorts during transition:
    Feb 15:  priority = 0 (full winter, excluded)
    Feb 22:  priority = 1 (blended)
    Mar 1:   priority = 2 (blended)
    Mar 8:   priority = 2 (blended)
    Mar 15:  priority = 3 (full spring value)

Transition Window Configuration

SettingDefaultDescription
transitionDaysBefore14Days before official transition to start blending
transitionDaysAfter14Days after official transition to finish blending
transitionEnabledtrueWhether to use gradual transitions

Blended Priority Calculation

// src/services/priority/transition.ts

interface TransitionState {
  inTransition: boolean;
  fromSeason: string;
  toSeason: string;
  progress: number;  // 0.0 (fully "from" season) to 1.0 (fully "to" season)
}

/**
 * Calculate the transition state for a given date.
 * Returns whether we are in a transition window and the blending progress.
 */
export function getTransitionState(
  calendars: SeasonalCalendar[],
  today: Date,
  transitionDaysBefore: number = 14,
  transitionDaysAfter: number = 14
): TransitionState | null {
  const currentMonth = today.getMonth() + 1;
  const currentDay = today.getDate();

  // Find the nearest transition boundary
  for (const cal of calendars) {
    const transitionDate = new Date(
      today.getFullYear(),
      cal.startMonth - 1,
      1
    );

    const daysDiff = daysBetween(today, transitionDate);

    // Check if we are within the transition window
    if (daysDiff > 0 && daysDiff <= transitionDaysBefore) {
      // Before the transition: ramping down current season
      const previousCal = getPreviousSeason(calendars, cal.season);
      const totalWindow = transitionDaysBefore + transitionDaysAfter;
      const progress = (transitionDaysBefore - daysDiff) / totalWindow;

      return {
        inTransition: true,
        fromSeason: previousCal.season,
        toSeason: cal.season,
        progress,
      };
    }

    if (daysDiff <= 0 && Math.abs(daysDiff) <= transitionDaysAfter) {
      // After the transition: ramping up new season
      const previousCal = getPreviousSeason(calendars, cal.season);
      const totalWindow = transitionDaysBefore + transitionDaysAfter;
      const progress = (transitionDaysBefore + Math.abs(daysDiff)) / totalWindow;

      return {
        inTransition: true,
        fromSeason: previousCal.season,
        toSeason: cal.season,
        progress,
      };
    }
  }

  return null;
}

/**
 * Blend two seasonal priorities based on transition progress.
 */
export function blendSeasonalPriority(
  fromPriority: number,
  toPriority: number,
  progress: number  // 0.0 = fully "from", 1.0 = fully "to"
): number {
  const blended = fromPriority * (1 - progress) + toPriority * progress;
  return Math.round(blended);
}

Transition in Practice

Consider the Winter-to-Spring transition on March 1, with a 14-day window on each side (February 15 through March 15).

DateProgressOuterwear-HeavyShortsT-Shirts
Feb 14– (no transition)5 (winter)0 (winter)2 (winter)
Feb 150.00502
Feb 180.11502
Feb 220.25413
Feb 250.36413
Mar 10.50323
Mar 40.61223
Mar 80.75224
Mar 110.86134
Mar 151.00134
Mar 16– (no transition)1 (spring)3 (spring)4 (spring)

Customizable Calendars

Custom Season Dates

Merchants on the Growth tier and above can adjust the start and end months of each season through the Settings UI. This accommodates regional differences and business-specific patterns. A store in Florida might start summer a month earlier; a store selling ski gear might extend winter through March.

// Example: Custom calendar for a Florida-based store
const floridaCalendar = [
  { season: 'winter',  startMonth: 12, endMonth: 1  },  // Short winter
  { season: 'spring',  startMonth: 2,  endMonth: 3  },  // Early spring
  { season: 'summer',  startMonth: 4,  endMonth: 9  },  // Long summer
  { season: 'fall',    startMonth: 10, endMonth: 11 },   // Short fall
];

Micro-Seasons

Beyond the four standard seasons, merchants can define micro-seasons for specific retail events. A micro-season temporarily overrides the active season’s priority matrix for a defined date range.

Micro-SeasonTypical DatesDurationCategory Impact
Back to SchoolAug 1 - Sep 156 weeksJeans +2, T-shirts +1, Hoodies +1
Holiday RushNov 20 - Dec 255 weeksAll categories +1, gift items +2
Valentine’s DayFeb 7 - Feb 141 weekAccessories +2
Spring BreakMar 10 - Mar 2010 daysShorts +1, Swim shorts +2
Clearance WeekEnd of each season1-2 weeksSale items -1, remaining season items -2

Micro-Season Data Model

Micro-seasons are stored as category rules with date ranges, using the category_rules table.

// Example: Creating a "Back to School" micro-season rule
await prisma.categoryRule.createMany({
  data: [
    {
      tenantId: tenant.id,
      productTypePattern: 'jeans-pants',
      season: 'summer', // Active during summer season
      basePriority: 5,  // Override summer's default 3 for jeans
      modifiers: {
        microSeason: 'back-to-school',
        startDate: '2026-08-01',
        endDate: '2026-09-15',
        reason: 'Back to School promotion'
      },
    },
    {
      tenantId: tenant.id,
      productTypePattern: 'hoodies-sweatshirts',
      season: 'summer',
      basePriority: 3,  // Override summer's default 1 for hoodies
      modifiers: {
        microSeason: 'back-to-school',
        startDate: '2026-08-01',
        endDate: '2026-09-15',
        reason: 'Early fall prep for Back to School'
      },
    },
  ],
});

Seasonal Rules Examples with Real Nexus Data

Scenario 1: Winter Season Active (Current)

Nexus Clothing in February 2026. Winter season is active with the standard matrix.

NEXUS CATALOG - WINTER PRIORITIES
===================================

Category                    | Products | Priority | Budget Impact
----------------------------+----------+----------+--------------
Outerwear (Heavy)           |    72    |    5     | Maximum spend
Hoodies & Sweatshirts       |   264    |    5     | Maximum spend
Headwear (Beanies/Balacl.)  |   171    |    5     | Maximum spend
Jeans & Pants               |   911    |    4     | High spend
Sweatpants                  |    94    |    4     | High spend
Joggers                     |    86    |    4     | High spend
Long Sleeve Tops            |    54    |    4     | High spend
Outerwear (Medium)          |   229    |    4     | High spend
Headwear (Caps)             |   777    |    3     | Normal spend
Footwear (Shoes)            |    48    |    3     | Normal spend
T-Shirts                    | 1,363    |    2     | Low spend
Underwear & Socks           |   523    |    2     | Low spend
Accessories                 |   350    |    2     | Low spend
Women's Apparel             |    80    |    2     | Low spend
Shorts (all types)          |   315    |    0     | EXCLUDED
Swim Shorts                 |    40    |    0     | EXCLUDED
Sandals & Slides            |    58    |    0     | EXCLUDED
Headwear (Bucket Hats)      |    51    |    0     | EXCLUDED
Excluded (Gift Cards etc.)  |   ~50    |    0     | EXCLUDED

Total active in ads:  ~3,911 products (excludes 514 at priority 0)
Priority 5 ad push:     507 products (heavy outerwear, hoodies, beanies)
Priority 4 balanced:   1,374 products (jeans, sweatpants, joggers, medium outerwear)

Scenario 2: Transition to Spring (March 1)

On March 1, the automation engine detects the season change and recalculates all priorities.

WINTER -> SPRING TRANSITION IMPACT
====================================

Category                    | Winter | Spring | Change | Products
----------------------------+--------+--------+--------+---------
Outerwear (Heavy)           |   5    |   1    |  -4    |    72
Outerwear (Medium)          |   4    |   3    |  -1    |   229
Headwear (Beanies/Balacl.)  |   5    |   1    |  -4    |   171
Hoodies & Sweatshirts       |   5    |   3    |  -2    |   264
Sweatpants                  |   4    |   3    |  -1    |    94
Long Sleeve Tops            |   4    |   3    |  -1    |    54
Joggers                     |   4    |   3    |  -1    |    86
                            |        |        |        |
Shorts (all types)          |   0    |   3    |  +3    |   315
Headwear (Bucket Hats)      |   0    |   3    |  +3    |    51
Swim Shorts                 |   0    |   2    |  +2    |    40
Sandals & Slides            |   0    |   2    |  +2    |    58
T-Shirts                    |   2    |   4    |  +2    | 1,363
Outerwear (Light)           |   2    |   4    |  +2    |    39
                            |        |        |        |
Jeans & Pants               |   4    |   4    |   0    |   911
Headwear (Caps)             |   3    |   3    |   0    |   777
Footwear (Shoes)            |   3    |   3    |   0    |    48
Underwear & Socks           |   2    |   2    |   0    |   523
Accessories                 |   2    |   2    |   0    |   350

SUMMARY:
  Products with INCREASED priority:  1,866
  Products with DECREASED priority:    970
  Products UNCHANGED:               2,609
  Products newly INCLUDED:            464 (were priority 0)
  Products newly EXCLUDED:              0 (nothing goes to 0 in Spring)

Scenario 3: Summer Peak (July)

Deep summer. Warm-weather products are at maximum priority. This is when the seasonal engine delivers the most value, ensuring every ad dollar goes toward products people actually want to buy.

NEXUS CATALOG - SUMMER PRIORITIES (JULY)
==========================================

PRIORITY 5 (Push Hard) - Maximum ad spend:
  Shorts (all types):       315 products
  T-Shirts:               1,363 products
  Swim Shorts:               40 products
  Sandals & Slides:          58 products
  ---
  TOTAL PUSH HARD:        1,776 products

PRIORITY 4 (Strong):
  Bucket Hats:               51 products

PRIORITY 3 (Normal):
  Jeans & Pants:            911 products
  Headwear (Caps):          777 products
  Footwear (Shoes):          48 products
  Women's Apparel:           80 products

PRIORITY 2 (Low):
  Underwear & Socks:        523 products
  Accessories:              350 products
  Joggers:                   86 products
  Outerwear (Light):         39 products

PRIORITY 1 (Minimal):
  Hoodies & Sweatshirts:    264 products
  Sweatpants:                94 products
  Long Sleeve Tops:          54 products

PRIORITY 0 (Excluded):
  Outerwear (Heavy):         72 products
  Outerwear (Medium):       229 products
  Headwear (Beanies):       171 products
  Excluded (Gift Cards):    ~50 products
  ---
  TOTAL EXCLUDED:           522 products

Scenario 4: Back-to-School Micro-Season (August)

A merchant activates a “Back to School” micro-season from August 1 through September 15. This overrides certain summer priorities to capture the back-to-school shopping rush.

BACK TO SCHOOL OVERRIDE (Aug 1 - Sep 15)
==========================================

Base: Summer season priorities
Override: Back-to-School micro-season adjustments

Category              | Summer | BTS Override | Net  | Reason
----------------------+--------+--------------+------+----------------------------
Jeans & Pants         |   3    |     +2       |  5   | Kids and teens buying jeans
Hoodies & Sweatshirts |   1    |     +2       |  3   | Early fall wardrobe prep
T-Shirts              |   5    |      0       |  5   | Still summer, remains high
Shorts                |   5    |     -1       |  4   | Starting to decline
Outerwear (Medium)    |   0    |     +2       |  2   | Denim jackets for school
Headwear (Caps)       |   3    |     +1       |  4   | Back-to-school accessory

Products affected by BTS micro-season: ~2,180
Duration: 46 days
Auto-reverts: September 16 (returns to standard fall priorities)

Implementation Details

Scoring Config Builder

The scoring config builder assembles the complete configuration needed by the scoring engine, including seasonal overrides and transition blending.

// src/services/priority/configBuilder.ts

import { PrismaClient, Season } from '@prisma/client';
import { detectCurrentSeason } from './seasonal';
import { getTransitionState, blendSeasonalPriority } from './transition';

export async function buildScoringConfig(
  tenantId: string,
  overrideSeason?: Season
): Promise<ScoringConfig> {
  const tenant = await prisma.tenant.findUniqueOrThrow({
    where: { id: tenantId },
    include: {
      seasonalCalendars: true,
      categoryRules: true,
    },
  });

  // Detect current season
  const seasonInfo = detectCurrentSeason(tenant.seasonalCalendars);
  const activeSeason = overrideSeason || seasonInfo.currentSeason;

  // Get the seasonal overrides for the active season
  const activeCalendar = tenant.seasonalCalendars.find(
    c => c.season === activeSeason
  );
  let seasonOverrides = (activeCalendar?.categoryOverrides as Record<string, number>) || {};

  // Check for gradual transition
  const transition = getTransitionState(tenant.seasonalCalendars, new Date());
  if (transition && !overrideSeason) {
    const fromCalendar = tenant.seasonalCalendars.find(
      c => c.season === transition.fromSeason
    );
    const toCalendar = tenant.seasonalCalendars.find(
      c => c.season === transition.toSeason
    );

    if (fromCalendar && toCalendar) {
      const fromOverrides = (fromCalendar.categoryOverrides as Record<string, number>) || {};
      const toOverrides = (toCalendar.categoryOverrides as Record<string, number>) || {};

      // Blend priorities for all category groups
      const allGroups = new Set([
        ...Object.keys(fromOverrides),
        ...Object.keys(toOverrides),
      ]);

      seasonOverrides = {};
      for (const group of allGroups) {
        const fromPriority = fromOverrides[group] ?? 3;
        const toPriority = toOverrides[group] ?? 3;
        seasonOverrides[group] = blendSeasonalPriority(
          fromPriority, toPriority, transition.progress
        );
      }
    }
  }

  // Build tag modifiers from category rules
  const tagModifiers: Record<string, { override?: number; adjustment?: number }> = {};
  for (const rule of tenant.categoryRules) {
    const mods = rule.modifiers as any;
    if (mods?.tagAdjustments) {
      for (const [tag, modifier] of Object.entries(mods.tagAdjustments)) {
        tagModifiers[tag] = modifier as any;
      }
    }
  }

  return {
    currentSeason: activeSeason,
    seasonOverrides,
    defaultPriority: 3,
    newArrivalDays: 30,
    newArrivalPriority: 5,
    tagModifiers,
    inventoryModifiers: {
      zeroStockOverride: 0,
      lowStockThreshold: 5,
      lowStockAdjustment: -1,
      overstockThreshold: 50,
      overstockAdjustment: -1,
    },
  };
}

Notification on Season Transition

When a season transition is detected, the system creates a notification visible in the Shopify Admin dashboard.

// src/services/notifications/seasonalNotification.ts

interface TransitionNotification {
  type: 'season_transition';
  title: string;
  body: string;
  severity: 'info';
  data: {
    fromSeason: string;
    toSeason: string;
    productsAffected: number;
    increases: number;
    decreases: number;
  };
}

export function buildTransitionNotification(
  fromSeason: string,
  toSeason: string,
  result: RecalculationResult
): TransitionNotification {
  return {
    type: 'season_transition',
    title: `Season changed: ${capitalize(fromSeason)} to ${capitalize(toSeason)}`,
    body: `${result.changed} product priorities were updated. ` +
          `${result.processed - result.changed} products unchanged. ` +
          `Scores will sync to Google Merchant Center on the next scheduled sync.`,
    severity: 'info',
    data: {
      fromSeason,
      toSeason,
      productsAffected: result.changed,
      increases: 0, // Populated by recalculator
      decreases: 0, // Populated by recalculator
    },
  };
}

function capitalize(s: string): string {
  return s.charAt(0).toUpperCase() + s.slice(1);
}

Testing the Seasonal Engine

Unit Test: Season Detection

// tests/unit/seasonal.test.ts

import { detectCurrentSeason } from '../../src/services/priority/seasonal';

const calendars = [
  { id: '1', tenantId: 't1', season: 'winter', startMonth: 11, endMonth: 2, name: 'Winter', categoryOverrides: {}, createdAt: new Date(), updatedAt: new Date() },
  { id: '2', tenantId: 't1', season: 'spring', startMonth: 3,  endMonth: 4, name: 'Spring', categoryOverrides: {}, createdAt: new Date(), updatedAt: new Date() },
  { id: '3', tenantId: 't1', season: 'summer', startMonth: 5,  endMonth: 8, name: 'Summer', categoryOverrides: {}, createdAt: new Date(), updatedAt: new Date() },
  { id: '4', tenantId: 't1', season: 'fall',   startMonth: 9,  endMonth: 10, name: 'Fall',  categoryOverrides: {}, createdAt: new Date(), updatedAt: new Date() },
] as any[];

describe('Season Detection', () => {
  it('detects winter in January', () => {
    const result = detectCurrentSeason(calendars, new Date('2026-01-15'));
    expect(result.currentSeason).toBe('winter');
    expect(result.nextSeason).toBe('spring');
  });

  it('detects spring in March', () => {
    const result = detectCurrentSeason(calendars, new Date('2026-03-15'));
    expect(result.currentSeason).toBe('spring');
    expect(result.nextSeason).toBe('summer');
  });

  it('detects summer in July', () => {
    const result = detectCurrentSeason(calendars, new Date('2026-07-15'));
    expect(result.currentSeason).toBe('summer');
    expect(result.nextSeason).toBe('fall');
  });

  it('detects fall in October', () => {
    const result = detectCurrentSeason(calendars, new Date('2026-10-15'));
    expect(result.currentSeason).toBe('fall');
    expect(result.nextSeason).toBe('winter');
  });

  it('detects winter in November (start of winter)', () => {
    const result = detectCurrentSeason(calendars, new Date('2026-11-01'));
    expect(result.currentSeason).toBe('winter');
  });

  it('detects winter in February (end of winter)', () => {
    const result = detectCurrentSeason(calendars, new Date('2026-02-28'));
    expect(result.currentSeason).toBe('winter');
  });

  it('handles year boundary (December is winter)', () => {
    const result = detectCurrentSeason(calendars, new Date('2026-12-25'));
    expect(result.currentSeason).toBe('winter');
  });
});

Integration Test: Full Season Transition

// tests/integration/seasonTransition.test.ts

describe('Season Transition', () => {
  it('recalculates all priorities on winter to spring transition', async () => {
    // Setup: Create tenant with winter priorities
    const tenant = await createTestTenant();
    await seedWinterPriorities(tenant.id);

    // Verify initial state
    const winterScores = await getScoreDistribution(tenant.id);
    expect(winterScores.excludedCount).toBeGreaterThan(400); // shorts, sandals excluded

    // Act: Trigger spring transition
    const springConfig = await buildScoringConfig(tenant.id, 'spring');
    const result = await recalculateAllPriorities(
      prisma, tenant.id, springConfig, { includeOverrides: false }
    );

    // Assert: Spring priorities applied
    expect(result.changed).toBeGreaterThan(1000);

    // Shorts should now be priority 3 (were 0)
    const shortsScores = await getScoresForCategory(tenant.id, 'shorts');
    for (const score of shortsScores) {
      expect(score.priority).toBe(3);
    }

    // Heavy outerwear should now be priority 1 (were 5)
    const outerScores = await getScoresForCategory(tenant.id, 'outerwear-heavy');
    for (const score of outerScores) {
      expect(score.priority).toBe(1);
    }
  });
});

Summary

Seasonal automation is the feature that transforms AdPriority from a manual scoring tool into an intelligent, hands-off system. The automation engine detects season changes automatically, applies the category-season priority matrix, and recalculates every variant’s score in minutes. Gradual transitions prevent abrupt priority shifts, and customizable calendars with micro-seasons accommodate the unique rhythms of each merchant’s business.

For Nexus Clothing, seasonal automation means roughly 1,500 products automatically receive updated priorities four times per year, ensuring that winter jackets get maximum ad spend in December and zero spend in July, without the merchant lifting a finger. The back-to-school micro-season ensures jeans and hoodies receive a boost in August, capturing early fall demand while summer products are still strong. This level of automation is the core value proposition of the Growth tier and represents the difference between “nice to have” and “cannot live without” for seasonal retailers.

Chapter 18: Shopify Polaris UI

Embedded App Architecture

AdPriority runs as an embedded application inside the Shopify Admin. This means the app renders within an iframe managed by Shopify’s App Bridge, inheriting the admin’s navigation, authentication context, and visual language. Merchants never leave the Shopify Admin to use AdPriority.

App Bridge 4.1 Integration

App Bridge 4.1 provides the communication layer between AdPriority’s React frontend and the Shopify Admin host. It handles session token generation, navigation synchronization, and modal management.

EMBEDDED APP ARCHITECTURE
=========================

  +---------------------------------------------------------------+
  |  Shopify Admin (host)                                          |
  |                                                                |
  |  +---------------------------+  +----------------------------+ |
  |  | Admin Navigation          |  | Top Bar                    | |
  |  | - Home                    |  | - Store name               | |
  |  | - Orders                  |  | - Notifications            | |
  |  | - Products                |  | - Account                  | |
  |  | - Customers               |  +----------------------------+ |
  |  | - ...                     |                                 |
  |  | - Apps                    |  +----------------------------+ |
  |  |   +- AdPriority    <--------| App Bridge 4.1 iframe       | |
  |  |      |- Dashboard         |  |                            | |
  |  |      |- Products          |  |  React + Polaris v13       | |
  |  |      |- Rules             |  |  +----------------------+  | |
  |  |      |- Seasons           |  |  | AdPriority UI        |  | |
  |  |      |- Settings          |  |  | (renders here)       |  | |
  |  +---------------------------+  |  +----------------------+  | |
  |                                 +----------------------------+ |
  +---------------------------------------------------------------+

The entry point initializes App Bridge and wraps the application in the Polaris AppProvider:

// main.tsx
import { AppProvider } from "@shopify/polaris";
import { BrowserRouter } from "react-router-dom";
import enTranslations from "@shopify/polaris/locales/en.json";
import App from "./App";

function Root() {
  return (
    <AppProvider i18n={enTranslations}>
      <BrowserRouter>
        <App />
      </BrowserRouter>
    </AppProvider>
  );
}

Session tokens are extracted automatically by App Bridge and attached to every API request. The backend validates these JWTs using the Shopify client secret, confirming the merchant’s identity and shop context without cookies.


Polaris v13 Component Usage

AdPriority uses Polaris v13 exclusively for all UI elements. This guarantees visual consistency with the Shopify Admin and satisfies the App Store review requirement that embedded apps use Polaris.

Core Component Map

ComponentAdPriority Usage
PageTop-level page wrapper with title, breadcrumbs, primary action
LayoutTwo-column and single-column content sections
CardContent containers for each functional area
DataTableProduct list with sortable columns
BadgePriority level indicators (color-coded 0-5)
ButtonPrimary, secondary, and destructive actions
ModalConfirmation dialogs for bulk operations and overrides
BannerSync status alerts, errors, onboarding messages
SelectPriority level dropdown, season picker, filter selectors
TextFieldSearch, manual override reason, rule pattern input
NavigationMenuApp-level navigation (Dashboard, Products, Rules, Seasons, Settings)
ProgressBarSync progress indicator
SpinnerLoading states for data fetches
EmptyStateFirst-run experience when no products or rules exist
TabsSub-navigation within pages (e.g., product filters)
IndexTableAlternative to DataTable for selectable product rows
FiltersApplied filter tags for product list refinement

Priority Badge Component

The priority badge is the most frequently used custom component. It maps the 0-5 score to Polaris Badge tones:

PRIORITY BADGE MAPPING
======================

  Score   Badge Tone      Label          Color
  -----   ----------      -----          -----
    5      "success"       "5 - Push"     Green
    4      "info"          "4 - Strong"   Teal
    3      "attention"     "3 - Normal"   Yellow
    2      "warning"       "2 - Low"      Orange
    1      "critical"      "1 - Minimal"  Red (light)
    0      "new"           "0 - Exclude"  Grey
// components/PriorityBadge.tsx
import { Badge } from "@shopify/polaris";

const PRIORITY_CONFIG = {
  5: { tone: "success",   label: "5 - Push Hard" },
  4: { tone: "info",      label: "4 - Strong" },
  3: { tone: "attention",  label: "3 - Normal" },
  2: { tone: "warning",   label: "2 - Low" },
  1: { tone: "critical",  label: "1 - Minimal" },
  0: { tone: "new",       label: "0 - Exclude" },
} as const;

export function PriorityBadge({ priority }: { priority: number }) {
  const config = PRIORITY_CONFIG[priority as keyof typeof PRIORITY_CONFIG];
  return <Badge tone={config.tone}>{config.label}</Badge>;
}

Key Screens

Screen 1: Dashboard

The dashboard is the landing page after app installation. It provides a high-level overview of the priority distribution across the product catalog, the last sync status, and quick action buttons.

+------------------------------------------------------------------+
| AdPriority                                              [Sync Now]|
+------------------------------------------------------------------+
|                                                                    |
|  +---------------------------+  +-------------------------------+  |
|  | Priority Distribution     |  | Sync Status                  |  |
|  |                           |  |                               |  |
|  |       [PIE CHART]         |  | Shopify:  2 min ago    [ok]  |  |
|  |                           |  | Sheet:    1 hour ago   [ok]  |  |
|  |  5 - Push:     312  13%  |  | GMC:      Daily at 2AM [ok]  |  |
|  |  4 - Strong:   487  20%  |  |                               |  |
|  |  3 - Normal:   824  34%  |  | Next scheduled sync:         |  |
|  |  2 - Low:      401  17%  |  | Today at 3:00 PM             |  |
|  |  1 - Minimal:  198   8%  |  |                               |  |
|  |  0 - Exclude:  203   8%  |  +-------------------------------+  |
|  |                           |                                    |
|  +---------------------------+  +-------------------------------+  |
|                                 | Quick Stats                   |  |
|  +---------------------------+  |                               |  |
|  | Recent Activity           |  | Total Products:     2,425    |  |
|  |                           |  | Active in GMC:     15,284    |  |
|  | 10:30 - Season changed    |  | Needs Attention:       47    |  |
|  |   to Winter               |  | New Arrivals:          12    |  |
|  | 10:28 - 47 products       |  | Rules Active:          20    |  |
|  |   updated via rules       |  |                               |  |
|  | 09:15 - Sync completed    |  | [View Products]              |  |
|  |   (2,425 ok, 0 errors)   |  | [Manage Rules]               |  |
|  |                           |  +-------------------------------+  |
|  +---------------------------+                                    |
+------------------------------------------------------------------+

Layout: Layout with two-column Layout.Section pairs. The pie chart uses Chart.js rendered inside a Card. Each stat card uses Polaris Text components with variant="headingLg" for the numbers.

Quick Actions:

  • “Sync Now” button (primary action on the Page component)
  • “View Products” link navigates to the Products screen
  • “Manage Rules” link navigates to the Rules screen

“Needs Attention” count highlights products with priority 0 that still have inventory, meaning they could be advertised but are currently excluded.

Screen 2: Products List

The products list is the primary working screen. It displays every active product with its current priority score, category group, seasonal context, and sync status.

+------------------------------------------------------------------+
| Products                                   [Recalculate All] [...]|
+------------------------------------------------------------------+
| Filters: [Priority: All v] [Type: All v] [Status: All v] [Search]|
| Applied: Priority >= 3  x  |  Type: Outerwear  x                 |
+------------------------------------------------------------------+
| [ ] Product            | Type           | Vendor      | Priority  |
|     Title              |                |             | Score     |
|------------------------------------------------------------------+
| [ ] Jordan Craig       | Stacked Jeans  | Jordan Craig| [5-Push]  |
|     Stacked Jeans      |                |             | seasonal  |
|------------------------------------------------------------------+
| [ ] Rebel Minds Puffer | Puffer Jackets | Rebel Minds | [5-Push]  |
|     Jacket Camo        |                |             | seasonal  |
|------------------------------------------------------------------+
| [ ] New Era Yankees    | Baseball-Fitted| New Era     | [4-Strong]|
|     59FIFTY Navy       |                |             | rule+tag  |
|------------------------------------------------------------------+
| [ ] Ethika Men Go      | Men-Underwear  | Ethika      | [2-Low]   |
|     Pac Go             |                |             | default   |
|------------------------------------------------------------------+
| [ ] G3 Patriots        | Hoodies &      | G-III Sports| [0-Excl]  |
|     Waffled Hoodie     | Sweatshirts    |             | tag:DEAD50|
+------------------------------------------------------------------+
| Showing 1-50 of 2,425     [<] [1] [2] [3] ... [49] [>]           |
+------------------------------------------------------------------+
| Selected: 3 products   [Set Priority...] [Recalculate] [Export]   |
+------------------------------------------------------------------+

Columns:

ColumnSourceSortable
Product (title + image thumbnail)Shopify product dataYes (alphabetical)
Typeproduct_type fieldYes
Vendorvendor fieldYes
PriorityCalculated score with PriorityBadgeYes (numeric)
SeasonCurrent season labelNo
Statussync_status (synced/pending/error)Yes
ActionsEdit button, overflow menuNo

Filters (using Polaris Filters component):

  • Priority: Dropdown with 0-5, or “All”
  • Type: Dropdown populated from category groups (T-Shirts, Jeans & Pants, etc.)
  • Status: Synced, Pending, Error
  • Search: Free-text search across title, vendor, SKU

Bulk Actions (appear when rows are selected via IndexTable):

  • “Set Priority” – Opens modal with priority selector and reason field
  • “Recalculate” – Re-runs the scoring engine for selected products
  • “Export” – Downloads selected products as CSV

Pagination: Server-side, 50 products per page. Uses cursor-based pagination for performance with large catalogs.

Screen 3: Product Detail

Clicking a product row opens the detail view. This screen shows the full priority calculation breakdown: what the base score is, which modifiers applied, and the resulting final score.

+------------------------------------------------------------------+
| < Back to Products                                                |
| Jordan Craig Stacked Jeans                            [Save] [...]|
+------------------------------------------------------------------+
|                                                                    |
|  +---------------------------+  +-------------------------------+  |
|  | Product Info              |  | Priority Breakdown            |  |
|  |                           |  |                               |  |
|  | Title: Jordan Craig       |  | Category: Jeans & Pants       |  |
|  |   Stacked Jeans           |  | Base priority:           4    |  |
|  | Type: Men-Bottoms-         |  |                               |  |
|  |   Stacked Jeans           |  | Seasonal (Winter):       4    |  |
|  | Vendor: Jordan Craig      |  |                               |  |
|  | Tags: jordan-craig,       |  | Tag: NAME BRAND         +1   |  |
|  |   NAME BRAND, in-stock    |  | Tag: in-stock            +1   |  |
|  | Created: 2025-09-15       |  | (capped at 5)                |  |
|  | Variants: 12              |  |                               |  |
|  | Inventory: 48 total       |  | ================================|  |
|  |                           |  | Final Priority:    [5-Push]   |  |
|  +---------------------------+  | Source: seasonal + tags        |  |
|                                 |                               |  |
|  +---------------------------+  | [ ] Lock this priority        |  |
|  | Manual Override           |  +-------------------------------+  |
|  |                           |                                    |
|  | Priority: [5 v]           |  +-------------------------------+  |
|  | Reason:  [____________]   |  | Custom Labels (GMC)          |  |
|  | [Apply Override]          |  |                               |  |
|  |                           |  | label_0: priority-5           |  |
|  | Note: Overrides all       |  | label_1: winter               |  |
|  | rules until unlocked.     |  | label_2: jeans-pants          |  |
|  +---------------------------+  | label_3: in-stock             |  |
|                                 | label_4: name-brand           |  |
|                                 |                               |  |
|                                 | Last synced: 2 hours ago      |  |
|                                 +-------------------------------+  |
+------------------------------------------------------------------+

Priority Breakdown Card: Shows each step of the calculation pipeline in order. The merchant can see exactly why a product received its score, which eliminates the “black box” concern.

Manual Override Card: Contains a Select dropdown (0-5), a TextField for the reason (required when overriding), and an “Apply Override” Button. When applied, the product’s priority_locked flag is set to true, and no automated rules will change the score until the lock is removed.

Custom Labels Card: Displays the exact values that will be (or have been) synced to Google Merchant Center. This provides transparency into what Google Ads campaigns will see.

Screen 4: Rules Engine

The rules engine screen lists all category rules and provides a form for creating and editing rules. Rules define the base priority for product type patterns and their seasonal modifiers.

+------------------------------------------------------------------+
| Rules                                                  [Add Rule] |
+------------------------------------------------------------------+
|                                                                    |
|  +--------------------------------------------------------------+  |
|  | Active Rules (20)                                    [Reorder]|  |
|  |--------------------------------------------------------------|  |
|  | #  | Name               | Pattern          | Base | Products |  |
|  |----|--------------------|---------—--------|------|----------|  |
|  | 1  | Outerwear - Heavy  | *Puffer*,        |  5   |    35    |  |
|  |    |                    | *Shearling*      |      |          |  |
|  | 2  | Hoodies & Sweats   | *Hoodies*,       |  3   |   264    |  |
|  |    |                    | *Sweatshirts*    |      |          |  |
|  | 3  | T-Shirts           | Men-Tops-T-Shirt*|  3   |  1,363   |  |
|  | 4  | Jeans & Pants      | *Pants-Jeans,    |  4   |   911    |  |
|  |    |                    | *Stacked Jeans   |      |          |  |
|  | 5  | Shorts             | *Shorts*         |  3   |   315    |  |
|  | ...                                                           |  |
|  | 20 | Exclude            | Bath & Body,     |  0   |    50    |  |
|  |    |                    | Gift Cards, ...  |      |          |  |
|  +--------------------------------------------------------------+  |
|                                                                    |
+------------------------------------------------------------------+

Create / Edit Rule Form (opens in a modal or inline section):

+------------------------------------------------------------------+
| Create Rule                                                       |
+------------------------------------------------------------------+
|                                                                    |
|  Rule Name:     [Outerwear - Heavy______________]                  |
|  Description:   [Winter outerwear, peak demand___]                 |
|                                                                    |
|  Product Type Pattern:                                             |
|  [*Puffer Jackets, *Shearling___________________]                  |
|  (comma-separated, supports * wildcard)                            |
|                                                                    |
|  Base Priority: [5 - Push Hard  v]                                 |
|                                                                    |
|  Seasonal Modifiers:                                               |
|  +-------------+----------+                                        |
|  | Season      | Priority |                                        |
|  |-------------|----------|                                        |
|  | Winter      | [5 v]    |                                        |
|  | Spring      | [1 v]    |                                        |
|  | Summer      | [0 v]    |                                        |
|  | Fall        | [4 v]    |                                        |
|  +-------------+----------+                                        |
|                                                                    |
|  [ ] Active                                                        |
|                                                                    |
|  Matching Products: 35 (preview)                                   |
|                                                                    |
|                                       [Cancel]  [Save Rule]       |
+------------------------------------------------------------------+

The pattern field supports comma-separated product type strings with * wildcard matching. When the merchant types a pattern, a live preview shows how many products match. This provides immediate feedback before saving.

Rules are evaluated in order (drag-to-reorder supported). The first matching rule wins, so more specific rules should be placed higher in the list.

Screen 5: Seasonal Calendar

The seasonal calendar provides a visual representation of season boundaries across the year, with a matrix editor for category-season priority assignments.

+------------------------------------------------------------------+
| Seasons                                                [Add Season]|
+------------------------------------------------------------------+
|                                                                    |
|  +--------------------------------------------------------------+  |
|  | Calendar View                                                 |  |
|  |                                                               |  |
|  | Jan  Feb  Mar  Apr  May  Jun  Jul  Aug  Sep  Oct  Nov  Dec   |  |
|  | [====WINTER====][=====SPRING=====][=====SUMMER=====][==FALL==]|  |
|  | Dec 1 - Feb 28  Mar 1 - May 31   Jun 1 - Aug 31  Sep1-Nov30 |  |
|  |                                                               |  |
|  | Drag boundaries to adjust dates                               |  |
|  +--------------------------------------------------------------+  |
|                                                                    |
|  +--------------------------------------------------------------+  |
|  | Category x Season Matrix                          [Edit Mode] |  |
|  |--------------------------------------------------------------|  |
|  | Category          | Winter | Spring | Summer | Fall | Default |  |
|  |-------------------|--------|--------|--------|------|---------|  |
|  | T-Shirts          |   2    |   4    |   5    |  3   |    3    |  |
|  | Jeans & Pants     |   4    |   4    |   3    |  5   |    4    |  |
|  | Shorts            |   0    |   3    |   5    |  1   |    3    |  |
|  | Hoodies & Sweats  |   5    |   3    |   1    |  5   |    3    |  |
|  | Outerwear - Heavy |   5    |   1    |   0    |  4   |    3    |  |
|  | Outerwear - Medium|   4    |   3    |   0    |  4   |    3    |  |
|  | Outerwear - Light |   2    |   4    |   1    |  3   |    3    |  |
|  | Headwear - Caps   |   3    |   3    |   3    |  3   |    3    |  |
|  | Headwear - Cold   |   5    |   1    |   0    |  3   |    2    |  |
|  | Headwear - Summer |   0    |   3    |   4    |  2   |    2    |  |
|  | Joggers           |   4    |   3    |   2    |  4   |    3    |  |
|  | Underwear & Socks |   2    |   2    |   2    |  2   |    2    |  |
|  | Accessories       |   2    |   2    |   2    |  2   |    2    |  |
|  | Footwear - Sandals|   0    |   2    |   5    |  0   |    2    |  |
|  | Footwear - Shoes  |   3    |   3    |   3    |  3   |    3    |  |
|  | Swim Shorts       |   0    |   2    |   5    |  0   |    2    |  |
|  | Women - Apparel   |   2    |   3    |   3    |  2   |    2    |  |
|  | Exclude           |   0    |   0    |   0    |  0   |    0    |  |
|  +--------------------------------------------------------------+  |
|                                                                    |
|  Current Season: Winter (Dec 1 - Feb 28)                           |
|  Next Transition: Spring on Mar 1 (19 days)                        |
|                                                                    |
+------------------------------------------------------------------+

Calendar View: A horizontal bar chart showing the four season blocks across 12 months. In edit mode, the season boundaries become draggable handles. Adjusting a boundary updates the seasons table dates and triggers a recalculation preview showing how many products would change priority.

Category x Season Matrix: A grid where each cell is a Select dropdown (0-5). Cells are color-coded to match the priority badge scheme (green for 5, grey for 0). Editing a cell updates the corresponding season_rules record.

Transition Countdown: Displayed below the matrix, showing the current active season and days until the next automatic transition. When a transition is imminent (within 7 days), a Banner with tone warning appears at the top of the page.

Screen 6: Settings

The settings screen manages GMC connection details, sync configuration, and notification preferences.

+------------------------------------------------------------------+
| Settings                                                   [Save] |
+------------------------------------------------------------------+
|                                                                    |
|  +--------------------------------------------------------------+  |
|  | Google Merchant Center                                        |  |
|  |                                                               |  |
|  | Connection Status: Connected [checkmark]                      |  |
|  | Merchant ID:    [123456789________]                           |  |
|  | Sync Method:    [Google Sheets v]                             |  |
|  | Sheet URL:      [https://docs.google.com/spreadsheets/d/...] |  |
|  |                                                               |  |
|  | [Test Connection]  [Disconnect]                               |  |
|  +--------------------------------------------------------------+  |
|                                                                    |
|  +--------------------------------------------------------------+  |
|  | Sync Schedule                                                 |  |
|  |                                                               |  |
|  | Frequency:      [Every 6 hours v]                             |  |
|  | Options:        Every hour / Every 6 hours / Daily / Manual   |  |
|  |                                                               |  |
|  | Auto-sync on product changes:  [x] Enabled                   |  |
|  | Auto-sync on season transition: [x] Enabled                  |  |
|  +--------------------------------------------------------------+  |
|                                                                    |
|  +--------------------------------------------------------------+  |
|  | Priority Defaults                                             |  |
|  |                                                               |  |
|  | Default priority for unmapped types: [3 - Normal v]           |  |
|  | New arrival boost:                   [x] Enabled              |  |
|  | New arrival duration:                [14] days                |  |
|  | New arrival priority:                [5 - Push Hard v]        |  |
|  +--------------------------------------------------------------+  |
|                                                                    |
|  +--------------------------------------------------------------+  |
|  | Notifications                                                 |  |
|  |                                                               |  |
|  | Email on sync failure:       [x] Enabled                     |  |
|  | Email on season transition:  [x] Enabled                     |  |
|  | Notification email:          [will@nexusclothing.com]         |  |
|  +--------------------------------------------------------------+  |
|                                                                    |
+------------------------------------------------------------------+

GMC Connection Card: The Google Sheets URL is the primary configuration. A “Test Connection” button verifies the Sheets API can write to the specified spreadsheet. For Pro tier merchants using the Content API directly, this card shows OAuth connection status instead.

Sync Schedule Card: Controls how often AdPriority pushes updated labels to the Google Sheet. The frequency dropdown determines the cron interval for the background worker job.

Priority Defaults Card: Sets the fallback priority for products whose product type does not match any rule, and configures the new arrival boost behavior.

Screen 7: Billing

The billing screen shows the current subscription plan, usage against limits, and upgrade/downgrade options. It integrates with the Shopify Billing API.

+------------------------------------------------------------------+
| Billing                                                           |
+------------------------------------------------------------------+
|                                                                    |
|  +--------------------------------------------------------------+  |
|  | Current Plan: Growth ($79/mo)                                 |  |
|  |                                                               |  |
|  | Status:    Active                                             |  |
|  | Billing:   Monthly (via Shopify)                              |  |
|  | Next bill: March 10, 2026                                     |  |
|  | Products:  2,425 / Unlimited                                  |  |
|  |                                                               |  |
|  | Features included:                                            |  |
|  | [ok] Unlimited products                                       |  |
|  | [ok] Seasonal automation                                      |  |
|  | [ok] Rules engine                                             |  |
|  | [ok] Tag modifiers                                            |  |
|  | [ok] New arrival boost                                        |  |
|  | [--] Google Ads integration (Pro)                             |  |
|  | [--] AI recommendations (Pro)                                 |  |
|  | [--] ROAS tracking (Pro)                                      |  |
|  +--------------------------------------------------------------+  |
|                                                                    |
|  +-------------------+  +-------------------+  +-----------------+ |
|  | Starter  $29/mo   |  | Growth   $79/mo   |  | Pro    $199/mo  | |
|  |                   |  | (current)         |  |                 | |
|  | 500 products      |  | Unlimited         |  | Unlimited       | |
|  | Manual scoring    |  | Seasonal auto     |  | Google Ads API  | |
|  | GMC sync          |  | Rules engine      |  | AI suggestions  | |
|  |                   |  | Tag modifiers     |  | ROAS tracking   | |
|  |                   |  |                   |  | Performance     | |
|  |                   |  |                   |  |   dashboard     | |
|  | [Downgrade]       |  | Current Plan      |  | [Upgrade]       | |
|  +-------------------+  +-------------------+  +-----------------+ |
|                                                                    |
+------------------------------------------------------------------+

Upgrade and downgrade actions redirect to the Shopify billing confirmation page via the appSubscriptionCreate GraphQL mutation. Shopify handles payment collection, proration, and cancellation natively.


AdPriority uses the Polaris NavigationMenu component (provided by App Bridge) to register its navigation items with the Shopify Admin sidebar. This places the app’s pages directly in the admin navigation when the merchant is inside the app.

APP NAVIGATION STRUCTURE
========================

  AdPriority
  |
  +-- Dashboard        /          Overview, stats, quick actions
  +-- Products         /products   Product list, priorities, bulk ops
  +-- Rules            /rules      Category rules, rule builder
  +-- Seasons          /seasons    Seasonal calendar, matrix editor
  +-- Settings         /settings   GMC config, sync, notifications
// App.tsx
import { NavMenu } from "@shopify/app-bridge-react";
import { Routes, Route } from "react-router-dom";

export default function App() {
  return (
    <>
      <NavMenu>
        <a href="/" rel="home">Dashboard</a>
        <a href="/products">Products</a>
        <a href="/rules">Rules</a>
        <a href="/seasons">Seasons</a>
        <a href="/settings">Settings</a>
      </NavMenu>
      <Routes>
        <Route path="/" element={<Dashboard />} />
        <Route path="/products" element={<Products />} />
        <Route path="/products/:id" element={<ProductDetail />} />
        <Route path="/rules" element={<Rules />} />
        <Route path="/seasons" element={<Seasons />} />
        <Route path="/settings" element={<Settings />} />
        <Route path="/billing" element={<Billing />} />
      </Routes>
    </>
  );
}

The billing page is intentionally omitted from the main navigation since merchants access it infrequently. A link to it is placed in the Settings page and in the Dashboard’s plan status card.


Responsive Design and Accessibility

Polaris v13 handles responsive layout automatically. All Polaris components adapt to the embedded iframe width, which varies based on the merchant’s browser window size. The Layout component switches from two-column to single-column at narrow widths.

Accessibility is built into Polaris at the component level, meeting WCAG 2.0 AA standards. AdPriority adds the following custom accessibility considerations:

ConsiderationImplementation
Priority colorsNever rely on color alone; badge text always includes the numeric score
Data tablesUse proper th scope and aria-sort attributes via Polaris DataTable
ModalsFocus trap and keyboard navigation handled by Polaris Modal
Loading statesaria-busy on containers during data fetch, Spinner with label text
Error messagesBanner with tone="critical" and descriptive text, linked to the failed field

State Management

AdPriority uses React Query (TanStack Query) for server state and React context for local UI state. There is no global client-side store (no Redux, no Zustand). The rationale is that AdPriority’s state is almost entirely server-derived: product data, rules, seasons, and sync status all originate from the backend API.

STATE MANAGEMENT PATTERN
========================

  React Query (Server State)         React Context (UI State)
  +---------------------------+      +-------------------------+
  | useProducts()             |      | FilterContext            |
  |   - product list          |      |   - selected priority   |
  |   - pagination cursor     |      |   - selected type       |
  |   - refetch on mutation   |      |   - search query        |
  |                           |      |                         |
  | useRules()                |      | SelectionContext         |
  |   - rule list             |      |   - selected row IDs    |
  |   - create/update/delete  |      |   - bulk action state   |
  |                           |      |                         |
  | useSeasons()              |      | ToastContext             |
  |   - season config         |      |   - success/error msgs  |
  |   - matrix data           |      |                         |
  |                           |      |                         |
  | useSyncStatus()           |      |                         |
  |   - polling every 30s     |      |                         |
  +---------------------------+      +-------------------------+

React Query’s stale-while-revalidate pattern ensures the UI always shows data immediately from cache while fetching fresh data in the background. Mutations (priority changes, rule edits) optimistically update the cache and roll back on server error.

Chapter 19: Dashboard & Analytics

Dashboard Overview

The dashboard is the first screen merchants see after opening AdPriority. Its purpose is to answer three questions in under five seconds: How are my products distributed across priority levels? Is everything syncing correctly? Is there anything that needs my attention?

The dashboard is intentionally not a deep analytics tool. For the Starter and Growth tiers, it provides operational awareness. The Pro tier adds performance analytics powered by Google Ads data.


Priority Distribution Chart

The centerpiece of the dashboard is a pie chart (or doughnut chart) showing how many products fall into each priority level. This gives the merchant an instant visual sense of their catalog’s advertising posture.

PRIORITY DISTRIBUTION (Nexus Clothing - Winter)
================================================

                    +-------+
                 /     5     \        5 - Push Hard:     312  (13%)
               /    (312)      \      4 - Strong:        487  (20%)
             |   +-------+      |     3 - Normal:        824  (34%)
             |   |       |      |     2 - Low:           401  (17%)
             | 4 |  PIE  | 3   |     1 - Minimal:       198   (8%)
             |   | CHART |      |     0 - Exclude:       203   (8%)
             |(487)      |(824) |     --------------------------
               \   +-------+  /       Total Active:    2,425
                 \  2 (401) /
                   +--1--+
                   (198)
                  0 (203)

  Color Key:
  [green] 5  [teal] 4  [yellow] 3  [orange] 2  [red] 1  [grey] 0

Chart Implementation

The chart uses Chart.js with the following configuration:

const distributionData = {
  labels: [
    "5 - Push Hard",
    "4 - Strong",
    "3 - Normal",
    "2 - Low",
    "1 - Minimal",
    "0 - Exclude",
  ],
  datasets: [{
    data: [312, 487, 824, 401, 198, 203],
    backgroundColor: [
      "#008060",  // Polaris success (green)
      "#2B6CB0",  // Polaris info (teal)
      "#B98900",  // Polaris attention (yellow)
      "#C05717",  // Polaris warning (orange)
      "#D72C0D",  // Polaris critical (red)
      "#8C9196",  // Polaris subdued (grey)
    ],
  }],
};

The chart renders inside a Polaris Card with the heading “Priority Distribution”. Below the chart, a summary table lists each level with the count and percentage. Clicking a segment navigates to the Products screen pre-filtered to that priority level.

Distribution Alerts

The dashboard monitors the distribution for anomalies and surfaces them as Polaris Banner components:

ConditionBanner ToneMessage
More than 30% of active products at priority 0warning“312 active products are excluded from ads. Review your rules to ensure this is intentional.”
Zero products at priority 5info“No products are set to maximum priority. Consider boosting seasonal bestsellers.”
All products at the same prioritywarning“All products share the same priority. Differentiation is needed for PMAX to allocate budget effectively.”
Distribution unchanged for 30+ daysinfo“Your priority distribution has not changed in over 30 days. Review seasonal settings.”

Sync Status

The sync status card shows the health of the three integration points: Shopify product data, Google Sheets feed, and Google Merchant Center pickup.

SYNC STATUS CARD
================

  +-----------------------------------------------+
  | Sync Status                                    |
  |                                                |
  | Shopify Products                               |
  |   Last sync: 2 minutes ago           [green]   |
  |   Products imported: 2,425                     |
  |   Status: Up to date                           |
  |                                                |
  | Google Sheet                                   |
  |   Last write: 1 hour ago             [green]   |
  |   Rows written: 15,284 variants                |
  |   Status: Synced                               |
  |                                                |
  | Google Merchant Center                         |
  |   Feed schedule: Daily at 2:00 AM    [green]   |
  |   Last fetch: Today at 2:03 AM                 |
  |   Matched: 15,284 / 15,284 (100%)             |
  |   Status: All labels applied                   |
  |                                                |
  | Next scheduled sync: Today at 3:00 PM          |
  |                                        [Sync Now]|
  +-----------------------------------------------+

Status Indicators

Each integration point displays a colored indicator:

StatusIndicatorCondition
HealthyGreen circleLast sync completed within expected interval, zero errors
StaleYellow circleLast sync exceeds expected interval by 2x or more
ErrorRed circleLast sync failed, or error rate exceeds 5%
PendingGrey circleSync in progress or never synced

Sync Timeline

Below the status indicators, a collapsible timeline shows the last 10 sync events in reverse chronological order:

SYNC TIMELINE (last 10 events)
===============================

  [green]  Today 10:30 AM  - Shopify sync completed (2,425 products, 0 errors)
  [green]  Today 10:28 AM  - Sheet updated (15,284 rows written)
  [green]  Today 02:03 AM  - GMC fetched feed (15,284 matched)
  [green]  Yesterday 4:00 PM - Shopify sync completed (2,425 products, 0 errors)
  [green]  Yesterday 3:58 PM - Sheet updated (15,284 rows written)
  [yellow] Yesterday 2:00 AM - GMC fetched feed (15,281 matched, 3 unmatched)
  [green]  Feb 8, 10:00 AM - Shopify sync completed (2,423 products, 0 errors)
  ...

Each event is stored in the sync_logs table and retrieved via the /api/sync/logs endpoint with pagination.


Quick Stats

The quick stats card provides key numbers at a glance. Each number is a Polaris Text component with variant="headingXl" and a descriptive sublabel.

QUICK STATS CARD
================

  +-------------------+-------------------+
  |    2,425          |    15,284         |
  |  Active Products  |  Variants in GMC  |
  +-------------------+-------------------+
  |       47          |       12          |
  |  Needs Attention  |  New Arrivals     |
  +-------------------+-------------------+
  |       20          |       4           |
  |  Rules Active     |  Seasons Defined  |
  +-------------------+-------------------+

Metric Definitions

MetricDefinitionData Source
Active ProductsProducts with status = 'active' in Shopifyproducts table count where shopify_status = 'active'
Variants in GMCTotal variant rows written to the supplemental feedproducts table count where sync_status = 'synced'
Needs AttentionProducts with priority 0 that have inventory > 0 and are not tagged archived or DEAD50Query: priority = 0 AND inventory_quantity > 0 AND 'archived' NOT IN tags AND 'DEAD50' NOT IN tags
New ArrivalsProducts created within the configured new arrival window (default 14 days)Query: created_at > NOW() - INTERVAL '14 days'
Rules ActiveNumber of enabled rulesrules table count where is_active = true
Seasons DefinedNumber of configured seasonsseasons table count

Needs Attention Detail

The “Needs Attention” count is the most actionable metric on the dashboard. Clicking it navigates to the Products screen filtered to show these products. These are products that:

  1. Are active in Shopify (not archived, not draft)
  2. Have inventory available (could be sold)
  3. Currently have priority 0 (excluded from all advertising)
  4. Are NOT intentionally excluded (no archived or DEAD50 tag)

This typically catches products that fell to priority 0 due to an overly aggressive rule or a missing category mapping. The merchant can review them and either adjust rules or manually override.

Seasonal Recommendations

When a season transition is approaching (within 14 days), a Banner appears on the dashboard:

+------------------------------------------------------------------+
| [info] Season transition approaching                              |
|                                                                    |
| Spring begins on March 1 (19 days away). When the transition      |
| occurs, 847 products will change priority:                        |
|   - 312 products will increase (Shorts 0->3, T-Shirts 2->4)      |
|   - 535 products will decrease (Outerwear 5->1, Beanies 5->1)    |
|                                                                    |
| [Preview Changes]  [Dismiss]                                       |
+------------------------------------------------------------------+

The “Preview Changes” button navigates to a temporary comparison view showing the before and after priority for every affected product, grouped by category.


Activity Feed

The activity feed is a chronological log of significant events, displayed as a Polaris ResourceList inside a Card.

ACTIVITY FEED
=============

  +-----------------------------------------------+
  | Recent Activity                    [View All]  |
  |                                                |
  | Today                                          |
  |                                                |
  | 10:32 AM  Priority changed                     |
  |           47 products updated by seasonal       |
  |           rules (Winter applied)               |
  |                                                |
  | 10:30 AM  Sync completed                       |
  |           2,425 products synced to Google       |
  |           Sheet (0 errors)                     |
  |                                                |
  | 09:15 AM  Manual override                      |
  |           "Jordan Craig Stacked Jeans" set to   |
  |           priority 5 by merchant               |
  |           Reason: "Holiday promo"              |
  |                                                |
  | Yesterday                                      |
  |                                                |
  | 04:00 PM  Rule created                         |
  |           "Outerwear - Heavy" rule added        |
  |           (matches 35 products)                |
  |                                                |
  | 02:03 AM  GMC feed processed                   |
  |           15,284 variants matched (100%)        |
  |                                                |
  +-----------------------------------------------+

Event Types

Event TypeIconDescription
sync_completedCheckmarkShopify or Sheet sync finished
sync_failedAlertSync encountered errors
priority_changedArrowProducts changed priority (bulk or individual)
rule_createdPlusNew rule added
rule_updatedEditRule modified
season_transitionCalendarActive season changed
manual_overrideLockMerchant manually set a priority
gmc_feed_processedCloudGMC fetched the supplemental feed

Events are sourced from two tables: audit_logs for priority and rule changes, and sync_logs for sync events. The feed shows the 20 most recent events with a “View All” link to a full activity log page.


Pro Tier Analytics

Merchants on the Pro tier ($199/mo) unlock a performance analytics section powered by Google Ads data. This section appears below the standard dashboard cards and is hidden for Starter and Growth tier subscribers.

ROAS by Priority Tier

The primary Pro analytics view shows Return on Ad Spend broken down by priority level. This directly answers the question: “Is higher priority actually producing better returns?”

ROAS BY PRIORITY TIER (Last 30 Days)
=====================================

  Priority   Spend       Revenue     ROAS    Conversions
  --------   --------    ---------   ------  -----------
  5 - Push   $3,240      $16,200     5.0x       324
  4 - Strong $2,810      $11,240     4.0x       281
  3 - Normal $1,950       $5,850     3.0x       195
  2 - Low      $480       $1,200     2.5x        48
  1 - Minimal  $120         $240     2.0x        12
  0 - Exclude    $0           $0      --          0
  --------   --------    ---------   ------  -----------
  TOTAL      $8,600      $34,730     4.0x       860

  [BAR CHART: ROAS by priority level]

    5  |====================|  5.0x
    4  |================|      4.0x
    3  |============|          3.0x
    2  |==========|            2.5x
    1  |========|              2.0x
    0  |                       --
       +------+------+------+------+
       0x     2x     4x     6x

A time-series chart shows aggregate performance trends over the selected period (7 days, 30 days, 90 days):

PERFORMANCE TREND (30 Days)
============================

  ROAS
  6x |                    *
  5x |        *   *  * *   * *
  4x |  * * *   *       *       *
  3x | *                          *
  2x |
  1x |
     +--+--+--+--+--+--+--+--+--+-->
     W1    W2    W3    W4    Today

  --- Spend    --- Revenue    --- ROAS

Budget Recommendations

Based on the performance data, AdPriority generates actionable recommendations:

BUDGET RECOMMENDATIONS
======================

  +-----------------------------------------------+
  | [info] Recommendation: Increase Priority 5     |
  |        budget                                  |
  |                                                |
  | Priority 5 products have 5.0x ROAS, the        |
  | highest in your catalog. Consider increasing   |
  | the PMAX budget for this tier by 20%.          |
  |                                                |
  | Estimated impact: +$650 revenue/week           |
  |                                                |
  | [Apply Suggestion]  [Dismiss]                  |
  +-----------------------------------------------+
  |                                                |
  | [warning] Low performers detected              |
  |                                                |
  | 23 products in Priority 3 have ROAS below 1.5x.|
  | Consider moving them to Priority 2 to reduce   |
  | wasted spend.                                  |
  |                                                |
  | [View Products]  [Dismiss]                     |
  +-----------------------------------------------+

Recommendations are generated by the backend analytics service, which runs nightly after pulling the latest Google Ads performance data. Each recommendation includes:

FieldDescription
Typeincrease_budget, decrease_budget, reprioritize, exclude
ConfidenceHigh, Medium, Low (based on data volume and statistical significance)
Estimated impactProjected revenue change based on historical trends
Affected productsCount and list of products the recommendation applies to
ActionOne-click apply button that adjusts priorities accordingly

Data Sources for Pro Analytics

Data PointSourceFrequency
Spend by product groupGoogle Ads API SearchStreamDaily
Revenue by product groupGoogle Ads API conversion trackingDaily
Impressions and clicksGoogle Ads API campaign metricsDaily
ROAS calculationrevenue / spend computed in backendOn demand
Product-level attributionGoogle Ads shopping_performance_viewDaily

The Google Ads API requires separate OAuth consent. Merchants connect their Google Ads account in Settings, granting read-only access to campaign performance data. AdPriority never modifies Google Ads campaigns directly; it only reads performance data and adjusts custom labels in GMC.


Dashboard Data Loading

The dashboard fetches data from four API endpoints in parallel on mount:

DASHBOARD DATA LOADING
======================

  Page Mount
       |
       +---> GET /api/dashboard/distribution  --> Priority pie chart
       |
       +---> GET /api/dashboard/sync-status   --> Sync health indicators
       |
       +---> GET /api/dashboard/stats         --> Quick stats numbers
       |
       +---> GET /api/dashboard/activity      --> Recent activity feed
       |
       +---> GET /api/dashboard/analytics     --> Pro tier only (if authorized)
       |
       v
  All responses cached by React Query (stale time: 30 seconds)
  Auto-refetch on window focus

Each endpoint is lightweight, returning pre-aggregated data from materialized counts rather than computing them on every request. The sync-status endpoint polls every 30 seconds to keep the sync indicators current.

Loading and Error States

StateBehavior
Initial loadPolaris SkeletonPage with SkeletonBodyText placeholders
Partial failureShow available data, display Banner with tone="critical" for failed sections
Full failureEmptyState with error message and “Retry” button
No productsEmptyState with illustration and “Import Products” call to action
Stale dataShow cached data with subtle “Refreshing…” indicator, no blocking spinner

Chapter 20: Phase 0 - Nexus MVP

Goal

Validate the AdPriority concept using Nexus Clothing as a real-world test case. Phase 0 is a manual implementation using Google Sheets as the supplemental feed transport, with no application code. The purpose is to prove that priority-based custom labels in Google Merchant Center produce measurable ROAS improvement in Performance Max campaigns before investing in SaaS development.

Status: IN PROGRESS - Supplemental feed validated (10/10 matched)


Steps Completed

Step 1: Analyze Nexus Catalog

Pulled live data from the Shopify Admin API for nexus-clothes.myshopify.com.

MetricValue
Total products5,582
Active products2,425
Archived products3,121
Draft products36
Unique product types90
Unique vendors175
Unique tags2,522
Estimated active variants~15,000-20,000

The catalog follows a hierarchical product type naming convention: {Gender}-{Department}-{SubCategory}-{Detail}. Examples include Men-Tops-T-Shirts, Men-Bottoms-Pants-Jeans, and Headwear-Baseball-Fitted. The 90 product types were consolidated into 20 category groups for manageable priority assignment (see Appendix C).

Step 2: Verify GMC ID Format

Exported the full Google Merchant Center product catalog (124,060 variants) and confirmed the product ID format.

CONFIRMED FORMAT
================

  shopify_US_{productId}_{variantId}

  Examples:
    shopify_US_8779355160808_46050142748904
    shopify_US_9128994570472_47260097118440
    shopify_US_9057367064808_47004004712680

All products in GMC use variant-level IDs. The Item Group ID field contains just the Shopify product ID (e.g., 8779355160808). Country code is US for all Nexus products.

Step 3: Confirm Custom Labels Available

Audited all five custom label slots in the GMC product export.

LabelCurrent StateProducts UsingDecision
custom_label_0“Argonaut Nations - Converting”7 (0.006%)Safe to overwrite
custom_label_1EMPTY0Available
custom_label_2EMPTY0Available
custom_label_3EMPTY0Available
custom_label_4EMPTY0Available

All five labels are effectively available. The 7 products with existing custom_label_0 values represent a negligible fraction of the catalog and are safe to overwrite.

Step 4: Create Sample Feed

Built a Google Sheet with 10 test products spanning multiple categories, priority levels, and seasons.

Feed Structure:

Columns:
  id                 - GMC product ID (shopify_US_{pid}_{vid})
  custom_label_0     - Priority score (priority-0 through priority-5)
  custom_label_1     - Season (winter)
  custom_label_2     - Category group (e.g., headwear-caps, outerwear-heavy)
  custom_label_3     - Product status (in-stock, low-inventory, dead-stock)
  custom_label_4     - Brand tier (name-brand, off-brand)

Sample Products Tested:

ProductTypePriorityStatusBrand
New Era Colts Knit 2015Headwear-Knit Beanies4low-inventoryname-brand
New Era Yankees 59FIFTYHeadwear-Baseball-Fitted4in-stockname-brand
G3 Patriots HoodieMen-Tops-Hoodies & Sweatshirts0dead-stockoff-brand
Primitive Velour PantsMen-Bottoms-Joggers0dead-stockoff-brand
Mitchell & Ness Warriors HoodMen-Tops-T-Shirts3low-inventoryoff-brand
Levi’s Torn Up 501 JeansMen-Bottoms-Pants-Jeans0dead-stockoff-brand
Gray Earth Denim ShortsMen-Bottoms-Shorts-Denim1low-inventoryoff-brand
Cookies SF V3 GlowtrayAccessories3low-inventoryname-brand
Rebel Minds Puffer JacketMen-Tops-Outerwear-Jackets-Puffer Jackets5low-inventoryoff-brand
Ethika Men Go Pac GoMen-Underwear0dead-stockname-brand

Step 5: Upload to GMC

Connected the Google Sheet as a supplemental feed in Google Merchant Center.

Process:

  1. Shared the Google Sheet publicly (Viewer access)
  2. In GMC: Products > Feeds > Add supplemental feed > Google Sheets
  3. Linked the feed to all three primary data sources:
    • Content API - US, English
    • Content API - Local, US
    • Local Feed Partnership
  4. Triggered a manual update

Results:

MetricResult
Products in feed10
Products matched10 (100%)
Attribute names recognizedAll 6 columns
Issues foundNone
Processing time< 1 hour
Feed statusAccepted, no errors
VALIDATION RESULT
=================

  Feed Upload        -->  10 rows submitted
  ID Matching        -->  10/10 matched (100%)
  Label Application  -->  All 5 custom labels applied
  Errors             -->  0
  Warnings           -->  0
  Status             -->  VALIDATED

Remaining Steps

Step 6: Expand to Full Active Catalog

Scale the supplemental feed from 10 test products to the full active catalog.

Scope:

MetricEstimate
Active products~2,425
Active variants (rows needed)~15,000-20,000
Google Sheets cells used~120,000 (1.2% of 10M limit)

Process:

  1. Export all active products from Shopify API (paginated, 250/page, ~10 pages)
  2. For each product, enumerate all variants
  3. Apply category mapping rules (20 groups from category-mapping.md)
  4. Apply seasonal priorities (Winter season as of February 2026)
  5. Apply tag modifiers (NAME BRAND +1, in-stock +1, Sale -1, DEAD50 override to 0, archived override to 0, warning_inv_1 -1)
  6. Apply new arrival boost (products created within 14 days get priority 5)
  7. Generate GMC ID for each variant: shopify_US_{productId}_{variantId}
  8. Write all rows to Google Sheet via Sheets API or manual paste
  9. Verify GMC processes the full feed without errors

Expected Distribution (Winter, estimated):

PROJECTED PRIORITY DISTRIBUTION
================================

  Priority 5 (Push Hard):     ~300  (12%)  Puffer jackets, hoodies, new arrivals
  Priority 4 (Strong):        ~500  (21%)  Jeans, name brand items, joggers
  Priority 3 (Normal):        ~800  (33%)  Caps, basic t-shirts, year-round items
  Priority 2 (Low):           ~400  (16%)  Underwear, socks, off-season light items
  Priority 1 (Minimal):       ~200   (8%)  End-of-season, slow movers
  Priority 0 (Exclude):       ~225  (10%)  Dead stock, archived, out of stock
                              -----
  Total Active:              2,425

Step 7: Set Up PMAX Campaigns by Priority Tier

Restructure existing Google Ads Performance Max campaigns to segment products by the custom_label_0 priority score.

Proposed Campaign Structure:

PMAX CAMPAIGN STRUCTURE
=======================

  Campaign 1: "PMAX - High Priority (5)"
    Listing group filter: custom_label_0 = "priority-5"
    Daily budget: $50
    Target ROAS: 3.0x
    Products: ~300

  Campaign 2: "PMAX - Strong (4)"
    Listing group filter: custom_label_0 = "priority-4"
    Daily budget: $35
    Target ROAS: 4.0x
    Products: ~500

  Campaign 3: "PMAX - Normal (3)"
    Listing group filter: custom_label_0 = "priority-3"
    Daily budget: $20
    Target ROAS: 5.0x
    Products: ~800

  Campaign 4: "PMAX - Low (1-2)"
    Listing group filter: custom_label_0 IN ("priority-1", "priority-2")
    Daily budget: $10
    Target ROAS: 6.0x
    Products: ~600

  Priority 0 products: Excluded (not in any campaign)

Budget Allocation (daily):

CampaignPriorityBudget% of Total
High Priority5$5043%
Strong4$3530%
Normal3$2017%
Low1-2$109%
Exclude0$00%
Total$115100%

Step 8: Monitor ROAS for 30 Days

Track campaign performance daily for 30 days to establish whether the priority-segmented structure outperforms the previous flat structure.

Baseline Metrics (to capture before restructuring):

MetricCapture BeforeTrack After
Overall ROASCurrent PMAX ROASDaily ROAS by campaign
Cost per conversionCurrent CPACPA by priority tier
RevenueMonthly revenue from adsRevenue by priority tier
ImpressionsTotal impressionsImpressions by priority tier
Click-through rateOverall CTRCTR by priority tier
Wasted spendSpend on priority-0 productsShould be $0 after restructure

Success Criteria:

CriterionTarget
Overall ROAS improvement>= 20% increase vs. baseline
Priority 5 ROAS>= 4.0x
Wasted spend eliminated$0 on priority-0 products
Feed sync reliabilityZero missed daily syncs
No negative impactNo decrease in total revenue

Tracking Spreadsheet:

DATE        | CAMPAIGN     | SPEND  | REVENUE | ROAS  | CONV | CPA
------------|------------- |--------|---------|-------|------|------
2026-02-15  | High (5)     | $48.20 | $241.00 | 5.0x  |  24  | $2.01
2026-02-15  | Strong (4)   | $33.10 | $132.40 | 4.0x  |  17  | $1.95
2026-02-15  | Normal (3)   | $19.50 |  $58.50 | 3.0x  |  10  | $1.95
2026-02-15  | Low (1-2)    |  $9.80 |  $24.50 | 2.5x  |   5  | $1.96
...

Step 9: Document Results

Produce a comprehensive results document covering:

  1. Methodology: How products were scored, how campaigns were structured
  2. Baseline vs. Result: Before/after comparison with statistical significance
  3. Category Analysis: Which category groups benefited most from prioritization
  4. Seasonal Validation: Whether Winter priorities matched actual demand
  5. Lessons Learned: What to adjust in the SaaS version
  6. Process Documentation: Step-by-step replication guide for Phase 1

Tools and Resources

ToolPurposeStatus
Google SheetsSupplemental feed transportActive
Google Merchant CenterCustom label applicationConnected
Shopify Admin APIProduct data exportTested
Google AdsCampaign managementExisting campaigns
Sheets APIProgrammatic Sheet updates (for full catalog)Available

Success Metric

The single success metric for Phase 0 is:

Measurable ROAS improvement versus the flat (unsegmented) campaign structure, sustained over a 30-day monitoring period.

If ROAS improves by 20% or more with the priority-segmented approach, the concept is validated and Phase 1 (SaaS development) proceeds. If ROAS does not improve, the category mappings and seasonal priorities are revised before retesting.


Timeline

PHASE 0 TIMELINE
================

  Week 1 (Feb 3-9):
    [DONE] Analyze catalog (5,582 products, 90 types)
    [DONE] Verify GMC ID format
    [DONE] Confirm custom labels available
    [DONE] Create sample feed (10 products)
    [DONE] Upload and validate sample feed (10/10 matched)

  Week 2 (Feb 10-16):
    [TODO] Expand feed to full active catalog (~2,425 products)
    [TODO] Capture baseline PMAX metrics
    [TODO] Restructure PMAX campaigns by priority tier

  Week 3-6 (Feb 17 - Mar 16):
    [TODO] Monitor ROAS daily for 30 days
    [TODO] Weekly progress checks
    [TODO] Adjust priorities if data warrants

  Week 7 (Mar 17-23):
    [TODO] Compile results document
    [TODO] Go/no-go decision for Phase 1

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

Chapter 22: Phase 2 - Full Product

Goal

Complete the feature set required for the Growth tier ($79/mo). Phase 2 transforms the basic Phase 1 app into a product that merchants can configure, customize, and rely on without manual intervention. By the end of Phase 2, AdPriority supports seasonal automation, a configurable rules engine, bulk operations, a dashboard with analytics, and reliable error handling.

Timeline: 3-4 weeks


Feature Set

Seasonal Automation

The seasonal automation system is the primary differentiator between the Starter and Growth tiers. It automatically adjusts product priorities when the calendar crosses a season boundary.

Components:

ComponentDescriptionImplementation
Season definitionsStart/end dates for each seasonseasons table, configurable per store
Category-season matrixPriority for each category group in each seasonseason_rules table
Transition schedulerCron job that detects boundary crossingsBull queue, runs hourly
Transition previewShows what will change before it happensAPI endpoint computing diff
Transition executionRecalculates all affected productsBatch update with audit trail
Transition notificationEmail alert when a transition occursNotification service

Transition process:

SEASONAL TRANSITION
===================

  Hourly cron job checks:
    Is today's date in a different season than yesterday?
         |
    NO --+--> No action
         |
    YES -+--> Begin transition
              |
              v
         1. Log transition event
              |
              v
         2. Fetch all products for this store
              |
              v
         3. For each product (not manually locked):
            a. Look up new seasonal priority
            b. Compare with current priority
            c. If different: update, log change
              |
              v
         4. Queue Google Sheet sync
              |
              v
         5. Send notification email
              |
              v
         6. Log completion to sync_logs

Transition safeguards:

  • Manually locked products are never changed
  • A dry-run mode previews changes without applying them
  • Transitions log every individual product change to audit_logs
  • A rollback option reverts the transition within 24 hours

Configurable Rules Engine

Phase 1 delivers hardcoded category rules based on the Nexus Clothing mapping. Phase 2 replaces these with a merchant-configurable rules engine accessible through the UI.

Rule types:

TypeDescriptionExample
Category ruleMatch product type to base priority*Puffer Jackets* -> Priority 5
Tag modifierAdjust priority based on product tagsNAME BRAND -> +1
Vendor ruleSet priority by vendorNew Era -> Priority 4
Collection rulePriority for products in a collection“Holiday Sale” collection -> Priority 5
Time-limited ruleRule active only during a date rangeFeb 1-14: Valentine’s items Priority 5

Rule builder UI:

The rule builder uses Polaris form components within a modal or dedicated page:

CREATE RULE
===========

  Name:        [Holiday Valentine Boost_______]
  Description: [Boost Valentine's products____]

  Conditions (ALL must match):
  +------------------------------------------+
  | Field         | Operator   | Value        |
  |---------------|------------|--------------|
  | tag           | contains   | valentine25  |
  | product_type  | starts_with| Men-Tops     |
  +------------------------------------------+
  [+ Add Condition]

  Priority:    [5 - Push Hard v]

  Schedule:
  ( ) Always active
  (*) Date range: [Feb 1] to [Feb 14]

  [Cancel]  [Preview (47 match)]  [Save]

Rule evaluation:

  • Rules are evaluated in the order specified by the merchant (drag-to-reorder)
  • The first matching rule wins (no cascading)
  • Tag modifiers are applied after the winning rule
  • Manual overrides always take precedence

Bulk Operations

Bulk operations allow merchants to act on many products at once from the Products list screen.

OperationDescriptionImplementation
Set priorityApply a specific priority to selected productsBatch update with reason
RecalculateRe-run scoring engine for selected productsQueue job per batch
Lock/UnlockSet or remove manual override for selected productsToggle priority_locked
ExportDownload selected products as CSVServer-side generation

Bulk operation flow:

BULK OPERATION
==============

  1. Merchant selects products (checkboxes in IndexTable)
         |
         v
  2. Clicks bulk action button (e.g., "Set Priority")
         |
         v
  3. Modal opens: select priority, enter reason
         |
         v
  4. Confirmation: "Update 47 products to Priority 5?"
         |
         v
  5. POST /api/products/bulk-update
     { productIds: [...], priority: 5, reason: "Holiday promo" }
         |
         v
  6. Backend processes in transaction:
     a. Update products table
     b. Write audit_logs entries
     c. Set needs_sync = true
         |
         v
  7. Queue Sheet sync job
         |
         v
  8. Return result: { updated: 47, errors: 0 }
         |
         v
  9. UI refreshes product list, shows success toast

For large selections (500+ products), the operation runs as a background job with a progress indicator rather than a blocking API call.

Dashboard Analytics

Phase 2 delivers the full dashboard described in Chapter 17, covering:

  • Priority distribution pie chart
  • Sync status indicators
  • Quick stats (total products, needs attention, new arrivals)
  • Activity feed (recent changes, syncs, errors)
  • Seasonal transition countdown

The Pro tier analytics (ROAS by priority, Google Ads performance) remain deferred to Phase 3.

Category Management UI

A dedicated screen for managing category groups, replacing the hardcoded JSON configuration from Phase 1.

Features:

  • List all category groups with member product types
  • Add, edit, and delete category groups
  • Assign product types to groups (autocomplete from the store’s actual types)
  • Set default priority per group
  • Visual indicator of ungrouped product types (types that exist in the catalog but do not belong to any group)

Sync Monitoring

An operational view showing the health and history of Google Sheet synchronization.

Components:

ComponentDescription
Sync history tableLast 50 syncs with status, timestamp, product counts
Error detail viewExpandable rows showing specific errors per sync
Manual sync buttonTrigger an immediate full sync
Sync settingsFrequency, auto-sync toggles

Error Handling

Phase 2 hardens error handling across all integration points.

Error CategoryHandling Strategy
Shopify API rate limitExponential backoff, retry up to 3 times, queue for later
Shopify webhook delivery failureShopify retries automatically (19 attempts over 48 hours)
Google Sheets API quotaQueue writes, batch updates, alert merchant if daily quota exceeded
Google Sheets write failureRetry 3 times, log error, surface in sync status
Database connection lossAuto-reconnect via Prisma connection pool, surface error in health check
Invalid product dataSkip product, log warning, continue processing batch
Season transition failureRoll back partial changes, alert via email, retry on next hour

Beta Testing

Beta Program

Phase 2 concludes with a beta program: 5 merchants test the app in a real-world setting.

Beta merchant criteria:

CriterionMinimum
Shopify storeActive, with Google Ads running
Product count100-10,000 products
Google Ads spend$500+/month
Product typesAt least 5 different types
Seasonal variationCatalog with seasonal demand patterns

Beta recruitment:

  • Approach through Shopify merchant communities
  • Offer free Growth tier access for 60 days
  • Require weekly feedback survey completion
  • Collect quantitative data (ROAS before/after)

Beta Feedback Areas

AreaQuestions
OnboardingHow long did setup take? What was confusing?
Priority accuracyDo the automated scores make sense for your catalog?
Rules engineWere you able to configure rules for your categories?
Seasonal calendarDo the season boundaries match your business?
Sync reliabilityDid all products sync correctly to GMC?
Missing featuresWhat do you need that is not available?
PricingWould you pay $79/mo for this? What price feels right?

Iteration Based on Feedback

Allow 1 week between beta start and App Store submission for iteration. Common adjustments based on beta feedback:

Feedback PatternLikely Response
“Setup was confusing”Add onboarding wizard with step-by-step guide
“Wrong priorities for my catalog”Improve default rules, add more templates
“My categories don’t match”Allow custom grouping, not just product type matching
“Sync failed for some products”Improve error messaging, add retry UI
“I need X feature”Evaluate for Phase 3 or Growth tier scope

Timeline

PHASE 2 TIMELINE
================

  Week 1:
    - Seasonal automation engine (backend)
    - Season transition scheduler
    - Season calendar UI

  Week 2:
    - Rules engine (backend CRUD + evaluation)
    - Rule builder UI
    - Category management UI

  Week 3:
    - Bulk operations (backend + UI)
    - Dashboard (distribution chart, sync status, stats, activity)
    - Sync monitoring UI
    - Error handling hardening

  Week 4:
    - Beta merchant onboarding (5 stores)
    - Bug fixes based on beta feedback
    - Performance optimization
    - Documentation for beta merchants

Deliverables

DeliverableAcceptance Criteria
Seasonal automationTransitions execute automatically, preview accurate, rollback works
Rules engineMerchants create/edit/delete rules, rules evaluate correctly
Bulk operationsSelect and update 500+ products without timeout
DashboardDistribution chart, sync status, stats, activity feed all populated
Category managementAll store product types assignable to groups
Sync monitoringHistory viewable, errors surfaced, manual sync works
Error handlingNo unhandled exceptions, all errors logged with context
Beta program5 merchants installed, using app for 1+ week, feedback collected

Chapter 23: Phase 3 - App Store Launch

Goal

Submit AdPriority to the Shopify App Store, pass the review process, and launch publicly. Phase 3 focuses on meeting all Shopify requirements, preparing marketing materials, and establishing the billing infrastructure.


Shopify App Store Submission Requirements

Two legal documents must be hosted at publicly accessible URLs before submission.

DocumentURLContent
Privacy Policyhttps://adpriority.com/privacyData collected (product data only, no customer PII), data usage (priority scoring and GMC sync), data sharing (custom labels to GMC only), data retention (deleted within 30 days of uninstall), user rights (export/deletion via GDPR webhooks)
Terms of Servicehttps://adpriority.com/termsService description, acceptable use, billing terms, limitation of liability, termination clause, data ownership (merchant owns their data)

App Listing

The listing is the storefront that merchants see when discovering AdPriority.

Required assets:

AssetSpecificationNotes
App icon1200x1200px PNGClean design, no text, recognizable at small sizes
App name“AdPriority” (30 characters max)Must be unique in App Store
Tagline“Automate Google Ads product priorities with smart scoring” (70 chars max)Primary search hook
Screenshots3-5 images, 1600x900px eachDashboard, Products list, Rules engine, Seasonal calendar, Settings
Description100-500 wordsSEO-optimized, covers problem/solution/features
Key benefits3-5 bullet pointsValue propositions for scanning
Demo video60-90 seconds (optional but recommended)Walkthrough of install to first sync
Support URLhttps://adpriority.com/supportHelp center or contact form
CategoryMarketing > AdvertisingPrimary category for App Store placement

Listing description (draft):

AdPriority: Smart Product Scoring for Google Ads

Stop wasting ad spend on the wrong products. AdPriority automatically
scores your products from 0-5 based on category, season, inventory,
and brand, then syncs those scores as custom labels to Google Merchant
Center. Use these labels to build focused Performance Max campaigns
that put your budget where it matters.

KEY FEATURES:

- Automatic 0-5 priority scoring based on configurable rules
- Seasonal calendar that adjusts priorities when seasons change
- New arrival boost gives fresh products maximum visibility
- One-click sync to Google Merchant Center via supplemental feed
- Visual dashboard showing priority distribution and sync health

HOW IT WORKS:

1. Install and import your products (automatic)
2. Configure category rules and seasonal priorities
3. AdPriority calculates a priority score for every product
4. Scores sync to Google Merchant Center as custom labels
5. Segment your PMAX campaigns by priority for smarter budgets

Works with any Shopify store running Google Ads. No Google API
setup required for the Starter tier.

Start your 14-day free trial today.

Screenshots

Each screenshot should demonstrate a specific value proposition:

#ScreenCaptionWhat to Show
1Dashboard“See your priority distribution at a glance”Pie chart, sync status, quick stats
2Products“Every product scored automatically”Product list with priority badges, filters
3Product Detail“Understand exactly why each score was assigned”Priority breakdown card showing base + modifiers
4Rules“Configure rules that match your business”Rule list with pattern matching
5Seasons“Priorities adjust automatically with the seasons”Calendar view and category-season matrix

Demo Video

A 60-90 second video covering:

  1. Opening: “Stop wasting ad spend” hook (5 seconds)
  2. Problem statement: Manual custom label management (10 seconds)
  3. Install flow: Click install, products import automatically (15 seconds)
  4. Dashboard: Show priority distribution (10 seconds)
  5. Products: Browse products with priority badges (10 seconds)
  6. Rules: Create a category rule (15 seconds)
  7. Sync: Watch labels push to Google Sheet (10 seconds)
  8. Closing: “Start your free trial” CTA (5 seconds)

Review Process

Timeline

Shopify App Store review typically takes 5-10 business days from submission to decision. If the app is rejected, the team provides specific feedback, and resubmission restarts the review clock.

Review Checklist

Shopify reviews the app against these criteria:

RequirementStatusNotes
HTTPS everywhereRequiredCloudflare Tunnel provides TLS
OAuth implementationRequiredToken exchange flow, no implicit grants
Session token validationRequiredJWT verification with client secret
Webhook HMAC verificationRequiredAll webhook endpoints verify signature
GDPR webhooks implementedRequired3 mandatory endpoints
Polaris UI componentsRequiredAll UI built with Polaris v13
Responsive designRequiredPolaris handles automatically
Accessibility (WCAG 2.0 AA)RequiredPolaris provides built-in a11y
Loading statesRequiredSpinner on every async operation
Empty statesRequiredHelpful messaging when no data exists
Error handlingRequiredUser-friendly error messages
Scope justificationRequiredDocumented reason for each OAuth scope
No test/debug dataRequiredClean submission with no placeholder content
Privacy policyRequiredPublicly hosted URL
Terms of serviceRequiredPublicly hosted URL
Support contactRequiredEmail or help center URL

Common Rejection Reasons

ReasonHow to Avoid
Missing GDPR webhooksImplement all three, even as no-ops for non-PII apps
Requesting unnecessary scopesOnly request read_products, write_products, read_inventory
UI not using PolarisUse only Polaris components, no custom CSS that overrides Polaris
No loading statesAdd Spinner to every data fetch, SkeletonPage on initial load
No empty statesAdd EmptyState with illustration and CTA for every empty list
Broken OAuth flowTest with multiple development stores, handle edge cases
Missing error handlingCatch all API errors, show Banner with tone="critical"
App crashes on installTest fresh install on a store with no products, with 50K products
Billing not workingTest appSubscriptionCreate in test mode before submission
Data not deleted on uninstallapp/uninstalled webhook must cascade delete all store data

Resubmission Strategy

If rejected:

  1. Read the rejection feedback carefully (Shopify is specific)
  2. Fix every cited issue (do not submit with partial fixes)
  3. Test the fixes thoroughly
  4. Add a note in the resubmission explaining what was changed
  5. Resubmit (review clock resets to 5-10 business days)

Marketing

App Store SEO

Shopify’s App Store search uses the app name, tagline, and description for ranking. Target keywords:

KeywordSearch Volume (est.)Competition
google adsHighHigh
custom labelsMediumLow
performance maxMediumMedium
google shoppingHighHigh
product priorityLowVery Low
google merchant centerMediumMedium
pmax optimizationLowLow

SEO strategy: Include primary keywords in the tagline (“Google Ads product priorities”) and secondary keywords in the description. The unique terms “product priority” and “priority scoring” have very low competition because no existing app targets this concept.

Launch Campaign

ChannelActionTimeline
Shopify Community forumsPost in “Apps” section with use case storyLaunch day
Reddit r/shopifyShare experience with priority-based PMAX resultsLaunch week
Reddit r/PPCPost about custom label automation strategyLaunch week
Twitter/XThread: “How we improved ROAS by X% with product scoring”Launch day
Blog post“Why Your PMAX Campaigns Need Product Priorities”Launch week
Email to beta usersAnnounce launch, ask for App Store reviewLaunch day
YouTubePost the demo video as a tutorialLaunch week

Demo Video Distribution

The demo video serves double duty as a marketing asset and an App Store listing video:

  • Upload to YouTube (public, SEO-optimized title and description)
  • Embed on the App Store listing
  • Embed on the marketing website landing page
  • Share in forum posts and social media

Pricing Setup: Shopify Billing API

Subscription Plans

AdPriority uses Shopify’s Billing API for recurring charges. Merchants are charged through their Shopify bill, which eliminates payment friction.

Plan configuration:

PlanPriceTrialProductsFeatures
Starter$29/mo14 days500Manual scoring, GMC sync, basic labels
Growth$79/mo14 daysUnlimitedSeasonal auto, rules engine, tag modifiers, new arrival boost
Pro$199/mo14 daysUnlimitedGoogle Ads API, AI suggestions, ROAS tracking, performance dashboard

Billing Flow

BILLING FLOW
============

  1. Merchant installs app (free during trial)
         |
         v
  2. 14-day trial starts automatically
         |
         v
  3. Trial expiration approaching (day 12):
     Show Banner: "Your trial ends in 2 days. Choose a plan."
         |
         v
  4. Merchant clicks "Choose Plan" on Billing page
         |
         v
  5. App creates subscription via GraphQL:
     appSubscriptionCreate(
       name: "AdPriority Growth"
       price: $79.00
       interval: EVERY_30_DAYS
       trialDays: 0  (already in trial)
       returnUrl: /billing/callback
     )
         |
         v
  6. Shopify returns confirmationUrl
         |
         v
  7. Redirect merchant to Shopify payment confirmation
         |
         v
  8. Merchant approves charge
         |
         v
  9. Shopify redirects to returnUrl
         |
         v
  10. App verifies subscription status via GraphQL
          |
          v
  11. Update store.plan_tier in database
          |
          v
  12. Enable tier features

Plan Enforcement

FeatureStarterGrowthPro
Product importCapped at 500UnlimitedUnlimited
Seasonal automationBlocked (show upgrade prompt)EnabledEnabled
Rules engineBasic (3 rules max)FullFull
Tag modifiersBlockedEnabledEnabled
Google Ads connectionBlockedBlockedEnabled
ROAS trackingBlockedBlockedEnabled

When a merchant on the Starter tier tries to access a Growth feature, the UI shows a Polaris Banner with an upgrade call-to-action rather than hiding the feature entirely. This educates merchants about available features and drives upgrades.

Billing Webhooks

WebhookAction
app_subscriptions/updateSync plan status changes (activated, cancelled, frozen)
app/uninstalledCancel subscription, initiate data retention countdown

Post-Launch Priorities

After App Store approval, the focus shifts to:

  1. Monitor installs: Track daily install rate, conversion from trial to paid
  2. Respond to reviews: Reply to every App Store review within 24 hours
  3. Support queue: Handle incoming support emails within 24-48 hours
  4. Bug fixes: Rapid response to any issues reported by early users
  5. Gather testimonials: Ask satisfied merchants for case studies
  6. Iterate on onboarding: Reduce time-to-value based on user behavior data

Target Metrics (First 90 Days)

MetricTarget
Installs100+
Trial-to-paid conversion20%+
Monthly churn< 5%
App Store rating4.0+ stars
Support response time< 24 hours
NPS score> 40

Chapter 24: Deployment

Docker Deployment on Synology NAS

AdPriority runs on the same Synology NAS (192.168.1.26) that hosts the other Docker-based applications. It joins the shared postgres_default network to access the PostgreSQL 16 container, and uses a Cloudflare Tunnel for HTTPS access required by Shopify.

DEPLOYMENT ARCHITECTURE
=======================

  Internet                                 Synology NAS (192.168.1.26)
  --------                                 ---------------------------

  Shopify Admin                            +---------------------------+
  (embedded app)                           |  Docker Engine            |
       |                                   |                           |
       | HTTPS                             |  +---------------------+  |
       v                                   |  | cloudflared         |  |
  Cloudflare Tunnel ---------------------->|  | (tunnel container)  |  |
       |                                   |  +----------+----------+  |
       |                                   |             |             |
       +--- /api/*, /auth/*, /webhooks/ -->|  +----------v----------+  |
       |                                   |  | adpriority-backend  |  |
       |                                   |  | (Express + TS)      |  |
       |                                   |  | Port: 3010          |  |
       |                                   |  +----------+----------+  |
       |                                   |             |             |
       +--- /* (UI assets) -------------->|  +----------v----------+  |
                                           |  | adpriority-admin   |  |
                                           |  | (React + Vite)     |  |
                                           |  | Port: 3011         |  |
                                           |  +---------------------+  |
                                           |                           |
                                           |  +---------------------+  |
                                           |  | adpriority-worker   |  |
                                           |  | (Bull queue)        |  |
                                           |  +----------+----------+  |
                                           |             |             |
                                           |  +----------v----------+  |
                                           |  | redis               |  |
                                           |  | Port: 6379          |  |
                                           |  +---------------------+  |
                                           |             |             |
                                           |  +----------v----------+  |
                                           |  | postgres16          |  |
                                           |  | (shared)            |  |
                                           |  | DB: adpriority_db   |  |
                                           |  | Port: 5432          |  |
                                           |  +---------------------+  |
                                           +---------------------------+

Docker Compose Structure

Development Configuration

# /volume1/docker/adpriority/docker-compose.yml
version: "3.8"

services:
  backend:
    build:
      context: ./backend
      dockerfile: Dockerfile
    container_name: adpriority-backend
    ports:
      - "3010:3010"
    volumes:
      - ./backend/src:/app/src
      - ./backend/prisma:/app/prisma
    environment:
      - NODE_ENV=development
      - PORT=3010
      - DATABASE_URL=postgresql://adpriority_user:${DB_PASSWORD}@postgres16:5432/adpriority_db
      - REDIS_URL=redis://redis:6379
      - SHOPIFY_CLIENT_ID=${SHOPIFY_CLIENT_ID}
      - SHOPIFY_CLIENT_SECRET=${SHOPIFY_CLIENT_SECRET}
      - SHOPIFY_SCOPES=read_products,write_products,read_inventory
      - HOST=https://${APP_DOMAIN}
      - GOOGLE_SHEETS_CREDENTIALS=${GOOGLE_SHEETS_CREDENTIALS}
      - ENCRYPTION_KEY=${ENCRYPTION_KEY}
    depends_on:
      - redis
    networks:
      - postgres_default
      - adpriority
    restart: unless-stopped

  admin-ui:
    build:
      context: ./admin-ui
      dockerfile: Dockerfile
    container_name: adpriority-admin
    ports:
      - "3011:3011"
    volumes:
      - ./admin-ui/src:/app/src
    environment:
      - VITE_SHOPIFY_CLIENT_ID=${SHOPIFY_CLIENT_ID}
      - VITE_API_URL=https://${APP_DOMAIN}
    networks:
      - adpriority
    restart: unless-stopped

  worker:
    build:
      context: ./backend
      dockerfile: Dockerfile
    container_name: adpriority-worker
    command: ["npm", "run", "worker"]
    environment:
      - NODE_ENV=development
      - DATABASE_URL=postgresql://adpriority_user:${DB_PASSWORD}@postgres16:5432/adpriority_db
      - REDIS_URL=redis://redis:6379
      - GOOGLE_SHEETS_CREDENTIALS=${GOOGLE_SHEETS_CREDENTIALS}
      - ENCRYPTION_KEY=${ENCRYPTION_KEY}
    depends_on:
      - redis
    networks:
      - postgres_default
      - adpriority
    restart: unless-stopped

  redis:
    image: redis:7-alpine
    container_name: adpriority-redis
    ports:
      - "6380:6379"
    volumes:
      - redis-data:/data
    networks:
      - adpriority
    restart: unless-stopped

volumes:
  redis-data:

networks:
  postgres_default:
    external: true
  adpriority:
    driver: bridge

Production Configuration

The production configuration removes volume mounts (no hot-reload), uses versioned images, and adds health checks.

# /volume1/docker/adpriority/docker-compose.prod.yml
version: "3.8"

services:
  backend:
    image: adpriority-backend:${VERSION:-latest}
    container_name: adpriority-backend
    ports:
      - "3010:3010"
    environment:
      - NODE_ENV=production
      - PORT=3010
      - DATABASE_URL=postgresql://adpriority_user:${DB_PASSWORD}@postgres16:5432/adpriority_db
      - REDIS_URL=redis://redis:6379
      - SHOPIFY_CLIENT_ID=${SHOPIFY_CLIENT_ID}
      - SHOPIFY_CLIENT_SECRET=${SHOPIFY_CLIENT_SECRET}
      - SHOPIFY_SCOPES=read_products,write_products,read_inventory
      - HOST=https://${APP_DOMAIN}
      - GOOGLE_SHEETS_CREDENTIALS=${GOOGLE_SHEETS_CREDENTIALS}
      - ENCRYPTION_KEY=${ENCRYPTION_KEY}
    depends_on:
      redis:
        condition: service_healthy
    networks:
      - postgres_default
      - adpriority
    restart: always
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3010/health"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 15s

  admin-ui:
    image: adpriority-admin:${VERSION:-latest}
    container_name: adpriority-admin
    ports:
      - "3011:3011"
    environment:
      - VITE_SHOPIFY_CLIENT_ID=${SHOPIFY_CLIENT_ID}
      - VITE_API_URL=https://${APP_DOMAIN}
    networks:
      - adpriority
    restart: always

  worker:
    image: adpriority-backend:${VERSION:-latest}
    container_name: adpriority-worker
    command: ["npm", "run", "worker"]
    environment:
      - NODE_ENV=production
      - DATABASE_URL=postgresql://adpriority_user:${DB_PASSWORD}@postgres16:5432/adpriority_db
      - REDIS_URL=redis://redis:6379
      - GOOGLE_SHEETS_CREDENTIALS=${GOOGLE_SHEETS_CREDENTIALS}
      - ENCRYPTION_KEY=${ENCRYPTION_KEY}
    depends_on:
      redis:
        condition: service_healthy
    networks:
      - postgres_default
      - adpriority
    restart: always

  redis:
    image: redis:7-alpine
    container_name: adpriority-redis
    volumes:
      - redis-data:/data
    command: ["redis-server", "--appendonly", "yes"]
    networks:
      - adpriority
    restart: always
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 3

volumes:
  redis-data:

networks:
  postgres_default:
    external: true
  adpriority:
    driver: bridge

Container Roles

ContainerImageRoleNetwork
adpriority-backendadpriority-backendExpress API server, handles HTTP requests, OAuth, webhookspostgres_default + adpriority
adpriority-adminadpriority-adminServes React/Polaris frontend (Nginx in production)adpriority
adpriority-workeradpriority-backend (same image, different entrypoint)Processes Bull queue jobs: syncs, seasonal transitions, bulk operationspostgres_default + adpriority
adpriority-redisredis:7-alpineJob queue backend, session cacheadpriority
postgres16Shared (external)PostgreSQL 16, database: adpriority_dbpostgres_default

The worker container uses the same Docker image as the backend but runs a different entrypoint (npm run worker) that starts the Bull queue processor instead of the Express server. This ensures the scoring engine and sync logic are identical between API-triggered and scheduled operations.


Cloudflare Tunnel Configuration

Shopify requires HTTPS callback URLs for OAuth redirects and webhook delivery. The Cloudflare Tunnel provides this without exposing ports on the NAS firewall.

Tunnel routing rules:

HostnameServicePurpose
adpriority.nexusclothing.synology.mehttp://localhost:3010API, auth, webhooks
adpriority.nexusclothing.synology.me (path: /*)http://localhost:3011Admin UI assets

The backend serves both API routes and proxies UI requests in production mode. In development, the Vite dev server runs separately on port 3011.

Tunnel config (added to existing cloudflared configuration):

# Addition to existing tunnel config
ingress:
  - hostname: adpriority.nexusclothing.synology.me
    service: http://localhost:3010
  # ... existing rules ...
  - service: http_status:404

Environment Variables

Required Variables

VariableDescriptionExample
SHOPIFY_CLIENT_IDApp client ID from Partner Dashboardabc123def456
SHOPIFY_CLIENT_SECRETApp client secret (never log this)shpss_xxxxxxxx
SHOPIFY_SCOPESOAuth scopesread_products,write_products,read_inventory
DATABASE_URLPostgreSQL connection stringpostgresql://adpriority_user:pass@postgres16:5432/adpriority_db
DB_PASSWORDDatabase password (referenced in DATABASE_URL)AdPrioritySecure2026
REDIS_URLRedis connection stringredis://redis:6379
HOSTPublic HTTPS URL (for OAuth redirects)https://adpriority.nexusclothing.synology.me
APP_DOMAINDomain without protocoladpriority.nexusclothing.synology.me
ENCRYPTION_KEYAES-256 key for token encryption32-byte hex string
GOOGLE_SHEETS_CREDENTIALSService account JSON (base64 encoded)Base64 string

Optional Variables

VariableDescriptionDefault
PORTBackend server port3010
NODE_ENVRuntime environmentdevelopment
LOG_LEVELLogging verbosityinfo
SYNC_FREQUENCY_MINUTESDefault sync interval360 (6 hours)
NEW_ARRIVAL_DAYSDays to consider a product “new”14
SENTRY_DSNError tracking (optional)Not set

Environment File Template

# /volume1/docker/adpriority/.env.example

# Shopify App
SHOPIFY_CLIENT_ID=
SHOPIFY_CLIENT_SECRET=
SHOPIFY_SCOPES=read_products,write_products,read_inventory

# Database
DB_PASSWORD=AdPrioritySecure2026
DATABASE_URL=postgresql://adpriority_user:${DB_PASSWORD}@postgres16:5432/adpriority_db

# Redis
REDIS_URL=redis://redis:6379

# Application
HOST=https://adpriority.nexusclothing.synology.me
APP_DOMAIN=adpriority.nexusclothing.synology.me
PORT=3010
NODE_ENV=development

# Security
ENCRYPTION_KEY=

# Google
GOOGLE_SHEETS_CREDENTIALS=

# Version (for production images)
VERSION=latest

CI/CD Pipeline

GitHub Actions Workflow

# .github/workflows/deploy.yml
name: Build and Deploy

on:
  push:
    branches: [main]
    tags: ["v*"]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set version
        run: |
          if [[ "${{ github.ref }}" == refs/tags/* ]]; then
            echo "VERSION=${{ github.ref_name }}" >> $GITHUB_ENV
          else
            echo "VERSION=latest" >> $GITHUB_ENV
          fi

      - name: Build backend image
        run: |
          docker build -t adpriority-backend:${{ env.VERSION }} ./backend

      - name: Build admin image
        run: |
          docker build -t adpriority-admin:${{ env.VERSION }} ./admin-ui

      - name: Save images
        run: |
          docker save adpriority-backend:${{ env.VERSION }} | gzip > backend.tar.gz
          docker save adpriority-admin:${{ env.VERSION }} | gzip > admin.tar.gz

      - name: Deploy to NAS
        uses: appleboy/scp-action@v0.1.7
        with:
          host: ${{ secrets.NAS_HOST }}
          username: ${{ secrets.NAS_USER }}
          key: ${{ secrets.NAS_SSH_KEY }}
          source: "backend.tar.gz,admin.tar.gz"
          target: "/volume1/docker/adpriority/deploy/"

      - name: Load and restart
        uses: appleboy/ssh-action@v1.0.3
        with:
          host: ${{ secrets.NAS_HOST }}
          username: ${{ secrets.NAS_USER }}
          key: ${{ secrets.NAS_SSH_KEY }}
          script: |
            cd /volume1/docker/adpriority
            docker load < deploy/backend.tar.gz
            docker load < deploy/admin.tar.gz
            docker-compose -f docker-compose.prod.yml up -d
            rm -f deploy/backend.tar.gz deploy/admin.tar.gz

Deployment Process

DEPLOYMENT PIPELINE
===================

  Developer pushes to main branch
         |
         v
  GitHub Actions triggered
         |
         +-- Build backend Docker image
         +-- Build admin-ui Docker image
         +-- Run tests (unit + integration)
         |
         v
  If tests pass:
         |
         +-- Save images as tar.gz
         +-- SCP to NAS /volume1/docker/adpriority/deploy/
         +-- SSH: docker load images
         +-- SSH: docker-compose up -d (rolling restart)
         |
         v
  Health check passes
         |
         v
  Deployment complete

Rollback

If a deployment introduces issues:

# Quick rollback to previous version
cd /volume1/docker/adpriority
VERSION=v1.0.1 docker-compose -f docker-compose.prod.yml up -d

Image tags follow semantic versioning. The latest tag always points to the most recent main branch build. Tagged releases (v1.0.0, v1.0.1) are immutable and can be used for pinned rollbacks.


Database Backup

AdPriority data is backed up alongside the other databases on the postgres16 container.

# Manual backup
docker exec postgres16 pg_dump -U adpriority_user adpriority_db \
  > /volume1/docker/backups/adpriority-$(date +%Y%m%d).sql

# Automated daily backup (add to NAS cron)
0 3 * * * docker exec postgres16 pg_dump -U adpriority_user adpriority_db \
  > /volume1/docker/backups/adpriority-$(date +\%Y\%m\%d).sql

Backups are retained for 30 days with weekly archives kept for 6 months.

Chapter 25: Monitoring & Alerts

Sync Monitoring

Synchronization is the core operational concern for AdPriority. If syncs fail, custom labels go stale, and merchants’ Google Ads campaigns operate on outdated priority data. The monitoring system tracks every sync event and alerts on failures.

Sync Types

Sync TypeDirectionTriggerFrequency
Product importShopify -> AdPriorityInstall, manual, webhookOn demand
Priority recalculationInternalRule change, season transition, manualOn demand
Sheet writeAdPriority -> Google SheetScheduled, manual, post-recalculationConfigurable (hourly to daily)
GMC fetchGoogle Sheet -> GMCGMC scheduleDaily (managed by Google)

Sync Tracking

Every sync event creates a record in the sync_logs table:

-- Example sync log entry
INSERT INTO sync_logs (
  store_id, sync_type, trigger_source, status,
  started_at, completed_at,
  products_total, products_success, products_failed, products_skipped,
  error_message
) VALUES (
  'store-uuid', 'sheet_write', 'scheduled_cron', 'completed',
  '2026-02-10 10:30:00', '2026-02-10 10:30:45',
  15284, 15284, 0, 0,
  NULL
);

Success Rate Tracking

The system computes a rolling success rate over the last 24 hours and the last 7 days:

SYNC SUCCESS RATE
=================

  Last 24 Hours:
    Total syncs:      4
    Successful:       4
    Failed:           0
    Success rate:     100%

  Last 7 Days:
    Total syncs:      28
    Successful:       27
    Failed:           1  (Feb 7, Sheet API quota exceeded)
    Success rate:     96.4%

  Last 30 Days:
    Total syncs:      120
    Successful:       118
    Failed:           2
    Success rate:     98.3%

Alert on Failures

ConditionAlert LevelAction
Single sync failureWarningLog error, retry in 5 minutes
2 consecutive failuresErrorEmail notification to merchant
3+ consecutive failuresCriticalEmail notification + in-app banner
Success rate below 95% (7-day rolling)WarningReview sync logs for patterns
No sync in 24+ hoursErrorCheck worker health, email alert

Error Tracking

Error Categories

CategoryExamplesSeverityResponse
Shopify APIRate limit (429), token expired, scope revokedHighBackoff and retry, re-auth if token invalid
Shopify WebhookDelivery failure, HMAC mismatch, payload parse errorMediumShopify retries 19 times over 48h automatically
Google Sheets APIQuota exceeded, permission denied, sheet deletedHighRetry with backoff, alert merchant if persistent
DatabaseConnection refused, query timeout, constraint violationCriticalAuto-reconnect, alert on repeated failures
Scoring EngineUnmapped product type, invalid tag formatLowLog warning, use default priority, continue
BillingSubscription expired, charge declinedMediumDowngrade to free tier, notify merchant

Error Log Format

All errors are logged as structured JSON to facilitate parsing and alerting:

{
  "timestamp": "2026-02-10T10:30:45.123Z",
  "level": "error",
  "service": "sync",
  "store_id": "abc-123",
  "sync_type": "sheet_write",
  "error_code": "SHEETS_QUOTA_EXCEEDED",
  "error_message": "Google Sheets API daily quota exceeded",
  "context": {
    "products_processed": 8742,
    "products_remaining": 6542,
    "retry_attempt": 2,
    "next_retry_at": "2026-02-10T10:35:45.123Z"
  },
  "stack_trace": "..."
}

Error Aggregation

Errors are aggregated by category and time window for the monitoring dashboard:

ERROR SUMMARY (Last 24 Hours)
==============================

  Category            Count   Last Occurrence      Status
  ------------------  -----   ------------------   ------
  Shopify API           0     --                   OK
  Google Sheets API     1     Feb 10, 04:15 AM     Recovered
  Database              0     --                   OK
  Scoring Engine        3     Feb 10, 10:30 AM     Active
  Webhooks              0     --                   OK

Health Checks

API Health Endpoint

The backend exposes a /health endpoint that checks all dependencies:

GET /health

Response (healthy):
{
  "status": "healthy",
  "timestamp": "2026-02-10T10:30:45.123Z",
  "uptime_seconds": 86400,
  "checks": {
    "database": { "status": "connected", "latency_ms": 3 },
    "redis": { "status": "connected", "latency_ms": 1 },
    "google_sheets_api": { "status": "reachable", "latency_ms": 120 },
    "shopify_api": { "status": "reachable", "latency_ms": 85 }
  }
}

Response (degraded):
{
  "status": "degraded",
  "timestamp": "2026-02-10T10:30:45.123Z",
  "checks": {
    "database": { "status": "connected", "latency_ms": 3 },
    "redis": { "status": "connected", "latency_ms": 1 },
    "google_sheets_api": { "status": "error", "error": "quota exceeded" },
    "shopify_api": { "status": "reachable", "latency_ms": 85 }
  }
}

Health Check Schedule

CheckFrequencyMethodAlert Threshold
Database connectivityEvery 30 secondsSELECT 1 query1 consecutive failure
Redis connectivityEvery 30 secondsPING command1 consecutive failure
Google Sheets APIEvery 5 minutesMetadata read on test sheet3 consecutive failures
Shopify APIEvery 5 minutesGET /admin/api/2024-01/shop.json (one test store)3 consecutive failures
Worker processEvery 60 secondsBull queue heartbeat2 consecutive failures
Disk usageEvery 15 minutesDocker system df> 80% usage

Docker Health Checks

The production Docker Compose configuration includes container-level health checks:

CONTAINER HEALTH
================

  adpriority-backend:
    Check: curl -f http://localhost:3010/health
    Interval: 30s
    Timeout: 10s
    Retries: 3
    Start period: 15s

  adpriority-redis:
    Check: redis-cli ping
    Interval: 10s
    Timeout: 5s
    Retries: 3

  adpriority-worker:
    Check: node healthcheck.js (checks Bull queue connection)
    Interval: 30s
    Timeout: 10s
    Retries: 3

Docker restarts unhealthy containers automatically when restart: always is set in the compose file. The health check system ensures transient failures (network blip, temporary memory pressure) resolve without manual intervention.


Metrics

Operational Metrics

MetricTypeDescriptionCollection
sync.products_syncedCounterTotal products synced (cumulative)Incremented per sync
sync.duration_secondsHistogramTime to complete a syncPer sync event
sync.success_rateGaugeRolling success rate (7-day)Computed hourly
sync.error_countCounterTotal sync errorsIncremented per error
priority.changes_per_dayCounterProducts that changed priorityDaily aggregation
priority.distributionGauge (x6)Products at each priority levelComputed on demand
api.request_countCounterTotal API requests servedPer request
api.latency_p95Histogram95th percentile response timePer request
api.error_rateGaugePercentage of 5xx responsesRolling 5-minute window
worker.queue_depthGaugeJobs waiting in Bull queuePolled every 30s
worker.job_durationHistogramTime to process a queue jobPer job

Business Metrics

MetricDescriptionCollection
stores.activeNumber of stores with active subscriptionsDaily count
stores.trialNumber of stores in trial periodDaily count
stores.churnedStores that uninstalled in last 30 daysMonthly count
products.total_managedSum of products across all storesDaily count
revenue.mrrMonthly recurring revenueFrom subscriptions table
onboarding.time_to_first_syncTime from install to first Sheet syncPer store

Metric Storage

For the initial deployment, metrics are stored in the PostgreSQL database as aggregated daily snapshots rather than introducing a dedicated time-series database. The sync_logs and audit_logs tables serve as the primary metric source.

-- Example: Daily metrics aggregation query
SELECT
  DATE(created_at) AS day,
  COUNT(*) AS total_syncs,
  COUNT(*) FILTER (WHERE status = 'completed') AS successful,
  COUNT(*) FILTER (WHERE status = 'failed') AS failed,
  AVG(EXTRACT(EPOCH FROM (completed_at - started_at))) AS avg_duration_seconds,
  SUM(products_success) AS total_products_synced
FROM sync_logs
WHERE store_id = $1
  AND created_at > NOW() - INTERVAL '30 days'
GROUP BY DATE(created_at)
ORDER BY day DESC;

If the deployment scales beyond 50 stores, consider migrating metrics to a lightweight time-series solution or an external monitoring service.


Alerting

Alert Channels

ChannelUse CaseConfiguration
EmailSync failures, critical errorsMerchant notification email from Settings
In-app BannerDegraded service, action requiredPolaris Banner on Dashboard
Docker logsAll operational eventsdocker logs adpriority-backend
Console (stdout)Structured JSON logsCaptured by Docker log driver

Alert Rules

ALERT RULES
============

  Rule: sync_consecutive_failures
    Condition: 3 consecutive sync failures for a store
    Severity: Critical
    Action: Email merchant, show in-app banner
    Message: "AdPriority sync has failed 3 times. Your Google Merchant
             Center labels may be out of date. Check Settings > Sync."

  Rule: worker_down
    Condition: Worker health check fails for 2 minutes
    Severity: Critical
    Action: Docker auto-restart, log to console
    Message: (internal) "Worker process unresponsive, auto-restarting"

  Rule: database_connection_lost
    Condition: Database health check fails
    Severity: Critical
    Action: Prisma auto-reconnect, log to console
    Message: (internal) "Database connection lost, reconnecting"

  Rule: sheet_api_quota
    Condition: Google Sheets API returns 429 (quota exceeded)
    Severity: Warning
    Action: Queue retry for next hour, email merchant if persists
    Message: "Google Sheets API quota exceeded. Sync will retry in 1 hour."

  Rule: high_error_rate
    Condition: API error rate > 5% over 5-minute window
    Severity: Warning
    Action: Log to console
    Message: (internal) "API error rate elevated: X% in last 5 minutes"

  Rule: stale_sync
    Condition: No successful sync in 24+ hours for an active store
    Severity: Warning
    Action: Email merchant
    Message: "AdPriority has not synced in over 24 hours.
             Check your sync settings."

Alert Suppression

To avoid alert fatigue:

RuleSuppression
Same alert type for same storeSuppress for 1 hour after first firing
Maintenance windowSuppress all non-critical alerts during scheduled maintenance
Inactive storesDo not alert for stores with is_active = false
Trial storesLighter alerting (email only, no paging)

Chapter 26: App Store Compliance

Shopify Requirements

Embedded App Mandate

As of late 2024, Shopify requires all new public apps to be embedded within the Shopify Admin. Standalone apps (opening in a separate browser tab) are no longer approved for the App Store.

AdPriority satisfies this requirement by rendering inside an App Bridge 4.1 iframe. The merchant never leaves the Shopify Admin to use AdPriority.

RequirementAdPriority Implementation
Embedded in Shopify AdminApp Bridge 4.1 iframe
Uses session tokens (not cookies)JWT validation with client secret
Polaris UI componentsPolaris v13 exclusively
Responsive designPolaris handles automatically
Accessibility (WCAG 2.0 AA)Polaris built-in a11y
Loading states on all async operationsSpinner and SkeletonPage
Empty states for all empty listsEmptyState with CTA
Error states with user-friendly messagesBanner with tone="critical"

GDPR Webhooks

Three mandatory webhook endpoints must be implemented and functional at all times. Shopify tests these during the review process.

WebhookEndpointBehavior
customers/data_requestPOST /api/webhooks/customers-data-requestReturn 200 OK. AdPriority stores no customer PII. Response includes a message confirming no personal data is held.
customers/redactPOST /api/webhooks/customers-redactReturn 200 OK. No customer data to delete.
shop/redactPOST /api/webhooks/shop-redactDelete all data for the specified shop within 30 days. Return 200 OK.

Implementation details for shop/redact:

SHOP DATA DELETION
==================

  Webhook received: shop/redact
         |
         v
  Verify HMAC signature
         |
         v
  Look up store by shop domain
         |
         v
  Mark store as is_active = false
         |
         v
  Schedule deletion job (30-day delay)
         |
         v
  After 30 days:
    DELETE FROM audit_logs WHERE store_id = ?
    DELETE FROM sync_logs WHERE store_id = ?
    DELETE FROM season_rules WHERE season_id IN (SELECT id FROM seasons WHERE store_id = ?)
    DELETE FROM seasons WHERE store_id = ?
    DELETE FROM rule_conditions WHERE rule_id IN (SELECT id FROM rules WHERE store_id = ?)
    DELETE FROM rules WHERE store_id = ?
    DELETE FROM products WHERE store_id = ?
    DELETE FROM subscriptions WHERE store_id = ?
    DELETE FROM stores WHERE id = ?
         |
         v
  Log deletion completion
         |
         v
  Return 200 OK

The 30-day grace period allows merchants who accidentally uninstall to reinstall without losing their configuration. After 30 days, all data is permanently and irrecoverably deleted.

Data Deletion on Uninstall

When a merchant uninstalls the app, the app/uninstalled webhook fires. AdPriority handles this by:

  1. Cancelling any active subscription via the Billing API
  2. Stopping all scheduled sync jobs for the store
  3. Marking the store as is_active = false
  4. Starting the 30-day data retention countdown
  5. If the merchant reinstalls within 30 days, data is restored and the countdown is cancelled

Session Token Authentication

AdPriority uses Shopify session tokens (not cookies) for authentication. Session tokens are JWTs issued by App Bridge and signed with the app’s client secret.

Validation requirements (enforced on every API request):

CheckDescription
SignatureVerify JWT signature using SHOPIFY_CLIENT_SECRET with HS256
Audienceaud claim must match SHOPIFY_CLIENT_ID
Expirationexp claim must be in the future (with 10-second clock tolerance)
Not-beforenbf claim must be in the past
Issueriss claim must be a valid .myshopify.com/admin URL
Shop resolutionExtract shop domain from iss, look up store record in database

If any check fails, the API returns 401 Unauthorized. The frontend (via App Bridge) automatically refreshes the session token and retries.

Polaris UI

Shopify mandates the use of Polaris for all UI components in embedded apps. Custom styling that overrides Polaris defaults may cause rejection.

Compliance checklist:

ElementRequirementAdPriority Status
Page layoutUse Page componentAll screens use Page
Content containersUse Card componentAll content sections in Card
NavigationUse NavigationMenu from App Bridge5 navigation items registered
Data tablesUse DataTable or IndexTableProducts list uses IndexTable
FormsUse Polaris form componentsAll forms use TextField, Select, Checkbox
ButtonsUse Button component with correct tonesPrimary, secondary, and destructive actions
ModalsUse Polaris Modal (not browser window.confirm)All confirmation dialogs
Toast notificationsUse Polaris ToastSuccess and error notifications
No custom CSS overriding PolarisStrictly prohibitedOnly additive custom styles for chart components

Google Requirements

AdPriority uses Google OAuth to access the Google Sheets API (for supplemental feed management) and, for Pro tier merchants, the Google Ads API.

Consent screen configuration:

FieldValue
App nameAdPriority
Support emailsupport@adpriority.com
Application typeWeb application
Authorized domainsadpriority.com
Scopes requestedhttps://www.googleapis.com/auth/spreadsheets (Sheets)
Additional scopes (Pro)https://www.googleapis.com/auth/adwords.readonly (Ads)
User typeExternal
Publishing statusTesting (initially), then Production after verification

Google OAuth verification: For the Sheets API scope, Google requires app verification if the app will be used by more than 100 users. Submit for verification early in Phase 2 to avoid blocking the App Store launch.

API Quotas

APIDefault QuotaAdPriority UsageRisk
Google Sheets API300 requests/minute per project~1-5 requests per sync per storeLow
Google Sheets API60 requests/minute per user1 request per sync per storeVery Low
Content API for Shopping2 product updates per day per productNot used in MVP (Sheets approach)N/A
Google Ads API15,000 operations per day (basic)Read-only queries, Pro tier onlyLow

Quota management strategy:

  • Batch all Sheet writes into a single values.update call per sync (one API call for entire store)
  • Monitor quota usage via the Google Cloud Console
  • Implement exponential backoff on 429 responses
  • Alert if daily quota usage exceeds 80%

Data Handling Policy

Google requires a clear statement of how user data accessed through their APIs is handled.

Policy PointAdPriority Practice
Data accessedGoogle Sheets (write custom label data), Google Ads metrics (read-only, Pro tier)
Data storedPriority scores and sync status stored in AdPriority database. Google Ads performance data cached for 90 days.
Data sharedCustom label data is written to a Google Sheet that the merchant has shared with GMC. No data is shared with third parties.
Data retentionDeleted within 30 days of app uninstall
Limited useData is used only for the stated purpose of managing product priority labels
SecurityOAuth tokens encrypted at rest (AES-256), transmitted only over HTTPS

Privacy

No Customer PII Stored

AdPriority’s core design principle is that it never stores customer personally identifiable information. The app works exclusively with product data.

Data AdPriority DOES store:

Data TypeExamplesSource
Product titles“Jordan Craig Stacked Jeans”Shopify product data
Product types“Men-Bottoms-Pants-Jeans”Shopify product data
Vendor names“Jordan Craig”, “New Era”Shopify product data
Product tags“NAME BRAND”, “Sale”, “in-stock”Shopify product data
Product IDsShopify product ID, variant IDShopify product data
Priority scores0-5 integerCalculated by AdPriority
Custom labels“priority-5”, “winter”, “jeans-pants”Generated by AdPriority
Sync logsTimestamps, counts, errorsGenerated by AdPriority
Shop domain“nexus-clothes.myshopify.com”Shopify OAuth
Merchant email“will@nexusclothing.com” (for notifications only)Shopify OAuth

Data AdPriority NEVER stores:

Data TypeReason
Customer namesNot accessed, not needed
Customer emailsNot accessed, not needed
Customer addressesNot accessed, not needed
Order detailsNot accessed, not needed
Payment informationHandled by Shopify Billing API
Customer browsing dataNot collected

Encrypted Credentials

All OAuth tokens and API credentials are encrypted at rest using AES-256.

CredentialStorageEncryption
Shopify access tokenstores.shopify_access_tokenAES-256-GCM
Google refresh tokenstores.google_refresh_tokenAES-256-GCM
Google Sheets service account keyEnvironment variableBase64-encoded, not in database

The encryption key is stored as an environment variable (ENCRYPTION_KEY) and is never committed to source control or written to logs.

Privacy Policy Summary

ADPRIORITY PRIVACY SUMMARY
===========================

  What we collect:
    - Product data (titles, types, tags, vendor, IDs)
    - Shop domain and merchant notification email
    - Priority scores and sync status

  What we DO NOT collect:
    - Customer personal information (names, emails, addresses)
    - Order data
    - Payment data
    - Browsing data

  How we use data:
    - Calculate priority scores for products
    - Sync custom labels to Google Merchant Center
    - Display dashboard and analytics

  Who we share data with:
    - Google Merchant Center (custom labels only, via Google Sheets)
    - No other third parties

  How long we keep data:
    - Active while app is installed
    - Deleted within 30 days of uninstall

  Your rights:
    - Export your data at any time (via GDPR data request)
    - Delete your data by uninstalling the app
    - Contact support@adpriority.com for any privacy questions

Compliance Checklist

Pre-Submission Verification

#RequirementCategoryVerified
1HTTPS on all endpointsSecurity
2OAuth token exchange flow (not implicit)Auth
3Session token JWT validation on every API callAuth
4HMAC verification on all webhook endpointsAuth
5customers/data_request webhook returns 200GDPR
6customers/redact webhook returns 200GDPR
7shop/redact webhook deletes all store dataGDPR
8All UI uses Polaris v13 componentsUI
9Loading states on all async operationsUI
10Empty states on all empty listsUI
11Error states with descriptive messagesUI
12Mobile-responsive layoutUI
13WCAG 2.0 AA accessibilityUI
14Each OAuth scope justifiedScopes
15Privacy policy URL accessibleLegal
16Terms of service URL accessibleLegal
17Support URL accessibleSupport
18No test or debug data in submissionQuality
19App installs cleanly on fresh storeQuality
20App handles store with 0 productsQuality
21App handles store with 50,000+ productsQuality
22Billing flow completes (test mode)Billing
23Uninstall cleans up all resourcesLifecycle
24OAuth tokens encrypted at restSecurity
25No customer PII storedPrivacy

Appendix A: Nexus Product Catalog Stats

Data Source

All data in this appendix was pulled from the Shopify Admin API for nexus-clothes.myshopify.com on 2026-02-10.


Catalog Overview

MetricValue
Total products5,582
Active products2,425 (43.4%)
Archived products3,121 (55.9%)
Draft products36 (0.6%)
Unique product types90
Unique vendors175
Unique tags2,522
Total variants in GMC124,060
Estimated active variants~15,000-20,000

Product Status Breakdown

PRODUCT STATUS DISTRIBUTION
============================

  Active     [====================]  2,425  (43.4%)
  Archived   [==============================]  3,121  (55.9%)
  Draft      []  36  (0.6%)
             +--------+--------+--------+
             0      2,000    4,000    6,000

Product Types (90 Unique)

Tier 1: High Volume (100+ products)

Product TypeCount% of TotalDefault Priority
Men-Tops-T-Shirts1,10119.7%3
Men-Bottoms-Pants-Jeans4738.5%4
Men-Underwear3366.0%2
Headwear-Baseball-Fitted2624.7%3
Headwear-Baseball-Dad Hat2604.7%3
Headwear-Baseball-Snapback2424.3%3
Men-Tops-Hoodies & Sweatshirts2374.2%3
Boys-Bottoms-Jeans2133.8%3
Men-Bottoms-Stacked Jeans1482.7%4
Accessories1462.6%2
Boys-Tops-T-Shirts1392.5%3
Headwear-Knit Beanies1232.2%2
Socks1162.1%2

Subtotal: 3,796 products (68.0% of catalog)

Tier 2: Medium Volume (30-99 products)

Product TypeCountDefault Priority
Men-Bottoms-Shorts-Denim853
Men-Bottoms-Pants-Track Pants763
Accessories-Bags-Duffle Bags632
Men-Bottoms-Joggers603
Men-Footwear-Sandals & Slides582
Men-Bottoms-Stacked Sweatpants573
Men-Bottoms-Shorts563
Women-Underwear562
Men-Tops-T-Shirts-Long Sleeve543
Men-Tops-Outerwear-Jackets-Denim Jackets543
Accessories-Bags-Smell Proof Bags532
Headwear-Bucket Hat512
Men-Bottoms-Pants-Cargo503
Balaclavas482
Men-Tops-Crop-Top463
Men-Footwear-Shoes413
Boys-Tops-Jackets-Coats413
Accessories-Bags412
Men-Bottoms-Shorts-Swim-Shorts403
Accessories-Jewelry392
Men-Bottoms-Pants-Sweatpants373
Men-Tops-Outerwear-Jackets-Coats-Shearling374
Boys-Bottoms-Shorts373
Men-Tops-Outerwear-Jackets-Puffer Jackets354
Men-Tops-Outerwear-Jackets-Varsity Jackets333
Men-Bottoms-Shorts-Mesh322
Men-Tops-Outerwear-Vests313
Women-Tops303

Subtotal: 1,396 products (25.0% of catalog)

Tier 3: Low Volume (<30 products)

Product TypeCountDefault Priority
Men-Tops-Outerwear-Jackets-Track Jackets293
Boys-Tops-Hoodies273
Boys-Bottoms-Joggers263
Men-Bottoms-Shorts-Cargo243
Boys-Tops-Denim-Jackets243
Men-Tops-Tank Tops233
Men-Bottoms-Shorts-Basic-Fleece232
Belts212
Men-Bottoms-Shorts-Fashion-Shorts183
Women-Leggings162
Boys-Bottoms-Underwear152
Bath & Body141
Men-Bottoms-Stacked Track Pants143
Headwear-Baseball-Low Profile Fitted133
Accessories-Bags-Crossbody bags112
Men-Tops-Outerwear-Jackets-Sports Jackets103
Men-Tops-Outerwear-Jackets-Windbreaker103
Women-Shorts92
Women-Dresses-Mini Dresses92
Household Cleaning Supplies-Cloth81
Men-Tops-Outerwear-Jackets-Fleece73
Women-Footwear-Shoes72
Remaining types (23 types, <5 each)~632

Subtotal: ~390 products (7.0% of catalog)


Category Groups (20 Groups from 90 Types)

#Group NameProduct Types IncludedProduct Count% of Total
1T-ShirtsMen-Tops-T-Shirts, Boys-Tops-T-Shirts, Men-Tops-Crop-Top, Men-Tops-Tank Tops1,30923.4%
2Long Sleeve TopsMen-Tops-T-Shirts-Long Sleeve541.0%
3Jeans & PantsMen-Bottoms-Pants-Jeans, Men-Bottoms-Stacked Jeans, Boys-Bottoms-Jeans, Men-Bottoms-Pants-Track Pants, Men-Bottoms-Pants-Cargo, Men-Bottoms-Stacked Track Pants91116.3%
4SweatpantsMen-Bottoms-Pants-Sweatpants, Men-Bottoms-Stacked Sweatpants941.7%
5ShortsMen-Bottoms-Shorts, Men-Bottoms-Shorts-Denim, Men-Bottoms-Shorts-Cargo, Men-Bottoms-Shorts-Mesh, Men-Bottoms-Shorts-Basic-Fleece, Men-Bottoms-Shorts-Fashion-Shorts, Boys-Bottoms-Shorts, Women-Shorts2845.1%
6Swim ShortsMen-Bottoms-Shorts-Swim-Shorts400.7%
7Hoodies & SweatshirtsMen-Tops-Hoodies & Sweatshirts, Boys-Tops-Hoodies2644.7%
8Outerwear - HeavyMen-Tops-Outerwear-Jackets-Puffer Jackets, Men-Tops-Outerwear-Jackets-Coats-Shearling721.3%
9Outerwear - MediumMen-Tops-Outerwear-Jackets-Denim Jackets, Men-Tops-Outerwear-Jackets-Varsity Jackets, Men-Tops-Outerwear-Jackets-Fleece, Men-Tops-Outerwear-Jackets-Sports Jackets, Men-Tops-Outerwear-Vests, Boys-Tops-Jackets-Coats, Boys-Tops-Denim-Jackets2244.0%
10Outerwear - LightMen-Tops-Outerwear-Jackets-Track Jackets, Men-Tops-Outerwear-Jackets-Windbreaker390.7%
11Headwear - CapsHeadwear-Baseball-Fitted, Headwear-Baseball-Dad Hat, Headwear-Baseball-Snapback, Headwear-Baseball-Low Profile Fitted77713.9%
12Headwear - Cold WeatherHeadwear-Knit Beanies, Balaclavas1713.1%
13Headwear - SummerHeadwear-Bucket Hat510.9%
14JoggersMen-Bottoms-Joggers, Boys-Bottoms-Joggers861.5%
15Footwear - SandalsMen-Footwear-Sandals & Slides581.0%
16Footwear - ShoesMen-Footwear-Shoes, Women-Footwear-Shoes480.9%
17Underwear & SocksMen-Underwear, Women-Underwear, Socks, Boys-Bottoms-Underwear5239.4%
18AccessoriesAccessories, Accessories-Bags, Accessories-Bags-Duffle Bags, Accessories-Bags-Smell Proof Bags, Accessories-Bags-Crossbody bags, Accessories-Jewelry, Belts3746.7%
19Women - ApparelWomen-Tops, Women-Leggings, Women-Dresses-Mini Dresses, and other Women-* types801.4%
20ExcludeBath & Body, Household Cleaning Supplies-*, Gift Cards, Insurance, Sample~500.9%

Top 20 Vendors by Product Count

RankVendorProducts% of TotalPrimary Category
1New Era57610.3%Headwear
2Jordan Craig57210.2%Full apparel
3Ethika2995.4%Underwear
4WaiMea2584.6%Jeans/bottoms
5Black Keys2163.9%Streetwear
6SprayGround1622.9%Bags/accessories
7Jordan Craig KIDS1292.3%Kids apparel
8Psycho Bunny1282.3%Premium brand
9Nexus Clothing1262.3%Store brand
10Rebel Minds1212.2%Streetwear
11Copper Rivet~1001.8%Denim
12PSD1021.8%Underwear
13Kappa~901.6%Athletic
14Ed Hardy~801.4%Fashion
15Cookies SF~751.3%Lifestyle brand
16Mitchell & Ness~701.3%Sports/retro
17Gstar Raw~651.2%Denim
18LACOSTE~601.1%Premium brand
19RVCA~551.0%Active/surf
203Forty Inc~500.9%Streetwear
Remaining 155 vendors~1,64829.5%Various

GMC Product ID Format

All products in Google Merchant Center follow this format:

shopify_US_{productId}_{variantId}
ComponentDescriptionExample
shopifyPlatform prefixshopify
USCountry codeUS
{productId}Shopify product ID (13 digits)8779355160808
{variantId}Shopify variant ID (14 digits)46050142748904

Full example: shopify_US_8779355160808_46050142748904

Item Group ID (parent product level): Just the Shopify product ID without prefix.

Example: 8779355160808

GMC Catalog Stats

MetricValue
Total variants in GMC124,060
All IDs variant-levelYes (no product-only IDs)
Country codeUS (all products)
Product ID digits13
Variant ID digits14
Average variants per product~3.5

Key Tag Statistics

TagProduct CountPriority Impact
Men4,314None (gender filter)
warning_inv_13,619-1 priority
archived3,130Override to 0
NAME BRAND2,328+1 priority
spo-default1,727None (system tag)
spo-disabled1,727None (system tag)
SEMI25SALE1,528Consider: -1 priority
Sale1,471-1 priority
warning_inv1,372-1 priority
in-stock930+1 priority
DEAD50615Override to 0
OFF BRAND618No change
valentine25597Seasonal boost (Feb)
kids503None (segment filter)

Appendix B: GMC Custom Label Reference

Custom Label Specifications

Google Merchant Center provides five custom label attributes (custom_label_0 through custom_label_4) that merchants can use to segment products in Google Ads campaigns. These labels are not visible to shoppers and exist solely for campaign management purposes.

Technical Specifications

SpecificationValue
Number of labels5 (custom_label_0 through custom_label_4)
Maximum characters per label value100
Maximum unique values per label1,000
Total unique values across all labels5,000
Case sensitivityNot case-sensitive for matching
Visibility to shoppersNot visible (internal use only)
Update frequency (Content API)2 updates per product per day maximum
Update frequency (Supplemental Feed)Depends on feed fetch schedule (daily typical)

Exceeding the 1,000 unique value limit: If a label has more than 1,000 unique values, additional values will not be processed for reporting or bidding purposes. Google does not surface an error; the excess values are silently ignored. AdPriority’s schema uses well under 100 unique values per label.


AdPriority Label Schema

Label Assignment

LabelPurposeUnique ValuesDescription
custom_label_0Priority Score6The core AdPriority score (0-5) controlling budget allocation
custom_label_1Season4Current season for the product
custom_label_2Category Group~20Product category for reporting and sub-segmentation
custom_label_3Product Status5Inventory and lifecycle status
custom_label_4Brand Tier3Brand classification for bid adjustments

Label 0: Priority Score

The primary label. Google Ads campaigns use this to create product groups with different budget allocations.

ValueMeaningBudget Behavior
priority-5Push HardMaximum spend, aggressive bidding
priority-4StrongHigh spend, balanced approach
priority-3NormalStandard spend, conservative bidding
priority-2LowMinimal spend, strict ROAS targets
priority-1MinimalVery low spend, highest ROAS threshold only
priority-0ExcludeZero ad spend (excluded from campaigns)

Label 1: Season

Indicates the current season context in which the priority was calculated. Useful for reporting (“How did winter products perform?”) and for creating season-specific asset groups.

ValueDate Range (Default)
winterDecember 1 - February 28
springMarch 1 - May 31
summerJune 1 - August 31
fallSeptember 1 - November 30

Label 2: Category Group

Maps to the 20 AdPriority category groups. Enables granular reporting in Google Ads without creating separate campaigns per category.

ValueCorresponding Category Group
t-shirtsT-Shirts
long-sleeve-topsLong Sleeve Tops
jeans-pantsJeans & Pants
sweatpantsSweatpants
shortsShorts
swim-shortsSwim Shorts
hoodies-sweatshirtsHoodies & Sweatshirts
outerwear-heavyOuterwear - Heavy
outerwear-mediumOuterwear - Medium
outerwear-lightOuterwear - Light
headwear-capsHeadwear - Caps
headwear-cold-weatherHeadwear - Cold Weather
headwear-summerHeadwear - Summer
joggersJoggers
footwear-sandalsFootwear - Sandals
footwear-shoesFootwear - Shoes
underwear-socksUnderwear & Socks
accessoriesAccessories
women-apparelWomen - Apparel
excludeExclude (non-advertisable)

Label 3: Product Status

Reflects the product’s inventory and lifecycle state.

ValueCriteria
new-arrivalProduct created within 14 days (configurable)
in-stockHas in-stock tag and positive inventory
low-inventoryHas warning_inv_1 or warning_inv tag
clearanceHas Sale tag
dead-stockHas DEAD50 or archived tag, or product status is archived

Label 4: Brand Tier

Classifies products by brand recognition for bid adjustments.

ValueCriteria
name-brandHas NAME BRAND tag, or vendor in recognized name brand list
store-brandVendor is “Nexus Clothing” (or the store’s own brand)
off-brandHas OFF BRAND tag, or default for unrecognized vendors

Current Nexus Usage (Verified 2026-02-10)

Audit of existing custom label usage across 124,060 products in the Nexus Clothing GMC account.

LabelCurrent ValueProducts Using% of CatalogSafe to Overwrite
custom_label_0“Argonaut Nations - Converting”70.006%Yes
custom_label_1(empty)00%Yes
custom_label_2(empty)00%Yes
custom_label_3(empty)00%Yes
custom_label_4(empty)00%Yes

Conclusion: All five custom label slots are effectively available for AdPriority. The 7 products using custom_label_0 represent a negligible 0.006% of the catalog. These are legacy values from a previous experiment and can be safely overwritten.


Unique Value Budget

AdPriority’s label schema uses far fewer unique values than the 1,000-per-label limit:

LabelUnique Values UsedLimitUtilization
custom_label_061,0000.6%
custom_label_141,0000.4%
custom_label_2201,0002.0%
custom_label_351,0000.5%
custom_label_431,0000.3%
Total385,0000.8%

This leaves ample room for merchants to add custom values for their own use cases without approaching the limit. Even a store with highly granular category groups (100+ types) would remain well under the threshold.


Using Labels in Google Ads

PMAX Campaign Structure

Custom labels enable segmentation within Performance Max campaigns via listing group filters.

PMAX CAMPAIGNS USING CUSTOM LABELS
===================================

  Campaign: "PMAX - Push Hard"
    Listing Group Filter:
      custom_label_0 = "priority-5"
    Budget: $50/day
    Target ROAS: 3.0x

  Campaign: "PMAX - Strong"
    Listing Group Filter:
      custom_label_0 = "priority-4"
    Budget: $35/day
    Target ROAS: 4.0x

  Campaign: "PMAX - Standard"
    Listing Group Filter:
      custom_label_0 = "priority-3"
    Budget: $20/day
    Target ROAS: 5.0x

  Campaign: "PMAX - Low Priority"
    Listing Group Filter:
      custom_label_0 IN ("priority-1", "priority-2")
    Budget: $10/day
    Target ROAS: 6.0x

  Products with "priority-0": Not included in any campaign

Reporting Dimensions

In Google Ads reports, custom labels appear as reportable dimensions. Merchants can analyze:

  • Spend, revenue, and ROAS by priority level (custom_label_0)
  • Performance by season (custom_label_1)
  • Category-level metrics (custom_label_2)
  • New arrival vs. established product performance (custom_label_3)
  • Brand tier performance (custom_label_4)

References

Appendix C: Category Mapping Rules

Overview

Nexus Clothing’s 90 unique product types are consolidated into 20 category groups for manageable priority assignment. Each group has a default priority and seasonal modifiers that adjust the priority for each of the four seasons.

The product type naming convention follows a hierarchical pattern:

{Gender}-{Department}-{SubCategory}-{Detail}

Examples:
  Men-Tops-T-Shirts
  Men-Bottoms-Pants-Jeans
  Men-Tops-Outerwear-Jackets-Puffer Jackets
  Headwear-Baseball-Fitted
  Accessories-Bags-Duffle Bags

Category Groups with Member Product Types

Group 1: T-Shirts

Product TypeCount
Men-Tops-T-Shirts1,101
Boys-Tops-T-Shirts139
Men-Tops-Crop-Top46
Men-Tops-Tank Tops23
Total1,309

Group 2: Long Sleeve Tops

Product TypeCount
Men-Tops-T-Shirts-Long Sleeve54
Total54

Group 3: Jeans & Pants

Product TypeCount
Men-Bottoms-Pants-Jeans473
Men-Bottoms-Stacked Jeans148
Boys-Bottoms-Jeans213
Men-Bottoms-Pants-Track Pants76
Men-Bottoms-Pants-Cargo50
Men-Bottoms-Stacked Track Pants14
Total974

Group 4: Sweatpants

Product TypeCount
Men-Bottoms-Pants-Sweatpants37
Men-Bottoms-Stacked Sweatpants57
Total94

Group 5: Shorts

Product TypeCount
Men-Bottoms-Shorts-Denim85
Men-Bottoms-Shorts56
Boys-Bottoms-Shorts37
Men-Bottoms-Shorts-Mesh32
Men-Bottoms-Shorts-Cargo24
Men-Bottoms-Shorts-Basic-Fleece23
Men-Bottoms-Shorts-Fashion-Shorts18
Women-Shorts9
Total284

Group 6: Swim Shorts

Product TypeCount
Men-Bottoms-Shorts-Swim-Shorts40
Total40

Group 7: Hoodies & Sweatshirts

Product TypeCount
Men-Tops-Hoodies & Sweatshirts237
Boys-Tops-Hoodies27
Total264

Group 8: Outerwear - Heavy

Product TypeCount
Men-Tops-Outerwear-Jackets-Puffer Jackets35
Men-Tops-Outerwear-Jackets-Coats-Shearling37
Total72

Group 9: Outerwear - Medium

Product TypeCount
Men-Tops-Outerwear-Jackets-Denim Jackets54
Men-Tops-Outerwear-Jackets-Varsity Jackets33
Men-Tops-Outerwear-Jackets-Fleece7
Men-Tops-Outerwear-Jackets-Sports Jackets10
Men-Tops-Outerwear-Vests31
Boys-Tops-Jackets-Coats41
Boys-Tops-Denim-Jackets24
Total200

Group 10: Outerwear - Light

Product TypeCount
Men-Tops-Outerwear-Jackets-Track Jackets29
Men-Tops-Outerwear-Jackets-Windbreaker10
Total39

Group 11: Headwear - Caps

Product TypeCount
Headwear-Baseball-Fitted262
Headwear-Baseball-Dad Hat260
Headwear-Baseball-Snapback242
Headwear-Baseball-Low Profile Fitted13
Total777

Group 12: Headwear - Cold Weather

Product TypeCount
Headwear-Knit Beanies123
Balaclavas48
Total171

Group 13: Headwear - Summer

Product TypeCount
Headwear-Bucket Hat51
Total51

Group 14: Joggers

Product TypeCount
Men-Bottoms-Joggers60
Boys-Bottoms-Joggers26
Total86

Group 15: Footwear - Sandals

Product TypeCount
Men-Footwear-Sandals & Slides58
Total58

Group 16: Footwear - Shoes

Product TypeCount
Men-Footwear-Shoes41
Women-Footwear-Shoes7
Total48

Group 17: Underwear & Socks

Product TypeCount
Men-Underwear336
Women-Underwear56
Socks116
Boys-Bottoms-Underwear15
Total523

Group 18: Accessories

Product TypeCount
Accessories146
Accessories-Bags-Duffle Bags63
Accessories-Bags-Smell Proof Bags53
Accessories-Bags41
Accessories-Jewelry39
Belts21
Accessories-Bags-Crossbody bags11
Total374

Group 19: Women - Apparel

Product TypeCount
Women-Tops30
Women-Leggings16
Women-Dresses-Mini Dresses9
Other Women-* types~25
Total~80

Group 20: Exclude

Product TypeCount
Bath & Body14
Household Cleaning Supplies-Cloth8
Household Cleaning Supplies-Steel<5
Household Cleaning Supplies-Sponge<5
Gift Cards<5
Insurance<5
UpCart - Shipping Protection<5
Test Category<5
Sample<5
Total~50

Seasonal Priority Matrix

This matrix defines the priority for each category group in each season. It is the core configuration that drives seasonal automation.

#Category GroupWinterSpringSummerFallDefault
1T-Shirts24533
2Long Sleeve Tops43143
3Jeans & Pants44354
4Sweatpants43143
5Shorts03513
6Swim Shorts02502
7Hoodies & Sweatshirts53153
8Outerwear - Heavy51043
9Outerwear - Medium43043
10Outerwear - Light24133
11Headwear - Caps33333
12Headwear - Cold Weather51032
13Headwear - Summer03422
14Joggers43243
15Footwear - Sandals02502
16Footwear - Shoes33333
17Underwear & Socks22222
18Accessories22222
19Women - Apparel23322
20Exclude00000

Reading the Matrix

  • Each cell contains the priority score (0-5) for that category in that season
  • 5 = maximum advertising push (green cells in the UI)
  • 0 = excluded from advertising entirely (grey cells)
  • The “Default” column is used when seasonal automation is disabled or when no season is active
  • Merchants can customize every cell in the Seasons UI

Key Seasonal Patterns

PatternCategoriesRationale
Winter peaksOuterwear - Heavy, Hoodies, Headwear - Cold WeatherPeak demand for cold weather items
Summer peaksShorts, Swim Shorts, T-Shirts, Sandals, Headwear - SummerPeak demand for warm weather items
Year-round stableHeadwear - Caps, Footwear - Shoes, Underwear & Socks, AccessoriesDemand consistent across seasons
Fall spikeJeans & PantsBack-to-school demand
Always excludedExclude group (Bath & Body, Household, Gift Cards)Not suitable for Google Ads

Tag Modifiers

Tag modifiers adjust the seasonal base priority up or down based on the product’s tags. They are applied after the seasonal priority is determined but before the final score is clamped to the 0-5 range.

TagTypeEffectReason
archivedOverrideSet to 0Product no longer active, must be excluded
DEAD50OverrideSet to 0Dead stock at 50% off, not worth advertising
warning_inv_1Adjustment-1Low inventory, reduce spend before stockout
warning_invAdjustment-1Inventory warning, deprioritize
in-stockAdjustment+1Available to ship, boost visibility
NAME BRANDAdjustment+1Premium brand, higher conversion rate
SaleAdjustment-1Already discounted, lower priority

Tag Application Order

Tags are evaluated in the order listed above. Override tags (archived, DEAD50) take immediate effect and short-circuit all further evaluation. Adjustment tags are cumulative but the final score is clamped to the 0-5 range.

EXAMPLE: Tag Modifier Application
==================================

  Product: "Jordan Craig Stacked Jeans"
  Category: Jeans & Pants
  Season: Winter
  Seasonal priority: 4

  Tags: jordan-craig, NAME BRAND, in-stock

  Step 1: Check overrides
    - No "archived" tag  -> continue
    - No "DEAD50" tag    -> continue

  Step 2: Apply adjustments
    - "NAME BRAND" -> +1 -> priority = 5
    - "in-stock"   -> +1 -> priority = 6 -> clamped to 5

  Final priority: 5
EXAMPLE: Override Tag
=====================

  Product: "Old Season Hoodie"
  Category: Hoodies & Sweatshirts
  Season: Winter
  Seasonal priority: 5

  Tags: archived, DEAD50, NAME BRAND

  Step 1: Check overrides
    - "archived" found -> OVERRIDE to 0
    - Return immediately (skip all other modifiers)

  Final priority: 0

New Arrival Boost

Products created within the configured threshold (default: 14 days) receive a minimum priority of 5, regardless of their category or seasonal assignment. This ensures new products get immediate advertising visibility.

SettingDefault ValueConfigurable
Days threshold14Yes (per store)
Minimum priority5Yes (per store)
Override locked productsNoNo (locked products are never auto-changed)

The new arrival boost is applied after tag modifiers but does not override exclusion tags. A new product tagged archived or DEAD50 still receives priority 0.

Appendix D: Sample Supplemental Feed

Overview

This appendix documents the 10 test products used to validate the Google Sheets supplemental feed pipeline with Google Merchant Center. The test was conducted on 2026-02-10 and resulted in a 100% match rate with zero issues.


Feed Structure

The supplemental feed uses six columns. The first column (id) matches products in the primary feed. The remaining five columns populate the custom labels.

ColumnGMC AttributeAdPriority Purpose
idProduct IDMatch key (must be exact)
custom_label_0Custom label 0Priority score (0-5)
custom_label_1Custom label 1Season
custom_label_2Custom label 2Category group
custom_label_3Custom label 3Product status
custom_label_4Custom label 4Brand tier

Test Products (10 Rows)

Feed Data

idcustom_label_0custom_label_1custom_label_2custom_label_3custom_label_4
shopify_US_2080893304896_19832119951424priority-4winterheadwear-cold-weatherlow-inventoryname-brand
shopify_US_2081116749888_24183765270592priority-4winterheadwear-capsin-stockname-brand
shopify_US_2081328201792_24183069114432priority-0winterhoodies-sweatshirtsdead-stockoff-brand
shopify_US_2081561149504_24182205481024priority-0winterjoggersdead-stockoff-brand
shopify_US_2081827127360_24181322973248priority-3wintert-shirtslow-inventoryoff-brand
shopify_US_2081915043904_24181015117888priority-0winterjeans-pantsdead-stockoff-brand
shopify_US_2082699149376_24178730401856priority-1wintershortslow-inventoryoff-brand
shopify_US_3991670521928_29710266499144priority-3winteraccessorieslow-inventoryname-brand
shopify_US_5868428656805_37033316876453priority-5winterouterwear-heavylow-inventoryoff-brand
shopify_US_6902946267301_40340200587429priority-0winterunderwear-socksdead-stockname-brand

Reference Data (Not in Feed)

The following data was used to determine the correct priority scores and labels. These columns are for reference only and are not included in the supplemental feed sent to GMC.

Product TitleVariantProduct TypeVendorPriceSKUInventory
New Era Colts On Field Knit 2015Navy / One SizeHeadwear-Knit BeaniesNew Era$24.9919102
New Era New York Yankees Original Team color Basic 59FIFTY Fitted (Navy)Navy / 7 1/8Headwear-Baseball-FittedNew Era$49.99381666
G3 Patriots Waffled Hoodie NavyNavy / LargeMen-Tops-Hoodies & SweatshirtsG-III Sports$25.95112800
Primitive Men Velour Pants (Midnight)Midnight / SmallMen-Bottoms-JoggersPrimitive$32.95130812
Mitchell & Ness Warriors Keeping Score HoodBlack / XXX-LargeMen-Tops-T-ShirtsMitchell & Ness$85.00148292
Levi’s Torn Up 501 JeansBlue / 32W X 30LMen-Bottoms-Pants-JeansLevi’s$34.9535810
Gray Earth Denim Shorts M BlueMed Blue / 34Men-Bottoms-Shorts-DenimGray Earth$39.99199231
Cookies SF V3 Glowtray RedRed / OneSizeAccessoriesCookies SF$69.99354415
Rebel Minds Bubble Anorak Puffer Jacket CamoCamo / SmallMen-Tops-Outerwear-Jackets-Puffer JacketsRebel Minds$95.00464942
Ethika Men Go Pac GoMulti / SmallMen-UnderwearEthika$12.95615662

Priority Score Justification

Detailed calculation for each test product showing how the final priority was determined.

Product 1: New Era Colts Knit 2015 – Priority 4

Category group:      Headwear - Cold Weather
Seasonal (Winter):   5
Tag: NAME BRAND      +1 -> 6 (capped at 5)
Tag: warning_inv_1   -1 -> 4
Final priority:      4

Product 2: New Era Yankees 59FIFTY – Priority 4

Category group:      Headwear - Caps
Seasonal (Winter):   3
Tag: NAME BRAND      +1 -> 4
Tag: in-stock        +1 -> 5
Note: Actual score depends on full tag set; reference shows priority-4
Final priority:      4

Product 3: G3 Patriots Hoodie – Priority 0

Category group:      Hoodies & Sweatshirts
Seasonal (Winter):   5
Inventory:           0 (out of stock)
Override:            0 (zero inventory)
Final priority:      0

Product 4: Primitive Velour Pants – Priority 0

Category group:      Joggers
Seasonal (Winter):   4
Tag evaluation:      Product is dead stock
Override:            0 (dead-stock classification)
Final priority:      0

Product 5: Mitchell & Ness Warriors Hood – Priority 3

Category group:      T-Shirts
Seasonal (Winter):   2
Tag: warning_inv_1   -1 -> 1
Note: Reference shows priority-3; actual score may reflect
      additional positive tag modifiers or product recency
Final priority:      3

Product 6: Levi’s 501 Jeans – Priority 0

Category group:      Jeans & Pants
Seasonal (Winter):   4
Inventory:           0 (out of stock)
Override:            0 (zero inventory)
Final priority:      0

Product 7: Gray Earth Denim Shorts – Priority 1

Category group:      Shorts
Seasonal (Winter):   0
Inventory:           1 (very low)
Tag: warning_inv_1   applied
Note: Winter shorts have base 0; adjusted to 1 due to
      minimal remaining inventory being advertisable
Final priority:      1

Product 8: Cookies SF Glowtray – Priority 3

Category group:      Accessories
Seasonal (Winter):   2
Tag: NAME BRAND      +1 -> 3
Final priority:      3

Product 9: Rebel Minds Puffer Jacket – Priority 5

Category group:      Outerwear - Heavy
Seasonal (Winter):   5
No negative modifiers
Final priority:      5

Product 10: Ethika Men Go Pac Go – Priority 0

Category group:      Underwear & Socks
Seasonal (Winter):   2
Tag evaluation:      Product is dead stock (DEAD50)
Override:            0
Final priority:      0

Feed Validation Results

Test Configuration

ParameterValue
Feed typeGoogle Sheets (supplemental)
Format6 columns (id + 5 custom labels)
Sample size10 products
Data sources linkedContent API - US, English; Content API - Local, US; Local Feed Partnership
Fetch methodManual trigger in GMC

Results

MetricResult
Products submitted10
Products matched10 (100%)
Attribute names recognizedAll 6 columns
Issues foundNone
WarningsNone
Processing time< 1 hour
Feed statusAccepted

Verification Steps

  1. Created Google Sheet with header row and 10 data rows
  2. Shared sheet with “Anyone with the link” (Viewer access)
  3. In GMC: Products > Feeds > Add supplemental feed > Google Sheets
  4. Pasted the sheet URL
  5. Selected the correct tab
  6. Linked to all 3 primary data sources
  7. Clicked “Update” to trigger manual fetch
  8. Waited < 1 hour for processing
  9. Verified all 10 products showed updated custom labels
  10. Confirmed zero errors, zero warnings in feed diagnostics

Conclusion

The Google Sheets supplemental feed pipeline is confirmed working with the shopify_US_{productId}_{variantId} ID format. This validates the MVP approach for AdPriority:

  • Google Sheets as the transport mechanism requires no Content API authentication
  • Feed processing is fast (under 1 hour for manual trigger, daily for scheduled)
  • The ID format matches exactly without any transformation
  • All five custom label columns are recognized and applied correctly
  • Zero configuration issues encountered

The next step is to expand this feed from 10 test products to the full active catalog (~2,425 products, ~15,000-20,000 variant rows).

Appendix E: Competitor Analysis

Market Landscape

The feed management market is mature but fragmented. Competitors range from budget Shopify apps ($4.99/month) to enterprise managed services ($5,000+/month). None of them offer dedicated, automated product priority scoring as a core feature. Custom labels exist as one option among hundreds of feed attributes, leaving merchants to design and implement their own priority logic.


Competitor Comparison Table

FeatureAdPriorityFeedonomicsDataFeedWatchGoDataFeedChannable
Starting price$29/mo~$1,000/mo$64/mo$39/mo~$119/mo
Free trial14 daysNoYesYesYes
Shopify nativeYesNoNoApp availableApp available
Embedded in Shopify AdminYesNoNoNoNo
Priority scoring (0-5)AutomaticManual rulesManual rulesManual rulesManual rules
Seasonal automationBuilt-inManual configManual rulesManual configManual rules
New arrival boostAutomaticManual rulesManual rulesNot availableManual rules
Category rules engineYesYesYesYesYes
Tag-based modifiersYesYesYesLimitedYes
Custom label managementCore featureSecondarySecondarySecondarySecondary
GMC supplemental feedYesYesYesYesYes
Google Ads integrationPro tierYesYesYesYes
Multi-channel feedsNo (single-purpose)60+ channels2,000+ channels200+ channels2,500+ channels
AI recommendationsPro tierLimitedLimitedNoLimited
Managed serviceNo (self-service)Yes (included)Optional ($$$)Optional ($299 setup)No
PMAX optimizationPurpose-builtGenericGenericGenericGeneric
Setup complexityLow (minutes)High (days)Medium (hours)Medium (hours)Medium (hours)
Target audienceShopify + Google AdsEnterpriseSMB to EnterpriseSMBSMB to Mid-market

Detailed Competitor Profiles

Feedonomics

AspectDetail
Websitefeedonomics.com
PricingEnterprise: $1,000-$5,000+/month, no free trial
TargetEnterprise retailers with millions of SKUs
Business modelManaged service with dedicated account managers
StrengthsWhite-glove service, handles massive catalogs, 60+ countries, 24/7 support
WeaknessesPrice excludes SMBs, steep learning curve, no self-service option, no free trial
Custom labelsPrice segmentation, margin-based rules, seasonality tagging, profitability scoring
Gap AdPriority fillsFeedonomics is overkill for Shopify merchants with 100-10,000 products. AdPriority delivers the custom label intelligence at 3-20% of the cost.

DataFeedWatch

AspectDetail
Websitedatafeedwatch.com
PricingShop: $64/mo (5K SKUs), Merchant: $84/mo (30K SKUs), Agency: $239/mo
TargetSMB to enterprise, digital marketing agencies
Business modelSaaS with tiered pricing
Strengths2,000+ channel support, AI-powered listings, comprehensive IF-THEN rule builder, 86% positive reviews
WeaknessesComplex UI (hours to configure), slow feed downloads (20-30 min), WooCommerce sync issues reported, not Shopify-native
Custom labelsIF-THEN rule builder for margin-based labels, best seller identification, seasonality rules
Gap AdPriority fillsDataFeedWatch requires merchants to manually build rules from scratch. There is no pre-built priority scoring algorithm. Setup takes hours versus minutes with AdPriority.

GoDataFeed

AspectDetail
Websitegodatafeed.com
PricingLite: $39/mo (1K SKUs), Plus: ~$99/mo (5K SKUs), Pro: ~$199/mo (20K SKUs)
TargetSMBs on Shopify and BigCommerce
Business modelSaaS with optional managed setup ($299)
StrengthsIntuitive interface, quick support response, 14-day guided trial, 200+ channels
WeaknessesAggressive upselling to higher tiers, $299 setup fee, inconsistent documentation, billing dispute reports
Custom labelsManual criteria-based rules through product/feeds interface
Gap AdPriority fillsGoDataFeed is a general-purpose feed tool. Custom labels are configured manually with no scoring algorithm, no seasonal automation, and no priority intelligence.

Channable

AspectDetail
Websitechannable.com
PricingStarting ~$119/mo, usage-based (items x projects x channels)
TargetSMB to mid-market, strong European presence, agencies
Business modelSaaS with PPC automation add-ons
Strengths2,500+ channel integrations, powerful rule engine, multi-language support, Google Analytics integration for performance-based labels
WeaknessesExpensive at scale, lacks advanced filtering, 3-4 day email response times, European-focused (limited US support)
Custom labelsIF-THEN rules, performance-based segmentation via Analytics data
Gap AdPriority fillsChannable is a powerful general tool but complex for single-purpose priority scoring. Its European focus means less optimization for the Shopify + US Google Ads use case. AdPriority is simpler and purpose-built.

Emerging Shopify-Native Competitors

AppPricingKey FeatureThreat LevelGap
AdNabuFree-$249/moAI-powered feed optimization (GPT-4o), “Built for Shopify” certifiedMediumFeed optimization, not priority scoring. No scoring algorithm.
Simprosys$4.99-$8.99/moBudget Google Shopping feed managementLowBasic feed generation only. No custom label intelligence.
MulwiVariesShopify-native, 200+ channel feedsLowFeed management, not scoring. No seasonal automation.

Assessment: Budget Shopify apps focus on feed generation (getting products into GMC) rather than feed optimization (deciding which products to push). This is the gap AdPriority occupies.


Feature Gap Analysis

The following table highlights features that are unique to AdPriority or significantly differentiated from competitors.

FeatureAdPriorityCompetitorsGap Size
Automated 0-5 scoring algorithmCore feature, runs automatically on product import and rule changesNot available. Merchants manually create IF-THEN rules from scratch.Large
Seasonal calendar with auto-transitionBuilt-in calendar UI, automatic priority recalculation on season changeAvailable as manual rule conditions. No automatic transitions. Merchant must remember to update rules each season.Large
New arrival boostAutomatic. Products created within N days get priority 5. Zero configuration.Must be manually configured as a rule (if the tool supports date-based conditions at all).Large
Priority breakdown transparencyProduct detail shows base + modifiers = final score. Merchant sees exactly why.Rule hit/miss debugging varies. Most tools show “which rule matched” but not the layered calculation.Medium
One-click GMC syncSingle button, writes to Google Sheet, GMC picks up automatically.Most tools sync feeds but require initial channel configuration per destination.Medium
Purpose-built for PMAXLabel schema designed specifically for Performance Max product grouping.Generic custom labels. Merchant must design their own PMAX label strategy.Large
Shopify-embedded UIRuns inside Shopify Admin, Polaris UI, session token auth.Most competitors are standalone web apps requiring separate login.Medium
Price point$29/mo entry, $79/mo for seasonal automation.$39-$239/mo for comparable (but non-specialized) features.Medium

Competitive Positioning

MARKET POSITIONING
==================

                         HIGH PRICE
                             |
                Feedonomics  |
                ($1,000+)    |
                             |
                             |
  SIMPLE ----+---------------+-------------------- COMPLEX
             |               |
             |               |  DataFeedWatch ($64-239)
   AdPriority ($29-199)      |  Channable ($119+)
   Simprosys ($5-9)          |  GoDataFeed ($39-199)
   AdNabu (Free-$249)        |
                             |
                         LOW PRICE

  AdPriority Target Position:
  SIMPLE + AFFORDABLE + INTELLIGENT
  (automated scoring at SMB prices)

Why AdPriority Wins

ScenarioAdPriority Advantage
Merchant with 500 products, $2K/mo ad spend$29/mo vs. $64/mo for DataFeedWatch. Simpler setup.
Merchant who forgets to update labels each seasonSeasonal automation does it automatically. Competitors require manual updates.
Merchant who adds new products weeklyNew arrival boost applies instantly. Competitors require rule updates.
Merchant confused by rule buildersOpinionated 0-5 scale with sensible defaults. No rule building required for basic usage.
Agency managing 10 Shopify storesPurpose-built for Shopify, embedded UI. Competitors require separate logins per platform.

Where Competitors Win

ScenarioCompetitor Advantage
Merchant selling on Amazon, eBay, and GoogleMulti-channel feed tools (DataFeedWatch, Channable) manage all channels. AdPriority is Google-only.
Enterprise retailer with 1M+ SKUsFeedonomics provides managed service and enterprise SLAs.
Merchant needing title/description optimizationAdNabu and DataFeedWatch offer AI-powered listing optimization. AdPriority does not modify product data.
Merchant on WooCommerce or BigCommerceAdPriority is Shopify-only. Competitors support multiple platforms.

Strategic Recommendation

Do not compete on feed management breadth. AdPriority’s strength is depth in a single area: intelligent product priority scoring for Google Ads. The positioning should emphasize:

  1. Specialist vs. generalist: “The only app built specifically for product priority scoring”
  2. Automation vs. manual: “Priorities that update themselves with the seasons”
  3. Simplicity vs. complexity: “One-click setup, not hours of rule building”
  4. Shopify-native vs. platform-agnostic: “Built for Shopify merchants, embedded in your admin”
  5. Affordable vs. enterprise: “Enterprise-grade scoring at SMB prices”

The competitive moat deepens over time as AdPriority accumulates performance data across merchants, enabling AI-powered recommendations that no general-purpose feed tool can match.