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_activefilter 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:
DELETEoperations setdeletedAttimestamp 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
-
Two-Tier Access System: Application-level roles control who can manage memberships; project-level access controls what users can do within projects.
-
Invitation Flow: Memberships start with
is_active: false. The system tracksinvited_atandaccepted_atseparately to support invitation workflows. -
Legacy Field: The TypeScript interface includes a
rolefield for backward compatibility, but the database usesaccess_levelENUM. -
Cascade Deletes: Memberships are automatically deleted when their associated project or user is deleted.
-
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
-
Default Access Level: New memberships default to
vieweraccess, the most restrictive level. -
Autocomplete Field: The DB API uses
access_levelfor 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 │ │ │
└─────────────────────────────────────────────────────────────────────────────┘