| Workstream | Phase | Task | Resource | Days | Rate (£) | Sell (£) | Cont% | Total (£) |
|---|
{{tag}} placeholders
anywhere in the template where you want AI-generated content inserted.
The AI reads your full template structure and replaces every tag with content derived from the selected cost model.
{{your_custom_section}}.
The AI will detect all {{tags}} in your document and replace them intelligently.
{{background}} ·
4. Save and upload below ·
5. The AI replaces every tag with content from your cost model
| Feature | Free | Professional |
|---|---|---|
| AI generations/month | 3 | 200 (+ top-ups) |
| Users per organisation | 1 | Unlimited |
| SoW generator | ✕ | ✓ |
| Approval workflow | ✕ | ✓ |
| Rate card management | ✕ | ✓ |
| Templates | ✕ | ✓ |
| AI top-up credits | ✕ | ✓ |
| Shared Anthropic AI connection | ✓ | ✓ |
superAdminHash in js/config.js using the instructions in that file and push to GitHub to make it permanent.
ANTHROPIC_KEY lives in Cloudflare → costmodel-api Worker → Settings → Variables as an encrypted secret. That is the only place it needs to be.
Cloudflare → Workers & Pages → costmodel-api → Settings → Variables → click Edit next to
ANTHROPIC_KEY → paste new key → Encrypt → Save and deploy
// ═══════════════════════════════════════════════════════════════
// CostModel.co.uk — Cloudflare Worker Script (D1 Database Edition)
// ═══════════════════════════════════════════════════════════════
// Worker Variables (Settings → Variables, all Encrypted):
// ANTHROPIC_KEY — sk-ant-... Anthropic API key
// WORKER_TOKEN — secret auth token
// BREVO_API_KEY — xkeysib-... from brevo.com
// STRIPE_WEBHOOK_SECRET — whsec_... from Stripe dashboard webhooks page
//
// Worker Bindings (Settings → Bindings):
// D1 Database binding: variable name = DB, database = costmodel-db
// ═══════════════════════════════════════════════════════════════
const ALLOWED_ORIGINS = [
'https://costmodel.co.uk',
'https://www.costmodel.co.uk',
];
function getCORS(request) {
const origin = request ? request.headers.get('Origin') || '' : '';
const allowed = ALLOWED_ORIGINS.includes(origin) ? origin : ALLOWED_ORIGINS[0];
return {
'Access-Control-Allow-Origin': allowed,
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, X-Worker-Token, X-Session-Id',
'Vary': 'Origin',
};
}
const CORS = getCORS(null);
// ── Helpers ───────────────────────────────────────────────────
function ok(data, status, req) {
return new Response(JSON.stringify(data), {
status: status || 200,
headers: { 'Content-Type': 'application/json', ...getCORS(req) },
});
}
function err(msg, status, req) {
return new Response(JSON.stringify({ ok: false, error: msg }), {
status: status || 400,
headers: { 'Content-Type': 'application/json', ...getCORS(req) },
});
}
function genId() {
return crypto.randomUUID();
}
async function hashValue(val, salt) {
const data = new TextEncoder().encode((salt || 'cma-2025') + ':' + val.toLowerCase().trim());
const buf = await crypto.subtle.digest('SHA-256', data);
return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2,'0')).join('');
}
// Verify Stripe webhook signature (HMAC-SHA256)
async function verifyStripeSignature(payload, sigHeader, secret) {
if (!sigHeader || !secret) return false;
try {
// Extract timestamp and signatures from header
// Format: t=1234567890,v1=abc123def456,...
const parts = sigHeader.split(',');
const tPart = parts.find(p => p.startsWith('t='));
const v1Part = parts.find(p => p.startsWith('v1='));
if (!tPart || !v1Part) return false;
const timestamp = tPart.slice(2);
const signature = v1Part.slice(3);
// Reject webhooks older than 5 minutes (replay attack protection)
const age = Math.abs(Date.now() / 1000 - parseInt(timestamp));
if (age > 300) {
console.error('Stripe webhook too old:', age, 'seconds');
return false;
}
// Compute expected signature: HMAC-SHA256(timestamp + '.' + payload, secret)
const signedPayload = timestamp + '.' + payload;
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const mac = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(signedPayload));
const expected = Array.from(new Uint8Array(mac)).map(b => b.toString(16).padStart(2,'0')).join('');
// Constant-time comparison
return expected === signature;
} catch(e) {
console.error('Stripe signature verification error:', e.message);
return false;
}
}
// ── Rate limiting ─────────────────────────────────────────────
async function checkRateLimit(env, key, maxAttempts, windowMinutes) {
const windowStart = new Date(Date.now() - windowMinutes * 60 * 1000).toISOString();
// Count recent attempts
const row = await env.DB.prepare(
'SELECT COUNT(*) as count FROM otp_attempts WHERE key = ? AND created_at > ?'
).bind(key, windowStart).first().catch(() => null);
const count = row ? (row.count || 0) : 0;
if (count >= maxAttempts) return false;
// Record this attempt
await env.DB.prepare(
'INSERT INTO otp_attempts (id, key, created_at) VALUES (?, ?, datetime("now"))'
).bind(genId(), key).run().catch(() => {});
return true;
}
async function clearRateLimit(env, key) {
await env.DB.prepare('DELETE FROM otp_attempts WHERE key = ?').bind(key).run().catch(() => {});
}
// ── Auth helpers ──────────────────────────────────────────────
async function getSession(env, sessionId) {
if (!sessionId) return null;
const row = await env.DB.prepare(
'SELECT s.*, u.role, u.first_name, u.last_name, u.email_hash FROM sessions s JOIN users u ON s.user_id = u.id WHERE s.id = ? AND s.expires_at > datetime("now")'
).bind(sessionId).first();
return row || null;
}
async function requireAuth(env, request) {
const sessionId = request.headers.get('X-Session-Id');
const session = await getSession(env, sessionId);
if (!session) return null;
// Security: reject suspended/removed users immediately
if (session.status === 'Suspended' || session.status === 'Removed') return null;
return session;
}
// Security: verify a resource belongs to the session's org before returning/modifying it
function ownedBySession(record, session, orgField) {
if (!record || !session) return false;
const field = orgField || 'org_id';
if (record[field] !== session.org_id) {
console.error('SECURITY org_id mismatch:', record[field], '!==', session.org_id);
return false;
}
return true;
}
// ── Email via Brevo ───────────────────────────────────────────
async function sendEmail(env, { to, subject, html }) {
if (!env.BREVO_API_KEY) return { ok: false, error: 'BREVO_API_KEY not set' };
const resp = await fetch('https://api.brevo.com/v3/smtp/email', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'api-key': env.BREVO_API_KEY },
body: JSON.stringify({
sender: { name: 'CostModel.co.uk', email: 'noreply@costmodel.co.uk' },
to: [{ email: to }],
subject,
htmlContent: html,
}),
});
return resp.ok ? { ok: true } : { ok: false, error: 'HTTP ' + resp.status };
}
function otpEmailHtml(code) {
return '<div style="font-family:sans-serif;max-width:520px;margin:0 auto;padding:20px">' +
'<div style="background:linear-gradient(135deg,#071a36,#1254a0);padding:20px;border-radius:12px 12px 0 0;text-align:center">' +
'<h1 style="color:#fff;margin:0;font-size:18px;font-weight:700">CostModel.co.uk</h1></div>' +
'<div style="background:#fff;padding:28px;border:1px solid #e5e7eb;border-top:none;border-radius:0 0 12px 12px">' +
'<h2 style="color:#1a1a18;margin:0 0 10px">Your sign-in code</h2>' +
'<p style="color:#6b7280;margin:0 0 20px;font-size:14px">Use this code to sign in. It expires in 10 minutes.</p>' +
'<div style="text-align:center;background:#EEF2FF;border:2px solid #4F46E5;border-radius:12px;padding:20px;margin:0 0 20px">' +
'<div style="font-size:40px;font-weight:800;font-family:monospace;letter-spacing:10px;color:#4F46E5">' + code + '</div></div>' +
'<p style="color:#9ca3af;font-size:12px;margin:0">If you did not request this code, ignore this email.</p></div>' +
'<p style="text-align:center;color:#9ca3af;font-size:11px;margin-top:16px">2025 CostModel.co.uk · support@costmodel.co.uk</p></div>';
}
// ══════════════════════════════════════════════════════════════
// MAIN FETCH HANDLER
// ══════════════════════════════════════════════════════════════
export default {
async fetch(request, env) {
if (request.method === 'OPTIONS') return new Response(null, { headers: CORS });
const url = new URL(request.url);
const path = url.pathname;
// ── Auth strategy ────────────────────────────────────────────
// Public routes — no auth required (OTP flow + health check)
// Session routes — protected by X-Session-Id (D1 session lookup)
// includes /api/claude, /api/opportunities etc.
// Token routes — protected by X-Worker-Token (SA + admin ops)
//
// Regular users never have the Worker token — they use session auth.
// The Worker token is only for Super Admin Global Settings operations.
const publicRoutes = ['/api/health', '/api/auth/request-otp', '/api/auth/verify-otp',
'/api/orgs', '/api/platform-config', '/api/stripe/webhook'];
const sessionRoutes = ['/api/claude', '/api/opportunities', '/api/cost-models', '/api/rate-cards',
'/api/templates', '/api/comments', '/api/users'];
const isPublic = publicRoutes.some(r => path === r || path.startsWith(r + '/'));
const isSession = sessionRoutes.some(r => path === r || path.startsWith(r + '/'));
const isAdmin = path.startsWith('/api/admin/');
if (!isPublic && !isSession) {
// Token-protected route (admin ops + SA ops)
const token = request.headers.get('X-Worker-Token');
if (env.WORKER_TOKEN && token !== env.WORKER_TOKEN) {
return err('Unauthorized', 401);
}
}
// Session routes: validate session ID if provided
// /api/claude: session is used for usage tracking but NOT required to block the request
// All other session routes still require a valid session
if (isSession && path !== '/api/claude') {
const sid = request.headers.get('X-Session-Id');
if (!sid) return err('Session required — please sign in', 401);
}
// ── GET /api/health ──────────────────────────────────────
if (path === '/api/health') {
const dbOk = !!env.DB;
// Ensure platform_config table exists
if (env.DB) {
await env.DB.prepare(`CREATE TABLE IF NOT EXISTS platform_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT DEFAULT (datetime('now'))
)`).run().catch(() => {});
await env.DB.prepare(`CREATE TABLE IF NOT EXISTS otp_attempts (
id TEXT PRIMARY KEY,
key TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
)`).run().catch(() => {});
await env.DB.prepare(
'CREATE INDEX IF NOT EXISTS idx_otp_attempts_key ON otp_attempts(key, created_at)'
).run().catch(() => {});
}
return ok({ ok: true, service: 'CostModel.co.uk Worker', db: dbOk, ts: Date.now() });
}
// POST /api/admin/cleanup — manual cleanup trigger (SA token required)
if (path === '/api/admin/cleanup' && request.method === 'POST') {
const token = request.headers.get('X-Worker-Token');
if (!env.WORKER_TOKEN || token !== env.WORKER_TOKEN) return err('Unauthorized', 401);
const results = await runCleanup(env);
return ok({ ok: true, results });
}
// ── POST /api/stripe/webhook — Stripe payment events ────────
// This route MUST be before auth checks — Stripe sends its own signature
// Add to Stripe: Dashboard → Webhooks → Add endpoint
// URL: https://costmodel-api.stevebennett.workers.dev/api/stripe/webhook
// Events: checkout.session.completed, customer.subscription.deleted,
// customer.subscription.updated, invoice.payment_failed
if (path === '/api/stripe/webhook' && request.method === 'POST') {
const rawBody = await request.text();
const sigHeader = request.headers.get('stripe-signature');
// Verify signature
const valid = await verifyStripeSignature(rawBody, sigHeader, env.STRIPE_WEBHOOK_SECRET);
if (!valid) {
console.error('Invalid Stripe webhook signature');
return new Response('Unauthorized', { status: 401 });
}
let event;
try { event = JSON.parse(rawBody); } catch(e) { return new Response('Bad JSON', { status: 400 }); }
console.log('Stripe webhook received:', event.type);
switch (event.type) {
// ── Checkout completed — upgrade org to paid ─────────────
case 'checkout.session.completed': {
const session = event.data.object;
const orgId = session.client_reference_id;
const mode = session.mode; // 'subscription' or 'payment'
if (!orgId) { console.error('No client_reference_id in checkout session'); break; }
if (mode === 'subscription') {
// Professional plan subscription — mark org as paid
await env.DB.prepare(
'UPDATE orgs SET plan = ?, trial_ends_at = NULL, updated_at = datetime("now") WHERE id = ?'
).bind('paid', orgId).run();
console.log('Org upgraded to paid:', orgId);
// Notify org admin by email
const orgAdmin = await env.DB.prepare(
'SELECT u.email_hash FROM users u WHERE u.org_id = ? AND u.role = ? LIMIT 1'
).bind(orgId, 'admin').first();
// Note: we store email hashes not emails, so we can't email directly here.
// The user will see the plan change next time they sign in.
} else if (mode === 'payment') {
// One-time top-up — add credits based on metadata
const credits = parseInt(session.metadata?.credits || '0');
if (credits > 0) {
await env.DB.prepare(
'UPDATE orgs SET topup_credits = topup_credits + ?, updated_at = datetime("now") WHERE id = ?'
).bind(credits, orgId).run();
console.log('Top-up credits added:', credits, 'to org:', orgId);
}
}
break;
}
// ── Subscription cancelled — downgrade to free ───────────
case 'customer.subscription.deleted': {
const sub = event.data.object;
const orgId = sub.metadata?.org_id || sub.client_reference_id;
if (orgId) {
await env.DB.prepare(
'UPDATE orgs SET plan = ?, updated_at = datetime("now") WHERE id = ?'
).bind('free-activated', orgId).run();
console.log('Org downgraded after cancellation:', orgId);
}
break;
}
// ── Payment failed — flag the org ────────────────────────
case 'invoice.payment_failed': {
const inv = event.data.object;
const orgId = inv.metadata?.org_id;
if (orgId) {
// Don't immediately downgrade — Stripe retries. Just log.
console.warn('Payment failed for org:', orgId, 'attempt:', inv.attempt_count);
}
break;
}
default:
console.log('Unhandled Stripe event type:', event.type);
}
// Always return 200 to Stripe — any other status causes retries
return new Response(JSON.stringify({ received: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
// ── GET /api/platform-config — public, read all platform settings ─
if (path === '/api/platform-config' && request.method === 'GET') {
if (!env.DB) return ok({ ok: true, config: {} });
const rows = await env.DB.prepare('SELECT key, value FROM platform_config').all();
const config = {};
(rows.results || []).forEach(r => { try { config[r.key] = JSON.parse(r.value); } catch(_) { config[r.key] = r.value; } });
return ok({ ok: true, config });
}
// ── PUT /api/platform-config — SA token required ──────────────────
if (path === '/api/platform-config' && request.method === 'PUT') {
const token = request.headers.get('X-Worker-Token');
if (!env.WORKER_TOKEN || token !== env.WORKER_TOKEN) return err('Unauthorized', 401);
let body;
try { body = await request.json(); } catch(e) { return err('Invalid JSON', 400); }
// Upsert each key
const stmt = env.DB.prepare('INSERT OR REPLACE INTO platform_config (key, value, updated_at) VALUES (?, ?, datetime("now"))');
const batch = Object.entries(body).map(([k, v]) => stmt.bind(k, JSON.stringify(v)));
if (batch.length > 0) await env.DB.batch(batch);
return ok({ ok: true, saved: Object.keys(body).length });
}
// ── POST /api/claude — Anthropic proxy ───────────────────
if (path === '/api/claude' && request.method === 'POST') {
const apiKey = env.ANTHROPIC_KEY;
if (!apiKey) return err('ANTHROPIC_KEY not configured in Worker Variables', 500);
let body;
try { body = await request.json(); } catch(e) { return err('Invalid JSON', 400); }
// Get session for usage tracking (already validated above for session routes)
const session = await requireAuth(env, request);
if (session && env.DB) {
// Check monthly generation limit for this org
const org = await env.DB.prepare(
'SELECT plan, monthly_gen, topup_credits, trial_ends_at FROM orgs WHERE id = ?'
).bind(session.org_id).first().catch(() => null);
if (org) {
// Reset monthly counter if we're in a new month
const currentMonth = new Date().toISOString().slice(0, 7);
if (org.gen_reset_month !== currentMonth) {
await env.DB.prepare(
'UPDATE orgs SET monthly_gen = 0, gen_reset_month = ? WHERE id = ?'
).bind(currentMonth, session.org_id).run().catch(() => {});
org.monthly_gen = 0;
org.gen_reset_month = currentMonth;
}
// Map D1 plan values to limit tiers
// 'paid' → unlimited
// 'trial' → 25/month
// 'free-activated' → 3/month
// anything else → 3/month (free)
const plan = org.plan || 'free';
if (plan === 'paid') {
// Paid plan — no limit enforced server-side
} else {
const limits = { 'trial': 25, 'free-activated': 3, 'free': 3 };
const monthlyLimit = limits[plan] !== undefined ? limits[plan] : 3;
const totalAllowance = monthlyLimit + (org.topup_credits || 0);
if ((org.monthly_gen || 0) >= totalAllowance) {
return err('Monthly AI generation limit reached (' + org.monthly_gen + '/' + monthlyLimit + '). Upgrade or purchase top-up credits.', 429);
}
}
}
// Log usage and increment counter
await env.DB.prepare(
'INSERT INTO ai_usage (id, org_id, user_id, type) VALUES (?, ?, ?, ?)'
).bind(genId(), session.org_id, session.user_id, body.type || 'generation').run().catch(() => {});
await env.DB.prepare(
'UPDATE orgs SET monthly_gen = monthly_gen + 1 WHERE id = ?'
).bind(session.org_id).run().catch(() => {});
}
const resp = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' },
body: JSON.stringify({
model: body.model || 'claude-sonnet-4-6',
max_tokens: Math.min(body.max_tokens || 6000, 8000),
system: body.system,
messages: body.messages,
}),
});
const data = await resp.json();
// If Anthropic returns an error, surface it clearly
if (!resp.ok) {
const errObj = data?.error || {};
const errMsg = errObj.message || JSON.stringify(data).slice(0, 200);
const errType = errObj.type || 'api_error';
console.error('Anthropic API error', resp.status, errType, errMsg);
return ok({
type: 'error',
error: { type: errType, message: errMsg, status: resp.status }
}, resp.status);
}
return ok(data, resp.status);
}
// ── POST /api/send-otp — legacy direct OTP send ──────────
if (path === '/api/send-otp' && request.method === 'POST') {
let payload;
try { payload = await request.json(); } catch(e) { return err('Invalid JSON', 400); }
const { email, code, fromEmail, fromName } = payload;
if (!email || !code) return err('email and code required', 400);
const result = await sendEmail(env, { to: email, subject: 'Your CostModel.co.uk sign-in code', html: otpEmailHtml(code) });
return ok(result, result.ok ? 200 : 500);
}
// ══════════════════════════════════════════════════════════
// AUTH ROUTES
// ══════════════════════════════════════════════════════════
// POST /api/auth/request-otp
if (path === '/api/auth/request-otp' && request.method === 'POST') {
let body;
try { body = await request.json(); } catch(e) { return err('Invalid JSON', 400); }
const { emailHash } = body;
if (!emailHash) return err('emailHash required', 400);
// Rate limit: max 5 OTP requests per email per hour
const allowed = await checkRateLimit(env, 'otp-req:' + emailHash, 5, 60);
if (!allowed) {
return ok({ ok: false, reason: 'rate_limited',
message: 'Too many sign-in attempts. Please wait an hour before trying again.' });
}
// Check user exists
const user = await env.DB.prepare('SELECT id, org_id, role FROM users WHERE email_hash = ?').bind(emailHash).first();
if (!user) return ok({ ok: false, reason: 'not_found' });
// Generate 6-digit code
const code = String(Math.floor(100000 + Math.random() * 900000));
const codeHash = await hashValue(code, 'otp');
const expires = new Date(Date.now() + 10 * 60 * 1000).toISOString();
// Store OTP in DB
await env.DB.prepare(
'INSERT INTO otp_codes (id, email_hash, code_hash, expires_at) VALUES (?, ?, ?, ?)'
).bind(genId(), emailHash, codeHash, expires).run();
// Get user's email from org for sending
// We don't store plain email — send to the email hash lookup via a stored display email
// For now, the client passes the display email separately
if (body.email) {
await sendEmail(env, { to: body.email, subject: 'Your CostModel.co.uk sign-in code', html: otpEmailHtml(code) });
}
// Return code only in dev mode (no Brevo key)
const showCode = !env.BREVO_API_KEY;
return ok({ ok: true, ...(showCode ? { code } : {}) });
}
// POST /api/auth/verify-otp
if (path === '/api/auth/verify-otp' && request.method === 'POST') {
let body;
try { body = await request.json(); } catch(e) { return err('Invalid JSON', 400); }
const { emailHash, code } = body;
if (!emailHash || !code) return err('emailHash and code required', 400);
// Rate limit: max 10 verify attempts per email per hour (prevents brute force of 6-digit codes)
const verifyAllowed = await checkRateLimit(env, 'otp-verify:' + emailHash, 10, 60);
if (!verifyAllowed) {
return ok({ ok: false, reason: 'rate_limited',
message: 'Too many incorrect attempts. Please wait an hour or request a new code.' });
}
const codeHash = await hashValue(code, 'otp');
// Find valid unused OTP
const otp = await env.DB.prepare(
'SELECT id FROM otp_codes WHERE email_hash = ? AND code_hash = ? AND used = 0 AND expires_at > datetime("now") ORDER BY created_at DESC LIMIT 1'
).bind(emailHash, codeHash).first();
if (!otp) return ok({ ok: false, reason: 'invalid_code' });
// Mark used and clear rate limit counter on success
await env.DB.prepare('UPDATE otp_codes SET used = 1 WHERE id = ?').bind(otp.id).run();
await clearRateLimit(env, 'otp-verify:' + emailHash);
await clearRateLimit(env, 'otp-req:' + emailHash);
// Get user + org
const user = await env.DB.prepare('SELECT u.*, o.plan, o.trial_ends_at, o.monthly_gen, o.topup_credits, o.min_margin, o.currency, o.name as org_name FROM users u JOIN orgs o ON u.org_id = o.id WHERE u.email_hash = ?').bind(emailHash).first();
if (!user) return ok({ ok: false, reason: 'user_not_found' });
// Create session (30 days)
const sessionId = genId();
const expires = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString();
await env.DB.prepare(
'INSERT INTO sessions (id, user_id, org_id, expires_at) VALUES (?, ?, ?, ?)'
).bind(sessionId, user.id, user.org_id, expires).run();
return ok({ ok: true, sessionId, user });
}
// ══════════════════════════════════════════════════════════
// ORG ROUTES
// ══════════════════════════════════════════════════════════
// POST /api/orgs — register new org
if (path === '/api/orgs' && request.method === 'POST') {
let body;
try { body = await request.json(); } catch(e) { return err('Invalid JSON', 400); }
const { org, user, plan } = body;
if (!org || !user) return err('org and user required', 400);
// Check email not already registered
const existing = await env.DB.prepare('SELECT id FROM users WHERE email_hash = ?').bind(user.emailHash).first();
if (existing) return ok({ ok: false, reason: 'email_exists' });
const orgId = genId();
const userId = genId();
const chosenPlan = plan || 'trial';
const trialEnds = chosenPlan === 'trial' ? new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString() : null;
await env.DB.prepare(
'INSERT INTO orgs (id, name, slug, domain, industry, country, currency, plan, trial_ends_at, min_margin, gen_reset_month, chosen_plan_label) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
).bind(orgId, org.name, org.slug, org.domain, org.industry, org.country, org.currency || 'GBP', chosenPlan === 'free' ? 'free-activated' : 'trial', trialEnds, org.minMargin || 25, new Date().toISOString().slice(0,7), chosenPlan).run();
await env.DB.prepare(
'INSERT INTO users (id, org_id, email_hash, first_name, last_name, role, email_verified) VALUES (?, ?, ?, ?, ?, ?, ?)'
).bind(userId, orgId, user.emailHash, user.firstName, user.lastName, 'admin', 1).run();
// Create session
const sessionId = genId();
const expires = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString();
await env.DB.prepare('INSERT INTO sessions (id, user_id, org_id, expires_at) VALUES (?, ?, ?, ?)').bind(sessionId, userId, orgId, expires).run();
const newUser = await env.DB.prepare('SELECT u.*, o.plan, o.trial_ends_at, o.monthly_gen, o.topup_credits, o.min_margin, o.currency, o.name as org_name FROM users u JOIN orgs o ON u.org_id = o.id WHERE u.id = ?').bind(userId).first();
return ok({ ok: true, sessionId, user: newUser });
}
// GET /api/orgs/:id
if (path.match(/^\/api\/orgs\/[^/]+$/) && request.method === 'GET') {
const session = await requireAuth(env, request);
if (!session) return err('Unauthorized', 401);
const org = await env.DB.prepare('SELECT * FROM orgs WHERE id = ?').bind(session.org_id).first();
return ok({ ok: true, org });
}
// PUT /api/orgs/:id — update org settings
if (path.match(/^\/api\/orgs\/[^/]+$/) && request.method === 'PUT') {
const session = await requireAuth(env, request);
if (!session || session.role !== 'admin') return err('Admin required', 403);
let body;
try { body = await request.json(); } catch(e) { return err('Invalid JSON', 400); }
await env.DB.prepare(
'UPDATE orgs SET min_margin = ?, approval_trigger = ?, currency = ?, updated_at = datetime("now") WHERE id = ?'
).bind(body.minMargin || 25, body.approvalTrigger || 'margin', body.currency || 'GBP', session.org_id).run();
return ok({ ok: true });
}
// ══════════════════════════════════════════════════════════
// USER ROUTES
// ══════════════════════════════════════════════════════════
// GET /api/users
if (path === '/api/users' && request.method === 'GET') {
const session = await requireAuth(env, request);
if (!session) return err('Unauthorized', 401);
const users = await env.DB.prepare('SELECT id, first_name, last_name, role, status, created_at FROM users WHERE org_id = ? ORDER BY created_at ASC').bind(session.org_id).all();
return ok({ ok: true, users: users.results });
}
// POST /api/users — add user to org
if (path === '/api/users' && request.method === 'POST') {
const session = await requireAuth(env, request);
if (!session || session.role !== 'admin') return err('Admin required', 403);
let body;
try { body = await request.json(); } catch(e) { return err('Invalid JSON', 400); }
const existing = await env.DB.prepare('SELECT id FROM users WHERE email_hash = ?').bind(body.emailHash).first();
if (existing) return ok({ ok: false, reason: 'email_exists' });
const userId = genId();
await env.DB.prepare('INSERT INTO users (id, org_id, email_hash, first_name, last_name, role) VALUES (?, ?, ?, ?, ?, ?)').bind(userId, session.org_id, body.emailHash, body.firstName, body.lastName, body.role || 'user').run();
return ok({ ok: true, userId });
}
// PUT /api/users/:id
if (path.match(/^\/api\/users\/[^/]+$/) && request.method === 'PUT') {
const session = await requireAuth(env, request);
if (!session || session.role !== 'admin') return err('Admin required', 403);
const userId = path.split('/')[3];
let body;
try { body = await request.json(); } catch(e) { return err('Invalid JSON', 400); }
await env.DB.prepare('UPDATE users SET role = ?, status = ?, updated_at = datetime("now") WHERE id = ? AND org_id = ?').bind(body.role, body.status || 'Active', userId, session.org_id).run();
return ok({ ok: true });
}
// DELETE /api/users/:id
if (path.match(/^\/api\/users\/[^/]+$/) && request.method === 'DELETE') {
const session = await requireAuth(env, request);
if (!session || session.role !== 'admin') return err('Admin required', 403);
const userId = path.split('/')[3];
if (userId === session.user_id) return err('Cannot remove yourself', 400);
// Security: AND org_id ensures you can only delete users in your own org
await env.DB.prepare('DELETE FROM users WHERE id = ? AND org_id = ?').bind(userId, session.org_id).run();
return ok({ ok: true });
}
// ══════════════════════════════════════════════════════════
// OPPORTUNITY ROUTES
// ══════════════════════════════════════════════════════════
// GET /api/opportunities
if (path === '/api/opportunities' && request.method === 'GET') {
const session = await requireAuth(env, request);
if (!session) return err('Unauthorized', 401);
const opps = await env.DB.prepare('SELECT * FROM opportunities WHERE org_id = ? ORDER BY created_at DESC').bind(session.org_id).all();
return ok({ ok: true, opportunities: opps.results });
}
// POST /api/opportunities
if (path === '/api/opportunities' && request.method === 'POST') {
const session = await requireAuth(env, request);
if (!session) return err('Unauthorized', 401);
let body;
try { body = await request.json(); } catch(e) { return err('Invalid JSON', 400); }
const id = body.id || genId();
await env.DB.prepare(
'INSERT OR REPLACE INTO opportunities (id, org_id, created_by, name, client, stage, value, margin, status, approval_status, brief, engagement_type, pmo_scale, rate_card_id, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime("now"))'
).bind(id, session.org_id, session.user_id, body.name, body.client, body.stage || 'Discovery', body.value || 0, body.margin || 0, body.status || 'Draft', body.approvalStatus || 'draft', body.brief, body.engagementType || 'T&M', body.pmoScale || 'Medium', body.rateCardId || null).run();
return ok({ ok: true, id });
}
// PUT /api/opportunities/:id/approve
if (path.match(/^\/api\/opportunities\/[^/]+\/approve$/) && request.method === 'PUT') {
const session = await requireAuth(env, request);
if (!session || session.role !== 'admin') return err('Admin required', 403);
const oppId = path.split('/')[3];
let body;
try { body = await request.json(); } catch(e) { return err('Invalid JSON', 400); }
await env.DB.prepare('UPDATE opportunities SET approval_status = ?, approval_notes = ?, approved_by = ?, approved_at = datetime("now"), updated_at = datetime("now") WHERE id = ? AND org_id = ?').bind(body.decision, body.notes, session.user_id, oppId, session.org_id).run();
return ok({ ok: true });
}
// ══════════════════════════════════════════════════════════
// COST MODEL ROUTES
// ══════════════════════════════════════════════════════════
// GET /api/cost-models/:oppId
if (path.match(/^\/api\/cost-models\/[^/]+$/) && request.method === 'GET') {
const session = await requireAuth(env, request);
if (!session) return err('Unauthorized', 401);
const oppId = path.split('/')[3];
const models = await env.DB.prepare('SELECT * FROM cost_models WHERE opportunity_id = ? AND org_id = ? ORDER BY version DESC').bind(oppId, session.org_id).all();
return ok({ ok: true, models: models.results });
}
// POST /api/cost-models
if (path === '/api/cost-models' && request.method === 'POST') {
const session = await requireAuth(env, request);
if (!session) return err('Unauthorized', 401);
let body;
try { body = await request.json(); } catch(e) { return err('Invalid JSON', 400); }
// Mark previous versions as not current
await env.DB.prepare('UPDATE cost_models SET is_current = 0 WHERE opportunity_id = ? AND org_id = ?').bind(body.opportunityId, session.org_id).run();
// Get next version number
const vRow = await env.DB.prepare('SELECT MAX(version) as v FROM cost_models WHERE opportunity_id = ?').bind(body.opportunityId).first();
const version = (vRow?.v || 0) + 1;
const id = genId();
await env.DB.prepare(
'INSERT INTO cost_models (id, opportunity_id, org_id, version, workstreams, total_days, total_cost, total_sell, margin, contingency, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
).bind(id, body.opportunityId, session.org_id, version, JSON.stringify(body.workstreams || []), body.totalDays || 0, body.totalCost || 0, body.totalSell || 0, body.margin || 0, body.contingency || 15, session.user_id).run();
// Update opportunity value/margin
await env.DB.prepare('UPDATE opportunities SET value = ?, margin = ?, updated_at = datetime("now") WHERE id = ? AND org_id = ?').bind(body.totalSell || 0, body.margin || 0, body.opportunityId, session.org_id).run();
return ok({ ok: true, id, version });
}
// ══════════════════════════════════════════════════════════
// RATE CARD ROUTES
// ══════════════════════════════════════════════════════════
// GET /api/rate-cards
if (path === '/api/rate-cards' && request.method === 'GET') {
const session = await requireAuth(env, request);
if (!session) return err('Unauthorized', 401);
const cards = await env.DB.prepare('SELECT * FROM rate_cards WHERE org_id = ? ORDER BY is_default DESC, name ASC').bind(session.org_id).all();
return ok({ ok: true, rateCards: cards.results });
}
// POST /api/rate-cards
if (path === '/api/rate-cards' && request.method === 'POST') {
const session = await requireAuth(env, request);
if (!session || session.role !== 'admin') return err('Admin required', 403);
let body;
try { body = await request.json(); } catch(e) { return err('Invalid JSON', 400); }
const id = body.id || genId();
await env.DB.prepare('INSERT OR REPLACE INTO rate_cards (id, org_id, name, is_default, rows, updated_at) VALUES (?, ?, ?, ?, ?, datetime("now"))').bind(id, session.org_id, body.name, body.isDefault ? 1 : 0, JSON.stringify(body.rows || [])).run();
return ok({ ok: true, id });
}
// DELETE /api/rate-cards/:id
if (path.match(/^\/api\/rate-cards\/[^/]+$/) && request.method === 'DELETE') {
const session = await requireAuth(env, request);
if (!session || session.role !== 'admin') return err('Admin required', 403);
const cardId = path.split('/')[3];
await env.DB.prepare('DELETE FROM rate_cards WHERE id = ? AND org_id = ?').bind(cardId, session.org_id).run();
return ok({ ok: true });
}
// ══════════════════════════════════════════════════════════
// TEMPLATE ROUTES
// ══════════════════════════════════════════════════════════
// GET /api/templates
if (path === '/api/templates' && request.method === 'GET') {
const session = await requireAuth(env, request);
if (!session) return err('Unauthorized', 401);
const tmpls = await env.DB.prepare('SELECT * FROM templates WHERE org_id = ? ORDER BY name ASC').bind(session.org_id).all();
return ok({ ok: true, templates: tmpls.results });
}
// POST /api/templates
if (path === '/api/templates' && request.method === 'POST') {
const session = await requireAuth(env, request);
if (!session) return err('Unauthorized', 401);
let body;
try { body = await request.json(); } catch(e) { return err('Invalid JSON', 400); }
const id = body.id || genId();
await env.DB.prepare('INSERT OR REPLACE INTO templates (id, org_id, name, type, content, created_by, updated_at) VALUES (?, ?, ?, ?, ?, ?, datetime("now"))').bind(id, session.org_id, body.name, body.type || 'engagement', JSON.stringify(body.content || {}), session.user_id).run();
return ok({ ok: true, id });
}
// DELETE /api/templates/:id
if (path.match(/^\/api\/templates\/[^/]+$/) && request.method === 'DELETE') {
const session = await requireAuth(env, request);
if (!session) return err('Unauthorized', 401);
const tmplId = path.split('/')[3];
await env.DB.prepare('DELETE FROM templates WHERE id = ? AND org_id = ?').bind(tmplId, session.org_id).run();
return ok({ ok: true });
}
// ══════════════════════════════════════════════════════════
// COMMENTS ROUTES
// ══════════════════════════════════════════════════════════
// GET /api/comments/:oppId
if (path.match(/^\/api\/comments\/[^/]+$/) && request.method === 'GET') {
const session = await requireAuth(env, request);
if (!session) return err('Unauthorized', 401);
const oppId = path.split('/')[3];
const comments = await env.DB.prepare('SELECT * FROM comments WHERE opportunity_id = ? AND org_id = ? ORDER BY created_at ASC').bind(oppId, session.org_id).all();
return ok({ ok: true, comments: comments.results });
}
// POST /api/comments
if (path === '/api/comments' && request.method === 'POST') {
const session = await requireAuth(env, request);
if (!session) return err('Unauthorized', 401);
let body;
try { body = await request.json(); } catch(e) { return err('Invalid JSON', 400); }
const id = genId();
await env.DB.prepare('INSERT INTO comments (id, opportunity_id, org_id, user_id, author_name, body) VALUES (?, ?, ?, ?, ?, ?)').bind(id, body.opportunityId, session.org_id, session.user_id, session.first_name + ' ' + session.last_name, body.body).run();
return ok({ ok: true, id });
}
// ══════════════════════════════════════════════════════════
// SUPER ADMIN ROUTES
// ══════════════════════════════════════════════════════════
// GET /api/admin/orgs — all orgs for SA dashboard
if (path === '/api/admin/orgs' && request.method === 'GET') {
const token = request.headers.get('X-Worker-Token');
if (!env.WORKER_TOKEN || token !== env.WORKER_TOKEN) return err('Unauthorized', 401);
const orgs = await env.DB.prepare('SELECT o.*, (SELECT COUNT(*) FROM users u WHERE u.org_id = o.id) as user_count, (SELECT COUNT(*) FROM opportunities op WHERE op.org_id = o.id) as opp_count FROM orgs o ORDER BY o.created_at DESC').all();
return ok({ ok: true, orgs: orgs.results });
}
// PUT /api/admin/orgs/:id/reset-usage — manually reset monthly counter (SA)
if (path.match(/^\/api\/admin\/orgs\/[^/]+\/reset-usage$/) && request.method === 'PUT') {
const token = request.headers.get('X-Worker-Token');
if (!env.WORKER_TOKEN || token !== env.WORKER_TOKEN) return err('Unauthorized', 401);
const orgId = path.split('/')[4];
const currentMonth = new Date().toISOString().slice(0, 7);
await env.DB.prepare(
'UPDATE orgs SET monthly_gen = 0, gen_reset_month = ? WHERE id = ?'
).bind(currentMonth, orgId).run();
return ok({ ok: true });
}
// PUT /api/admin/orgs/:id/plan — mark org as paid
if (path.match(/^\/api\/admin\/orgs\/[^/]+\/plan$/) && request.method === 'PUT') {
const token = request.headers.get('X-Worker-Token');
if (!env.WORKER_TOKEN || token !== env.WORKER_TOKEN) return err('Unauthorized', 401);
const orgId = path.split('/')[4];
let body;
try { body = await request.json(); } catch(e) { return err('Invalid JSON', 400); }
await env.DB.prepare('UPDATE orgs SET plan = ?, trial_ends_at = NULL, updated_at = datetime("now") WHERE id = ?').bind(body.plan || 'paid', orgId).run();
return ok({ ok: true });
}
return new Response('Not found', { status: 404 });
},
// ── Scheduled cleanup — runs on Cron Trigger ─────────────────
// Set up in Cloudflare: Worker → Settings → Triggers → Cron Triggers
// Recommended schedule: 0 3 * * * (3am UTC every night)
async scheduled(event, env, ctx) {
if (!env.DB) return;
const results = {};
try {
// ── 1. OTP codes ────────────────────────────────────────
// Delete all OTP codes that are either:
// a) Already used (used = 1)
// b) Expired more than 1 hour ago (even if not used)
const otpUsed = await env.DB.prepare(
"DELETE FROM otp_codes WHERE used = 1 AND created_at < datetime('now', '-1 hour')"
).run();
const otpExpired = await env.DB.prepare(
"DELETE FROM otp_codes WHERE expires_at < datetime('now', '-1 hour')"
).run();
results.otp_used_deleted = otpUsed.meta?.changes || 0;
results.otp_expired_deleted = otpExpired.meta?.changes || 0;
// ── 2. Sessions ─────────────────────────────────────────
// Delete sessions that expired more than 24 hours ago
const sessions = await env.DB.prepare(
"DELETE FROM sessions WHERE expires_at < datetime('now', '-1 day')"
).run();
results.sessions_deleted = sessions.meta?.changes || 0;
// ── 3. AI usage log ─────────────────────────────────────
// Keep 90 days of usage history for billing/analytics
// Delete anything older
const usage = await env.DB.prepare(
"DELETE FROM ai_usage WHERE created_at < datetime('now', '-90 days')"
).run();
results.ai_usage_deleted = usage.meta?.changes || 0;
// ── 4. Expired trial orgs cleanup (optional) ─────────────
// Don't delete expired orgs — they may have data worth keeping
// and the owner may want to reactivate. Just log how many exist.
const expiredTrials = await env.DB.prepare(
"SELECT COUNT(*) as count FROM orgs WHERE plan = 'trial' AND trial_ends_at < datetime('now')"
).first();
results.expired_trials = expiredTrials?.count || 0;
// ── 5. Old cost model versions — keep latest 10 per opportunity ─
// D1 supports window functions — ROW_NUMBER() partitioned by opportunity
const oldVersions = await env.DB.prepare(`
DELETE FROM cost_models WHERE id IN (
SELECT id FROM (
SELECT id, ROW_NUMBER() OVER (PARTITION BY opportunity_id ORDER BY version DESC) AS rn
FROM cost_models
) WHERE rn > 10
)
`).run();
results.old_versions_deleted = oldVersions.meta?.changes || 0;
// ── 6. Orphaned comments ────────────────────────────────────
const orphans = await env.DB.prepare(
'DELETE FROM comments WHERE opportunity_id NOT IN (SELECT id FROM opportunities)'
).run();
results.orphaned_comments = orphans.meta?.changes || 0;
// ── 7. Reset monthly generation counters if new month ────
// Catches any orgs that didn't trigger a reset via API call
const currentMonth = new Date().toISOString().slice(0, 7);
const resetCounters = await env.DB.prepare(
"UPDATE orgs SET monthly_gen = 0, gen_reset_month = ? WHERE gen_reset_month != ? OR gen_reset_month IS NULL"
).bind(currentMonth, currentMonth).run();
results.counters_reset = resetCounters.meta?.changes || 0;
console.log('Nightly cleanup complete:', JSON.stringify(results));
} catch(e) {
console.error('Cleanup error:', e.message);
}
},
};
// ── Manual cleanup — called by POST /api/admin/cleanup ────────────
// Re-uses the same logic as the scheduled handler
async function runCleanup(env) {
const results = {};
try {
const otpUsed = await env.DB.prepare("DELETE FROM otp_codes WHERE used = 1 AND created_at < datetime('now', '-1 hour')").run();
const otpExpired = await env.DB.prepare("DELETE FROM otp_codes WHERE expires_at < datetime('now', '-1 hour')").run();
const otpAttempts = await env.DB.prepare("DELETE FROM otp_attempts WHERE created_at < datetime('now', '-2 hours')").run();
results.otp_attempts_deleted = otpAttempts.meta?.changes || 0;
const sessions = await env.DB.prepare("DELETE FROM sessions WHERE expires_at < datetime('now', '-1 day')").run();
const usage = await env.DB.prepare("DELETE FROM ai_usage WHERE created_at < datetime('now', '-90 days')").run();
const oldVers = await env.DB.prepare("DELETE FROM cost_models WHERE id IN (SELECT id FROM (SELECT id, ROW_NUMBER() OVER (PARTITION BY opportunity_id ORDER BY version DESC) AS rn FROM cost_models) WHERE rn > 10)").run();
const orphans = await env.DB.prepare("DELETE FROM comments WHERE opportunity_id NOT IN (SELECT id FROM opportunities)").run();
const currentMonth = new Date().toISOString().slice(0, 7);
const resets = await env.DB.prepare("UPDATE orgs SET monthly_gen = 0, gen_reset_month = ? WHERE gen_reset_month != ? OR gen_reset_month IS NULL").bind(currentMonth, currentMonth).run();
results.otp_deleted = (otpUsed.meta?.changes || 0) + (otpExpired.meta?.changes || 0);
results.sessions_deleted = sessions.meta?.changes || 0;
results.ai_usage_deleted = usage.meta?.changes || 0;
results.old_versions_deleted= oldVers.meta?.changes || 0;
results.orphans_deleted = orphans.meta?.changes || 0;
results.counters_reset = resets.meta?.changes || 0;
console.log('Manual cleanup complete:', JSON.stringify(results));
return results;
} catch(e) {
console.error('Cleanup error:', e.message);
return { error: e.message };
}
}
1. Cloudflare → Workers & Pages → costmodel-api → Edit code → select all → delete → paste script above → Deploy
2. Worker → Settings → Variables → ensure these three encrypted variables are set:
·
ANTHROPIC_KEY — your sk-ant-… Anthropic API key·
WORKER_TOKEN — your secret auth token (must match the token saved in Global Cloudflare Worker above)·
BREVO_API_KEY — your xkeysib-… key from brevo.com → Settings → API Keys3. Copy your Worker URL from the Overview tab → paste into Global Cloudflare Worker above with
/api/claude appended → Save & Test
STRIPE_SECRET_KEY — never here.
BREVO_API_KEY is set as an encrypted variable in your Cloudflare Worker — it never appears here.
Free tier: 300 emails/day · 9,000/month.
| Organisation | Plan | Users | Generations | Trial ends | Created |
|---|
| Code | Resource name | SFIA | Area | Cost (£/day) | Sell (£/day) | Margin % |
|---|
| Name | Role | Status | Added |
|---|