← 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
M src/lib/auth.jsM src/server/community.jsM src/server/community_render.js
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 →