Entity Relationships and Data Model
Architecture Reference
For full diagrams covering the entity model, permission evaluation, and system components, read:
- Functional (org structure, roles, invitation flows):
docu/docs/domain/system-architecture.md - Technical (ERD, JPQL permission queries, system components):
docu/docs/technical/architecture.md
Core Entities
- User: One account per real person. Has profile, appearance, notifications, billing, addresses, emails, members (to teams), profileSharings.
superadminboolean for global admin. - Organization: Has profile, billing, addresses, workspaces, and teams. Teams belong directly to the org — not to a workspace.
- Workspace (base class): Shared fields — name, slug, plan, description, logoUrl, workspaceType. Has
@TransientdelegatesgetOrgId(),getOrganization(),getUserId(),getUser()for backwards-compat. - OrgWorkspace (extends Workspace): Org-owned workspace. Owns
orgId,organizationFK,workspacePurpose(STAFF/CLIENT/MIXED),publicProfileFK. Stored in joined tableorg_workspace. - UserWorkspace (extends Workspace): Personal workspace. Owns
userId,userFK. Stored in joined tableuser_workspace. - Team: Org-scoped (post-refactor — not workspace-scoped). Has
orgId,baseRole(OWNER/ADMIN/MANAGER/MEMBER),teamType(DEFAULT/CLIENT/STAFF). Assigned to workspaces viaTeamWorkspaceAssignment. - TeamWorkspaceAssignment: Join table
(teamId, workspaceId). Grants all team members access to a workspace. Many-to-many between Team and OrgWorkspace. - Member (= TeamMember in concept): Links User to Team. Has
roleOverride(nullable) — overridesteam.baseRolefor this individual when set. Effective role =COALESCE(roleOverride, team.baseRole). Hasstatus(INVITED | ACTIVE | INACTIVE | SUSPENDED);sentDate,expiresAt,reminderSentare nullable and used only whenstatus = INVITED. There is no separate invitation entity — invitation state is tracked directly on this record. - Client: External applicant.
orgIdFK — directly assigned to anOrganization(not workspace-scoped). No team membership needed. Hasstatus(INVITED | ACTIVE | INACTIVE | SUSPENDED);sentDate,expiresAt,reminderSentare nullable and used only whenstatus = INVITED. Queried viaclients(orgId: ID)(filterable by status). There is no separate invitation entity — invitation state is tracked directly on this record. - OrgRelationship: Formal org-to-org link.
sourceOrgId,targetOrgId,type(AGENT/SUBCONTRACTOR),status(ACTIVE/INACTIVE). - OrgPublicProfile: Public-facing org profile per workspace. Each
OrgWorkspacehas exactly one.synced=truemirrors private org profile automatically. - BootstrapInfo: Contains User and List
for initial frontend load.
Relationships
- User → Organization: Many-to-many via
Memberrecords (no direct FK). - User → Workspace: Via
UserWorkspace(personal, auto-created on signup). - User → Team: Via
Member(user is member of one or more teams). - Organization → Workspace: 1-m via
OrgWorkspace. - Organization → Team: 1-m directly —
team.orgIdFK. Teams are NOT workspace-scoped. This changed in the IAM & Membership Refactor (March 2026). - Team → Workspace: Many-to-many via
TeamWorkspaceAssignment. - Team → Member: 1-m (each member has optional
roleOverride). - Client → Organization: m-1 direct FK (
client.orgId). Clients are org-scoped — not workspace-scoped. - OrgWorkspace → OrgPublicProfile: 1-1 (invariant — always must exist).
Workspace Inheritance — Critical Rules
- Always instantiate the correct subclass: use
OrgWorkspacefor org workspaces,UserWorkspacefor personal. Never instantiate plainWorkspacedirectly. - WorkspaceRepository queries for subclass fields must target the subclass in JPQL:
SELECT w FROM OrgWorkspace w WHERE w.orgId = :orgId. UseTYPE(w) = OrgWorkspacefor filtering in polymorphic queries. - Casting: Use
instanceof OrgWorkspace owpattern matching to access org-specific fields from aWorkspacereference. - Do NOT add new columns directly to the
Workspacebase class if they are type-specific. Put them inOrgWorkspaceorUserWorkspaceinstead. OrgWorkspace.orgIdisinsertable=false, updatable=false— populated from DB on load, but null in-memory aftersave(). When you need the orgId immediately after creating an OrgWorkspace, useorgWs.getOrganization().getId()instead.
Defaults and Lifecycle
- Each user gets a
UserWorkspaceand a default team on signup. - Each org creation produces: one
OrgWorkspace(purpose=STAFF), oneOrgPublicProfile, oneTeam(type=DEFAULT, baseRole=OWNER), oneTeamWorkspaceAssignment, oneMember(creator, status=ACTIVE). - CLIENT team is no longer auto-created. A CLIENT-purpose workspace is set up separately.
- When an
OrgWorkspaceis created, anOrgPublicProfileis always created immediately (viaOrgPublicProfileService.sync()). There must never be anOrgWorkspacewithout anOrgPublicProfile. - User is always a member of at least one team per org they belong to.