From 1feba25ced80d4c54fcd415905ac10a0f3cbefd7 Mon Sep 17 00:00:00 2001 From: default Date: Thu, 4 Jun 2026 15:32:16 +0000 Subject: [PATCH] chore: commit tour-seed scripts and delete_stop 400 fix from prior work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * inspect_xlsx.py — one-off xlsx dumper used to discover the Stop Directory sheet structure (14 lines, kept for reference when the tour schedule xlsx is updated next season). * scripts/seed_tuxedo_tour.py — Python+openpyxl seed script that parsed the 3-week Tuxedo Corn 2026 tour schedule and inserted 269 stops via admin_create_stops_batch. Also linked each stop to its Stop Directory venue (address/phone/contact) and self-published by flipping status='draft' -> 'active'. * supabase/migrations/202_delete_stop_orders_guard_fix.sql — fixes a 400 error on the admin Stop Table delete button. The delete_stop RPC referenced o.deleted_at on the orders table, but that column doesn't exist, so the SELECT raised 'column does not exist' and PostgREST surfaced it as HTTP 400. The fix drops the redundant o.deleted_at IS NULL clause (pickup_complete = false is sufficient on its own); re-add it here if/when orders grows a deleted_at column. --- inspect_xlsx.py | 14 + scripts/seed_tuxedo_tour.py | 346 ++++++++++++++++++ .../202_delete_stop_orders_guard_fix.sql | 70 ++++ 3 files changed, 430 insertions(+) create mode 100644 inspect_xlsx.py create mode 100644 scripts/seed_tuxedo_tour.py create mode 100644 supabase/migrations/202_delete_stop_orders_guard_fix.sql 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/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/supabase/migrations/202_delete_stop_orders_guard_fix.sql b/supabase/migrations/202_delete_stop_orders_guard_fix.sql new file mode 100644 index 0000000..1c7f2d7 --- /dev/null +++ b/supabase/migrations/202_delete_stop_orders_guard_fix.sql @@ -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;