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
+146
View File
@@ -0,0 +1,146 @@
import { NextResponse } from "next/server";
import { getSupabaseAdmin } from "@/lib/admin";
import { requireAdmin } from "@/lib/apiAuth";
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 orgId = searchParams.get("orgId");
const page = parseInt(searchParams.get("page") || "1");
const limit = parseInt(searchParams.get("limit") || "20");
const offset = (page - 1) * limit;
try {
if (orgId) {
// Get specific org's credit balance and transaction history
const [orgResult, transactionsResult] = await Promise.all([
supabase
.from("organizations")
.select("id, name, credit_balance")
.eq("id", orgId)
.single(),
supabase
.from("credit_transactions")
.select(
"*, creator:users!credit_transactions_created_by_fkey(id, email, name)",
{ count: "exact" }
)
.eq("organization_id", orgId)
.order("created_at", { ascending: false })
.range(offset, offset + limit - 1),
]);
if (orgResult.error) throw orgResult.error;
return NextResponse.json({
organization: orgResult.data,
transactions: transactionsResult.data || [],
pagination: {
page,
limit,
total: transactionsResult.count || 0,
pages: Math.ceil((transactionsResult.count || 0) / limit),
},
});
}
// List all organizations with credit balances
const { data: orgs, count, error } = await supabase
.from("organizations")
.select("id, name, credit_balance, subscription_tier", { count: "exact" })
.order("credit_balance", { ascending: false })
.range(offset, offset + limit - 1);
if (error) throw error;
return NextResponse.json({
organizations: orgs || [],
pagination: { page, limit, total: count || 0, pages: Math.ceil((count || 0) / limit) },
});
} catch (error) {
console.error("Error fetching credits:", error);
return NextResponse.json({ error: "Failed to fetch credits" }, { 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, type, reason, notes } = body;
if (!organization_id || amount === undefined || !type || !reason) {
return NextResponse.json(
{ error: "organization_id, amount, type, and reason are required" },
{ status: 400 }
);
}
if (!["credit", "debit"].includes(type)) {
return NextResponse.json({ error: "type must be 'credit' or 'debit'" }, { status: 400 });
}
// Get current balance
const { data: org, error: orgError } = await supabase
.from("organizations")
.select("id, name, credit_balance")
.eq("id", organization_id)
.single();
if (orgError || !org) {
return NextResponse.json({ error: "Organization not found" }, { status: 404 });
}
const absAmount = Math.abs(Math.round(amount));
const signedAmount = type === "credit" ? absAmount : -absAmount;
const currentBalance = typeof org.credit_balance === "number" ? org.credit_balance : 0;
const newBalance = currentBalance + signedAmount;
if (newBalance < 0) {
return NextResponse.json(
{ error: `Insufficient balance. Current: ${currentBalance}, debit: ${absAmount}` },
{ status: 400 }
);
}
// Insert transaction and update balance atomically
const { data: transaction, error: txError } = await supabase
.from("credit_transactions")
.insert({
organization_id,
amount: signedAmount,
balance_after: newBalance,
type,
reason,
notes,
created_by: authResult.userId,
})
.select()
.single();
if (txError) throw txError;
const { error: updateError } = await supabase
.from("organizations")
.update({ credit_balance: newBalance })
.eq("id", organization_id);
if (updateError) throw updateError;
return NextResponse.json({
transaction,
new_balance: newBalance,
organization: { id: org.id, name: org.name },
});
} catch (error) {
console.error("Error processing credit transaction:", error);
return NextResponse.json({ error: "Failed to process credit transaction" }, { status: 500 });
}
}