← back to Animals
yolo: Business owners can claim their listing
2fa8980c457cee7e197c1f715352856768bbc33c · 2026-05-08 17:19:54 -0700 · animal-yolo
Task: yolo.business-claim-flow
Prompt: In ~/Projects/animals: add a /clinic/:id/claim route. If logged-in user submits, it sends a verification email to the email already on file for that business. Click the link, businesses.claimed_by get
Files touched
M agents/animal-yolo/state.jsonA migrations/015_business_claims.sqlA src/lib/business_claim.jsM src/server/index.js
Diff
commit 2fa8980c457cee7e197c1f715352856768bbc33c
Author: animal-yolo <steve@designerwallcoverings.com>
Date: Fri May 8 17:19:54 2026 -0700
yolo: Business owners can claim their listing
Task: yolo.business-claim-flow
Prompt: In ~/Projects/animals: add a /clinic/:id/claim route. If logged-in user submits, it sends a verification email to the email already on file for that business. Click the link, businesses.claimed_by get
---
agents/animal-yolo/state.json | 4 +-
migrations/015_business_claims.sql | 39 ++++++
src/lib/business_claim.js | 112 +++++++++++++++++
src/server/index.js | 243 ++++++++++++++++++++++++++++++++++++-
4 files changed, 393 insertions(+), 5 deletions(-)
diff --git a/agents/animal-yolo/state.json b/agents/animal-yolo/state.json
index cab1b8f..fb5b1f4 100644
--- a/agents/animal-yolo/state.json
+++ b/agents/animal-yolo/state.json
@@ -1,5 +1,5 @@
{
- "cursor": 6,
- "runs": 6,
+ "cursor": 7,
+ "runs": 7,
"started_at": "2026-05-08T07:01:22.386Z"
}
\ No newline at end of file
diff --git a/migrations/015_business_claims.sql b/migrations/015_business_claims.sql
new file mode 100644
index 0000000..b3dde1a
--- /dev/null
+++ b/migrations/015_business_claims.sql
@@ -0,0 +1,39 @@
+-- Project: Animals — business-claim tokens.
+--
+-- Wires the /clinic/:id/claim flow:
+-- 1. Logged-in user POSTs /clinic/:id/claim
+-- 2. We generate a fresh token, persist it on businesses.claim_token,
+-- and send the click-link to businesses.email (the address already on
+-- file from the directory crawl).
+-- 3. Anyone with access to that mailbox clicks the link → /clinic/:id/verify-claim?token=…
+-- 4. Token is consumed (cleared) and businesses.claimed_by is set to the
+-- user_id stored alongside the token.
+-- 5. From then on /clinic/:id/edit lets that user (and only that user)
+-- edit hours/website/phone.
+--
+-- claimed_by already existed (BIGINT, see 001_initial_schema.sql) — we are
+-- only adding the token-flow plumbing + claimed_at audit timestamp.
+--
+-- Single-token-at-a-time semantics: if a second user tries to claim while the
+-- first token is still live, the second token overwrites the first. The
+-- email-on-file owner is the arbiter — whoever's link they click, wins.
+
+BEGIN;
+
+ALTER TABLE businesses
+ ADD COLUMN IF NOT EXISTS claim_token TEXT,
+ ADD COLUMN IF NOT EXISTS claim_token_user_id BIGINT,
+ ADD COLUMN IF NOT EXISTS claim_token_sent_at TIMESTAMPTZ,
+ ADD COLUMN IF NOT EXISTS claimed_at TIMESTAMPTZ;
+
+-- Live tokens must be unique (NULL after consumption).
+CREATE UNIQUE INDEX IF NOT EXISTS idx_businesses_claim_token
+ ON businesses (claim_token)
+ WHERE claim_token IS NOT NULL;
+
+-- Helps the green-badge / "your listings" lookup.
+CREATE INDEX IF NOT EXISTS idx_biz_claimed_by
+ ON businesses (claimed_by)
+ WHERE claimed_by IS NOT NULL;
+
+COMMIT;
diff --git a/src/lib/business_claim.js b/src/lib/business_claim.js
new file mode 100644
index 0000000..154622c
--- /dev/null
+++ b/src/lib/business_claim.js
@@ -0,0 +1,112 @@
+// Business-claim flow — generate a token, persist it onto the businesses row,
+// send the click-link to the email already on file via George (localhost:9850),
+// then consume the token at /clinic/:id/verify-claim and set claimed_by.
+//
+// Mirrors the shape of email_verification.js (same George wire, same fire-and-
+// forget semantics, same esc/page helpers) so failure modes stay predictable
+// across both flows.
+
+import crypto from 'node:crypto';
+import { pool, one } from './db.js';
+import { log } from './log.js';
+
+const GEORGE_URL = process.env.GEORGE_GMAIL_URL || 'http://localhost:9850';
+const GEORGE_AUTH = process.env.GEORGE_AUTH || 'admin:DWSecure2024!';
+const PUBLIC_BASE = process.env.PUBLIC_BASE_URL || 'https://animalsdirectory.com';
+const FROM_NAME = process.env.GEORGE_FROM_NAME || 'AnimalsDirectory';
+
+export function generateToken() {
+ return crypto.randomBytes(32).toString('base64url');
+}
+
+// Mask an email for display: john.smith@vetowner.com → j***h@v***r.com
+// Used on /clinic/:id/claim so the user can verify they recognize the address
+// before submitting, without leaking the full address to drive-by viewers.
+export function maskEmail(email) {
+ if (!email || typeof email !== 'string') return '';
+ const at = email.indexOf('@');
+ if (at < 1) return '';
+ const local = email.slice(0, at);
+ const domain = email.slice(at + 1);
+ const maskPart = (s) => s.length <= 2 ? s[0] + '***' : s[0] + '***' + s[s.length - 1];
+ const dot = domain.lastIndexOf('.');
+ const host = dot > 0 ? domain.slice(0, dot) : domain;
+ const tld = dot > 0 ? domain.slice(dot) : '';
+ return `${maskPart(local)}@${maskPart(host)}${tld}`;
+}
+
+// Persist a fresh token onto the business row + send the email. Returns the
+// URL (mostly so dev/testing can grab it without round-tripping George).
+export async function issueAndSendClaimToken({ bizId, userId, biz }) {
+ if (!biz?.email) {
+ throw new Error('no email on file');
+ }
+ const token = generateToken();
+ await pool.query(
+ `UPDATE businesses
+ SET claim_token = $1,
+ claim_token_user_id = $2,
+ claim_token_sent_at = NOW()
+ WHERE id = $3`,
+ [token, userId, bizId]
+ );
+ const url = `${PUBLIC_BASE}/clinic/${bizId}/verify-claim?token=${encodeURIComponent(token)}`;
+
+ // Fire-and-forget — don't block the request on George being healthy.
+ sendClaimEmail({ to: biz.email, biz, url }).catch(err => {
+ log.warn('claim email send failed', { bizId, userId, err: err.message });
+ });
+
+ return { token, url };
+}
+
+async function sendClaimEmail({ to, biz, url }) {
+ const subject = `Confirm ownership of ${biz.name} on AnimalsDirectory`;
+ const body = `
+<p>Hi,</p>
+<p>Someone (likely you) is asking to claim the AnimalsDirectory listing for <strong>${esc(biz.name)}</strong> in ${esc(biz.city || '')}${biz.state ? ', ' + esc(biz.state) : ''}.</p>
+<p>If that was you, click below to confirm. The person who started the claim will then be able to edit hours, website, and phone for this listing.</p>
+<p><a href="${esc(url)}" style="display:inline-block;background:#0f4d3a;color:#fff;padding:10px 18px;border-radius:6px;text-decoration:none">Confirm I own ${esc(biz.name)}</a></p>
+<p style="color:#666;font-size:.9em">Or paste this link into your browser:<br><code style="word-break:break-all">${esc(url)}</code></p>
+<p style="color:#888;font-size:.85em">If this wasn't you, ignore this email — the listing stays unclaimed unless someone with access to this mailbox clicks the link.</p>
+<p style="color:#888;font-size:.85em">— ${esc(FROM_NAME)}</p>
+`.trim();
+
+ const r = await fetch(`${GEORGE_URL}/api/send`, {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ 'authorization': 'Basic ' + Buffer.from(GEORGE_AUTH).toString('base64'),
+ },
+ body: JSON.stringify({ to, subject, body }),
+ signal: AbortSignal.timeout(10000),
+ });
+ if (!r.ok) {
+ const text = await r.text().catch(() => '');
+ throw new Error(`george returned ${r.status}: ${text.slice(0, 200)}`);
+ }
+}
+
+// Consume the token and set claimed_by atomically. Returns the row on success
+// (so the caller knows which user_id won the claim and can redirect to the
+// edit page on their behalf), or null if the token is invalid / already used.
+export async function consumeClaimToken({ bizId, token }) {
+ if (!token || !bizId) return null;
+ const row = await one(
+ `UPDATE businesses
+ SET claimed_by = claim_token_user_id,
+ claimed_at = NOW(),
+ claim_token = NULL,
+ claim_token_user_id = NULL
+ WHERE id = $1
+ AND claim_token = $2
+ AND claim_token_user_id IS NOT NULL
+ RETURNING id, name, claimed_by`,
+ [bizId, token]
+ );
+ return row || null;
+}
+
+function esc(s) {
+ return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
+}
diff --git a/src/server/index.js b/src/server/index.js
index d4bfc46..95a5900 100644
--- a/src/server/index.js
+++ b/src/server/index.js
@@ -33,6 +33,7 @@ import { dogFriends } from './dog_friends.js';
import { attachDogPark } from './dogpark.js';
import { avatarsRouter } from './avatars_router.js';
import { attachUser, requireUser } from '../lib/auth.js';
+import { issueAndSendClaimToken, consumeClaimToken, maskEmail } from '../lib/business_claim.js';
// SECURITY: middleware to gate admin routes registered directly on `app`
// (the adminPitch router has its own internal gate, but `/admin`, `/admin/flags`,
@@ -939,8 +940,9 @@ app.get('/:cat', async (req, res, next) => {
res.send(renderCategory({ category: req.params.cat, dbCategory: dbCat, businesses, ad }));
});
-// Business detail
-app.get('/clinic/:id', async (req, res) => {
+// Business detail. attachUser so renderBusiness can swap the claim CTA for an
+// "Edit your listing" link when the viewer owns the claim.
+app.get('/clinic/:id', attachUser, async (req, res) => {
const biz = await one('SELECT * FROM businesses WHERE id=$1 AND opt_out_flag=FALSE', [req.params.id]);
if (!biz) return res.status(404).send(renderNotFound('Listing not found'));
const audit = await one('SELECT * FROM site_audits WHERE business_id=$1 ORDER BY audited_at DESC LIMIT 1', [biz.id]);
@@ -952,9 +954,244 @@ app.get('/clinic/:id', async (req, res) => {
ORDER BY fetched_at DESC
LIMIT 60`, [biz.id])
: [];
- res.send(renderBusiness({ biz, audit, ad, animals }));
+ res.send(renderBusiness({ biz, audit, ad, animals, user: req.user }));
});
+// ─── Business-claim flow ───────────────────────────────────────────────────
+//
+// /clinic/:id/claim GET → form (must be logged in; shows masked email)
+// /clinic/:id/claim POST → issue token + send email to biz.email
+// /clinic/:id/verify-claim GET → token consumer; sets claimed_by + redirects
+// /clinic/:id/edit GET → owner-only edit form (hours / website / phone)
+// /clinic/:id/edit POST → owner-only save
+//
+// Auth model: a logged-in user requests the claim, but the email-on-file is
+// the arbiter — whoever has the inbox + clicks the link wins. requireUser is
+// JSON-flavored (returns 401 JSON), so for HTML routes we redirect to /login.
+
+function requireUserHtml(req, res, next) {
+ if (!req.user) {
+ const next_ = encodeURIComponent(req.originalUrl);
+ return res.redirect(302, `/login?next=${next_}`);
+ }
+ next();
+}
+
+app.get('/clinic/:id/claim', attachUser, requireUserHtml, async (req, res) => {
+ const biz = await one('SELECT * FROM businesses WHERE id=$1 AND opt_out_flag=FALSE', [req.params.id]);
+ if (!biz) return res.status(404).send(renderNotFound('Listing not found'));
+ const masked = biz.email ? maskEmail(biz.email) : null;
+ const alreadyClaimed = !!biz.claimed_by;
+ res.send(renderClaimPage({ biz, masked, alreadyClaimed, user: req.user }));
+});
+
+app.post('/clinic/:id/claim', attachUser, requireUserHtml, async (req, res) => {
+ const biz = await one('SELECT * FROM businesses WHERE id=$1 AND opt_out_flag=FALSE', [req.params.id]);
+ if (!biz) return res.status(404).send(renderNotFound('Listing not found'));
+ if (biz.claimed_by) {
+ return res.status(409).send(renderClaimResult({
+ biz, ok: false,
+ message: 'This listing has already been claimed. If you are the rightful owner and need access, contact us.',
+ }));
+ }
+ if (!biz.email) {
+ return res.status(400).send(renderClaimResult({
+ biz, ok: false,
+ message: 'We do not have an email on file for this listing yet — claim by email is not available. Use the lead form below to contact us instead.',
+ }));
+ }
+ try {
+ await issueAndSendClaimToken({ bizId: biz.id, userId: req.user.id, biz });
+ } catch (err) {
+ log.error('claim issue failed', { bizId: biz.id, userId: req.user.id, err: err.message });
+ return res.status(500).send(renderClaimResult({
+ biz, ok: false,
+ message: 'Something went wrong sending the verification email. Try again in a minute.',
+ }));
+ }
+ res.send(renderClaimResult({
+ biz, ok: true,
+ message: `Verification email sent to ${maskEmail(biz.email)}. Click the link in that inbox to confirm — once you do, you'll be able to edit hours, website, and phone for this listing.`,
+ }));
+});
+
+app.get('/clinic/:id/verify-claim', async (req, res) => {
+ const id = parseInt(req.params.id, 10);
+ const token = String(req.query?.token || '');
+ if (!Number.isFinite(id) || !token) {
+ return res.status(400).send(renderClaimResult({
+ biz: { id, name: 'Listing' }, ok: false,
+ message: 'Missing or malformed claim link.',
+ }));
+ }
+ const row = await consumeClaimToken({ bizId: id, token });
+ if (!row) {
+ return res.status(400).send(renderClaimResult({
+ biz: { id, name: 'Listing' }, ok: false,
+ message: 'This claim link is invalid, expired, or has already been used.',
+ }));
+ }
+ log.info('business claim confirmed', { bizId: row.id, claimedBy: row.claimed_by });
+ res.send(renderClaimResult({
+ biz: row, ok: true,
+ message: `You're now the verified owner of ${row.name}. Edit hours, website, and phone any time from the listing's edit page.`,
+ editLink: `/clinic/${row.id}/edit`,
+ }));
+});
+
+app.get('/clinic/:id/edit', attachUser, requireUserHtml, async (req, res) => {
+ const biz = await one('SELECT * FROM businesses WHERE id=$1 AND opt_out_flag=FALSE', [req.params.id]);
+ if (!biz) return res.status(404).send(renderNotFound('Listing not found'));
+ if (biz.claimed_by !== req.user.id) {
+ return res.status(403).send(renderClaimResult({
+ biz, ok: false,
+ message: 'You are not the verified owner of this listing.',
+ }));
+ }
+ res.send(renderEditPage({ biz, user: req.user }));
+});
+
+app.post('/clinic/:id/edit', attachUser, requireUserHtml, async (req, res) => {
+ const biz = await one('SELECT id, claimed_by FROM businesses WHERE id=$1 AND opt_out_flag=FALSE', [req.params.id]);
+ if (!biz) return res.status(404).send(renderNotFound('Listing not found'));
+ if (biz.claimed_by !== req.user.id) return res.status(403).send('forbidden');
+
+ const { website, phone, hours } = req.body || {};
+ // Light normalization — empty strings → NULL so we don't overwrite real data
+ // with whitespace, and so the public detail page hides empty rows cleanly.
+ const cleanWebsite = (typeof website === 'string' && website.trim()) ? website.trim().slice(0, 500) : null;
+ const cleanPhone = (typeof phone === 'string' && phone.trim()) ? phone.trim().slice(0, 60) : null;
+ // Hours is a freeform 7-line block (Mon: 9-5, Tue: 9-5, …). Stored as JSON
+ // alongside hours_json's existing schema-free shape — keyed by `freeform`
+ // so future structured-hours UI can coexist without a migration.
+ const cleanHours = (typeof hours === 'string' && hours.trim())
+ ? { freeform: hours.trim().slice(0, 2000) }
+ : null;
+
+ if (cleanWebsite && !/^https?:\/\//i.test(cleanWebsite)) {
+ return res.status(400).send(renderClaimResult({
+ biz: { id: biz.id, name: 'Listing' }, ok: false,
+ message: 'Website must start with http:// or https://.',
+ }));
+ }
+
+ await pool.query(
+ `UPDATE businesses
+ SET website = $1,
+ phone = $2,
+ hours_json = $3,
+ updated_at = NOW()
+ WHERE id = $4 AND claimed_by = $5`,
+ [cleanWebsite, cleanPhone, cleanHours, biz.id, req.user.id]
+ );
+ res.redirect(302, `/clinic/${biz.id}`);
+});
+
+function renderClaimPage({ biz, masked, alreadyClaimed, user }) {
+ const title = `Claim ${biz.name} — AnimalsDirectory`;
+ return `<!doctype html><html lang="en"><head>
+<meta charset="utf-8"><title>${esc(title)}</title>
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<style>
+ body{font-family:system-ui,sans-serif;max-width:560px;margin:48px auto;padding:0 20px;color:#111;line-height:1.55}
+ h1{font-weight:300;font-size:26px;margin:0 0 6px}
+ .muted{color:#5a6f63}
+ .card{background:#f8faf6;border:1px solid #d2dac8;border-radius:10px;padding:20px;margin:18px 0}
+ .btn{display:inline-block;background:#0f4d3a;color:#fff;padding:10px 18px;border-radius:6px;text-decoration:none;border:0;font:inherit;cursor:pointer}
+ .btn[disabled]{background:#9aa89a;cursor:not-allowed}
+ code{background:#eef2eb;padding:2px 6px;border-radius:4px;font-size:13px}
+ .back{display:inline-block;margin-top:18px;color:#0f4d3a;text-decoration:none}
+</style></head><body>
+<h1>Claim this listing</h1>
+<p class="muted"><strong>${esc(biz.name)}</strong>${biz.city ? ' · '+esc(biz.city) : ''}${biz.state ? ', '+esc(biz.state) : ''}</p>
+${alreadyClaimed ? `
+ <div class="card">
+ <p>This listing has already been claimed. If you're the rightful owner and need access, <a href="/lead?biz=${biz.id}">contact us</a>.</p>
+ </div>
+` : !biz.email ? `
+ <div class="card">
+ <p>We don't have an email on file for this listing yet, so claim-by-email isn't available.</p>
+ <p>Use the <a href="/lead?biz=${biz.id}">contact form</a> and we'll get you set up manually.</p>
+ </div>
+` : `
+ <div class="card">
+ <p>To verify you own ${esc(biz.name)}, we'll email a confirmation link to the address already on file:</p>
+ <p style="font-size:18px;margin:12px 0"><code>${esc(masked)}</code></p>
+ <p class="muted" style="font-size:13px">If that's not your inbox — for example, the listing's email is wrong — use the <a href="/lead?biz=${biz.id}">contact form</a> instead and we'll sort it out.</p>
+ <form method="POST" action="/clinic/${biz.id}/claim" style="margin-top:14px">
+ <button type="submit" class="btn">Email me the confirmation link</button>
+ </form>
+ </div>
+`}
+<a class="back" href="/clinic/${biz.id}">← Back to listing</a>
+</body></html>`;
+}
+
+function renderClaimResult({ biz, ok, message, editLink }) {
+ const title = ok ? 'Claim updated' : 'Claim problem';
+ const color = ok ? '#0f4d3a' : '#c0382b';
+ return `<!doctype html><html lang="en"><head>
+<meta charset="utf-8"><title>${esc(title)} — AnimalsDirectory</title>
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<style>
+ body{font-family:system-ui,sans-serif;max-width:560px;margin:48px auto;padding:0 20px;color:#111;line-height:1.55}
+ h1{font-weight:400;color:${color};margin:0 0 8px;font-size:24px}
+ .btn{display:inline-block;background:#0f4d3a;color:#fff;padding:10px 18px;border-radius:6px;text-decoration:none;margin-top:18px;margin-right:8px}
+ .btn-secondary{background:#fff;color:#0f4d3a;border:1px solid #0f4d3a}
+</style></head><body>
+<h1>${esc(title)}</h1>
+<p>${esc(message)}</p>
+${editLink ? `<a class="btn" href="${esc(editLink)}">Edit listing →</a>` : ''}
+<a class="btn ${editLink ? 'btn-secondary' : ''}" href="/clinic/${biz.id}">Back to ${esc(biz.name || 'listing')}</a>
+</body></html>`;
+}
+
+function renderEditPage({ biz, user }) {
+ // hours_json is freeform-shaped (see POST handler) — we stored it under .freeform.
+ // Older rows might have schema-driven hours; render whatever string we can find
+ // for round-tripping. Falls back to empty.
+ const hoursDefault = (() => {
+ const h = biz.hours_json;
+ if (!h) return '';
+ if (typeof h === 'string') return h;
+ if (typeof h.freeform === 'string') return h.freeform;
+ try { return JSON.stringify(h, null, 2); } catch { return ''; }
+ })();
+ return `<!doctype html><html lang="en"><head>
+<meta charset="utf-8"><title>Edit ${esc(biz.name)} — AnimalsDirectory</title>
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<style>
+ body{font-family:system-ui,sans-serif;max-width:640px;margin:48px auto;padding:0 20px;color:#111;line-height:1.55}
+ h1{font-weight:300;font-size:26px;margin:0 0 6px}
+ .muted{color:#5a6f63}
+ label{display:block;margin:18px 0 6px;font-weight:500;font-size:14px}
+ input[type=text],input[type=url],input[type=tel],textarea{
+ width:100%;padding:10px 12px;border:1px solid #d2dac8;border-radius:6px;font:inherit;background:#fff;box-sizing:border-box}
+ textarea{min-height:140px;font-family:ui-monospace,Menlo,monospace;font-size:13px}
+ .btn{display:inline-block;background:#0f4d3a;color:#fff;padding:10px 18px;border-radius:6px;text-decoration:none;border:0;font:inherit;cursor:pointer;margin-top:20px}
+ .btn-secondary{background:#fff;color:#0f4d3a;border:1px solid #0f4d3a;margin-left:8px}
+ .verified-badge{display:inline-block;background:#dcefe2;color:#0f4d3a;border:1px solid #7eaf91;padding:2px 10px;border-radius:999px;font-size:12px;font-weight:500;margin-left:8px}
+</style></head><body>
+<h1>Edit listing <span class="verified-badge">✓ Claimed</span></h1>
+<p class="muted">${esc(biz.name)}${biz.city ? ' · '+esc(biz.city) : ''}${biz.state ? ', '+esc(biz.state) : ''}</p>
+<p class="muted" style="font-size:13px">Editing as <strong>${esc(user.email)}</strong>. Changes go live immediately.</p>
+
+<form method="POST" action="/clinic/${biz.id}/edit">
+ <label for="website">Website</label>
+ <input id="website" name="website" type="url" placeholder="https://example.com" value="${esc(biz.website || '')}">
+
+ <label for="phone">Phone</label>
+ <input id="phone" name="phone" type="tel" placeholder="(555) 123-4567" value="${esc(biz.phone || '')}">
+
+ <label for="hours">Hours <span class="muted" style="font-weight:400">(freeform — one line per day, or however you like)</span></label>
+ <textarea id="hours" name="hours" placeholder="Mon-Fri: 8am-6pm Sat: 9am-2pm Sun: closed">${esc(hoursDefault)}</textarea>
+
+ <button type="submit" class="btn">Save changes</button>
+ <a class="btn btn-secondary" href="/clinic/${biz.id}">Cancel</a>
+</form>
+</body></html>`;
+}
+
// Owner pets gallery — /my-pets is a PRIVATE dashboard for the logged-in user.
// (Codex r2 CRITICAL 2026-05-01: previously this route had no auth, no
// share_publicly filter, and listed every owner's pets+photos+bios on the
← 33ff008 yolo: Pet store detail: 'what they sell' section
·
back to Animals
·
yolo: Lost-pet listing → SMS alert to neighbors in ZIP f2d7975 →