Relationship CRM
Weekly reminders and honest drafts to keep a large LinkedIn network warm.
Next.js 16React 19TypeScriptTailwind CSS 4Supabase (Postgres, RLS, Auth)Vercel
Problem
Hundreds of LinkedIn connections and no system for staying in touch means relationships go cold silently, and the natural moments to reach out, like a job change, pass unnoticed. Off-the-shelf fixes either scrape LinkedIn, a real account-ban risk, or demand manual data entry nobody keeps up. Built as a gift for a well-networked friend, so simplicity for the recipient was the top constraint.
What I built
A Next.js 16 + Supabase app that imports LinkedIn's official Connections.csv export, buckets the network into Overdue / Due soon / Not yet confirmed / On track, and produces a weekly "This week" digest of eight people to reach out to, warmest-overdue-first, each with a copy-ready draft and one-tap Sent / Snooze / Skip triage. The importer is a hand-written CSV tokenizer that survives LinkedIn's multi-line preamble, quoted fields and BOM, and keys contacts on profile URL so re-uploading the same export is idempotent. That re-upload doubles as job-change detection: URL-matched rows whose company or title moved become change events that jump to the top of the digest with a congrats draft, with no enrichment API or scraping involved. Drafts are pure, deterministic template functions written as drop-ins for an LLM later. Every route except sign-in sits behind an email magic-link gate with an allowlist, enforced in the Next 16 proxy, and the database is RLS-locked with no client policies, so only the server's service-role client touches it.
Highlights
- Job-change detection without scraping: diffs company/title between two LinkedIn exports on the profile-URL key and only flags a change when both old and new values exist, so silent backfills and privacy-blanked fields never fire. Zero false positives on a re-upload of a real 970-row export.
- Honest drafts as a rule, not a prompt: a connection date is never treated as a last-contact date, so unconfirmed contacts get "we connected on LinkedIn X ago" rather than a fictional "since we last caught up". A migration nulled the earlier mistaken backfill while preserving every real logged interaction.
- Weekly digest with no scheduler: generation is lazy, so a fresh batch of 8 is created only when nothing is pending and the last batch is more than 6 days old; a 14-day exclusion horizon stops Skip/Sent contacts resurfacing, and 30-day snooze / 90-day skip share a single snoozed_until column.
- Data-exposure guards in code: a magic-link sign-in gate with an ALLOWED_EMAILS allowlist in Next 16's proxy.ts covers every route except sign-in; RLS is on with zero client policies; DB-reading pages are force-dynamic after catching that Next bakes a data snapshot into static HTML at build time.
- Official export only, by design: PhantomBuster, session-cookie scraping and a background-scanning Chrome extension were all rejected as LinkedIn ban risks, so the data path is a human-requested CSV export, and re-uploading the next export is the refresh mechanism.
Numbers
- CSV parser and importer validated against a real 970-connection LinkedIn export
- 0 false-positive job changes on a re-upload of the full 970-row export
- 7 of 970 rows in the test export carried an email address, which is why profile URL is the dedup key
- Digest batch of 8, regenerated after 6 days; 14-day exclusion horizon; 30-day snooze; 90-day skip