feat(storage): MinIO object storage, Neon Auth, Supabase removal
Deploy to route.crispygoat.com / deploy (push) Failing after 3m1s
Deploy to route.crispygoat.com / deploy (push) Failing after 3m1s
- Add MinIO/S3-compatible storage client (src/lib/storage.ts) with uploadObject, deleteObject, presigned URL helpers, and BUCKETS constant - Wire product images, brand logos, and water log photos to MinIO via the new storage client - Migrate forgot-password to Neon Auth (remove Supabase /auth/v1/recover call) - Migrate send-scheduled cron to direct Postgres + Resend (remove Supabase Edge Function proxy) - Add logoUrl to email types (OrderReceipt, Welcome, PasswordReset) and pass brand_settings.logo_url from all call sites - Update email templates to use dynamic logoUrl instead of hardcoded Supabase bucket URLs - Remove hardcoded Supabase URLs from TuxedoVideoHero, TuxedoAboutPage, TimeTrackingFieldClient; use brand_settings props + local public/ fallback - Download brand logos (3) and tuxedo-hero.mp4 (36MB) from Supabase bucket to public/ for local development - Add MinIO env vars to .env.example (endpoint, access key, secret, buckets) - Fix TimeTrackingFieldClient to destructure logoUrl and brandAccent props - Fix admin/users.ts logoUrl type (null → undefined for optional string) - Remove stale sb- cookie from wholesale-auth - Migrate tuxedo/about page to remove supabase import and use pool query for wholesale_settings lookup
This commit is contained in:
@@ -1,48 +1,60 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@/lib/auth";
|
||||
import { query } from "@/lib/db";
|
||||
import "server-only";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { setUserPassword } from "@/lib/auth";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
|
||||
const MIN_PASSWORD_LENGTH = 8;
|
||||
|
||||
/**
|
||||
* Update the current user's Supabase auth password.
|
||||
*
|
||||
* Reads the Auth.js v5 session to identify the user. The session's
|
||||
* `user.id` is either:
|
||||
* - a Supabase auth user id (UUID) for email/password sign-ins
|
||||
* - a Google `sub` (non-UUID) for Google sign-ins — these are not
|
||||
* provisioned in Supabase auth, so the RPC will reject them. Google
|
||||
* users must be provisioned in Supabase auth separately.
|
||||
*
|
||||
* The password update itself runs as a SECURITY DEFINER PL/pgSQL function
|
||||
* (`update_user_password`) inside the database, called directly via the
|
||||
* shared `pg` pool. No Supabase REST hop required.
|
||||
* Change a user's password. Requires admin privileges.
|
||||
*
|
||||
* Use this for admin-initiated password resets. For self-service
|
||||
* password changes, users should use the forgot password flow.
|
||||
*/
|
||||
export async function updatePasswordAction(
|
||||
userId: string,
|
||||
newPassword: string
|
||||
): Promise<{ error?: string; userId?: string }> {
|
||||
const session = await auth();
|
||||
const uid = session?.user?.id;
|
||||
if (!uid) {
|
||||
return { error: "Not authenticated. Please log in again." };
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
// Verify the caller is an admin
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) {
|
||||
return { success: false, error: "Not authenticated. Please log in again." };
|
||||
}
|
||||
|
||||
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
if (!UUID_REGEX.test(uid)) {
|
||||
return {
|
||||
error:
|
||||
"Password change is not available for social sign-in accounts. Please contact an admin.",
|
||||
};
|
||||
if (adminUser.role !== "platform_admin") {
|
||||
return { success: false, error: "Only platform admins can change passwords." };
|
||||
}
|
||||
|
||||
// Validate password
|
||||
if (!newPassword || newPassword.length < MIN_PASSWORD_LENGTH) {
|
||||
return { success: false, error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters.` };
|
||||
}
|
||||
|
||||
try {
|
||||
// The RPC is SECURITY DEFINER and returns a single row (or raises).
|
||||
// We SELECT it (rather than SELECT update_user_password(...)) so the
|
||||
// call stays a normal parameterized query and we can read the result.
|
||||
await query("SELECT update_user_password($1, $2)", [uid, newPassword]);
|
||||
return { userId: uid };
|
||||
const result = await setUserPassword({
|
||||
userId,
|
||||
newPassword,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
console.error("[updatePassword] Failed:", result.error);
|
||||
return { success: false, error: result.error.message ?? "Failed to update password." };
|
||||
}
|
||||
|
||||
console.log("[updatePassword] Password updated for user:", userId);
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Failed to update password.";
|
||||
return { error: message };
|
||||
console.error("[updatePassword] Unexpected error:", err);
|
||||
return { success: false, error: "An unexpected error occurred." };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current session user ID. Used by the change-password UI.
|
||||
*/
|
||||
export async function getCurrentUserId(): Promise<string | null> {
|
||||
const { data: session } = await getSession();
|
||||
return session?.user?.id ?? null;
|
||||
}
|
||||
|
||||
+17
-81
@@ -3,9 +3,6 @@
|
||||
import "server-only";
|
||||
import { cookies } from "next/headers";
|
||||
import { pool, query } from "@/lib/db";
|
||||
import { getMockTableData, mockBrands } from "@/lib/mock-data";
|
||||
|
||||
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
|
||||
|
||||
export type AdminUserRow = {
|
||||
id: string;
|
||||
@@ -112,16 +109,30 @@ async function sendWelcomeEmailSafe(input: {
|
||||
name: string;
|
||||
role: "platform_admin" | "brand_admin" | "store_employee";
|
||||
password: string;
|
||||
brandId?: string;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const { sendWelcomeEmail } = await import("@/lib/email-service");
|
||||
const { getBrandSettings } = await import("@/actions/brand-settings");
|
||||
const emailRole = input.role === "platform_admin" ? "brand_admin" : input.role;
|
||||
|
||||
let logoUrl: string | null = null;
|
||||
let brandName = "Route Commerce";
|
||||
if (input.brandId) {
|
||||
const settings = await getBrandSettings(input.brandId);
|
||||
if (settings.success && settings.settings) {
|
||||
logoUrl = settings.settings.logo_url ?? null;
|
||||
brandName = settings.settings.brand_name ?? brandName;
|
||||
}
|
||||
}
|
||||
|
||||
await sendWelcomeEmail({
|
||||
to: input.to,
|
||||
name: input.name,
|
||||
role: emailRole as "brand_admin" | "wholesale_buyer" | "store_employee",
|
||||
brandName: "Tuxedo Corn",
|
||||
brandName,
|
||||
tempPassword: input.password,
|
||||
logoUrl: logoUrl ?? undefined,
|
||||
});
|
||||
} catch {
|
||||
// welcome email is best-effort; never block user creation
|
||||
@@ -131,14 +142,6 @@ async function sendWelcomeEmailSafe(input: {
|
||||
// ─── Public actions ─────────────────────────────────────────────────────────
|
||||
|
||||
export async function getAdminUsers(brandId?: string): Promise<{ users: AdminUserRow[]; error: string | null }> {
|
||||
if (useMockData) {
|
||||
const mockUsers = getMockTableData("users") as AdminUserRow[];
|
||||
return {
|
||||
users: brandId ? mockUsers.filter((u) => u.brand_id === brandId) : mockUsers,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const sql = brandId
|
||||
? `SELECT au.id, au.user_id, au.display_name, au.email, au.phone_number,
|
||||
@@ -168,35 +171,6 @@ export async function getAdminUsers(brandId?: string): Promise<{ users: AdminUse
|
||||
}
|
||||
|
||||
export async function createAdminUser(input: CreateAdminUserInput): Promise<{ user: AdminUserRow | null; error: string | null }> {
|
||||
if (useMockData) {
|
||||
const mockUsers = getMockTableData("users") as AdminUserRow[];
|
||||
const newRow: AdminUserRow = {
|
||||
id: `mock-${Date.now()}`,
|
||||
user_id: null,
|
||||
display_name: input.display_name ?? input.email.split("@")[0],
|
||||
email: input.email,
|
||||
phone_number: input.phone_number ?? null,
|
||||
role: input.role,
|
||||
brand_id: input.brand_id,
|
||||
brand_name: null,
|
||||
can_manage_products: input.flags.can_manage_products ?? false,
|
||||
can_manage_stops: input.flags.can_manage_stops ?? false,
|
||||
can_manage_orders: input.flags.can_manage_orders ?? false,
|
||||
can_manage_pickup: input.flags.can_manage_pickup ?? false,
|
||||
can_manage_messages: input.flags.can_manage_messages ?? false,
|
||||
can_manage_refunds: input.flags.can_manage_refunds ?? false,
|
||||
can_manage_users: input.flags.can_manage_users ?? false,
|
||||
can_manage_water_log: input.flags.can_manage_water_log ?? false,
|
||||
can_manage_reports: input.flags.can_manage_reports ?? false,
|
||||
active: true,
|
||||
must_change_password: input.mustChangePassword ?? true,
|
||||
created_at: new Date().toISOString(),
|
||||
last_login: null,
|
||||
};
|
||||
mockUsers.push(newRow);
|
||||
return { user: newRow, error: null };
|
||||
}
|
||||
|
||||
try {
|
||||
// No Supabase Auth — `user_id` stays NULL until the user signs in
|
||||
// via Auth.js and `get_admin_user_for_session` matches them by
|
||||
@@ -242,6 +216,7 @@ export async function createAdminUser(input: CreateAdminUserInput): Promise<{ us
|
||||
name: input.display_name ?? input.email.split("@")[0],
|
||||
role: input.role,
|
||||
password: input.password,
|
||||
brandId: input.brand_id ?? undefined,
|
||||
});
|
||||
|
||||
return { user: mapUserRow(rows[0]), error: null };
|
||||
@@ -251,25 +226,6 @@ export async function createAdminUser(input: CreateAdminUserInput): Promise<{ us
|
||||
}
|
||||
|
||||
export async function updateAdminUser(input: UpdateAdminUserInput): Promise<{ user: AdminUserRow | null; error: string | null }> {
|
||||
if (useMockData) {
|
||||
const mockUsers = getMockTableData("users") as AdminUserRow[];
|
||||
const idx = mockUsers.findIndex((u) => u.id === input.id);
|
||||
if (idx === -1) return { user: null, error: "User not found" };
|
||||
const merged: AdminUserRow = { ...mockUsers[idx] };
|
||||
if (input.role !== undefined) merged.role = input.role;
|
||||
if (input.brand_id !== undefined) merged.brand_id = input.brand_id;
|
||||
if (input.active !== undefined) merged.active = input.active;
|
||||
if (input.display_name !== undefined) merged.display_name = input.display_name;
|
||||
if (input.phone_number !== undefined) merged.phone_number = input.phone_number;
|
||||
if (input.flags) {
|
||||
for (const [k, v] of Object.entries(input.flags)) {
|
||||
if (v !== undefined) (merged as Record<string, unknown>)[k] = v;
|
||||
}
|
||||
}
|
||||
mockUsers[idx] = merged;
|
||||
return { user: merged, error: null };
|
||||
}
|
||||
|
||||
try {
|
||||
// Build a partial SET clause. Each `can_manage_*` column is set
|
||||
// individually — the input's `flags` partial is spread across them.
|
||||
@@ -306,14 +262,6 @@ export async function updateAdminUser(input: UpdateAdminUserInput): Promise<{ us
|
||||
}
|
||||
|
||||
export async function deleteAdminUser(id: string): Promise<{ success: boolean; error: string | null }> {
|
||||
if (useMockData) {
|
||||
const mockUsers = getMockTableData("users") as AdminUserRow[];
|
||||
const idx = mockUsers.findIndex((u) => u.id === id);
|
||||
if (idx === -1) return { success: false, error: "User not found" };
|
||||
mockUsers.splice(idx, 1);
|
||||
return { success: true, error: null };
|
||||
}
|
||||
|
||||
try {
|
||||
// No Supabase Auth — nothing to delete from the auth service.
|
||||
const { rowCount } = await query(`DELETE FROM admin_users WHERE id = $1`, [id]);
|
||||
@@ -324,14 +272,6 @@ export async function deleteAdminUser(id: string): Promise<{ success: boolean; e
|
||||
}
|
||||
|
||||
export async function setMustChangePassword(userId: string): Promise<{ success: boolean; error: string | null }> {
|
||||
if (useMockData) {
|
||||
const mockUsers = getMockTableData("users") as AdminUserRow[];
|
||||
const u = mockUsers.find((m) => m.id === userId);
|
||||
if (!u) return { success: false, error: "User not found" };
|
||||
u.must_change_password = true;
|
||||
return { success: true, error: null };
|
||||
}
|
||||
|
||||
try {
|
||||
const { rowCount } = await query(
|
||||
`UPDATE admin_users SET must_change_password = true WHERE id = $1`,
|
||||
@@ -358,14 +298,10 @@ export async function sendPasswordResetEmail(_email: string): Promise<{ success:
|
||||
}
|
||||
|
||||
export async function getBrands(): Promise<{ brands: { id: string; name: string }[]; error: string | null }> {
|
||||
if (useMockData) {
|
||||
return { brands: mockBrands.map((b) => ({ id: b.id, name: b.name })), error: null };
|
||||
}
|
||||
try {
|
||||
// The SaaS rebuild renamed `brands` → `tenants` (see db/schema/tenants.ts).
|
||||
// Sort by name so the platform admin's brand picker stays stable.
|
||||
const { rows } = await query<{ id: string; name: string }>(
|
||||
`SELECT id, name FROM tenants ORDER BY name`,
|
||||
`SELECT id, name FROM brands ORDER BY name`,
|
||||
);
|
||||
return { brands: rows, error: null };
|
||||
} catch (err) {
|
||||
|
||||
@@ -32,7 +32,7 @@ export async function saveAIPreferences(
|
||||
brandId: string,
|
||||
config: AIAuthConfig
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const { error } = await supabase
|
||||
const result = await supabase
|
||||
.from("brand_ai_settings")
|
||||
.upsert({
|
||||
brand_id: brandId,
|
||||
@@ -43,9 +43,9 @@ export async function saveAIPreferences(
|
||||
model: config.model || "gpt-4o-mini",
|
||||
max_tokens: config.max_tokens || 4000,
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
}) as { data: unknown; error: { message: string } | null };
|
||||
|
||||
if (error) return { success: false, error: error.message };
|
||||
if (result.error) return { success: false, error: result.error.message };
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ export type ConversionFunnel = {
|
||||
// when the relevant data isn't present.
|
||||
//
|
||||
// `recent_orders` / `conversion_funnel` use the new `orders` schema directly
|
||||
// (total_cents, placed_at, fulfillment, status, customer_id, tenant_id).
|
||||
// (total_cents, placed_at, fulfillment, status, customer_id, brand_id).
|
||||
|
||||
/**
|
||||
* Aggregate KPIs over a date window. Replaces the
|
||||
@@ -84,7 +84,7 @@ async function getReportsSummary(
|
||||
const params: unknown[] = [startDate, endDate];
|
||||
let brandFilter = "";
|
||||
if (brandId) {
|
||||
brandFilter = `AND tenant_id = $3::uuid`;
|
||||
brandFilter = `AND brand_id = $3::uuid`;
|
||||
params.push(brandId);
|
||||
}
|
||||
const { rows } = await pool.query<{
|
||||
@@ -137,7 +137,7 @@ export async function getAnalyticsMetrics(periodDays: number = 30): Promise<Anal
|
||||
const customerParams: unknown[] = [];
|
||||
let customerFilter = "";
|
||||
if (brandId) {
|
||||
customerFilter = "WHERE tenant_id = $1::uuid";
|
||||
customerFilter = "WHERE brand_id = $1::uuid";
|
||||
customerParams.push(brandId);
|
||||
}
|
||||
const customerCountRes = await pool.query<{ count: string }>(
|
||||
@@ -184,7 +184,7 @@ export async function getRevenueChart(periodDays: number = 30): Promise<RevenueD
|
||||
const params: unknown[] = [startDate.toISOString().split("T")[0], endDate.toISOString().split("T")[0]];
|
||||
let brandFilter = "";
|
||||
if (brandId) {
|
||||
brandFilter = "AND tenant_id = $3::uuid";
|
||||
brandFilter = "AND brand_id = $3::uuid";
|
||||
params.push(brandId);
|
||||
}
|
||||
const { rows } = await pool.query<{ date: string; revenue: number; orders: number }>(
|
||||
@@ -219,7 +219,7 @@ export async function getTopProducts(limit: number = 5): Promise<ProductPerforma
|
||||
const params: unknown[] = [startDate, endDate];
|
||||
let brandFilter = "";
|
||||
if (brandId) {
|
||||
brandFilter = "AND o.tenant_id = $3::uuid";
|
||||
brandFilter = "AND o.brand_id = $3::uuid";
|
||||
params.push(brandId);
|
||||
}
|
||||
|
||||
@@ -270,7 +270,7 @@ export async function getRecentOrders(limit: number = 10): Promise<RecentOrder[]
|
||||
const params: unknown[] = [];
|
||||
let brandFilter = "";
|
||||
if (brandId) {
|
||||
brandFilter = "WHERE tenant_id = $1::uuid";
|
||||
brandFilter = "WHERE brand_id = $1::uuid";
|
||||
params.push(brandId);
|
||||
}
|
||||
const cappedLimit = Math.max(1, Math.min(limit, 100));
|
||||
@@ -322,7 +322,7 @@ export async function getCustomerGrowth(): Promise<CustomerGrowth> {
|
||||
const params: unknown[] = [startDate, endDate];
|
||||
let brandFilter = "";
|
||||
if (brandId) {
|
||||
brandFilter = "AND tenant_id = $3::uuid";
|
||||
brandFilter = "AND brand_id = $3::uuid";
|
||||
params.push(brandId);
|
||||
}
|
||||
|
||||
@@ -365,7 +365,7 @@ export async function getConversionFunnel(): Promise<ConversionFunnel[]> {
|
||||
const params: unknown[] = [];
|
||||
let brandFilter = "";
|
||||
if (brandId) {
|
||||
brandFilter = "WHERE tenant_id = $1::uuid";
|
||||
brandFilter = "WHERE brand_id = $1::uuid";
|
||||
params.push(brandId);
|
||||
}
|
||||
const { rows } = await pool.query<{ status: string }>(
|
||||
|
||||
@@ -1,44 +1,14 @@
|
||||
"use server";
|
||||
|
||||
import "server-only";
|
||||
import { signIn, signOut } from "@/lib/auth";
|
||||
import { signOut } from "@/lib/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
/**
|
||||
* Kick off the Google OAuth flow. Auth.js will redirect to Google's
|
||||
* consent screen and then back to /api/auth/callback/google, which sets
|
||||
* the session cookie and redirects to /admin.
|
||||
*/
|
||||
export async function signInWithGoogle(): Promise<void> {
|
||||
await signIn("google", { redirectTo: "/admin" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign in with email + password. The `credentials` provider is enabled
|
||||
* in dev (see `isDevLoginEnabled()` in `src/auth.config.ts`); in
|
||||
* production it is omitted entirely and this action returns an
|
||||
* `AuthError` (Auth.js surfaces `?error=Configuration` on /login).
|
||||
*
|
||||
* On a failed credential check Auth.js redirects back to
|
||||
* /login?error=CredentialsSignin, which the LoginClient renders as
|
||||
* "Invalid email or password."
|
||||
*/
|
||||
export async function signInWithCredentials(formData: FormData): Promise<void> {
|
||||
const email = String(formData.get("email") ?? "").trim().toLowerCase();
|
||||
const password = String(formData.get("password") ?? "");
|
||||
if (!email || !password) {
|
||||
redirect("/login?error=MissingCredentials");
|
||||
}
|
||||
await signIn("credentials", {
|
||||
email,
|
||||
password,
|
||||
redirectTo: "/admin",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign out and clear the Auth.js session cookie.
|
||||
* Sign out and clear the Neon Auth session cookie.
|
||||
*/
|
||||
export async function signOutAction(): Promise<void> {
|
||||
await signOut({ redirectTo: "/login" });
|
||||
console.log("[auth/sign-out] Signing out");
|
||||
await signOut();
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { signIn, signOut } from "@/lib/auth";
|
||||
|
||||
/**
|
||||
* Server actions that wrap the Auth.js v5 `signIn` / `signOut` API for
|
||||
* use from client components.
|
||||
*
|
||||
* Why server actions?
|
||||
* • The Auth.js v5 `signIn` function has to run on the server (it
|
||||
* needs to set the session cookie, talk to the database adapter,
|
||||
* and redirect the user to the OAuth provider).
|
||||
* • Calling it from a client component via a server action keeps the
|
||||
* client bundle small and avoids exposing the OAuth client secret.
|
||||
*
|
||||
* Usage from a client component:
|
||||
* <form action={signInWithGoogle}>
|
||||
* <button type="submit">Sign in with Google</button>
|
||||
* </form>
|
||||
*
|
||||
* Note: dev/demo authentication is no longer a button on the login page.
|
||||
* `src/middleware.ts` auto-issues the `dev_session` cookie for /admin/*
|
||||
* when ALLOW_DEV_LOGIN is enabled. See CLAUDE.md.
|
||||
*/
|
||||
|
||||
export async function signInWithGoogle(): Promise<void> {
|
||||
await signIn("google", { redirectTo: "/admin" });
|
||||
}
|
||||
|
||||
export async function signOutAction(): Promise<void> {
|
||||
await signOut({ redirectTo: "/login" });
|
||||
}
|
||||
@@ -37,7 +37,7 @@
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { pool } from "@/lib/db";
|
||||
import { withTenant } from "@/db/client";
|
||||
import { withBrand } from "@/db/client";
|
||||
import { products } from "@/db/schema";
|
||||
import { and, eq, count } from "drizzle-orm";
|
||||
import { ADDONS, PLAN_TIERS, type AddonKey, type PlanTierKey } from "@/lib/pricing";
|
||||
@@ -113,14 +113,14 @@ export async function getBillingOverview(
|
||||
COALESCE(t.max_stops_monthly, 10) AS max_stops_monthly,
|
||||
COALESCE(t.max_products, 25) AS max_products,
|
||||
jsonb_build_object(
|
||||
'users', (SELECT count(*)::int FROM tenant_users tu WHERE tu.tenant_id = t.id),
|
||||
'users', (SELECT count(*)::int FROM admin_user_brands tu WHERE tu.brand_id = t.id),
|
||||
'stops_this_month', (SELECT count(*)::int FROM stops s
|
||||
WHERE s.tenant_id = t.id
|
||||
WHERE s.brand_id = t.id
|
||||
AND s.created_at >= date_trunc('month', now())),
|
||||
'products', (SELECT count(*)::int FROM products p
|
||||
WHERE p.tenant_id = t.id AND p.active = true AND p.deleted_at IS NULL)
|
||||
WHERE p.brand_id = t.id AND p.active = true AND p.deleted_at IS NULL)
|
||||
) AS usage
|
||||
FROM tenants t
|
||||
FROM brands t
|
||||
WHERE t.id = $1`,
|
||||
[brandId]
|
||||
);
|
||||
@@ -136,14 +136,14 @@ export async function getBillingOverview(
|
||||
}>(
|
||||
`SELECT name, stripe_customer_id, NULL::text AS stripe_subscription_id,
|
||||
NULL::text AS stripe_subscription_status, NULL::text AS stripe_current_period_end
|
||||
FROM tenants WHERE id = $1`,
|
||||
FROM brands WHERE id = $1`,
|
||||
[brandId]
|
||||
);
|
||||
const brand = brandRes.rows[0] ?? null;
|
||||
|
||||
// 3) Enabled add-ons (feature flags from brand_settings)
|
||||
const addonsRes = await pool.query<{ feature_flags: Record<string, unknown> | null }>(
|
||||
`SELECT feature_flags FROM brand_settings WHERE tenant_id = $1`,
|
||||
`SELECT feature_flags FROM brand_settings WHERE brand_id = $1`,
|
||||
[brandId]
|
||||
);
|
||||
const flags = addonsRes.rows[0]?.feature_flags ?? {};
|
||||
@@ -155,13 +155,13 @@ export async function getBillingOverview(
|
||||
// 4) Active product count — same semantics as the dashboard
|
||||
// (active=true). Keeps the billing page in sync with /admin
|
||||
// "Active Products" stat.
|
||||
const productCountRows = await withTenant(brandId, (db) =>
|
||||
const productCountRows = await withBrand(brandId, (db) =>
|
||||
db
|
||||
.select({ value: count() })
|
||||
.from(products)
|
||||
.where(
|
||||
and(
|
||||
eq(products.tenantId, brandId),
|
||||
eq(products.brandId, brandId),
|
||||
eq(products.active, true)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -9,6 +9,9 @@ type LineItem = {
|
||||
quantity: number;
|
||||
};
|
||||
|
||||
// Stripe API version type - using const assertion for type safety
|
||||
type StripeApiVersion = "2026-05-27.dahlia";
|
||||
|
||||
export async function createRetailStripeCheckoutSession(
|
||||
items: LineItem[],
|
||||
orderId: string,
|
||||
@@ -20,7 +23,7 @@ export async function createRetailStripeCheckoutSession(
|
||||
if (!stripeKey) return { success: false, error: "Stripe not configured on this server." };
|
||||
|
||||
const Stripe = (await import("stripe")).default;
|
||||
const stripe = new Stripe(stripeKey, { apiVersion: "2026-04-22.dahlia" as any });
|
||||
const stripe = new Stripe(stripeKey, { apiVersion: "2026-05-27.dahlia" as StripeApiVersion });
|
||||
|
||||
const lineItems = items.map((item) => ({
|
||||
price_data: {
|
||||
@@ -35,7 +38,7 @@ export async function createRetailStripeCheckoutSession(
|
||||
let brandName = "Route Commerce";
|
||||
try {
|
||||
const brandRes = await pool.query<{ name: string }>(
|
||||
"SELECT name FROM tenants WHERE id = $1 LIMIT 1",
|
||||
"SELECT name FROM brands WHERE id = $1 LIMIT 1",
|
||||
[brandId]
|
||||
);
|
||||
if (brandRes.rows[0]?.name) brandName = brandRes.rows[0].name;
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
import { pool } from "@/lib/db";
|
||||
|
||||
// Stripe API version type
|
||||
type StripeApiVersion = "2026-05-27.dahlia";
|
||||
|
||||
/**
|
||||
* Creates a Stripe PaymentIntent for the supplied cart so the browser
|
||||
* can confirm the payment with embedded Stripe Elements (Apple Pay /
|
||||
@@ -46,7 +49,7 @@ export async function createRetailPaymentIntent(
|
||||
}
|
||||
|
||||
const Stripe = (await import("stripe")).default;
|
||||
const stripe = new Stripe(stripeKey, { apiVersion: "2026-04-22.dahlia" as any });
|
||||
const stripe = new Stripe(stripeKey, { apiVersion: "2026-05-27.dahlia" as StripeApiVersion });
|
||||
|
||||
// Compute the subtotal in cents. We don't compute sales tax here —
|
||||
// Stripe's `automatic_tax` would be ideal but requires address collection
|
||||
@@ -67,7 +70,7 @@ export async function createRetailPaymentIntent(
|
||||
if (brandId) {
|
||||
try {
|
||||
const brandRes = await pool.query<{ name: string }>(
|
||||
"SELECT name FROM tenants WHERE id = $1 LIMIT 1",
|
||||
"SELECT name FROM brands WHERE id = $1 LIMIT 1",
|
||||
[brandId]
|
||||
);
|
||||
if (brandRes.rows[0]?.name) brandName = brandRes.rows[0].name;
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { pool } from "@/lib/db";
|
||||
|
||||
// Stripe API version type - using const assertion for type safety
|
||||
type StripeApiVersion = "2026-05-27.dahlia";
|
||||
|
||||
// ── Price ID config ────────────────────────────────────────────────────────────
|
||||
// Maps plan/addon keys to Stripe price IDs via environment variables
|
||||
|
||||
@@ -45,7 +48,7 @@ export async function createStripeCheckoutSession(
|
||||
|
||||
// Get brand's Stripe customer ID
|
||||
const custRes = await pool.query<{ stripe_customer_id: string | null; name: string }>(
|
||||
"SELECT stripe_customer_id, name FROM tenants WHERE id = $1 LIMIT 1",
|
||||
"SELECT stripe_customer_id, name FROM brands WHERE id = $1 LIMIT 1",
|
||||
[brandId]
|
||||
);
|
||||
const brand = custRes.rows[0];
|
||||
@@ -54,7 +57,7 @@ export async function createStripeCheckoutSession(
|
||||
}
|
||||
|
||||
const Stripe = (await import("stripe")).default;
|
||||
const stripe = new Stripe(stripeKey, { apiVersion: "2026-04-22.dahlia" as any });
|
||||
const stripe = new Stripe(stripeKey, { apiVersion: "2026-05-27.dahlia" as StripeApiVersion });
|
||||
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000";
|
||||
|
||||
@@ -121,7 +124,7 @@ export async function cancelAddonSubscription(
|
||||
|
||||
// Get active subscription for this brand
|
||||
const subRes = await pool.query<{ stripe_subscription_id: string | null }>(
|
||||
"SELECT stripe_subscription_id FROM subscriptions WHERE tenant_id = $1 LIMIT 1",
|
||||
"SELECT stripe_subscription_id FROM subscriptions WHERE brand_id = $1 LIMIT 1",
|
||||
[brandId]
|
||||
);
|
||||
if (subRes.rows.length === 0) {
|
||||
@@ -133,7 +136,7 @@ export async function cancelAddonSubscription(
|
||||
}
|
||||
|
||||
const Stripe = (await import("stripe")).default;
|
||||
const stripe = new Stripe(stripeKey, { apiVersion: "2026-04-22.dahlia" as any });
|
||||
const stripe = new Stripe(stripeKey, { apiVersion: "2026-05-27.dahlia" as StripeApiVersion });
|
||||
|
||||
// Retrieve subscription and find the item for this add-on
|
||||
const subscription = await stripe.subscriptions.retrieve(subData.stripe_subscription_id);
|
||||
|
||||
@@ -3,6 +3,29 @@
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { pool } from "@/lib/db";
|
||||
|
||||
// Stripe API version type
|
||||
type StripeApiVersion = "2026-05-27.dahlia";
|
||||
|
||||
// Type for plan info response
|
||||
type PlanInfo = {
|
||||
plan_tier: string;
|
||||
plan_name: string | null;
|
||||
max_users: number;
|
||||
max_stops_monthly: number;
|
||||
max_products: number;
|
||||
usage: { users: number; stops_this_month: number; products: number } | null;
|
||||
};
|
||||
|
||||
// Type for wholesale order
|
||||
type WholesaleOrder = {
|
||||
id: string;
|
||||
created_at: Date;
|
||||
customer_name: string;
|
||||
total: number;
|
||||
status: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export async function getStripeBillingPortalUrl(brandId: string): Promise<{ success: boolean; url?: string; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
@@ -15,7 +38,7 @@ export async function getStripeBillingPortalUrl(brandId: string): Promise<{ succ
|
||||
|
||||
// Get stripe_customer_id from tenants table
|
||||
const custRes = await pool.query<{ stripe_customer_id: string | null }>(
|
||||
"SELECT stripe_customer_id FROM tenants WHERE id = $1 LIMIT 1",
|
||||
"SELECT stripe_customer_id FROM brands WHERE id = $1 LIMIT 1",
|
||||
[brandId]
|
||||
);
|
||||
const stripeCustomerId = custRes.rows[0]?.stripe_customer_id;
|
||||
@@ -25,7 +48,7 @@ export async function getStripeBillingPortalUrl(brandId: string): Promise<{ succ
|
||||
}
|
||||
|
||||
const Stripe = (await import("stripe")).default;
|
||||
const stripe = new Stripe(stripeKey, { apiVersion: "2026-04-22.dahlia" as any });
|
||||
const stripe = new Stripe(stripeKey, { apiVersion: "2026-05-27.dahlia" as StripeApiVersion });
|
||||
|
||||
const session = await stripe.billingPortal.sessions.create({
|
||||
customer: stripeCustomerId,
|
||||
@@ -74,7 +97,7 @@ export async function updateBrandStripeCustomerId(brandId: string, stripeCustome
|
||||
}
|
||||
}
|
||||
|
||||
export async function getBrandPlanInfo(brandId: string): Promise<{ success: boolean; data?: any; error?: string }> {
|
||||
export async function getBrandPlanInfo(brandId: string): Promise<{ success: boolean; data?: PlanInfo; error?: string }> {
|
||||
// Replicate get_brand_plan_info via a JOIN on tenants + plans
|
||||
const res = await pool.query<{
|
||||
plan_tier: string;
|
||||
@@ -91,14 +114,14 @@ export async function getBrandPlanInfo(brandId: string): Promise<{ success: bool
|
||||
COALESCE(t.max_stops_monthly, 10) AS max_stops_monthly,
|
||||
COALESCE(t.max_products, 25) AS max_products,
|
||||
jsonb_build_object(
|
||||
'users', (SELECT count(*)::int FROM tenant_users tu WHERE tu.tenant_id = t.id),
|
||||
'users', (SELECT count(*)::int FROM admin_user_brands tu WHERE tu.brand_id = t.id),
|
||||
'stops_this_month', (SELECT count(*)::int FROM stops s
|
||||
WHERE s.tenant_id = t.id
|
||||
WHERE s.brand_id = t.id
|
||||
AND s.created_at >= date_trunc('month', now())),
|
||||
'products', (SELECT count(*)::int FROM products p
|
||||
WHERE p.tenant_id = t.id AND p.active = true AND p.deleted_at IS NULL)
|
||||
WHERE p.brand_id = t.id AND p.active = true AND p.deleted_at IS NULL)
|
||||
) AS usage
|
||||
FROM tenants t
|
||||
FROM brands t
|
||||
WHERE t.id = $1`,
|
||||
[brandId]
|
||||
);
|
||||
@@ -111,7 +134,7 @@ export async function getBrandPlanInfo(brandId: string): Promise<{ success: bool
|
||||
export async function getEnabledAddons(brandId: string): Promise<Record<string, boolean>> {
|
||||
// get_brand_features returns JSONB — a single object, not an array
|
||||
const res = await pool.query<{ feature_flags: Record<string, unknown> | null }>(
|
||||
"SELECT feature_flags FROM brand_settings WHERE tenant_id = $1 LIMIT 1",
|
||||
"SELECT feature_flags FROM brand_settings WHERE brand_id = $1 LIMIT 1",
|
||||
[brandId]
|
||||
);
|
||||
const flags = res.rows[0]?.feature_flags ?? {};
|
||||
@@ -123,7 +146,7 @@ export async function getEnabledAddons(brandId: string): Promise<Record<string,
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function getRecentWholesaleOrders(brandId: string, limit = 20): Promise<any[]> {
|
||||
export async function getRecentWholesaleOrders(brandId: string, limit = 20): Promise<WholesaleOrder[]> {
|
||||
try {
|
||||
const res = await pool.query(
|
||||
"SELECT * FROM get_wholesale_orders($1)",
|
||||
|
||||
@@ -2,21 +2,16 @@
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { pool } from "@/lib/db";
|
||||
import { uploadObject, BUCKETS } from "@/lib/storage";
|
||||
|
||||
export type UploadLogoResult =
|
||||
| { success: true; logoUrl: string }
|
||||
| { success: false; error: string };
|
||||
|
||||
/**
|
||||
* Upload a brand logo (light or dark variant) to Supabase Storage and
|
||||
* save the resulting public URL to the brand_settings row via the
|
||||
* Upload a brand logo (light or dark variant) to MinIO and save the
|
||||
* resulting public URL to the brand_settings row via the
|
||||
* `upsert_brand_settings` SECURITY DEFINER RPC.
|
||||
*
|
||||
* NOTE: the storage PUT itself is not a database call, so it still
|
||||
* goes through the Supabase Storage REST API. Storage migration to an
|
||||
* S3-compatible backend is tracked separately; until that lands, the
|
||||
* public URL pattern (`/storage/v1/object/public/...`) keeps working
|
||||
* for any pre-existing bucket.
|
||||
*/
|
||||
export async function uploadBrandLogo(
|
||||
brandId: string,
|
||||
@@ -42,37 +37,28 @@ export async function uploadBrandLogo(
|
||||
|
||||
const ext = file.type === "image/svg+xml" ? "svg" : file.type.split("/")[1];
|
||||
const path = isDark ? `logo-dark.${ext}` : `logo.${ext}`;
|
||||
const storagePath = `brand-logos/${brandId}/${path}`;
|
||||
const storageKey = `brand-logos/${brandId}/${path}`;
|
||||
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
const uploadRes = await fetch(
|
||||
`${supabaseUrl}/storage/v1/object/brand-logos/${storagePath}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { apikey: supabaseKey, "Content-Type": file.type, "x-upsert": "true" },
|
||||
try {
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
const logoUrl = await uploadObject({
|
||||
bucket: BUCKETS.BRAND_LOGOS,
|
||||
key: storageKey,
|
||||
body: buffer,
|
||||
contentType: file.type,
|
||||
});
|
||||
|
||||
const saveOk = await callUpsertBrandSettings(brandId, {
|
||||
[isDark ? "p_logo_url_dark" : "p_logo_url"]: logoUrl,
|
||||
});
|
||||
if (!saveOk) {
|
||||
return { success: false, error: "Upload succeeded but failed to save URL" };
|
||||
}
|
||||
);
|
||||
|
||||
if (!uploadRes.ok) {
|
||||
return { success: false, error: `Upload failed: ${await uploadRes.text()}` };
|
||||
return { success: true, logoUrl };
|
||||
} catch (err) {
|
||||
return { success: false, error: `Upload failed: ${err instanceof Error ? err.message : String(err)}` };
|
||||
}
|
||||
|
||||
const publicUrl = `${supabaseUrl}/storage/v1/object/public/brand-logos/${storagePath}`;
|
||||
|
||||
const saveOk = await callUpsertBrandSettings(brandId, {
|
||||
[isDark ? "p_logo_url_dark" : "p_logo_url"]: publicUrl,
|
||||
});
|
||||
if (!saveOk) {
|
||||
return { success: false, error: "Upload succeeded but failed to save URL" };
|
||||
}
|
||||
|
||||
return { success: true, logoUrl: publicUrl };
|
||||
}
|
||||
|
||||
export async function uploadOlatheSweetLogo(
|
||||
@@ -97,37 +83,28 @@ export async function uploadOlatheSweetLogo(
|
||||
}
|
||||
|
||||
const ext = file.type === "image/svg+xml" ? "svg" : file.type.split("/")[1];
|
||||
const storagePath = `brand-logos/${brandId}/olathe-sweet-logo.${ext}`;
|
||||
const storageKey = `brand-logos/${brandId}/olathe-sweet-logo.${ext}`;
|
||||
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
const uploadRes = await fetch(
|
||||
`${supabaseUrl}/storage/v1/object/brand-logos/${storagePath}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { apikey: supabaseKey, "Content-Type": file.type, "x-upsert": "true" },
|
||||
try {
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
const logoUrl = await uploadObject({
|
||||
bucket: BUCKETS.BRAND_LOGOS,
|
||||
key: storageKey,
|
||||
body: buffer,
|
||||
contentType: file.type,
|
||||
});
|
||||
|
||||
const saveOk = await callUpsertBrandSettings(brandId, {
|
||||
p_olathe_sweet_logo_url: logoUrl,
|
||||
});
|
||||
if (!saveOk) {
|
||||
return { success: false, error: "Upload succeeded but failed to save URL" };
|
||||
}
|
||||
);
|
||||
|
||||
if (!uploadRes.ok) {
|
||||
return { success: false, error: `Upload failed: ${await uploadRes.text()}` };
|
||||
return { success: true, logoUrl };
|
||||
} catch (err) {
|
||||
return { success: false, error: `Upload failed: ${err instanceof Error ? err.message : String(err)}` };
|
||||
}
|
||||
|
||||
const publicUrl = `${supabaseUrl}/storage/v1/object/public/brand-logos/${storagePath}`;
|
||||
|
||||
const saveOk = await callUpsertBrandSettings(brandId, {
|
||||
p_olathe_sweet_logo_url: publicUrl,
|
||||
});
|
||||
if (!saveOk) {
|
||||
return { success: false, error: "Upload succeeded but failed to save URL" };
|
||||
}
|
||||
|
||||
return { success: true, logoUrl: publicUrl };
|
||||
}
|
||||
|
||||
export async function uploadOlatheSweetLogoDark(
|
||||
@@ -152,37 +129,28 @@ export async function uploadOlatheSweetLogoDark(
|
||||
}
|
||||
|
||||
const ext = file.type === "image/svg+xml" ? "svg" : file.type.split("/")[1];
|
||||
const storagePath = `brand-logos/${brandId}/olathe-sweet-logo-dark.${ext}`;
|
||||
const storageKey = `brand-logos/${brandId}/olathe-sweet-logo-dark.${ext}`;
|
||||
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
const uploadRes = await fetch(
|
||||
`${supabaseUrl}/storage/v1/object/brand-logos/${storagePath}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { apikey: supabaseKey, "Content-Type": file.type, "x-upsert": "true" },
|
||||
try {
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
const logoUrl = await uploadObject({
|
||||
bucket: BUCKETS.BRAND_LOGOS,
|
||||
key: storageKey,
|
||||
body: buffer,
|
||||
contentType: file.type,
|
||||
});
|
||||
|
||||
const saveOk = await callUpsertBrandSettings(brandId, {
|
||||
p_olathe_sweet_logo_url_dark: logoUrl,
|
||||
});
|
||||
if (!saveOk) {
|
||||
return { success: false, error: "Upload succeeded but failed to save URL" };
|
||||
}
|
||||
);
|
||||
|
||||
if (!uploadRes.ok) {
|
||||
return { success: false, error: `Upload failed: ${await uploadRes.text()}` };
|
||||
return { success: true, logoUrl };
|
||||
} catch (err) {
|
||||
return { success: false, error: `Upload failed: ${err instanceof Error ? err.message : String(err)}` };
|
||||
}
|
||||
|
||||
const publicUrl = `${supabaseUrl}/storage/v1/object/public/brand-logos/${storagePath}`;
|
||||
|
||||
const saveOk = await callUpsertBrandSettings(brandId, {
|
||||
p_olathe_sweet_logo_url_dark: publicUrl,
|
||||
});
|
||||
if (!saveOk) {
|
||||
return { success: false, error: "Upload succeeded but failed to save URL" };
|
||||
}
|
||||
|
||||
return { success: true, logoUrl: publicUrl };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -110,8 +110,24 @@ export async function createOrder(
|
||||
// Send order receipt email
|
||||
try {
|
||||
const { sendOrderReceiptEmail } = await import("@/lib/email-service");
|
||||
const { getBrandSettingsPublic } = await import("@/actions/brand-settings");
|
||||
const rawItems = data.items ?? [];
|
||||
const taxAmount = (data as { tax_amount?: number }).tax_amount ?? 0;
|
||||
|
||||
// Look up brand settings to get the logo URL
|
||||
let logoUrl: string | null = null;
|
||||
if (brandId) {
|
||||
// Resolve brand slug from brand ID, then fetch settings
|
||||
const { rows: brandRows } = await pool.query<{ slug: string }>(
|
||||
"SELECT slug FROM brands WHERE id = $1",
|
||||
[brandId]
|
||||
);
|
||||
if (brandRows[0]) {
|
||||
const settings = await getBrandSettingsPublic(brandRows[0].slug);
|
||||
logoUrl = settings.success ? (settings.settings?.logo_url ?? null) : null;
|
||||
}
|
||||
}
|
||||
|
||||
await sendOrderReceiptEmail({
|
||||
customerName,
|
||||
customerEmail,
|
||||
@@ -131,6 +147,7 @@ export async function createOrder(
|
||||
stopTime: data.stop_time ?? undefined,
|
||||
stopLocation: data.stop_location ?? undefined,
|
||||
brandName: "Tuxedo Corn",
|
||||
logoUrl,
|
||||
});
|
||||
} catch {
|
||||
// Email failure should not fail the order
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { and, desc, eq, SQL } from "drizzle-orm";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import { withTenant, withPlatformAdmin } from "@/db/client";
|
||||
import { withBrand, withPlatformAdmin } from "@/db/client";
|
||||
import { campaigns, emailTemplates } from "@/db/schema";
|
||||
|
||||
export type CampaignType = "marketing" | "operational" | "transactional";
|
||||
@@ -83,7 +83,7 @@ function stripHtml(html: string | null): string {
|
||||
function rowToCampaign(c: CampaignRow, t?: TemplateRow | null): Campaign {
|
||||
return {
|
||||
id: c.id,
|
||||
brand_id: c.tenantId,
|
||||
brand_id: c.brandId,
|
||||
name: c.name,
|
||||
subject: t?.subject ?? null,
|
||||
body_text: stripHtml(t?.bodyHtml ?? null),
|
||||
@@ -111,7 +111,7 @@ export async function getCommunicationCampaigns(brandId?: string): Promise<ListC
|
||||
|
||||
try {
|
||||
const rows = activeBrandId
|
||||
? await withTenant(activeBrandId, (db) =>
|
||||
? await withBrand(activeBrandId, (db) =>
|
||||
db
|
||||
.select({
|
||||
campaign: campaigns,
|
||||
@@ -178,7 +178,7 @@ export async function upsertCampaign(params: {
|
||||
const subject = params.subject ?? "";
|
||||
const bodyHtml = params.body_html ?? (params.body_text ? `<p style="white-space:pre-wrap">${escapeHtml(params.body_text)}</p>` : "<p></p>");
|
||||
|
||||
const { row, template } = await withTenant(params.brand_id, async (db) => {
|
||||
const { row, template } = await withBrand(params.brand_id, async (db) => {
|
||||
// Resolve / create the linked email_templates row.
|
||||
let templateId: string | null = params.template_id ?? null;
|
||||
let templateRow: TemplateRow | null = null;
|
||||
@@ -187,7 +187,7 @@ export async function upsertCampaign(params: {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(emailTemplates)
|
||||
.where(and(eq(emailTemplates.id, templateId), eq(emailTemplates.tenantId, params.brand_id)))
|
||||
.where(and(eq(emailTemplates.id, templateId), eq(emailTemplates.brandId, params.brand_id)))
|
||||
.limit(1);
|
||||
if (existing[0]) {
|
||||
const updated = await db
|
||||
@@ -207,7 +207,7 @@ export async function upsertCampaign(params: {
|
||||
const inserted = await db
|
||||
.insert(emailTemplates)
|
||||
.values({
|
||||
tenantId: params.brand_id,
|
||||
brandId: params.brand_id,
|
||||
name: params.name,
|
||||
subject,
|
||||
bodyHtml,
|
||||
@@ -229,7 +229,7 @@ export async function upsertCampaign(params: {
|
||||
scheduledFor: scheduled,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(campaigns.id, params.id), eq(campaigns.tenantId, params.brand_id)))
|
||||
.where(and(eq(campaigns.id, params.id), eq(campaigns.brandId, params.brand_id)))
|
||||
.returning();
|
||||
if (!updated[0]) {
|
||||
return { row: null, template: null };
|
||||
@@ -239,7 +239,7 @@ export async function upsertCampaign(params: {
|
||||
const inserted = await db
|
||||
.insert(campaigns)
|
||||
.values({
|
||||
tenantId: params.brand_id,
|
||||
brandId: params.brand_id,
|
||||
name: params.name,
|
||||
templateId,
|
||||
status,
|
||||
@@ -274,8 +274,8 @@ export async function deleteCampaign(campaignId: string, brandId?: string): Prom
|
||||
|
||||
try {
|
||||
if (activeBrandId) {
|
||||
await withTenant(activeBrandId, (db) =>
|
||||
db.delete(campaigns).where(and(eq(campaigns.id, campaignId), eq(campaigns.tenantId, activeBrandId))),
|
||||
await withBrand(activeBrandId, (db) =>
|
||||
db.delete(campaigns).where(and(eq(campaigns.id, campaignId), eq(campaigns.brandId, activeBrandId))),
|
||||
);
|
||||
} else {
|
||||
await withPlatformAdmin((db) => db.delete(campaigns).where(eq(campaigns.id, campaignId)));
|
||||
@@ -297,12 +297,12 @@ export async function getCampaignById(campaignId: string, brandId?: string): Pro
|
||||
|
||||
try {
|
||||
const result = activeBrandId
|
||||
? await withTenant(activeBrandId, (db) =>
|
||||
? await withBrand(activeBrandId, (db) =>
|
||||
db
|
||||
.select({ campaign: campaigns, template: emailTemplates })
|
||||
.from(campaigns)
|
||||
.leftJoin(emailTemplates, eq(emailTemplates.id, campaigns.templateId))
|
||||
.where(and(eq(campaigns.id, campaignId), eq(campaigns.tenantId, activeBrandId)))
|
||||
.where(and(eq(campaigns.id, campaignId), eq(campaigns.brandId, activeBrandId)))
|
||||
.limit(1)
|
||||
.then((r) => r[0] ?? null),
|
||||
)
|
||||
@@ -318,7 +318,7 @@ export async function getCampaignById(campaignId: string, brandId?: string): Pro
|
||||
|
||||
if (!result) return null;
|
||||
|
||||
if (adminUser.role === "brand_admin" && result.campaign.tenantId !== adminUser.brand_id) {
|
||||
if (adminUser.role === "brand_admin" && result.campaign.brandId !== adminUser.brand_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { and, eq, ilike, or, sql, SQL } from "drizzle-orm";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { parseCSVWithLimits } from "@/lib/csv-parser";
|
||||
import { buildImportPreview } from "@/lib/column-detector";
|
||||
import { withTenant, withPlatformAdmin } from "@/db/client";
|
||||
import { withBrand, withPlatformAdmin } from "@/db/client";
|
||||
import { customers } from "@/db/schema";
|
||||
|
||||
export type ContactSource = "order" | "import" | "manual" | "admin";
|
||||
@@ -19,7 +19,7 @@ export type ContactSource = "order" | "import" | "manual" | "admin";
|
||||
*/
|
||||
export type Contact = {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
brand_id: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
full_name: string;
|
||||
@@ -101,23 +101,9 @@ export type GetContactsResult = {
|
||||
};
|
||||
|
||||
function rowToContact(row: typeof customers.$inferSelect): Contact {
|
||||
// Derive first/last name from the stored single `name` column.
|
||||
// Multi-word names split on the first whitespace; single-word names
|
||||
// populate first_name and leave last_name null.
|
||||
const fullName = row.name ?? "";
|
||||
const trimmed = fullName.trim();
|
||||
let firstName: string | null = null;
|
||||
let lastName: string | null = null;
|
||||
if (trimmed) {
|
||||
const idx = trimmed.indexOf(" ");
|
||||
if (idx === -1) {
|
||||
firstName = trimmed;
|
||||
} else {
|
||||
firstName = trimmed.slice(0, idx);
|
||||
const rest = trimmed.slice(idx + 1).trim();
|
||||
lastName = rest.length > 0 ? rest : null;
|
||||
}
|
||||
}
|
||||
const firstName = row.firstName;
|
||||
const lastName = row.lastName;
|
||||
const fullName = [firstName, lastName].filter(Boolean).join(" ") || "";
|
||||
|
||||
// Derive unsubscribed_at: the new schema only has opt-in flags, not
|
||||
// a timestamp. Mark the unsubscribe moment as updatedAt if opted out
|
||||
@@ -126,10 +112,10 @@ function rowToContact(row: typeof customers.$inferSelect): Contact {
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
tenant_id: row.tenantId,
|
||||
brand_id: row.brandId,
|
||||
email: row.email,
|
||||
phone: row.phone,
|
||||
full_name: row.name,
|
||||
full_name: row.fullName ?? "",
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
source: "manual",
|
||||
@@ -162,13 +148,13 @@ export async function getContacts(params: {
|
||||
const search = params.search?.trim() ?? "";
|
||||
|
||||
try {
|
||||
const conds: SQL[] = [eq(customers.tenantId, params.brandId)];
|
||||
const conds: SQL[] = [eq(customers.brandId, params.brandId)];
|
||||
if (search) {
|
||||
const like = `%${search}%`;
|
||||
conds.push(or(ilike(customers.name, like), ilike(customers.email, like))!);
|
||||
conds.push(or(ilike(customers.firstName, like), ilike(customers.email, like))!);
|
||||
}
|
||||
|
||||
const rows = await withTenant(params.brandId, async (db) => {
|
||||
const rows = await withBrand(params.brandId, async (db) => {
|
||||
const [items, countRows] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
@@ -233,7 +219,7 @@ export async function upsertContact(contact: {
|
||||
return { success: false, error: "Not authorized for this brand" };
|
||||
}
|
||||
|
||||
const name =
|
||||
const fullName =
|
||||
contact.full_name?.trim() ||
|
||||
[contact.first_name, contact.last_name].filter(Boolean).join(" ").trim() ||
|
||||
contact.email ||
|
||||
@@ -243,18 +229,20 @@ export async function upsertContact(contact: {
|
||||
try {
|
||||
if (contact.id) {
|
||||
const contactId = contact.id;
|
||||
const updated = await withTenant(contact.brand_id, (db) =>
|
||||
const updated = await withBrand(contact.brand_id, (db) =>
|
||||
db
|
||||
.update(customers)
|
||||
.set({
|
||||
name,
|
||||
fullName,
|
||||
email: contact.email ?? null,
|
||||
phone: contact.phone ?? null,
|
||||
firstName: contact.first_name ?? null,
|
||||
lastName: contact.last_name ?? null,
|
||||
smsOptIn: contact.sms_opt_in ?? false,
|
||||
emailOptIn: contact.email_opt_in ?? true,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(customers.id, contactId), eq(customers.tenantId, contact.brand_id)))
|
||||
.where(and(eq(customers.id, contactId), eq(customers.brandId, contact.brand_id)))
|
||||
.returning(),
|
||||
);
|
||||
const row = updated[0];
|
||||
@@ -262,23 +250,25 @@ export async function upsertContact(contact: {
|
||||
return { success: true, contact: rowToContact(row) };
|
||||
}
|
||||
|
||||
// INSERT — de-dupe on (tenant_id, email) when email is provided
|
||||
// INSERT — de-dupe on (brand_id, email) when email is provided
|
||||
const contactEmail = contact.email;
|
||||
if (contactEmail) {
|
||||
const existing = await withTenant(contact.brand_id, (db) =>
|
||||
const existing = await withBrand(contact.brand_id, (db) =>
|
||||
db
|
||||
.select()
|
||||
.from(customers)
|
||||
.where(and(eq(customers.email, contactEmail), eq(customers.tenantId, contact.brand_id)))
|
||||
.where(and(eq(customers.email, contactEmail), eq(customers.brandId, contact.brand_id)))
|
||||
.limit(1),
|
||||
);
|
||||
if (existing[0]) {
|
||||
const updated = await withTenant(contact.brand_id, (db) =>
|
||||
const updated = await withBrand(contact.brand_id, (db) =>
|
||||
db
|
||||
.update(customers)
|
||||
.set({
|
||||
name,
|
||||
fullName,
|
||||
phone: contact.phone ?? null,
|
||||
firstName: contact.first_name ?? null,
|
||||
lastName: contact.last_name ?? null,
|
||||
smsOptIn: contact.sms_opt_in ?? existing[0].smsOptIn,
|
||||
emailOptIn: contact.email_opt_in ?? existing[0].emailOptIn,
|
||||
updatedAt: new Date(),
|
||||
@@ -292,14 +282,16 @@ export async function upsertContact(contact: {
|
||||
}
|
||||
}
|
||||
|
||||
const inserted = await withTenant(contact.brand_id, (db) =>
|
||||
const inserted = await withBrand(contact.brand_id, (db) =>
|
||||
db
|
||||
.insert(customers)
|
||||
.values({
|
||||
tenantId: contact.brand_id,
|
||||
name,
|
||||
brandId: contact.brand_id,
|
||||
fullName,
|
||||
email: contact.email ?? null,
|
||||
phone: contact.phone ?? null,
|
||||
firstName: contact.first_name ?? null,
|
||||
lastName: contact.last_name ?? null,
|
||||
smsOptIn: contact.sms_opt_in ?? false,
|
||||
emailOptIn: contact.email_opt_in ?? true,
|
||||
})
|
||||
@@ -328,7 +320,7 @@ export type ImportContactsResult = {
|
||||
* The legacy `import_communication_contacts_batch` RPC is replaced with
|
||||
* an in-process batch: parse → dedupe → upsert per row, returning the
|
||||
* same ImportResult shape. This avoids a round-trip to the DB for each
|
||||
* row and keeps the call inside the `withTenant` transaction.
|
||||
* row and keeps the call inside the `withBrand` transaction.
|
||||
*/
|
||||
export async function importContactsBatch(params: {
|
||||
brandId: string;
|
||||
@@ -403,14 +395,14 @@ export async function optOutContact(params: {
|
||||
}
|
||||
|
||||
try {
|
||||
await withTenant(params.brandId, (db) =>
|
||||
await withBrand(params.brandId, (db) =>
|
||||
db
|
||||
.update(customers)
|
||||
.set({
|
||||
...(params.method === "email" ? { emailOptIn: false } : { smsOptIn: false }),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(customers.email, params.email), eq(customers.tenantId, params.brandId))),
|
||||
.where(and(eq(customers.email, params.email), eq(customers.brandId, params.brandId))),
|
||||
);
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
@@ -434,10 +426,10 @@ export async function deleteContact(id: string, brandId?: string): Promise<{ suc
|
||||
|
||||
try {
|
||||
if (brandId) {
|
||||
await withTenant(brandId, (db) =>
|
||||
await withBrand(brandId, (db) =>
|
||||
db
|
||||
.delete(customers)
|
||||
.where(and(eq(customers.id, id), eq(customers.tenantId, brandId))),
|
||||
.where(and(eq(customers.id, id), eq(customers.brandId, brandId))),
|
||||
);
|
||||
} else {
|
||||
// platform_admin fallback — by id only
|
||||
@@ -492,20 +484,20 @@ export async function exportContacts(params: {
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const rows = await withTenant(effectiveBrandId, (db) =>
|
||||
const rows = await withBrand(effectiveBrandId, (db) =>
|
||||
db
|
||||
.select()
|
||||
.from(customers)
|
||||
.where(
|
||||
params.search
|
||||
? and(
|
||||
eq(customers.tenantId, effectiveBrandId),
|
||||
eq(customers.brandId, effectiveBrandId),
|
||||
or(
|
||||
ilike(customers.name, `%${params.search}%`),
|
||||
ilike(customers.firstName, `%${params.search}%`),
|
||||
ilike(customers.email, `%${params.search}%`),
|
||||
),
|
||||
)
|
||||
: eq(customers.tenantId, effectiveBrandId),
|
||||
: eq(customers.brandId, effectiveBrandId),
|
||||
)
|
||||
.limit(batchSize)
|
||||
.offset(offset)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { withTenant, withPlatformAdmin } from "@/db/client";
|
||||
import { withBrand, withPlatformAdmin } from "@/db/client";
|
||||
import { files } from "@/db/schema";
|
||||
import {
|
||||
importContactsBatch,
|
||||
@@ -45,11 +45,11 @@ export async function uploadContactsToBucket(
|
||||
const storageKey = `imports/${brandId}/${timestamp}-${safeName}`;
|
||||
|
||||
try {
|
||||
const [row] = await withTenant(brandId, (db) =>
|
||||
const [row] = await withBrand(brandId, (db) =>
|
||||
db
|
||||
.insert(files)
|
||||
.values({
|
||||
tenantId: brandId,
|
||||
brandId: brandId,
|
||||
storageKey,
|
||||
mimeType: "text/csv",
|
||||
sizeBytes: file.size,
|
||||
@@ -130,7 +130,7 @@ export async function listImportHistory(
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
try {
|
||||
const rows = await withTenant(brandId, (db) =>
|
||||
const rows = await withBrand(brandId, (db) =>
|
||||
db
|
||||
.select({
|
||||
storageKey: files.storageKey,
|
||||
@@ -138,7 +138,7 @@ export async function listImportHistory(
|
||||
createdAt: files.createdAt,
|
||||
})
|
||||
.from(files)
|
||||
.where(and(eq(files.tenantId, brandId), eq(files.purpose, "contact_import")))
|
||||
.where(and(eq(files.brandId, brandId), eq(files.purpose, "contact_import")))
|
||||
.orderBy(desc(files.createdAt))
|
||||
.limit(limit),
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { withTenant } from "@/db/client";
|
||||
import { withBrand } from "@/db/client";
|
||||
import { brandSettings } from "@/db/schema";
|
||||
import type { AudienceRules } from "./campaigns";
|
||||
|
||||
@@ -56,22 +56,22 @@ function isSegment(v: unknown): v is Segment {
|
||||
}
|
||||
|
||||
async function loadSegments(brandId: string): Promise<Segment[]> {
|
||||
const rows = await withTenant(brandId, (db) =>
|
||||
const rows = await withBrand(brandId, (db) =>
|
||||
db
|
||||
.select({ flags: brandSettings.featureFlags })
|
||||
.from(brandSettings)
|
||||
.where(eq(brandSettings.tenantId, brandId))
|
||||
.where(eq(brandSettings.brandId, brandId))
|
||||
.limit(1),
|
||||
);
|
||||
return readSegments((rows[0]?.flags ?? null) as Record<string, unknown> | null);
|
||||
}
|
||||
|
||||
async function saveSegments(brandId: string, segments: Segment[]): Promise<void> {
|
||||
await withTenant(brandId, async (db) => {
|
||||
await withBrand(brandId, async (db) => {
|
||||
const existing = await db
|
||||
.select({ flags: brandSettings.featureFlags })
|
||||
.from(brandSettings)
|
||||
.where(eq(brandSettings.tenantId, brandId))
|
||||
.where(eq(brandSettings.brandId, brandId))
|
||||
.limit(1);
|
||||
const baseFlags = (existing[0]?.flags ?? {}) as Record<string, unknown>;
|
||||
const nextFlags: Record<string, unknown> = {
|
||||
@@ -81,7 +81,7 @@ async function saveSegments(brandId: string, segments: Segment[]): Promise<void>
|
||||
await db
|
||||
.update(brandSettings)
|
||||
.set({ featureFlags: nextFlags, updatedAt: new Date() })
|
||||
.where(eq(brandSettings.tenantId, brandId));
|
||||
.where(eq(brandSettings.brandId, brandId));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
import { and, eq, sql, SQL } from "drizzle-orm";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import { withTenant, withPlatformAdmin } from "@/db/client";
|
||||
import { withBrand, withPlatformAdmin } from "@/db/client";
|
||||
import { campaigns, customers } from "@/db/schema";
|
||||
import type { AudienceRules } from "./campaigns";
|
||||
|
||||
export type AudiencePreviewResult = {
|
||||
count: number;
|
||||
sample_customers: { id: string; email: string; name: string }[];
|
||||
sample_customers: { id: string; email: string; fullName: string | null }[];
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -36,23 +36,23 @@ export async function previewCampaignAudience(
|
||||
}
|
||||
|
||||
try {
|
||||
const rows = await withTenant(brandId, (db) =>
|
||||
const rows = await withBrand(brandId, (db) =>
|
||||
db
|
||||
.select({
|
||||
id: customers.id,
|
||||
email: customers.email,
|
||||
name: customers.name,
|
||||
fullName: customers.fullName,
|
||||
})
|
||||
.from(customers)
|
||||
.where(and(eq(customers.tenantId, brandId), eq(customers.emailOptIn, true)))
|
||||
.where(and(eq(customers.brandId, brandId), eq(customers.emailOptIn, true)))
|
||||
.limit(5),
|
||||
);
|
||||
|
||||
const countRows = await withTenant(brandId, (db) =>
|
||||
const countRows = await withBrand(brandId, (db) =>
|
||||
db
|
||||
.select({ value: sql<number>`count(*)::int` })
|
||||
.from(customers)
|
||||
.where(and(eq(customers.tenantId, brandId), eq(customers.emailOptIn, true))),
|
||||
.where(and(eq(customers.brandId, brandId), eq(customers.emailOptIn, true))),
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -60,7 +60,7 @@ export async function previewCampaignAudience(
|
||||
sample_customers: rows.map((r) => ({
|
||||
id: r.id,
|
||||
email: r.email ?? "",
|
||||
name: r.name,
|
||||
fullName: r.fullName,
|
||||
})),
|
||||
};
|
||||
} catch {
|
||||
@@ -153,11 +153,11 @@ export async function sendCampaign(campaignId: string, brandId?: string): Promis
|
||||
}
|
||||
|
||||
const conds: SQL[] = [eq(campaigns.id, campaignId)];
|
||||
if (activeBrandId) conds.push(eq(campaigns.tenantId, activeBrandId));
|
||||
if (activeBrandId) conds.push(eq(campaigns.brandId, activeBrandId));
|
||||
|
||||
try {
|
||||
const updated = activeBrandId
|
||||
? await withTenant(activeBrandId, (db) =>
|
||||
? await withBrand(activeBrandId, (db) =>
|
||||
db
|
||||
.update(campaigns)
|
||||
.set({ status: "sent", sentAt: new Date() })
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { withTenant } from "@/db/client";
|
||||
import { withBrand } from "@/db/client";
|
||||
import { brandSettings } from "@/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
@@ -47,15 +47,15 @@ function readFlag(
|
||||
|
||||
export async function getCommunicationSettings(brandId: string): Promise<CommunicationSettings | null> {
|
||||
try {
|
||||
const rows = await withTenant(brandId, (db) =>
|
||||
const rows = await withBrand(brandId, (db) =>
|
||||
db
|
||||
.select({
|
||||
tenantId: brandSettings.tenantId,
|
||||
brandId: brandSettings.brandId,
|
||||
featureFlags: brandSettings.featureFlags,
|
||||
updatedAt: brandSettings.updatedAt,
|
||||
})
|
||||
.from(brandSettings)
|
||||
.where(eq(brandSettings.tenantId, brandId))
|
||||
.where(eq(brandSettings.brandId, brandId))
|
||||
.limit(1),
|
||||
);
|
||||
|
||||
@@ -65,8 +65,8 @@ export async function getCommunicationSettings(brandId: string): Promise<Communi
|
||||
const flags = (row.featureFlags ?? {}) as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
id: row.tenantId,
|
||||
brand_id: row.tenantId,
|
||||
id: row.brandId,
|
||||
brand_id: row.brandId,
|
||||
default_sender_email: readFlag(flags, "comm_default_sender_email"),
|
||||
default_sender_name: readFlag(flags, "comm_default_sender_name"),
|
||||
reply_to_email: readFlag(flags, "comm_reply_to_email"),
|
||||
@@ -96,11 +96,11 @@ export async function upsertCommunicationSettings(params: {
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await withTenant(params.brand_id, async (db) => {
|
||||
const updated = await withBrand(params.brand_id, async (db) => {
|
||||
const existing = await db
|
||||
.select({ flags: brandSettings.featureFlags })
|
||||
.from(brandSettings)
|
||||
.where(eq(brandSettings.tenantId, params.brand_id))
|
||||
.where(eq(brandSettings.brandId, params.brand_id))
|
||||
.limit(1);
|
||||
const baseFlags = (existing[0]?.flags ?? {}) as Record<string, unknown>;
|
||||
const nextFlags: Record<string, unknown> = {
|
||||
@@ -115,9 +115,9 @@ export async function upsertCommunicationSettings(params: {
|
||||
const result = await db
|
||||
.update(brandSettings)
|
||||
.set({ featureFlags: nextFlags, updatedAt: new Date() })
|
||||
.where(eq(brandSettings.tenantId, params.brand_id))
|
||||
.where(eq(brandSettings.brandId, params.brand_id))
|
||||
.returning({
|
||||
tenantId: brandSettings.tenantId,
|
||||
brandId: brandSettings.brandId,
|
||||
updatedAt: brandSettings.updatedAt,
|
||||
});
|
||||
return result[0] ?? null;
|
||||
@@ -128,8 +128,8 @@ export async function upsertCommunicationSettings(params: {
|
||||
}
|
||||
|
||||
const settings: CommunicationSettings = {
|
||||
id: updated.tenantId,
|
||||
brand_id: updated.tenantId,
|
||||
id: updated.brandId,
|
||||
brand_id: updated.brandId,
|
||||
default_sender_email: params.sender_email ?? null,
|
||||
default_sender_name: params.sender_name ?? null,
|
||||
reply_to_email: params.reply_to_email ?? null,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { and, eq, sql, SQL } from "drizzle-orm";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { withTenant } from "@/db/client";
|
||||
import { withBrand } from "@/db/client";
|
||||
import { customers, orders, campaigns, emailTemplates } from "@/db/schema";
|
||||
|
||||
export type StopBlastResult =
|
||||
@@ -37,8 +37,8 @@ export async function sendStopBlast(params: {
|
||||
}
|
||||
|
||||
try {
|
||||
const conds: SQL[] = [eq(customers.tenantId, params.brandId)];
|
||||
const recipientRows = await withTenant(params.brandId, async (db) => {
|
||||
const conds: SQL[] = [eq(customers.brandId, params.brandId)];
|
||||
const recipientRows = await withBrand(params.brandId, async (db) => {
|
||||
// Distinct customers from the tenant's recent orders.
|
||||
const orderCustomers = await db
|
||||
.selectDistinct({ id: customers.id })
|
||||
@@ -46,7 +46,7 @@ export async function sendStopBlast(params: {
|
||||
.innerJoin(orders, eq(orders.customerId, customers.id))
|
||||
.where(
|
||||
and(
|
||||
eq(orders.tenantId, params.brandId),
|
||||
eq(orders.brandId, params.brandId),
|
||||
params.channel === "sms"
|
||||
? eq(customers.smsOptIn, true)
|
||||
: params.channel === "email"
|
||||
@@ -81,11 +81,11 @@ export async function sendStopBlast(params: {
|
||||
// Persist a draft campaign for traceability. No template link — the
|
||||
// stop-blast content is supplied inline and not yet modeled.
|
||||
const bodyHtml = `<p style="white-space:pre-wrap">${escapeHtml(params.body)}</p>`;
|
||||
const inserted = await withTenant(params.brandId, async (db) => {
|
||||
const inserted = await withBrand(params.brandId, async (db) => {
|
||||
const template = await db
|
||||
.insert(emailTemplates)
|
||||
.values({
|
||||
tenantId: params.brandId,
|
||||
brandId: params.brandId,
|
||||
name: `Stop blast ${new Date().toISOString()}`,
|
||||
subject: params.subject ?? "Pickup update",
|
||||
bodyHtml,
|
||||
@@ -96,7 +96,7 @@ export async function sendStopBlast(params: {
|
||||
const campaign = await db
|
||||
.insert(campaigns)
|
||||
.values({
|
||||
tenantId: params.brandId,
|
||||
brandId: params.brandId,
|
||||
name: `Stop blast ${new Date().toISOString()}`,
|
||||
templateId: tplId,
|
||||
status: "sent",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { and, desc, eq, isNotNull } from "drizzle-orm";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { withTenant } from "@/db/client";
|
||||
import { withBrand } from "@/db/client";
|
||||
import { customers, orders, campaigns } from "@/db/schema";
|
||||
|
||||
/**
|
||||
@@ -57,13 +57,13 @@ export async function getStopMessagingData(params: {
|
||||
}
|
||||
|
||||
try {
|
||||
const [orderRows, campaignRows] = await withTenant(brandId, async (db) => {
|
||||
const [orderRows, campaignRows] = await withBrand(brandId, async (db) => {
|
||||
const o = await db
|
||||
.select({
|
||||
id: orders.id,
|
||||
status: orders.status,
|
||||
placedAt: orders.placedAt,
|
||||
customerName: customers.name,
|
||||
customerName: customers.fullName,
|
||||
customerEmail: customers.email,
|
||||
customerPhone: customers.phone,
|
||||
})
|
||||
@@ -71,7 +71,7 @@ export async function getStopMessagingData(params: {
|
||||
.innerJoin(customers, eq(customers.id, orders.customerId))
|
||||
.where(
|
||||
and(
|
||||
eq(orders.tenantId, brandId),
|
||||
eq(orders.brandId, brandId),
|
||||
isNotNull(customers.email),
|
||||
),
|
||||
)
|
||||
@@ -86,7 +86,7 @@ export async function getStopMessagingData(params: {
|
||||
updatedAt: campaigns.updatedAt,
|
||||
})
|
||||
.from(campaigns)
|
||||
.where(eq(campaigns.tenantId, brandId))
|
||||
.where(eq(campaigns.brandId, brandId))
|
||||
.orderBy(desc(campaigns.sentAt), desc(campaigns.updatedAt))
|
||||
.limit(10);
|
||||
|
||||
@@ -97,7 +97,7 @@ export async function getStopMessagingData(params: {
|
||||
// schema; treat "fulfilled" status as picked up).
|
||||
const mappedOrders: StopOrder[] = orderRows.map((o) => ({
|
||||
id: o.id,
|
||||
customer_name: o.customerName,
|
||||
customer_name: o.customerName ?? "",
|
||||
customer_email: o.customerEmail,
|
||||
customer_phone: o.customerPhone,
|
||||
pickup_complete: o.status === "fulfilled",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import { withTenant, withPlatformAdmin } from "@/db/client";
|
||||
import { withBrand, withPlatformAdmin } from "@/db/client";
|
||||
import { emailTemplates } from "@/db/schema";
|
||||
|
||||
export type TemplateType = "email_template" | "sms_template" | "internal_note";
|
||||
@@ -20,7 +20,7 @@ export type CampaignType = "marketing" | "operational" | "transactional";
|
||||
*/
|
||||
export type Template = {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
brand_id: string;
|
||||
name: string;
|
||||
subject: string;
|
||||
body_text: string;
|
||||
@@ -42,7 +42,7 @@ export type UpsertTemplateResult = {
|
||||
function rowToTemplate(row: typeof emailTemplates.$inferSelect): Template {
|
||||
return {
|
||||
id: row.id,
|
||||
tenant_id: row.tenantId,
|
||||
brand_id: row.brandId,
|
||||
name: row.name,
|
||||
subject: row.subject,
|
||||
body_text: stripHtml(row.bodyHtml),
|
||||
@@ -79,7 +79,7 @@ export async function getCommunicationTemplates(brandId?: string): Promise<{ suc
|
||||
|
||||
try {
|
||||
const rows = activeBrandId
|
||||
? await withTenant(activeBrandId, (db) =>
|
||||
? await withBrand(activeBrandId, (db) =>
|
||||
db
|
||||
.select()
|
||||
.from(emailTemplates)
|
||||
@@ -121,7 +121,7 @@ export async function upsertTemplate(params: {
|
||||
|
||||
try {
|
||||
const row = params.id
|
||||
? await withTenant(params.brand_id, async (db) => {
|
||||
? await withBrand(params.brand_id, async (db) => {
|
||||
const updated = await db
|
||||
.update(emailTemplates)
|
||||
.set({
|
||||
@@ -133,17 +133,17 @@ export async function upsertTemplate(params: {
|
||||
.where(
|
||||
and(
|
||||
eq(emailTemplates.id, params.id!),
|
||||
eq(emailTemplates.tenantId, params.brand_id),
|
||||
eq(emailTemplates.brandId, params.brand_id),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
return updated[0] ?? null;
|
||||
})
|
||||
: await withTenant(params.brand_id, async (db) => {
|
||||
: await withBrand(params.brand_id, async (db) => {
|
||||
const inserted = await db
|
||||
.insert(emailTemplates)
|
||||
.values({
|
||||
tenantId: params.brand_id,
|
||||
brandId: params.brand_id,
|
||||
name: params.name,
|
||||
subject: params.subject,
|
||||
bodyHtml,
|
||||
@@ -170,14 +170,14 @@ export async function getTemplateById(templateId: string): Promise<Template | nu
|
||||
|
||||
try {
|
||||
const row = activeBrandId
|
||||
? await withTenant(activeBrandId, (db) =>
|
||||
? await withBrand(activeBrandId, (db) =>
|
||||
db
|
||||
.select()
|
||||
.from(emailTemplates)
|
||||
.where(
|
||||
and(
|
||||
eq(emailTemplates.id, templateId),
|
||||
eq(emailTemplates.tenantId, activeBrandId),
|
||||
eq(emailTemplates.brandId, activeBrandId),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
@@ -194,7 +194,7 @@ export async function getTemplateById(templateId: string): Promise<Template | nu
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
if (adminUser.role === "brand_admin" && row.tenantId !== adminUser.brand_id) {
|
||||
if (adminUser.role === "brand_admin" && row.brandId !== adminUser.brand_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { withTenant, withPlatformAdmin } from "@/db/client";
|
||||
import { withBrand, withPlatformAdmin } from "@/db/client";
|
||||
import { campaigns } from "@/db/schema";
|
||||
import type { AudienceRules } from "@/actions/communications/campaigns";
|
||||
|
||||
@@ -57,7 +57,7 @@ export type CampaignAnalytics = {
|
||||
function rowToCampaign(row: typeof campaigns.$inferSelect): Campaign {
|
||||
return {
|
||||
id: row.id,
|
||||
brand_id: row.tenantId,
|
||||
brand_id: row.brandId,
|
||||
name: row.name,
|
||||
subject: null,
|
||||
body_text: null,
|
||||
@@ -84,7 +84,7 @@ export async function getHarvestReachCampaigns(
|
||||
}
|
||||
|
||||
try {
|
||||
const rows = await withTenant(brandId, (db) =>
|
||||
const rows = await withBrand(brandId, (db) =>
|
||||
db
|
||||
.select()
|
||||
.from(campaigns)
|
||||
@@ -118,13 +118,13 @@ export async function getCampaignAnalytics(
|
||||
|
||||
try {
|
||||
const rows = campaignId
|
||||
? await withTenant(brandId, (db) =>
|
||||
? await withBrand(brandId, (db) =>
|
||||
db
|
||||
.select()
|
||||
.from(campaigns)
|
||||
.where(eq(campaigns.id, campaignId)),
|
||||
)
|
||||
: await withTenant(brandId, (db) =>
|
||||
: await withBrand(brandId, (db) =>
|
||||
db
|
||||
.select()
|
||||
.from(campaigns)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { withTenant } from "@/db/client";
|
||||
import { withBrand } from "@/db/client";
|
||||
import { products } from "@/db/schema";
|
||||
|
||||
export type ProductOption = {
|
||||
@@ -27,7 +27,7 @@ export async function getProductsForSegmentPicker(
|
||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) return [];
|
||||
|
||||
try {
|
||||
const rows = await withTenant(brandId, (db) =>
|
||||
const rows = await withBrand(brandId, (db) =>
|
||||
db
|
||||
.select({
|
||||
id: products.id,
|
||||
@@ -36,7 +36,7 @@ export async function getProductsForSegmentPicker(
|
||||
active: products.active,
|
||||
})
|
||||
.from(products)
|
||||
.where(and(eq(products.tenantId, brandId), eq(products.active, true))),
|
||||
.where(and(eq(products.brandId, brandId), eq(products.active, true))),
|
||||
);
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { and, eq, ilike, or, SQL, sql } from "drizzle-orm";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { withTenant } from "@/db/client";
|
||||
import { withBrand } from "@/db/client";
|
||||
import { customers, brandSettings } from "@/db/schema";
|
||||
import type { AudienceRules } from "@/actions/communications/campaigns";
|
||||
|
||||
@@ -55,7 +55,7 @@ export type Segment = {
|
||||
export type CustomerSample = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
fullName: string | null;
|
||||
tags: string[];
|
||||
phone: string | null;
|
||||
};
|
||||
@@ -70,11 +70,11 @@ function isSegmentList(v: unknown): v is Segment[] {
|
||||
}
|
||||
|
||||
async function loadSegments(brandId: string): Promise<Segment[]> {
|
||||
const rows = await withTenant(brandId, (db) =>
|
||||
const rows = await withBrand(brandId, (db) =>
|
||||
db
|
||||
.select({ flags: brandSettings.featureFlags })
|
||||
.from(brandSettings)
|
||||
.where(eq(brandSettings.tenantId, brandId))
|
||||
.where(eq(brandSettings.brandId, brandId))
|
||||
.limit(1),
|
||||
);
|
||||
const raw = (rows[0]?.flags ?? {}) as Record<string, unknown>;
|
||||
@@ -83,11 +83,11 @@ async function loadSegments(brandId: string): Promise<Segment[]> {
|
||||
}
|
||||
|
||||
async function saveSegments(brandId: string, segments: Segment[]): Promise<void> {
|
||||
await withTenant(brandId, async (db) => {
|
||||
await withBrand(brandId, async (db) => {
|
||||
const existing = await db
|
||||
.select({ flags: brandSettings.featureFlags })
|
||||
.from(brandSettings)
|
||||
.where(eq(brandSettings.tenantId, brandId))
|
||||
.where(eq(brandSettings.brandId, brandId))
|
||||
.limit(1);
|
||||
const baseFlags = (existing[0]?.flags ?? {}) as Record<string, unknown>;
|
||||
const nextFlags: Record<string, unknown> = {
|
||||
@@ -97,7 +97,7 @@ async function saveSegments(brandId: string, segments: Segment[]): Promise<void>
|
||||
await db
|
||||
.update(brandSettings)
|
||||
.set({ featureFlags: nextFlags, updatedAt: new Date() })
|
||||
.where(eq(brandSettings.tenantId, brandId));
|
||||
.where(eq(brandSettings.brandId, brandId));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -221,15 +221,15 @@ export async function previewSegmentWithCustomers(
|
||||
}
|
||||
void rules;
|
||||
|
||||
const conds: SQL[] = [eq(customers.tenantId, brandId), eq(customers.emailOptIn, true)];
|
||||
const conds: SQL[] = [eq(customers.brandId, brandId), eq(customers.emailOptIn, true)];
|
||||
|
||||
try {
|
||||
const [samples, counts] = await withTenant(brandId, async (db) => {
|
||||
const [samples, counts] = await withBrand(brandId, async (db) => {
|
||||
const sample = await db
|
||||
.select({
|
||||
id: customers.id,
|
||||
email: customers.email,
|
||||
name: customers.name,
|
||||
fullName: customers.fullName,
|
||||
phone: customers.phone,
|
||||
})
|
||||
.from(customers)
|
||||
@@ -247,7 +247,7 @@ export async function previewSegmentWithCustomers(
|
||||
sample_customers: samples.map((s) => ({
|
||||
id: s.id,
|
||||
email: s.email ?? "",
|
||||
name: s.name,
|
||||
fullName: s.fullName,
|
||||
tags: [],
|
||||
phone: s.phone,
|
||||
})),
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { withTenant } from "@/db/client";
|
||||
import { withBrand } from "@/db/client";
|
||||
import { stops } from "@/db/schema";
|
||||
|
||||
export type StopOption = {
|
||||
@@ -51,29 +51,28 @@ export async function getStopsForSegmentPicker(
|
||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) return [];
|
||||
|
||||
try {
|
||||
const rows = await withTenant(brandId, (db) =>
|
||||
const rows = await withBrand(brandId, (db) =>
|
||||
db
|
||||
.select({
|
||||
id: stops.id,
|
||||
name: stops.name,
|
||||
address: stops.address,
|
||||
schedule: stops.schedule,
|
||||
date: stops.date,
|
||||
time: stops.time,
|
||||
})
|
||||
.from(stops)
|
||||
.where(
|
||||
stopId
|
||||
? and(eq(stops.tenantId, brandId), eq(stops.id, stopId))
|
||||
: eq(stops.tenantId, brandId),
|
||||
? and(eq(stops.brandId, brandId), eq(stops.id, stopId))
|
||||
: eq(stops.brandId, brandId),
|
||||
),
|
||||
);
|
||||
|
||||
const now = Date.now();
|
||||
return rows.map((r) => {
|
||||
const { city, state, zip } = parseAddress(r.address);
|
||||
const sched = Array.isArray(r.schedule) ? r.schedule : [];
|
||||
const first = sched[0] as { date?: string; time?: string } | undefined;
|
||||
const dateStr = first?.date ?? "";
|
||||
const timeStr = first?.time ?? "";
|
||||
const { city, state, zip } = parseAddress(r.address ?? "");
|
||||
const dateStr = r.date ?? "";
|
||||
const timeStr = r.time ?? "";
|
||||
const ts = dateStr ? new Date(dateStr).getTime() : NaN;
|
||||
const isUpcoming = Number.isFinite(ts) ? ts >= now : false;
|
||||
const isPast = Number.isFinite(ts) ? ts < now : false;
|
||||
|
||||
@@ -50,7 +50,7 @@ export async function importOrdersBatch(
|
||||
|
||||
// Fetch product prices for the brand.
|
||||
const productRes = await pool.query<{ id: string; price_cents: number }>(
|
||||
`SELECT id, price_cents FROM products WHERE tenant_id = $1 AND id = ANY($2::uuid[])`,
|
||||
`SELECT id, price_cents FROM products WHERE brand_id = $1 AND id = ANY($2::uuid[])`,
|
||||
[brandId, productIds]
|
||||
);
|
||||
const priceMap = new Map(productRes.rows.map((p) => [p.id, p.price_cents]));
|
||||
@@ -76,11 +76,11 @@ export async function importOrdersBatch(
|
||||
|
||||
// Insert in a single transaction: customers + orders + order_items.
|
||||
await withTx(async (client) => {
|
||||
// Upsert customer by (tenant_id, email).
|
||||
// Upsert customer by (brand_id, email).
|
||||
const customerRes = await client.query<{ id: string }>(
|
||||
`INSERT INTO customers (tenant_id, name, email, phone, email_opt_in)
|
||||
`INSERT INTO customers (brand_id, name, email, phone, email_opt_in)
|
||||
VALUES ($1, $2, $3, $4, true)
|
||||
ON CONFLICT (tenant_id, email) WHERE email IS NOT NULL
|
||||
ON CONFLICT (brand_id, email) WHERE email IS NOT NULL
|
||||
DO UPDATE SET name = EXCLUDED.name, phone = EXCLUDED.phone, updated_at = now()
|
||||
RETURNING id`,
|
||||
[brandId, order.customer_name, order.customer_email || null, order.customer_phone || null]
|
||||
@@ -88,7 +88,7 @@ export async function importOrdersBatch(
|
||||
const customerId = customerRes.rows[0]?.id ?? null;
|
||||
|
||||
const orderRes = await client.query<{ id: string }>(
|
||||
`INSERT INTO orders (tenant_id, customer_id, total_cents, status, fulfillment)
|
||||
`INSERT INTO orders (brand_id, customer_id, total_cents, status, fulfillment)
|
||||
VALUES ($1, $2, $3, 'pending', $4)
|
||||
RETURNING id`,
|
||||
[brandId, customerId, totalCents, fulfillment]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { withTenant } from "@/db/client";
|
||||
import { withBrand } from "@/db/client";
|
||||
import { products } from "@/db/schema";
|
||||
|
||||
export type ImportProductsResult =
|
||||
@@ -45,9 +45,9 @@ export async function importProductsBatch(
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await withTenant(brandId, (db) =>
|
||||
await withBrand(brandId, (db) =>
|
||||
db.insert(products).values({
|
||||
tenantId: brandId,
|
||||
brandId: brandId,
|
||||
name: p.name,
|
||||
description: p.description ?? null,
|
||||
priceCents,
|
||||
|
||||
+1
-61
@@ -2,9 +2,8 @@
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { pool } from "@/lib/db";
|
||||
import { mockOrders, mockStops } from "@/lib/mock-data";
|
||||
|
||||
type AdminOrder = {
|
||||
export type AdminOrder = {
|
||||
id: string;
|
||||
customer_name: string;
|
||||
customer_email: string | null;
|
||||
@@ -106,46 +105,7 @@ type AdminOrderDetail = {
|
||||
}>;
|
||||
};
|
||||
|
||||
// Mock orders for UI review
|
||||
const mockAdminOrders: AdminOrder[] = mockOrders.map((o: any) => ({
|
||||
id: o.id,
|
||||
customer_name: o.customer_name,
|
||||
customer_email: o.customer_email,
|
||||
customer_phone: o.customer_phone,
|
||||
stop_id: o.stop_id,
|
||||
status: o.status,
|
||||
subtotal: o.subtotal,
|
||||
pickup_complete: o.pickup_complete,
|
||||
pickup_completed_at: o.pickup_completed_at,
|
||||
pickup_completed_by: null,
|
||||
created_at: o.created_at,
|
||||
payment_processor: o.payment_processor,
|
||||
stops: o.stops,
|
||||
order_items: o.order_items,
|
||||
}));
|
||||
|
||||
const mockAdminStops: AdminStop[] = mockStops.map((s: any) => ({
|
||||
id: s.id,
|
||||
city: s.city,
|
||||
state: s.state,
|
||||
date: s.date,
|
||||
location: s.location,
|
||||
brand_id: s.brand_id,
|
||||
}));
|
||||
|
||||
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
|
||||
|
||||
export async function getAdminOrders(): Promise<AdminOrdersResponse> {
|
||||
// Return mock data in mock mode
|
||||
if (useMockData) {
|
||||
return {
|
||||
success: true,
|
||||
orders: mockAdminOrders,
|
||||
stops: mockAdminStops,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
const adminUser = await getAdminUser();
|
||||
const brandId = adminUser?.brand_id ?? null;
|
||||
|
||||
@@ -193,26 +153,6 @@ export async function getAdminPickedUpOrders(): Promise<AdminOrder[]> {
|
||||
}
|
||||
|
||||
export async function getAdminOrderDetail(orderId: string): Promise<AdminOrderDetail | null> {
|
||||
// Return mock order detail in mock mode
|
||||
if (useMockData) {
|
||||
const order = mockAdminOrders.find((o) => o.id === orderId);
|
||||
if (!order) return null;
|
||||
return {
|
||||
...order,
|
||||
discount_amount: null,
|
||||
tax_amount: order.subtotal * 0.08,
|
||||
tax_rate: 0.08,
|
||||
tax_location: "Colorado",
|
||||
discount_reason: null,
|
||||
internal_notes: null,
|
||||
payment_status: "paid",
|
||||
payment_transaction_id: `txn_mock_${orderId}`,
|
||||
refunded_amount: 0,
|
||||
refund_reason: null,
|
||||
refunds: [],
|
||||
};
|
||||
}
|
||||
|
||||
const adminUser = await getAdminUser();
|
||||
const brandId = adminUser?.brand_id ?? null;
|
||||
|
||||
|
||||
@@ -25,8 +25,14 @@ export type AdminCreateOrderInput = {
|
||||
discount_reason?: string | null;
|
||||
};
|
||||
|
||||
// Type for the created order from the RPC
|
||||
export type CreatedOrder = {
|
||||
id: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type AdminCreateOrderResult =
|
||||
| { success: true; orderId: string; order: any }
|
||||
| { success: true; orderId: string; order: CreatedOrder }
|
||||
| { success: false; error: string };
|
||||
|
||||
export async function createAdminOrder(
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getMockTableData } from "@/lib/mock-data";
|
||||
import { withTenant } from "@/db/client";
|
||||
import { withBrand } from "@/db/client";
|
||||
import { products } from "@/db/schema";
|
||||
|
||||
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
|
||||
|
||||
export type CreateProductResult =
|
||||
| { success: true; id: string }
|
||||
| { success: false; error: string };
|
||||
@@ -32,25 +29,6 @@ export async function createProduct(
|
||||
return { success: false, error: "Not authorized for this brand" };
|
||||
}
|
||||
|
||||
if (useMockData) {
|
||||
const mockProducts = getMockTableData("products") as any[];
|
||||
const newId = `mock-prod-${Date.now()}`;
|
||||
const newProduct = {
|
||||
id: newId,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
price: data.price,
|
||||
type: data.type,
|
||||
is_active: data.active,
|
||||
image_url: data.image_url ?? null,
|
||||
is_taxable: data.is_taxable,
|
||||
pickup_type: data.pickup_type ?? "scheduled_stop",
|
||||
brand_id: brandId,
|
||||
};
|
||||
mockProducts.push(newProduct);
|
||||
return { success: true, id: newId };
|
||||
}
|
||||
|
||||
// The new schema stores products with `price_cents` (integer) and no `type`,
|
||||
// `is_taxable`, `pickup_type`, or `image_url` column. `image_url` is now
|
||||
// attached via the `product_images` table; `type` / `is_taxable` / `pickup_type`
|
||||
@@ -62,11 +40,11 @@ export async function createProduct(
|
||||
}
|
||||
|
||||
try {
|
||||
const inserted = await withTenant(brandId, async (db) => {
|
||||
const inserted = await withBrand(brandId, async (db) => {
|
||||
const [row] = await db
|
||||
.insert(products)
|
||||
.values({
|
||||
tenantId: brandId,
|
||||
brandId: brandId,
|
||||
name: data.name,
|
||||
description: data.description ?? null,
|
||||
priceCents,
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getMockTableData } from "@/lib/mock-data";
|
||||
import { withTenant } from "@/db/client";
|
||||
import { withBrand } from "@/db/client";
|
||||
import { products } from "@/db/schema";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
|
||||
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
|
||||
|
||||
export type UpdateProductResult =
|
||||
| { success: true }
|
||||
| { success: false; error: string };
|
||||
@@ -34,10 +31,6 @@ export async function updateProduct(
|
||||
return { success: false, error: "Not authorized for this brand" };
|
||||
}
|
||||
|
||||
if (useMockData) {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// The new schema has `price_cents` (integer cents) and no `type`,
|
||||
// `is_taxable`, `pickup_type`, or `image_url` column. `type` /
|
||||
// `is_taxable` / `pickup_type` are dropped for the SaaS rebuild;
|
||||
@@ -48,7 +41,7 @@ export async function updateProduct(
|
||||
}
|
||||
|
||||
try {
|
||||
await withTenant(brandId, (db) =>
|
||||
await withBrand(brandId, (db) =>
|
||||
db
|
||||
.update(products)
|
||||
.set({
|
||||
@@ -58,7 +51,7 @@ export async function updateProduct(
|
||||
active: data.active,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(products.id, productId), eq(products.tenantId, brandId)))
|
||||
.where(and(eq(products.id, productId), eq(products.brandId, brandId)))
|
||||
);
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,20 +1,14 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { withTenant } from "@/db/client";
|
||||
import { withBrand } from "@/db/client";
|
||||
import { productImages } from "@/db/schema";
|
||||
import { uploadObject, BUCKETS } from "@/lib/storage";
|
||||
|
||||
export type UploadProductImageResult =
|
||||
| { success: true; imageUrl: string }
|
||||
| { success: false; error: string };
|
||||
|
||||
// TODO(migration): product images in the new SaaS schema live in the
|
||||
// `product_images` table (storage_key + position + alt_text) backed by
|
||||
// the `files` table. Supabase Storage is gone, so we no longer upload
|
||||
// to `/storage/v1/object/...` — that pathway is stubbed. Callers that
|
||||
// upload images should write to the `files` table via an S3-compatible
|
||||
// backend (still TODO). This stub persists the intended storage key as
|
||||
// a record so the UI can continue to render an image URL placeholder.
|
||||
export async function uploadProductImage(
|
||||
productId: string,
|
||||
file: File
|
||||
@@ -33,16 +27,16 @@ export async function uploadProductImage(
|
||||
const ext = file.type.split("/")[1] === "jpeg" ? "jpg" : file.type.split("/")[1];
|
||||
const storageKey = `products/${productId}/${crypto.randomUUID()}.${ext}`;
|
||||
|
||||
// Without a configured object store, we cannot actually upload bytes.
|
||||
// We still record the planned storage key in `product_images` so the
|
||||
// schema-level FK + ordering are exercised. The actual upload will be
|
||||
// re-introduced when the S3-compatible store lands.
|
||||
if (productId === "__NEW__") {
|
||||
return { success: false, error: "Object store not configured; cannot upload images yet" };
|
||||
}
|
||||
|
||||
try {
|
||||
await withTenant(adminUser.brand_id ?? "__missing__", (db) =>
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
const imageUrl = await uploadObject({
|
||||
bucket: BUCKETS.PRODUCTS,
|
||||
key: storageKey,
|
||||
body: buffer,
|
||||
contentType: file.type,
|
||||
});
|
||||
|
||||
await withBrand(adminUser.brand_id ?? "__missing__", (db) =>
|
||||
db.insert(productImages).values({
|
||||
productId,
|
||||
storageKey,
|
||||
@@ -50,7 +44,8 @@ export async function uploadProductImage(
|
||||
altText: null,
|
||||
})
|
||||
);
|
||||
return { success: true, imageUrl: `/storage/${storageKey}` };
|
||||
|
||||
return { success: true, imageUrl };
|
||||
} catch (err) {
|
||||
return { success: false, error: `Upload failed: ${err instanceof Error ? err.message : String(err)}` };
|
||||
}
|
||||
@@ -66,7 +61,7 @@ export async function deleteProductImage(
|
||||
// from `product_images` for this product. The legacy `image_url` PATCH
|
||||
// pathway is gone (that column no longer exists on `products`).
|
||||
try {
|
||||
await withTenant(adminUser.brand_id ?? "__missing__", (db) =>
|
||||
await withBrand(adminUser.brand_id ?? "__missing__", (db) =>
|
||||
db.delete(productImages).where(eq(productImages.productId, productId))
|
||||
);
|
||||
return { success: true };
|
||||
|
||||
@@ -84,7 +84,7 @@ export type CampaignActivityRow = {
|
||||
|
||||
/** Substitute the brandId into the SQL — null = platform admin (all brands). */
|
||||
function brandClause(brandId: string | null, tableAlias = "o"): string {
|
||||
return brandId ? `AND ${tableAlias}.tenant_id = $3::uuid` : "";
|
||||
return brandId ? `AND ${tableAlias}.brand_id = $3::uuid` : "";
|
||||
}
|
||||
|
||||
function brandParams(brandId: string | null): unknown[] {
|
||||
@@ -235,7 +235,7 @@ export async function getContactGrowthReport(range: DateRange, brandId: string |
|
||||
FROM generate_series($1::date, $2::date, '1 day'::interval) d
|
||||
LEFT JOIN customers c
|
||||
ON c.created_at::date = d::date
|
||||
${effectiveBrandId ? "AND c.tenant_id = $3::uuid" : ""}
|
||||
${effectiveBrandId ? "AND c.brand_id = $3::uuid" : ""}
|
||||
GROUP BY d::date
|
||||
ORDER BY d::date DESC`,
|
||||
[range.start, range.end, ...brandParams(effectiveBrandId)]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { withTenant } from "@/db/client";
|
||||
import { withBrand } from "@/db/client";
|
||||
import { brandSettings } from "@/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
@@ -36,11 +36,11 @@ export async function toggleBrandFeature(
|
||||
}
|
||||
|
||||
try {
|
||||
await withTenant(brandId, async (db) => {
|
||||
await withBrand(brandId, async (db) => {
|
||||
const existing = await db
|
||||
.select({ featureFlags: brandSettings.featureFlags })
|
||||
.from(brandSettings)
|
||||
.where(eq(brandSettings.tenantId, brandId))
|
||||
.where(eq(brandSettings.brandId, brandId))
|
||||
.limit(1);
|
||||
const current = (existing[0]?.featureFlags ?? {}) as Record<string, unknown>;
|
||||
const next = { ...current, [featureKey]: enabled };
|
||||
@@ -48,12 +48,12 @@ export async function toggleBrandFeature(
|
||||
// No row yet — bootstrap with the brand name from tenants.
|
||||
await db
|
||||
.insert(brandSettings)
|
||||
.values({ tenantId: brandId, brandName: "Brand", featureFlags: next });
|
||||
.values({ brandId: brandId, featureFlags: next });
|
||||
} else {
|
||||
await db
|
||||
.update(brandSettings)
|
||||
.set({ featureFlags: next, updatedAt: new Date() })
|
||||
.where(eq(brandSettings.tenantId, brandId));
|
||||
.where(eq(brandSettings.brandId, brandId));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ export async function getShippingOrders(): Promise<GetShippingOrdersResult> {
|
||||
status: string;
|
||||
subtotal: number;
|
||||
created_at: string;
|
||||
tenant_id: string;
|
||||
brand_id: string;
|
||||
}>(
|
||||
`SELECT
|
||||
o.id::text AS id,
|
||||
@@ -79,7 +79,7 @@ export async function getShippingOrders(): Promise<GetShippingOrdersResult> {
|
||||
o.status,
|
||||
o.total_cents::float / 100.0 AS subtotal,
|
||||
o.placed_at::text AS created_at,
|
||||
o.tenant_id::text AS tenant_id
|
||||
o.brand_id::text AS brand_id
|
||||
FROM orders o
|
||||
LEFT JOIN customers c ON c.id = o.customer_id
|
||||
WHERE o.fulfillment IN ('ship', 'mixed')
|
||||
@@ -97,7 +97,7 @@ export async function getShippingOrders(): Promise<GetShippingOrdersResult> {
|
||||
shipping_status: "pending",
|
||||
tracking_number: null,
|
||||
created_at: r.created_at,
|
||||
brand_id: r.tenant_id,
|
||||
brand_id: r.brand_id,
|
||||
order_items: [],
|
||||
}));
|
||||
|
||||
|
||||
@@ -135,10 +135,10 @@ export async function createFedExShipment(
|
||||
// than fabricate a fake address.
|
||||
const { rows: orderRows } = await pool.query<{
|
||||
id: string;
|
||||
tenant_id: string | null;
|
||||
brand_id: string | null;
|
||||
status: string;
|
||||
}>(
|
||||
`SELECT id::text AS id, tenant_id::text AS tenant_id, status
|
||||
`SELECT id::text AS id, brand_id::text AS brand_id, status
|
||||
FROM orders WHERE id = $1 LIMIT 1`,
|
||||
[orderId]
|
||||
);
|
||||
@@ -147,10 +147,10 @@ export async function createFedExShipment(
|
||||
if (!order) return { success: false, error: "Order not found" };
|
||||
|
||||
// The order's brand is the source of truth. Validate the admin has access.
|
||||
if (order.tenant_id) {
|
||||
try { assertBrandAccess(adminUser, order.tenant_id); } catch { return { success: false, error: "Brand access denied" }; }
|
||||
if (order.brand_id) {
|
||||
try { assertBrandAccess(adminUser, order.brand_id); } catch { return { success: false, error: "Brand access denied" }; }
|
||||
}
|
||||
const effectiveBrandId = order.tenant_id ?? adminUser.brand_id ?? null;
|
||||
const effectiveBrandId = order.brand_id ?? adminUser.brand_id ?? null;
|
||||
if (!effectiveBrandId) {
|
||||
return { success: false, error: "Brand required for shipping" };
|
||||
}
|
||||
|
||||
@@ -165,10 +165,10 @@ export async function getFedExRates(
|
||||
// meaningful error rather than silently call the API with junk.
|
||||
const { rows: orderRows } = await pool.query<{
|
||||
id: string;
|
||||
tenant_id: string | null;
|
||||
brand_id: string | null;
|
||||
status: string;
|
||||
}>(
|
||||
`SELECT id::text AS id, tenant_id::text AS tenant_id, status
|
||||
`SELECT id::text AS id, brand_id::text AS brand_id, status
|
||||
FROM orders WHERE id = $1 LIMIT 1`,
|
||||
[orderId]
|
||||
);
|
||||
@@ -176,10 +176,10 @@ export async function getFedExRates(
|
||||
if (!order) return { success: false, error: "Order not found" };
|
||||
|
||||
// The order's brand is the source of truth. Validate the admin has access.
|
||||
if (order.tenant_id) {
|
||||
try { assertBrandAccess(adminUser, order.tenant_id); } catch { return { success: false, error: "Brand access denied" }; }
|
||||
if (order.brand_id) {
|
||||
try { assertBrandAccess(adminUser, order.brand_id); } catch { return { success: false, error: "Brand access denied" }; }
|
||||
}
|
||||
const effectiveBrandId = order.tenant_id ?? adminUser.brand_id ?? null;
|
||||
const effectiveBrandId = order.brand_id ?? adminUser.brand_id ?? null;
|
||||
if (!effectiveBrandId) {
|
||||
return { success: false, error: "Brand required for shipping" };
|
||||
}
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getPaymentSettings } from "@/actions/payments";
|
||||
import { withTenant } from "@/db/client";
|
||||
import { withBrand } from "@/db/client";
|
||||
import { products } from "@/db/schema";
|
||||
import { inArray } from "drizzle-orm";
|
||||
|
||||
function getSquareBaseUrl(accessToken: string) {
|
||||
function getSquareBaseUrl() {
|
||||
return process.env.SQUARE_ENVIRONMENT === "production"
|
||||
? "https://connect.squareup.com"
|
||||
: "https://connect.squareupsandbox.com";
|
||||
@@ -16,7 +16,7 @@ async function getSquareCatalogItemVariation(
|
||||
accessToken: string,
|
||||
catalogObjectId: string
|
||||
) {
|
||||
const baseUrl = getSquareBaseUrl(accessToken);
|
||||
const baseUrl = getSquareBaseUrl();
|
||||
const response = await fetch(
|
||||
`${baseUrl}/v2/catalog/object/${catalogObjectId}`,
|
||||
{
|
||||
@@ -41,7 +41,7 @@ async function batchUpdateSquareInventory(
|
||||
type: "AVAILABLE" | "ON_HAND" | "SOLD_OUT";
|
||||
}>
|
||||
) {
|
||||
const baseUrl = getSquareBaseUrl(accessToken);
|
||||
const baseUrl = getSquareBaseUrl();
|
||||
const response = await fetch(
|
||||
`${baseUrl}/v2/inventory/changes/batch-create`,
|
||||
{
|
||||
@@ -118,7 +118,7 @@ export async function syncInventoryToSquare(
|
||||
try {
|
||||
// Fetch product details from RC
|
||||
const productIds = items.map((i) => i.productId);
|
||||
const rows = await withTenant(brandId, (db) =>
|
||||
const rows = await withBrand(brandId, (db) =>
|
||||
db
|
||||
.select({ id: products.id, name: products.name })
|
||||
.from(products)
|
||||
@@ -178,7 +178,7 @@ export async function syncInventoryFromSquare(brandId: string): Promise<SyncResu
|
||||
|
||||
try {
|
||||
// Fetch Square inventory counts for the location
|
||||
const baseUrl = getSquareBaseUrl(settings.square_access_token);
|
||||
const baseUrl = getSquareBaseUrl();
|
||||
const response = await fetch(
|
||||
`${baseUrl}/v2/inventory/${settings.square_location_id}/counts`,
|
||||
{
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getPaymentSettings } from "@/actions/payments";
|
||||
import { pool } from "@/lib/db";
|
||||
|
||||
function getSquareBaseUrl(accessToken: string) {
|
||||
function getSquareBaseUrl() {
|
||||
return process.env.SQUARE_ENVIRONMENT === "production"
|
||||
? "https://connect.squareup.com"
|
||||
: "https://connect.squareupsandbox.com";
|
||||
@@ -15,7 +15,7 @@ async function fetchSquarePayments(
|
||||
locationId: string,
|
||||
since: string
|
||||
) {
|
||||
const baseUrl = getSquareBaseUrl(accessToken);
|
||||
const baseUrl = getSquareBaseUrl();
|
||||
const response = await fetch(
|
||||
`${baseUrl}/v2/payments/list`,
|
||||
{
|
||||
@@ -41,7 +41,7 @@ async function fetchSquarePayments(
|
||||
}
|
||||
|
||||
async function fetchSquareOrder(accessToken: string, orderId: string) {
|
||||
const baseUrl = getSquareBaseUrl(accessToken);
|
||||
const baseUrl = getSquareBaseUrl();
|
||||
const response = await fetch(
|
||||
`${baseUrl}/v2/orders/${orderId}`,
|
||||
{
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getPaymentSettings } from "@/actions/payments";
|
||||
import { SquareClient, SquareEnvironment, type BaseClientOptions } from "square";
|
||||
import { pool } from "@/lib/db";
|
||||
|
||||
export type SquareCatalogItem = {
|
||||
@@ -14,19 +13,7 @@ export type SquareCatalogItem = {
|
||||
imageUrl?: string;
|
||||
};
|
||||
|
||||
function getSquareClient(accessToken: string) {
|
||||
const env = process.env.SQUARE_ENVIRONMENT === "production"
|
||||
? SquareEnvironment.Production
|
||||
: SquareEnvironment.Sandbox;
|
||||
|
||||
const config: BaseClientOptions = {
|
||||
token: accessToken,
|
||||
environment: env,
|
||||
};
|
||||
return new SquareClient(config);
|
||||
}
|
||||
|
||||
function getSquareBaseUrl(accessToken: string) {
|
||||
function getSquareBaseUrl() {
|
||||
const env = process.env.SQUARE_ENVIRONMENT === "production"
|
||||
? "https://connect.squareup.com"
|
||||
: "https://connect.squareupsandbox.com";
|
||||
@@ -34,7 +21,7 @@ function getSquareBaseUrl(accessToken: string) {
|
||||
}
|
||||
|
||||
async function fetchSquareCatalog(accessToken: string, cursor?: string) {
|
||||
const baseUrl = getSquareBaseUrl(accessToken);
|
||||
const baseUrl = getSquareBaseUrl();
|
||||
const response = await fetch(
|
||||
`${baseUrl}/v2/catalog/list`,
|
||||
{
|
||||
@@ -67,7 +54,7 @@ async function upsertSquareCatalogItem(
|
||||
},
|
||||
existingItemId?: string
|
||||
) {
|
||||
const baseUrl = getSquareBaseUrl(accessToken);
|
||||
const baseUrl = getSquareBaseUrl();
|
||||
const body: Record<string, unknown> = {
|
||||
idempotency_key: crypto.randomUUID(),
|
||||
item: {
|
||||
@@ -130,7 +117,6 @@ export async function syncProductsToSquare(brandId: string): Promise<SyncResult>
|
||||
return { success: false, synced: 0, errors: ["Square not connected"] };
|
||||
}
|
||||
|
||||
const client = getSquareClient(settings.square_access_token);
|
||||
const errors: string[] = [];
|
||||
let synced = 0;
|
||||
|
||||
|
||||
@@ -3,9 +3,6 @@
|
||||
import { revalidateTag } from "next/cache";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { pool } from "@/lib/db";
|
||||
import { getMockTableData } from "@/lib/mock-data";
|
||||
|
||||
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
|
||||
|
||||
export type CreateStopResult =
|
||||
| { success: true; id: string }
|
||||
@@ -33,11 +30,6 @@ export async function createStop(
|
||||
return { success: false, error: "Not authorized for this brand" };
|
||||
}
|
||||
|
||||
if (useMockData) {
|
||||
const mockStops = getMockTableData("stops") as Array<{ id: string }>;
|
||||
return { success: true, id: `mock-stop-${Date.now()}` };
|
||||
}
|
||||
|
||||
// `admin_create_stop` is a SECURITY DEFINER RPC that bypasses the
|
||||
// block_stops_mutations RLS policy. It returns either {success,
|
||||
// stop_id} or {success:false, error}. Migration 202.
|
||||
|
||||
@@ -50,12 +50,6 @@ export async function getStopDetails(stopId: string): Promise<StopDetailsResult>
|
||||
return { success: false, error: "Not authorized" };
|
||||
}
|
||||
|
||||
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
|
||||
|
||||
if (useMockData) {
|
||||
return { success: false, error: "Stop not found" };
|
||||
}
|
||||
|
||||
// 1. Stop + brand — legacy schema, raw SQL (the Drizzle stops table
|
||||
// maps to the new schema which doesn't have city/state/date/etc).
|
||||
const stopRes = await pool.query<StopDetail & { brand_name: string; brand_slug: string }>(
|
||||
|
||||
@@ -4,8 +4,6 @@ import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { pool } from "@/lib/db";
|
||||
import { logAuditEvent } from "@/actions/audit";
|
||||
|
||||
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
|
||||
|
||||
export type UpdateStopResult =
|
||||
| { success: true }
|
||||
| { success: false; error: string };
|
||||
@@ -33,10 +31,6 @@ export async function updateStop(
|
||||
return { success: false, error: "Not authorized for this brand" };
|
||||
}
|
||||
|
||||
if (useMockData) {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
const slug = `${data.city.toLowerCase().replace(/\s+/g, "-")}-${data.date}`;
|
||||
|
||||
// Direct UPDATE on the legacy stops table — the new-schema Drizzle
|
||||
|
||||
@@ -4,6 +4,9 @@ import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { pool } from "@/lib/db";
|
||||
import Stripe from "stripe";
|
||||
|
||||
// Stripe API version type
|
||||
type StripeApiVersion = "2026-05-27.dahlia";
|
||||
|
||||
/**
|
||||
* Get Stripe Connect status for a brand.
|
||||
* Checks if brand has a stripe_user_id (connected account).
|
||||
@@ -38,7 +41,7 @@ export async function getStripeConnectStatus(brandId: string): Promise<{
|
||||
return { is_connected: true, account_id: stripeUserId };
|
||||
}
|
||||
|
||||
const stripe = new Stripe(stripeKey, { apiVersion: "2026-04-22.dahlia" as any });
|
||||
const stripe = new Stripe(stripeKey, { apiVersion: "2026-05-27.dahlia" as StripeApiVersion });
|
||||
const account = await stripe.accounts.retrieve(stripeUserId);
|
||||
|
||||
return {
|
||||
@@ -48,7 +51,7 @@ export async function getStripeConnectStatus(brandId: string): Promise<{
|
||||
payouts_enabled: account.payouts_enabled,
|
||||
details_submitted: account.details_submitted,
|
||||
};
|
||||
} catch (err) {
|
||||
} catch {
|
||||
// If we can't verify, assume connected but with stale data
|
||||
return {
|
||||
is_connected: true,
|
||||
@@ -82,7 +85,7 @@ export async function createStripeConnectLink(brandId: string): Promise<{
|
||||
}
|
||||
|
||||
try {
|
||||
const stripe = new Stripe(stripeKey, { apiVersion: "2026-04-22.dahlia" as any });
|
||||
const stripe = new Stripe(stripeKey, { apiVersion: "2026-05-27.dahlia" as StripeApiVersion });
|
||||
const origin = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000";
|
||||
|
||||
// Create Express account
|
||||
@@ -144,7 +147,7 @@ export async function refreshStripeConnectLink(brandId: string): Promise<{
|
||||
}
|
||||
|
||||
try {
|
||||
const stripe = new Stripe(stripeKey, { apiVersion: "2026-04-22.dahlia" as any });
|
||||
const stripe = new Stripe(stripeKey, { apiVersion: "2026-05-27.dahlia" as StripeApiVersion });
|
||||
const origin = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000";
|
||||
|
||||
const accountLink = await stripe.accountLinks.create({
|
||||
@@ -234,7 +237,7 @@ export async function createStripeDashboardLink(brandId: string): Promise<{
|
||||
}
|
||||
|
||||
try {
|
||||
const stripe = new Stripe(stripeKey, { apiVersion: "2026-04-22.dahlia" as any });
|
||||
const stripe = new Stripe(stripeKey, { apiVersion: "2026-05-27.dahlia" as StripeApiVersion });
|
||||
const loginLink = await stripe.accounts.createLoginLink(status.account_id);
|
||||
|
||||
return {
|
||||
|
||||
+4
-5
@@ -2,7 +2,7 @@
|
||||
|
||||
import Stripe from "stripe";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { withTenant } from "@/db/client";
|
||||
import { withBrand } from "@/db/client";
|
||||
import { brandSettings } from "@/db/schema";
|
||||
import { pool } from "@/lib/db";
|
||||
|
||||
@@ -64,8 +64,7 @@ export async function calculateOrderTax(params: {
|
||||
const stripe = new Stripe(stripeKey);
|
||||
|
||||
// Build Stripe line items for tax calculation — only taxable items
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const lineItems: any[] = params.items
|
||||
const lineItems: Stripe.Tax.CalculationCreateParams.LineItem[] = params.items
|
||||
.filter((item) => item.is_taxable !== false)
|
||||
.map((item) => ({
|
||||
amount: Math.round(item.price * item.quantity * 100), // cents
|
||||
@@ -120,11 +119,11 @@ export async function calculateOrderTax(params: {
|
||||
*/
|
||||
async function getBrandTaxSettings(brandId: string): Promise<BrandTaxSettings | null> {
|
||||
try {
|
||||
const rows = await withTenant(brandId, async (db) =>
|
||||
const rows = await withBrand(brandId, async (db) =>
|
||||
db
|
||||
.select({ featureFlags: brandSettings.featureFlags })
|
||||
.from(brandSettings)
|
||||
.where(eq(brandSettings.tenantId, brandId))
|
||||
.where(eq(brandSettings.brandId, brandId))
|
||||
.limit(1),
|
||||
);
|
||||
const flags = rows[0]?.featureFlags as
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { mockWorkers, mockTasks, mockTimeEntries } from "@/lib/mock-data";
|
||||
|
||||
// TODO(migration): the time-tracking admin RPCs (get_time_tracking_workers,
|
||||
// create_time_worker, reset_time_worker_pin, update_time_worker,
|
||||
@@ -13,15 +12,10 @@ import { mockWorkers, mockTasks, mockTimeEntries } from "@/lib/mock-data";
|
||||
// the underlying tables (`time_workers`, `time_tasks`, `time_logs`,
|
||||
// `time_tracking_settings`, `time_tracking_notification_log`) were
|
||||
// not carried over into the SaaS rebuild's `db/schema/`. The actions
|
||||
// below preserve the original signatures and return mock data when
|
||||
// `NEXT_PUBLIC_USE_MOCK_DATA === "true"`, but the real RPC paths now
|
||||
// return empty/empty-list results. To bring time tracking back, add
|
||||
// below return empty results. To bring time tracking back, add
|
||||
// the tables to `db/schema/` and re-implement against Drizzle. See
|
||||
// `actions/route-trace/lots.ts` for the same pattern.
|
||||
|
||||
// Mock mode flag - only enabled when explicitly set
|
||||
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type TimeWorker = {
|
||||
@@ -69,21 +63,7 @@ export type TimeSummary = {
|
||||
|
||||
// ── Workers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getTimeTrackingWorkers(brandId: string): Promise<TimeWorker[]> {
|
||||
if (useMockData) {
|
||||
return mockWorkers.map(w => ({
|
||||
id: w.id,
|
||||
brand_id: brandId,
|
||||
name: w.name,
|
||||
role: w.role,
|
||||
lang: w.language,
|
||||
pin: "0000",
|
||||
active: w.is_active,
|
||||
last_used_at: null,
|
||||
created_at: new Date().toISOString(),
|
||||
worker_number: null,
|
||||
}));
|
||||
}
|
||||
export async function getTimeTrackingWorkers(_brandId: string): Promise<TimeWorker[]> {
|
||||
// Time tracking tables not in SaaS rebuild — return empty list.
|
||||
return [];
|
||||
}
|
||||
@@ -125,17 +105,7 @@ export async function deleteTimeWorker(_workerId: string): Promise<{ success: bo
|
||||
|
||||
// ── Tasks ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getTimeTrackingTasks(brandId: string, activeOnly = false): Promise<TimeTask[]> {
|
||||
if (useMockData) {
|
||||
return mockTasks.map(t => ({
|
||||
id: t.id,
|
||||
name: t.name_en,
|
||||
name_es: t.name_es,
|
||||
unit: t.unit,
|
||||
active: true,
|
||||
sort_order: t.sort_order,
|
||||
}));
|
||||
}
|
||||
export async function getTimeTrackingTasks(_brandId: string, _activeOnly = false): Promise<TimeTask[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -173,7 +143,7 @@ export async function deleteTimeTask(_taskId: string): Promise<{ success: boolea
|
||||
// ── Time Logs ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getWorkerTimeLogs(
|
||||
brandId: string,
|
||||
_brandId: string,
|
||||
_options: {
|
||||
workerId?: string;
|
||||
taskId?: string;
|
||||
@@ -183,30 +153,6 @@ export async function getWorkerTimeLogs(
|
||||
offset?: number;
|
||||
} = {}
|
||||
): Promise<TimeLog[]> {
|
||||
if (useMockData) {
|
||||
// Filter by worker, task, date range
|
||||
let entries = mockTimeEntries.filter(e => e.brand_id === brandId);
|
||||
if (_options.workerId) entries = entries.filter(e => e.worker_id === _options.workerId);
|
||||
|
||||
return entries.map(e => {
|
||||
const worker = mockWorkers.find(w => w.id === e.worker_id);
|
||||
const task = mockTasks.find(t => t.id === e.task_id);
|
||||
return {
|
||||
id: e.id,
|
||||
worker_id: e.worker_id,
|
||||
worker_name: worker?.name ?? "Unknown",
|
||||
task_id: e.task_id,
|
||||
task_name: task?.name_en ?? "Unknown",
|
||||
clock_in: `${e.date}T09:00:00Z`,
|
||||
clock_out: `${e.date}T${9 + e.hours}:00:00Z`,
|
||||
lunch_break_minutes: 0,
|
||||
notes: null,
|
||||
submitted_via: "web",
|
||||
total_minutes: Math.round(e.hours * 60),
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
});
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -230,43 +176,10 @@ export async function deleteWorkerTimeLog(_logId: string): Promise<{ success: bo
|
||||
}
|
||||
|
||||
export async function getTimeTrackingSummary(
|
||||
brandId: string,
|
||||
_brandId: string,
|
||||
_start: string,
|
||||
_end: string
|
||||
): Promise<TimeSummary> {
|
||||
if (useMockData) {
|
||||
const entries = mockTimeEntries.filter(e => e.brand_id === brandId);
|
||||
|
||||
// Calculate by worker
|
||||
const workerMap = new Map<string, { id: string; name: string; total_hours: number; entry_count: number }>();
|
||||
entries.forEach(e => {
|
||||
const worker = mockWorkers.find(w => w.id === e.worker_id);
|
||||
const existing = workerMap.get(e.worker_id) || { id: e.worker_id, name: worker?.name ?? "Unknown", total_hours: 0, entry_count: 0 };
|
||||
existing.total_hours += e.hours;
|
||||
existing.entry_count += 1;
|
||||
workerMap.set(e.worker_id, existing);
|
||||
});
|
||||
|
||||
// Calculate by task
|
||||
const taskMap = new Map<string, { id: string; name: string; name_es: string | null; total_hours: number; entry_count: number }>();
|
||||
entries.forEach(e => {
|
||||
const task = mockTasks.find(t => t.id === e.task_id);
|
||||
const existing = taskMap.get(e.task_id) || { id: e.task_id, name: task?.name_en ?? "Unknown", name_es: task?.name_es ?? null, total_hours: 0, entry_count: 0 };
|
||||
existing.total_hours += e.hours;
|
||||
existing.entry_count += 1;
|
||||
taskMap.set(e.task_id, existing);
|
||||
});
|
||||
|
||||
return {
|
||||
by_worker: Array.from(workerMap.values()),
|
||||
by_task: Array.from(taskMap.values()),
|
||||
totals: {
|
||||
entry_count: entries.length,
|
||||
total_hours: entries.reduce((sum, e) => sum + e.hours, 0),
|
||||
open_count: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
return { by_worker: [], by_task: [], totals: { entry_count: 0, total_hours: 0, open_count: 0 } };
|
||||
}
|
||||
|
||||
@@ -291,27 +204,7 @@ export type TimeTrackingSettings = {
|
||||
brand_name: string;
|
||||
};
|
||||
|
||||
export async function getTimeTrackingSettings(brandId: string): Promise<TimeTrackingSettings | null> {
|
||||
if (useMockData) {
|
||||
return {
|
||||
id: "settings-mock",
|
||||
brand_id: brandId,
|
||||
pay_period_start_day: 0,
|
||||
pay_period_length_days: 7,
|
||||
daily_overtime_threshold: 8,
|
||||
weekly_overtime_threshold: 40,
|
||||
overtime_multiplier: 1.5,
|
||||
overtime_notifications: true,
|
||||
notification_emails: ["admin@tuxedocorn.com"],
|
||||
notification_sms_numbers: [],
|
||||
enable_daily_alerts: true,
|
||||
enable_weekly_alerts: true,
|
||||
daily_alert_threshold: 8,
|
||||
weekly_alert_threshold: 40,
|
||||
send_end_of_period_summary: true,
|
||||
brand_name: "Tuxedo Corn",
|
||||
};
|
||||
}
|
||||
export async function getTimeTrackingSettings(_brandId: string): Promise<TimeTrackingSettings | null> {
|
||||
// Real RPC not in SaaS rebuild.
|
||||
return null;
|
||||
}
|
||||
@@ -362,4 +255,4 @@ export async function getTimeTrackingNotificationLog(
|
||||
_limit = 100
|
||||
): Promise<NotificationLogEntry[]> {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,5 @@ export async function wholesaleLogoutAction(): Promise<{ success: true } | { suc
|
||||
const cookieStore = await cookies();
|
||||
// Best-effort cookie cleanup so a returning customer doesn't see stale state
|
||||
cookieStore.delete("wholesale_session");
|
||||
cookieStore.delete("sb-wnzkhezyhnfzhkhiflrp-auth-token");
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user