39948-vm/documentation/project-memberships.md
2026-07-03 16:11:24 +02:00

21 KiB

Project Memberships

Complete documentation for the Tour Builder Platform's Project Memberships system including team collaboration, member roles, and access control per project.

Overview

The Project Memberships system enables team collaboration by managing user access to specific projects. It operates as a separate access layer from the application-level RBAC system, allowing granular per-project permissions.

┌─────────────────────────────────────────────────────────────────────────────┐
│                     Project Memberships Architecture                         │
│                                                                              │
│  ┌────────────────────────────────────────────────────────────────────────┐ │
│  │              Application-Level RBAC (roles table)                       │ │
│  │      Controls which users can manage project memberships                │ │
│  │                                                                         │ │
│  │   • PlatformOwner, Administrator: Full CRUD on memberships             │ │
│  │   • AccountManager: Create, Read, Update (no delete)                   │ │
│  │   • TourDesigner, ContentReviewer, AnalyticsViewer: Read only          │ │
│  └─────────────────────────────┬──────────────────────────────────────────┘ │
│                                │                                             │
│                   (permission to manage)                                     │
│                                │                                             │
│                                ▼                                             │
│  ┌────────────────────────────────────────────────────────────────────────┐ │
│  │              Project-Level Access (project_memberships)                 │ │
│  │      Controls what users can do within a specific project              │ │
│  │                                                                         │ │
│  │   Project A                      Project B                              │ │
│  │   ┌─────────────────────┐       ┌─────────────────────┐                │ │
│  │   │ User1: owner        │       │ User2: owner        │                │ │
│  │   │ User2: editor       │       │ User3: viewer       │                │ │
│  │   │ User3: reviewer     │       │ User4: editor       │                │ │
│  │   └─────────────────────┘       └─────────────────────┘                │ │
│  └────────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘

Data Model

Database Schema

Table: project_memberships

Field Type Description
id UUID Primary key (auto-generated)
access_level ENUM Project role: owner, editor, reviewer, viewer
is_active BOOLEAN Whether membership is active (default: false)
invited_at DATE When invitation was sent
accepted_at DATE When user accepted invitation
projectId UUID Foreign key to projects table
userId UUID Foreign key to users table
createdAt TIMESTAMP Record creation time
updatedAt TIMESTAMP Record update time
deletedAt TIMESTAMP Soft delete timestamp (paranoid mode)
importHash STRING(255) Unique hash for CSV imports

Source: backend/src/db/models/project_memberships.js

Database Indexes

indexes: [
  { fields: ['projectId'] },              // Fast project lookups
  { fields: ['userId'] },                 // Fast user lookups
  { fields: ['projectId', 'userId'], unique: true },  // One membership per user-project
  { fields: ['is_active'] },              // Filter by active status
  { fields: ['deletedAt'] },              // Soft delete queries
]

Relationships

// Each membership belongs to one project
db.project_memberships.belongsTo(db.projects, {
  as: 'project',
  foreignKey: 'projectId',
  onDelete: 'CASCADE',  // Delete membership when project is deleted
  onUpdate: 'CASCADE',
});

// Each membership belongs to one user
db.project_memberships.belongsTo(db.users, {
  as: 'user',
  foreignKey: 'userId',
  onDelete: 'CASCADE',  // Delete membership when user is deleted
  onUpdate: 'CASCADE',
});

// Audit trail associations
db.project_memberships.belongsTo(db.users, { as: 'createdBy' });
db.project_memberships.belongsTo(db.users, { as: 'updatedBy' });

Project Access Levels

ENUM Values

Level Description Typical Permissions
owner Full project control All operations, can delete project, manage team
editor Content editing Edit pages, elements, assets, transitions
reviewer Review only View all content, add comments/feedback
viewer Read-only View published content only

Default: viewer (when not specified)

Access Level Hierarchy

owner → editor → reviewer → viewer
  │        │         │         │
  │        │         │         └── View published content
  │        │         └── View all content + provide feedback
  │        └── Edit content + reviewer permissions
  └── Full control + editor permissions + manage team

Invitation Workflow

Membership States

┌─────────────────────────────────────────────────────────────────────┐
│                     Invitation Lifecycle                             │
│                                                                      │
│  1. Membership Created                                               │
│     ├── invited_at: timestamp                                        │
│     ├── accepted_at: null                                            │
│     └── is_active: false                                             │
│                                                                      │
│                    ↓                                                 │
│                                                                      │
│  2. User Accepts Invitation                                          │
│     ├── invited_at: original timestamp                               │
│     ├── accepted_at: timestamp                                       │
│     └── is_active: true                                              │
│                                                                      │
│                    ↓                                                 │
│                                                                      │
│  3. Membership Active                                                │
│     └── User can access project with assigned access_level          │
│                                                                      │
│                    ↓ (optional)                                      │
│                                                                      │
│  4. Membership Revoked (soft delete)                                 │
│     └── deletedAt: timestamp (paranoid mode)                        │
└─────────────────────────────────────────────────────────────────────┘

Timestamp Fields

Field Set When Purpose
invited_at Membership created Track when invitation was sent
accepted_at User accepts Track when user joined project
is_active User accepts Quick filter for active members

API Reference

Endpoint

Base URL: /api/project_memberships

Source: backend/src/routes/project_memberships.ts

Standard Operations

Method Endpoint Description
GET /api/project_memberships List all memberships
GET /api/project_memberships/:id Get single membership
POST /api/project_memberships Create membership (invite user)
PUT /api/project_memberships/:id Update membership
DELETE /api/project_memberships/:id Delete membership (soft delete)

Request/Response Format

Create Membership:

POST /api/project_memberships
{
  "data": {
    "project": "project-uuid",
    "user": "user-uuid",
    "access_level": "editor",
    "invited_at": "2024-01-15T10:00:00Z"
  }
}

Query with Filters:

GET /api/project_memberships?project=<uuid>&access_level=editor&is_active=true

Filtering Options

Parameter Type Description
project UUID/name Filter by project ID or name (iLike search)
user UUID/firstName Filter by user ID or firstName (iLike search)
access_level ENUM Filter by access level
is_active boolean Filter by active status
invited_atRange [start, end] Filter by invitation date range
accepted_atRange [start, end] Filter by acceptance date range
createdAtRange [start, end] Filter by record creation date range

Source: backend/src/db/api/project_memberships.ts

Application-Level Permissions

Permission Names

The RBAC system uses these permissions to control who can manage memberships:

Permission Description
CREATE_PROJECT_MEMBERSHIPS Invite users to projects
READ_PROJECT_MEMBERSHIPS View project team members
UPDATE_PROJECT_MEMBERSHIPS Change user access levels
DELETE_PROJECT_MEMBERSHIPS Remove users from projects

Role-Permission Matrix

Role CREATE READ UPDATE DELETE
PlatformOwner
Administrator
AccountManager
TourDesigner
ContentReviewer
AnalyticsViewer

Source: backend/src/db/seeders/20200430130760-user-roles.js

Frontend Integration

Redux State Management

Slice: frontend/src/stores/project_memberships/project_membershipsSlice.ts

import { createEntitySlice } from '../createEntitySlice';
import type { ProjectMembership } from '../../types/entities';

const { slice, actions, reducer } = createEntitySlice<ProjectMembership>({
  name: 'project_memberships',
  endpoint: 'project_memberships',
  singularName: 'Project Membership',
});

export const {
  fetch,
  create,
  update,
  deleteItem,
  deleteItemsByIds,
  uploadCsv,
  setRefetch,
} = actions;

TypeScript Interface

Source: frontend/src/types/entities.ts

export interface ProjectMembership extends BaseEntity {
  user?: User | string | null;
  project?: Project | string | null;
  access_level?: 'owner' | 'editor' | 'reviewer' | 'viewer';
  is_active?: boolean;
  invited_at?: string | Date | null;
  accepted_at?: string | Date | null;
  // Legacy field
  role?: 'owner' | 'editor' | 'viewer';
}

Admin Pages

Location: frontend/src/pages/project_memberships/

Page Path Description
List /project_memberships/project_memberships-list Paginated table with filters (factory-generated)
Table /project_memberships/project_memberships-table Alternative table view (manual implementation)
New /project_memberships/project_memberships-new Create membership form
Edit (query) /project_memberships/project_memberships-edit?id=<uuid> Edit membership form (query param)
Edit (path) /project_memberships/<uuid> Edit membership form (path param)
View /project_memberships/project_memberships-view?id=<uuid> Read-only details

Note: Two edit routes exist - query-based (?id=) and dynamic path-based ([project_membershipsId].tsx).

Table Columns: project, user, access_level, is_active, invited_at, accepted_at, actions

List Filters: access_level (enum dropdown with owner/editor/reviewer/viewer options), project (text search), user (text search), invited_at (date range), accepted_at (date range)

Note: is_active filter is supported by the API but not exposed in the admin list UI.

Components: frontend/src/components/Project_memberships/

  • TableProject_memberships.tsx - Data grid component (23 LOC)
  • CardProject_memberships.tsx - Card view component (163 LOC)
  • ListProject_memberships.tsx - List view component (125 LOC)
  • configureProject_membershipsCols.tsx - Column definitions with permission-based editability (51 LOC)

Usage Example

import { useAppDispatch, useAppSelector } from '../stores/hooks';
import { fetch, create, update, deleteItem } from '../stores/project_memberships/project_membershipsSlice';

// Fetch project members
dispatch(fetch({ query: `?project=${projectId}&is_active=true` }));

// Invite user to project
dispatch(create({
  project: projectId,
  user: userId,
  access_level: 'editor',
  invited_at: new Date().toISOString(),
}));

// Update access level
dispatch(update({
  id: membershipId,
  access_level: 'reviewer',
}));

// Remove from project
dispatch(deleteItem(membershipId));

Unique Constraint

The composite unique index on (projectId, userId) ensures:

  • Each user can only have one membership per project
  • Attempting to invite the same user twice will fail
  • To change a user's role, update the existing membership
-- This constraint prevents duplicate memberships
CREATE UNIQUE INDEX ON project_memberships (projectId, userId);

Soft Delete Behavior

The model uses Sequelize's paranoid mode:

  • DELETE operations set deletedAt timestamp instead of removing record
  • Default queries exclude soft-deleted records
  • Membership history is preserved for audit purposes
{
  timestamps: true,
  paranoid: true,  // Enables soft delete
}

Known Considerations

  1. Two-Tier Access System: Application-level roles control who can manage memberships; project-level access controls what users can do within projects.

  2. Invitation Flow: Memberships start with is_active: false. The system tracks invited_at and accepted_at separately to support invitation workflows.

  3. Legacy Field: The TypeScript interface includes a role field for backward compatibility, but the database uses access_level ENUM.

  4. Cascade Deletes: Memberships are automatically deleted when their associated project or user is deleted.

  5. Dedicated Admin Pages: Standard CRUD pages exist at /project_memberships/*:

    • project_memberships-list.tsx - List with filters using factory pattern (35 LOC)
    • project_memberships-table.tsx - Alternative table view (185 LOC, manual implementation)
    • project_memberships-new.tsx - Create new membership (149 LOC)
    • project_memberships-edit.tsx - Edit via query param (200 LOC)
    • [project_membershipsId].tsx - Edit via path param (221 LOC)
    • project_memberships-view.tsx - View membership details (163 LOC)
    • Components: TableProject_memberships, CardProject_memberships, ListProject_memberships
  6. Default Access Level: New memberships default to viewer access, the most restrictive level.

  7. Autocomplete Field: The DB API uses access_level for autocomplete searches, enabling dropdown-style user interfaces.

Data Flow Diagram

┌─────────────────────────────────────────────────────────────────────────────┐
│                       Team Collaboration Flow                                │
│                                                                              │
│  Project Owner                    Backend API                     Database  │
│       │                               │                               │     │
│       │ POST /project_memberships     │                               │     │
│       │ {user, project, editor}       │                               │     │
│       │──────────────────────────────>│                               │     │
│       │                               │                               │     │
│       │                               │ Check CREATE_PROJECT_         │     │
│       │                               │ MEMBERSHIPS permission        │     │
│       │                               │                               │     │
│       │                               │ Validate unique constraint    │     │
│       │                               │──────────────────────────────>│     │
│       │                               │                               │     │
│       │                               │ Create membership record      │     │
│       │                               │ invited_at: now               │     │
│       │                               │ is_active: false              │     │
│       │                               │<──────────────────────────────│     │
│       │                               │                               │     │
│       │<──────────────────────────────│                               │     │
│       │ 200 OK {membership}           │                               │     │
│                                                                              │
│  ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─  │
│                                                                              │
│  Invited User                                                               │
│       │ PUT /project_memberships/:id                                        │
│       │ {is_active: true}             │                               │     │
│       │──────────────────────────────>│                               │     │
│       │                               │                               │     │
│       │                               │ Update membership             │     │
│       │                               │ accepted_at: now              │     │
│       │                               │ is_active: true               │     │
│       │                               │──────────────────────────────>│     │
│       │                               │<──────────────────────────────│     │
│       │<──────────────────────────────│                               │     │
│       │                               │                               │     │
│       │ User can now access project   │                               │     │
│       │ with 'editor' permissions     │                               │     │
└─────────────────────────────────────────────────────────────────────────────┘