← back to Costa Rica
costa-rica: contacts feature (backend) — migration 006, import/match/invite endpoints, E.164 normalize (+506 default), WhatsApp invite deep link; privacy: owner-scoped, user-initiated invites only — TK-10346
a2b80ff2c71a61540db7d3a2c1deee5ad3eedde1 · 2026-08-07 10:30:28 -0700 · Steve
Files touched
M routes/app.jsA scripts/migrate_006_contacts.sql
Diff
commit a2b80ff2c71a61540db7d3a2c1deee5ad3eedde1
Author: Steve <steve@designerwallcoverings.com>
Date: Fri Aug 7 10:30:28 2026 -0700
costa-rica: contacts feature (backend) — migration 006, import/match/invite endpoints, E.164 normalize (+506 default), WhatsApp invite deep link; privacy: owner-scoped, user-initiated invites only — TK-10346
---
routes/app.js | 58 ++++++++++++++++++++++++++++++++++++++++
scripts/migrate_006_contacts.sql | 17 ++++++++++++
2 files changed, 75 insertions(+)
diff --git a/routes/app.js b/routes/app.js
index a5751dd..cd573c3 100644
--- a/routes/app.js
+++ b/routes/app.js
@@ -13,6 +13,18 @@ const apple = require('../lib/apple');
const router = express.Router();
const bookingCode = () => 'CR-' + crypto.randomBytes(3).toString('hex').toUpperCase();
+
+// Normalize a phone to E.164. Costa Rica default (+506) for bare 8-digit numbers.
+function normalizePhone(raw) {
+ if (!raw) return null;
+ let d = String(raw).replace(/[^\d+]/g, '');
+ if (d.startsWith('+')) return d;
+ d = d.replace(/\D/g, '');
+ if (d.length === 8) return '+506' + d; // CR local
+ if (d.length === 11 && d.startsWith('506')) return '+' + d;
+ if (d.length >= 10) return '+' + d;
+ return null;
+}
const ok = (res, data) => res.json({ ok: true, ...data });
const bad = (res, code, msg) => res.status(code).json({ ok: false, error: msg });
@@ -257,6 +269,52 @@ async function confirmBooking(bookingId) {
} catch (e) { console.warn('[confirm] wa notify failed', e.message); }
}
+// ---------------------------------------------------------------- contacts (import + invite)
+// Bulk import the user's address book. Body: { contacts: [{name, phone, email}] }.
+// Normalizes, upserts (dedup per owner), and matches against existing users.
+router.post('/contacts/import', authRequired, async (req, res) => {
+ const list = Array.isArray(req.body?.contacts) ? req.body.contacts.slice(0, 5000) : null;
+ if (!list) return bad(res, 400, 'contacts array required');
+ let imported = 0, matched = 0;
+ for (const c of list) {
+ const phone = normalizePhone(c.phone);
+ if (!phone) continue;
+ const { rows: [mu] } = await pool.query(`SELECT id FROM app_users WHERE phone_e164=$1 AND id<>$2`, [phone, req.user.sub]);
+ const r = await pool.query(
+ `INSERT INTO contacts (owner_id, display_name, phone_e164, email, matched_user_id)
+ VALUES ($1,$2,$3,$4,$5)
+ ON CONFLICT (owner_id, phone_e164)
+ DO UPDATE SET display_name=COALESCE(EXCLUDED.display_name, contacts.display_name),
+ matched_user_id=EXCLUDED.matched_user_id
+ RETURNING (xmax=0) AS inserted`,
+ [req.user.sub, c.name || null, phone, c.email || null, mu?.id || null]);
+ if (r.rows[0].inserted) imported++;
+ if (mu) matched++;
+ }
+ ok(res, { imported, matched, received: list.length });
+});
+
+router.get('/contacts', authRequired, async (req, res) => {
+ const { rows } = await pool.query(
+ `SELECT c.id, c.display_name, c.phone_e164, c.email, c.invited_at,
+ (c.matched_user_id IS NOT NULL) AS on_app
+ FROM contacts c WHERE c.owner_id=$1
+ ORDER BY on_app DESC, c.display_name NULLS LAST LIMIT 2000`, [req.user.sub]);
+ ok(res, { count: rows.length, contacts: rows });
+});
+
+// Generate a WhatsApp invite deep link for a contact (and record the invite).
+router.post('/contacts/:id/invite', authRequired, async (req, res) => {
+ const { rows: [c] } = await pool.query(`SELECT * FROM contacts WHERE id=$1 AND owner_id=$2`, [req.params.id, req.user.sub]);
+ if (!c) return bad(res, 404, 'contact not found');
+ const { rows: [me] } = await pool.query(`SELECT full_name FROM app_users WHERE id=$1`, [req.user.sub]);
+ const msg = `${me?.full_name || 'A friend'} invited you to explore & book stays, tours and services in Costa Rica 🇨🇷 → https://costarica.agentabrams.com`;
+ const w6 = (c.phone_e164 || '').replace(/^\+/, '');
+ const link = `https://wa.me/${w6}?text=${encodeURIComponent(msg)}`;
+ await pool.query(`UPDATE contacts SET invited_at=NOW(), invite_channel='whatsapp' WHERE id=$1`, [c.id]);
+ ok(res, { invite_link: link });
+});
+
// ---------------------------------------------------------------- host onboarding
router.post('/host/apply', authRequired, async (req, res) => {
const { legal_name, cedula, country = 'CR' } = req.body || {};
diff --git a/scripts/migrate_006_contacts.sql b/scripts/migrate_006_contacts.sql
new file mode 100644
index 0000000..a8afdc7
--- /dev/null
+++ b/scripts/migrate_006_contacts.sql
@@ -0,0 +1,17 @@
+-- User-imported contacts (address book) — for platform-match + invite/referral.
+-- PII: a user's own contacts only; never bulk-messaged by the platform.
+CREATE TABLE IF NOT EXISTS contacts (
+ id BIGSERIAL PRIMARY KEY,
+ owner_id BIGINT NOT NULL REFERENCES app_users(id) ON DELETE CASCADE,
+ display_name TEXT,
+ phone_e164 TEXT, -- normalized
+ email TEXT,
+ matched_user_id BIGINT REFERENCES app_users(id) ON DELETE SET NULL, -- already on platform?
+ invited_at TIMESTAMPTZ,
+ invite_channel TEXT, -- whatsapp | sms
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ UNIQUE (owner_id, phone_e164)
+);
+CREATE INDEX IF NOT EXISTS idx_contacts_owner ON contacts(owner_id);
+CREATE INDEX IF NOT EXISTS idx_contacts_matched ON contacts(matched_user_id);
+CREATE INDEX IF NOT EXISTS idx_contacts_phone ON contacts(phone_e164);
← 0689909 costa-rica: Cody gate c2 — only auto-link Apple identity to
·
back to Costa Rica
·
costa-rica: SANDBOX banner on admin dashboard — test data cl 633fcf0 →