← 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}

module.exports = router;