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
15 changes: 12 additions & 3 deletions apps/local-api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,11 @@ 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:[{id,name,kind,currency,role,memberCount,address,destination,stayName,stayAddress,changeMark}]}`; a commune's `changeMark` moves whenever anything in it changes |
| GET | `/api/communes` | | `{communes:[{id,name,kind,currency,role,memberCount,address,destination,stayName,stayAddress,changeMark,unreadNotices}]}`; a commune's `changeMark` moves whenever anything in it changes; `unreadNotices` is the reader's unread notices there (0039) |
| 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?}`; 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; `?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/snapshot` | membership checked; outsiders get 404; `?detail=summary` leaves out what only the commune's own screens show, keeping a share's pending payment reports (0039) | `{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) |
Expand All @@ -45,6 +45,7 @@ browser code sends same-origin `/api` requests. Non-2xx errors are
| POST | `/api/communes/:id/members/:userId` | `{action:"admin"\|"member"\|"remove"\|"leave"}`; admin changes roles/removes, member can leave; an admin who leaves, is removed or becomes a member loses their open invitations | `{ok:true}` |
| POST | `/api/reminders` | `{kind:"nudge"\|"chase",sourceId,requestId?}`; a repeated `requestId` returns the reminder it already created | `{id,duplicate,delivery:"in-app",emailStatus:"none",email:"not-configured"}` (201, or 200 for a repeat) |
| POST | `/api/notifications/:id/read` | `{}` | `{ok:true}` |
| GET | `/api/notices/unread` | | `{notices}`: the unread notices of every commune the reader belongs to, newest first, each as in `snapshot.notifications` with `senderName` and, for a pledge notice whose fund is open, `fundId` (0039) |
| GET | `/api/communes/:id/invites` | admin | `{invites:[...]}` with `invitedBy` and `invitedByName`; an invitation works only while its sender is an admin |
| POST | `/api/communes/:id/invites` | `{email,role}` admin | `{token,url}` to copy; no email sent |
| GET | `/api/invites/:token` | matching signed-in email | `{invite}` or `{invite:null}` |
Expand All @@ -71,7 +72,9 @@ carry `version`, `voidedAt/voidedBy/voidReason` and their last 20 `revisions`
(`{version,action,actorId,reason,before,after,createdAt}`). Expense shares have
`{id,userId,amountMinor,settledMinor,settledAt,receipts}`; `settledMinor` is
the sum of receipts that have not been reversed. Operations carry `history`
(recent occurrences) and `undoOccurrenceId`. `sentReminders` lists the
(recent occurrences) and `undoOccurrenceId`. Each of `activityEvents` names its
`recordType` (expense, repayment, fund, pledge, contribution, recurring, template,
task, event, note, member or announcement; 0039). `sentReminders` lists the
signed-in sender's own recent reminders with their email state. Funds contain commitments
with `contributedMinor`, cancellation and contribution-correction history.
Notifications and reminder targets include the IDs
Expand Down Expand Up @@ -102,6 +105,7 @@ 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
API_BASE=http://127.0.0.1:3006 node apps/local-api/tests/notices-and-activity.mjs
```

`money-lifecycle.mjs` needs migrations 0015 to 0017 and covers reminder
Expand Down Expand Up @@ -131,6 +135,11 @@ the signed-in account and requests without the header are still served.
snapshot, the overview summary, pages of older expenses, named expense records
and the limit on creating communes. It refuses port 3002.

`notices-and-activity.mjs` needs 0039 and covers each commune's unread count in
the list and the snapshot, the unread notices of every commune, the record type
of each activity event and the pending payment reports an overview summary
keeps. 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
8 changes: 8 additions & 0 deletions apps/local-api/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,14 @@ route("GET", /^\/api\/reminders\/usage$/, async (req, res) => {
});
});

// Unread notices from every commune the caller belongs to (0039), read when
// Notices opens. The commune list carries each commune's count.
route("GET", /^\/api\/notices\/unread$/, async (req, res) => {
const notices = await userAction(req, async (client) => (await client.query(
"SELECT public.my_unread_notices() AS notices")).rows[0].notices);
send(res, { notices });
});

route("POST", /^\/api\/communes\/([0-9a-f-]{36})\/expenses$/, async (req, res, match) => {
const input = await body(req);
const communeId = uuid(match[1]);
Expand Down
2 changes: 1 addition & 1 deletion apps/local-api/tests/lighter-loading.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ async function listed(call) {
}
const first = await listed(owner);
assert.deepEqual(Object.keys(first).sort(), ["address", "changeMark", "currency", "destination", "id", "kind",
"memberCount", "name", "role", "stayAddress", "stayName"]);
"memberCount", "name", "role", "stayAddress", "stayName", "unreadNotices"]);
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");
Expand Down
115 changes: 115 additions & 0 deletions apps/local-api/tests/notices-and-activity.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";

// Unread notices across communes, activity record types and the pending
// payment reports an overview summary keeps (0039). 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("Notice and activity 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.replace(/\/$/, "")}/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];
const data = await response.json();
return { ...data, reportStatus: data.status, status: response.status };
};
}
const ok = (result, status = 200) => { assert.equal(result.status, status, JSON.stringify(result)); return result; };
async function signup(call, name, slug) {
return ok(await call("POST", "/auth/signup", { name, email: `${slug}-${suffix}@example.test`,
password: "DisposableNoticeFixture-2026!" }), 201).user;
}
const a = session(), b = session(), outsider = session(), guest = session();
const avery = await signup(a, "Avery Notices", "notices-avery");
const blake = await signup(b, "Blake Notices", "notices-blake");
await signup(outsider, "Casey Notices", "notices-casey");
async function commune(call, name, currency, member, memberCall) {
const id = ok(await call("POST", "/communes", { name, kind: "household", currency }), 201).id;
const invite = ok(await call("POST", `/communes/${id}/invites`, { email: member.email, role: "member" }), 201);
ok(await memberCall("POST", `/invites/${invite.token}/accept`, {}));
return id;
}
// Avery's home, with Blake, and Blake's trip, with Avery.
const home = await commune(a, `Home ${suffix}`, "GBP", blake, b);
const trip = await commune(b, `Trip ${suffix}`, "EUR", avery, a);
const today = new Date().toISOString().slice(0, 10);
const snapshot = async (call, id) => ok(await call("GET", `/communes/${id}/snapshot`));
const counts = async (call) => Object.fromEntries((ok(await call("GET", "/communes")).communes)
.filter((item) => [home, trip].includes(item.id)).map((item) => [item.id, item.unreadNotices]));
assert.deepEqual(await counts(a), { [home]: 0, [trip]: 0 });
assert.deepEqual(ok(await a("GET", "/notices/unread")).notices, []);

// Home: Blake says he has sent his share of the gas, which Avery has to confirm.
const gas = ok(await a("POST", `/communes/${home}/expenses`, { title: "Gas", amountMinor: 3000,
shares: [{ userId: avery.id, amountMinor: 1500 }, { userId: blake.id, amountMinor: 1500 }], requestId: randomUUID() }), 201).id;
const gasShare = (await snapshot(b, home)).expenses.find((item) => item.id === gas).shares.find((item) => item.userId === blake.id);
const report = ok(await b("POST", `/shares/${gasShare.id}/payment-reports`,
{ amountMinor: 1500, sentOn: today, reference: "Bank transfer", requestId: randomUUID() }), 201);
// Trip: Blake nudges Avery about her share of the ferry.
const ferry = ok(await b("POST", `/communes/${trip}/expenses`, { title: "Ferry", amountMinor: 4000, dueOn: today,
shares: [{ userId: avery.id, amountMinor: 2000 }, { userId: blake.id, amountMinor: 2000 }], requestId: randomUUID() }), 201).id;
const ferryShare = (await snapshot(b, trip)).expenses.find((item) => item.id === ferry).shares.find((item) => item.userId === avery.id);
ok(await b("POST", "/reminders", { kind: "nudge", sourceId: ferryShare.id, requestId: randomUUID() }), 201);
// Home again: Avery moves Blake's pledge to a later date, which tells Blake.
const fund = ok(await a("POST", `/communes/${home}/funds`, { name: "Boiler", targetMinor: 50000, requestId: randomUUID() }), 201).id;
const inDays = (days) => new Date(Date.now() + days * 86400000).toISOString().slice(0, 10);
const pledge = ok(await b("POST", `/funds/${fund}/pledges`, { amountMinor: 10000, dueOn: inDays(30), requestId: randomUUID() }), 201).id;
ok(await a("PATCH", `/commitments/${pledge}`, { amountMinor: 10000, dueOn: inDays(60), expectedAmountMinor: 10000, expectedDueOn: inDays(30) }));

// ------------------------------------------------------------ counts
// Each commune in the list carries the reader's own unread count, as does the snapshot.
assert.deepEqual(await counts(a), { [home]: 1, [trip]: 1 });
assert.deepEqual(await counts(b), { [home]: 1, [trip]: 0 });
assert.equal((await snapshot(a, trip)).commune.unreadNotices, 1);
assert.equal(ok(await a("GET", `/communes/${home}/snapshot?detail=summary`)).commune.unreadNotices, 1);

// ------------------------------------------------------------ every commune's unread notices
const averys = ok(await a("GET", "/notices/unread")).notices;
assert.deepEqual(averys.map((item) => [item.communeId, item.kind, item.senderName, item.fundId]),
[[trip, "nudge", "Blake Notices", null], [home, "payment-reported", "Blake Notices", null]]);
// Each is the snapshot's notice with the sender's name and a pledge notice's fund.
const { senderName, fundId, ...nudge } = averys[0];
assert.deepEqual(nudge, (await snapshot(a, trip)).notifications[0]);
const blakes = ok(await b("GET", "/notices/unread")).notices;
assert.deepEqual(blakes.map((item) => [item.communeId, item.kind, item.senderName, item.fundId]),
[[home, "pledge-changed", "Avery Notices", fund]]);
assert.deepEqual(ok(await outsider("GET", "/notices/unread")).notices, []);
assert.equal((await guest("GET", "/notices/unread")).status, 401);

// Reading one changes only its own commune.
ok(await a("POST", `/notifications/${averys[0].id}/read`, {}));
assert.deepEqual(await counts(a), { [home]: 1, [trip]: 0 });
assert.deepEqual(ok(await a("GET", "/notices/unread")).notices.map((item) => item.id), [averys[1].id]);

// ------------------------------------------------------------ record types
// Each activity event names the kind of record it is about.
const events = (await snapshot(a, home)).activityEvents;
assert.ok(events.every((event) => typeof event.recordType === "string"), JSON.stringify(events));
const types = Object.fromEntries(events.map((event) => [`${event.title}: ${event.description}`, event.recordType]));
assert.deepEqual([types["Boiler: Created"], types["Boiler: Pledge made"], types["Boiler: Pledge updated"],
types["Blake Notices: Joined the commune"]], ["fund", "pledge", "pledge", "member"], JSON.stringify(types));

// ------------------------------------------------------------ the summary keeps a pending report
const reportsIn = (body) => body.expenses.find((item) => item.id === gas).shares
.find((item) => item.userId === blake.id).paymentReports.map((item) => [item.id, item.status]);
assert.deepEqual(reportsIn(ok(await a("GET", `/communes/${home}/snapshot?detail=summary`))), [[report.id, "pending"]]);
ok(await a("POST", `/payment-reports/${report.id}/resolve`, { decision: "confirm", requestId: randomUUID() }));
assert.deepEqual(reportsIn(ok(await a("GET", `/communes/${home}/snapshot?detail=summary`))), []);
assert.deepEqual(reportsIn(await snapshot(a, home)), [[report.id, "confirmed"]]);
// Confirming it records a repayment, named as one.
const repaid = (await snapshot(a, home)).activityEvents.filter((event) => event.title === "Gas");
assert.deepEqual(repaid.map((event) => event.recordType), ["repayment"], JSON.stringify(repaid));
console.log("Notices and activity: counts, notices across communes, record types and pending reports behave as expected.");
2 changes: 1 addition & 1 deletion apps/web/reference/design-language/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -613,7 +613,7 @@
<div class="account">
<span class="avatar">AC</span
><span
><strong>Alex Chen</strong><small>Admin · 58 reminders left</small></span
><strong>Alex Chen</strong><small>Admin</small></span
>
</div>
</div>
Expand Down
10 changes: 9 additions & 1 deletion apps/web/src/data/household.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ export type SettlementMove = {
expenseId?: string;
};

/** The kinds of record an activity row can be about (0039 names one for each event). */
export type ActivityType =
| "expense" | "repayment" | "fund" | "pledge" | "contribution" | "recurring" | "template"
| "task" | "event" | "note" | "member" | "announcement";

export type Hub = {
id: HubId;
currency?: "GBP" | "USD" | "EUR";
Expand Down Expand Up @@ -104,6 +109,8 @@ export type Hub = {
time: string;
createdAt?: string;
expenseId?: string;
/** The kind of record the row is about. */
type?: ActivityType;
}>;
notes: Array<{ go: "note"; note: string; title: string; when: string }>;
jumps: Array<{ go: ScreenId | WindowId; kicker: string; title: string; hint: string }>;
Expand Down Expand Up @@ -745,7 +752,8 @@ export type CreatedHub = {

export function initialsOf(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (!parts.length) return "MC";
// No name, no letters: a neutral mark rather than someone else's initials.
if (!parts.length) return "?";
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase();
}
Expand Down
Loading