Skip to main content

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. superadmin boolean 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 @Transient delegates getOrgId(), getOrganization(), getUserId(), getUser() for backwards-compat.
  • OrgWorkspace (extends Workspace): Org-owned workspace. Owns orgId, organization FK, workspacePurpose (STAFF/CLIENT/MIXED), publicProfile FK. Stored in joined table org_workspace.
  • UserWorkspace (extends Workspace): Personal workspace. Owns userId, user FK. Stored in joined table user_workspace.
  • Team: Org-scoped (post-refactor — not workspace-scoped). Has orgId, baseRole (OWNER/ADMIN/MANAGER/MEMBER), teamType (DEFAULT/CLIENT/STAFF). Assigned to workspaces via TeamWorkspaceAssignment.
  • 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) — overrides team.baseRole for this individual when set. Effective role = COALESCE(roleOverride, team.baseRole). Has status (INVITED | ACTIVE | INACTIVE | SUSPENDED); sentDate, expiresAt, reminderSent are nullable and used only when status = INVITED. There is no separate invitation entity — invitation state is tracked directly on this record.
  • Client: External applicant. orgId FK — directly assigned to an Organization (not workspace-scoped). No team membership needed. Has status (INVITED | ACTIVE | INACTIVE | SUSPENDED); sentDate, expiresAt, reminderSent are nullable and used only when status = INVITED. Queried via clients(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 OrgWorkspace has exactly one. synced=true mirrors private org profile automatically.
  • BootstrapInfo: Contains User and List for initial frontend load.

Relationships

  • User → Organization: Many-to-many via Member records (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 directlyteam.orgId FK. 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 OrgWorkspace for org workspaces, UserWorkspace for personal. Never instantiate plain Workspace directly.
  • WorkspaceRepository queries for subclass fields must target the subclass in JPQL: SELECT w FROM OrgWorkspace w WHERE w.orgId = :orgId. Use TYPE(w) = OrgWorkspace for filtering in polymorphic queries.
  • Casting: Use instanceof OrgWorkspace ow pattern matching to access org-specific fields from a Workspace reference.
  • Do NOT add new columns directly to the Workspace base class if they are type-specific. Put them in OrgWorkspace or UserWorkspace instead.
  • OrgWorkspace.orgId is insertable=false, updatable=false — populated from DB on load, but null in-memory after save(). When you need the orgId immediately after creating an OrgWorkspace, use orgWs.getOrganization().getId() instead.

Defaults and Lifecycle

  • Each user gets a UserWorkspace and a default team on signup.
  • Each org creation produces: one OrgWorkspace (purpose=STAFF), one OrgPublicProfile, one Team (type=DEFAULT, baseRole=OWNER), one TeamWorkspaceAssignment, one Member (creator, status=ACTIVE).
  • CLIENT team is no longer auto-created. A CLIENT-purpose workspace is set up separately.
  • When an OrgWorkspace is created, an OrgPublicProfile is always created immediately (via OrgPublicProfileService.sync()). There must never be an OrgWorkspace without an OrgPublicProfile.
  • User is always a member of at least one team per org they belong to.