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

430 lines
21 KiB
Markdown

# 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
```javascript
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
```javascript
// 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:**
```json
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`
```typescript
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`
```typescript
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
```typescript
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
```sql
-- 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
```javascript
{
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 │ │ │
└─────────────────────────────────────────────────────────────────────────────┘
```