[object Object]

← back to Animals

Add lost-pet alert opt-in surface (/me prefs page + signup checkbox)

ecd4ce93da7ae62144d800d19589f5dd161ddbb0 · 2026-05-26 11:43:32 -0700 · SteveStudio2

The backend (migration 017 + src/lib/lost_pet_alert.js + dispatch wiring in
community.js) already shipped, but nothing set alert_lost_pets=TRUE, so the
audience was always empty and the email footer's /me link 404'd.

- expose alert_lost_pets/_sms on req.user (currentUser SELECT)
- signup: explicit unchecked opt-in checkbox -> alert_lost_pets
- GET /me preferences page (the surface the alert email + one-click off link to)
- POST /me/alerts/lost-pets toggles opt-in on/off
- SMS kept off — page states we never text without separate opt-in

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit ecd4ce93da7ae62144d800d19589f5dd161ddbb0
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date:   Tue May 26 11:43:32 2026 -0700

    Add lost-pet alert opt-in surface (/me prefs page + signup checkbox)
    
    The backend (migration 017 + src/lib/lost_pet_alert.js + dispatch wiring in
    community.js) already shipped, but nothing set alert_lost_pets=TRUE, so the
    audience was always empty and the email footer's /me link 404'd.
    
    - expose alert_lost_pets/_sms on req.user (currentUser SELECT)
    - signup: explicit unchecked opt-in checkbox -> alert_lost_pets
    - GET /me preferences page (the surface the alert email + one-click off link to)
    - POST /me/alerts/lost-pets toggles opt-in on/off
    - SMS kept off — page states we never text without separate opt-in
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 src/lib/auth.js                | 15 ++++++++++-----
 src/server/community.js        | 19 ++++++++++++++++++-
 src/server/community_render.js | 34 ++++++++++++++++++++++++++++++++++
 3 files changed, 62 insertions(+), 6 deletions(-)

diff --git a/src/lib/auth.js b/src/lib/auth.js
index f278857..6929ef8 100644
--- a/src/lib/auth.js
+++ b/src/lib/auth.js
@@ -37,7 +37,8 @@ export async function currentUser(req) {
   const row = await one(
     `SELECT u.id, u.email, u.handle, u.full_name, u.role, u.home_zip, u.home_city, u.home_state,
             u.home_lat, u.home_lng, u.species_pets, u.visibility_default,
-            u.approved_circles, u.approved_zips, u.email_verified
+            u.approved_circles, u.approved_zips, u.email_verified,
+            u.alert_lost_pets, u.alert_lost_pets_sms
        FROM app_sessions s JOIN app_users u ON u.id = s.app_user_id
       WHERE s.token = $1 AND s.expires_at > NOW() AND u.suspended_at IS NULL`,
     [tok]
@@ -61,7 +62,7 @@ export async function requireUser(req, res, next) {
 
 export async function signup(req, res) {
   try {
-    const { email, password, full_name, handle, home_zip, home_city, home_state, species_pets } = req.body || {};
+    const { email, password, full_name, handle, home_zip, home_city, home_state, species_pets, alert_lost_pets } = req.body || {};
     if (!email || !password || !home_zip) return res.status(400).json({ error: 'email, password, home_zip required' });
     if (!/^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/i.test(email)) return res.status(400).json({ error: 'invalid email' });
     if (password.length < 8) return res.status(400).json({ error: 'password ≥ 8 chars' });
@@ -77,14 +78,18 @@ export async function signup(req, res) {
       : Array.isArray(species_pets) ? species_pets
       : [species_pets];
 
+    // Opt-in checkbox sends 'on' / 'true' / '1' / true when ticked, nothing
+    // when unchecked. Explicit opt-in only — default FALSE per the column.
+    const optLostPets = [true, 'on', 'true', '1', 1].includes(alert_lost_pets);
+
     let user;
     try {
       user = await one(
-        `INSERT INTO app_users (email, password_hash, full_name, handle, home_zip, home_city, home_state, species_pets, role, email_verified)
-         VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'business_owner',FALSE)
+        `INSERT INTO app_users (email, password_hash, full_name, handle, home_zip, home_city, home_state, species_pets, alert_lost_pets, role, email_verified)
+         VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,'business_owner',FALSE)
          RETURNING id, email, handle, home_zip`,
         [email.toLowerCase(), hash, full_name || null, finalHandle, home_zip, home_city || null,
-         (home_state||'').toUpperCase().slice(0,2) || null, petsArr]
+         (home_state||'').toUpperCase().slice(0,2) || null, petsArr, optLostPets]
       );
     } catch (err) {
       if (err.code === '23505') {
diff --git a/src/server/community.js b/src/server/community.js
index 62cad77..d6495ae 100644
--- a/src/server/community.js
+++ b/src/server/community.js
@@ -8,7 +8,7 @@ import { verifyToken, resendVerification, requireVerifiedEmail } from '../lib/em
 import { unsubscribe as newsletterUnsubscribe } from '../lib/weekly_newsletter.js';
 import { dispatchLostPetAlert, alertsOff as lostPetAlertsOff } from '../lib/lost_pet_alert.js';
 import { geocodeZip } from '../lib/geo.js';
-import { renderSignup, renderLogin, renderMarketplace, renderListing, renderListingNew, renderCircle, renderMyCircles, renderMarketingFirms, renderUpgradePreview } from './community_render.js';
+import { renderSignup, renderLogin, renderMe, renderMarketplace, renderListing, renderListingNew, renderCircle, renderMyCircles, renderMarketingFirms, renderUpgradePreview } from './community_render.js';
 import { createUpgradeCheckoutSession, planSpec, isStripeConfigured } from '../lib/stripe.js';
 
 export const community = express.Router();
@@ -80,6 +80,23 @@ community.get ('/auth/unsubscribe', newsletterUnsubscribe);
 // only flips alert_lost_pets (keeps newsletter on).
 community.get ('/auth/alerts/lost-pets/off', lostPetAlertsOff);
 
+// ── preferences (/me) ───────────────────────────────────────────────────────
+// The "Manage preferences" surface the lost-pet alert email + the one-click
+// "turn off" fallback both link to. Also where a logged-in user opts IN to
+// neighborhood lost-pet alerts (the only ON path — signup checkbox is the other).
+community.get('/me', (req, res) => {
+  if (!req.user) return res.redirect('/login?next=/me');
+  res.send(renderMe({ user: req.user }));
+});
+
+// Toggle lost-pet alerts on/off for the logged-in user. Explicit opt-in:
+// alert_lost_pets is only ever TRUE via this route or the signup checkbox.
+community.post('/me/alerts/lost-pets', requireUser, async (req, res) => {
+  const on = req.body?.on === true || req.body?.on === 'on' || req.body?.on === 'true';
+  await pool.query(`UPDATE app_users SET alert_lost_pets = $1 WHERE id = $2`, [on, req.user.id]);
+  res.json({ ok: true, alert_lost_pets: on });
+});
+
 // ── marketplace ───────────────────────────────────────────────────────────
 community.get('/marketplace', async (req, res) => {
   const { type, zip, species, q } = req.query;
diff --git a/src/server/community_render.js b/src/server/community_render.js
index cee5be6..49f083b 100644
--- a/src/server/community_render.js
+++ b/src/server/community_render.js
@@ -75,6 +75,9 @@ export function renderSignup({ user, next }) {
       <label class="ck"><input type="checkbox" name="species_pets" value="reptile">🦎 Reptile</label>
       <label class="ck"><input type="checkbox" name="species_pets" value="small_mammal">🐰 Small mammal</label>
     </fieldset>
+    <fieldset class="checks"><legend>Neighborhood alerts</legend>
+      <label class="ck"><input type="checkbox" name="alert_lost_pets" value="on">🆘 Email me when a pet goes missing in my ZIP (and nearby)</label>
+    </fieldset>
     <button type="submit">Create account</button>
     <p class="err" style="color:#c0382b"></p>
   </form>
@@ -97,6 +100,37 @@ export function renderLogin({ user, next }) {
 </main>` + footer() + '</body></html>';
 }
 
+// Preferences / "Manage preferences" page — the /me surface the lost-pet
+// alert email footer and the one-click "turn off" fallback both link to.
+// Lets a user opt IN to (or back out of) neighborhood lost-pet email alerts.
+export function renderMe({ user, saved }) {
+  const zip3 = (user.home_zip || '').slice(0, 3);
+  const on = !!user.alert_lost_pets;
+  const verifyNote = user.email_verified
+    ? ''
+    : `<p class="muted small" style="color:#92400e">⚠️ Your email isn't verified yet — alerts only go to verified addresses. <a href="#" onclick="event.preventDefault();fetch('/auth/resend-verification',{method:'POST',headers:{'x-requested-with':'fetch'}}).then(()=>alert('Verification email sent.'));">Resend verification</a>.</p>`;
+  return head('Your preferences') + nav(user) + `
+<main class="container narrow">
+  <h1>Your preferences</h1>
+  <p class="muted">${esc(user.email)} · ZIP ${esc(user.home_zip || '—')}</p>
+  ${verifyNote}
+
+  <section class="pref-card" style="border:1px solid #e5e5e5;border-radius:10px;padding:18px 20px;margin:18px 0">
+    <h2 style="font-size:1.1rem;margin:0 0 6px">🆘 Lost-pet alerts</h2>
+    <p class="muted small" style="margin:0 0 14px">When a neighbor posts that their pet is missing in your ZIP (<strong>${esc(user.home_zip || '—')}</strong>) or nearby (ZIP-3 <strong>${esc(zip3 || '—')}…</strong>), we'll email you the photo, last-seen location, and how to reach them. No spam — only genuine lost-pet posts. You can turn this off anytime.</p>
+    <label class="ck" style="font-size:1rem">
+      <input type="checkbox" id="alert_lost_pets" ${on ? 'checked' : ''}
+        onchange="fetch('/me/alerts/lost-pets',{method:'POST',headers:{'content-type':'application/json','x-requested-with':'fetch'},body:JSON.stringify({on:this.checked})}).then(r=>r.json()).then(j=>{document.getElementById('pref-status').textContent=j.ok?(this.checked?'On — you'll get alerts.':'Off.'):(j.error||'failed');}).catch(()=>{document.getElementById('pref-status').textContent='network error';this.checked=!this.checked;});">
+      Email me about lost pets near me
+    </label>
+    <p id="pref-status" class="muted small" style="margin:8px 0 0;color:#16a34a">${saved ? 'Saved.' : ''}</p>
+    <p class="muted small" style="margin:12px 0 0;color:#888">📱 SMS alerts aren't available — we never text without a separate, explicit opt-in.</p>
+  </section>
+
+  <p class="muted small"><a href="/circles">← Back to your circles</a></p>
+</main>` + footer() + '</body></html>';
+}
+
 // ── marketplace ───────────────────────────────────────────────────────────
 const TYPE_LABELS = {
   lost_pet:'🆘 Lost pet', found_pet:'👀 Found pet', adoption:'❤️ Adoption',

← 2f8b7b5 Honor ?next= after login/signup so claimers return to the cl  ·  back to Animals  ·  test: verify Stripe upgrade-checkout line items + live-key g 2ab8a9e →