B2B-organisaties en teams: een database-schema dat beheersbaar blijft
B2B organisaties- en teamschema: een praktisch relationeel patroon voor uitnodigingen, lidmaatschapsstatussen, rol-erfenis en auditklare wijzigingen.

Welk probleem lost dit schema-patroon op?
De meeste B2B-apps zijn geen simpele “user accounts”-apps. Het zijn gedeelde werkruimtes waar mensen bij een organisatie horen, verdeeld in teams, en verschillende rechten hebben afhankelijk van hun rol. Sales, support, finance en admins hebben andere toegang, en die toegang verandert in de loop van de tijd.
Een te simpel model houdt het niet vol. Als je één users-tabel hebt met één role-kolom, kun je niet uitdrukken dat “dezelfde persoon Admin is in het ene org, maar Viewer in het andere.” Ook kun je geen veelvoorkomende gevallen afhandelen zoals een contractor die alleen één team mag zien, of een werknemer die een project verlaat maar nog steeds bij het bedrijf hoort.
Uitnodigingen zijn een andere frequente bron van bugs. Als een invitation enkel een e-mailrij is, wordt onduidelijk of de persoon al “in” het org is, bij welk team ze horen en wat er gebeurt als ze aanmelden met een andere e-mail. Kleine inconsistenties hier leiden vaak tot beveiligingsproblemen.
Dit patroon heeft vier doelen:
- Beveiliging: rechten komen van expliciet lidmaatschap, niet van aannames.
- Duidelijkheid: orgs, teams en rollen hebben elk één bron van waarheid.
- Consistentie: uitnodigingen en lidmaatschappen volgen een voorspelbare levenscyclus.
- Geschiedenis: je kunt uitleggen wie toegang gaf, rollen wijzigde of iemand verwijderde.
De belofte is één relationeel model dat begrijpelijk blijft als de features groeien: meerdere orgs per gebruiker, meerdere teams per org, voorspelbare rol-erfenis en auditvriendelijke wijzigingen. Het is een structuur die je vandaag kunt implementeren en later kunt uitbreiden zonder alles te herschrijven.
Belangrijke termen: orgs, teams, users en memberships
Als je een schema wilt dat na zes maanden nog leesbaar is, begin dan met het eens worden over een paar termen. De meeste verwarring komt van het mengen van “wie iemand is” met “wat ze kunnen doen”.
Een Organization (org) is de hoogste tenant-grens. Het vertegenwoordigt de klant- of businessaccount die data bezit. Als twee gebruikers in verschillende orgs zitten, zouden ze standaard elkaars data niet moeten zien. Die ene regel voorkomt veel per ongeluk cross-tenant toegang.
Een Team is een kleinere groep binnen een org. Teams modelleren echte werk-eenheden: Sales, Support, Finance of “Project A”. Teams vervangen niet de org-grens; ze leven daaronder.
Een User is een identiteit. Het is de login en het profiel van een persoon: e-mail, naam, wachtwoord of SSO-ID en eventueel MFA-instellingen. Een user kan bestaan zonder meteen toegang te hebben tot iets.
Een Membership is het toegangsrecord. Het beantwoordt: “Deze user hoort bij dit org (en optioneel dit team) met deze status en deze rollen.” Identiteit (User) gescheiden houden van toegang (Membership) maakt contractors, offboarding en multi-org toegang veel makkelijker te modelleren.
Eenvoudige betekenissen die je in code en UI kunt gebruiken:
- Member: een user met een actief membership in een org of team.
- Role: een benoemde bundel permissies (bijv. Org Admin, Team Manager).
- Permission: een enkele toegestane actie (bijv. “view invoices”).
- Tenant boundary: de regel dat data gescoped is naar een org.
Behandel membership als een kleine state machine, niet als een boolean. Typische staten zijn invited, active, suspended en removed. Zo blijven uitnodigingen, goedkeuringen en offboarding consistent en auditeerbaar.
Het enkele relationele model: kern-tabellen en relaties
Een goed multi-tenant schema begint met één idee: sla “wie waar hoort” op op één plek, en houd alles anders als ondersteunende tabellen. Op die manier kun je basisvragen beantwoorden (wie zit in het org, wie zit in een team, wat mogen ze doen) zonder te springen tussen ongeïntegreerde modellen.
Kern-tabellen die je meestal nodig hebt:
- organizations: één rij per klantaccount (tenant). Bevat naam, status, billing-velden en een onveranderlijk id.
- teams: groepen binnen een organisatie (Support, Sales, Admin). Behoort altijd tot één organisatie.
- users: één rij per persoon. Dit is globaal, niet per organisatie.
- memberships: de brug die zegt “deze user hoort bij dit organization” en optioneel “ook bij dit team.”
- role_grants (of role_assignments): welke rollen een membership heeft, op org-niveau, team-niveau of beide.
Houd keys en constraints strikt. Gebruik surrogate primary keys (UUIDs of bigints) voor elke tabel. Voeg foreign keys toe zoals teams.organization_id -> organizations.id en memberships.user_id -> users.id. Voeg vervolgens een paar unieke constraints toe om duplicaten te stoppen voordat ze in productie verschijnen.
Regels die de meeste slechte data vroeg vangen:
- Eén org-slug of externe sleutel:
unique(organizations.slug) - Teamnamen per org:
unique(teams.organization_id, teams.name) - Geen dubbel org-lidmaatschap:
unique(memberships.organization_id, memberships.user_id) - Geen dubbele team-lidmaatschappen (alleen als je team membership apart modelleert):
unique(team_memberships.team_id, team_memberships.user_id)
Bepaal wat append-only is versus wat updatebaar is. Organizations, teams en users zijn updatebaar. Memberships zijn vaak updatebaar voor de huidige staat (active, suspended), maar wijzigingen moeten ook naar een append-only access log schrijven zodat audits later eenvoudig zijn.
Uitnodigingen en lidmaatschapsstatussen die consistent blijven
De makkelijkste manier om toegang netjes te houden is uitnodigingen als eigen record te behandelen, niet als een half-maakte membership. Een membership betekent “deze user behoort momenteel”, een invitation betekent “we boden toegang aan, maar het is nog niet echt.” Ze gescheiden houden voorkomt ghost members, half-gecreëerde permissies en mysterieën zoals “wie nodigde deze persoon uit?”.
Een simpel, betrouwbaar staatmodel
Voor memberships gebruik je een kleine set staten die je iedereen kunt uitleggen:
- active: de gebruiker kan toegang krijgen tot het org (en alle teams waar ze lid van zijn)
- suspended: tijdelijk geblokkeerd, maar geschiedenis blijft intact
- removed: niet langer lid, bewaard voor audit en rapportage
Veel teams vermijden een membership-staat invited en houden invited strikt in de invitations-tabel. Dat is vaak netter: membership-rijen bestaan alleen voor gebruikers die daadwerkelijk toegang hebben (active), of die dat vroeger hadden (suspended/removed).
E-mailuitnodigingen voordat een account bestaat
B2B-apps nodigen vaak uit per e-mail wanneer er nog geen user-account bestaat. Bewaar de e-mail op het invitation-record, samen met waar de invite voor geldt (org of team), de bedoelde rol en wie het verstuurde. Als de persoon zich later aanmeldt met die e-mail, kun je openstaande invites matchen en ze laten accepteren.
Als een invite geaccepteerd wordt, handel dat in één transactie af: markeer de invitation als accepted, maak de membership aan en schrijf een audit-entry (wie accepteerde, wanneer en welke e-mail gebruikt werd).
Definieer duidelijke eindstaten voor uitnodigingen:
- expired: voorbij de deadline en niet meer te accepteren
- revoked: door een admin geannuleerd en niet meer te accepteren
- accepted: omgezet in een membership
Voorkom dubbele invites door “slechts één pending invite per org of team per e-mail” af te dwingen. Als je re-invites ondersteunt, verleng of update je de bestaande pending invite of intrek je de oude en geef je een nieuw token uit.
Rollen en erfelijkheid zonder toegang verwarrend te maken
De meeste B2B-apps hebben twee toegangslevels nodig: wat iemand in de organisatie als geheel kan doen, en wat ze binnen een specifiek team kunnen doen. Die in één role-veld stoppen is waar apps inconsistent worden.
Org-niveau rollen beantwoorden vragen zoals: kan deze persoon billing beheren, mensen uitnodigen of alle teams zien? Team-niveau rollen beantwoorden: kan deze persoon items in Team A bewerken, aanvragen in Team B goedkeuren of alleen bekijken?
Rol-erfenis is het makkelijkst als het één regel volgt: een org-rol geldt overal tenzij een team expliciet anders bepaalt. Dat houdt gedrag voorspelbaar en vermindert dubbele data.
Een nette manier om dit te modelleren is roltoewijzingen met een scope op te slaan:
role_assignments:user_id,org_id, optioneelteam_id(NULL betekent org-breed),role_id,created_at,created_by
Als je “één rol per scope” wilt afdwingen, voeg dan een unieke constraint toe op (user_id, org_id, team_id).
Dan wordt effectieve toegang voor een team:
-
Zoek een team-specifieke toewijzing (
team_id = X). Als die bestaat, gebruik die. -
Anders val terug op de org-brede toewijzing (
team_id IS NULL).
Voor least-privilege defaults kies je een minimale org-rol (vaak “Member”) en geef die geen verborgen admin-machten. Nieuwe gebruikers krijgen geen impliciete teamtoegang tenzij je product dat echt nodig heeft. Als je automatisch teamtoegang geeft, doe dat door expliciete team-memberships te creëren, niet door stilletjes de org-rol te verbreden.
Overrides moeten zeldzaam en duidelijk zijn. Voorbeeld: Maria is org “Manager” (kan uitnodigen, rapporten zien), maar in het Finance-team moet ze “Viewer” zijn. Je slaat één org-brede toewijzing voor Maria op en een team-gescopeerde override voor Finance. Geen permissie-kopieën, en de uitzondering is zichtbaar.
Rol-namen werken goed voor veelvoorkomende patronen. Gebruik expliciete permissies alleen voor echte uitzonderingen (zoals “mag exporteren maar niet bewerken”), of wanneer compliance een duidelijk overzicht van toegestane acties vereist. Houd ook dan hetzelfde scope-idee aan zodat het mentale model consistent blijft.
Auditvriendelijke wijzigingen: bijhouden wie toegang wijzigde
Als je alleen de huidige rol op een membership-rij bewaart, verlies je het verhaal. Als iemand vraagt: “Wie gaf Alex admin-toegang afgelopen dinsdag?” heb je geen betrouwbare antwoord. Je hebt wijzigingsgeschiedenis nodig, niet alleen de huidige staat.
De simpelste aanpak is een dedicated audit-log tabel die toegangsevenementen opneemt. Behandel het als een append-only journaal: je bewerkt oude audit-rijen nooit; je voegt alleen nieuwe toe.
Een praktische audit-tabel bevat meestal:
actor_user_id(wie de wijziging deed)subject_typeensubject_id(membership, team, org)action(invite_sent, role_changed, membership_suspended, team_deleted)occurred_at(wanneer het gebeurde)reason(optionele vrije tekst zoals “contractor offboarding”)
Om “voor” en “na” vast te leggen, sla je een kleine snapshot op van de velden die je belangrijk vindt. Houd het beperkt tot toegang-control data, niet volledige gebruikersprofielen. Bijvoorbeeld: before_role, after_role, before_state, after_state, before_team_id, after_team_id. Als je flexibiliteit wil, gebruik twee JSON-kolommen (before, after), maar houd de payload klein en consistent.
Voor memberships en teams is soft delete meestal beter dan hard delete. In plaats van de rij te verwijderen, markeer je hem als disabled met velden als deleted_at en deleted_by. Dat houdt foreign keys intact en maakt het makkelijker om vroegere toegang uit te leggen. Hard delete kan nog steeds zinvol zijn voor tijdelijke records (zoals verlopen invites), maar alleen als je zeker weet dat je ze later niet nodig hebt.
Met dit in place kun je veel compliancevragen snel beantwoorden:
- Wie gaf of verwijderde toegang, en wanneer?
- Wat veranderde precies (rol, team, status)?
- Werd toegang verwijderd als onderdeel van een normaal offboardingproces?
Stap-voor-stap: het schema ontwerpen in een relationele database
Begin simpel: één plek om te zeggen wie waar hoort, en waarom. Bouw het in kleine stappen en voeg regels toe zodat data niet kan vervormen naar “bijna correct”.
Een praktische volgorde die goed werkt in PostgreSQL en andere relationele databases:
-
Maak
organizationsenteams, elk met een stabiele primary key (UUID of bigint). Voegteams.organization_idals foreign key toe en beslis vroeg of teamnamen per org uniek moeten zijn. -
Houd
usersgescheiden van membership. Zet identiteitvelden inusers(email, status, created_at). Zet “hoort bij org/team” in eenmemberships-tabel metuser_id,organization_id, optioneleteam_id(als je het zo modelleert) en eenstate-kolom (active,suspended,removed). -
Voeg
invitationsals eigen tabel toe, niet als kolom op membership. Bewaarorganization_id, optioneleteam_id,email,token,expires_atenaccepted_at. Handhaaf uniciteit voor “één open invite per org + email + team” zodat je geen duplicaten aanmaakt. -
Modelleer rollen met expliciete tabellen. Een simpele aanpak is
roles(admin, member, etc.) plusrole_assignmentsdie wijzen naar org-scope (geenteam_id) of team-scope (team_idgezet). Houd erfelijkheidsregels consistent en testbaar. -
Voeg vanaf dag één een audit trail toe. Gebruik een
access_events-tabel metactor_user_id,target_user_id(of e-mail voor invites),action(invite_sent, role_changed, removed),scope(org/team) encreated_at.
Nadat deze tabellen bestaan, voer een paar basis admin-queries uit om de realiteit te valideren: “wie heeft org-brede toegang?”, “welke teams hebben geen admins?” en “welke invites zijn verlopen maar nog open?” Die vragen onthullen vroeg ontbrekende constraints.
Regels en constraints die rommelige data voorkomen
Een schema blijft beheersbaar als de database, niet alleen je code, tenant-grenzen afdwingt. De simpelste regel is: elke tenant-gescopede tabel draagt org_id, en elke lookup bevat die filter. Zelfs als iemand een filter vergeet in de app, moet de database cross-org verbindingen tegenhouden.
Guardrails die data schoon houden
Begin met foreign keys die altijd binnen hetzelfde org wijzen. Bijvoorbeeld, als je team-membership apart opslaat, moet een team_memberships-rij een team_id en een user_id refereren, maar ook org_id dragen. Met samengestelde keys kun je afdwingen dat het gerefereerde team tot hetzelfde org behoort.
Constraints die de meest voorkomende problemen voorkomen:
- Eén actief org-lidmaatschap per user per org: unieke constraint op
(org_id, user_id)met een partial condition voor actieve rijen (waar ondersteund). - Eén pending invite per e-mail per org of team: unieke constraint op
(org_id, team_id, email)waarstate = 'pending'. - Invite tokens zijn globaal uniek en worden nooit hergebruikt: unique op
invite_token. - Team hoort precies tot één org:
teams.org_idNOT NULL met foreign key naarorgs(id). - Beëindig memberships in plaats van verwijderen: sla
ended_atop (en optioneelended_by) om auditgeschiedenis te beschermen.
Indexering voor de lookups die je echt doet
Indexeer de queries die je app constant draait:
(org_id, user_id)voor “in welke orgs zit deze gebruiker?”(org_id, team_id)voor “lijst leden van dit team”(invite_token)voor “accepteer invite”(org_id, state)voor “pending invites” en “active members”
Houd org-namen wijzigbaar. Gebruik een onveranderlijk orgs.id overal en behandel orgs.name (en eventuele slug) als bewerkbare velden. Hernoemen raakt dan één rij.
Een team tussen orgs verplaatsen is meestal een beleidskeuze. De veiligste optie is het verbieden (of het team clonen) omdat memberships, rollen en auditgeschiedenis org-gescopeerd zijn. Als je het toch toestaat, doe het in één transactie en update alle child-rijen die org_id dragen.
Om verweesde records te voorkomen als gebruikers vertrekken, vermijd hard deletes. Deactiveer de gebruiker, beëindig hun memberships en beperk deletes op parent-rijen (ON DELETE RESTRICT) tenzij je cascadeverwijdering echt wilt.
Voorbeeldscenario: één org, twee teams, toegang veilig wijzigen
Stel je een bedrijf voor: Northwind Co met één org en twee teams: Sales en Support. Ze huren een contractor, Mia, in om een maand aan Support-tickets te werken. Dit is waar het model voorspelbaar moet blijven: één persoon, één org-membership, optionele team-memberships en duidelijke staten.
Een org-admin (Ava) nodigt Mia uit per e-mail. Het systeem maakt een invitation-rij aan gekoppeld aan het org, met status pending en een vervaldatum. Er verandert nog niets anders, dus er is geen “half-gebruikersaccount” met onduidelijke toegang.
Als Mia accepteert, wordt de invitation gemarkeerd als accepted en wordt een org-membership aangemaakt met state active. Ava zet Mia’s org-rol op member (niet admin). Daarna voegt Ava Mia toe aan het Support-team en wijst een team-rol toe zoals support_agent.
Voeg nu een twist toe: Ben is fulltime medewerker met org-rol admin, maar hij mag geen Support-data zien. Dat los je op met een team-level override die zijn rol voor Support expliciet downgrade terwijl zijn org-brede admin-mogelijkheden voor org-instellingen blijven.
Een week later overtreedt Mia het beleid en wordt ze geschorst. In plaats van rijen te verwijderen zet Ava Mia’s org-membership op suspended. Team-memberships kunnen blijven bestaan maar zijn niet effectief zolang het org-membership niet actief is.
De auditgeschiedenis blijft schoon omdat elke wijziging een event is:
- Ava nodigde Mia uit (wie, wat, wanneer)
- Mia accepteerde de invite
- Ava voegde Mia toe aan Support en wees
support_agenttoe - Ava zette Bens Support-override
- Ava schorste Mia
Met dit model kan de UI een duidelijke toegangssamenvatting tonen: org-status (active of suspended), org-rol, teamlijst met rollen en overrides en een “Recente toegangswijzigingen”-feed die uitlegt waarom iemand Sales of Support wel of niet kan zien.
Veelvoorkomende fouten en valkuilen om te vermijden
De meeste access-bugs komen van “bijna goed” datamodellen. Het schema lijkt in het begin prima, daarna stapelen edge-cases zich op: re-invites, team-verplaatsingen, rol-wijzigingen en offboarding.
Een veelvoorkomende valkuil is uitnodigingen en memberships in één rij mixen. Als je “invited” en “active” in hetzelfde record stopt zonder duidelijke betekenis, krijg je onmogelijke vragen zoals “Is deze persoon lid als ze nooit accepteerden?” Houd invitations en memberships gescheiden, of maak de state machine expliciet en consistent.
Een andere fout is één role-kolom op de user-tabel zetten en denken dat het af is. Rollen zijn bijna altijd gescoord (org-rol, team-rol, project-rol). Een globale rol dwingt hacks af zoals “gebruiker is admin voor één klant, maar alleen-lezen voor een andere”, wat multi-tenant verwachtingen breekt en support-hoofdpijn veroorzaakt.
Valkuilen die later vaak pijn doen:
- Per ongeluk cross-org team-membership toestaan (team_id wijst naar org A, membership naar org B).
- Hard deleting van memberships en het verliezen van “wie had vorige week toegang?”-historie.
- Missende uniciteitregels zodat een gebruiker duplicaattoegang krijgt via identieke rijen.
- Erfelijkheid stilletjes laten opstapelen (org admin plus team member plus override) zodat niemand kan verklaren waarom toegang bestaat.
- “Invite accepted” behandelen als een UI-event, niet als een databasefeit.
Een kort voorbeeld: een contractor wordt uitgenodigd voor een org, sluit zich aan bij Team Sales, wordt verwijderd en een maand later opnieuw uitgenodigd. Als je oude rij overschrijft, verlies je geschiedenis. Als je duplicaten toestaat, kunnen ze twee actieve memberships krijgen. Duidelijke staten, gescopeerde rollen en de juiste constraints voorkomen beide problemen.
Snelchecks en volgende stappen om dit in je app te bouwen
Voordat je gaat coderen, loop je model snel na en kijk je of het op papier nog steeds zinvol is. Een goed multi-tenant access-model voelt saai: dezelfde regels gelden overal en “special cases” zijn zeldzaam.
Een snelle checklist om veel voorkomende gaten te vinden:
- Elk membership verwijst precies naar één user en één org, met een unieke constraint om duplicaten te voorkomen.
- Invitation-, membership- en removal-staten zijn expliciet (niet geïmpliceerd door nulls) en transities zijn beperkt (bijv. je kunt geen verlopen invite accepteren).
- Rollen worden op één plek opgeslagen en effectieve toegang wordt consequent berekend (inclusief erfelijkheidsregels als je die gebruikt).
- Het verwijderen van orgs/teams/users wist de geschiedenis niet (gebruik soft delete of archiveringsvelden waar auditsporen nodig zijn).
- Elke toegangswijziging genereert een audit-event met actor, target, scope, timestamp en reden/bron.
Test het ontwerp met echte vragen. Als je deze niet met één query en een eenduidige regel kunt beantwoorden, heb je waarschijnlijk een constraint of extra staat nodig:
- Wat gebeurt er als een gebruiker twee keer wordt uitgenodigd en daarna de e-mail verandert?
- Kan een team-admin een org-eigenaar uit dat team verwijderen?
- Als een org-rol toegang geeft tot alle teams, kan één team dat overrulen?
- Als een invite wordt geaccepteerd nadat een rol is gewijzigd, welke rol geldt dan?
- Als support vraagt “wie verwijderde toegang”, kun je het snel bewijzen?
Schrijf op wat admins en supportpersoneel moeten begrijpen: membership-staten (en wat ze triggeren), wie kan uitnodigen/verwijderen, wat rol-erfelijkheid in gewone taal betekent en waar je audit-events zoekt tijdens een incident.
Implementeer eerst constraints (uniques, foreign keys, toegestane transities), bouw daar businesslogica omheen zodat de database je helpt eerlijk te blijven. Houd beleidskeuzes (erfenis aan/uit, standaardrollen, invite-expiry) in configuratietabellen in plaats van codeconstants.
Als je dit wilt bouwen zonder elke backend- en adminpagina met de hand te schrijven, kan AppMaster (appmaster.io) je helpen deze tabellen in PostgreSQL te modelleren en invite- en membership-transities als expliciete bedrijfsprocessen te implementeren, terwijl er nog steeds echte broncode voor productie deployments gegenereerd wordt.
FAQ
Gebruik een aparte membership-record zodat rollen en toegang gekoppeld zijn aan een org (en optioneel een team), niet aan de globale gebruikeridentiteit. Zo kan dezelfde persoon Admin zijn in het ene org en Viewer in een ander zonder hacks.
Houd ze gescheiden: een invitation is een aanbod met een e-mail, scope en vervaldatum, terwijl een membership betekent dat de gebruiker daadwerkelijk toegang heeft. Dit voorkomt “spookleden”, onduidelijke status en beveiligingsfouten als e-mails veranderen.
Een kleine set zoals active, suspended en removed is voor de meeste B2B-apps genoeg. Als je “invited” alleen in de invitations-tabel bewaart, blijven memberships ondubbelzinnig: ze representeren huidige of vroegere toegang, niet lopende uitnodigingen.
Sla org- en teamrollen op als toewijzingen met een scope (org-breed wanneer team_id null is, team-specifiek wanneer team_id is gezet). Bij toegang tot een team geef je voorrang aan de team-specifieke toewijzing; ontbreekt die, val je terug op de org-brede toewijzing.
Begin met één voorspelbare regel: org-rollen gelden standaard overal, en team-rollen overriden alleen wanneer ze expliciet zijn ingesteld. Houd overrides zeldzaam en zichtbaar zodat mensen toegang kunnen verklaren zonder te raden.
Handhaaf “slechts één open uitnodiging per org/team per e-mail” met een unieke constraint en een duidelijke lifecycle (pending/accepted/revoked/expired). Voor re-invites kun je de vervaldatum van de bestaande pending invite verlengen of de oude intrekken voordat je een nieuw token uitgeeft.
Elke tenant-gescopede rij moet org_id dragen en je foreign keys/constraints moeten mengen van orgs voorkomen (bijvoorbeeld: een team die door een membership wordt gerefereerd moet tot hetzelfde org behoren). Dit verkleint de impact van ontbrekende filters in applicatiecode.
Houd een append-only access event log bij die registreert wie wat deed, aan wie, wanneer en in welke scope (org of team). Leg de belangrijkste before/after velden vast (rol, state, team) zodat je betrouwbaar kunt aantonen “wie adminrechten gaf op dinsdag?”.
Vermijd hard deletes voor memberships en teams; markeer ze als beëindigd/disabled zodat de geschiedenis raadpleegbaar blijft en foreign keys niet breken. Voor invites kun je ze bewaren (zelfs als ze verlopen zijn) voor een volledig securitytraject; zorg in elk geval dat tokens niet hergebruikt worden.
Indexeer je hot paths: (org_id, user_id) voor org-membership checks, (org_id, team_id) voor team-lidlijsten, (invite_token) voor invite-acceptatie en (org_id, state) voor adminschermen zoals “active members” of “pending invites”. Indexen moeten je werkelijke queries weerspiegelen, niet elke kolom.


