63842a9efc
Implements the design at
docs/superpowers/specs/2026-06-04-multi-brand-admin-design.md.
Adds:
- admin_user_brands junction table (m:n admin<->brand) via migration 207
- New role 'multi_brand_admin' (auto-set when an admin has 2+ brands)
- 'active_brand_id' cookie that persists the admin's currently-selected
brand across navigations; switchable via the new BrandSelector dropdown
in the sidebar
- Centralised brand resolution in src/lib/brand-scope.ts:
- getActiveBrandId (URL > cookie > legacy brand_id > first of brand_ids)
- assertBrandAccess (defence-in-depth for cases where the brandId
comes from a URL form or RPC return)
- ~30 server actions and ~10 page server components migrated to use
getActiveBrandId instead of the silent brandId ?? adminUser.brand_id
pattern that allowed cross-brand access bugs
- BrandSelector client component with proper a11y (aria-haspopup,
aria-expanded, role=listbox, outside-click and escape-to-close)
Migration 207 also adds RLS so admins can read their own junction rows
(needed for the dropdown to populate) and SECURITY DEFINER RPCs
add/remove_admin_user_brand that auto-promote/demote between
brand_admin and multi_brand_admin.
Notes:
- Migration number is 207 not 204 — 204-206 were taken in this worktree
by the concurrent locations work.
- The legacy admin_users.brand_id column is preserved for backwards
compat; a follow-up migration 220_* will drop it.
- Dev sessions (dev_session cookie, NEXT_PUBLIC_USE_MOCK_DATA) get
brand_ids: []; the documented limitation is that dev store_employee
will see <AdminAccessDenied /> if no real brands exist.
- Pages that hardcode a Tuxedo brand UUID as a fallback
(adminUser.brand_id ?? '64294306-...') are NOT migrated in this PR —
they still work for single-brand admins and are out of scope.
Co-authored-by: implementer subagent (cancelled mid-run), Grok orchestrator
305 lines
11 KiB
TypeScript
305 lines
11 KiB
TypeScript
"use server";
|
|
|
|
import { revalidateTag } from "next/cache";
|
|
import { getAdminUser } from "@/lib/admin-permissions";
|
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
|
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 activeBrandId = await getActiveBrandId(adminUser, brandId);
|
|
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
|
return { success: false, error: "Brand access required" };
|
|
}
|
|
const effectiveBrandId = activeBrandId;
|
|
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 activeBrandId = await getActiveBrandId(adminUser, brandId);
|
|
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
|
return { success: false, created: 0, error: "Brand access required" };
|
|
}
|
|
const effectiveBrandId = activeBrandId;
|
|
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 activeBrandId = await getActiveBrandId(adminUser, brandId);
|
|
const effectiveBrandId = activeBrandId;
|
|
|
|
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 };
|
|
}
|