[object Object]

← 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

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 →