Merge GitHub main into Gitea main
Deploy to route.crispygoat.com / deploy (push) Failing after 7s
Build / build (push) Has been cancelled

Brings in:
- .gitea/workflows/build.yml (build + typecheck + lint on self-hosted runner)
- scripts/e2e-test.sh (local Postgres + PostgREST + MinIO + Next.js validation)
- Codex review round 2 fixes (buyer/billing/comms/a11y)
- 207 multi-brand admin migration
- Locations table + UI
- Various product/stops/auth refinements

Resolved 7 conflicts by taking Gitea's version to preserve production
behavior:
- package.json (deps)
- src/actions/products/upload-image.ts (storage)
- src/app/admin/taxes/page.tsx
- src/app/checkout/CheckoutClient.tsx
- src/components/admin/AdminSidebar.tsx
- src/components/admin/StopProductAssignment.tsx
- src/lib/admin-permissions.ts

Our new dev_session auth + MinIO storage changes are deferred to a
focused follow-up to avoid breaking the production deploy.
This commit is contained in:
2026-06-05 23:09:51 +00:00
79 changed files with 12589 additions and 857 deletions
@@ -0,0 +1,9 @@
-- Add seasonal availability date fields to products table
-- Allows products to have start/end availability dates (e.g., "Available mid-July through mid-September")
ALTER TABLE products ADD COLUMN IF NOT EXISTS available_from TIMESTAMPTZ;
ALTER TABLE products ADD COLUMN IF NOT EXISTS available_until TIMESTAMPTZ;
-- Add comment for documentation
COMMENT ON COLUMN products.available_from IS 'Start date for product availability (seasonal)';
COMMENT ON COLUMN products.available_until IS 'End date for product availability (seasonal)';
@@ -0,0 +1,70 @@
-- Migration 202: Fix delete_stop RPC — orders table has no deleted_at column
--
-- The delete_stop RPC added in migration 075 guards against deleting a stop
-- that has active orders:
--
-- SELECT COUNT(*) INTO v_active_order_count
-- FROM orders o JOIN stops s ON o.stop_id = s.id
-- WHERE s.id = p_stop_id
-- AND o.pickup_complete = false
-- AND o.deleted_at IS NULL; -- <-- column does not exist on orders
--
-- The orders table never got a deleted_at column, so the SELECT raises
-- "column o.deleted_at does not exist" and the RPC returns HTTP 400 from
-- PostgREST. Stop deletion has been silently broken since 075 landed.
--
-- Fix: drop the o.deleted_at IS NULL clause. The pickup_complete = false
-- guard is sufficient for now (no soft-deleted orders can exist).
-- If/when orders get a deleted_at column, re-add the clause.
--
-- This migration only touches the function. Schema is unchanged.
CREATE OR REPLACE FUNCTION public.delete_stop(p_stop_id UUID, p_brand_id UUID DEFAULT NULL)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_stop RECORD;
v_active_order_count INT;
BEGIN
-- Lock the stop row for update
SELECT * INTO v_stop FROM stops WHERE id = p_stop_id FOR UPDATE;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Stop not found');
END IF;
-- Brand scoping: brand_admin can only delete their own stops
IF p_brand_id IS NOT NULL AND v_stop.brand_id != p_brand_id THEN
RETURN jsonb_build_object('success', false, 'error', 'Stop not found');
ELSIF p_brand_id IS NULL AND v_stop.brand_id IS NOT NULL THEN
-- Caller didn't pass brand_id but stop has one — guard against platform admin accident
RETURN jsonb_build_object('success', false, 'error', 'Stop not found');
END IF;
-- Guard: refuse to delete a stop that still has active (un-picked-up) orders.
-- Note: orders has no deleted_at column (hard delete pattern) so we only
-- check pickup_complete. Re-add `AND o.deleted_at IS NULL` if/when orders
-- grows a soft-delete column.
SELECT COUNT(*) INTO v_active_order_count
FROM orders o
JOIN stops s ON o.stop_id = s.id
WHERE s.id = p_stop_id
AND o.pickup_complete = false;
IF v_active_order_count > 0 THEN
RETURN jsonb_build_object(
'success', false,
'error', 'Cannot delete stop — it has ' || v_active_order_count || ' active order(s). Mark orders as picked up first.'
);
END IF;
-- Soft delete
UPDATE stops SET deleted_at = NOW() WHERE id = p_stop_id;
RETURN jsonb_build_object('success', true);
END;
$$;
-- Grants are inherited from 075; verify they're present (no-op if so).
GRANT EXECUTE ON FUNCTION public.delete_stop(UUID, UUID) TO anon, authenticated, service_role;
+470
View File
@@ -0,0 +1,470 @@
-- Migration 203: Locations table (reusable venues) + admin RPCs
--
-- Purpose
-- -------
-- Today, "where a stop happens" is captured in 3 denormalized fields on the
-- stops table:
-- * location (venue name, e.g. "Tractor Supply")
-- * address (street address, e.g. "13778 E I-25 Frontage Rd")
-- * phone (was never on stops — only enriched from the xlsx Stop Directory)
--
-- That works for one-off inserts but breaks down for the Tuxedo 2026 tour
-- where the same venue (Tractor Supply, Murdoch's, etc.) repeats 5-15 times
-- across the season with the same address/phone/contact. Editing the address
-- means updating N stops. Listing "all unique venues we visit" requires
-- SELECT DISTINCT with a GROUP BY, not a real table.
--
-- This migration introduces a `locations` table — the canonical venue record
-- — and links each stop to it via a nullable location_id FK. Existing
-- denormalized fields on stops are kept (backwards compatible) but new
-- admin paths should prefer location_id.
--
-- The 269 seeded Tuxedo stops are backfilled into 49 unique locations
-- (one per (brand_id, name, address) tuple) by a follow-up script —
-- this migration only creates the schema and RPCs.
--
-- RPCs (all SECURITY DEFINER, all brand-scoped):
-- * admin_create_location — insert single location
-- * admin_create_locations_batch — insert many in one call (mirrors stops pattern)
-- * admin_update_location — partial update of name/address/phone/contact
-- * admin_delete_location — soft delete via deleted_at (mirrors stops)
-- * get_locations_for_brand — public read RPC (filters deleted_at, soft-publish)
-- * admin_attach_location_to_stop — link a stop to a location (idempotent)
BEGIN;
-- =============================================================================
-- 1. locations table
-- =============================================================================
CREATE TABLE IF NOT EXISTS public.locations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
brand_id UUID NOT NULL REFERENCES public.brands(id) ON DELETE CASCADE,
name TEXT NOT NULL,
address TEXT,
city TEXT,
state TEXT,
zip TEXT,
phone TEXT,
contact_name TEXT,
contact_email TEXT,
notes TEXT,
active BOOLEAN NOT NULL DEFAULT true,
deleted_at TIMESTAMPTZ DEFAULT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Slug per brand for nice URLs (locations/slug-{id}) — derived from name + zip
-- to disambiguate. Same collision-handling pattern as stops.
ALTER TABLE public.locations
ADD COLUMN IF NOT EXISTS slug TEXT;
CREATE UNIQUE INDEX IF NOT EXISTS idx_locations_brand_slug
ON public.locations (brand_id, slug)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_locations_brand_active
ON public.locations (brand_id, active)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_locations_brand_name
ON public.locations (brand_id, lower(name))
WHERE deleted_at IS NULL;
-- updated_at trigger (reuses the generic set_updated_at fn if present)
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_proc WHERE proname = 'set_updated_at') THEN
DROP TRIGGER IF EXISTS trg_locations_updated_at ON public.locations;
CREATE TRIGGER trg_locations_updated_at
BEFORE UPDATE ON public.locations
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
END IF;
END$$;
-- =============================================================================
-- 2. Link stops → locations
-- =============================================================================
ALTER TABLE public.stops
ADD COLUMN IF NOT EXISTS location_id UUID REFERENCES public.locations(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_stops_location_id
ON public.stops (location_id)
WHERE deleted_at IS NULL;
-- =============================================================================
-- 3. RLS for locations
-- =============================================================================
ALTER TABLE public.locations ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "platform_admin_read_locations" ON public.locations;
DROP POLICY IF EXISTS "brand_admin_read_locations" ON public.locations;
DROP POLICY IF EXISTS "platform_admin_all_locations" ON public.locations;
DROP POLICY IF EXISTS "brand_admin_all_locations" ON public.locations;
-- Platform admin sees all non-deleted
CREATE POLICY "platform_admin_read_locations" ON public.locations
FOR SELECT USING (
deleted_at IS NULL
AND EXISTS (
SELECT 1 FROM public.admin_users au
WHERE au.user_id = auth.uid()
AND au.role = 'platform_admin'
)
);
-- Brand admin / store_employee sees their own brand's non-deleted
CREATE POLICY "brand_admin_read_locations" ON public.locations
FOR SELECT USING (
deleted_at IS NULL
AND EXISTS (
SELECT 1 FROM public.admin_users au
WHERE au.user_id = auth.uid()
AND au.role IN ('brand_admin', 'store_employee')
AND au.brand_id = locations.brand_id
)
);
-- Writes go through SECURITY DEFINER RPCs (no direct INSERT/UPDATE/DELETE from anon)
-- so no INSERT/UPDATE/DELETE policies are granted to anon/authenticated.
-- =============================================================================
-- 4. RPCs
-- =============================================================================
-- ── 4a. admin_create_location ────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION public.admin_create_location(
p_brand_id UUID,
p_name TEXT,
p_address TEXT DEFAULT NULL,
p_city TEXT DEFAULT NULL,
p_state TEXT DEFAULT NULL,
p_zip TEXT DEFAULT NULL,
p_phone TEXT DEFAULT NULL,
p_contact_name TEXT DEFAULT NULL,
p_contact_email TEXT DEFAULT NULL,
p_notes TEXT DEFAULT NULL,
p_active BOOLEAN DEFAULT true
)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_id UUID;
v_slug TEXT;
v_slug_base TEXT;
v_counter INT := 0;
BEGIN
IF p_brand_id IS NULL THEN
RAISE EXCEPTION 'brand_id is required';
END IF;
IF p_name IS NULL OR length(trim(p_name)) = 0 THEN
RAISE EXCEPTION 'name is required';
END IF;
v_slug_base := lower(regexp_replace(trim(p_name), '[^a-z0-9]+', '-', 'g'))
|| COALESCE('-' || regexp_replace(COALESCE(p_zip, ''), '[^a-z0-9]+', '', 'gi'), '');
v_slug_base := trim(BOTH '-' FROM v_slug_base);
v_slug := v_slug_base;
WHILE EXISTS (SELECT 1 FROM locations WHERE brand_id = p_brand_id AND slug = v_slug AND deleted_at IS NULL) LOOP
v_counter := v_counter + 1;
v_slug := v_slug_base || '-' || v_counter;
END LOOP;
INSERT INTO public.locations (
brand_id, name, address, city, state, zip, phone,
contact_name, contact_email, notes, active, slug
) VALUES (
p_brand_id, trim(p_name),
NULLIF(trim(p_address), ''),
NULLIF(trim(p_city), ''),
NULLIF(trim(p_state), ''),
NULLIF(trim(p_zip), ''),
NULLIF(trim(p_phone), ''),
NULLIF(trim(p_contact_name), ''),
NULLIF(trim(p_contact_email), ''),
NULLIF(trim(p_notes), ''),
p_active, v_slug
)
RETURNING id INTO v_id;
RETURN jsonb_build_object('id', v_id, 'slug', v_slug);
EXCEPTION WHEN OTHERS THEN
RAISE EXCEPTION 'admin_create_location failed: % (SQLSTATE %)', SQLERRM, SQLSTATE;
END;
$$;
-- ── 4b. admin_create_locations_batch ─────────────────────────────────────────
-- Mirrors admin_create_stops_batch. p_locations: JSONB array of objects with
-- the same shape as the single-row params. Returns JSONB array of {id, slug}.
-- Transactional: any per-row failure rolls the whole batch back.
CREATE OR REPLACE FUNCTION public.admin_create_locations_batch(
p_brand_id UUID,
p_locations JSONB
)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_row JSONB;
v_result JSONB := '[]'::JSONB;
v_id UUID;
v_slug TEXT;
v_slug_base TEXT;
v_counter INT;
BEGIN
IF p_brand_id IS NULL THEN
RAISE EXCEPTION 'brand_id is required';
END IF;
IF p_locations IS NULL OR jsonb_typeof(p_locations) <> 'array' OR jsonb_array_length(p_locations) = 0 THEN
RETURN '[]'::JSONB;
END IF;
FOR v_row IN SELECT * FROM jsonb_array_elements(p_locations) LOOP
IF v_row->>'name' IS NULL OR length(trim(v_row->>'name')) = 0 THEN
RAISE EXCEPTION 'name is required for all locations';
END IF;
v_slug_base := lower(regexp_replace(trim(v_row->>'name'), '[^a-z0-9]+', '-', 'g'))
|| COALESCE('-' || regexp_replace(COALESCE(v_row->>'zip', ''), '[^a-z0-9]+', '', 'gi'), '');
v_slug_base := trim(BOTH '-' FROM v_slug_base);
v_slug := v_slug_base;
v_counter := 0;
WHILE EXISTS (SELECT 1 FROM locations WHERE brand_id = p_brand_id AND slug = v_slug AND deleted_at IS NULL) LOOP
v_counter := v_counter + 1;
v_slug := v_slug_base || '-' || v_counter;
END LOOP;
INSERT INTO public.locations (
brand_id, name, address, city, state, zip, phone,
contact_name, contact_email, notes, active, slug
) VALUES (
p_brand_id,
trim(v_row->>'name'),
NULLIF(trim(v_row->>'address'), ''),
NULLIF(trim(v_row->>'city'), ''),
NULLIF(trim(v_row->>'state'), ''),
NULLIF(trim(v_row->>'zip'), ''),
NULLIF(trim(v_row->>'phone'), ''),
NULLIF(trim(v_row->>'contact_name'), ''),
NULLIF(trim(v_row->>'contact_email'), ''),
NULLIF(trim(v_row->>'notes'), ''),
COALESCE((v_row->>'active')::BOOLEAN, true),
v_slug
)
RETURNING id INTO v_id;
v_result := v_result || jsonb_build_object('id', v_id, 'slug', v_slug)::JSONB;
END LOOP;
RETURN v_result;
EXCEPTION WHEN OTHERS THEN
RAISE EXCEPTION 'admin_create_locations_batch failed: % (SQLSTATE %)', SQLERRM, SQLSTATE;
END;
$$;
-- ── 4c. admin_update_location ────────────────────────────────────────────────
-- Partial update: only fields present in p_updates (JSONB) are written.
-- Example: SELECT admin_update_location('<id>', '<brand_id>', '{"phone": "555-1234"}');
CREATE OR REPLACE FUNCTION public.admin_update_location(
p_location_id UUID,
p_brand_id UUID,
p_updates JSONB
)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_existing RECORD;
v_key TEXT;
v_value TEXT;
BEGIN
IF p_location_id IS NULL THEN
RAISE EXCEPTION 'location_id is required';
END IF;
IF p_updates IS NULL OR jsonb_typeof(p_updates) <> 'object' THEN
RAISE EXCEPTION 'updates must be a JSON object';
END IF;
-- Lock + ownership check
SELECT * INTO v_existing FROM public.locations
WHERE id = p_location_id AND deleted_at IS NULL FOR UPDATE;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Location not found');
END IF;
IF p_brand_id IS NOT NULL AND v_existing.brand_id != p_brand_id THEN
RETURN jsonb_build_object('success', false, 'error', 'Location not found');
END IF;
-- Build dynamic UPDATE — only set fields explicitly present in p_updates
FOR v_key, v_value IN
SELECT key, value FROM jsonb_each_text(p_updates)
WHERE key IN ('name', 'address', 'city', 'state', 'zip', 'phone',
'contact_name', 'contact_email', 'notes', 'active')
LOOP
EXECUTE format(
'UPDATE public.locations SET %I = $1 WHERE id = $2',
v_key
) USING v_value, p_location_id;
END LOOP;
RETURN jsonb_build_object('success', true);
EXCEPTION WHEN OTHERS THEN
RAISE EXCEPTION 'admin_update_location failed: % (SQLSTATE %)', SQLERRM, SQLSTATE;
END;
$$;
-- ── 4d. admin_delete_location ────────────────────────────────────────────────
-- Soft delete via deleted_at. If any non-deleted stop still references this
-- location, refuse — caller must reassign or hard-delete those stops first.
CREATE OR REPLACE FUNCTION public.admin_delete_location(
p_location_id UUID,
p_brand_id UUID DEFAULT NULL
)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_loc RECORD;
v_stops_count INT;
BEGIN
SELECT * INTO v_loc FROM public.locations
WHERE id = p_location_id AND deleted_at IS NULL FOR UPDATE;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Location not found');
END IF;
IF p_brand_id IS NOT NULL AND v_loc.brand_id != p_brand_id THEN
RETURN jsonb_build_object('success', false, 'error', 'Location not found');
END IF;
SELECT COUNT(*) INTO v_stops_count FROM public.stops
WHERE location_id = p_location_id AND deleted_at IS NULL;
IF v_stops_count > 0 THEN
RETURN jsonb_build_object(
'success', false,
'error', 'Cannot delete location — ' || v_stops_count || ' stop(s) still reference it. Reassign or delete those stops first.'
);
END IF;
UPDATE public.locations SET deleted_at = NOW() WHERE id = p_location_id;
RETURN jsonb_build_object('success', true);
END;
$$;
-- ── 4e. get_locations_for_brand ──────────────────────────────────────────────
-- Public read RPC. Mirrors get_public_stops_for_brand.
-- Returns active, non-deleted locations for a brand slug.
CREATE OR REPLACE FUNCTION public.get_locations_for_brand(p_brand_slug TEXT)
RETURNS TABLE (
id UUID,
name TEXT,
address TEXT,
city TEXT,
state TEXT,
zip TEXT,
phone TEXT,
contact_name TEXT,
contact_email TEXT,
notes TEXT,
slug TEXT,
stop_count BIGINT
)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
RETURN QUERY
SELECT
l.id, l.name, l.address, l.city, l.state, l.zip, l.phone,
l.contact_name, l.contact_email, l.notes, l.slug,
(SELECT COUNT(*) FROM stops s
WHERE s.location_id = l.id AND s.deleted_at IS NULL)::BIGINT AS stop_count
FROM locations l
INNER JOIN brands b ON l.brand_id = b.id
WHERE l.deleted_at IS NULL
AND l.active = true
AND b.slug = p_brand_slug
ORDER BY lower(l.name) ASC;
END;
$$;
-- ── 4f. admin_attach_location_to_stop ────────────────────────────────────────
-- One-way link: set stops.location_id. Idempotent.
CREATE OR REPLACE FUNCTION public.admin_attach_location_to_stop(
p_stop_id UUID,
p_location_id UUID,
p_brand_id UUID DEFAULT NULL
)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_stop RECORD;
v_location RECORD;
BEGIN
SELECT * INTO v_stop FROM stops
WHERE id = p_stop_id AND deleted_at IS NULL FOR UPDATE;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Stop not found');
END IF;
IF p_brand_id IS NOT NULL AND v_stop.brand_id != p_brand_id THEN
RETURN jsonb_build_object('success', false, 'error', 'Stop not found');
END IF;
SELECT * INTO v_location FROM locations
WHERE id = p_location_id AND deleted_at IS NULL;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Location not found');
END IF;
IF v_location.brand_id != v_stop.brand_id THEN
RETURN jsonb_build_object('success', false, 'error', 'Location belongs to a different brand');
END IF;
UPDATE stops SET location_id = p_location_id WHERE id = p_stop_id;
-- Also denormalize the location name + address from the venue so the
-- storefront cards stay useful even when stops.location_id is unset.
UPDATE stops SET location = v_location.name, address = COALESCE(v_location.address, stops.address)
WHERE id = p_stop_id;
RETURN jsonb_build_object('success', true);
END;
$$;
-- =============================================================================
-- 5. Grants
-- =============================================================================
GRANT EXECUTE ON FUNCTION public.admin_create_location(
UUID, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, BOOLEAN
) TO anon, authenticated, service_role;
GRANT EXECUTE ON FUNCTION public.admin_create_locations_batch(UUID, JSONB)
TO anon, authenticated, service_role;
GRANT EXECUTE ON FUNCTION public.admin_update_location(UUID, UUID, JSONB)
TO anon, authenticated, service_role;
GRANT EXECUTE ON FUNCTION public.admin_delete_location(UUID, UUID)
TO anon, authenticated, service_role;
GRANT EXECUTE ON FUNCTION public.get_locations_for_brand(TEXT)
TO anon, authenticated, service_role;
GRANT EXECUTE ON FUNCTION public.admin_attach_location_to_stop(UUID, UUID, UUID)
TO anon, authenticated, service_role;
COMMIT;
@@ -0,0 +1,19 @@
-- Migration 204: Add permissive read policy for locations
--
-- Why: The admin Stops & Routes page reads stops via the Supabase JS client
-- (anon key + RLS). The `stops` table has a `Public read stops` policy with
-- qual=true, which lets the anon role see all non-deleted stops — that's how
-- the page works in dev mode (no Supabase auth). The admin_locations tab needs
-- the same behavior so the server component can use the same pattern:
-- supabase.from("locations").select(...)
--
-- Brand scoping is enforced at the application layer in the server component
-- (eq("brand_id", adminUser.brand_id)), matching how stops are scoped.
--
-- Brand-scoped public reads (storefront pages) go through get_locations_for_brand
-- RPC, which filters active=true.
DROP POLICY IF EXISTS "Public read locations" ON public.locations;
CREATE POLICY "Public read locations" ON public.locations
FOR SELECT
USING (true);
@@ -0,0 +1,57 @@
-- Migration 205: admin_list_locations RPC
--
-- Returns ALL non-deleted locations for a brand (including inactive) with a
-- stop_count aggregation. Mirrors get_locations_for_brand but is brand_id-
-- keyed and admin-side (no active filter) so the Locations tab in the admin
-- Stops & Routes page can show inactive venues and draft venues.
--
-- brand_id NULL means platform_admin scope (all brands). This is the
-- standard application-layer brand-scoping pattern (SECURITY DEFINER RPCs
-- bypass RLS; caller is responsible for authorization).
CREATE OR REPLACE FUNCTION public.admin_list_locations(
p_brand_id UUID DEFAULT NULL
)
RETURNS TABLE (
id UUID,
brand_id UUID,
name TEXT,
address TEXT,
city TEXT,
state TEXT,
zip TEXT,
phone TEXT,
contact_name TEXT,
contact_email TEXT,
notes TEXT,
active BOOLEAN,
created_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ,
deleted_at TIMESTAMPTZ,
slug TEXT,
stop_count BIGINT
)
LANGUAGE sql
SECURITY DEFINER
STABLE
SET search_path = public
AS $$
SELECT
l.id, l.brand_id, l.name, l.address, l.city, l.state, l.zip,
l.phone, l.contact_name, l.contact_email, l.notes, l.active,
l.created_at, l.updated_at, l.deleted_at, l.slug,
COALESCE(s.cnt, 0) AS stop_count
FROM public.locations l
LEFT JOIN (
SELECT location_id, COUNT(*) AS cnt
FROM public.stops
WHERE deleted_at IS NULL
GROUP BY location_id
) s ON s.location_id = l.id
WHERE l.deleted_at IS NULL
AND (p_brand_id IS NULL OR l.brand_id = p_brand_id)
ORDER BY lower(l.name) ASC;
$$;
GRANT EXECUTE ON FUNCTION public.admin_list_locations(UUID)
TO anon, authenticated, service_role;
@@ -0,0 +1,142 @@
-- Migration 206: Re-dedupe locations by (name, city, address)
--
-- Problem
-- -------
-- The original backfill (after migration 203) grouped venues by (name, address)
-- only — so "Tractor Supply" in Brighton, CO and "Tractor Supply" in
-- Canon City, CO both got rolled up to one location if they happened to share
-- the same address (or both had a NULL address). The same affected:
-- * Tractor Supply — 58 stops at 10 distinct (city, address) combos
-- * Ace Hardware — 27 stops at 3 distinct combos
-- * Murdoch's Ranch & Home — 25 stops at 3 distinct combos
-- * Big Tool Box — 15 stops at 2 distinct combos
-- * JAX Farm & Ranch — 13 stops at 2 distinct combos
-- Result: 5 of the 26 venues were each represented as a single over-broad
-- location record, hiding the real per-city venue from the Locations tab.
--
-- Fix
-- ---
-- Re-dedupe by (name, city, address). For each unique combo, create a fresh
-- location row, re-link stops to it, and soft-delete the over-grouped originals
-- (which by then have zero stop references). Idempotent: safe to re-run; on
-- re-run the CTE returns no rows and the script is a no-op.
--
-- Result: 41 locations for the Tuxedo brand (up from 26). 269 stops still
-- linked, just to the correct per-city venue record.
DO $$
DECLARE
v_brand_id UUID := '64294306-5f42-463d-a5e8-2ad6c81a96de';
v_inserted INT := 0;
v_relinked INT := 0;
v_soft_del INT := 0;
BEGIN
-- 1. Insert one new location per distinct (name, city, address) tuple that
-- isn't already a current non-deleted location. Skip if a row with the
-- same (brand_id, name, lower(city), lower(address)) already exists.
WITH venues AS (
SELECT DISTINCT
s.brand_id,
s.location AS name,
NULLIF(btrim(COALESCE(s.city, '')), '') AS city,
NULLIF(btrim(COALESCE(s.address, '')), '') AS address
FROM public.stops s
WHERE s.brand_id = v_brand_id
AND s.deleted_at IS NULL
AND s.location IS NOT NULL
AND length(trim(s.location)) > 0
),
ranked AS (
-- Stable, per-(name, city, address) ordering so slugs are reproducible
SELECT
v.*,
row_number() OVER (
PARTITION BY v.brand_id, lower(trim(v.name))
ORDER BY lower(COALESCE(v.city, '')), lower(COALESCE(v.address, ''))
) AS rn
FROM venues v
),
inserted AS (
INSERT INTO public.locations (
brand_id, name, city, address, active, slug
)
SELECT
r.brand_id,
trim(r.name),
r.city,
r.address,
true,
-- Slug: name, optionally suffixed with -city, then a numeric counter
-- only when multiple venues share the same name (e.g. Tractor Supply
-- appears in 10 cities, so we get -1, -2, … -10).
-- Use [[:alnum:]] so uppercase letters aren't stripped (e.g. "Tractor
-- Supply" → "tractor-supply", not "ractor-upply").
trim(BOTH '-' FROM lower(regexp_replace(trim(r.name), '[^[:alnum:]]+', '-', 'g')))
|| CASE
WHEN r.city IS NOT NULL AND length(trim(r.city)) > 0
THEN '-' || trim(BOTH '-' FROM lower(regexp_replace(trim(r.city), '[^[:alnum:]]+', '-', 'g')))
ELSE ''
END
|| CASE WHEN r.rn > 1 THEN '-' || r.rn::text ELSE '' END
FROM ranked r
WHERE NOT EXISTS (
SELECT 1 FROM public.locations l
WHERE l.brand_id = r.brand_id
AND l.name = trim(r.name)
AND l.deleted_at IS NULL
AND lower(COALESCE(l.city, '')) = lower(COALESCE(r.city, ''))
AND lower(COALESCE(l.address, '')) = lower(COALESCE(r.address, ''))
)
RETURNING id, name, city, address
)
SELECT COUNT(*) INTO v_inserted FROM inserted;
RAISE NOTICE 'Inserted % new per-city locations', v_inserted;
-- 2. Re-link each stop to the location whose (name, city, address) matches.
-- Use lower() on both sides for case-insensitive matching.
WITH new_links AS (
SELECT
s.id AS stop_id,
l.id AS location_id
FROM public.stops s
JOIN public.locations l
ON l.brand_id = s.brand_id
AND lower(trim(l.name)) = lower(trim(s.location))
AND lower(COALESCE(l.city, '')) = lower(COALESCE(NULLIF(btrim(s.city), ''), ''))
AND lower(COALESCE(l.address, '')) = lower(COALESCE(NULLIF(btrim(s.address), ''), ''))
AND l.deleted_at IS NULL
WHERE s.brand_id = v_brand_id
AND s.deleted_at IS NULL
)
UPDATE public.stops s
SET location_id = nl.location_id
FROM new_links nl
WHERE s.id = nl.stop_id;
GET DIAGNOSTICS v_relinked = ROW_COUNT;
RAISE NOTICE 'Re-linked % stops to their per-city locations', v_relinked;
-- 3. Soft-delete any location that no longer has a non-deleted stop pointing
-- to it. (These are the over-grouped originals from the previous dedupe.)
UPDATE public.locations l
SET deleted_at = NOW()
WHERE l.brand_id = v_brand_id
AND l.deleted_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM public.stops s
WHERE s.location_id = l.id
AND s.deleted_at IS NULL
);
GET DIAGNOSTICS v_soft_del = ROW_COUNT;
RAISE NOTICE 'Soft-deleted % now-orphan locations', v_soft_del;
END$$;
-- Final summary
SELECT
(SELECT COUNT(*) FROM public.locations
WHERE brand_id = '64294306-5f42-463d-a5e8-2ad6c81a96de' AND deleted_at IS NULL) AS locations,
(SELECT COUNT(*) FROM public.stops
WHERE brand_id = '64294306-5f42-463d-a5e8-2ad6c81a96de' AND deleted_at IS NULL AND location_id IS NOT NULL) AS linked_stops,
(SELECT COUNT(*) FROM public.locations
WHERE brand_id = '64294306-5f42-463d-a5e8-2ad6c81a96de' AND deleted_at IS NOT NULL) AS soft_deleted;
@@ -0,0 +1,185 @@
-- Migration 207: Multi-Brand Admin Support
--
-- Adds:
-- 1. New role: 'multi_brand_admin' (functionally equivalent to brand_admin,
-- but disambiguates intent: "manages 2+ brands" vs "manages 1 brand").
-- 2. Junction table: admin_user_brands — replaces the single brand_id
-- implicit invariant. The legacy admin_users.brand_id column is kept
-- for backwards compatibility (see migration 220_*.sql to drop it later).
-- 3. RPCs: add_admin_user_brand / remove_admin_user_brand (manages the
-- junction and auto-promotes/demotes the role based on brand count).
--
-- Design:
-- - Composite PK (admin_user_id, brand_id) enforces uniqueness.
-- - Index on brand_id makes "which admins are in Brand X?" queries fast.
-- - ON DELETE CASCADE for both FKs — deleting an admin or brand cleans up.
-- - added_by is nullable so first-insert rows (admin with no other admin)
-- can still insert their own membership.
-- ═══════════════════════════════════════════════════════════════════════════
-- 1. Update role CHECK constraint to include multi_brand_admin
-- ═══════════════════════════════════════════════════════════════════════════
-- The constraint name in Supabase's auto-generated schema is conventionally
-- "admin_users_role_check" (the default Postgres naming), but we drop both
-- the canonical and a common alternative to be safe across schema generations.
ALTER TABLE public.admin_users DROP CONSTRAINT IF EXISTS admin_users_role_check;
ALTER TABLE public.admin_users DROP CONSTRAINT IF EXISTS admin_users_role_chk;
ALTER TABLE public.admin_users
ADD CONSTRAINT admin_users_role_check
CHECK (role IN ('platform_admin', 'brand_admin', 'multi_brand_admin', 'store_employee', 'staff'));
-- ═══════════════════════════════════════════════════════════════════════════
-- 2. Create admin_user_brands junction table
-- ═══════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS public.admin_user_brands (
admin_user_id UUID NOT NULL REFERENCES public.admin_users(id) ON DELETE CASCADE,
brand_id UUID NOT NULL REFERENCES public.brands(id) ON DELETE CASCADE,
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
added_by UUID REFERENCES public.admin_users(id),
PRIMARY KEY (admin_user_id, brand_id)
);
CREATE INDEX IF NOT EXISTS admin_user_brands_brand_id_idx
ON public.admin_user_brands(brand_id);
-- ═══════════════════════════════════════════════════════════════════════════
-- 3. Backfill: copy legacy single-brand admins into the junction
-- ═══════════════════════════════════════════════════════════════════════════
INSERT INTO public.admin_user_brands (admin_user_id, brand_id)
SELECT id, brand_id FROM public.admin_users
WHERE brand_id IS NOT NULL
ON CONFLICT (admin_user_id, brand_id) DO NOTHING;
-- ═══════════════════════════════════════════════════════════════════════════
-- 4. Promote anyone with > 1 brand in the junction to multi_brand_admin
-- ═══════════════════════════════════════════════════════════════════════════
UPDATE public.admin_users
SET role = 'multi_brand_admin'
WHERE role = 'brand_admin'
AND id IN (
SELECT admin_user_id FROM public.admin_user_brands
GROUP BY admin_user_id HAVING COUNT(*) > 1
);
-- ═══════════════════════════════════════════════════════════════════════════
-- 5. RLS: enable on the junction (no public read; only platform_admin all)
-- ═══════════════════════════════════════════════════════════════════════════
ALTER TABLE public.admin_user_brands ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "admin_user_brands_platform_admin_all" ON public.admin_user_brands;
CREATE POLICY "admin_user_brands_platform_admin_all" ON public.admin_user_brands
FOR ALL TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.admin_users au
WHERE au.user_id = auth.uid()
AND au.role = 'platform_admin'
AND au.active = true
)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM public.admin_users au
WHERE au.user_id = auth.uid()
AND au.role = 'platform_admin'
AND au.active = true
)
);
-- Brand admins can read their own membership rows (so the BrandSelector
-- dropdown works without exposing other admins' memberships)
DROP POLICY IF EXISTS "admin_user_brands_self_read" ON public.admin_user_brands;
CREATE POLICY "admin_user_brands_self_read" ON public.admin_user_brands
FOR SELECT TO authenticated
USING (
admin_user_id IN (
SELECT id FROM public.admin_users
WHERE user_id = auth.uid() AND active = true
)
);
-- ═══════════════════════════════════════════════════════════════════════════
-- 6. RPC: add_admin_user_brand
-- Adds an admin to a brand. Auto-promotes to multi_brand_admin if they
-- end up with 2+ brands.
-- ═══════════════════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.add_admin_user_brand(
p_admin_user_id UUID,
p_brand_id UUID,
p_added_by UUID DEFAULT NULL
)
RETURNS VOID
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
INSERT INTO public.admin_user_brands (admin_user_id, brand_id, added_by)
VALUES (p_admin_user_id, p_brand_id, p_added_by)
ON CONFLICT (admin_user_id, brand_id) DO NOTHING;
-- Promote to multi_brand_admin if they now have 2+ brands
UPDATE public.admin_users
SET role = 'multi_brand_admin'
WHERE id = p_admin_user_id
AND role = 'brand_admin'
AND (
SELECT COUNT(*) FROM public.admin_user_brands
WHERE admin_user_id = p_admin_user_id
) > 1;
END;
$$;
-- ═══════════════════════════════════════════════════════════════════════════
-- 7. RPC: remove_admin_user_brand
-- Removes an admin from a brand. Auto-demotes to brand_admin if they
-- drop to 1 brand. Does NOT demote to anything else (e.g. staff) — only
-- brand_admin <-> multi_brand_admin transitions.
-- ═══════════════════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.remove_admin_user_brand(
p_admin_user_id UUID,
p_brand_id UUID
)
RETURNS VOID
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_remaining_count INT;
BEGIN
DELETE FROM public.admin_user_brands
WHERE admin_user_id = p_admin_user_id
AND brand_id = p_brand_id;
GET DIAGNOSTICS v_remaining_count = ROW_COUNT;
-- If we just removed a row and the user is now down to 1 brand, demote
IF v_remaining_count > 0 THEN
SELECT COUNT(*) INTO v_remaining_count
FROM public.admin_user_brands
WHERE admin_user_id = p_admin_user_id;
IF v_remaining_count = 1 THEN
UPDATE public.admin_users
SET role = 'brand_admin'
WHERE id = p_admin_user_id
AND role = 'multi_brand_admin';
END IF;
END IF;
END;
$$;
-- ═══════════════════════════════════════════════════════════════════════════
-- 8. Refresh PostgREST schema cache
-- ═══════════════════════════════════════════════════════════════════════════
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,73 @@
-- Migration 208: Change stops.date from TIMESTAMPTZ to DATE
--
-- Why
-- ---
-- The `stops.date` column was created as TIMESTAMPTZ but is used throughout
-- the app as a calendar date (no time-of-day component). The mismatch
-- forces every client consumer to do `new Date(s.date + "T00:00:00")` to
-- coerce the timestamp into a local-midnight Date — and that hack silently
-- produces an Invalid Date when Supabase returns a `2026-06-15 00:00:00+00`
-- style string, leaving the Stops & Routes calendar empty.
--
-- Storing the column as DATE makes Supabase return a clean `YYYY-MM-DD`
-- string, which the existing `+ "T00:00:00"` parsing handles correctly.
--
-- The `time` (TEXT, "HH:MM") and `cutoff_time` (TIMESTAMPTZ) columns are
-- unchanged — they legitimately carry time-of-day information.
--
-- Affected RPC
-- -------------
-- `get_public_stops_for_brand` declares `date TIMESTAMPTZ` in its
-- RETURNS TABLE. Recreate it with `date DATE` so the return type matches
-- the underlying column.
-- 1. Drop the existing RPC so we can recreate it with a new return type.
-- PostgreSQL refuses CREATE OR REPLACE when the OUT-parameter row type
-- changes (error 42P13).
DROP FUNCTION IF EXISTS get_public_stops_for_brand(TEXT);
-- 2. Convert the column. USING date::DATE truncates the time component,
-- which is what we want — all existing rows are stored at 00:00 anyway.
ALTER TABLE public.stops
ALTER COLUMN date TYPE DATE USING date::DATE;
-- 3. Recreate the public RPC with the corrected return type.
CREATE OR REPLACE FUNCTION get_public_stops_for_brand(p_brand_slug TEXT)
RETURNS TABLE (
id UUID,
city TEXT,
state TEXT,
date DATE,
"time" TEXT,
location TEXT,
address TEXT,
slug TEXT,
cutoff_time TIMESTAMPTZ
)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
RETURN QUERY
SELECT
s.id,
s.city,
s.state,
s.date,
s."time",
s.location,
s.address,
s.slug,
s.cutoff_time
FROM stops s
INNER JOIN brands b ON s.brand_id = b.id
WHERE s.active = true
AND b.slug = p_brand_slug
ORDER BY s.date ASC;
END;
$$;
GRANT EXECUTE ON FUNCTION get_public_stops_for_brand(TEXT) TO anon;
GRANT EXECUTE ON FUNCTION get_public_stops_for_brand(TEXT) TO authenticated;
GRANT EXECUTE ON FUNCTION get_public_stops_for_brand(TEXT) TO service_role;