This page connects a feature to the rest of the app: group settings, the activity log, the overview, the inbox, a new account's first minutes and the public demo. Each section copies what notes or groups already do.
You need:
- The aggregate built (the aggregate reference).
A setting belongs to the group itself, such as its description, colour and emoji. It is not a new
aggregate: the group already has the table, the repository and the change event. Copy the details
command, group.updateDetails.
- Migration. Add the column, with a default or a nullable type so existing rows stay valid.
Add a
CHECKfor a limit the database can hold. Mirror it inlibs/server/db/schema.sql. - Domain. Add the field to
GroupandGroupSummaryinlibs/domain/groups/+lib.ts. Write one parser per field that throwsGroupErrorwithINVALID_REQUEST. Settings that change together share one command, so one request is one audit row. - Repository. Add the column to the selects,
toGroupandtoSummary. The write locks the member's row and checks the role. It then callsaudit()andrecordChange()in the same transaction. - Command and handler. The handler judges the role again (
assertCanEditDetails). Register the command inapps/api/cqrs/+init.ts,apps/api/index.tsandrealtimeHub.ts. - Transports. Add the route (
PUT /api/groups/:groupId/details) and the socket command, with the same bad-input and role tests as the other group commands. - Screen. Add the field to
libs/ui/group-editor-screen.tsxand to the create request. A role that may not edit sees the value as text.
A setting that existing groups must have from the start, such as the time zone, has no default
state to show: the migration fills every row (the time zone takes the owner's), then sets
NOT NULL. A setting the client may leave out of the details request (the time zone again) keeps
older clients working: absent means unchanged.
A colour is a lower-case #rrggbb, checked with isHexColor from
@spy4x/platform/universal/color. Draw text on it with readableTextColor. New fields on
GroupRow, PickerGroup and the SPA's GroupItem are optional: the device may hold groups saved
before the field existed.
Other members need nothing new. recordChange() sends a hint to their sockets, and their pages
read the group again. e2e/groups-details.e2e.ts tests this with two pages.
A setting only your product has, such as a finance product's main currency, keeps its own table
and command instead, so taking Seed's later changes never conflicts in the group files. Pass its
section as settings to GroupEditorScreen in apps/spa/src/views/GroupEditorView.tsx: it shows
under the General tab's details, for every role, and the section itself decides who may change it.
A change only your product has, such as a household's main currency, gets a line of its own in the group's activity panel and the admin audit log. Seed's own files stay untouched.
Write the event. In the change's transaction, call
writeAuditEvent(tx, …)fromlibs/server/groups/audit.tswith your kind and the facts its sentence needs, aslibs/server/groups/postgres-group-repository.tsdoes. A kind is two lower-case words joined by a dot, each of at most 31 letters and underscores, such ashousehold.base_currency_changed; the admin audit filter accepts no other shape. Details hold names and codes, never a body or an address. An event about one of your own things passes itsentityTypeandentityId, such as"account"and the account's id.Register the kind. Replace
libs/domain/groups/product-activity.tswith your own list, and add that path to.seed/skip.ProductEntityTypenames theentityTypevalues your events use, or staysnever:import type { ProductActivityKind } from "./activity.ts" export type ProductEntityType = "account" export const PRODUCT_ACTIVITY_KINDS: readonly ProductActivityKind[] = [{ kind: `household.base_currency_changed`, label: `AdminAuditEvent.baseCurrencyChanged`, message: (details) => ({ key: `GroupActivity.baseCurrencyChanged`, values: { from: details.from, to: details.to }, }), describe: (who, { from, to }) => `${who} changed the main currency from ${from} to ${to}`, }]Add the words. Put
label's key in everyi18n/admin/*.tsandmessage's key in everyi18n/app/*.ts, as Translating (on GitHub) says. The panel fills in{who}; wrap what members typed in<value>…</value>, as Seed's messages do:"<who>{who}</who> changed the main currency from <value>{from}</value> to <value>{to}</value>".
The panel, describeActivity and the admin audit log, with its event filter, then name the event.
A kind Seed already has keeps Seed's sentence, and a kind nobody registered reads "changed something
in the group".
The group overview (/overview/:groupId) is a page of numbers and charts about the group over a
range of days. Each aggregate offers its own widgets. A widget that shows the group's own content
or figures (its spending, its revenue, an item, who did what) is operatorView: "never": the admin
group page shows only the "counts" widgets.
- Definition. Add the widget to the aggregate's list, as
NOTE_WIDGETSinlibs/domain/notes/overview.tsdoes. Its fields are in the table below. ChooseoperatorView:"counts"when it only counts things,"never"otherwise. For a"never"widget the admin group page draws no card and adds the line "Widgets that show this group's own figures are visible to its members only.", and its admin route answers 404 without reading anything. - Query and handler. Write one query class per widget and a handler, as
apps/api/features/notes/widgets.tsdoes. The handler makes the list's membership check before it reads anything, so a stranger is told the group does not exist. - Numbers. Read them from a read-only repository over one group, as
libs/server/notes/note-overview.tsdoes. Cut days at midnight in the zone the request names: the pages send the group's (groupTimeZone(group)), so every member sees the same days. - Register. Add the handler to
apps/api/cqrs/+init.tsand the operation torealtimeHub.ts. - Drawing. Add a
WidgetView, asNOTE_WIDGET_VIEWSinlibs/ui/note-widgets.tsxdoes. A number takesWidgetStat; a chart takesLineChartwith a one-sentence summary as its caption. - Registry. Add the list and the drawings to
OVERVIEW_WIDGETSandOVERVIEW_VIEWSinlibs/ui/overview-widgets.ts. The SPA and the admin group page both read it. - Admin. For a
"counts"widget, add the list and the readers toADMIN_WIDGETSinapps/api/routes/admin/groups.ts. Without them the admin card shows its error. A"never"widget needs nothing there. - Tests. Copy
widgets.test.ts,tests/integration/note-overview.integration.test.tsand the drawing's case inlibs/ui/overview.test.tsx. Include another group's rows that must not count.
| Field | What it holds |
|---|---|
id |
A stable name. A saved layout keeps it, so it never changes |
titleKey |
The app catalog key of its name |
size |
stat for a number, full for a chart |
canView |
Which roles see it |
operation |
The operation that answers it |
operatorView |
counts: the admin group page shows it; never: only the group's members see it |
A number answers with CountWidgetResult, and a chart with SeriesWidgetResult, from
@domain/groups. Both carry the period before the range, or null when the group did not exist
for all of it. A person's saved layout gets the new widget at its end.
A change that a person should hear about, and did not make, writes a notification. Copy
libs/server/groups/group-notifications.ts.
- Same transaction. Call
createNotification(tx, …)from@server/notificationsafter the change, inside its transaction. The row then exists exactly when the change does. - A kind. Add it to
NotificationKindinlibs/domain/notifications. Its sentence goes innotificationMessage, with its key in everyi18n/app/*.ts. - A payload and a link. The payload holds only the facts the sentence needs, never a note body or an address. The link is an in-app path; anything else is refused.
- A test. Add one per writer to
tests/integration/notifications.integration.test.ts.
Nothing else is needed. createNotification sends user_notification in the same transaction,
and the API turns it into a hint on the person's sockets. The inbox and Telegram say the same
sentence, in the person's language.
A new account should not open onto an empty app. Sign-up queues one worker job per account with
tx.starterData.queue(user.id). The job, starterDataJob in libs/server/jobs/starter-data.ts,
creates one welcome note. Sign-up never waits for it and never fails because of it.
- Add your data inside
starterDataJob, after the welcome note. Use your repository's own create, with the person as the actor. - Make the write repeatable. Derive each row's id from the request id, or use
ON CONFLICT DO NOTHING. The welcome note's id is the request's id, so a retry adds none. - Skip quietly when the group is gone: that means "nothing to seed", not a failure to retry.
- Test a forced retry in
tests/integration/starter-data.integration.test.ts: run the job twice, and count the rows.
The starter_data_requests row marks the job done, so a job that runs again does nothing. Every
list also needs a first-run empty state: EmptyState, with one action when there is something to
do (UI layout (on GitHub)).
A new account's home page shows a first-steps checklist and offers a short tour
(libs/ui/onboarding-card.tsx). Nothing ticks a step: each one is a fact your feature already
stores.
- The fact. Add one
EXISTS (…)toreadOnboardinginlibs/server/auth/onboarding.ts, and its name toOnboardingStepsinlibs/domain/identity/+lib.ts. Count only what the person did themselves, and give the read an index. - The step. Add it to
onboardingStepsin the card, with a link to the page that does it. - The tests. Add a step to
tests/integration/onboarding.integration.test.tsand its title tolibs/ui/onboarding.test.tsx.
To add a screen to the tour, add it to tourSteps with the selector of its control and its text
keys. Dismissing the card and finishing the tour are stored on the person's users row, so both
follow the person to every device.
A new aggregate adds fake rows to the demo in the same pull request (demo.md (on GitHub), "When the database changes").
- Add a few believable items per themed group to
apps/worker/demo/fake-data.ts. - Write them in
apps/worker/demo/seed.ts, through the aggregate's own repository. - Update the counts in
tests/integration/demo-seed.integration.test.ts.
The nightly wipe empties the new table with no change.
- Admin modules: the aggregate's admin counterpart.
- Delete, move and export an aggregate