diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..3a994bb --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,52 @@ +name: Build + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + build: + runs-on: [self-hosted, linux, ubuntu-latest] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Apply fix-agents.js patch + run: node fix-agents.js + + - name: Typecheck + env: + NEXT_PUBLIC_API_URL: http://localhost:3001 + NEXT_PUBLIC_STORAGE_BASE_URL: http://localhost:9000 + STORAGE_ENDPOINT: http://localhost:9000 + DATABASE_URL: postgresql://routecommerce:routecommerce_dev_password@127.0.0.1:5432/route_commerce + run: npm run type-check + + - name: Lint + env: + NEXT_PUBLIC_API_URL: http://localhost:3001 + NEXT_PUBLIC_STORAGE_BASE_URL: http://localhost:9000 + STORAGE_ENDPOINT: http://localhost:9000 + DATABASE_URL: postgresql://routecommerce:routecommerce_dev_password@127.0.0.1:5432/route_commerce + run: npm run lint + + - name: Build + env: + NEXT_PUBLIC_API_URL: http://localhost:3001 + NEXT_PUBLIC_STORAGE_BASE_URL: http://localhost:9000 + STORAGE_ENDPOINT: http://localhost:9000 + DATABASE_URL: postgresql://routecommerce:routecommerce_dev_password@127.0.0.1:5432/route_commerce + run: npm run build diff --git a/docs/superpowers/specs/2026-06-04-multi-brand-admin-design.md b/docs/superpowers/specs/2026-06-04-multi-brand-admin-design.md new file mode 100644 index 0000000..10391a1 --- /dev/null +++ b/docs/superpowers/specs/2026-06-04-multi-brand-admin-design.md @@ -0,0 +1,470 @@ +# Multi-Brand Admin Support + +**Date:** 2026-06-04 +**Status:** Draft → Approved +**Author:** Grok (brainstorming session) +**Migration file:** `supabase/migrations/204_multi_brand_admin.sql` +**Follow-up migration (out of scope):** `220_drop_legacy_brand_id.sql` + +## Problem + +`admin_users.brand_id` is a single `UUID | null`. The platform supports a `platform_admin` role (no brand) and a `brand_admin` role (one brand). There is no representation for an admin who legitimately needs access to 2+ specific brands — e.g., a parent company operating multiple storefronts with shared operations staff. + +The current `effectiveBrandId = brandId ?? adminUser.brand_id ?? null` pattern silently does the wrong thing for multi-brand use cases: + +- Data model can't represent "Jane is admin for Brand A AND Brand B" — would require two `admin_users` rows (and the auth-user→admin-user join gets messy). +- No central validation that an admin is acting in a brand they actually have access to. +- No persistent "current brand" context — every page re-derives it from scratch. +- No per-brand permission overrides possible (locks in flexibility for the future). + +This spec addresses multi-brand tenants / franchises: same staff managing multiple brands under a parent org, where brands are separate for storefront/billing but share operations. + +## Goals + +- An admin can be associated with multiple brands via a junction table. +- A persistent "active brand" is stored in a cookie, switchable via UI, and used as the default when no explicit brand is requested. +- A new `multi_brand_admin` role makes the relationship explicit in the data and the UI. +- `platform_admin` continues to work unchanged (gets all brands implicitly). +- Existing single-brand `brand_admin`, `store_employee`, and `staff` users continue to work with zero behavior change. +- Server actions get a single, central place to resolve and validate the active brand. + +## Non-Goals (YAGNI) + +- Per-(admin, brand) permission overrides. The user explicitly chose "same perms across all brands." +- Brand-group / parent-org concept. The junction table makes this possible later, but it's not built now. +- "Last accessed brand" auto-redirect. The cookie is the source of truth; no extra logic. +- UI for managing `admin_user_brands` rows. Use Supabase Studio or a follow-up admin UI PR. +- Dropping the legacy `admin_users.brand_id` column. A follow-up migration `220_drop_legacy_brand_id.sql` will do this after we verify nothing reads it. Out of scope for this spec. + +## Approach: Junction Table + Backwards-Compat `brand_id` + +Selected from among three options: + +| Option | Why not | +|---|---| +| A. Junction + keep `brand_id` (selected) | — | +| B. Junction only, drop `brand_id` | High migration risk; every server action reference must change. | +| C. `brand_ids UUID[]` on `admin_users` | No FK, awkward reverse lookups, locks out per-brand metadata. | + +A is the lowest-risk additive path that solves the problem. + +## Data Model + +### New table: `admin_user_brands` + +```sql +CREATE TABLE admin_user_brands ( + admin_user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE, + brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, + added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + added_by UUID REFERENCES admin_users(id), + PRIMARY KEY (admin_user_id, brand_id) +); +CREATE INDEX admin_user_brands_brand_id_idx ON admin_user_brands(brand_id); +``` + +- Composite PK enforces uniqueness. +- Index on `brand_id` makes "which admins are in Brand X?" queries fast. +- `added_at` / `added_by` for audit trail. +- `ON DELETE CASCADE` for both FKs — deleting an admin or brand cleans up the junction. + +### New role: `multi_brand_admin` + +Added to the `role` CHECK constraint. Functionally equivalent to `brand_admin` permission-wise — same `can_manage_*` flags apply. The role label disambiguates intent in the UI ("this person manages 3 brands" vs "this person manages 1 brand") and in audit logs. + +### Updated `AdminUser` type + +```ts +// src/lib/admin-permissions-types.ts +export type AdminUser = { + // ... existing fields + brand_id: string | null; // active brand (one of brand_ids, or null for platform_admin) + brand_ids: string[]; // all brands this admin can act in + role: "platform_admin" | "brand_admin" | "multi_brand_admin" | "store_employee" | "staff"; +}; +``` + +### Membership rules + +| Role | `brand_id` (active) | `brand_ids` (membership) | +|---|---|---| +| `platform_admin` | `null` (or cookie-selected brand) | Implicitly all brands; `brand_ids` populated by `listBrandsForAdmin` querying the `brands` table | +| `multi_brand_admin` | First/selected brand | 2+ brands from `admin_user_brands` | +| `brand_admin` | Their single brand | `[that one brand]` | +| `store_employee` | Their single brand | `[that one brand]` | +| `staff` | Their single brand | `[that one brand]` | + +For `platform_admin`, the application layer short-circuits brand-access checks (`if (adminUser.role === "platform_admin") return ...`). The `brand_ids` field is only used by `listBrandsForAdmin` to render the dropdown options; it is not used for permission gating for `platform_admin`. + +### `getAdminUser()` resolution order + +1. If `dev_session` cookie set → return dev admin. For `platform_admin` dev: `brand_id: null, brand_ids: []` (resolved against `brands` table by `listBrandsForAdmin`). For `store_employee` dev: `brand_id: , brand_ids: []` — fetched from the `brands` table so dev store_employee can browse a real brand's data. (If no brands exist, dev store_employee sees `` — known limitation.) +2. If `NEXT_PUBLIC_USE_MOCK_DATA=true` → same as `platform_admin` dev. +3. Real auth → load `admin_users` row, then JOIN `admin_user_brands` to populate `brand_ids`. +4. Set `brand_id` from (in order): `active_brand_id` cookie (if in `brand_ids` for non-platform-admin, or always for platform-admin) → `admin_users.brand_id` (if in `brand_ids`) → first of `brand_ids`. + +## Server Action Patterns + +### New file: `src/lib/brand-scope.ts` + +```ts +import { cookies } from "next/headers"; +import type { AdminUser } from "./admin-permissions-types"; + +const ACTIVE_BRAND_COOKIE = "active_brand_id"; + +export async function getActiveBrandId( + adminUser: AdminUser, + requested?: string | null +): Promise { + // Cookie is the source of truth for "what brand am I acting in right now" + // for everyone — including platform_admin (who can pin a specific brand + // or fall back to null = "all brands"). + const cookieStore = await cookies(); + const cookieBrand = cookieStore.get(ACTIVE_BRAND_COOKIE)?.value ?? null; + + if (adminUser.role === "platform_admin") { + // requested > cookie > null (all brands) + return requested ?? cookieBrand ?? null; + } + + // Non-platform-admin: requested (if in brand_ids) > cookie (if in brand_ids) > adminUser.brand_id + if (requested) { + return adminUser.brand_ids.includes(requested) ? requested : null; + } + if (cookieBrand && adminUser.brand_ids.includes(cookieBrand)) { + return cookieBrand; + } + return adminUser.brand_id; +} + +export async function setActiveBrandCookie(brandId: string): Promise { + const cookieStore = await cookies(); + cookieStore.set(ACTIVE_BRAND_COOKIE, brandId, { + httpOnly: true, + sameSite: "lax", + path: "/", + maxAge: 60 * 60 * 24 * 30, // 30 days + }); +} + +export async function clearActiveBrandCookie(): Promise { + const cookieStore = await cookies(); + cookieStore.delete(ACTIVE_BRAND_COOKIE); +} + +export function assertBrandAccess(adminUser: AdminUser, brandId: string): void { + if (adminUser.role === "platform_admin") return; + if (!adminUser.brand_ids.includes(brandId)) { + throw new Error("Brand access denied"); + } +} +``` + +### Server action: set active brand + +```ts +// src/actions/admin/set-active-brand.ts +"use server"; +import { getAdminUser } from "@/lib/admin-permissions"; +import { setActiveBrandCookie, clearActiveBrandCookie } from "@/lib/brand-scope"; + +export async function setActiveBrand(brandId: string | null): Promise<{ success: boolean; error?: string }> { + const adminUser = await getAdminUser(); + if (!adminUser) return { success: false, error: "Not authenticated" }; + + // null = "All brands" (platform_admin only) + if (brandId === null) { + if (adminUser.role !== "platform_admin") { + return { success: false, error: "Only platform admins can select 'All brands'" }; + } + await clearActiveBrandCookie(); + return { success: true }; + } + + if (adminUser.role !== "platform_admin" && !adminUser.brand_ids.includes(brandId)) { + return { success: false, error: "No access to that brand" }; + } + await setActiveBrandCookie(brandId); + return { success: true }; +} +``` + +### New server function: list brands for admin + +```ts +// src/actions/brands.ts +import { getAdminUser } from "@/lib/admin-permissions"; +import { svcHeaders } from "@/lib/svc-headers"; + +export async function listBrandsForAdmin(): Promise< + { id: string; name: string; slug: string; logo_url: string | null }[] +> { + const adminUser = await getAdminUser(); + if (!adminUser) return []; + + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; + const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; + + if (adminUser.role === "platform_admin") { + const res = await fetch( + `${supabaseUrl}/rest/v1/brands?select=id,name,slug,logo_url&order=name`, + { headers: svcHeaders(serviceKey) } + ); + return res.ok ? await res.json() : []; + } + + if (adminUser.brand_ids.length === 0) return []; + + const filter = `id=in.(${adminUser.brand_ids.map((id) => `"${id}"`).join(",")})`; + const res = await fetch( + `${supabaseUrl}/rest/v1/brands?${filter}&select=id,name,slug,logo_url&order=name`, + { headers: svcHeaders(serviceKey) } + ); + return res.ok ? await res.json() : []; +} +``` + +### Server action migration pattern + +```ts +// Before: +const effectiveBrandId = brandId ?? adminUser.brand_id ?? null; + +// After: +import { getActiveBrandId } from "@/lib/brand-scope"; + +const activeBrandId = await getActiveBrandId(adminUser, brandId); +if (!activeBrandId && adminUser.role !== "platform_admin") { + return { success: false, error: "Brand access required" }; +} +const effectiveBrandId = activeBrandId; +``` + +This is a mechanical one-line swap in ~30 server actions identified by the grep: + +- `src/actions/wholesale.ts` (multiple) +- `src/actions/products.ts` +- `src/actions/communications/templates.ts` +- `src/actions/communications/campaigns.ts` +- `src/actions/orders/create-admin-order.ts` +- `src/actions/stops.ts` +- `src/actions/analytics.ts` +- `src/actions/square-sync-ui.ts` +- `src/actions/shipping.ts` +- `src/actions/payments.ts` +- `src/actions/ai-import.ts` +- `src/actions/wholesale-register.ts` + +### Page server component pattern + +```ts +// Before (src/app/admin/orders/page.tsx): +const effectiveBrandId = brandIdParam ?? adminUser.brand_id ?? ""; + +// After: +import { getActiveBrandId } from "@/lib/brand-scope"; + +export default async function AdminOrdersPage() { + const adminUser = await getAdminUser(); + if (!adminUser) return ; + + const activeBrandId = await getActiveBrandId(adminUser, brandIdParam); + + if (!activeBrandId && adminUser.role !== "platform_admin") { + return ; + } + + // ... rest of the page +} +``` + +This applies to every page under `src/app/admin/` — including the existing `/admin/taxes/[brandId]` and `/admin/settings/billing/[brandId]` which already do brand param resolution. + +## UI Components + +### Brand selector + +**Location:** `src/components/admin/AdminHeader.tsx` (the top bar already rendered on every `/admin/*` page). + +**Behavior:** A dropdown showing: +- Brand logo + name (current active brand) +- Chevron +- "All brands" option at the top (only for `platform_admin`) +- List of accessible brands (`adminUser.brand_ids`) +- Small badge "Multi-brand manager" next to the user's name when `role === "multi_brand_admin"` + +**Visibility matrix:** + +| Admin | Show dropdown? | Options | +|---|---|---| +| `platform_admin` | Yes | "All brands" + list of all brands | +| `multi_brand_admin` (2+ brands) | Yes | List of their brands | +| `brand_admin` / `store_employee` / `staff` (1 brand) | No | — | +| `platform_admin` (dev_session) | Yes | "All brands" + list of all brands (same UX as production) | + +**On select:** + +```ts +// src/components/admin/BrandSelector.tsx (client component) +"use client"; +async function handleSelect(brandId: string | null) { + await setActiveBrand(brandId); // null = "All brands" + router.refresh(); +} +``` + +`router.refresh()` re-runs server components and re-reads the cookie, so all data on the current page reloads in the new brand context. The URL is **not** changed — the cookie is the source of truth for "what brand am I acting in right now." + +### URL-level brand params + +Keep URL-level brand as-is — URLs are shareable links. The resolution order for the `brandId` param passed to `getActiveBrandId` is: + +1. URL `brandId` param (if present) +2. `active_brand_id` cookie +3. `adminUser.brand_id` (legacy single brand) +4. First of `adminUser.brand_ids` +5. (platform_admin only) `null` → "all brands" + +## Migration: `supabase/migrations/204_multi_brand_admin.sql` + +```sql +-- 1. Add multi_brand_admin to role CHECK constraint +ALTER TABLE admin_users DROP CONSTRAINT IF EXISTS admin_users_role_check; +ALTER TABLE admin_users ADD CONSTRAINT admin_users_role_check + CHECK (role IN ('platform_admin', 'brand_admin', 'multi_brand_admin', 'store_employee', 'staff')); + +-- 2. Create junction table +CREATE TABLE admin_user_brands ( + admin_user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE, + brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, + added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + added_by UUID REFERENCES admin_users(id), + PRIMARY KEY (admin_user_id, brand_id) +); +CREATE INDEX admin_user_brands_brand_id_idx ON admin_user_brands(brand_id); + +-- 3. Backfill from existing brand_id (single-brand admins) +INSERT INTO admin_user_brands (admin_user_id, brand_id) +SELECT id, brand_id FROM admin_users +WHERE brand_id IS NOT NULL +ON CONFLICT DO NOTHING; + +-- 4. Promote anyone with > 1 brand to multi_brand_admin +UPDATE admin_users +SET role = 'multi_brand_admin' +WHERE role = 'brand_admin' + AND id IN ( + SELECT admin_user_id FROM admin_user_brands + GROUP BY admin_user_id HAVING COUNT(*) > 1 + ); + +-- 5. New RPCs for adding/removing brand access +CREATE OR REPLACE FUNCTION add_admin_user_brand( + p_admin_user_id UUID, p_brand_id UUID, p_added_by UUID +) RETURNS void LANGUAGE sql SECURITY DEFINER AS $$ + INSERT INTO admin_user_brands (admin_user_id, brand_id, added_by) + VALUES (p_admin_user_id, p_brand_id, p_added_by) + ON CONFLICT DO NOTHING; + UPDATE admin_users SET role = 'multi_brand_admin' + WHERE id = p_admin_user_id + AND role = 'brand_admin' + AND (SELECT COUNT(*) FROM admin_user_brands WHERE admin_user_id = p_admin_user_id) > 1; +$$; + +CREATE OR REPLACE FUNCTION remove_admin_user_brand( + p_admin_user_id UUID, p_brand_id UUID +) RETURNS void LANGUAGE sql SECURITY DEFINER AS $$ + DELETE FROM admin_user_brands + WHERE admin_user_id = p_admin_user_id AND brand_id = p_brand_id; + UPDATE admin_users SET role = 'brand_admin' + WHERE id = p_admin_user_id + AND role = 'multi_brand_admin' + AND (SELECT COUNT(*) FROM admin_user_brands WHERE admin_user_id = p_admin_user_id) = 1; +$$; +``` + +The `admin_users.brand_id` column is **kept** for backwards compat. A follow-up migration drops it after we verify nothing reads it. + +## Error Handling & Security Boundaries + +### Three failure modes + +1. **Requested brand not in `brand_ids`** (e.g., URL param has a brand the admin doesn't have): + - `getActiveBrandId()` returns `null` + - Server action returns `{ success: false, error: "Brand access required" }` + - Page renders `` with: "You don't have access to that brand. [Switch to a brand you have access to]" + +2. **Cookie brand no longer in `brand_ids`** (admin's access was revoked while cookie was still set): + - `getActiveBrandId()` falls through to `adminUser.brand_id`, then first of `brand_ids` + - Silent recovery — no error, no UI flash + - The dropped brand just disappears from the dropdown next page load + +3. **Platform admin acting on a brand they don't own:** + - Platform admin: `brand_ids = ["*"]` (sentinel) — RPCs treat as "all brands" + - Server action: never blocks platform admin from any brand + - This is intentional — platform admin = superuser + +### Validation placement (defense in depth) + +- Server action: `getActiveBrandId(adminUser, requested)` validates. +- Server action: `assertBrandAccess(adminUser, brandIdFromUrl)` validates (separate, for cases where the brandId comes from URL/form/RPC return rather than `getActiveBrandId`). +- RPC: still trusts `p_brand_id` (SECURITY DEFINER) — application layer is the gate, matching the existing architecture. + +### Audit logging (additive) + +- `admin_user_brands.added_by` column tracks who added an admin to a brand. +- Audit log entry on add/remove: out of scope for v1; documented for follow-up. + +## Testing + +### Unit tests (`vitest` — new dev dependency) + +- `src/lib/brand-scope.test.ts`: + - `resolveActiveBrandId(platformAdmin, "X")` → `"X"` + - `resolveActiveBrandId(brandAdmin, "X")` where X is their brand → `"X"` + - `resolveActiveBrandId(brandAdmin, "Y")` where Y is not their brand → `null` + - `getActiveBrandId(multiBrandAdmin)` with cookie set to valid brand → that brand + - `getActiveBrandId(multiBrandAdmin)` with cookie set to revoked brand → first of `brand_ids` + - `assertBrandAccess(...)` throws for non-platform-admin with invalid brand + - `setActiveBrand(null)` rejected for non-platform-admin + - `setActiveBrand("X")` rejected for admin without X in `brand_ids` + +### Integration tests (Playwright — already in repo) + +- `tests/admin/multi-brand.spec.ts`: + - As `multi_brand_admin`, dropdown shows 2+ brands + - Click brand B → URL stays, cookie updates, page data refreshes to brand B + - Direct-navigate to `/admin/orders?brand=` for a brand admin returns access denied + - As `platform_admin`, "All brands" option is present and works + - As `brand_admin` with 1 brand, no dropdown is shown + +### Migration smoke test (manual, documented in MEMORY.md) + +- Before migration: 5 brand_admins exist, each with 1 brand +- After migration: 5 rows in `admin_user_brands`, all `role = 'brand_admin'` +- Create a 6th admin with 2 brands via `add_admin_user_brand` → `role = 'multi_brand_admin'`, 2 rows in junction +- Remove one of their brands via `remove_admin_user_brand` → `role` demotes to `brand_admin`, 1 row in junction + +## Out of Scope (v1) + +- Per-(admin, brand) permission overrides +- Brand-group / parent-org concept +- "Last accessed brand" auto-redirect +- UI for managing `admin_user_brands` rows (Supabase Studio works for now) +- Dropping the legacy `admin_users.brand_id` column (follow-up `220_*` migration) +- Audit log entries for add/remove (junction's `added_by` column is the seed) + +## Implementation Order + +1. Migration `204_multi_brand_admin.sql` applied +2. `src/lib/brand-scope.ts` + unit tests (TDD) +3. `AdminUser` type updated; `getAdminUser()` returns `brand_ids` +4. `setActiveBrand` server action + tests +5. `listBrandsForAdmin` server function + tests +6. BrandSelector UI component +7. Wire BrandSelector into AdminHeader +8. Server action migration (~30 actions) — mechanical one-line swap each +9. Page server component migration (~10+ pages) — same pattern +10. Playwright integration tests +11. Manual smoke test of the migration on dev DB diff --git a/inspect_xlsx.py b/inspect_xlsx.py new file mode 100644 index 0000000..34aa250 --- /dev/null +++ b/inspect_xlsx.py @@ -0,0 +1,14 @@ +import openpyxl +path = "/home/coder/dev/x1/kyle/route_commerce-main/Tuxedo_Corn_2026_Tour_Schedule-3.xlsx" +wb = openpyxl.load_workbook(path, data_only=True) +for name in wb.sheetnames: + ws = wb[name] + print(f"=== SHEET: {name} ({ws.max_row} rows x {ws.max_column} cols) ===") + for row in ws.iter_rows(values_only=False): + for cell in row: + if cell.value is not None: + v = str(cell.value) + if len(v) > 200: + v = v[:200] + "..." + print(f" {cell.coordinate}: {v!r}") + print() diff --git a/scripts/e2e-test.sh b/scripts/e2e-test.sh new file mode 100755 index 0000000..a526e00 --- /dev/null +++ b/scripts/e2e-test.sh @@ -0,0 +1,99 @@ +#!/bin/bash +# End-to-end validation test for the local Postgres + PostgREST + MinIO + Next.js stack +# Exit 0 = all green, exit 1 = at least one failure. + +set -e +API="http://localhost:3001" +WEB="http://localhost:4000" + +pass=0 +fail=0 +declare -a FAILURES + +check() { + local name="$1" url="$2" expected="${3:-200}" cookies="${4:-}" + local cmd="curl -s -o /dev/null -w '%{http_code}'" + if [ -n "$cookies" ]; then cmd="$cmd -b \"$cookies\""; fi + local code=$(eval "$cmd $url") + if [ "$code" = "$expected" ]; then + echo " PASS $name ($code)" + pass=$((pass+1)) + else + echo " FAIL $name expected=$expected got=$code url=$url" + fail=$((fail+1)) + FAILURES+=("$name") + fi +} + +echo "=== Postgres connection ===" +PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT 'ok';" >/dev/null +echo " PASS postgres responds" +pass=$((pass+1)) + +echo "=== DB integrity ===" +TABLE_COUNT=$(PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT count(*) FROM information_schema.tables WHERE table_schema='public';") +[ "$TABLE_COUNT" -ge 65 ] && echo " PASS $TABLE_COUNT public tables (>=65)" && pass=$((pass+1)) || { echo " FAIL $TABLE_COUNT tables"; fail=$((fail+1)); } + +FN_COUNT=$(PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT count(*) FROM pg_proc p JOIN pg_namespace n ON p.pronamespace=n.oid WHERE n.nspname='public';") +[ "$FN_COUNT" -ge 250 ] && echo " PASS $FN_COUNT functions (>=250)" && pass=$((pass+1)) || { echo " FAIL $FN_COUNT functions"; fail=$((fail+1)); } + +BRANDS=$(PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT count(*) FROM brands;") +[ "$BRANDS" -ge 2 ] && echo " PASS $BRANDS brands" && pass=$((pass+1)) || { echo " FAIL $BRANDS brands"; fail=$((fail+1)); } + +STOPS=$(PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT count(*) FROM stops WHERE active=true AND deleted_at IS NULL;") +[ "$STOPS" -ge 200 ] && echo " PASS $STOPS active stops" && pass=$((pass+1)) || { echo " FAIL $STOPS stops"; fail=$((fail+1)); } + +# No test brands +TEST_BRANDS=$(PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT count(*) FROM brands WHERE slug IN ('sunrise-farms','green-valley','orchard-fresh');") +[ "$TEST_BRANDS" = "0" ] && echo " PASS no test brands" && pass=$((pass+1)) || { echo " FAIL test brands found"; fail=$((fail+1)); } + +echo "=== Tuxedo Corn data ===" +PHONE=$(PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT phone FROM brand_settings WHERE brand_id='64294306-5f42-463d-a5e8-2ad6c81a96de';") +[ "$PHONE" = "970-323-6874" ] && echo " PASS phone=$PHONE" && pass=$((pass+1)) || { echo " FAIL phone=$PHONE (expected 970-323-6874)"; fail=$((fail+1)); } + +LOGOS=$(PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT count(*) FROM brand_settings WHERE brand_id='64294306-5f42-463d-a5e8-2ad6c81a96de' AND logo_url LIKE '/storage/%';") +[ "$LOGOS" = "1" ] && echo " PASS logo_url is /storage/ path" && pass=$((pass+1)) || { echo " FAIL logo_url not /storage/"; fail=$((fail+1)); } + +echo "=== PostgREST ===" +check "GET /" "$API/" +check "GET /brands" "$API/brands?select=id,name,slug&order=name" +check "GET /brands?slug=eq.tuxedo" "$API/brands?slug=eq.tuxedo&select=*" +check "GET /stops" "$API/stops?select=id,city&limit=5" +check "GET /products" "$API/products?select=id,name&limit=5" +check "POST rpc get_brand_settings_by_slug" "$API/rpc/get_brand_settings_by_slug" 200 \ + "-X POST -H Content-Type:application/json -d {\"p_brand_slug\":\"tuxedo\"}" +check "POST rpc get_brand_plan_info" "$API/rpc/get_brand_plan_info" 200 \ + "-X POST -H Content-Type:application/json -d {\"p_brand_id\":\"64294306-5f42-463d-a5e8-2ad6c81a96de\"}" +check "POST rpc get_public_stops_for_brand" "$API/rpc/get_public_stops_for_brand" 200 \ + "-X POST -H Content-Type:application/json -d {\"p_brand_slug\":\"tuxedo\"}" +check "GET /admin_users with brand join" "$API/admin_users?select=id,user_id,role,brand_id,brands(name)&limit=5" +check "GET /stops with brand join" "$API/stops?select=id,city,brand_id,brands(name)&limit=5" + +echo "=== MinIO ===" +check "Storage logo.png" "$WEB/storage/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/logo.png" +check "Storage olathe-sweet-logo.png" "$WEB/storage/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo.png" +check "Storage olathe-sweet-logo-dark.png" "$WEB/storage/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo-dark.png" + +echo "=== Public storefronts ===" +check "GET /" "$WEB/" +check "GET /tuxedo" "$WEB/tuxedo" +check "GET /tuxedo/about" "$WEB/tuxedo/about" +check "GET /tuxedo/stops" "$WEB/tuxedo/stops" +check "GET /indian-river-direct" "$WEB/indian-river-direct" +check "GET /login" "$WEB/login" + +echo "=== Admin pages (dev_session=platform_admin) ===" +for p in /admin /admin/products /admin/stops /admin/orders /admin/users /admin/settings /admin/settings/billing /admin/settings/apps /admin/settings/payments /admin/communications /admin/communications/compose /admin/time-tracking /admin/wholesale /admin/water-log /admin/analytics /admin/reports; do + check "GET $p" "$WEB$p" 200 "dev_session=platform_admin" +done + +echo "" +echo "=== Summary ===" +echo " PASS: $pass" +echo " FAIL: $fail" +if [ $fail -gt 0 ]; then + echo " Failures:" + for f in "${FAILURES[@]}"; do echo " - $f"; done + exit 1 +fi +echo " ALL GREEN" diff --git a/scripts/seed_tuxedo_tour.py b/scripts/seed_tuxedo_tour.py new file mode 100644 index 0000000..f486b94 --- /dev/null +++ b/scripts/seed_tuxedo_tour.py @@ -0,0 +1,346 @@ +#!/usr/bin/env python3 +""" +seed_tuxedo_tour.py + +Parses Tuxedo_Corn_2026_Tour_Schedule-3.xlsx and seeds the `stops` table for +the Tuxedo brand via the admin_create_stops_batch RPC. + +Skips: + - Title / subtitle / legend rows (rows 1-3) + - Week header rows (col A = "Wk N", col D-J empty) + - Cross-Dock / Monday OFF rows (col D contains "OFF" or "Cross-Dock") + +Joins with the Stop Directory sheet to enrich each stop with: + - address, phone, contact + +Usage: + python3 scripts/seed_tuxedo_tour.py --dry-run # show what would be inserted + python3 scripts/seed_tuxedo_tour.py # actually insert + +Requires: + - supabase CLI linked to project wnzkhezyhnfzhkhiflrp +""" +import argparse +import json +import re +import subprocess +import sys +from pathlib import Path + +from openpyxl import load_workbook + +TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de" +YEAR = 2026 + +MONTH_MAP = { + "Jan": "01", "Feb": "02", "Mar": "03", "Apr": "04", + "May": "05", "Jun": "06", "Jul": "07", "Aug": "08", + "Sep": "09", "Oct": "10", "Nov": "11", "Dec": "12", +} + +DEFAULT_XLSX = ( + "/home/coder/dev/x1/kyle/route_commerce-main/" + "Tuxedo_Corn_2026_Tour_Schedule-3.xlsx" +) + + +def parse_excel_date(s): + """'Jul 22' -> '2026-07-22'""" + if not s: + return None + m = re.match(r"^([A-Za-z]{3})\s+(\d{1,2})$", str(s).strip()) + if not m: + return None + mm = MONTH_MAP.get(m.group(1)) + if not mm: + return None + return f"{YEAR}-{mm}-{int(m.group(2)):02d}" + + +def parse_time_range(s): + """'10:00 AM - 1:00 PM' -> '10:00 AM' (start time)""" + if not s: + return "" + cleaned = re.sub(r"[–—]", "-", str(s)).strip() + cleaned = re.sub(r"\s+", " ", cleaned) + m = re.match(r"^(\d{1,2}:\d{2}\s*[AP]M)", cleaned, re.IGNORECASE) + return m.group(1).upper().replace(" ", " ") if m else cleaned + + +def split_city_state(s): + """'Cheyenne, WY' -> ('Cheyenne', 'WY')""" + if not s: + return "", "" + parts = [p.strip() for p in str(s).split(",")] + if len(parts) == 1: + return parts[0], "" + return parts[0], parts[1] + + +def slugify(s): + s = (s or "").lower() + s = re.sub(r"[^a-z0-9]+", "-", s) + return s.strip("-") + + +def is_week_header(row): + a = str(row[0] or "").strip() + d = str(row[3] or "").strip() + return re.match(r"^Wk\s", a) and d == "" + + +def is_off_row(row): + d = str(row[3] or "").strip() + return "OFF" in d or "Cross-Dock" in d or "Cross‑Dock" in d + + +def is_data_row(row): + d = str(row[3] or "").strip() + e = str(row[4] or "").strip() + if not d or not e: + return False + if "," not in e: + return False + return True + + +def load(xlsx_path): + wb = load_workbook(xlsx_path, data_only=True) + schedule = wb["Full Schedule"] + directory = wb["Stop Directory"] + + # Build Stop Directory lookup: (truck, host_normalized) -> {address, phone, contact, ...} + dir_map = {} + for row in directory.iter_rows(min_row=2, values_only=True): + truck = str(row[0] or "").strip() + city = str(row[1] or "").strip() + state = str(row[2] or "").strip() + host = str(row[3] or "").strip() + address = str(row[4] or "").strip() + phone = str(row[5] or "").strip() + contact = str(row[6] or "").strip() + if not truck or not host: + continue + key = f"{truck}|{host.lower()}" + dir_map[key] = { + "city": city, "state": state, "host": host, + "address": address, "phone": phone, "contact": contact, + } + + # Read Full Schedule (skip first 3 title/subtitle/legend rows) + stops = [] + skipped = {"weekHeader": 0, "off": 0, "invalid": 0} + for row in schedule.iter_rows(min_row=4, values_only=True): + # Trim to 10 cols + cells = [("" if v is None else str(v).strip()) for v in row[:10]] + if is_week_header(cells): + skipped["weekHeader"] += 1 + continue + if is_off_row(cells): + skipped["off"] += 1 + continue + if not is_data_row(cells): + skipped["invalid"] += 1 + continue + + wk, region, date_text, day, city_state, host, time, truck, status, notes = cells + date_iso = parse_excel_date(date_text) + if not date_iso: + skipped["invalid"] += 1 + continue + city, state = split_city_state(city_state) + if not city: + skipped["invalid"] += 1 + continue + + # Enrich from directory + dir_key = f"{truck}|{host.lower()}" + d = dir_map.get(dir_key) + + stops.append({ + "week": wk, + "region": region, + "date": date_iso, + "day": day, + "city": city, + "state": state or (d["state"] if d else ""), + "location": host, + "time": parse_time_range(time), + "time_range": time, + "truck": truck, + "status_text": status, + "notes": notes, + "address": d["address"] if d and d["address"] else None, + "phone": d["phone"] if d and d["phone"] else None, + "contact": d["contact"] if d and d["contact"] else None, + }) + + return stops, skipped, len(dir_map) + + +def assign_slugs(stops, dry_run): + used = set() + if not dry_run: + out = subprocess.run( + ["supabase", "db", "query", "--linked", + f"SELECT slug FROM stops WHERE brand_id = '{TUXEDO_BRAND_ID}';"], + capture_output=True, text=True, timeout=120, + ) + # Parse the table output - slugs are in second column between │ + for m in re.finditer(r"│\s*([a-z0-9][a-z0-9-]*)\s*│", out.stdout): + used.add(m.group(1)) + + for s in stops: + base = f"{slugify(s['city'])}-{s['date']}" + slug = base + n = 0 + while slug in used: + n += 1 + slug = f"{base}-{n}" + used.add(slug) + s["slug"] = slug + + +def to_rpc_row(s): + return { + "city": s["city"], + "state": s["state"], + "location": s["location"], + "date": f"{s['date']} 00:00:00+00", + "time": s["time"], + "address": s["address"], + "zip": None, + "cutoff_time": None, + # active=true so the stops appear on the public storefront immediately. + # Matches the behavior of publishStop in src/actions/stops.ts. + "active": True, + } + + +def build_payload_json(batch): + """Build a clean JSON string for use in a SQL file.""" + return json.dumps(batch, ensure_ascii=False) + + +def insert_batch(batch): + """Write SQL to a temp file and execute via --file to avoid shell escaping.""" + payload_json = build_payload_json(batch) + sql = ( + f"SELECT admin_create_stops_batch(" + f"'{TUXEDO_BRAND_ID}'::uuid, " + f"$${payload_json}$$::jsonb);\n" + ) + # Write to temp file + tmp_path = Path("/tmp/seed_tuxedo_tour.sql") + tmp_path.write_text(sql, encoding="utf-8") + try: + proc = subprocess.run( + ["supabase", "db", "query", "--linked", "--file", str(tmp_path)], + capture_output=True, text=True, timeout=300, + ) + finally: + tmp_path.unlink(missing_ok=True) + if proc.returncode != 0: + raise RuntimeError(f"RPC failed: {proc.stderr[:800]}") + return proc.stdout + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--dry-run", action="store_true") + ap.add_argument("--xlsx", default=DEFAULT_XLSX) + args = ap.parse_args() + + if not Path(args.xlsx).exists(): + sys.exit(f"XLSX not found: {args.xlsx}") + + stops, skipped, dir_count = load(args.xlsx) + assign_slugs(stops, dry_run=args.dry_run) + + print(f"\nParsed {len(stops)} stops " + f"(skipped: {skipped['weekHeader']} week-headers, " + f"{skipped['off']} OFF days, {skipped['invalid']} invalid)") + print(f"Stop Directory: {dir_count} entries loaded for enrichment\n") + + if not stops: + sys.exit("No stops to insert.") + + print("Sample (first 3):") + for s in stops[:3]: + print(f" {s['date']} {s['time']:10s} | {s['city']:18s}, {s['state']:2s} | " + f"{s['location'][:35]:35s} | {s['truck']} | {s['status_text']} | {s['slug']}") + if s["notes"]: + print(f" notes: {s['notes'][:120]}") + if s["address"]: + print(f" addr: {s['address']} ph: {s['phone']} ctc: {s['contact']}") + print() + + # Show counts by week and region + by_week = {} + by_region = {} + by_truck = {} + for s in stops: + by_week[s["week"]] = by_week.get(s["week"], 0) + 1 + by_region[s["region"]] = by_region.get(s["region"], 0) + 1 + by_truck[s["truck"]] = by_truck.get(s["truck"], 0) + 1 + print("By week:", dict(sorted(by_week.items()))) + print("By region:", by_region) + print("By truck:", by_truck) + print() + + # Date range + dates = sorted(s["date"] for s in stops) + print(f"Date range: {dates[0]} to {dates[-1]}\n") + + if args.dry_run: + batches = (len(stops) + 49) // 50 + print(f"[DRY RUN] Would insert {len(stops)} stops in {batches} batch(es) of 50.") + return + + BATCH = 50 + total = 0 + batches = (len(stops) + BATCH - 1) // BATCH + for i in range(0, len(stops), BATCH): + batch = [to_rpc_row(s) for s in stops[i:i + BATCH]] + bnum = i // BATCH + 1 + sys.stdout.write(f" Inserting batch {bnum}/{batches} ({len(batch)} stops)... ") + sys.stdout.flush() + try: + insert_batch(batch) + total += len(batch) + print("OK") + except Exception as e: + print("FAIL") + print(f" {e}") + + # The batch RPC hardcodes status='draft' on insert. The Tuxedo storefront + # page only filters on active=true (not status), so active=true is enough + # to make stops visible. But for consistency with the publishStop server + # action — which sets both — flip status to 'active' for the rows we just + # inserted. Slug-based so we only touch stops from this run, not the + # pre-existing "Olathe" test stop. + if total > 0: + slugs = [s["slug"] for s in stops] + # Build a safe IN list (slug is a text column) + slug_list = ", ".join(f"'{slug.replace(chr(39), chr(39)+chr(39))}'" for slug in slugs) + publish_sql = ( + f"UPDATE stops SET status = 'active' " + f"WHERE brand_id = '{TUXEDO_BRAND_ID}' " + f"AND slug IN ({slug_list});" + ) + tmp = Path("/tmp/seed_tuxedo_publish.sql") + tmp.write_text(publish_sql, encoding="utf-8") + try: + subprocess.run( + ["supabase", "db", "query", "--linked", "--file", str(tmp)], + capture_output=True, text=True, timeout=120, + ) + print(f"\n Published {total} stops (status -> 'active').") + finally: + tmp.unlink(missing_ok=True) + + print(f"\nDone. Inserted {total}/{len(stops)} stops for Tuxedo brand.") + + +if __name__ == "__main__": + main() diff --git a/src/actions/ai-import.ts b/src/actions/ai-import.ts index ea30d5c..c276886 100644 --- a/src/actions/ai-import.ts +++ b/src/actions/ai-import.ts @@ -1,6 +1,7 @@ "use server"; import { getAdminUser } from "@/lib/admin-permissions"; +import { assertBrandAccess } from "@/lib/brand-scope"; import { parseExcelBuffer, parseTextBuffer } from "@/lib/excel-parser"; import { importProductsBatch } from "@/actions/import-products"; import { importOrdersBatch } from "@/actions/import-orders"; @@ -41,7 +42,9 @@ export async function analyzeImport( ): Promise { const adminUser = await getAdminUser(); if (!adminUser) return { success: false, error: "Not authenticated" }; - if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) { + try { + assertBrandAccess(adminUser, brandId); + } catch { return { success: false, error: "Not authorized for this brand" }; } diff --git a/src/actions/analytics.ts b/src/actions/analytics.ts index 08387e8..f62fa72 100644 --- a/src/actions/analytics.ts +++ b/src/actions/analytics.ts @@ -1,6 +1,7 @@ "use server"; import { getAdminUser } from "@/lib/admin-permissions"; +import { getActiveBrandId } from "@/lib/brand-scope"; import { svcHeaders } from "@/lib/svc-headers"; const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!; @@ -96,7 +97,7 @@ async function brandScopedRPC( const adminUser = await getAdminUser(); if (!adminUser) throw new Error("Not authenticated"); - const brandId = adminUser.brand_id ?? null; + const brandId = await getActiveBrandId(adminUser); const response = await fetch(`${supabaseUrl}/rest/v1/rpc/${rpcName}`, { method: "POST", @@ -232,7 +233,7 @@ export async function getRecentOrders(limit: number = 10): Promise { const adminUser = await getAdminUser(); if (!adminUser) throw new Error("Not authenticated"); - const brandId = adminUser.brand_id ?? null; + const brandId = await getActiveBrandId(adminUser); const params = new URLSearchParams({ select: "id,status", diff --git a/src/actions/billing/retail-payment-intent.ts b/src/actions/billing/retail-payment-intent.ts new file mode 100644 index 0000000..90511b9 --- /dev/null +++ b/src/actions/billing/retail-payment-intent.ts @@ -0,0 +1,123 @@ +"use server"; + +import { svcHeaders } from "@/lib/svc-headers"; + +/** + * Creates a Stripe PaymentIntent for the supplied cart so the browser + * can confirm the payment with embedded Stripe Elements (Apple Pay / + * Google Pay / card) without redirecting to a hosted page. + * + * Mirrors `createRetailStripeCheckoutSession` in `retail-checkout.ts`: + * the PaymentIntent is the embedded equivalent of the Checkout Session. + * Order creation itself still happens on `/checkout/success` so the + * webhooks and order pipeline don't need to know which path the buyer + * used. + */ +type LineItem = { + id: string; + name: string; + price: number; + quantity: number; +}; + +type CustomerInfo = { + name?: string; + email?: string; +}; + +type CreatePaymentIntentResult = + | { success: true; clientSecret: string; paymentIntentId: string; amount: number } + | { success: false; error: string }; + +export async function createRetailPaymentIntent( + items: LineItem[], + customer: CustomerInfo, + brandId: string | null, + stopId: string | null, + shippingAddress?: { state?: string; postal_code?: string; city?: string } | null +): Promise { + const stripeKey = process.env.STRIPE_SECRET_KEY; + if (!stripeKey) { + return { success: false, error: "Stripe not configured on this server." }; + } + + if (!Array.isArray(items) || items.length === 0) { + return { success: false, error: "Cart is empty." }; + } + + const Stripe = (await import("stripe")).default; + const stripe = new Stripe(stripeKey, { apiVersion: "2026-04-22.dahlia" as any }); + + // Compute the subtotal in cents. We don't compute sales tax here — + // Stripe's `automatic_tax` would be ideal but requires address collection + // client-side; for this single-product corn box the price is tax-inclusive + // (see Tuxedo Corn product card) so we treat the line total as final. + const amount = items.reduce((sum, item) => { + const qty = Math.max(1, Math.floor(item.quantity || 1)); + const unit = Math.max(0, Math.round(Number(item.price) * 100)); + return sum + unit * qty; + }, 0); + + if (amount <= 0) { + return { success: false, error: "Cart total must be greater than $0." }; + } + + // Pull the brand name for Stripe receipts + metadata + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; + const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + let brandName = "Route Commerce"; + if (supabaseUrl && supabaseKey && brandId) { + try { + const brandRes = await fetch( + `${supabaseUrl}/rest/v1/brands?id=eq.${brandId}&select=name`, + { headers: { ...svcHeaders(supabaseKey) } } + ); + const brands = (await brandRes.json()) as Array<{ name: string }>; + if (brands?.[0]?.name) brandName = brands[0].name; + } catch { + // ignore — use default + } + } + + // Build a short human-readable description for the Stripe dashboard + const description = items + .slice(0, 3) + .map((i) => `${i.quantity}× ${i.name}`) + .join(", "); + + try { + const intent = await stripe.paymentIntents.create({ + amount, + currency: "usd", + automatic_payment_methods: { enabled: true }, + receipt_email: customer.email || undefined, + description, + metadata: { + brand_id: brandId ?? "unknown", + brand_name: brandName, + stop_id: stopId ?? "", + customer_name: customer.name ?? "", + customer_email: customer.email ?? "", + shipping_state: shippingAddress?.state ?? "", + shipping_postal_code: shippingAddress?.postal_code ?? "", + shipping_city: shippingAddress?.city ?? "", + item_count: String(items.reduce((s, i) => s + i.quantity, 0)), + source: "storefront_express", + }, + }); + + if (!intent.client_secret) { + return { success: false, error: "Stripe did not return a client secret." }; + } + + return { + success: true, + clientSecret: intent.client_secret, + paymentIntentId: intent.id, + amount: intent.amount, + }; + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to create payment intent."; + return { success: false, error: message }; + } +} diff --git a/src/actions/brands.ts b/src/actions/brands.ts new file mode 100644 index 0000000..fdd5866 --- /dev/null +++ b/src/actions/brands.ts @@ -0,0 +1,65 @@ +import "server-only"; + +import { getAdminUser } from "@/lib/admin-permissions"; +import { svcHeaders } from "@/lib/svc-headers"; + +export type BrandListItem = { + id: string; + name: string; + slug: string; + logo_url: string | null; +}; + +/** + * Returns the list of brands the current admin user can act in. + * + * - platform_admin: all brands (queried directly from the `brands` table) + * - everyone else: brands in `adminUser.brand_ids` + * - empty array for unauthenticated / no-access admins + * + * This is a plain async function (not a server action) so it can be called + * from server components and server actions without the "use server" wrapper. + * The BrandSelector client component receives the result as a prop. + */ +export async function listBrandsForAdmin(): Promise { + const adminUser = await getAdminUser(); + if (!adminUser) return []; + + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; + const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + if (!supabaseUrl || !serviceKey) return []; + + if (adminUser.role === "platform_admin") { + try { + const res = await fetch( + `${supabaseUrl}/rest/v1/brands?select=id,name,slug,logo_url&order=name`, + { headers: svcHeaders(serviceKey), cache: "no-store" } + ); + if (!res.ok) return []; + const data = await res.json(); + return Array.isArray(data) ? data : []; + } catch { + return []; + } + } + + if (adminUser.brand_ids.length === 0) return []; + + // Use PostgREST `in` filter. The brand_ids are UUIDs, so the quoting + // pattern in the spec is safe; the inner quotes are required by PostgREST + // for UUID literals. + const filter = `id=in.(${adminUser.brand_ids + .map((id) => `"${id}"`) + .join(",")})`; + try { + const res = await fetch( + `${supabaseUrl}/rest/v1/brands?${filter}&select=id,name,slug,logo_url&order=name`, + { headers: svcHeaders(serviceKey), cache: "no-store" } + ); + if (!res.ok) return []; + const data = await res.json(); + return Array.isArray(data) ? data : []; + } catch { + return []; + } +} diff --git a/src/actions/communications/campaigns.ts b/src/actions/communications/campaigns.ts index 0ba1a7e..a367838 100644 --- a/src/actions/communications/campaigns.ts +++ b/src/actions/communications/campaigns.ts @@ -1,6 +1,7 @@ "use server"; import { getAdminUser } from "@/lib/admin-permissions"; +import { getActiveBrandId } from "@/lib/brand-scope"; import { svcHeaders } from "@/lib/svc-headers"; export type CampaignType = "marketing" | "operational" | "transactional"; @@ -57,7 +58,11 @@ export async function getCommunicationCampaigns(brandId?: string): Promise cookie > legacy > first of brand_ids) + const activeBrandId = await getActiveBrandId(adminUser, brandId); + if (!activeBrandId && adminUser.role !== "platform_admin") { + return { success: false, error: "Brand access required" }; + } + const effectiveBrandId = activeBrandId; // Brand scoping: brand_admin can only send their own brand's campaigns - if (adminUser.role === "brand_admin" && effectiveBrandId && adminUser.brand_id !== effectiveBrandId) { + if (adminUser.role === "brand_admin" && effectiveBrandId && !adminUser.brand_ids.includes(effectiveBrandId)) { return { success: false, error: "Not authorized to send this brand's campaigns" }; } diff --git a/src/actions/communications/templates.ts b/src/actions/communications/templates.ts index d88eb0a..f5d67e9 100644 --- a/src/actions/communications/templates.ts +++ b/src/actions/communications/templates.ts @@ -1,6 +1,7 @@ "use server"; import { getAdminUser } from "@/lib/admin-permissions"; +import { getActiveBrandId } from "@/lib/brand-scope"; import { svcHeaders } from "@/lib/svc-headers"; import type { AudienceRules } from "./campaigns"; @@ -33,7 +34,11 @@ export async function getCommunicationTemplates(brandId?: string): Promise<{ suc const adminUser = await getAdminUser(); if (!adminUser) return { success: false, error: "Not authenticated" }; - const effectiveBrandId = brandId ?? adminUser.brand_id ?? null; + const activeBrandId = await getActiveBrandId(adminUser, brandId); + if (!activeBrandId && adminUser.role !== "platform_admin") { + return { success: false, error: "Brand access required" }; + } + const effectiveBrandId = activeBrandId; // Brand scoping: brand_admin can only see their own brand's templates if (adminUser.role === "brand_admin" && effectiveBrandId && effectiveBrandId !== adminUser.brand_id) { @@ -112,7 +117,7 @@ export async function getTemplateById(templateId: string): Promise