Skip to content
Merged
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
13 changes: 10 additions & 3 deletions apps/local-api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,13 @@ browser code sends same-origin `/api` requests. Non-2xx errors are
| POST | `/api/auth/login` | `{email,password}` | `{user}` + cookie |
| POST | `/api/auth/logout` | `{}` | `{ok:true}` + cleared cookie |
| GET | `/api/auth/me` | public | `{user}` or `{user:null}` |
| GET | `/api/communes` | | `{communes:[...]}` |
| GET | `/api/communes` | | `{communes:[{id,name,kind,currency,role,memberCount,address,destination,stayName,stayAddress,changeMark}]}`; a commune's `changeMark` moves whenever anything in it changes |
| GET | `/api/search?q=...&commune=...&limit=8` | session required; optional commune UUID; 2–80 chars, 1–25 results per type | `{query,items,total,hasMore}`; only active memberships, safe note excerpts |
| POST | `/api/communes` | `{name,kind,currency,address?,destination?,stayName?,stayAddress?}` | `{id}` |
| POST | `/api/communes` | `{name,kind,currency,address?,destination?,stayName?,stayAddress?}`; one account creates at most 10 communes an hour, 30 a day and 100 in all (409) | `{id}` |
| PATCH | `/api/communes/:id` | `{name,kind,currency,address?,destination?,stayName?,stayAddress?}` admin; the currency is fixed (400) once the commune has an expense, fund, recurring bill or exact split template, voided or archived ones included | `{ok:true}` |
| GET | `/api/communes/:id/snapshot` | membership checked; outsiders get 404 | `{commune,members,expenses,funds,notes,operations,notifications,reminderTargets,recurringBills,splitTemplates,activityEvents}` |
| GET | `/api/communes/:id/snapshot` | membership checked; outsiders get 404; `?detail=summary` leaves out what only the commune's own screens show | `{commune,members,expenses,funds,notes,operations,notifications,reminderTargets,recurringBills,splitTemplates,activityEvents,expenseHistory,changeMark}`; `expenses` are the open ones, those created or changed since the start of last month (UTC) and the five newest that are not voided; `expenseHistory` is `{since,olderCount,olderVoidedCount}` |
| GET | `/api/communes/:id/expenses/history?before=&months=` | member; `before` (a date) defaults to the snapshot's `since`; `months` 1 to 12, or left out for every older expense | `{expenses,before,from,olderCount,olderVoidedCount}`: whole months (UTC) before `before`, reaching back past months with none; `from` is null once nothing older remains |
| GET | `/api/communes/:id/expenses/records?ids=` | member; 1 to 50 comma-separated expense ids; ids from another commune are left out | `{expenses}` in the snapshot's shape |
| POST | `/api/communes/:id/expenses` | `{title,amountMinor,paidByUserId?,category?,shares:[{userId,amountMinor}],dueOn?,requestId?}`; recording for another payer requires admin; a repeated `requestId` returns the same expense, and reusing it for different details is 400 | `{id,duplicate}` (201, or 200 for a repeat) |
| POST | `/api/shares/:id/confirm` | `{}` payer only; records one receipt for everything outstanding | `{ok:true}` |
| POST | `/api/shares/:id/receipts` | `{amountMinor,receivedOn,reference?,requestId}` payer only; amount up to the outstanding share; a repeated `requestId` returns the same entry | `{id,duplicate,settledMinor,outstandingMinor}` (201, or 200 for a repeat) |
Expand Down Expand Up @@ -99,6 +101,7 @@ API_BASE=http://127.0.0.1:3006 node apps/local-api/tests/money-lifecycle.mjs
API_BASE=http://127.0.0.1:3006 node apps/local-api/tests/operations-history.mjs
API_BASE=http://127.0.0.1:3006 node apps/local-api/tests/invites-pledges-requests.mjs
API_BASE=http://127.0.0.1:3006 node apps/local-api/tests/session-viewer.mjs
API_BASE=http://127.0.0.1:3006 node apps/local-api/tests/lighter-loading.mjs
```

`money-lifecycle.mjs` needs migrations 0015 to 0017 and covers reminder
Expand All @@ -124,6 +127,10 @@ the signed-in account and requests without the header are still served.
`invitation-email-opt-in.mjs` needs no API:
`node --test apps/local-api/tests/invitation-email-opt-in.mjs`.

`lighter-loading.mjs` needs 0036 and covers change marks in the list and the
snapshot, the overview summary, pages of older expenses, named expense records
and the limit on creating communes. It refuses port 3002.

Search integration requires migration 0014. It covers all eight result types,
active membership, selected/all-commune scopes, removed-member denial, literal
wildcards, result caps and private-note masking. Its loopback guard refuses
Expand Down
55 changes: 36 additions & 19 deletions apps/local-api/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,30 +210,20 @@ function failed(key: string): void {
});
}

// The same list the hosted app reads, with each commune's change mark.
async function listCommunes(user: User) {
return withActor(user.id, async (client) => {
const { rows } = await client.query(`
SELECT c.id, c.name, c.kind, c.currency, c.address,
c.destination, c.stay_name, c.stay_address, m.role,
(SELECT count(*)::integer FROM public.commune_members mm
WHERE mm.commune_id = c.id AND mm.active) AS member_count
FROM public.communes c
JOIN public.commune_members m ON m.commune_id = c.id
WHERE m.user_id = auth.user_id() AND m.active
ORDER BY c.created_at DESC
`);
return { communes: rows.map((row) => ({
id: row.id, name: row.name, kind: row.kind, currency: row.currency,
address: row.address, destination: row.destination,
stayName: row.stay_name, stayAddress: row.stay_address,
role: row.role, memberCount: row.member_count,
})) };
const { rows } = await client.query("SELECT public.my_communes() AS communes");
return { communes: rows[0].communes };
});
}

async function snapshot(user: User, communeId: string) {
// A summary is what the overview screens show of a commune that is not open.
async function snapshot(user: User, communeId: string, summary = false) {
return withActor(user.id, async client => {
const result = await client.query("SELECT CASE WHEN public.is_commune_member($1) THEN public.commune_snapshot($1) ELSE NULL END AS snapshot", [communeId]);
const result = await client.query(summary
? "SELECT CASE WHEN public.is_commune_member($1) THEN public.commune_summary($1) ELSE NULL END AS snapshot"
: "SELECT CASE WHEN public.is_commune_member($1) THEN public.commune_snapshot($1) ELSE NULL END AS snapshot", [communeId]);
if (!result.rows[0].snapshot) throw new ApiError(404,"Commune not found");
return result.rows[0].snapshot;
});
Expand Down Expand Up @@ -404,7 +394,34 @@ route("PATCH", /^\/api\/communes\/([0-9a-f-]{36})$/, async (req, res, match) =>
});

route("GET", /^\/api\/communes\/([0-9a-f-]{36})\/snapshot$/, async (req, res, match) => {
send(res, await snapshot(await actor(req), uuid(match[1])));
const detail = new URL(req.url ?? "", "http://127.0.0.1").searchParams.get("detail");
if (detail !== null && detail !== "summary") throw new ApiError(400, "Invalid detail");
send(res, await snapshot(await actor(req), uuid(match[1]), detail === "summary"));
});

// Expenses older than the snapshot holds: the given number of months before
// `before` (the snapshot's own start when absent), or all of them when months
// is absent, as a statement or an export needs.
route("GET", /^\/api\/communes\/([0-9a-f-]{36})\/expenses\/history$/, async (req, res, match) => {
const params = new URL(req.url ?? "", "http://127.0.0.1").searchParams;
const before = isoDate(params.get("before"), true, "before");
const months = params.has("months") ? Number(params.get("months")) : null;
if (months !== null && (!Number.isInteger(months) || months < 1 || months > 12)) {
throw new ApiError(400, "Ask for 1 to 12 months of expenses at a time");
}
const result = await userAction(req, async client => (await client.query(
"SELECT public.commune_expense_page($1, $2::date, $3) AS result", [uuid(match[1]), before, months])).rows[0].result);
send(res, result);
});

// Named expenses, for a link to one that is not loaded.
route("GET", /^\/api\/communes\/([0-9a-f-]{36})\/expenses\/records$/, async (req, res, match) => {
const ids = (new URL(req.url ?? "", "http://127.0.0.1").searchParams.get("ids") ?? "").split(",").filter(Boolean);
if (ids.length < 1 || ids.length > 50) throw new ApiError(400, "Ask for 1 to 50 expenses at a time");
const result = await userAction(req, async client => (await client.query(
"SELECT public.commune_expense_records($1, $2::uuid[]) AS result",
[uuid(match[1]), ids.map((id) => uuid(id, "ids"))])).rows[0].result);
send(res, result);
});

route("GET", /^\/api\/reminders\/usage$/, async (req, res) => {
Expand Down
116 changes: 116 additions & 0 deletions apps/local-api/tests/lighter-loading.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";

// Change marks in the commune list, the overview summary, pages of older
// expenses, named expense records and the limit on creating communes (0036).
// Creates accounts and communes, so it refuses the normal application API.
const base = process.env.API_BASE;
if (!base) throw new Error("Set API_BASE to an isolated local test API, not the user's database.");
const url = new URL(base);
if (url.protocol !== "http:" || !["127.0.0.1", "localhost"].includes(url.hostname) || !url.port ||
url.port === "3002" || url.pathname !== "/" || url.search || url.hash || url.username || url.password) {
throw new Error("Lighter loading tests require an isolated loopback API port (not 3002).");
}
// Letters only, so a commune name that carries it never holds a phone-like run of digits.
const suffix = randomUUID().slice(0, 8).replace(/[0-9]/g, (digit) => "ghijklmnop"[digit]);
function session() {
let cookie = "";
return async (method, path, input) => {
const response = await fetch(`${base}/api${path}`, {
method,
headers: { ...(input === undefined ? {} : { "Content-Type": "application/json" }), ...(cookie ? { Cookie: cookie } : {}) },
body: input === undefined ? undefined : JSON.stringify(input),
});
const set = response.headers.get("set-cookie");
if (set) cookie = set.split(";")[0];
return { ...(await response.json()), status: response.status };
};
}
async function signup(call, name, slug) {
const result = await call("POST", "/auth/signup", { name, email: `${slug}-${suffix}@example.test`, password: "DisposableLoadingFixture-2026!" });
assert.equal(result.status, 201, JSON.stringify(result));
return result.user;
}
const owner = session(), member = session(), outsider = session(), maker = session();
const avery = await signup(owner, "Avery Loading", "loading-owner");
const blake = await signup(member, "Blake Loading", "loading-member");
await signup(outsider, "Casey Loading", "loading-outsider");
const home = await owner("POST", "/communes", { name: `Loading ${suffix}`, kind: "household", currency: "GBP" });
assert.equal(home.status, 201, JSON.stringify(home));
const invite = await owner("POST", `/communes/${home.id}/invites`, { email: blake.email, role: "member" });
assert.equal((await member("POST", `/invites/${invite.token}/accept`, {})).status, 200);

// The list carries each commune's mark, which stays put until something changes.
async function listed(call) {
const list = await call("GET", "/communes");
assert.equal(list.status, 200, JSON.stringify(list));
return list.communes.find((commune) => commune.id === home.id);
}
const first = await listed(owner);
assert.deepEqual(Object.keys(first).sort(), ["address", "changeMark", "currency", "destination", "id", "kind",
"memberCount", "name", "role", "stayAddress", "stayName"]);
assert.deepEqual([first.name, first.role, first.memberCount], [`Loading ${suffix}`, "admin", 2]);
assert.match(first.changeMark, /^\d{4}-\d{2}-\d{2}:\d+:\d+$/);
assert.equal((await listed(owner)).changeMark, first.changeMark, "Reading moved the mark");
assert.equal((await listed(member)).changeMark, first.changeMark, "Members see the same mark");
assert.equal((await listed(outsider)), undefined, "Someone outside sees the commune");
const snapshot = await owner("GET", `/communes/${home.id}/snapshot`);
assert.equal(snapshot.changeMark, first.changeMark);
const today = new Date();
const since = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth() - 1, 1)).toISOString().slice(0, 10);
assert.deepEqual(snapshot.expenseHistory, { since, olderCount: 0, olderVoidedCount: 0 });

// A write by anyone in the commune moves it.
const expense = await member("POST", `/communes/${home.id}/expenses`, { title: "Milk", category: "Groceries", amountMinor: 300,
shares: [{ userId: avery.id, amountMinor: 150 }, { userId: blake.id, amountMinor: 150 }], requestId: randomUUID() });
assert.equal(expense.status, 201, JSON.stringify(expense));
const moved = (await listed(owner)).changeMark;
assert.notEqual(moved, first.changeMark, "A new expense did not move the mark");

// The overview summary: the same parts without the detail of the commune's own screens.
const summary = await owner("GET", `/communes/${home.id}/snapshot?detail=summary`);
assert.equal(summary.status, 200, JSON.stringify(summary));
assert.equal(summary.detail, "summary");
assert.equal(summary.changeMark, moved);
assert.deepEqual(summary.expenses.map((item) => item.id), [expense.id]);
assert.deepEqual(summary.expenses[0].shares.map((share) => share.amountMinor), [150, 150]);
assert.equal((await owner("GET", `/communes/${home.id}/snapshot?detail=everything`)).status, 400);
assert.equal((await outsider("GET", `/communes/${home.id}/snapshot?detail=summary`)).status, 404);

// Older pages. Everything here is new, so there is nothing before the snapshot's start.
const page = await member("GET", `/communes/${home.id}/expenses/history?months=1`);
assert.equal(page.status, 200, JSON.stringify(page));
assert.deepEqual([page.expenses, page.before, page.from, page.olderCount, page.olderVoidedCount], [[], since, null, 0, 0]);
const all = await member("GET", `/communes/${home.id}/expenses/history`);
assert.deepEqual([all.status, all.expenses, all.from], [200, [], null]);
const earlier = await member("GET", `/communes/${home.id}/expenses/history?before=2026-01-15&months=2`);
assert.deepEqual([earlier.status, earlier.before], [200, "2026-01-01"]);
for (const query of ["months=0", "months=13", "months=one", "before=2026-02-30", "before=yesterday"]) {
assert.equal((await member("GET", `/communes/${home.id}/expenses/history?${query}`)).status, 400, query);
}
assert.equal((await outsider("GET", `/communes/${home.id}/expenses/history?months=1`)).status, 403);

// Named records come in the snapshot's shape, and only from this commune.
const fresh = await owner("GET", `/communes/${home.id}/snapshot`);
const named = await owner("GET", `/communes/${home.id}/expenses/records?ids=${expense.id},${randomUUID()}`);
assert.equal(named.status, 200, JSON.stringify(named));
assert.deepEqual(named.expenses, fresh.expenses);
const tooMany = Array.from({ length: 51 }, () => randomUUID()).join(",");
for (const query of ["", "ids=", `ids=${tooMany}`, "ids=not-an-id"]) {
assert.equal((await owner("GET", `/communes/${home.id}/expenses/records?${query}`)).status, 400, query);
}
assert.equal((await outsider("GET", `/communes/${home.id}/expenses/records?ids=${expense.id}`)).status, 403);

// Ten new communes in an hour is the most; the next one is refused calmly.
await signup(maker, "Morgan Loading", "loading-maker");
for (const letter of "abcdefghij") {
const made = await maker("POST", "/communes", { name: `Maker ${letter} ${suffix}`, kind: "household", currency: "GBP" });
assert.equal(made.status, 201, JSON.stringify(made));
}
const refused = await maker("POST", "/communes", { name: `Maker more ${suffix}`, kind: "household", currency: "GBP" });
assert.equal(refused.status, 409, JSON.stringify(refused));
assert.equal(refused.error, "You have created 10 communes in the last hour. Try again later.");
assert.equal((await maker("GET", "/communes")).communes.length, 10);
// Someone else can still create one.
assert.equal((await outsider("POST", "/communes", { name: `Casey ${suffix}`, kind: "trip", currency: "EUR" })).status, 201);
console.log("Lighter loading: marks, summary, older pages, named records and the creation limit behave as expected.");
27 changes: 27 additions & 0 deletions apps/reminder-worker/tests/integration.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,12 @@ async function runWorker() {
headers: { "x-neon-trigger-invocation-id": "local-test" },
}));
assert.equal(response.status, 200);
return response.json();
}

async function changeMark(communeId) {
const { rows } = await db.query("SELECT commune_private.change_mark($1) AS mark", [communeId]);
return rows[0].mark;
}

async function outbox(reminderId) {
Expand Down Expand Up @@ -428,8 +434,12 @@ try {
`, [commune, afterShare, alice, bob])).rows[0].id;
await db.query("INSERT INTO public.reminder_email_outbox (reminder_id) VALUES ($1)", [afterReminder]);
const callsBefore = providerCalls.length;
// What became of the email shows in the sender's reminders, so the run
// moves the commune's change mark (0036) and open apps read it again.
const markBefore = await changeMark(commune);
await runWorker();
assert.equal((await outbox(afterReminder)).status, "suppressed");
assert.notEqual(await changeMark(commune), markBefore, "A delivery outcome must move the commune's mark");
assert.equal(providerCalls.length, callsBefore);
// The sender gets the same answer as when an email went, so it never
// tells them the recipient turned email off.
Expand All @@ -449,6 +459,23 @@ try {
assert.equal((await outbox(third.reminderId)).payload_amount_minor, null);
assert.notEqual((await outbox(batchIds[0])).payload, null, "A recent email is kept");

// The same run drops change marks older than 2 days. A mark looks back to
// the start of yesterday at most, so recent ones stay.
const { rows: [oldChange] } = await db.query(`
INSERT INTO commune_private.commune_changes (commune_id, changed_at)
VALUES ($1, now() - interval '3 days') RETURNING id
`, [commune]);
const { rows: [recentChange] } = await db.query(`
INSERT INTO commune_private.commune_changes (commune_id, changed_at)
VALUES ($1, now() - interval '1 day') RETURNING id
`, [commune]);
const sweep = await runWorker();
assert.equal(sweep.pruned.changeMarks, 1);
const { rows: kept } = await db.query(
"SELECT id FROM commune_private.commune_changes WHERE id IN ($1, $2)", [oldChange.id, recentChange.id],
);
assert.deepEqual(kept.map((row) => row.id), [recentChange.id]);

// An address the provider reported as bouncing (or as a spam complaint) is
// skipped exactly like an opt-out, whatever the case of the account's
// address, so the sender cannot tell which happened.
Expand Down
6 changes: 0 additions & 6 deletions apps/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,6 @@
name="description"
content="Hub OS for shared spaces. Track splits, settle balances, and keep house money clear."
/>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400..800&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
Expand Down
Loading