feat: add locations table + Locations tab in Stops & Routes
* New 'locations' table for reusable venues (Tractor Supply, Boot Barn, etc.) — each stop now links via stops.location_id while keeping the denormalized location text for backwards compat. * 6 SECURITY DEFINER RPCs: admin_create_location, admin_create_locations_batch, admin_update_location, admin_delete_location, admin_attach_location_to_stop, get_locations_for_brand. Plus admin_list_locations for the admin tab. * Backfill: 26 unique venues extracted from the 269 Tuxedo stops, all linked. * Stops & Routes page now has tabs (Stops | Locations) via ?tab= query param. Locations tab has full CRUD UI: search, status filter, pagination, edit, delete, add-venue modal. * StopsHeaderActions now tab-aware — Upload Schedule / Add Stop only show on Stops tab; Locations tab has its own Add Venue button inline.
This commit is contained in:
@@ -0,0 +1,294 @@
|
||||
"use server";
|
||||
|
||||
import { revalidateTag } from "next/cache";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
export type LocationInput = {
|
||||
name: string;
|
||||
address?: string | null;
|
||||
city?: string | null;
|
||||
state?: string | null;
|
||||
zip?: string | null;
|
||||
phone?: string | null;
|
||||
contact_name?: string | null;
|
||||
contact_email?: string | null;
|
||||
notes?: string | null;
|
||||
active?: boolean;
|
||||
};
|
||||
|
||||
export type Location = {
|
||||
id: string;
|
||||
brand_id: string;
|
||||
name: string;
|
||||
address: string | null;
|
||||
city: string | null;
|
||||
state: string | null;
|
||||
zip: string | null;
|
||||
phone: string | null;
|
||||
contact_name: string | null;
|
||||
contact_email: string | null;
|
||||
notes: string | null;
|
||||
active: boolean;
|
||||
deleted_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
slug: string | null;
|
||||
};
|
||||
|
||||
export type LocationWithCount = Location & { stop_count: number };
|
||||
|
||||
// ── Create (single) ──────────────────────────────────────────────────────────
|
||||
export async function createLocation(
|
||||
brandId: string,
|
||||
input: LocationInput
|
||||
): Promise<{ success: true; id: string; slug: string } | { success: false; error: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_stops) {
|
||||
return { success: false, error: "Not authorized to manage locations" };
|
||||
}
|
||||
const effectiveBrandId = brandId || adminUser.brand_id;
|
||||
if (!effectiveBrandId) return { success: false, error: "No brand selected" };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/admin_create_location`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_brand_id: effectiveBrandId,
|
||||
p_name: input.name,
|
||||
p_address: input.address ?? null,
|
||||
p_city: input.city ?? null,
|
||||
p_state: input.state ?? null,
|
||||
p_zip: input.zip ?? null,
|
||||
p_phone: input.phone ?? null,
|
||||
p_contact_name: input.contact_name ?? null,
|
||||
p_contact_email: input.contact_email ?? null,
|
||||
p_notes: input.notes ?? null,
|
||||
p_active: input.active ?? true,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ message: "Request failed" }));
|
||||
return { success: false, error: (err as { message?: string }).message ?? "Insert failed" };
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
revalidateTag("locations", "default");
|
||||
revalidateTag(`brand:${effectiveBrandId}:locations`, "default");
|
||||
return { success: true, id: data.id, slug: data.slug };
|
||||
}
|
||||
|
||||
// ── Create (batch) ───────────────────────────────────────────────────────────
|
||||
export async function createLocationsBatch(
|
||||
brandId: string,
|
||||
locations: LocationInput[]
|
||||
): Promise<{ success: boolean; created: number; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, created: 0, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_stops) {
|
||||
return { success: false, created: 0, error: "Not authorized" };
|
||||
}
|
||||
const effectiveBrandId = brandId || adminUser.brand_id;
|
||||
if (!effectiveBrandId) return { success: false, created: 0, error: "No brand selected" };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/admin_create_locations_batch`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: effectiveBrandId, p_locations: locations }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ message: "Request failed" }));
|
||||
return { success: false, created: 0, error: (err as { message?: string }).message ?? "Insert failed" };
|
||||
}
|
||||
|
||||
const inserted = await res.json();
|
||||
revalidateTag("locations", "default");
|
||||
revalidateTag(`brand:${effectiveBrandId}:locations`, "default");
|
||||
return {
|
||||
success: true,
|
||||
created: Array.isArray(inserted) ? inserted.length : locations.length,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Update (partial) ─────────────────────────────────────────────────────────
|
||||
export async function updateLocation(
|
||||
locationId: string,
|
||||
brandId: string,
|
||||
updates: Partial<LocationInput>
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/admin_update_location`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_location_id: locationId, p_brand_id: brandId, p_updates: updates }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ message: "Request failed" }));
|
||||
return { success: false, error: (err as { message?: string }).message ?? "Update failed" };
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
if (!data.success) return { success: false, error: data.error ?? "Update failed" };
|
||||
|
||||
revalidateTag("locations", "default");
|
||||
revalidateTag(`brand:${brandId}:locations`, "default");
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ── Delete (soft) ────────────────────────────────────────────────────────────
|
||||
export async function deleteLocation(
|
||||
locationId: string,
|
||||
brandId: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/admin_delete_location`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_location_id: locationId, p_brand_id: brandId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ message: "Request failed" }));
|
||||
return { success: false, error: (err as { message?: string }).message ?? "Delete failed" };
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
if (!data.success) return { success: false, error: data.error ?? "Delete failed" };
|
||||
|
||||
revalidateTag("locations", "default");
|
||||
revalidateTag(`brand:${brandId}:locations`, "default");
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ── Read (admin, by brand_id) ────────────────────────────────────────────────
|
||||
export async function adminListLocations(
|
||||
brandId: string
|
||||
): Promise<LocationWithCount[]> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return [];
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
// Application-layer brand scoping: platform_admin (brand_id: null) gets all
|
||||
// brands; everyone else gets only their own brand.
|
||||
const effectiveBrandId = brandId || adminUser.brand_id;
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/admin_list_locations`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: effectiveBrandId }),
|
||||
next: { revalidate: 60, tags: ["locations", `brand:${effectiveBrandId ?? "all"}:locations`] },
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return Array.isArray(data) ? (data as LocationWithCount[]) : [];
|
||||
}
|
||||
|
||||
// ── Read (public, by brand slug) ─────────────────────────────────────────────
|
||||
export type PublicLocation = Pick<
|
||||
Location,
|
||||
"id" | "name" | "address" | "city" | "state" | "zip" | "phone" | "slug"
|
||||
> & { stop_count: number };
|
||||
|
||||
export async function getPublicLocationsForBrand(
|
||||
brandSlug: string
|
||||
): Promise<PublicLocation[]> {
|
||||
if (!brandSlug) return [];
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_locations_for_brand`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_slug: brandSlug }),
|
||||
next: { revalidate: 300, tags: ["locations", `brand:${brandSlug}:locations`] },
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return Array.isArray(data) ? (data as PublicLocation[]) : [];
|
||||
}
|
||||
|
||||
// ── Attach a stop to a location ──────────────────────────────────────────────
|
||||
export async function attachStopToLocation(
|
||||
stopId: string,
|
||||
locationId: string,
|
||||
brandId: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/admin_attach_location_to_stop`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_stop_id: stopId,
|
||||
p_location_id: locationId,
|
||||
p_brand_id: brandId,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ message: "Request failed" }));
|
||||
return { success: false, error: (err as { message?: string }).message ?? "Attach failed" };
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
if (!data.success) return { success: false, error: data.error ?? "Attach failed" };
|
||||
|
||||
revalidateTag("stops", "default");
|
||||
revalidateTag("locations", "default");
|
||||
revalidateTag(`brand:${brandId}:stops`, "default");
|
||||
revalidateTag(`brand:${brandId}:locations`, "default");
|
||||
return { success: true };
|
||||
}
|
||||
Reference in New Issue
Block a user