Skip to content
Fusion

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 with django-guardian object permissions, except the "views" are server functions and the permission rules are a typed predicate registry. Table names keep Django's protocol_*; JS exports are prefixed protokoll*. 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.

LayerWhere it lives
Logic (package)@tikab-interactive/pulsn-apps/protokolln — schema, hierarchy-aware permissions, recursive-CTE row-filters, server logic, cards
Transportthin createServerFn wrappers in src/lib/protokolln-server.ts (inject db, map PermissionError → 403)
UIfusion-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.

Loading diagram...
  • protocol_company owns root **protocol_project**s; subprojects point at their parent via parentId (a self-FK), forming the tree. A project has no link to any other app — the PulsN project hub points to it instead.
  • protocol_projectmember is 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 via removed.
  • protocol_protocol is a meeting (name, meetingDate, location, secretaryId), belonging to a protocol_meetingseries. A XOR check constrains it to be exactly one of a meeting, a series template, or a project template.
  • protocol_protocolstepprotocol_protocolpoint — minutes are grouped into steps; each point is an action item with a statusId, an assignedToId member, and a dueDate.

Pages

URLWhat it shows
/project/$projectKeythe building overview — Carola plus the single most urgent ProtokollN item
/project/$projectKey/protokolln/protocolsthis building's protocols — a ResourceTablePage (name · series · location), editable when the viewer canEdit (secretary or editor)
/project/$projectKey/protokolln/pointsthis 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:

example/src/routes/_authed.project.$projectKey.protokolln.protocols.tsx
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:

  1. Downward inheritance. A grant on a parent project applies to every descendant. resolveProjectPerms walks 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) over protocol_projectmember ∪ all descendants.

  2. 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:

pulsn-apps/src/protokolln/index.ts
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.tsx and its listProject* server function; follow Building a feature.
  • A new editable column → add it to the ResourceTablePage columns + the row type (e.g. ProtokollPointRow), and return it from the loader's server function (alongside the existing canEdit).
  • A new permission → add a boolean to protocol_projectmember, OR it into EffectiveProjectPerms in resolveProjectPerms, 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(...) in src/permissions/protokolln.ts, then thread the needed context (e.g. an assignee user.id) into the object's get*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).