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:
@@ -61,7 +61,7 @@ Use "trending" for product popularity questions.
|
||||
Use "top_customers" for customer ranking questions.
|
||||
Use "recent_orders" for recent order questions.`;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
const client = aiResult.client as { chat: { completions: { create: (opts: unknown) => Promise<{ choices: Array<{ message: { content: string } }> }> } } };
|
||||
const response = await client.chat.completions.create({
|
||||
model: aiResult.model,
|
||||
|
||||
@@ -58,7 +58,7 @@ Historical Order Data: ${JSON.stringify(historicalData ?? [])}
|
||||
|
||||
Analyze demand patterns and return JSON with currentTrend, prediction (nextStopVolume, nextWeekVolume, confidence, confidenceReason), recommendedStock (units, reasoning), seasonalFactors array, and riskFlags array.`;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
const client = aiResult.client as { chat: { completions: { create: (opts: unknown) => Promise<{ choices: Array<{ message: { content: string } }> }> } } };
|
||||
const response = await client.chat.completions.create({
|
||||
model: aiResult.model,
|
||||
|
||||
@@ -58,7 +58,7 @@ Historical Sales (last 30-90 days): ${JSON.stringify(historicalSales ?? [])}
|
||||
|
||||
Analyze pricing and return JSON with currentState, recommendations array, opportunities, and warnings.`;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
const client = aiResult.client as { chat: { completions: { create: (opts: unknown) => Promise<{ choices: Array<{ message: { content: string } }> }> } } };
|
||||
const response = await client.chat.completions.create({
|
||||
model: aiResult.model,
|
||||
|
||||
@@ -63,7 +63,7 @@ ${JSON.stringify(sampleRows, null, 2)}
|
||||
|
||||
Return JSON with summary, keyInsights (array), and suggestedActions (array).`;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
const client = aiResult.client as { chat: { completions: { create: (opts: unknown) => Promise<{ choices: Array<{ message: { content: string } }> }> } } };
|
||||
const response = await client.chat.completions.create({
|
||||
model: aiResult.model,
|
||||
|
||||
@@ -62,7 +62,7 @@ ${stopsList}
|
||||
|
||||
Optimize the route for efficiency. Return JSON with optimizedSequence, totalEstimatedDistance, totalEstimatedDriveTime, warnings, and suggestions.`;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
const client = aiResult.client as { chat: { completions: { create: (opts: unknown) => Promise<{ choices: Array<{ message: { content: string } }> }> } } };
|
||||
const response = await client.chat.completions.create({
|
||||
model: aiResult.model,
|
||||
|
||||
@@ -64,7 +64,7 @@ Customer Count: ${customerCount ?? "unknown"}
|
||||
Analyze this stop's context and order history to recommend the optimal stop blast message.
|
||||
Return JSON with timingRecommendation, subjectLine, bodyPreview, audienceSize, audienceRecommendation, contentAngles array, and warnings.`;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
const client = aiResult.client as { chat: { completions: { create: (opts: unknown) => Promise<{ choices: Array<{ message: { content: string } }> }> } } };
|
||||
const response = await client.chat.completions.create({
|
||||
model: aiResult.model,
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
// Auth.js v5 route handler — re-exports the GET/POST handlers from src/lib/auth.ts
|
||||
// Mounted at /api/auth/* (signin, signout, callback, session, csrf, providers, etc.)
|
||||
import { handlers } from "@/lib/auth";
|
||||
export const { GET, POST } = handlers;
|
||||
// Neon Auth API route — handles all auth endpoints via Better Auth server proxy.
|
||||
// Mounted at /api/auth/* (sign-in, sign-out, session, callbacks, etc.)
|
||||
import { NextRequest } from "next/server";
|
||||
import { handlersGET, handlersPOST } from "@/lib/auth";
|
||||
|
||||
// Re-export as GET and POST for Next.js route handler
|
||||
export const GET = async (req: NextRequest, ctx: { params: Promise<{ nextauth: string[] }> }) => {
|
||||
return handlersGET(req as unknown as Request, ctx as unknown as { params: Promise<{ path: string[] }> });
|
||||
};
|
||||
|
||||
export const POST = async (req: NextRequest, ctx: { params: Promise<{ nextauth: string[] }> }) => {
|
||||
return handlersPOST(req as unknown as Request, ctx as unknown as { params: Promise<{ path: string[] }> });
|
||||
};
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getSession, setUserPassword } from "@/lib/auth";
|
||||
|
||||
interface ChangePasswordBody {
|
||||
password: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
let body: ChangePasswordBody;
|
||||
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid request body." }, { status: 400 });
|
||||
}
|
||||
|
||||
const { password, userId } = body;
|
||||
|
||||
if (!password || password.length < 8) {
|
||||
return NextResponse.json(
|
||||
{ error: "Password must be at least 8 characters." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get the current session
|
||||
const { data: session } = await getSession();
|
||||
const sessionUserId = session?.user?.id;
|
||||
|
||||
if (!sessionUserId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Not authenticated." },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// If userId is provided, require admin privileges (handled by updatePasswordAction)
|
||||
// If no userId, the user is changing their own password
|
||||
const targetUserId = userId ?? sessionUserId;
|
||||
|
||||
// Non-admin users can only change their own password
|
||||
if (userId && userId !== sessionUserId) {
|
||||
// For now, only admins can change other users' passwords
|
||||
// This is handled by the updatePasswordAction in admin/password.ts
|
||||
return NextResponse.json(
|
||||
{ error: "Use the admin panel to change other users' passwords." },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await setUserPassword({
|
||||
userId: targetUserId,
|
||||
newPassword: password,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
console.error("[auth/change-password] Failed:", result.error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: result.error.code ?? "ChangeFailed",
|
||||
message: result.error.message ?? "Failed to change password.",
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
console.log("[auth/change-password] Password changed for user:", targetUserId);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error("[auth/change-password] Unexpected error:", err);
|
||||
return NextResponse.json(
|
||||
{ error: "InternalError", message: "An unexpected error occurred." },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requestPasswordReset } from "@/lib/auth";
|
||||
|
||||
interface ForgotPasswordBody {
|
||||
email: string;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
let body: ForgotPasswordBody;
|
||||
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid request body." }, { status: 400 });
|
||||
}
|
||||
|
||||
const { email } = body;
|
||||
const trimmedEmail = typeof email === "string" ? email.trim().toLowerCase() : "";
|
||||
|
||||
if (!trimmedEmail) {
|
||||
return NextResponse.json(
|
||||
{ error: "Email is required." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:4000";
|
||||
|
||||
try {
|
||||
const result = await requestPasswordReset({
|
||||
email: trimmedEmail,
|
||||
redirectTo: `${siteUrl}/reset-password`,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
console.error("[auth/forgot-password] Request failed:", result.error);
|
||||
// Don't reveal whether the email exists or not for security
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
console.log("[auth/forgot-password] Reset email sent to:", trimmedEmail);
|
||||
// Always return success to prevent email enumeration
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error("[auth/forgot-password] Unexpected error:", err);
|
||||
// Don't reveal error details to the client
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { resetPassword } from "@/lib/auth";
|
||||
|
||||
interface ResetPasswordBody {
|
||||
password: string;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
let body: ResetPasswordBody;
|
||||
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid request body." }, { status: 400 });
|
||||
}
|
||||
|
||||
const { password } = body;
|
||||
|
||||
if (!password || password.length < 8) {
|
||||
return NextResponse.json(
|
||||
{ error: "Password must be at least 8 characters." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await resetPassword({
|
||||
newPassword: password,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
console.error("[auth/reset-password] Reset failed:", result.error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: result.error.code ?? "ResetFailed",
|
||||
message: result.error.message ?? "Failed to reset password. The link may have expired.",
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
console.log("[auth/reset-password] Password reset successful");
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error("[auth/reset-password] Unexpected error:", err);
|
||||
return NextResponse.json(
|
||||
{ error: "InternalError", message: "An unexpected error occurred." },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { signIn } from "@/lib/auth";
|
||||
|
||||
interface SignInBody {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
let body: SignInBody;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: "Invalid request body." }, { status: 400 });
|
||||
}
|
||||
|
||||
const email = body?.email;
|
||||
const password = body?.password;
|
||||
|
||||
if (!email || !password) {
|
||||
return NextResponse.json(
|
||||
{ error: "MissingCredentials", message: "Email and password are required." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await signIn.email({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
console.log("[sign-in] signIn.email result:", JSON.stringify(result));
|
||||
|
||||
if (result.error) {
|
||||
console.log("[sign-in] sign-in error:", result.error);
|
||||
return NextResponse.json(
|
||||
{ error: result.error.code ?? "SignInFailed", message: result.error.message ?? "Invalid email or password." },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
console.log("[sign-in] sign-in success, session should be set");
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error("[sign-in] Unexpected error:", err);
|
||||
return NextResponse.json(
|
||||
{ error: "ServiceError", message: "An unexpected error occurred." },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { signOut } from "@/lib/auth";
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
await signOut();
|
||||
// This line won't be reached because signOut() redirects
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error("[auth/sign-out] Error:", err);
|
||||
// Even if signOut throws, redirect to login
|
||||
return NextResponse.redirect(new URL("/login", process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:4000"));
|
||||
}
|
||||
}
|
||||
@@ -1,35 +1,96 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import { pool } from "@/lib/db";
|
||||
import { sendCampaignEmail } from "@/lib/email-service";
|
||||
|
||||
export async function GET() {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
|
||||
if (!supabaseUrl || !supabaseKey) {
|
||||
return NextResponse.json({ error: "Missing configuration" }, { status: 500 });
|
||||
/**
|
||||
* Cron: /api/cron/send-scheduled
|
||||
* Runs daily at 09:00 (declared in vercel.json).
|
||||
* Finds all campaigns with status='scheduled' and scheduled_at <= now(),
|
||||
* sends each one to all opted-in contacts for that brand, then marks
|
||||
* the campaign as 'sent'.
|
||||
*
|
||||
* Auth: Bearer token via CRON_SECRET header.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const authHeader = request.headers.get("authorization") ?? "";
|
||||
const expected = `Bearer ${process.env.CRON_SECRET ?? ""}`;
|
||||
if (!expected || authHeader !== expected) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/functions/v1/send-scheduled-campaigns`,
|
||||
{ headers: { ...svcHeaders(supabaseKey!), "Content-Type": "application/json" } }
|
||||
);
|
||||
|
||||
const text = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json({ error: "Failed to send campaigns", detail: text }, { status: 500 });
|
||||
}
|
||||
|
||||
let results: unknown[] = [];
|
||||
try {
|
||||
const data = JSON.parse(text);
|
||||
if (data && typeof data === "object") {
|
||||
const d = data as Record<string, unknown>;
|
||||
if (Array.isArray(d.results)) results = d.results as unknown[];
|
||||
else if (Array.isArray(d.send_scheduled_campaigns)) results = d.send_scheduled_campaigns as unknown[];
|
||||
else if (Array.isArray(data)) results = data as unknown[];
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
// 1. Find campaigns due to be sent
|
||||
const { rows: campaigns } = await pool.query<{
|
||||
id: string;
|
||||
brand_id: string;
|
||||
name: string;
|
||||
subject: string | null;
|
||||
body_html: string | null;
|
||||
brand_name: string | null;
|
||||
}>(
|
||||
`SELECT id, brand_id, name, subject, body_html, brand_name
|
||||
FROM communication_campaigns
|
||||
WHERE status = 'scheduled'
|
||||
AND scheduled_at IS NOT NULL
|
||||
AND scheduled_at <= now()
|
||||
ORDER BY scheduled_at ASC`
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true, timestamp: new Date().toISOString(), results });
|
||||
if (campaigns.length === 0) {
|
||||
return NextResponse.json({ success: true, timestamp: new Date().toISOString(), results: [] });
|
||||
}
|
||||
|
||||
const results: Array<{ campaignId: string; name: string; sent: number; errors: number }> = [];
|
||||
|
||||
for (const campaign of campaigns) {
|
||||
if (!campaign.body_html) {
|
||||
await pool.query(
|
||||
`UPDATE communication_campaigns SET status = 'sent', sent_at = now() WHERE id = $1`,
|
||||
[campaign.id]
|
||||
);
|
||||
results.push({ campaignId: campaign.id, name: campaign.name, sent: 0, errors: 0 });
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2. Find opted-in contacts for this brand
|
||||
const { rows: contacts } = await pool.query<{ id: string; email: string | null; full_name: string | null }>(
|
||||
`SELECT id, email, full_name
|
||||
FROM communication_contacts
|
||||
WHERE brand_id = $1
|
||||
AND email_opt_in = true
|
||||
AND email IS NOT NULL
|
||||
AND unsubscribed_at IS NULL`,
|
||||
[campaign.brand_id]
|
||||
);
|
||||
|
||||
let sent = 0;
|
||||
let errors = 0;
|
||||
|
||||
for (const contact of contacts) {
|
||||
if (!contact.email) continue;
|
||||
const ok = await sendCampaignEmail({
|
||||
to: contact.email,
|
||||
subject: campaign.subject ?? campaign.name,
|
||||
html: campaign.body_html,
|
||||
});
|
||||
if (ok) sent++;
|
||||
else errors++;
|
||||
}
|
||||
|
||||
// 3. Mark campaign as sent
|
||||
await pool.query(
|
||||
`UPDATE communication_campaigns
|
||||
SET status = 'sent', sent_at = now(), recipient_count = $2
|
||||
WHERE id = $1`,
|
||||
[campaign.id, sent]
|
||||
);
|
||||
|
||||
results.push({ campaignId: campaign.id, name: campaign.name, sent, errors });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, timestamp: new Date().toISOString(), results });
|
||||
} catch (err) {
|
||||
console.error("[cron/send-scheduled] Error:", err);
|
||||
return NextResponse.json({ error: "Internal error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,36 +1,37 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { headers } from "next/headers";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requestPasswordReset } from "@/lib/auth";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const formData = await request.formData();
|
||||
const email = formData.get("email") as string;
|
||||
export async function POST(req: NextRequest) {
|
||||
let body: { email?: string };
|
||||
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid request body." }, { status: 400 });
|
||||
}
|
||||
|
||||
const email = typeof body?.email === "string" ? body.email.trim().toLowerCase() : "";
|
||||
|
||||
if (!email) {
|
||||
return NextResponse.json({ error: "Email is required." }, { status: 400 });
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||
if (!supabaseUrl || !supabaseAnonKey) {
|
||||
return NextResponse.json({ error: "Server misconfiguration." }, { status: 500 });
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:4000";
|
||||
|
||||
try {
|
||||
const result = await requestPasswordReset({
|
||||
email,
|
||||
redirectTo: `${siteUrl}/reset-password`,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
console.error("[auth/forgot-password] Neon Auth error:", result.error);
|
||||
}
|
||||
|
||||
// Always return success to prevent email enumeration
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error("[auth/forgot-password] Unexpected error:", err);
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
const origin = (await headers()).get("origin") ?? "http://localhost:3000";
|
||||
|
||||
const res = await fetch(`${supabaseUrl}/auth/v1/recover`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
apikey: supabaseAnonKey,
|
||||
},
|
||||
body: JSON.stringify({ email, redirectTo: `${origin}/auth/callback` }),
|
||||
});
|
||||
|
||||
// Always return 200 to avoid email enumeration — Supabase may still send or not
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null);
|
||||
return NextResponse.json({ error: data?.message ?? "Failed to send reset link." }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ export async function GET() {
|
||||
.from("brands")
|
||||
.select("id, name")
|
||||
.eq("slug", brandSlug)
|
||||
.single();
|
||||
.single() as unknown as { data: { id: string; name: string } | null };
|
||||
|
||||
if (!brand?.id) {
|
||||
return new NextResponse("Brand not found", { status: 404 });
|
||||
@@ -20,13 +20,13 @@ export async function GET() {
|
||||
.select("*")
|
||||
.eq("brand_id", brand.id)
|
||||
.eq("active", true)
|
||||
.order("date", { ascending: true });
|
||||
.order("date", { ascending: true }) as unknown as { data: { city: string; state: string; date: string; time: string; location: string }[] | null };
|
||||
|
||||
const { data: settings } = await supabase
|
||||
.from("brand_settings")
|
||||
.select("logo_url, email, phone, schedule_pdf_notes")
|
||||
.eq("brand_id", brand.id)
|
||||
.single();
|
||||
.single() as unknown as { data: { logo_url: string | null; email: string | null; phone: string | null; schedule_pdf_notes: string | null } | null };
|
||||
|
||||
const pdfBytes = await buildProfessionalSchedulePdf({
|
||||
brandName: brand.name,
|
||||
|
||||
@@ -41,9 +41,9 @@ export async function GET(req: NextRequest) {
|
||||
db
|
||||
.select({
|
||||
id: orders.id,
|
||||
tenantId: orders.tenantId,
|
||||
brandId: orders.brandId,
|
||||
customerId: orders.customerId,
|
||||
customerName: customers.name,
|
||||
customerName: customers.fullName,
|
||||
customerEmail: customers.email,
|
||||
status: orders.status,
|
||||
totalCents: orders.totalCents,
|
||||
@@ -51,7 +51,7 @@ export async function GET(req: NextRequest) {
|
||||
})
|
||||
.from(orders)
|
||||
.leftJoin(customers, eq(customers.id, orders.customerId))
|
||||
.where(eq(orders.tenantId, brandId))
|
||||
.where(eq(orders.brandId, brandId))
|
||||
.orderBy(desc(orders.placedAt))
|
||||
.limit(1000),
|
||||
)
|
||||
@@ -59,9 +59,9 @@ export async function GET(req: NextRequest) {
|
||||
db
|
||||
.select({
|
||||
id: orders.id,
|
||||
tenantId: orders.tenantId,
|
||||
brandId: orders.brandId,
|
||||
customerId: orders.customerId,
|
||||
customerName: customers.name,
|
||||
customerName: customers.fullName,
|
||||
customerEmail: customers.email,
|
||||
status: orders.status,
|
||||
totalCents: orders.totalCents,
|
||||
|
||||
@@ -74,8 +74,8 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
// Collect all logs across brands
|
||||
let allLogs: LogEntry[] = [];
|
||||
let allSettings: Awaited<ReturnType<typeof getTimeTrackingSettings>>[] = [];
|
||||
let allWorkers: Awaited<ReturnType<typeof getTimeTrackingWorkers>>[] = [];
|
||||
const allSettings: Awaited<ReturnType<typeof getTimeTrackingSettings>>[] = [];
|
||||
const allWorkers: Awaited<ReturnType<typeof getTimeTrackingWorkers>>[] = [];
|
||||
|
||||
for (const brandId of brandIds) {
|
||||
const [workers, settings, logs] = await Promise.all([
|
||||
|
||||
@@ -17,7 +17,7 @@ export async function GET() {
|
||||
.from("brands")
|
||||
.select("id, name")
|
||||
.eq("slug", brandSlug)
|
||||
.single();
|
||||
.single() as unknown as { data: { id: string; name: string } | null };
|
||||
|
||||
if (!brand?.id) {
|
||||
return new NextResponse("Brand not found", { status: 404 });
|
||||
@@ -25,16 +25,16 @@ export async function GET() {
|
||||
|
||||
const { data: stops } = await supabase
|
||||
.from("stops")
|
||||
.select("*")
|
||||
.select("city, state, date, time, location")
|
||||
.eq("brand_id", brand.id)
|
||||
.eq("active", true)
|
||||
.order("date", { ascending: true });
|
||||
.order("date", { ascending: true }) as unknown as { data: { city: string; state: string; date: string; time: string; location: string }[] | null };
|
||||
|
||||
const { data: settings } = await supabase
|
||||
.from("brand_settings")
|
||||
.select("logo_url, email, phone, schedule_pdf_notes")
|
||||
.eq("brand_id", brand.id)
|
||||
.single();
|
||||
.single() as unknown as { data: { logo_url: string | null; email: string | null; phone: string | null; schedule_pdf_notes: string | null } | null };
|
||||
|
||||
const pdfBytes = await buildProfessionalSchedulePdf({
|
||||
brandName: brand.name,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { emailLimiter, checkRateLimit, rateLimitExceeded, securityHeaders } from
|
||||
import { analytics } from "@/lib/analytics";
|
||||
import { captureError } from "@/lib/sentry";
|
||||
import { pool } from "@/lib/db";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
|
||||
// Helper functions
|
||||
function apiResponse(data: unknown, status: number = 200) {
|
||||
@@ -36,6 +37,12 @@ const createCampaignSchema = z.object({
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// Authentication check
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Rate limiting
|
||||
const rateCheck = await checkRateLimit(emailLimiter, req.headers.get("x-forwarded-for") || "anonymous");
|
||||
if (!rateCheck.success) {
|
||||
@@ -83,6 +90,12 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
// Authentication check
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
const brand_id = searchParams.get("brand_id");
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { captureError } from "@/lib/sentry";
|
||||
import { withDb, withPlatformAdmin } from "@/db/client";
|
||||
import { products, type Product } from "@/db/schema";
|
||||
import { and, eq, ilike, or, sql } from "drizzle-orm";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
|
||||
// Helper functions
|
||||
function apiResponse(data: unknown, status: number = 200) {
|
||||
@@ -36,6 +37,12 @@ const getProductsSchema = z.object({
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
// Authentication check
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Rate limiting
|
||||
const rateCheck = await checkRateLimit(apiLimiter, req.headers.get("x-forwarded-for") || "anonymous");
|
||||
if (!rateCheck.success) {
|
||||
@@ -60,10 +67,10 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
// Build the WHERE conditions. We use `withDb` (no tenant GUC) here
|
||||
// because this is a public API — RLS isn't enforced via the new
|
||||
// schema's app.current_tenant_id GUC, so we filter explicitly.
|
||||
// schema's app.current_brand_id GUC, so we filter explicitly.
|
||||
const whereParts = [];
|
||||
if (validation.data.brand_id) {
|
||||
whereParts.push(eq(products.tenantId, validation.data.brand_id));
|
||||
whereParts.push(eq(products.brandId, validation.data.brand_id));
|
||||
}
|
||||
if (validation.data.is_active !== undefined) {
|
||||
whereParts.push(eq(products.active, validation.data.is_active));
|
||||
@@ -82,7 +89,7 @@ export async function GET(req: NextRequest) {
|
||||
void validation.data.category;
|
||||
|
||||
// Public read across all tenants (this is a public catalog API, not
|
||||
// an admin endpoint), so use `withDb` rather than `withTenant`.
|
||||
// an admin endpoint), so use `withDb` rather than `withBrand`.
|
||||
const rows = await withDb(async (db) =>
|
||||
db
|
||||
.select()
|
||||
@@ -107,6 +114,12 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// Authentication check
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Rate limiting
|
||||
const rateCheck = await checkRateLimit(apiLimiter, req.headers.get("x-forwarded-for") || "anonymous");
|
||||
if (!rateCheck.success) {
|
||||
@@ -139,7 +152,7 @@ export async function POST(req: NextRequest) {
|
||||
const [row] = await db
|
||||
.insert(products)
|
||||
.values({
|
||||
tenantId: brand_id,
|
||||
brandId: brand_id,
|
||||
name,
|
||||
description: description ?? null,
|
||||
priceCents: Math.round(price * 100),
|
||||
|
||||
@@ -5,6 +5,7 @@ import { apiLimiter, checkRateLimit, rateLimitExceeded, securityHeaders } from "
|
||||
import { analytics } from "@/lib/analytics";
|
||||
import { captureError } from "@/lib/sentry";
|
||||
import { pool } from "@/lib/db";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
|
||||
// Helper functions
|
||||
function apiResponse(data: unknown, status: number = 200) {
|
||||
@@ -31,6 +32,12 @@ const createReferralSchema = z.object({
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// Authentication check
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Rate limiting
|
||||
const rateCheck = await checkRateLimit(apiLimiter, req.headers.get("x-forwarded-for") || "anonymous");
|
||||
if (!rateCheck.success) {
|
||||
@@ -69,6 +76,12 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
// Authentication check
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
const brand_id = searchParams.get("brand_id");
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { z } from "zod";
|
||||
import { apiLimiter, checkRateLimit, rateLimitExceeded, securityHeaders } from "@/lib/rate-limit";
|
||||
import { captureError } from "@/lib/sentry";
|
||||
import { pool } from "@/lib/db";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
|
||||
// Helper functions
|
||||
function apiResponse(data: unknown, status: number = 200) {
|
||||
@@ -31,6 +32,12 @@ const reportFiltersSchema = z.object({
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
// Authentication check
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Rate limiting
|
||||
const rateCheck = await checkRateLimit(apiLimiter, req.headers.get("x-forwarded-for") || "anonymous");
|
||||
if (!rateCheck.success) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { z } from "zod";
|
||||
import { apiLimiter, checkRateLimit, rateLimitExceeded, securityHeaders } from "@/lib/rate-limit";
|
||||
import { captureError } from "@/lib/sentry";
|
||||
import { pool } from "@/lib/db";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
|
||||
// Helper functions
|
||||
function apiResponse(data: unknown, status: number = 200) {
|
||||
@@ -35,6 +36,12 @@ const createWaterLogSchema = z.object({
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// Authentication check
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Rate limiting
|
||||
const rateCheck = await checkRateLimit(apiLimiter, req.headers.get("x-forwarded-for") || "anonymous");
|
||||
if (!rateCheck.success) {
|
||||
@@ -77,6 +84,12 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
// Authentication check
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
const brand_id = searchParams.get("brand_id");
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import { uploadObject, BUCKETS } from "@/lib/storage";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
@@ -15,8 +15,8 @@ export async function POST(request: Request) {
|
||||
const file = formData.get("file") as File | null;
|
||||
const bucket = formData.get("bucket") as string | null;
|
||||
|
||||
if (!file || !bucket) {
|
||||
return NextResponse.json({ error: "Missing file or bucket" }, { status: 400 });
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: "Missing file" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
@@ -27,34 +27,19 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: "Only JPG or PNG allowed" }, { status: 400 });
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabasePat = process.env.SUPABASE_PAT!;
|
||||
|
||||
const ext = file.type === "image/jpeg" ? "jpg" : "png";
|
||||
const fileName = `${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`;
|
||||
const key = `water-logs/${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`;
|
||||
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const uint8 = new Uint8Array(arrayBuffer);
|
||||
// bucket param is accepted for backwards compat but ignored — always use WATER_LOGS
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
const url = await uploadObject({
|
||||
bucket: BUCKETS.WATER_LOGS,
|
||||
key,
|
||||
body: buffer,
|
||||
contentType: file.type,
|
||||
});
|
||||
|
||||
const uploadRes = await fetch(
|
||||
`${supabaseUrl}/storage/v1/object/${bucket}/${fileName}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
...svcHeaders(supabasePat),
|
||||
"Content-Type": file.type,
|
||||
"x-upsert": "true",
|
||||
},
|
||||
body: uint8,
|
||||
}
|
||||
);
|
||||
|
||||
if (!uploadRes.ok) {
|
||||
return NextResponse.json({ error: "Upload failed" }, { status: 500 });
|
||||
}
|
||||
|
||||
const publicUrl = `${supabaseUrl}/storage/v1/object/public/${bucket}/${fileName}`;
|
||||
return NextResponse.json({ url: publicUrl });
|
||||
return NextResponse.json({ url });
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: "Server error" }, { status: 500 });
|
||||
}
|
||||
|
||||
@@ -2,6 +2,59 @@ import { NextResponse } from "next/server";
|
||||
import crypto from "crypto";
|
||||
import { pool } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* Validates a webhook URL to prevent SSRF attacks.
|
||||
* Blocks:
|
||||
* - Non-HTTPS URLs
|
||||
* - localhost and 127.0.0.1
|
||||
* - Private IP ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)
|
||||
* - Link-local IPv6 addresses (fc00::/7, fe80::/10)
|
||||
* - AWS metadata endpoint (169.254.169.254)
|
||||
*/
|
||||
function validateWebhookUrl(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
|
||||
// Only allow HTTPS URLs
|
||||
if (parsed.protocol !== "https:") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hostname = parsed.hostname.toLowerCase();
|
||||
|
||||
// Block localhost and loopback
|
||||
if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Block private IP ranges using regex patterns
|
||||
const blockedPatterns = [
|
||||
// 10.0.0.0/8 - Class A private network
|
||||
/^10\./,
|
||||
// 172.16.0.0/12 - Class B private network (172.16.x.x - 172.31.x.x)
|
||||
/^172\.(1[6-9]|2[0-9]|3[01])\./,
|
||||
// 192.168.0.0/16 - Class C private network
|
||||
/^192\.168\./,
|
||||
// AWS metadata endpoint
|
||||
/^169\.254\.169\.254$/,
|
||||
// Link-local IPv6 (fc00::/7 and fe80::/10)
|
||||
/^fc00:/i,
|
||||
/^fe80:/i,
|
||||
];
|
||||
|
||||
for (const pattern of blockedPatterns) {
|
||||
if (pattern.test(hostname)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
// Invalid URL format
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/wholesale/webhooks/dispatch
|
||||
// Processes pending webhook events from wholesale_sync_log and dispatches to configured URLs.
|
||||
// Called by a cron job or manually after enqueue_wholesale_webhook has queued events.
|
||||
@@ -33,6 +86,19 @@ export async function POST() {
|
||||
let dispatched = 0;
|
||||
|
||||
for (const webhook of pending) {
|
||||
// Validate URL to prevent SSRF attacks
|
||||
if (!validateWebhookUrl(webhook.url)) {
|
||||
console.error("[SSRF_BLOCKED]", {
|
||||
webhookId: webhook.id,
|
||||
brandId: webhook.brand_id,
|
||||
url: webhook.url,
|
||||
eventType: webhook.event_type,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
await markFailed(webhook.id, "SSRF attempt blocked: invalid webhook URL");
|
||||
continue;
|
||||
}
|
||||
|
||||
const payload = webhook.payload ?? {};
|
||||
const payloadString = JSON.stringify(payload);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user