← back to Ventura Claw Leads
routes/claim.js
205 lines
const express = require('express');
const crypto = require('node:crypto');
const db = require('../lib/db');
const auth = require('../lib/auth');
const compliance = require('../lib/compliance');
const { sendEmail } = require('../lib/email');
const router = express.Router();
const PUBLIC_URL = (process.env.PUBLIC_URL || 'https://leads.venturaclaw.com').replace(/\/+$/, '');
// Step 1: visitor on a business profile clicks "Claim this listing" → enters
// the email they want to claim with. We email them a magic link.
router.post('/business/:slug/claim', async (req, res, next) => {
try {
const biz = await db.one(`SELECT id, slug, business_name, claim_status, email AS biz_email FROM businesses WHERE slug = $1`, [req.params.slug]);
if (!biz) return res.status(404).render('public/404', { title: 'Not Found' });
const submittedEmail = auth.normalizeEmail(req.body.email);
if (!auth.isEmailShape(submittedEmail)) {
return res.status(400).render('public/claim-result', {
title: 'Email needed', business: biz, ok: false,
error: 'Please enter a valid email address.'
});
}
const ipHash = crypto.createHash('sha256')
.update((req.headers['x-forwarded-for'] || req.ip || '') + (process.env.SESSION_SECRET || ''))
.digest('hex').slice(0, 16);
const { token } = await auth.issueClaimToken({ businessId: biz.id, email: submittedEmail, ipHash });
const link = `${PUBLIC_URL}/claim/${encodeURIComponent(token)}`;
const profileUrl = `${PUBLIC_URL}/business/${encodeURIComponent(biz.slug)}`;
// Compliance: if MAILING_ADDRESS / suppression tables aren't ready, this
// would assert-fail and we'd never email. For dev we skip the gate.
if (process.env.NODE_ENV === 'production') {
try { await compliance.assertSendCompliance({ campaign: 'claim_invite' }); }
catch (e) {
console.warn('[claim] compliance fail-closed', e.code);
return res.status(503).render('public/claim-result', { title: 'Try again later', business: biz, ok: false, error: 'Email service is not configured. Reach out at info@venturaclaw.com.' });
}
}
const subject = `Claim ${biz.business_name} on Ventura Claw`;
const html = `<div style="font-family:Georgia,serif;max-width:560px;margin:0 auto;padding:32px 24px;color:#0e0e0e">
<p style="font-size:11px;letter-spacing:0.16em;text-transform:uppercase;color:#b8860b;margin:0 0 8px">Claim your listing</p>
<h1 style="font-size:24px;font-weight:400;margin:0 0 12px">Confirm you own ${escapeHtml(biz.business_name)}</h1>
<p style="font-size:15px;line-height:1.55">Click the link below to claim the public listing for <strong>${escapeHtml(biz.business_name)}</strong> on Ventura Claw. The link is valid for 24 hours and works once.</p>
<p style="margin:24px 0"><a href="${link}" style="display:inline-block;background:#0e0e0e;color:#fff;padding:14px 22px;text-decoration:none;font-size:14px;letter-spacing:0.04em;border-radius:2px">Claim ${escapeHtml(biz.business_name)} →</a></p>
<p style="font-size:13px;color:#666;line-height:1.55">Profile: <a href="${profileUrl}" style="color:#666">${profileUrl.replace(/^https?:\/\//, '')}</a></p>
<p style="font-size:12px;color:#888;margin:24px 0 0;line-height:1.55">If you didn't request this, ignore the message — no action is taken until the link is clicked.</p>
${compliance.complianceFooter({ campaign: 'claim_invite', unsubscribeUrl: null })}
</div>`;
const text = `Claim ${biz.business_name} on Ventura Claw.\n\n${link}\n\n(link expires in 24 hours, single use)`;
const result = await sendEmail({ to: submittedEmail, subject, html, text });
if (process.env.NODE_ENV === 'production') {
await compliance.recordAudit({
channel: 'email', campaign: 'claim_invite',
recipient: submittedEmail, businessId: biz.id,
decision: result.ok ? 'sent' : 'failed',
reason: result.ok ? null : (result.error || 'unknown'),
subject, messageId: result.messageId || null,
payload: { token_prefix: token.slice(0, 8) }
});
}
res.render('public/claim-result', {
title: 'Check your email', business: biz, ok: true, email: submittedEmail
});
} catch (err) { next(err); }
});
// Step 2: visitor clicks the magic link from their email → set password →
// session-login.
router.get('/claim/:token', async (req, res, next) => {
try {
const r = await auth.consumeClaimToken(req.params.token);
if (!r.ok) {
return res.status(400).render('public/claim-complete', {
title: 'Link not valid',
business: null, ok: false, reason: r.reason
});
}
res.render('public/claim-complete', {
title: `Set up your account · ${r.claim.business_name}`,
business: { id: r.claim.business_id, slug: r.claim.slug, business_name: r.claim.business_name },
ok: true, token: req.params.token, email: r.claim.email, error: null
});
} catch (err) { next(err); }
});
router.post('/claim/:token', async (req, res, next) => {
try {
const r = await auth.consumeClaimToken(req.params.token);
if (!r.ok) {
return res.status(400).render('public/claim-complete', {
title: 'Link not valid', business: null, ok: false, reason: r.reason
});
}
const password = String(req.body.password || '');
const passwordConfirm = String(req.body.password_confirm || '');
if (password.length < 10) {
return res.status(400).render('public/claim-complete', {
title: 'Password too short',
business: { id: r.claim.business_id, slug: r.claim.slug, business_name: r.claim.business_name },
ok: true, token: req.params.token, email: r.claim.email,
error: 'Pick a password with at least 10 characters.'
});
}
if (password !== passwordConfirm) {
return res.status(400).render('public/claim-complete', {
title: 'Passwords don\'t match',
business: { id: r.claim.business_id, slug: r.claim.slug, business_name: r.claim.business_name },
ok: true, token: req.params.token, email: r.claim.email,
error: 'The two password fields must match.'
});
}
// Mark token consumed first to prevent replay during the rest of the flow.
await auth.markClaimConsumed(req.params.token);
const passwordHash = await auth.hashPassword(password);
const user = await auth.createUser({
email: r.claim.email, businessId: r.claim.business_id,
passwordHash, displayName: r.claim.email.split('@')[0],
emailVerified: true
});
if (user && !user.password_hash) {
// Pre-existing user without a password — set it now.
await auth.setPassword(user.id, password);
}
// Flip business to claimed.
await db.query(
`UPDATE businesses SET claim_status = 'self', claimed_by_email = $2, claimed_at = now() WHERE id = $1`,
[r.claim.business_id, r.claim.email]
);
req.session.businessUserId = user.id;
await auth.recordLogin(user.id);
res.redirect('/admin?welcome=1');
} catch (err) { next(err); }
});
// ─── Team-invitation acceptance (multi-user per business) ──────────────
router.get('/accept-invite/:token', async (req, res, next) => {
try {
const r = await auth.consumeInviteToken(req.params.token);
if (!r.ok) {
return res.status(400).render('public/accept-invite', {
title: 'Invitation not valid', invite: null, ok: false, reason: r.reason
});
}
res.render('public/accept-invite', {
title: `Join ${r.invite.business_name}`,
invite: r.invite, ok: true, token: req.params.token, error: null
});
} catch (err) { next(err); }
});
router.post('/accept-invite/:token', async (req, res, next) => {
try {
const r = await auth.consumeInviteToken(req.params.token);
if (!r.ok) {
return res.status(400).render('public/accept-invite', {
title: 'Invitation not valid', invite: null, ok: false, reason: r.reason
});
}
const password = String(req.body.password || '');
const passwordConfirm = String(req.body.password_confirm || '');
if (password.length < 10) {
return res.status(400).render('public/accept-invite', {
title: 'Password too short', invite: r.invite, ok: true, token: req.params.token,
error: 'Pick a password with at least 10 characters.'
});
}
if (password !== passwordConfirm) {
return res.status(400).render('public/accept-invite', {
title: 'Passwords don\'t match', invite: r.invite, ok: true, token: req.params.token,
error: 'The two password fields must match.'
});
}
await auth.markInviteConsumed(req.params.token);
const passwordHash = await auth.hashPassword(password);
const user = await auth.createUser({
email: r.invite.email, businessId: r.invite.business_id,
passwordHash, displayName: r.invite.email.split('@')[0],
emailVerified: true
});
if (user && !user.password_hash) {
await auth.setPassword(user.id, password);
}
req.session.businessUserId = user.id;
await auth.recordLogin(user.id);
res.redirect('/admin?welcome=1');
} catch (err) { next(err); }
});
function escapeHtml(s) {
return String(s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
}
module.exports = router;