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;