Kin: personal relationships app — server (cadence/due/migrations) + Expo Android app
Some checks failed
Build & Release APK / build (push) Failing after 13s

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-09 00:49:45 -07:00
commit ac0b82e6a0
101 changed files with 11534 additions and 0 deletions

3
server/.dockerignore Normal file
View File

@@ -0,0 +1,3 @@
node_modules
.env
.env.token.local

5
server/.env.example Normal file
View File

@@ -0,0 +1,5 @@
# Copy to .env on the server. Never commit real values.
DATABASE_URL=postgres://marcus:CHANGE_ME@personal-db:5432/personal
PORT=3000
# Bearer token the mobile app authenticates with (any long random string).
API_TOKEN=CHANGE_ME

16
server/Dockerfile Normal file
View File

@@ -0,0 +1,16 @@
FROM oven/bun:1-alpine
WORKDIR /app
# Install deps first for better layer caching.
COPY package.json ./
RUN bun install
# App source
COPY src ./src
COPY migrations ./migrations
COPY public ./public
EXPOSE 3000
CMD ["bun", "run", "src/index.ts"]

22
server/docker-compose.yml Normal file
View File

@@ -0,0 +1,22 @@
services:
crm:
build: .
container_name: crm
restart: unless-stopped
env_file: .env
networks:
# Reach Postgres by service name `personal-db` on its network.
- personal-db_default
# Be reachable by paico-proxy for crm.rehbock.xyz.
- caddy_net
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/healthz"]
interval: 30s
timeout: 5s
retries: 3
networks:
personal-db_default:
external: true
caddy_net:
external: true

View File

@@ -0,0 +1,73 @@
-- Baseline: the schema that existed in the live `personal` DB before this repo
-- had migrations. Fully idempotent so it no-ops against the live DB and builds
-- everything from scratch on a fresh install.
create extension if not exists "uuid-ossp";
create or replace function set_updated_at() returns trigger as $$
begin
new.updated_at = now();
return new;
end;
$$ language plpgsql;
create table if not exists people (
id uuid primary key default uuid_generate_v4(),
full_name text not null,
first_name text,
last_name text,
email text,
phone text,
tags text[] not null default '{}',
notes text,
location text,
source text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create unique index if not exists people_email_lower_uniq
on people (lower(email)) where email is not null;
create index if not exists people_tags_gin on people using gin (tags);
create table if not exists interactions (
id uuid primary key default uuid_generate_v4(),
person_id uuid not null references people(id) on delete cascade,
type text,
occurred_at timestamptz not null default now(),
summary text,
notes text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index if not exists interactions_person_idx on interactions (person_id);
create index if not exists interactions_occurred_at_idx on interactions (occurred_at desc);
create table if not exists relationships (
id uuid primary key default uuid_generate_v4(),
from_person_id uuid not null references people(id) on delete cascade,
to_person_id uuid not null references people(id) on delete cascade,
type text not null,
notes text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint relationships_no_self check (from_person_id <> to_person_id),
constraint relationships_unique unique (from_person_id, to_person_id, type)
);
create index if not exists relationships_from_idx on relationships (from_person_id);
create index if not exists relationships_to_idx on relationships (to_person_id);
-- Triggers: drop-and-recreate is the only idempotent form pre-PG14.
drop trigger if exists people_set_updated_at on people;
create trigger people_set_updated_at before update on people
for each row execute function set_updated_at();
drop trigger if exists interactions_set_updated_at on interactions;
create trigger interactions_set_updated_at before update on interactions
for each row execute function set_updated_at();
drop trigger if exists relationships_set_updated_at on relationships;
create trigger relationships_set_updated_at before update on relationships
for each row execute function set_updated_at();

View File

@@ -0,0 +1,11 @@
-- Relational-wealth fields: how often I want to be in touch with each person,
-- and enough state to drive the "who's due" list.
alter table people add column if not exists cadence_days integer,
add column if not exists snoozed_until date,
add column if not exists archived boolean not null default false,
add column if not exists birthday date;
comment on column people.cadence_days is 'Target days between contacts; null = no reminder for this person';
comment on column people.snoozed_until is 'Hide from the due list until this date';
comment on column people.archived is 'Hidden from lists and due computation, kept for history';

15
server/package.json Normal file
View File

@@ -0,0 +1,15 @@
{
"name": "kin-server",
"version": "1.0.0",
"description": "Kin — personal relationships CRM: contacts, interactions, cadence over Postgres",
"type": "module",
"module": "src/index.ts",
"scripts": {
"dev": "bun --watch run src/index.ts",
"start": "bun run src/index.ts"
},
"dependencies": {
"hono": "^4.6.14",
"postgres": "^3.4.5"
}
}

232
server/public/app.js Normal file
View File

@@ -0,0 +1,232 @@
// --- tiny helpers ---------------------------------------------------------
const $ = (sel) => document.querySelector(sel);
const el = (tag, props = {}, ...kids) => {
const n = Object.assign(document.createElement(tag), props);
for (const k of kids) n.append(k?.nodeType ? k : document.createTextNode(k ?? ""));
return n;
};
const api = async (path, opts) => {
const res = await fetch("/api" + path, {
headers: { "Content-Type": "application/json" },
...opts,
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || res.statusText);
}
return res.status === 204 ? null : res.json();
};
function toast(msg) {
let t = $(".toast");
if (!t) { t = el("div", { className: "toast" }); document.body.append(t); }
t.textContent = msg;
t.classList.add("show");
clearTimeout(t._timer);
t._timer = setTimeout(() => t.classList.remove("show"), 2200);
}
function relTime(iso) {
if (!iso) return "never";
const d = new Date(iso), now = new Date();
const days = Math.floor((now - d) / 86400000);
if (days <= 0) return "today";
if (days === 1) return "yesterday";
if (days < 30) return days + "d ago";
if (days < 365) return Math.floor(days / 30) + "mo ago";
return Math.floor(days / 365) + "y ago";
}
const fmtDate = (iso) => (iso ? new Date(iso).toLocaleDateString() : "");
// --- state ----------------------------------------------------------------
let state = { q: "", tag: "", sort: "name", selectedId: null };
// --- list -----------------------------------------------------------------
async function refresh() {
const [stats, list] = await Promise.all([
api("/stats"),
api(`/people?q=${encodeURIComponent(state.q)}&tag=${encodeURIComponent(state.tag)}&sort=${state.sort}`),
]);
$("#stats").textContent = `${stats.people} contacts · ${stats.interactions} interactions · ${stats.relationships} links`;
renderList(list);
}
function renderList(list) {
const ul = $("#list");
ul.innerHTML = "";
if (!list.length) { ul.append(el("li", { className: "hint" }, "No matches.")); return; }
for (const p of list) {
const meta = el("div", { className: "meta" },
el("span", {}, `📇 ${relTime(p.last_contacted)}`),
p.location ? el("span", {}, `📍 ${p.location}`) : "",
p.interaction_count ? el("span", {}, `${p.interaction_count}×`) : "",
);
(p.tags || []).forEach((t) => meta.append(el("span", { className: "tagchip" }, t)));
const li = el("li", { className: p.id === state.selectedId ? "active" : "" },
el("span", { className: "name" }, p.full_name),
meta,
);
li.onclick = () => selectPerson(p.id);
ul.append(li);
}
}
async function loadTags() {
const tags = await api("/tags");
const sel = $("#tag");
sel.innerHTML = '<option value="">All tags</option>';
tags.forEach((t) => sel.append(el("option", { value: t.tag }, `${t.tag} (${t.count})`)));
sel.value = state.tag;
}
// --- detail ---------------------------------------------------------------
async function selectPerson(id) {
state.selectedId = id;
document.querySelectorAll(".list li").forEach((li) => li.classList.remove("active"));
const p = await api(`/people/${id}`);
renderDetail(p);
refresh();
}
function field(label, input) {
return el("div", { className: "row" }, el("label", {}, label), el("div", { className: "field" }, input));
}
function renderDetail(p) {
const d = $("#detail");
d.innerHTML = "";
// --- editable info block ---
const nameI = el("input", { value: p.full_name });
const emailI = el("input", { value: p.email || "", type: "email" });
const phoneI = el("input", { value: p.phone || "", type: "tel", placeholder: "+61…" });
const locationI = el("input", { value: p.location || "", placeholder: "City, country" });
const tagsI = el("input", { value: (p.tags || []).join(", ") });
const notesI = el("textarea", { value: p.notes || "" });
const save = el("button", { className: "primary" }, "Save");
save.onclick = async () => {
try {
await api(`/people/${p.id}`, {
method: "PATCH",
body: JSON.stringify({ full_name: nameI.value, email: emailI.value, phone: phoneI.value, location: locationI.value, tags: tagsI.value, notes: notesI.value }),
});
toast("Saved");
await loadTags();
selectPerson(p.id);
} catch (e) { toast(e.message); }
};
const del = el("button", { className: "danger" }, "Delete");
del.onclick = async () => {
if (!confirm(`Delete ${p.full_name}? This removes their interactions too.`)) return;
try {
await api(`/people/${p.id}`, { method: "DELETE" });
toast("Deleted");
state.selectedId = null;
d.innerHTML = '<p class="empty">Select a contact, or add a new one.</p>';
loadTags(); refresh();
} catch (e) { toast(e.message); }
};
const info = el("section", { className: "block" },
el("h2", {}, p.full_name),
el("p", { className: "sub" }, `Last contacted ${relTime(p.last_contacted || (p.interactions[0]?.occurred_at))}`),
field("Name", nameI), field("Email", emailI), field("Phone", phoneI), field("Location", locationI), field("Tags", tagsI), field("Notes", notesI),
el("div", { className: "actions" }, save, del),
);
d.append(info);
// --- relationships ---
if (p.relationships?.length) {
const rl = el("ul", { className: "timeline" });
p.relationships.forEach((r) => {
const link = el("a", { href: "#", style: "color:var(--accent)" }, r.other_name);
link.onclick = (e) => { e.preventDefault(); selectPerson(r.other_id); };
rl.append(el("li", {}, el("span", { className: "badge" }, r.type), " ", link, r.notes ? `${r.notes}` : ""));
});
d.append(el("section", { className: "block" }, el("h3", {}, "Relationships"), rl));
}
// --- interactions ---
const typeI = el("select", {},
...["call", "message", "email", "meetup", "note", "other"].map((t) => el("option", { value: t }, t)));
const whenI = el("input", { type: "date", value: new Date().toISOString().slice(0, 10) });
const summaryI = el("input", { placeholder: "What happened? (short summary)" });
const inotesI = el("textarea", { placeholder: "Details (optional)" });
const logBtn = el("button", { className: "primary" }, "Log interaction");
logBtn.onclick = async () => {
if (!summaryI.value.trim() && !inotesI.value.trim()) return toast("Add a summary first");
try {
await api(`/people/${p.id}/interactions`, {
method: "POST",
body: JSON.stringify({ type: typeI.value, occurred_at: whenI.value, summary: summaryI.value, notes: inotesI.value }),
});
toast("Logged");
selectPerson(p.id);
} catch (e) { toast(e.message); }
};
const timeline = el("ul", { className: "timeline" });
if (!p.interactions.length) timeline.append(el("li", { className: "hint" }, "No interactions logged yet."));
p.interactions.forEach((i) => {
const delI = el("button", { className: "ghost", style: "float:right;color:var(--muted)" }, "✕");
delI.onclick = async () => {
if (!confirm("Delete this interaction?")) return;
await api(`/interactions/${i.id}`, { method: "DELETE" });
toast("Removed"); selectPerson(p.id);
};
timeline.append(el("li", {},
delI,
el("div", { className: "when" }, `${fmtDate(i.occurred_at)} · `, el("span", { className: "badge" }, i.type || "note")),
el("div", {}, i.summary || ""),
i.notes ? el("div", { className: "hint" }, i.notes) : "",
));
});
d.append(el("section", { className: "block" },
el("h3", {}, "Log an interaction"),
el("div", { className: "inline-form" }, field("Type", typeI), field("Date", whenI), field("Summary", summaryI), field("Notes", inotesI), logBtn),
));
d.append(el("section", { className: "block" }, el("h3", {}, "History"), timeline));
}
// --- add contact ----------------------------------------------------------
function showAddForm() {
state.selectedId = null;
document.querySelectorAll(".list li").forEach((li) => li.classList.remove("active"));
const d = $("#detail");
d.innerHTML = "";
const nameI = el("input", { placeholder: "Full name *" });
const emailI = el("input", { type: "email", placeholder: "email@example.com" });
const phoneI = el("input", { type: "tel", placeholder: "+61…" });
const locationI = el("input", { placeholder: "City, country" });
const tagsI = el("input", { placeholder: "comma, separated, tags" });
const notesI = el("textarea", { placeholder: "Notes" });
const create = el("button", { className: "primary" }, "Create contact");
create.onclick = async () => {
if (!nameI.value.trim()) return toast("Name is required");
try {
const p = await api("/people", {
method: "POST",
body: JSON.stringify({ full_name: nameI.value, email: emailI.value, phone: phoneI.value, location: locationI.value, tags: tagsI.value, notes: notesI.value }),
});
toast("Created");
await loadTags();
selectPerson(p.id);
} catch (e) { toast(e.message); }
};
d.append(el("section", { className: "block" },
el("h3", {}, "New contact"),
field("Name", nameI), field("Email", emailI), field("Phone", phoneI), field("Location", locationI), field("Tags", tagsI), field("Notes", notesI),
el("div", { className: "actions" }, create),
));
}
// --- wire up --------------------------------------------------------------
let searchTimer;
$("#search").oninput = (e) => { state.q = e.target.value; clearTimeout(searchTimer); searchTimer = setTimeout(refresh, 200); };
$("#tag").onchange = (e) => { state.tag = e.target.value; refresh(); };
$("#sort").onchange = (e) => { state.sort = e.target.value; refresh(); };
$("#add-btn").onclick = showAddForm;
loadTags();
refresh();

34
server/public/index.html Normal file
View File

@@ -0,0 +1,34 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Personal CRM</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<header>
<h1>Personal CRM</h1>
<div class="stats" id="stats"></div>
<div class="controls">
<input id="search" type="search" placeholder="Search name or email…" autocomplete="off" />
<select id="tag"><option value="">All tags</option></select>
<select id="sort">
<option value="name">Sort: Name</option>
<option value="recent">Sort: Recently contacted</option>
<option value="stale">Sort: Out of touch</option>
</select>
<button id="add-btn" class="primary">+ Contact</button>
</div>
</header>
<main>
<ul id="list" class="list"></ul>
<section id="detail" class="detail">
<p class="empty">Select a contact, or add a new one.</p>
</section>
</main>
<script src="/app.js"></script>
</body>
</html>

102
server/public/styles.css Normal file
View File

@@ -0,0 +1,102 @@
:root {
--bg: #0f1115;
--panel: #171a21;
--panel-2: #1e222b;
--border: #2a2f3a;
--text: #e6e8ec;
--muted: #8b93a1;
--accent: #4f8cff;
--accent-2: #2d6ae0;
--danger: #e5484d;
--radius: 10px;
}
* { box-sizing: border-box; }
body {
margin: 0;
font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--text);
}
header {
padding: 16px 20px;
border-bottom: 1px solid var(--border);
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px 16px;
background: var(--panel);
position: sticky;
top: 0;
z-index: 5;
}
header h1 { font-size: 18px; margin: 0; }
.stats { color: var(--muted); font-size: 13px; }
.controls { margin-left: auto; display: flex; gap: 8px; flex-wrap: wrap; }
input, select, textarea, button {
font: inherit;
color: var(--text);
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px 10px;
}
input:focus, select:focus, textarea:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
button { cursor: pointer; }
button.primary { background: var(--accent); border-color: var(--accent-2); color: #fff; font-weight: 600; }
button.primary:hover { background: var(--accent-2); }
button.ghost { background: transparent; }
button.danger { color: var(--danger); border-color: var(--danger); background: transparent; }
main { display: grid; grid-template-columns: 340px 1fr; min-height: calc(100vh - 66px); }
.list { list-style: none; margin: 0; padding: 8px; border-right: 1px solid var(--border); overflow-y: auto; }
.list li {
padding: 10px 12px;
border-radius: 8px;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 3px;
}
.list li:hover { background: var(--panel); }
.list li.active { background: var(--panel-2); outline: 1px solid var(--accent); }
.list .name { font-weight: 600; }
.list .meta { font-size: 12px; color: var(--muted); display: flex; gap: 8px; flex-wrap: wrap; }
.tagchip { font-size: 11px; padding: 1px 7px; border-radius: 999px; background: #23324d; color: #9dbcff; }
.detail { padding: 24px; overflow-y: auto; }
.detail .empty { color: var(--muted); }
.detail h2 { margin: 0 0 4px; }
.detail .sub { color: var(--muted); margin: 0 0 16px; }
.detail section.block { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; margin-bottom: 16px; }
.detail section.block h3 { margin: 0 0 12px; font-size: 13px; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); }
.row { display: flex; gap: 10px; margin-bottom: 10px; }
.row label { flex: 0 0 110px; color: var(--muted); padding-top: 8px; }
.row .field { flex: 1; }
.field input, .field textarea, .field select { width: 100%; }
textarea { resize: vertical; min-height: 60px; }
.timeline { list-style: none; margin: 0; padding: 0; }
.timeline li { padding: 10px 0; border-top: 1px solid var(--border); }
.timeline li:first-child { border-top: none; }
.timeline .when { font-size: 12px; color: var(--muted); }
.badge { font-size: 11px; padding: 1px 8px; border-radius: 999px; background: var(--panel-2); border: 1px solid var(--border); }
.actions { display: flex; gap: 8px; margin-top: 8px; }
.inline-form { display: grid; gap: 8px; }
.hint { color: var(--muted); font-size: 12px; }
.toast {
position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
background: var(--panel-2); border: 1px solid var(--border); padding: 10px 16px;
border-radius: 8px; opacity: 0; transition: opacity .2s; pointer-events: none;
}
.toast.show { opacity: 1; }
@media (max-width: 720px) {
main { grid-template-columns: 1fr; }
.list { max-height: 40vh; }
}

13
server/src/db.ts Normal file
View File

@@ -0,0 +1,13 @@
import postgres from "postgres";
const url = process.env.DATABASE_URL;
if (!url) throw new Error("DATABASE_URL is not set");
// Single shared connection pool for the app.
const sql = postgres(url, {
max: 5,
idle_timeout: 30,
onnotice: () => {}, // silence NOTICE spam
});
export default sql;

46
server/src/index.ts Normal file
View File

@@ -0,0 +1,46 @@
import { Hono } from "hono";
import { serveStatic } from "hono/bun";
import { logger } from "hono/logger";
import { migrate } from "./migrate";
import people from "./routes/people";
import due from "./routes/due";
const app = new Hono();
app.use("*", logger());
// Health check — used by the container healthcheck.
app.get("/healthz", (c) => c.text("ok"));
// API auth. Two ways in, matching the Caddy config for crm.rehbock.xyz:
// - Browser/web UI: Caddy enforces basic_auth before proxying, so requests
// without a Bearer header have already been authenticated upstream.
// - Mobile app: sends `Authorization: Bearer <API_TOKEN>`; Caddy passes
// Bearer requests straight through and WE are the auth layer, so any
// Bearer value that doesn't match the token is rejected here.
// Direct access on the Docker networks is unauthenticated by design — the
// port is never published on the host.
const API_TOKEN = process.env.API_TOKEN;
app.use("/api/*", async (c, next) => {
const auth = c.req.header("authorization") ?? "";
if (auth.startsWith("Bearer ")) {
if (!API_TOKEN || auth !== `Bearer ${API_TOKEN}`) {
return c.json({ error: "unauthorized" }, 401);
}
}
await next();
});
// API
app.route("/api", due);
app.route("/api", people);
// Static front end (public/)
app.use("/*", serveStatic({ root: "./public" }));
await migrate();
const port = Number(process.env.PORT ?? 3000);
console.log(`kin server listening on :${port}`);
export default { port, fetch: app.fetch };

31
server/src/migrate.ts Normal file
View File

@@ -0,0 +1,31 @@
import { readdir, readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import sql from "./db";
const MIGRATIONS_DIR = fileURLToPath(new URL("../migrations", import.meta.url));
// Applies migrations/*.sql in filename order, once each, recorded in
// schema_migrations. Runs at boot before the server starts listening.
export async function migrate() {
await sql`
create table if not exists schema_migrations (
name text primary key,
applied_at timestamptz not null default now()
)
`;
const files = (await readdir(MIGRATIONS_DIR)).filter((f) => f.endsWith(".sql")).sort();
const applied = new Set(
(await sql`select name from schema_migrations`).map((r) => r.name)
);
for (const file of files) {
if (applied.has(file)) continue;
const text = await readFile(`${MIGRATIONS_DIR}/${file}`, "utf8");
await sql.begin(async (tx) => {
await tx.unsafe(text);
await tx`insert into schema_migrations (name) values (${file})`;
});
console.log(`migrated: ${file}`);
}
}

65
server/src/routes/due.ts Normal file
View File

@@ -0,0 +1,65 @@
import { Hono } from "hono";
import sql from "../db";
const due = new Hono();
// The heart of the app: everyone with a cadence, annotated with how overdue
// they are. `urgency` = days_since / cadence_days (>= 1 means overdue), so a
// weekly friend 3 days late outranks a yearly contact 3 days late.
// People never contacted anchor on created_at so they surface immediately.
due.get("/due", async (c) => {
const rows = await sql`
SELECT
p.id, p.full_name, p.email, p.phone, p.tags, p.location,
p.cadence_days, p.snoozed_until, p.birthday,
li.last_contacted, li.last_type,
COALESCE(li.interaction_count, 0)::int AS interaction_count,
GREATEST(0, EXTRACT(epoch FROM now() - COALESCE(li.last_contacted, p.created_at)) / 86400)::int AS days_since
FROM people p
LEFT JOIN (
SELECT person_id,
max(occurred_at) AS last_contacted,
(array_agg(type ORDER BY occurred_at DESC))[1] AS last_type,
count(*) AS interaction_count
FROM interactions
GROUP BY person_id
) li ON li.person_id = p.id
WHERE NOT p.archived
AND p.cadence_days IS NOT NULL
`;
const today = new Date().toISOString().slice(0, 10);
const out = rows
.map((r) => {
const urgency = r.days_since / r.cadence_days;
const snoozed = r.snoozed_until != null && String(r.snoozed_until) > today;
return {
...r,
urgency: Math.round(urgency * 100) / 100,
due_in_days: Math.ceil(r.cadence_days - r.days_since),
snoozed,
status: snoozed ? "snoozed" : urgency >= 1 ? "overdue" : urgency >= 0.75 ? "due_soon" : "ok",
};
})
.sort((a, b) => b.urgency - a.urgency);
return c.json(out);
});
// Snooze someone off the due list for N days (default 7). days=0 unsnoozes.
due.post("/people/:id/snooze", async (c) => {
const id = c.req.param("id");
const body = await c.req.json().catch(() => ({}));
const days = Number.isFinite(Number(body.days)) ? Number(body.days) : 7;
const until =
days <= 0 ? null : new Date(Date.now() + days * 86400_000).toISOString().slice(0, 10);
const [row] = await sql`
UPDATE people SET snoozed_until = ${until} WHERE id = ${id}
RETURNING id, snoozed_until
`;
if (!row) return c.json({ error: "not found" }, 404);
return c.json(row);
});
export default due;

244
server/src/routes/people.ts Normal file
View File

@@ -0,0 +1,244 @@
import { Hono } from "hono";
import sql from "../db";
const people = new Hono();
// --- helpers -------------------------------------------------------------
// Coerce an incoming tags value into a clean string[].
function normalizeTags(input: unknown): string[] {
if (Array.isArray(input)) return input.map((t) => String(t).trim()).filter(Boolean);
if (typeof input === "string") {
return input.split(",").map((t) => t.trim()).filter(Boolean);
}
return [];
}
// --- stats ---------------------------------------------------------------
people.get("/stats", async (c) => {
const [row] = await sql`
SELECT
(SELECT count(*) FROM people WHERE NOT archived) AS people,
(SELECT count(*) FROM interactions) AS interactions,
(SELECT count(*) FROM relationships) AS relationships,
(SELECT count(*) FROM people WHERE NOT archived AND cadence_days IS NOT NULL) AS with_cadence
`;
return c.json(row);
});
// Distinct tags across all people, with counts.
people.get("/tags", async (c) => {
const rows = await sql`
SELECT tag, count(*)::int AS count
FROM people, unnest(tags) AS tag
WHERE NOT archived
GROUP BY tag
ORDER BY count DESC, tag ASC
`;
return c.json(rows);
});
// --- list ----------------------------------------------------------------
// Supports ?q=search &tag=filter &sort=name|recent|stale &archived=1
people.get("/people", async (c) => {
const q = (c.req.query("q") ?? "").trim();
const tag = (c.req.query("tag") ?? "").trim();
const sort = c.req.query("sort") ?? "name";
const includeArchived = c.req.query("archived") === "1";
const search = q ? `%${q}%` : null;
const orderBy =
sort === "recent"
? sql`last_contacted DESC NULLS LAST, full_name ASC`
: sort === "stale"
? sql`last_contacted ASC NULLS FIRST, full_name ASC`
: sql`full_name ASC`;
const rows = await sql`
SELECT
p.id, p.full_name, p.email, p.phone, p.tags, p.location,
p.cadence_days, p.snoozed_until, p.archived, p.birthday,
li.last_contacted,
COALESCE(li.interaction_count, 0)::int AS interaction_count
FROM people p
LEFT JOIN (
SELECT person_id,
max(occurred_at) AS last_contacted,
count(*) AS interaction_count
FROM interactions
GROUP BY person_id
) li ON li.person_id = p.id
WHERE (${includeArchived} OR NOT p.archived)
AND (${search}::text IS NULL OR p.full_name ILIKE ${search} OR p.email ILIKE ${search} OR p.phone ILIKE ${search} OR p.location ILIKE ${search})
AND (${tag || null}::text IS NULL OR ${tag} = ANY(p.tags))
ORDER BY ${orderBy}
`;
return c.json(rows);
});
// --- single (with interactions + relationships) --------------------------
people.get("/people/:id", async (c) => {
const id = c.req.param("id");
const [person] = await sql`SELECT * FROM people WHERE id = ${id}`;
if (!person) return c.json({ error: "not found" }, 404);
const interactions = await sql`
SELECT id, type, occurred_at, summary, notes
FROM interactions
WHERE person_id = ${id}
ORDER BY occurred_at DESC
`;
// Relationships in both directions, resolved to the other person's name.
const relationships = await sql`
SELECT r.id, r.type, r.notes,
other.id AS other_id, other.full_name AS other_name,
(r.from_person_id = ${id}) AS outgoing
FROM relationships r
JOIN people other
ON other.id = CASE WHEN r.from_person_id = ${id} THEN r.to_person_id ELSE r.from_person_id END
WHERE r.from_person_id = ${id} OR r.to_person_id = ${id}
ORDER BY other.full_name
`;
return c.json({ ...person, interactions, relationships });
});
// --- create --------------------------------------------------------------
people.post("/people", async (c) => {
const body = await c.req.json().catch(() => ({}));
const full_name = String(body.full_name ?? "").trim();
if (!full_name) return c.json({ error: "full_name is required" }, 400);
const tags = normalizeTags(body.tags);
const cadence = Number.isFinite(Number(body.cadence_days)) && Number(body.cadence_days) > 0
? Math.round(Number(body.cadence_days))
: null;
try {
const [row] = await sql`
INSERT INTO people (full_name, first_name, last_name, email, phone, tags, notes, location, source, cadence_days, birthday)
VALUES (
${full_name},
${body.first_name || null},
${body.last_name || null},
${body.email || null},
${body.phone || null},
${tags},
${body.notes || null},
${body.location || null},
${body.source || "crm"},
${cadence},
${body.birthday || null}
)
RETURNING *
`;
return c.json(row, 201);
} catch (err: any) {
if (err?.code === "23505") return c.json({ error: "a contact with that email already exists" }, 409);
throw err;
}
});
// --- update --------------------------------------------------------------
people.patch("/people/:id", async (c) => {
const id = c.req.param("id");
const body = await c.req.json().catch(() => ({}));
// Build a partial update from only the fields provided.
const fields: Record<string, unknown> = {};
for (const k of ["full_name", "first_name", "last_name", "email", "phone", "notes", "location", "birthday", "snoozed_until"]) {
if (k in body) fields[k] = body[k] === "" ? null : body[k];
}
if ("tags" in body) fields.tags = normalizeTags(body.tags);
if ("archived" in body) fields.archived = Boolean(body.archived);
if ("cadence_days" in body) {
const n = Number(body.cadence_days);
fields.cadence_days = Number.isFinite(n) && n > 0 ? Math.round(n) : null;
}
if (Object.keys(fields).length === 0) return c.json({ error: "nothing to update" }, 400);
try {
const [row] = await sql`
UPDATE people SET ${sql(fields)} WHERE id = ${id} RETURNING *
`;
if (!row) return c.json({ error: "not found" }, 404);
return c.json(row);
} catch (err: any) {
if (err?.code === "23505") return c.json({ error: "a contact with that email already exists" }, 409);
throw err;
}
});
// --- delete --------------------------------------------------------------
people.delete("/people/:id", async (c) => {
const id = c.req.param("id");
const [row] = await sql`DELETE FROM people WHERE id = ${id} RETURNING id`;
if (!row) return c.json({ error: "not found" }, 404);
return c.json({ deleted: row.id });
});
// --- add interaction -----------------------------------------------------
people.post("/people/:id/interactions", async (c) => {
const id = c.req.param("id");
const body = await c.req.json().catch(() => ({}));
const [person] = await sql`SELECT id FROM people WHERE id = ${id}`;
if (!person) return c.json({ error: "person not found" }, 404);
const [row] = await sql`
INSERT INTO interactions (person_id, type, occurred_at, summary, notes)
VALUES (
${id},
${body.type || null},
${body.occurred_at ? new Date(body.occurred_at) : sql`now()`},
${body.summary || null},
${body.notes || null}
)
RETURNING *
`;
// Logging contact naturally clears any snooze.
await sql`UPDATE people SET snoozed_until = NULL WHERE id = ${id} AND snoozed_until IS NOT NULL`;
return c.json(row, 201);
});
// --- delete interaction --------------------------------------------------
people.delete("/interactions/:id", async (c) => {
const id = c.req.param("id");
const [row] = await sql`DELETE FROM interactions WHERE id = ${id} RETURNING id`;
if (!row) return c.json({ error: "not found" }, 404);
return c.json({ deleted: row.id });
});
// --- relationships (create/delete — the old web CRM was read-only here) ---
people.post("/people/:id/relationships", async (c) => {
const id = c.req.param("id");
const body = await c.req.json().catch(() => ({}));
const to = String(body.to_person_id ?? "").trim();
const type = String(body.type ?? "").trim();
if (!to || !type) return c.json({ error: "to_person_id and type are required" }, 400);
try {
const [row] = await sql`
INSERT INTO relationships (from_person_id, to_person_id, type, notes)
VALUES (${id}, ${to}, ${type}, ${body.notes || null})
RETURNING *
`;
return c.json(row, 201);
} catch (err: any) {
if (err?.code === "23505") return c.json({ error: "that relationship already exists" }, 409);
if (err?.code === "23503") return c.json({ error: "person not found" }, 404);
if (err?.code === "23514") return c.json({ error: "cannot relate a person to themselves" }, 400);
throw err;
}
});
people.delete("/relationships/:id", async (c) => {
const id = c.req.param("id");
const [row] = await sql`DELETE FROM relationships WHERE id = ${id} RETURNING id`;
if (!row) return c.json({ error: "not found" }, 404);
return c.json({ deleted: row.id });
});
export default people;