From 379d9aa13c919bc8c515a18f9caadd6905021a74 Mon Sep 17 00:00:00 2001 From: Dennis Date: Sat, 7 Mar 2026 01:04:37 +0100 Subject: [PATCH] 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> --- README.md | 22 +- backend/package.json | 2 +- frontend/package-lock.json | 21 + frontend/package.json | 4 +- frontend/src/app/api/admin/coupons/route.ts | 173 +++++++ frontend/src/app/api/admin/credits/route.ts | 146 ++++++ frontend/src/app/api/admin/invoices/route.ts | 196 ++++++++ frontend/src/app/api/admin/payments/route.ts | 111 ++++ .../admin/_components/CouponsTab.tsx | 456 +++++++++++++++++ .../admin/_components/CreditsTab.tsx | 476 ++++++++++++++++++ .../admin/_components/InvoicesTab.tsx | 438 ++++++++++++++++ .../admin/_components/PaymentsTab.tsx | 333 ++++++++++++ frontend/src/app/dashboard/admin/page.tsx | 32 +- frontend/src/lib/email-templates/index.ts | 278 ++++++++++ frontend/src/lib/email.ts | 82 +++ frontend/src/services/notificationService.ts | 69 +-- ...6234000_admin_payments_coupons_credits.sql | 180 +++++++ package.json | 4 +- 18 files changed, 2956 insertions(+), 67 deletions(-) create mode 100644 frontend/src/app/api/admin/coupons/route.ts create mode 100644 frontend/src/app/api/admin/credits/route.ts create mode 100644 frontend/src/app/api/admin/invoices/route.ts create mode 100644 frontend/src/app/api/admin/payments/route.ts create mode 100644 frontend/src/app/dashboard/admin/_components/CouponsTab.tsx create mode 100644 frontend/src/app/dashboard/admin/_components/CreditsTab.tsx create mode 100644 frontend/src/app/dashboard/admin/_components/InvoicesTab.tsx create mode 100644 frontend/src/app/dashboard/admin/_components/PaymentsTab.tsx create mode 100644 frontend/src/lib/email-templates/index.ts create mode 100644 frontend/src/lib/email.ts create mode 100644 frontend/supabase/migrations/20260306234000_admin_payments_coupons_credits.sql diff --git a/README.md b/README.md index 02f542f..8372124 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ -# πŸ” Website Monitoring Platform +# ⬑ CloudLense -Full-stack website monitoring platform that uses **Google Lighthouse** to audit performance, SEO, accessibility, and best practices β€” with real-time progress tracking, team collaboration, and alerting. +Full-stack website monitoring & performance auditing platform powered by **Google Lighthouse** β€” with real-time progress tracking, team collaboration, billing, and alerting. ``` β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ Website Monitoring β”‚ +β”‚ CloudLense β”‚ β”‚ β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”‚ Frontend │───▢│ Backend │───▢│ PostgreSQL (DB) β”‚ β”‚ @@ -32,9 +32,9 @@ Full-stack website monitoring platform that uses **Google Lighthouse** to audit | **SEO Analysis** | βœ… Real | SEO score tracking and recommendations | | **Uptime Monitoring** | βœ… Real | HTTP HEAD checks every 5 min, response time + SSL tracking | | **Alert Engine** | βœ… Real | Evaluates scans against thresholds, auto-resolves on recovery | -| **Notifications** | βœ… Real | Email (Resend) + webhook delivery with debouncing | -| **Admin Dashboard** | βœ… Real | System stats, user CRUD, org management (role-protected) | -| **Billing & Usage** | βœ… Real | 4 tiers (free/starter/pro/enterprise), usage bars, limit enforcement | +| **Notifications** | βœ… Real | SMTP email (info@dk0.dev) + webhook delivery with debouncing | +| **Admin Dashboard** | βœ… Real | System stats, user CRUD, org management, payments, coupons, credits, invoices | +| **Billing & Usage** | βœ… Real | 4 tiers (free/starter/pro/enterprise), usage bars, limit enforcement, coupon system | | **Competitor Analysis** | βœ… Real | Lighthouse comparison + response time benchmarking | | **Team/Organization** | βœ… Real | Multi-user orgs with 4-level RBAC | | **Authentication** | βœ… Real | Supabase Auth (email, OAuth) | @@ -55,7 +55,7 @@ Full-stack website monitoring platform that uses **Google Lighthouse** to audit | Containers | Docker + Docker Compose | Free | | Linting | ESLint + Prettier | Free (OSS) | | Testing | Jest + Supertest + Testing Library | Free (OSS) | -| Email | Resend | Free tier (3000/mo) | +| Email | SMTP (nodemailer) | Self-hosted | | Pre-commit | Husky | Free (OSS) | ## πŸš€ Quick Start @@ -71,7 +71,7 @@ Full-stack website monitoring platform that uses **Google Lighthouse** to audit ```bash # Clone the repo git clone -cd website-monitoring +cd cloudlense # Install root dependencies (Husky, concurrently) npm install @@ -108,7 +108,7 @@ npm run docker:up ## πŸ“ Project Structure ``` -website-monitoring/ +cloudlense/ β”œβ”€β”€ backend/ # Express.js API + Lighthouse engine β”‚ β”œβ”€β”€ src/ β”‚ β”‚ β”œβ”€β”€ index.ts # Server entry, health check, routing @@ -173,6 +173,10 @@ npm run test:frontend | `/api/admin/stats` | GET | System-wide stats (admin only) | | `/api/admin/users` | GET/PATCH/DELETE | User management (admin only) | | `/api/admin/organizations` | GET/PATCH | Organization management (admin only) | +| `/api/admin/payments` | GET/POST | Payment tracking & recording (admin only) | +| `/api/admin/coupons` | GET/POST/PATCH/DELETE | Coupon management (admin only) | +| `/api/admin/credits` | GET/POST | Account credit management (admin only) | +| `/api/admin/invoices` | GET/POST/PATCH | Invoice creation, sending & management (admin only) | | `/api/billing/usage` | GET | Current org usage vs tier limits | | `/api/competitor-analysis` | GET/POST | Competitor benchmarking | diff --git a/backend/package.json b/backend/package.json index fd11f6e..ae53904 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,5 +1,5 @@ { - "name": "website-monitoring-backend", + "name": "cloudlense-backend", "version": "1.0.0", "description": "", "main": "index.js", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 88f67f0..7773068 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -28,6 +28,7 @@ "lighthouse": "^12.6.1", "lucide-react": "^0.477.0", "next": "^15.2.4", + "nodemailer": "^8.0.1", "postcss": "^8.5.3", "puppeteer": "^24.7.0", "react": "^19.0.0", @@ -48,6 +49,7 @@ "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.0.1", "@types/node": "^20", + "@types/nodemailer": "^7.0.11", "@types/react": "^19", "@types/react-dom": "^19", "concurrently": "^9.1.2", @@ -3754,6 +3756,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/nodemailer": { + "version": "7.0.11", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.11.tgz", + "integrity": "sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/phoenix": { "version": "1.6.6", "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz", @@ -11705,6 +11717,15 @@ "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", "license": "MIT" }, + "node_modules/nodemailer": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.1.tgz", + "integrity": "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 868c754..392c38c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,5 +1,5 @@ { - "name": "website-monitoring", + "name": "cloudlense-frontend", "version": "0.1.0", "private": true, "scripts": { @@ -31,6 +31,7 @@ "lighthouse": "^12.6.1", "lucide-react": "^0.477.0", "next": "^15.2.4", + "nodemailer": "^8.0.1", "postcss": "^8.5.3", "puppeteer": "^24.7.0", "react": "^19.0.0", @@ -51,6 +52,7 @@ "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.0.1", "@types/node": "^20", + "@types/nodemailer": "^7.0.11", "@types/react": "^19", "@types/react-dom": "^19", "concurrently": "^9.1.2", diff --git a/frontend/src/app/api/admin/coupons/route.ts b/frontend/src/app/api/admin/coupons/route.ts new file mode 100644 index 0000000..b884156 --- /dev/null +++ b/frontend/src/app/api/admin/coupons/route.ts @@ -0,0 +1,173 @@ +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 page = parseInt(searchParams.get("page") || "1"); + const limit = parseInt(searchParams.get("limit") || "20"); + const activeOnly = searchParams.get("activeOnly") === "true"; + const offset = (page - 1) * limit; + + try { + let query = supabase + .from("coupons") + .select("*, creator:users!coupons_created_by_fkey(id, email, name)", { count: "exact" }) + .order("created_at", { ascending: false }) + .range(offset, offset + limit - 1); + + if (activeOnly) query = query.eq("is_active", true); + + const { data: coupons, count, error } = await query; + if (error) throw error; + + return NextResponse.json({ + coupons: coupons || [], + pagination: { page, limit, total: count || 0, pages: Math.ceil((count || 0) / limit) }, + }); + } catch (error) { + console.error("Error fetching coupons:", error); + return NextResponse.json({ error: "Failed to fetch coupons" }, { 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 { + code, + description, + discount_type, + discount_value, + currency, + max_redemptions, + applicable_tiers, + valid_from, + valid_until, + } = body; + + if (!code || !discount_type || discount_value === undefined) { + return NextResponse.json( + { error: "code, discount_type, and discount_value are required" }, + { status: 400 } + ); + } + + if (discount_type === "percentage" && (discount_value < 1 || discount_value > 100)) { + return NextResponse.json( + { error: "Percentage discount must be between 1 and 100" }, + { status: 400 } + ); + } + + const { data: coupon, error } = await supabase + .from("coupons") + .insert({ + code: code.toUpperCase().trim(), + description, + discount_type, + discount_value: Math.round(discount_value), + currency: currency || "EUR", + max_redemptions: max_redemptions || null, + applicable_tiers: applicable_tiers || ["free", "starter", "professional"], + valid_from: valid_from || new Date().toISOString(), + valid_until: valid_until || null, + created_by: authResult.userId, + }) + .select() + .single(); + + if (error) { + if (error.code === "23505") { + return NextResponse.json({ error: "A coupon with this code already exists" }, { status: 409 }); + } + throw error; + } + + return NextResponse.json({ coupon }); + } catch (error) { + console.error("Error creating coupon:", error); + return NextResponse.json({ error: "Failed to create coupon" }, { 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, ...updates } = body; + + if (!id) { + return NextResponse.json({ error: "Coupon id is required" }, { status: 400 }); + } + + const allowedFields = [ + "description", + "discount_type", + "discount_value", + "max_redemptions", + "applicable_tiers", + "valid_from", + "valid_until", + "is_active", + ]; + + const safeUpdates: Record = { updated_at: new Date().toISOString() }; + for (const key of allowedFields) { + if (key in updates) safeUpdates[key] = updates[key]; + } + + if (updates.code) safeUpdates.code = updates.code.toUpperCase().trim(); + + const { data: coupon, error } = await supabase + .from("coupons") + .update(safeUpdates) + .eq("id", id) + .select() + .single(); + + if (error) throw error; + + return NextResponse.json({ coupon }); + } catch (error) { + console.error("Error updating coupon:", error); + return NextResponse.json({ error: "Failed to update coupon" }, { status: 500 }); + } +} + +export async function DELETE(request: Request) { + const authResult = await requireAdmin(request); + if (authResult instanceof NextResponse) return authResult; + + const supabase = getSupabaseAdmin(); + + try { + const { searchParams } = new URL(request.url); + const id = searchParams.get("id"); + + if (!id) { + return NextResponse.json({ error: "Coupon id is required" }, { status: 400 }); + } + + const { error } = await supabase.from("coupons").delete().eq("id", id); + if (error) throw error; + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Error deleting coupon:", error); + return NextResponse.json({ error: "Failed to delete coupon" }, { status: 500 }); + } +} diff --git a/frontend/src/app/api/admin/credits/route.ts b/frontend/src/app/api/admin/credits/route.ts new file mode 100644 index 0000000..cccc14d --- /dev/null +++ b/frontend/src/app/api/admin/credits/route.ts @@ -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 }); + } +} diff --git a/frontend/src/app/api/admin/invoices/route.ts b/frontend/src/app/api/admin/invoices/route.ts new file mode 100644 index 0000000..10629d1 --- /dev/null +++ b/frontend/src/app/api/admin/invoices/route.ts @@ -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 = { 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 }); + } +} diff --git a/frontend/src/app/api/admin/payments/route.ts b/frontend/src/app/api/admin/payments/route.ts new file mode 100644 index 0000000..ffa5e26 --- /dev/null +++ b/frontend/src/app/api/admin/payments/route.ts @@ -0,0 +1,111 @@ +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 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("payments") + .select( + "*, organization:organizations(id, name), creator:users!payments_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: payments, count, error } = await query; + if (error) throw error; + + return NextResponse.json({ + payments: payments || [], + pagination: { page, limit, total: count || 0, pages: Math.ceil((count || 0) / limit) }, + }); + } catch (error) { + console.error("Error fetching payments:", error); + return NextResponse.json({ error: "Failed to fetch payments" }, { 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, status, method, description, notes } = body; + + if (!organization_id || amount === undefined) { + return NextResponse.json( + { error: "organization_id and amount are required" }, + { status: 400 } + ); + } + + const { data: payment, error } = await supabase + .from("payments") + .insert({ + organization_id, + amount: Math.round(amount), + currency: currency || "EUR", + status: status || "completed", + method: method || "manual", + description, + notes, + created_by: authResult.userId, + }) + .select("*, organization:organizations(id, name)") + .single(); + + if (error) throw error; + + // If payment is completed, optionally add credits + if (payment.status === "completed" && body.add_as_credit) { + const { data: org } = await supabase + .from("organizations") + .select("credit_balance") + .eq("id", organization_id) + .single(); + + const currentBalance = typeof org?.credit_balance === "number" ? org.credit_balance : 0; + const newBalance = currentBalance + Math.round(amount); + + await supabase + .from("credit_transactions") + .insert({ + organization_id, + amount: Math.round(amount), + balance_after: newBalance, + type: "credit", + reason: "payment", + reference_id: payment.id, + created_by: authResult.userId, + }); + + await supabase + .from("organizations") + .update({ credit_balance: newBalance }) + .eq("id", organization_id); + } + + return NextResponse.json({ payment }); + } catch (error) { + console.error("Error creating payment:", error); + return NextResponse.json({ error: "Failed to create payment" }, { status: 500 }); + } +} diff --git a/frontend/src/app/dashboard/admin/_components/CouponsTab.tsx b/frontend/src/app/dashboard/admin/_components/CouponsTab.tsx new file mode 100644 index 0000000..ad58cb2 --- /dev/null +++ b/frontend/src/app/dashboard/admin/_components/CouponsTab.tsx @@ -0,0 +1,456 @@ +"use client"; + +import { useEffect, useState, useCallback } from "react"; +import { + Ticket, + Plus, + Pencil, + Trash2, + Copy, + Check, +} from "lucide-react"; + +interface Coupon { + id: string; + code: string; + description: string | null; + discount_type: string; + discount_value: number; + currency: string; + max_redemptions: number | null; + current_redemptions: number; + applicable_tiers: string[]; + valid_from: string; + valid_until: string | null; + is_active: boolean; + created_at: string; + creator: { id: string; email: string; name: string | null } | null; +} + +export function CouponsTab() { + const [coupons, setCoupons] = useState([]); + const [loading, setLoading] = useState(true); + const [showForm, setShowForm] = useState(false); + const [editingCoupon, setEditingCoupon] = useState(null); + const [copiedCode, setCopiedCode] = useState(null); + const [page, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const [formData, setFormData] = useState({ + code: "", + description: "", + discount_type: "percentage" as string, + discount_value: "", + currency: "EUR", + max_redemptions: "", + applicable_tiers: ["free", "starter", "professional"] as string[], + valid_until: "", + }); + + const fetchCoupons = useCallback(async () => { + setLoading(true); + try { + const res = await fetch(`/api/admin/coupons?page=${page}&limit=15`); + if (res.ok) { + const data = await res.json(); + setCoupons(data.coupons); + setTotalPages(data.pagination.pages); + } + } catch (e) { + console.error("Failed to fetch coupons:", e); + } finally { + setLoading(false); + } + }, [page]); + + useEffect(() => { + fetchCoupons(); + }, [fetchCoupons]); + + const resetForm = () => { + setFormData({ + code: "", + description: "", + discount_type: "percentage", + discount_value: "", + currency: "EUR", + max_redemptions: "", + applicable_tiers: ["free", "starter", "professional"], + valid_until: "", + }); + setEditingCoupon(null); + setShowForm(false); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + try { + const payload = { + ...formData, + discount_value: + formData.discount_type === "fixed_amount" + ? Math.round(parseFloat(formData.discount_value) * 100) + : parseInt(formData.discount_value), + max_redemptions: formData.max_redemptions ? parseInt(formData.max_redemptions) : null, + valid_until: formData.valid_until || null, + }; + + if (editingCoupon) { + const res = await fetch("/api/admin/coupons", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: editingCoupon.id, ...payload }), + }); + if (!res.ok) throw new Error("Failed to update"); + } else { + const res = await fetch("/api/admin/coupons", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + const data = await res.json(); + alert(data.error || "Failed to create coupon"); + return; + } + } + resetForm(); + fetchCoupons(); + } catch (e) { + console.error("Failed to save coupon:", e); + } + }; + + const handleDelete = async (id: string) => { + if (!confirm("Delete this coupon? This cannot be undone.")) return; + try { + await fetch(`/api/admin/coupons?id=${id}`, { method: "DELETE" }); + fetchCoupons(); + } catch (e) { + console.error("Failed to delete coupon:", e); + } + }; + + const handleToggleActive = async (coupon: Coupon) => { + try { + await fetch("/api/admin/coupons", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: coupon.id, is_active: !coupon.is_active }), + }); + fetchCoupons(); + } catch (e) { + console.error("Failed to toggle coupon:", e); + } + }; + + const startEdit = (coupon: Coupon) => { + setFormData({ + code: coupon.code, + description: coupon.description || "", + discount_type: coupon.discount_type, + discount_value: + coupon.discount_type === "fixed_amount" + ? String(coupon.discount_value / 100) + : String(coupon.discount_value), + currency: coupon.currency, + max_redemptions: coupon.max_redemptions ? String(coupon.max_redemptions) : "", + applicable_tiers: coupon.applicable_tiers || ["free", "starter", "professional"], + valid_until: coupon.valid_until ? coupon.valid_until.slice(0, 16) : "", + }); + setEditingCoupon(coupon); + setShowForm(true); + }; + + const copyCode = (code: string) => { + navigator.clipboard.writeText(code); + setCopiedCode(code); + setTimeout(() => setCopiedCode(null), 2000); + }; + + const formatDiscount = (coupon: Coupon) => { + if (coupon.discount_type === "percentage") return `${coupon.discount_value}%`; + if (coupon.discount_type === "fixed_amount") { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: coupon.currency, + }).format(coupon.discount_value / 100); + } + return "Tier Upgrade"; + }; + + const tierToggle = (tier: string) => { + const tiers = formData.applicable_tiers.includes(tier) + ? formData.applicable_tiers.filter((t) => t !== tier) + : [...formData.applicable_tiers, tier]; + setFormData({ ...formData, applicable_tiers: tiers }); + }; + + return ( +
+ {/* Header */} +
+ +
+ + {/* Form */} + {showForm && ( +
+

+ {editingCoupon ? "Edit Coupon" : "Create New Coupon"} +

+
+
+ + setFormData({ ...formData, code: e.target.value.toUpperCase() })} + placeholder="e.g. WELCOME20" + className="w-full text-sm border rounded-md px-3 py-2 font-mono uppercase focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+
+ + +
+
+ + setFormData({ ...formData, discount_value: e.target.value })} + placeholder={formData.discount_type === "percentage" ? "20" : "9.99"} + className="w-full text-sm border rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+
+ + setFormData({ ...formData, max_redemptions: e.target.value })} + placeholder="Unlimited" + className="w-full text-sm border rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+
+ + setFormData({ ...formData, valid_until: e.target.value })} + className="w-full text-sm border rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+
+ +
+ {["free", "starter", "professional", "enterprise"].map((tier) => ( + + ))} +
+
+
+ + setFormData({ ...formData, description: e.target.value })} + placeholder="e.g. Welcome discount for new users" + className="w-full text-sm border rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+
+
+ + +
+
+ )} + + {/* Coupons Table */} +
+ + + + + + + + + + + + + + {coupons.map((coupon) => { + const isExpired = coupon.valid_until && new Date(coupon.valid_until) < new Date(); + const isMaxed = + coupon.max_redemptions !== null && + coupon.current_redemptions >= coupon.max_redemptions; + + return ( + + + + + + + + + + ); + })} + {!loading && coupons.length === 0 && ( + + + + )} + +
CodeDiscountRedemptionsTiersExpiresStatusActions
+
+ + {coupon.code} + + +
+ {coupon.description && ( +

{coupon.description}

+ )} +
+ {formatDiscount(coupon)} + + {coupon.current_redemptions} + {coupon.max_redemptions !== null ? ` / ${coupon.max_redemptions}` : " / ∞"} + +
+ {(coupon.applicable_tiers || []).map((tier) => ( + + {tier} + + ))} +
+
+ {coupon.valid_until + ? new Date(coupon.valid_until).toLocaleDateString() + : "Never"} + + + +
+ + +
+
+ + No coupons created yet +
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ + Page {page} of {totalPages} + +
+ )} +
+ ); +} diff --git a/frontend/src/app/dashboard/admin/_components/CreditsTab.tsx b/frontend/src/app/dashboard/admin/_components/CreditsTab.tsx new file mode 100644 index 0000000..5a0875f --- /dev/null +++ b/frontend/src/app/dashboard/admin/_components/CreditsTab.tsx @@ -0,0 +1,476 @@ +"use client"; + +import { useEffect, useState, useCallback } from "react"; +import { + Wallet, + Plus, + Minus, + ArrowUpRight, + ArrowDownRight, + Search, +} from "lucide-react"; + +interface OrgCredit { + id: string; + name: string; + credit_balance: number; + subscription_tier: string; +} + +interface CreditTransaction { + id: string; + organization_id: string; + amount: number; + balance_after: number; + type: string; + reason: string; + notes: string | null; + created_at: string; + creator: { id: string; email: string; name: string | null } | null; +} + +interface AdminOrg { + id: string; + name: string; +} + +export function CreditsTab({ organizations }: { organizations: AdminOrg[] }) { + const [orgCredits, setOrgCredits] = useState([]); + const [selectedOrg, setSelectedOrg] = useState(null); + const [transactions, setTransactions] = useState([]); + const [selectedOrgData, setSelectedOrgData] = useState<{ id: string; name: string; credit_balance: number } | null>(null); + const [loading, setLoading] = useState(true); + const [showForm, setShowForm] = useState(false); + const [page, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const [searchTerm, setSearchTerm] = useState(""); + const [formData, setFormData] = useState({ + organization_id: "", + amount: "", + type: "credit" as "credit" | "debit", + reason: "manual_credit", + notes: "", + }); + + const fetchCredits = useCallback(async () => { + setLoading(true); + try { + if (selectedOrg) { + const res = await fetch(`/api/admin/credits?orgId=${selectedOrg}&page=${page}&limit=20`); + if (res.ok) { + const data = await res.json(); + setSelectedOrgData(data.organization); + setTransactions(data.transactions); + setTotalPages(data.pagination.pages); + } + } else { + const res = await fetch(`/api/admin/credits?page=${page}&limit=20`); + if (res.ok) { + const data = await res.json(); + setOrgCredits(data.organizations); + setTotalPages(data.pagination.pages); + } + } + } catch (e) { + console.error("Failed to fetch credits:", e); + } finally { + setLoading(false); + } + }, [selectedOrg, page]); + + useEffect(() => { + fetchCredits(); + }, [fetchCredits]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + try { + const res = await fetch("/api/admin/credits", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + ...formData, + amount: Math.round(parseFloat(formData.amount) * 100), + }), + }); + if (res.ok) { + setShowForm(false); + setFormData({ + organization_id: selectedOrg || "", + amount: "", + type: "credit", + reason: "manual_credit", + notes: "", + }); + fetchCredits(); + } else { + const data = await res.json(); + alert(data.error || "Failed to process transaction"); + } + } catch (e) { + console.error("Failed to process credit:", e); + } + }; + + const formatAmount = (amount: number) => { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "EUR", + }).format(amount / 100); + }; + + const tierColors: Record = { + free: "bg-gray-100 text-gray-700", + starter: "bg-blue-100 text-blue-700", + professional: "bg-purple-100 text-purple-700", + enterprise: "bg-amber-100 text-amber-700", + }; + + const filteredOrgs = searchTerm + ? orgCredits.filter((o) => o.name.toLowerCase().includes(searchTerm.toLowerCase())) + : orgCredits; + + // Detail view for a single org + if (selectedOrg && selectedOrgData) { + return ( +
+ {/* Back + Header */} +
+
+ +
+

{selectedOrgData.name}

+

+ Balance: {formatAmount(selectedOrgData.credit_balance)} +

+
+
+
+ + +
+
+ + {/* Form */} + {showForm && ( +
+

+ {formData.type === "credit" ? "Add Credit" : "Deduct Credit"} +

+
+
+ + setFormData({ ...formData, amount: e.target.value })} + placeholder="0.00" + className="w-full text-sm border rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+
+ + +
+
+ +