Ga naar inhoud

De backend

Naast de frontend kan een module server-side code hebben: TypeScript dat Tabbr in een sandbox op de server uitvoert. Daar hoort werk thuis dat niet van een open browser mag afhangen: ’s nachts statussen bijwerken, slimme groepen vullen, synchroniseren met een externe dienst, rollen toekennen.

De backend is een gewoon TypeScript-bestand dat functies exporteert. Het manifest zegt wanneer Tabbr ze oproept.

backend/src/index.ts
/** Elke nacht: de status van elke bevoegdheid (gepland, geldig, vervallen) volgens haar datums. */
export function refreshAll(): void {
const all = tabbr.entities.authorisation.query({ limit: 5000 });
for (const record of all) {
const status = statusOf(record);
if (status !== record.status) {
tabbr.entities.authorisation.updateFields(record.id, { status });
}
}
tabbr.settings.set('lastRun', { at: new Date().toISOString(), count: all.length });
}
/** Na het bewaren van een bevoegdheid: alleen die ene bijwerken. */
export function onAuthorisationSaved(authorisation: { id: string }): void {
const record = tabbr.entities.authorisation.find(authorisation.id);
if (record) refreshStatuses([record]);
}
tabbr.config.json
"backend": {
"entry": "backend/src/index.ts",
"triggers": [
{ "type": "CRON", "name": "refreshAll", "cronExpression": "10 2 * * *", "handler": "refreshAll" },
{ "type": "ENTITY_HOOK", "name": "authorisationSaved", "entity": "authorisation",
"event": "onCreated", "handler": "onAuthorisationSaved" },
{ "type": "ENTITY_HOOK", "name": "authorisationUpdated", "entity": "authorisation",
"event": "onUpdated", "handler": "onAuthorisationSaved" }
]
}
Type Wanneer
CRON Volgens cronExpression (5 velden, bv. 10 2 * * * = elke nacht om 02:10).
ENTITY_HOOK, event onCreate / onUpdate / onDelete Vóór het bewaren van een record, in dezelfde transactie.
ENTITY_HOOK, event onCreated / onUpdated Na de commit, op de achtergrond.
  • De schrijfacties van je module zelf starten haar eigen handlers niet opnieuw: geen oneindige lussen.
  • Een trigger met de hand starten kan met POST /api/custommodule/{moduleId}/trigger/{name}, of vanuit je frontend met injectModuleApi(moduleId).trigger(name) — handig voor een knop Nu synchroniseren.

Elke entiteit van de club, bij naam: tabbr.entities.person, tabbr.entities.flight, tabbr.entities.groupMember, … Lezen kan op elke entiteit; schrijven op de entiteiten van je eigen module en op flight.

Methode Wat ze doet
find(id) Eén record, of null.
findByFieldEquals(veld, waarde) De records met die waarde.
query({ where, filterLines, dataList, q, limit, offset }) Records — standaard 500, maximaal 5000 per oproep; blader met offset.
queryIds(…) Alleen de id’s (standaard allemaal).
create(velden) Maakt een record, geeft { id }.
updateFields(id, velden) Past velden aan.
delete(id) Verwijdert een record.
// de lopende lidmaatschappen van een persoon
const memberships = tabbr.entities.groupMember.query({
where: [
{ field: 'person', value: personId },
{ field: 'until', operator: 'IS_EMPTY' },
],
});
// de filter van een lijst van de club hergebruiken
const actief = tabbr.entities.person.queryIds({ dataList: 'Clubleden' });

Een record is een gewoon object: id, $label (hoe het record heet) en één eigenschap per veld. Een veld dat je module declareert, heet naar zijn key — stabiel, ook als de club het hernoemt; de eigen velden van de club naar hun naam, ingebouwde velden naar hun eigenschap (bv. firstname). Een verwijzing schrijf je als { id }.

tabbr.settings.get(key) en tabbr.settings.set(key, value) bewaren de instellingen van je module in de club. Een instelling die in secretFieldNames staat (een API-key), kan je server-side lezen maar komt nooit terug naar de browser.

const apiKey = tabbr.settings.get<string>('apiKey');
const res = http.fetchJson<Flight[]>('https://admin.gliding.app/api/flights.json', {
method: 'GET',
headers: { 'X-API-KEY': apiKey! },
});
if (!res.ok) throw new Error(`ZweefApp antwoordde ${res.status}`);
for (const flight of res.body) { /* … */ }

http.fetchJson werkt alleen voor de hosts in allowedHosts van je manifest. Alles daarbuiten wordt geweigerd.

De contracten uit uses en optionalUses, bij naam — zie Contracten:

const instructeurs = tabbr.uses['sys.access'].personsWithRole('INSTRUCTEUR');
const akkoord = tabbr.uses['tabbr.consent']?.personsWith('zweefapp.share') ?? [];

Houd logica die frontend en backend delen in shared/, en test ze met de ingebouwde testrunner van Node:

backend/test/status.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { statusOf } from '../../shared/authorisations.js';
test('een bevoegdheid met een einddatum in het verleden is vervallen', () => {
assert.equal(statusOf({ from: '2024-01-01', until: '2025-01-01' }, '2026-03-01'), 'expired');
});
Terminal window
npm test # node --test backend/test/*.test.mjs
npm run typecheck # tsc -p backend/tsconfig.json

De Module Store voert test en typecheck uit bij elke ingediende versie: faalt er een, dan wordt de versie niet gebouwd.