Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -469,3 +469,18 @@ No Stripe Connect code exists anywhere in the repo yet — `lib/stripe.js` only
- `lib/__tests__/rateLimit.test.js`: 12/12 unit tests passing (4 new assertions covering isolation, token independence, and Meta webhook ceilings).
- Full Vitest suite: 19/19 test files passed, 137/137 tests green (`yarn --ignore-engines test`).
- Production build: `yarn --ignore-engines build` exit code 0, 75 static/dynamic routes compiled cleanly in 78s.

---

## Session log (2026-10-17) — repo audit follow-ups (`fix/audit-followups`)

- ✅ **Live SMS sender honours STOP** — the Firebase `sendReminders` function (the only live reminder sender since July) never read `smsSuppressed`. It now normalizes the number to E.164, fails non-E.164 numbers (`invalid number`) and skips opted-out leads (`functions/smsGuard.js`). **Needs a redeploy:** `firebase deploy --only functions:sendReminders`.
- ✅ **Double "booked" no longer double-fires** — `updateProspect` reads and writes in one transaction, so only one of two concurrent booked updates fires webhooks, reminders and the GHL appointment.
- ✅ **No duplicate prospects from back-to-back replies** — `ingestInboundReply` matches-or-creates inside a transaction.
- ✅ **Long threads can be deleted** — `deleteProspect` uses `recursiveDelete` (single batches cap at 500 writes).
- ✅ **Outreach `to` is one address** — a comma list let an unsubscribed address past the suppression check (`outreachRecipientSchema`).

### Still open (from the same audit)
- [ ] `onProspectBooked` stores `prospect.phone` as typed; non-E.164 reminders now fail visibly instead of sending. Normalize at prospect save once #41's `normalizeE164` lands.
- [ ] Delete `functions/yarn.lock` or `package-lock.json` — they already disagree on `firebase-functions` (7.4.0 vs 7.3.2).
- [ ] In-memory rate-limit buckets (`lib/rateLimit.js`) never evict keys when Upstash is unset.
16 changes: 14 additions & 2 deletions app/api/[[...path]]/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ import { PROSPECT_CHANNELS } from "@/lib/prospects";
import { deriveResultState } from "@/lib/resultState";
import { findPlaceholders } from "@/lib/scriptText";
import { getBaseUrl } from "@/lib/baseUrl";
import { outreachRecipientSchema } from "@/lib/schemas";

// Cross-origin callers must be on the allow-list; same-origin requests never
// need CORS headers. Override via CORS_ORIGINS (comma-separated) — the old
Expand Down Expand Up @@ -985,8 +986,8 @@ Rules:
request,
NextResponse.json({ error: "invalid JSON body" }, { status: 400 }),
);
const { to, subject, body: text } = body;
if (!to || typeof to !== "string" || !subject || !text) {
const { subject, body: text } = body;
if (!body.to || typeof body.to !== "string" || !subject || !text) {
return handleCORS(
request,
NextResponse.json(
Expand All @@ -995,6 +996,17 @@ Rules:
),
);
}
const recipient = outreachRecipientSchema.safeParse(body.to);
if (!recipient.success) {
return handleCORS(
request,
NextResponse.json(
{ error: "to must be a single email address" },
{ status: 400 },
),
);
}
const to = recipient.data;
const channelSnap = await db
.collection("users")
.doc(decoded.uid)
Expand Down
10 changes: 9 additions & 1 deletion functions/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const { defineSecret } = require('firebase-functions/params')
const { initializeApp, getApp } = require('firebase-admin/app')
const { getFirestore, FieldValue } = require('firebase-admin/firestore')
const crypto = require('crypto')
const { checkRecipient } = require('./smsGuard')

initializeApp()

Expand Down Expand Up @@ -125,13 +126,20 @@ exports.sendReminders = onSchedule({
if (!claimed) continue

try {
// Never text a lead who replied STOP, or a number no opt-out could match.
const { to, stop } = await checkRecipient(db, r.uid, r.to)
if (stop) {
if (stop.status === 'failed') failed++
await doc.ref.update(stop)
continue
}
const chSnap = await db
.collection('users').doc(r.uid)
.collection('channels').doc('sms')
.get()
if (!chSnap.exists || !chSnap.data().connected) throw new Error('sms channel not connected')
const creds = JSON.parse(decrypt(chSnap.data().encryptedCreds, encKey, prevKey))
await sendSMS(creds, r.to, r.body)
await sendSMS(creds, to, r.body)
sent++
} catch (err) {
failed++
Expand Down
24 changes: 24 additions & 0 deletions functions/smsGuard.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
'use strict'
// Opt-out guard for sendReminders. Mirrors normalizeE164 in
// lib/services/reminderService.js — the functions package can't import lib/.

// Twilio reports STOP senders in E.164 and smsSuppressed is keyed by that, so a
// number in any other format could never match an opt-out. No country-code guessing.
function normalizeE164(raw) {
const compact = String(raw ?? '').replace(/\s+/g, '')
return /^\+[1-9]\d{7,14}$/.test(compact) ? compact : null
}

// Returns the number to send to, or the status update that stops the send.
async function checkRecipient(db, uid, rawTo) {
const to = normalizeE164(rawTo)
if (!to) return { to: null, stop: { status: 'failed', error: 'invalid number' } }
const suppressed = await db
.collection('users').doc(uid)
.collection('smsSuppressed').doc(to)
.get()
if (suppressed.exists) return { to, stop: { status: 'skipped', error: 'recipient opted out' } }
return { to, stop: null }
}

module.exports = { normalizeE164, checkRecipient }
14 changes: 9 additions & 5 deletions graphify-out/.graphify_labels.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"0": "react",
"1": "dependencies",
"2": "best/[slug]/page.js",
"2": "app/layout.js",
"3": "authFetch",
"4": "backend_test.py",
"5": "sidebar.jsx",
Expand Down Expand Up @@ -39,9 +39,9 @@
"37": "Project: DMForge — AI DM setter builder + Stripe-enabled SaaS",
"38": "ref_node_url",
"39": "scripts",
"40": "app/layout.js",
"40": "analytics.js",
"41": "encryption.js",
"42": "Use cases by area",
"42": "getAdminDb",
"43": "webhookAuth.js",
"44": "contact/page.js",
"45": "privacy/page.js",
Expand All @@ -61,17 +61,21 @@
"59": "vercel.json",
"60": "email.js",
"61": "DMForge Deployment Runbook",
"62": "[id]/page.js",
"62": "health/route.js",
"63": "Bug Report Template",
"64": "Feature Request Template",
"65": "Pull Request Template",
"66": "Legal & Regulatory Compliance Notes",
"67": "postcss.config.js",
"68": "useAuth",
"69": "__init__.py",
"70": "accordion.jsx",
"71": "about/page.js",
"72": "tabs.jsx",
"73": "smsGuard.test.js",
"74": "complaints/page.js",
"75": "data-deletion/page.js",
"83": "DMForge E2E — real-browser click tests (Playwright)",
"89": "Security Policy",
"93": "vitest.config.mjs",
"94": "CLEAN_CODE_REPORT.md",
"95": "engines",
Expand Down
Loading
Loading