# 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=&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({ 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=` | Edit membership form (query param) | | Edit (path) | `/project_memberships/` | Edit membership form (path param) | | View | `/project_memberships/project_memberships-view?id=` | 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 │ │ │ └─────────────────────────────────────────────────────────────────────────────┘ ```