ProtokollN — meeting protocols
ProtokollN is the example's meeting-minutes app: which meetings happened, what was decided, and who owes which action item by when. Those action items — points with a status, an assignee, and a deadline — are what feed the urgency ranking on the Portfolio and the "needs you" list the proactive agent surfaces.
It is project-scoped: every ProtokollN page lives under /project/$projectKey, so you
are always looking at one building's protocols. (The global surfaces — the
home, Carola, universal search, and the
admin area — aren't project-scoped.)
Coming from Django? ProtokollN is a port of one Django app —
protocol— modelling meetings and minutes over a hierarchical project tree withdjango-guardianobject permissions, except the "views" are server functions and the permission rules are a typed predicate registry. Table names keep Django'sprotocol_*; JS exports are prefixedprotokoll*. See Coming from Django.
Where it lives
The logic lives in the @tikab-interactive/pulsn-apps package
(/protokolln subpath); the example is a thin consumer.
| Layer | Where it lives |
|---|---|
| Logic (package) | @tikab-interactive/pulsn-apps/protokolln — schema, hierarchy-aware permissions, recursive-CTE row-filters, server logic, cards |
| Transport | thin createServerFn wrappers in src/lib/protokolln-server.ts (inject db, map PermissionError → 403) |
| UI | fusion-ui's ResourceTablePage — the example wraps it for labels + routing |
| Routes (pages) | src/routes/_authed.project.$projectKey.protokolln.protocols.tsx · …protokolln.points.tsx · admin: _authed.admin.protokolln.*.tsx |
Data model
The defining trait is the hierarchy: a company owns root projects, projects nest into subprojects, and permissions inherit down the tree.
protocol_companyowns root **protocol_project**s; subprojects point at their parent viaparentId(a self-FK), forming the tree. A project has no link to any other app — the PulsN project hub points to it instead.protocol_projectmemberis the membership table. The permission booleans (canChangeProject,canManageMembers,canManageSeries,canManagePoints,canChangePointStatus, …) are denormalised onto the row, and they inherit downward through the tree. Soft-deleted viaremoved.protocol_protocolis a meeting (name,meetingDate,location,secretaryId), belonging to aprotocol_meetingseries. AXORcheck constrains it to be exactly one of a meeting, a series template, or a project template.protocol_protocolstep→protocol_protocolpoint— minutes are grouped into steps; each point is an action item with astatusId, anassignedToIdmember, and adueDate.
Pages
| URL | What it shows |
|---|---|
/project/$projectKey | the building overview — Carola plus the single most urgent ProtokollN item |
/project/$projectKey/protokolln/protocols | this building's protocols — a ResourceTablePage (name · series · location), editable when the viewer canEdit (secretary or editor) |
/project/$projectKey/protokolln/points | this building's action items — title · protocol · status, editable when the viewer canEdit (editor or assignee) |
/admin/protokolln/* | site-admin CRUD over companies, projects, members, series, protocols, points (Admin) |
How a page loads
The protocols page is a textbook loader → server function:
export const Route = createFileRoute("/_authed/project/$projectKey/protokolln/protocols")({
loader: ({ params }) => listProjectProtocols({ data: { projectKey: params.projectKey } }),
component: ProjectProtocolsPage,
});The component, ProjectProtocolsPage, reads the loaded rows with Route.useLoaderData()
and hands them to ResourceTablePage — each row carries a canEdit flag, and the Edit
button is gated on it.
The points page is the same shape with listProjectPoints. Both server functions live in
src/lib/project-apps-server.ts (the project-scoped wrappers); the un-scoped global lists
(listProtokollnProtocols, listProtokollnPoints) live in src/lib/protokolln-server.ts.
Permissions
ProtokollN shares ProcessN's three-layer model — SQL view filter, per-object check, re-check on write (Authorization) — but adds two twists ProcessN doesn't have:
-
Downward inheritance. A grant on a parent project applies to every descendant.
resolveProjectPermswalks from a project up to the root and ORs each level's membership booleans into one effective set; the SQL filter mirrors this with a recursive CTE (accessibleProjectIds) overprotocol_projectmember∪ all descendants. -
Object-specific special cases. A protocol's secretary may edit their protocol, and a point's assignee may change their assigned point — even without a broad editor role. Each is a small predicate composed with
.or(...):pulsn-apps/src/protokolln/permissions.ts// Protocol — editable by its secretary OR a project/series editor (Django // `Protocol.can_write`). type ProtocolCtx = { eff: EffectiveProjectPerms; secretaryUserId: string | null }; const isSecretary = predicate<User, ProtocolCtx>( "protokoll.protocol.isSecretary", (u, c) => c.secretaryUserId !== null && c.secretaryUserId === u.id, ); const protocolEditorPerm = predicate<User, ProtocolCtx>( "protokoll.protocol.editorPerm", (_u, c) => c.eff.canManageSeries || c.eff.canChange, ); const canEditProtocol = isSecretary.or(protocolEditorPerm); // Protocol point — editable by a project editor OR the point's assignee (Django // `ProtocolPoint.can_write`). type PointCtx = { eff: EffectiveProjectPerms; assigneeUserId: string | null }; const isAssignee = predicate<User, PointCtx>( "protokoll.point.isAssignee", (u, c) => c.assigneeUserId !== null && c.assigneeUserId === u.id, ); const pointEditorPerm = predicate<User, PointCtx>( "protokoll.point.editorPerm", (_u, c) => c.eff.canChange, ); const canEditPoint = pointEditorPerm.or(isAssignee);
The per-object functions getProjectPermissions / getProtocolPermissions /
getProtocolPointPermissions resolve one object's effective perms, and every write server
function re-runs the matching check before mutating — so a tampered client canEdit flag
changes nothing:
export async function updateProtokollnPoint(
ctx: AppCtx,
viewerId: string,
input: UpdateProtokollnPointInput,
): Promise<{ ok: true }> {
const perms = await getProtocolPointPermissions(ctx.db, viewerId, input.id);
if (!perms.canEdit) throw new PermissionError("You can't edit this point.");
await ctx.db
.update(protokollProtocolPoint)
.set({ title: input.title, updatedAt: new Date() })
.where(eq(protokollProtocolPoint.id, input.id));
if (ctx.reindex) await Promise.resolve(ctx.reindex("protocol", input.id)).catch(() => {});
return { ok: true as const };
}Extending it
- A new ProtokollN page → copy
_authed.project.$projectKey.protokolln.points.tsxand itslistProject*server function; follow Building a feature. - A new editable column → add it to the
ResourceTablePagecolumns + the row type (e.g.ProtokollPointRow), and return it from the loader's server function (alongside the existingcanEdit). - A new permission → add a boolean to
protocol_projectmember, OR it intoEffectiveProjectPermsinresolveProjectPerms, add a predicate, and surface it on the row; never gate on the client alone — re-check in the write path. - A new special case → compose it like the secretary/assignee predicates with
.or(...)insrc/permissions/protokolln.ts, then thread the needed context (e.g. an assigneeuser.id) into the object'sget*Permissions.
See it populated by bun run db:seed — the seed gives every
Tikab login real protocols and assigned points (each with at least one overdue and one
due-soon item).