feat: add admin billing system, SMTP email, rename to CloudLense

- Add payments, coupons, credits, invoices management to admin dashboard
- Add 7 new admin tabs: Overview, Users, Orgs, Payments, Coupons, Credits, Invoices
- Replace Resend with SMTP email via nodemailer (info@dk0.dev / mail.dk0.dev)
- Add professional branded email templates (alerts, welcome, invoice, credit, password reset)
- Add database migration for payments, coupons, coupon_redemptions, credit_transactions, invoices tables
- Add credit_balance column to organizations
- Add RLS policies for all new tables
- Add 4 new API routes: /api/admin/{payments,coupons,credits,invoices}
- Rename project from website-monitoring to CloudLense
- Update all package.json names and README

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Dennis
2026-03-07 01:04:37 +01:00
parent 50e25e3ee8
commit 379d9aa13c
18 changed files with 2956 additions and 67 deletions
@@ -0,0 +1,196 @@
import { NextResponse } from "next/server";
import { getSupabaseAdmin } from "@/lib/admin";
import { requireAdmin } from "@/lib/apiAuth";
import { sendEmail } from "@/lib/email";
import { invoiceEmail } from "@/lib/email-templates";
export async function GET(request: Request) {
const authResult = await requireAdmin(request);
if (authResult instanceof NextResponse) return authResult;
const supabase = getSupabaseAdmin();
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get("page") || "1");
const limit = parseInt(searchParams.get("limit") || "20");
const orgId = searchParams.get("orgId");
const status = searchParams.get("status");
const offset = (page - 1) * limit;
try {
let query = supabase
.from("invoices")
.select(
"*, organization:organizations(id, name, billing_email), creator:users!invoices_created_by_fkey(id, email, name)",
{ count: "exact" }
)
.order("created_at", { ascending: false })
.range(offset, offset + limit - 1);
if (orgId) query = query.eq("organization_id", orgId);
if (status) query = query.eq("status", status);
const { data: invoices, count, error } = await query;
if (error) throw error;
return NextResponse.json({
invoices: invoices || [],
pagination: { page, limit, total: count || 0, pages: Math.ceil((count || 0) / limit) },
});
} catch (error) {
console.error("Error fetching invoices:", error);
return NextResponse.json({ error: "Failed to fetch invoices" }, { status: 500 });
}
}
export async function POST(request: Request) {
const authResult = await requireAdmin(request);
if (authResult instanceof NextResponse) return authResult;
const supabase = getSupabaseAdmin();
try {
const body = await request.json();
const { organization_id, amount, currency, items, due_date, notes, status: invoiceStatus } = body;
if (!organization_id || amount === undefined || !items?.length) {
return NextResponse.json(
{ error: "organization_id, amount, and items are required" },
{ status: 400 }
);
}
// Generate invoice number: INV-YYYYMM-XXXX
const now = new Date();
const prefix = `INV-${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}`;
const { count } = await supabase
.from("invoices")
.select("id", { count: "exact", head: true })
.like("invoice_number", `${prefix}%`);
const seq = String((count || 0) + 1).padStart(4, "0");
const { data: invoice, error } = await supabase
.from("invoices")
.insert({
invoice_number: `${prefix}-${seq}`,
organization_id,
amount: Math.round(amount),
currency: currency || "EUR",
status: invoiceStatus || "draft",
items,
due_date: due_date || null,
notes,
created_by: authResult.userId,
})
.select("*, organization:organizations(id, name, billing_email)")
.single();
if (error) throw error;
return NextResponse.json({ invoice });
} catch (error) {
console.error("Error creating invoice:", error);
return NextResponse.json({ error: "Failed to create invoice" }, { status: 500 });
}
}
export async function PATCH(request: Request) {
const authResult = await requireAdmin(request);
if (authResult instanceof NextResponse) return authResult;
const supabase = getSupabaseAdmin();
try {
const body = await request.json();
const { id, action, ...updates } = body;
if (!id) {
return NextResponse.json({ error: "Invoice id is required" }, { status: 400 });
}
// Send invoice via email
if (action === "send") {
const { data: invoice } = await supabase
.from("invoices")
.select("*, organization:organizations(id, name, billing_email)")
.eq("id", id)
.single();
if (!invoice) {
return NextResponse.json({ error: "Invoice not found" }, { status: 404 });
}
const org = invoice.organization as { id: string; name: string; billing_email?: string } | null;
const recipientEmail = org?.billing_email;
if (!recipientEmail) {
return NextResponse.json(
{ error: "Organization has no billing email configured" },
{ status: 400 }
);
}
const appUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
const template = invoiceEmail({
organizationName: org.name,
invoiceNumber: invoice.invoice_number,
amount: invoice.amount,
currency: invoice.currency,
dueDate: invoice.due_date,
items: (invoice.items as Array<{ description: string; amount: number }>) || [],
dashboardUrl: `${appUrl}/dashboard/settings`,
});
const sent = await sendEmail({
to: recipientEmail,
subject: template.subject,
html: template.html,
text: template.text,
});
if (!sent) {
return NextResponse.json({ error: "Failed to send email" }, { status: 500 });
}
await supabase
.from("invoices")
.update({ status: "sent", updated_at: new Date().toISOString() })
.eq("id", id);
return NextResponse.json({ success: true, message: "Invoice sent" });
}
// Mark as paid
if (action === "mark_paid") {
const { data: invoice, error } = await supabase
.from("invoices")
.update({ status: "paid", paid_at: new Date().toISOString(), updated_at: new Date().toISOString() })
.eq("id", id)
.select()
.single();
if (error) throw error;
return NextResponse.json({ invoice });
}
// Generic update
const allowedFields = ["status", "amount", "items", "due_date", "notes"];
const safeUpdates: Record<string, unknown> = { updated_at: new Date().toISOString() };
for (const key of allowedFields) {
if (key in updates) safeUpdates[key] = updates[key];
}
const { data: invoice, error } = await supabase
.from("invoices")
.update(safeUpdates)
.eq("id", id)
.select()
.single();
if (error) throw error;
return NextResponse.json({ invoice });
} catch (error) {
console.error("Error updating invoice:", error);
return NextResponse.json({ error: "Failed to update invoice" }, { status: 500 });
}
}