├── .cursorrules ├── .env.example ├── .eslintrc.cjs ├── .gitignore ├── .npmrc ├── .prettierrc.json ├── .vscode └── settings.json ├── .yarnrc ├── Dockerfile ├── README.md ├── docker-compose.yml ├── docs ├── collections.md ├── guide.md ├── outline.md ├── phase-1.md ├── phase-2.md ├── phase-3.md ├── phase-4.md ├── phase-5.md ├── phase-6.md ├── phase-7.md ├── phase-8.md ├── phase-9.md └── uml-diagram.puml ├── drizzle.config.ts ├── drizzle ├── 0000_flippant_silver_surfer.sql └── meta │ ├── 0000_snapshot.json │ └── _journal.json ├── next.config.mjs ├── package.json ├── pnpm-lock.yaml ├── src ├── access │ └── roles.ts ├── app │ ├── (payload) │ │ ├── admin │ │ │ ├── [[...segments]] │ │ │ │ ├── not-found.tsx │ │ │ │ └── page.tsx │ │ │ └── importMap.js │ │ ├── api │ │ │ ├── [...slug] │ │ │ │ └── route.ts │ │ │ ├── graphql-playground │ │ │ │ └── route.ts │ │ │ └── graphql │ │ │ │ └── route.ts │ │ ├── custom.scss │ │ └── layout.tsx │ └── actions │ │ └── achievements.ts ├── collections │ ├── Achievements.ts │ ├── Badges.ts │ ├── Courses.ts │ ├── Enrollments.ts │ ├── Leaderboards.ts │ ├── Lessons.ts │ ├── Levels.ts │ ├── Media.ts │ ├── Modules.ts │ ├── Points.ts │ ├── Progress.ts │ ├── Streaks.ts │ ├── StudentSettings.ts │ ├── Tenants.ts │ ├── Users.ts │ └── index.ts ├── hooks │ └── useAchievements.ts ├── lib │ ├── achievements │ │ ├── awardAchievement.ts │ │ ├── checkPrerequisites.ts │ │ ├── checkProgress.ts │ │ └── getProgress.ts │ ├── notifications │ │ └── createNotification.ts │ ├── payload │ │ └── editor.ts │ └── pusher.ts ├── payload-types.ts └── payload.config.ts └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_URI=mongodb://127.0.0.1/payload-template-blank-3-0 2 | PAYLOAD_SECRET=YOUR_SECRET_HERE 3 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('eslint').Linter.Config} */ 2 | module.exports = { 3 | extends: ['next/core-web-vitals'], 4 | parserOptions: { 5 | project: ['./tsconfig.json'], 6 | tsconfigRootDir: __dirname, 7 | }, 8 | } 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | /.idea/* 10 | !/.idea/runConfigurations 11 | 12 | # testing 13 | /coverage 14 | 15 | # next.js 16 | /.next/ 17 | /out/ 18 | 19 | # production 20 | /build 21 | 22 | # misc 23 | .DS_Store 24 | *.pem 25 | 26 | # debug 27 | npm-debug.log* 28 | yarn-debug.log* 29 | yarn-error.log* 30 | 31 | # local env files 32 | /.env*.local 33 | 34 | # vercel 35 | .vercel 36 | 37 | # typescript 38 | *.tsbuildinfo 39 | next-env.d.ts 40 | 41 | /.env 42 | 43 | /media 44 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | legacy-peer-deps=true -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "printWidth": 100, 5 | "semi": false 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "workbench.colorCustomizations": { 3 | "activityBar.activeBackground": "#5ccac4", 4 | "activityBar.background": "#5ccac4", 5 | "activityBar.foreground": "#15202b", 6 | "activityBar.inactiveForeground": "#15202b99", 7 | "activityBarBadge.background": "#b93fc0", 8 | "activityBarBadge.foreground": "#e7e7e7", 9 | "commandCenter.border": "#15202b99", 10 | "sash.hoverBorder": "#5ccac4", 11 | "statusBar.background": "#3cb7b1", 12 | "statusBar.foreground": "#15202b", 13 | "statusBarItem.hoverBackground": "#2f918c", 14 | "statusBarItem.remoteBackground": "#3cb7b1", 15 | "statusBarItem.remoteForeground": "#15202b", 16 | "titleBar.activeBackground": "#3cb7b1", 17 | "titleBar.activeForeground": "#15202b", 18 | "titleBar.inactiveBackground": "#3cb7b199", 19 | "titleBar.inactiveForeground": "#15202b99" 20 | }, 21 | "peacock.color": "#3cb7b1" 22 | } 23 | -------------------------------------------------------------------------------- /.yarnrc: -------------------------------------------------------------------------------- 1 | --install.ignore-engines true 2 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # From https://github.com/vercel/next.js/blob/canary/examples/with-docker/Dockerfile 2 | 3 | FROM node:18-alpine AS base 4 | 5 | # Install dependencies only when needed 6 | FROM base AS deps 7 | # Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. 8 | RUN apk add --no-cache libc6-compat 9 | WORKDIR /app 10 | 11 | # Install dependencies based on the preferred package manager 12 | COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./ 13 | RUN \ 14 | if [ -f yarn.lock ]; then yarn --frozen-lockfile; \ 15 | elif [ -f package-lock.json ]; then npm ci; \ 16 | elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \ 17 | else echo "Lockfile not found." && exit 1; \ 18 | fi 19 | 20 | 21 | # Rebuild the source code only when needed 22 | FROM base AS builder 23 | WORKDIR /app 24 | COPY --from=deps /app/node_modules ./node_modules 25 | COPY . . 26 | 27 | # Next.js collects completely anonymous telemetry data about general usage. 28 | # Learn more here: https://nextjs.org/telemetry 29 | # Uncomment the following line in case you want to disable telemetry during the build. 30 | # ENV NEXT_TELEMETRY_DISABLED 1 31 | 32 | RUN \ 33 | if [ -f yarn.lock ]; then yarn run build; \ 34 | elif [ -f package-lock.json ]; then npm run build; \ 35 | elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \ 36 | else echo "Lockfile not found." && exit 1; \ 37 | fi 38 | 39 | # Production image, copy all the files and run next 40 | FROM base AS runner 41 | WORKDIR /app 42 | 43 | ENV NODE_ENV production 44 | # Uncomment the following line in case you want to disable telemetry during runtime. 45 | # ENV NEXT_TELEMETRY_DISABLED 1 46 | 47 | RUN addgroup --system --gid 1001 nodejs 48 | RUN adduser --system --uid 1001 nextjs 49 | 50 | COPY --from=builder /app/public ./public 51 | 52 | # Set the correct permission for prerender cache 53 | RUN mkdir .next 54 | RUN chown nextjs:nodejs .next 55 | 56 | # Automatically leverage output traces to reduce image size 57 | # https://nextjs.org/docs/advanced-features/output-file-tracing 58 | COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ 59 | COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static 60 | 61 | USER nextjs 62 | 63 | EXPOSE 3000 64 | 65 | ENV PORT 3000 66 | 67 | # server.js is created by next build from the standalone output 68 | # https://nextjs.org/docs/pages/api-reference/next-config-js/output 69 | CMD HOSTNAME="0.0.0.0" node server.js 70 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # blank 2 | 3 | blank 4 | 5 | ## Attributes 6 | 7 | - **Database**: mongodb 8 | - **Storage Adapter**: localDisk 9 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | payload: 5 | image: node:18-alpine 6 | ports: 7 | - '3000:3000' 8 | volumes: 9 | - .:/home/node/app 10 | - node_modules:/home/node/app/node_modules 11 | working_dir: /home/node/app/ 12 | command: sh -c "corepack enable && corepack prepare pnpm@latest --activate && pnpm install && pnpm dev" 13 | depends_on: 14 | - mongo 15 | # - postgres 16 | env_file: 17 | - .env 18 | 19 | # Ensure your DATABASE_URI uses 'mongo' as the hostname ie. mongodb://mongo/my-db-name 20 | mongo: 21 | image: mongo:latest 22 | ports: 23 | - '27017:27017' 24 | command: 25 | - --storageEngine=wiredTiger 26 | volumes: 27 | - data:/data/db 28 | logging: 29 | driver: none 30 | 31 | # Uncomment the following to use postgres 32 | # postgres: 33 | # restart: always 34 | # image: postgres:latest 35 | # volumes: 36 | # - pgdata:/var/lib/postgresql/data 37 | # ports: 38 | # - "5432:5432" 39 | 40 | volumes: 41 | data: 42 | # pgdata: 43 | node_modules: 44 | -------------------------------------------------------------------------------- /docs/guide.md: -------------------------------------------------------------------------------- 1 | # Multi-Tenant LMS Implementation Guide 2 | 3 | ## Phase 1: Project Setup and Core Infrastructure 4 | 5 | ### 1.1 Initial Setup 6 | 1. Create a new Payload project 7 | ```bash 8 | pnpm create payload-app lms-mvp 9 | ``` 10 | 11 | When prompted, select: 12 | - Template: `blank` 13 | - Database: `Postgres` 14 | - Authentication: `Email / Password` 15 | - Bundle: `Webpack` 16 | - TypeScript: `Yes` 17 | - Install dependencies with pnpm: `Yes` 18 | 19 | 2. Install Next.js dependencies 20 | ```bash 21 | pnpm add next@latest react@latest react-dom@latest 22 | pnpm add -D @types/react @types/react-dom 23 | ``` 24 | 25 | 3. Set up Neon Database and update environment variables 26 | ```bash 27 | # Update DATABASE_URI in .env with your Neon connection string 28 | ``` 29 | 30 | 4. Install additional recommended dependencies 31 | ```bash 32 | # UI Components 33 | pnpm add @radix-ui/react-* shadcn-ui 34 | 35 | # Forms and Validation 36 | pnpm add react-hook-form zod @hookform/resolvers 37 | 38 | # Date handling 39 | pnpm add date-fns 40 | 41 | # State Management 42 | pnpm add zustand 43 | 44 | # Type generation for Payload 45 | pnpm add -D payload-types-generator 46 | ``` 47 | 48 | Important flags for `create-next-app` explained: 49 | - `--app`: Uses the App Router (default in Next.js 14) 50 | - `--import-alias "@"`: Sets up `@` import alias for cleaner imports 51 | - `--use-pnpm`: Configures the project to use pnpm 52 | - `--typescript`: Enables TypeScript support 53 | - `--tailwind`: Sets up Tailwind CSS 54 | 55 | ### 1.2 Authentication and Access Control 56 | 1. Configure Payload authentication with tenant isolation 57 | 2. Implement role-based access control (Admin, Instructor, Student) 58 | 3. Set up JWT token handling for API access 59 | 4. Create middleware for tenant subdomain routing 60 | 61 | ### 1.3 Core Collections Setup 62 | 1. Implement base collections: 63 | - Tenants 64 | - Users 65 | - Media 66 | - TenantSettings 67 | - StudentSettings 68 | 69 | 2. Set up tenant isolation logic 70 | 3. Configure admin panel grouping 71 | 4. Implement file upload handling 72 | 73 | ## Phase 2: Learning Content Structure 74 | 75 | ### 2.1 Course Management 76 | 1. Implement Courses collection with: 77 | - Basic course information 78 | - Start/end dates 79 | - Tenant relationships 80 | - Access control 81 | 82 | 2. Create course management interfaces: 83 | - Course creation/editing 84 | - Module organization 85 | - Content scheduling 86 | 87 | ### 2.2 Content Structure 88 | 1. Implement content collections: 89 | - Modules 90 | - Lessons 91 | - Quizzes 92 | - Assignments 93 | - Submissions 94 | 95 | 2. Set up content relationships and ordering 96 | 3. Create rich text editor configuration 97 | 4. Implement media handling for content 98 | 99 | ### 2.3 Progress Tracking 100 | 1. Implement Progress collection 101 | 2. Create progress tracking hooks 102 | 3. Set up completion criteria 103 | 4. Implement progress visualization 104 | 105 | ## Phase 3: Gamification System 106 | 107 | ### 3.1 Points and Levels 108 | 1. Implement point system: 109 | - Configure point values 110 | - Create point calculation hooks 111 | - Set up level progression 112 | 113 | 2. Create point tracking interfaces: 114 | - Point history 115 | - Level progress 116 | - Achievement tracking 117 | 118 | ### 3.2 Achievements and Badges 119 | 1. Implement Badges collection 120 | 2. Create achievement criteria system 121 | 3. Set up badge awarding hooks 122 | 4. Design badge UI components 123 | 124 | ### 3.3 Leaderboards 125 | 1. Implement Leaderboard collection 126 | 2. Create leaderboard calculation system 127 | 3. Set up timeframe-based rankings 128 | 4. Implement real-time updates 129 | 130 | ### 3.4 Streaks 131 | 1. Implement streak tracking 132 | 2. Create streak calculation hooks 133 | 3. Set up streak bonuses 134 | 4. Design streak UI components 135 | 136 | ## Phase 4: User Experience 137 | 138 | ### 4.1 Student Interface 139 | 1. Create student dashboard: 140 | - Course overview 141 | - Progress tracking 142 | - Achievement display 143 | - Leaderboard position 144 | 145 | 2. Implement learning interfaces: 146 | - Course navigation 147 | - Content viewing 148 | - Quiz taking 149 | - Assignment submission 150 | 151 | ### 4.2 Instructor Interface 152 | 1. Create instructor dashboard: 153 | - Course management 154 | - Student progress tracking 155 | - Content creation tools 156 | - Assignment grading 157 | 158 | ### 4.3 Admin Interface 159 | 1. Create admin dashboard: 160 | - Tenant management 161 | - User management 162 | - System analytics 163 | - Configuration tools 164 | 165 | ### 4.4 Notifications 166 | 1. Implement notification system: 167 | - Achievement notifications 168 | - Course announcements 169 | - Assignment reminders 170 | - Progress updates 171 | 172 | ## Phase 5: Advanced Features 173 | 174 | ### 5.1 Analytics 175 | 1. Implement analytics tracking: 176 | - User engagement 177 | - Course completion rates 178 | - Quiz performance 179 | - System usage 180 | 181 | 2. Create analytics dashboards: 182 | - Tenant-level insights 183 | - Course performance 184 | - Student progress 185 | - Gamification metrics 186 | 187 | ### 5.2 Tenant Customization 188 | 1. Implement tenant branding: 189 | - Custom themes 190 | - Logo management 191 | - Domain settings 192 | 193 | 2. Create feature toggles: 194 | - Gamification options 195 | - Module availability 196 | - Interface customization 197 | 198 | ### 5.3 Integration Features 199 | 1. Set up webhook system 200 | 2. Create API documentation 201 | 3. Implement export/import functionality 202 | 4. Set up email integration 203 | 204 | ## Phase 6: Testing and Deployment 205 | 206 | ### 6.1 Testing 207 | 1. Implement test suites: 208 | - Unit tests 209 | - Integration tests 210 | - E2E tests 211 | - Load tests 212 | 213 | 2. Create test data generators 214 | 3. Set up CI/CD pipeline 215 | 216 | ### 6.2 Deployment 217 | 1. Configure Vercel deployment: 218 | - Environment variables 219 | - Build settings 220 | - Domain configuration 221 | 222 | 2. Set up Neon Database: 223 | - Production database 224 | - Backup system 225 | - Monitoring 226 | 227 | 3. Implement logging and monitoring: 228 | - Error tracking 229 | - Performance monitoring 230 | - Usage analytics 231 | 232 | ### 6.3 Documentation 233 | 1. Create documentation: 234 | - API documentation 235 | - User guides 236 | - Admin guides 237 | - Development guides 238 | 239 | 2. Set up documentation site 240 | 3. Create onboarding materials 241 | 242 | ## Phase 7: Launch and Maintenance 243 | 244 | ### 7.1 Launch Preparation 245 | 1. Security audit 246 | 2. Performance optimization 247 | 3. Load testing 248 | 4. User acceptance testing 249 | 250 | ### 7.2 Launch 251 | 1. Production deployment 252 | 2. Monitor system health 253 | 3. User onboarding 254 | 4. Support system setup 255 | 256 | ### 7.3 Maintenance Plan 257 | 1. Regular updates schedule 258 | 2. Backup procedures 259 | 3. Performance monitoring 260 | 4. User feedback system 261 | 262 | ## Best Practices Throughout Implementation 263 | 264 | ### Code Organization 265 | - Use feature-based folder structure 266 | - Implement proper typing with TypeScript 267 | - Follow Next.js 14 best practices 268 | - Maintain consistent coding standards 269 | 270 | ### Security 271 | - Implement proper authentication flows 272 | - Use secure session handling 273 | - Follow OWASP security guidelines 274 | - Regular security audits 275 | 276 | ### Performance 277 | - Implement proper caching strategies 278 | - Use React Server Components appropriately 279 | - Optimize database queries 280 | - Regular performance monitoring 281 | 282 | ### Scalability 283 | - Design for horizontal scaling 284 | - Implement proper database indexing 285 | - Use efficient data structures 286 | - Plan for high availability 287 | -------------------------------------------------------------------------------- /docs/outline.md: -------------------------------------------------------------------------------- 1 | # Multi-Tenant LMS Project Plan 2 | 3 | ## 1. Project Overview 4 | The Multi-Tenant LMS (Learning Management System) is a platform that allows organizations (tenants) to independently manage courses, lessons, users, and progress. Each tenant operates within an isolated environment while the platform owner oversees the entire system. The LMS is designed to be scalable, interactive, and engaging, leveraging gamification, adaptive learning, and AI-driven analytics. 5 | 6 | ## 2. Objectives 7 | - **Tenant Empowerment**: Provide tenants with tools to manage their learning content and user engagement. 8 | - **Student Experience**: Deliver a seamless experience for students to access courses and track their progress. 9 | - **Platform Oversight**: Enable the platform owner to oversee operations, manage subscriptions, and analyze usage data. 10 | 11 | ## 3. Key Features 12 | 13 | ### 3.1 Tenant Features 14 | - **Tenant Dashboard**: Centralized management of courses, users, and settings. 15 | - **Course Management**: Tools to create, edit, and structure courses into modules and lessons. 16 | - **User Roles**: Customizable roles such as Admin, Instructor, and Student. 17 | - **Progress Tracking**: Insights into student progress and completion rates. 18 | - **Custom Branding**: Options for tenants to personalize their subdomains with logos and themes. 19 | 20 | ### 3.2 Student Features 21 | - **Course Access**: User-friendly interface to view and complete courses and lessons. 22 | - **Progress Tracking**: Visual indicators of lesson completion and achievements. 23 | - **Interactive Content**: Engagement through rich text, quizzes, and polls. 24 | - **Gamification**: Incentives like points, badges, and levels for accomplishments. 25 | 26 | ### 3.3 Platform Owner Features 27 | - **Owner Dashboard**: Comprehensive analytics, revenue tracking, and tenant management tools. 28 | - **Subscription Management**: Efficient handling of tenant subscriptions and billing. 29 | - **Support Management**: System to address support tickets and user issues. 30 | - **Usage Analytics**: Detailed analysis of system-wide activity and engagement metrics. 31 | 32 | ## 4. Technical Architecture 33 | 34 | ### 4.1 Backend 35 | - **Payload CMS 3.0**: 36 | - **Next.js Integration**: Seamless installation into the Next.js /app folder, leveraging React Server Components for enhanced performance. 37 | - **Collections**: Structured data models with built-in REST and GraphQL APIs, organized in logical groups. 38 | - **Authentication**: Built-in authentication system with tenant isolation and role-based access control. 39 | - **Admin Panel**: Customizable admin UI with tenant-specific views and logical grouping. 40 | - **File Uploads**: Automatic image optimization and file handling with media library. 41 | - **Hooks System**: Powerful hooks for business logic, gamification, and real-time updates. 42 | - **Access Control**: Granular tenant-specific and role-based access control at collection and field levels. 43 | - **Neon Database**: 44 | - **PostgreSQL**: Scalable and reliable data storage. 45 | - **Drizzle ORM**: Type-safe queries for efficient interaction with the database. 46 | 47 | ### 4.2 Frontend 48 | - **Next.js**: 49 | - **Server-Side Rendering**: Ensures fast, interactive pages with React Server Components. 50 | - **Tenant-Specific Subdomains**: Provides isolated branding and customized experiences for each tenant. 51 | 52 | ### 4.3 Deployment 53 | - **Hosting**: Frontend deployed on Vercel; backend and database hosted on Neon Database. 54 | - **Scaling**: Implements horizontal scaling to accommodate high traffic and optimized database queries. 55 | 56 | ## 5. Core Collections 57 | 58 | ### 5.1 Tenants 59 | **Fields**: 60 | - **Name**: { type: 'text', required: true } - The name of the tenant organization. 61 | - **Logo**: { type: 'upload', relationTo: 'media' } - Tenant-specific branding with automatic image optimization. 62 | - **Contact Email**: { type: 'email', required: true } - The primary contact email. 63 | - **Subscription Status**: { type: 'select', options: ['active', 'suspended', 'cancelled'] } - Subscription state. 64 | - **Domain**: { type: 'text', unique: true } - Tenant's subdomain. 65 | 66 | **Access Control**: 67 | - Tenant-specific data isolation using Payload's access control functions. 68 | - Role-based permissions at collection and field levels. 69 | 70 | **Relationships**: 71 | - **Users**: { type: 'relationship', relationTo: 'users', hasMany: true } 72 | - **Courses**: { type: 'relationship', relationTo: 'courses', hasMany: true } 73 | - **Settings**: { type: 'relationship', relationTo: 'tenant-settings', hasMany: false } 74 | 75 | ### 5.2 Users 76 | **Fields**: 77 | - **Name**: { type: 'text', required: true } 78 | - **Email**: { type: 'email', required: true, unique: true } 79 | - **Role**: { type: 'select', options: ['admin', 'instructor', 'student'] } 80 | - **TenantID**: { type: 'relationship', relationTo: 'tenants' } 81 | 82 | **Authentication**: 83 | - Utilizes Payload's built-in authentication with `auth: true` 84 | - Custom login handlers for tenant isolation 85 | - JWT token management for API access 86 | 87 | **Access Control**: 88 | - Role-based access using Payload's access control functions 89 | - Tenant-specific data filtering 90 | 91 | ### 5.3 Courses 92 | **Fields**: 93 | - **Title**: Name of the course. 94 | - **Description**: Detailed description of the course content and objectives. 95 | - **Instructor**: Reference to the user who created or is managing the course. 96 | - **TenantID**: Identifier linking the course to its respective tenant. 97 | - **Status**: Indicates if the course is published, draft, or archived. 98 | - **StartDate**: Date when the course becomes available. 99 | - **EndDate**: Date when the course access ends. 100 | 101 | **Relationships**: 102 | - **Modules**: Links to the modules that make up the course structure. 103 | - **Progress**: Tracks student progress in the course. 104 | - **Announcements**: Links to announcements made for the course. 105 | - **Assignments**: Links to assignments associated with the course. 106 | - **Certificates**: Links to certificates issued upon course completion. 107 | 108 | **Access Control**: 109 | - Tenant-specific access restrictions 110 | - Role-based viewing permissions 111 | - Instructor management capabilities 112 | 113 | ### 5.4 Modules 114 | **Fields**: 115 | - **Title**: Name of the module. 116 | - **CourseID**: Identifier linking the module to its parent course. 117 | - **Order**: Numerical value defining the module's order within the course. 118 | 119 | **Relationships**: 120 | - **Lessons**: Links to lessons that are part of the module. 121 | 122 | ### 5.5 Lessons 123 | **Fields**: 124 | - **Title**: { type: 'text', required: true } 125 | - **ModuleID**: { type: 'relationship', relationTo: 'modules' } 126 | - **Content**: { type: 'richText', required: true } 127 | - **Media**: { type: 'upload', relationTo: 'media', hasMany: true } 128 | - **Order**: { type: 'number' } 129 | 130 | **Hooks**: 131 | - `beforeChange`: Validate and process content 132 | - `afterChange`: Update progress tracking 133 | - `beforeRead`: Apply tenant-specific access control 134 | 135 | **Access Control**: 136 | - Tenant-specific content isolation 137 | - Role-based viewing permissions 138 | 139 | ### 5.6 Quizzes 140 | **Fields**: 141 | - **Title**: Name of the quiz. 142 | - **LessonID**: Identifier linking the quiz to its parent lesson. 143 | - **Questions**: Array of questions, each containing text, options, and correct answers. 144 | - **Attempts Allowed**: Number of attempts a student can make. 145 | - **Time Limit**: Maximum duration allowed for quiz completion (in minutes). 146 | 147 | **Relationships**: 148 | - **Lessons**: Links to the lesson the quiz is associated with. 149 | - **Progress**: Tracks the student's performance on quizzes. 150 | 151 | ### 5.7 Progress 152 | **Fields**: 153 | - **StudentID**: Identifier linking the progress record to a specific student. 154 | - **LessonID**: Identifier linking the progress record to a specific lesson. 155 | - **Completed**: Boolean indicating if the lesson or module is completed. 156 | - **Points**: Points earned by the student for completing quizzes or lessons. 157 | - **Quiz Scores**: Records of scores for completed quizzes. 158 | 159 | **Tracks**: 160 | - **Lesson Completion**: Tracks whether a student has completed a specific lesson. 161 | - **Gamification Points**: Tracks points earned for gamification purposes. 162 | - **Quiz Performance**: Tracks quiz scores for analysis. 163 | 164 | ### 5.8 Support Tickets 165 | **Fields**: 166 | - **TenantID**: Identifier linking the support ticket to a specific tenant. 167 | - **UserID**: Identifier linking the ticket to the user who raised it. 168 | - **Issue Description**: Detailed description of the issue reported. 169 | - **Status**: Current status of the support ticket (e.g., Open, In Progress, Resolved). 170 | - **Priority**: Priority level of the issue (e.g., Low, Medium, High). 171 | 172 | **Relationships**: 173 | - **User**: Links to the user who created the support ticket. 174 | - **Tenant**: Links to the tenant the support ticket is associated with. 175 | 176 | ### 5.9 Assignments 177 | **Fields**: 178 | - **Title**: Name of the assignment. 179 | - **Description**: Detailed description of what the assignment entails. 180 | - **LessonID**: Identifier linking the assignment to its parent lesson. 181 | - **Due Date**: Deadline for assignment submission. 182 | - **Max Score**: Maximum score that can be achieved. 183 | 184 | **Relationships**: 185 | - **Lesson**: Links to the lesson the assignment is part of. 186 | - **Submissions**: Tracks submissions made by students for this assignment. 187 | 188 | ### 5.10 Submissions 189 | **Fields**: 190 | - **StudentID**: Identifier linking the submission to a specific student. 191 | - **AssignmentID**: Identifier linking the submission to a specific assignment. 192 | - **Content**: The actual content of the submission (e.g., text, file uploads). 193 | - **Submission Date**: The date and time when the assignment was submitted. 194 | - **Score**: Score awarded for the assignment. 195 | - **Feedback**: Instructor's feedback on the submission. 196 | 197 | **Relationships**: 198 | - **Assignment**: Links to the assignment being submitted. 199 | - **Student**: Links to the student making the submission. 200 | 201 | ### 5.11 Announcements 202 | **Fields**: 203 | - **Title**: Title of the announcement. 204 | - **Content**: The message or content of the announcement. 205 | - **CourseID**: Identifier linking the announcement to a specific course. 206 | - **Date Created**: The date the announcement was made. 207 | 208 | **Relationships**: 209 | - **Course**: Links to the course for which the announcement is relevant. 210 | - **Users**: Notifications to relevant users (e.g., students, instructors). 211 | 212 | ### 5.12 Certificates 213 | **Fields**: 214 | - **CertificateID**: Unique identifier for each certificate. 215 | - **CourseID**: Link to the course for which the certificate is issued. 216 | - **StudentID**: Link to the student receiving the certificate. 217 | - **Issue Date**: Date of issue. 218 | - **Certificate URL**: Link to download or view the certificate. 219 | 220 | **Relationships**: 221 | - **Course**: Links to the course associated with the certificate. 222 | - **Student**: Links to the student receiving the certificate. 223 | 224 | ### 5.13 Tenant Settings 225 | **Fields**: 226 | - **Branding Options**: Customizable colors, logos, and themes. 227 | - **Feature Toggles**: Enable/disable features like gamification, adaptive learning, etc. 228 | - **Notification Preferences**: Define how and when to send notifications (e.g., email, SMS). 229 | - **Payment Settings**: Details for subscription billing, payment methods, and invoices. 230 | - **Access Control**: Specify role-based permissions and access levels. 231 | 232 | **Relationships**: 233 | - **Tenant**: Links to the tenant the settings belong to. 234 | 235 | ### 5.14 Student Settings 236 | **Fields**: 237 | - **Notification Preferences**: Define how and when students receive notifications (e.g., email, SMS). 238 | - **Accessibility Options**: Customization for accessibility (e.g., text size, contrast). 239 | - **Profile Preferences**: Control over privacy settings and profile visibility. 240 | - **Language Preferences**: Preferred language for course content and platform interface. 241 | 242 | **Relationships**: 243 | - **User**: Links to the student user that the settings belong to. 244 | 245 | ## 6. Gamification 246 | 247 | ### 6.1 Components 248 | - **Points System**: 249 | - Points awarded for completing lessons and quizzes 250 | - Configurable point values per activity type: 251 | - Lesson completion: Default 10 points 252 | - Quiz completion: Default 20 points 253 | - Perfect quiz score: Default 50 points 254 | - Streak bonus: Default 5 points 255 | - Total points tracking in Progress collection 256 | - Point thresholds for level progression 257 | - **Badges**: 258 | - Achievement-based rewards 259 | - Multiple criteria types (course completion, streaks, points, quiz scores) 260 | - Visual representation with customizable icons 261 | - Tenant-specific badge configurations 262 | - Level requirements for advanced badges 263 | - **Leaderboards**: 264 | - Tenant-specific rankings 265 | - Points-based positioning 266 | - Multiple timeframes (weekly, monthly, all-time) 267 | - Real-time updates via hooks 268 | - Filterable by time period and category 269 | - **Levels**: 270 | - Progressive difficulty system 271 | - Point-based advancement 272 | - Level-specific rewards and unlocks 273 | - Tracked in Progress collection 274 | - Required levels for certain badges 275 | - **Streaks**: 276 | - Daily engagement tracking 277 | - Current and longest streak records 278 | - Streak-based achievements 279 | - Bonus points for maintaining streaks 280 | - Last activity tracking for accurate calculations 281 | 282 | ### 6.2 Implementation 283 | - **Collections Structure**: 284 | - `Badges`: Defines achievement criteria and rewards 285 | - `Achievements`: Records earned badges and progress 286 | - `Leaderboard`: Tracks user rankings and points 287 | - `Progress`: Enhanced with gamification metrics 288 | - **Hooks System**: 289 | - `beforeChange` hooks for: 290 | - Point calculations based on activity type 291 | - Streak updates and bonus calculations 292 | - Level progression checks 293 | - `afterChange` hooks for: 294 | - Badge awards and progress updates 295 | - Leaderboard position updates 296 | - Achievement tracking and notifications 297 | - **Custom Admin Components**: 298 | - Gamification dashboard views 299 | - Progress visualization components 300 | - Achievement management interface 301 | - Leaderboard configuration panel 302 | - Point value management 303 | - **Frontend Components**: 304 | - Real-time leaderboard displays 305 | - Achievement notification system 306 | - Progress visualization 307 | - Level-up animations 308 | - Streak tracking indicators 309 | - Point earning notifications 310 | 311 | ## 7. Advanced Features 312 | 313 | ### 7.1 Adaptive Learning Paths 314 | Personalized lesson or course recommendations based on student performance. 315 | 316 | ### 7.2 AI-Driven Analytics 317 | Analyzes engagement patterns and predicts student outcomes. 318 | 319 | ### 7.3 Real-Time Collaboration 320 | Facilitates student collaboration through shared whiteboards or documents. 321 | 322 | ### 7.4 Custom Branding 323 | Enables tenants to personalize their learning portals. 324 | 325 | ## 8. Deployment Strategy 326 | 327 | ### 8.1 Frontend Deployment 328 | - **Hosting**: Deployed on Vercel for fast, scalable delivery. 329 | - **Subdomains**: Tenant-specific subdomains configured using DNS. 330 | 331 | ### 8.2 Backend Deployment 332 | - **Hosting**: Deployed on Neon Database for PostgreSQL storage. 333 | - **API Integration**: Payload CMS API exposed for frontend interaction. 334 | 335 | ### 8.3 Scaling 336 | - **Horizontal Scaling**: Supports frontend and backend scaling for high traffic. 337 | - **Optimized Queries**: Uses Drizzle ORM for efficient database interactions. 338 | 339 | ## 9. Implementation Plan 340 | 341 | ### 9.1 Phase 1: Backend Setup 342 | - Install and configure Payload CMS. 343 | - Set up Neon Database with Drizzle ORM. 344 | - Create core collections (Tenants, Users, Courses, etc.). 345 | 346 | ### 9.2 Phase 2: Frontend Development 347 | - Build tenant-specific dashboards using Next.js. 348 | - Implement gamification components (points, badges, leaderboards). 349 | 350 | ### 9.3 Phase 3: Deployment 351 | - Deploy the frontend to Vercel. 352 | - Deploy the backend to Neon Database. 353 | 354 | ### 9.4 Phase 4: Testing 355 | - Perform end-to-end testing of all features. 356 | - Test tenant isolation and role-based access control. 357 | 358 | ### 9.5 Phase 5: Launch 359 | - Onboard initial tenants. 360 | - Monitor performance and user feedback. 361 | 362 | ## 10. Future Enhancements 363 | - **Mobile App**: Develop a mobile version of the LMS. 364 | - **Integrations**: Add integrations with third-party tools (e.g., Google Classroom). 365 | - **Content Marketplace**: Allow instructors to sell courses to multiple tenants. 366 | -------------------------------------------------------------------------------- /docs/phase-1.md: -------------------------------------------------------------------------------- 1 | # Phase 1: Project Setup and Core Infrastructure 2 | 3 | ## Summary 4 | This phase establishes the foundation of our multi-tenant LMS platform using: 5 | 1. Next.js 15 with App Router (Updated from 14) 6 | 2. Payload CMS 3.4.0 7 | 3. Neon Database 8 | 4. TypeScript 9 | 5. pnpm 10 | 11 | **Key Components:** 12 | - ✅ Project initialization 13 | - ✅ Database setup 14 | - ✅ Authentication system (using Payload built-in auth) 15 | - 🚧 Core collections 16 | 17 | **Current Status:** 18 | A configured development environment with: 19 | - ✅ Working Next.js application 20 | - ✅ Connected Payload CMS 21 | - ✅ Configured database 22 | - ✅ Basic authentication 23 | - ✅ Email system with Resend 24 | 25 | ## 1.1 Project Setup 26 | 27 | ### Initialize Project 28 | ```bash 29 | # Create a new Payload + Next.js project 30 | pnpm create payload-app@latest --no-src -t blank 31 | 32 | # Follow the prompts: 33 | # - Choose "Custom Template" 34 | # - Select Next.js as the framework 35 | # - Choose TypeScript 36 | # - Select Postgres as the database 37 | # - Choose pnpm as the package manager 38 | ``` 39 | 40 | ### Additional Dependencies 41 | ```bash 42 | cd lms-mvp 43 | 44 | # UI Components 45 | pnpm add @radix-ui/react-icons @radix-ui/themes 46 | pnpm add class-variance-authority clsx tailwind-merge 47 | pnpm add @aceternity/ui @magic-ui/core vaul sonner cmdk 48 | 49 | # Forms and Validation 50 | pnpm add react-hook-form @hookform/resolvers zod nuqs 51 | 52 | # Animation 53 | pnpm add framer-motion @legendapp/motion @formkit/auto-animate 54 | 55 | # Utilities 56 | pnpm add date-fns nanoid slugify 57 | ``` 58 | 59 | ### Update Next.js Config 60 | The `create-payload-app` command will create a basic `next.config.js`, but we need to update it with our specific requirements: 61 | 62 | ```typescript:next.config.mjs 63 | import { withPayload } from '@payloadcms/next/withPayload' 64 | 65 | /** @type {import('next').NextConfig} */ 66 | const nextConfig = { 67 | experimental: { 68 | reactCompiler: false 69 | }, 70 | images: { 71 | domains: [ 72 | 'localhost', 73 | // Add your production domains here 74 | ], 75 | }, 76 | typescript: { 77 | ignoreBuildErrors: false, 78 | }, 79 | eslint: { 80 | ignoreDuringBuilds: false, 81 | }, 82 | } 83 | 84 | export default withPayload(nextConfig) 85 | ``` 86 | 87 | ### Environment Setup 88 | Current working environment variables: 89 | 90 | ```bash 91 | # Database 92 | DATABASE_URI=postgres://postgres:postgres@localhost:5432/lms_mvp 93 | POSTGRES_URL=postgres://postgres:postgres@localhost:5432/lms_mvp 94 | DIRECT_URL=${DATABASE_URI} 95 | 96 | # Payload 97 | PAYLOAD_SECRET=your-secret-key 98 | PAYLOAD_CONFIG_PATH=payload.config.ts 99 | PAYLOAD_PUBLIC_SERVER_URL=http://localhost:3000 100 | 101 | # Email 102 | RESEND_API_KEY=your-resend-key 103 | EMAIL_FROM=noreply@yourdomain.com 104 | 105 | # Site Config 106 | NEXT_PUBLIC_SERVER_URL=http://localhost:3000 107 | NEXT_PUBLIC_SITE_NAME="LMS Platform" 108 | ``` 109 | 110 | ### Project Structure 111 | Current working project structure: 112 | 113 | ``` 114 | lms-mvp/ 115 | ├── src/ 116 | │ ├── access/ 117 | │ │ └── roles.ts 118 | │ ├── collections/ 119 | │ │ ├── Media.ts 120 | │ │ ├── Tenants.ts 121 | │ │ ├── Users.ts 122 | │ │ ├── StudentSettings.ts 123 | │ │ └── index.ts 124 | │ ├── lib/ 125 | │ │ └── db/ 126 | │ │ ├── index.ts 127 | │ │ ├── migrate.ts 128 | │ │ └── schema/ 129 | │ └── payload.config.ts 130 | ├── docs/ 131 | │ └── phase-1.md 132 | ├── next.config.mjs 133 | ├── package.json 134 | └── .env 135 | ``` 136 | 137 | ## 1.2 Database Configuration 138 | 139 | ### Configure Neon Connection 140 | Create `lib/db/index.ts`: 141 | ```typescript 142 | import { Pool } from '@neondatabase/serverless' 143 | import { drizzle } from 'drizzle-orm/neon-serverless' 144 | 145 | // Configure connection pool 146 | const pool = new Pool({ 147 | connectionString: process.env.DATABASE_URI, 148 | maxSize: 10, 149 | idleTimeout: 30, 150 | connectionTimeoutMillis: 10_000, 151 | }) 152 | 153 | // Create Drizzle instance 154 | export const db = drizzle(pool) 155 | 156 | // Healthcheck function 157 | export async function checkDatabaseConnection() { 158 | try { 159 | const client = await pool.connect() 160 | await client.query('SELECT 1') 161 | client.release() 162 | return true 163 | } catch (error) { 164 | console.error('Database connection error:', error) 165 | return false 166 | } 167 | } 168 | ``` 169 | 170 | ## 1.3 Authentication System 171 | 172 | ### Configure Payload Auth (✅ Completed) 173 | The Users collection has been implemented with built-in Payload authentication, including: 174 | - Email/password authentication 175 | - Role-based access control 176 | - Multi-tenant isolation 177 | - Email verification 178 | - Password reset flow 179 | 180 | ## 1.4 Core Collections 181 | 182 | ### Configure Media Collection (✅ Completed) 183 | The Media collection has been implemented with: 184 | - Multi-tenant file isolation 185 | - Image resizing with Sharp 186 | - Proper access control 187 | - File type validation 188 | 189 | ### Configure Tenants Collection (✅ Completed) 190 | The Tenants collection has been implemented with: 191 | - Basic tenant information 192 | - Domain customization 193 | - Logo management 194 | - Status tracking 195 | 196 | ### Configure Settings Collections (✅ Completed) 197 | The StudentSettings collection has been implemented with: 198 | - User preferences 199 | - Learning path settings 200 | - UI preferences 201 | 202 | ## Next Steps 203 | 1. 🚧 Set up remaining collections: 204 | - Courses 205 | - Modules 206 | - Lessons 207 | - Assignments 208 | 2. 🚧 Implement tenant isolation for file uploads 209 | 3. 📝 Set up course management system 210 | 4. 📝 Configure progress tracking 211 | 5. 📝 Set up gamification system 212 | 213 | ## Current Dependencies 214 | ```json 215 | { 216 | "dependencies": { 217 | "@payloadcms/db-postgres": "^3.4.0", 218 | "@payloadcms/email-resend": "^3.4.0", 219 | "@payloadcms/next": "^3.4.0", 220 | "@payloadcms/richtext-slate": "^3.4.0", 221 | "next": "15.0.3", 222 | "payload": "^3.4.0", 223 | "drizzle-orm": "^0.37.0", 224 | "sharp": "0.32.6" 225 | } 226 | } 227 | ``` 228 | -------------------------------------------------------------------------------- /docs/phase-2.md: -------------------------------------------------------------------------------- 1 | # Phase 2: Learning Content Structure 2 | 3 | ## Summary 4 | This phase implements the core learning content structure using: 5 | 1. Payload Collections for content types 6 | 2. Lexical Rich Text Editor 7 | 3. Media handling 8 | 4. Progress tracking 9 | 5. Enrollment system 10 | 11 | **Key Components:** 12 | - ✅ Course management 13 | - ✅ Content organization 14 | - ✅ Quiz system 15 | - ✅ Progress tracking 16 | - ✅ Enrollment system 17 | 18 | **Current Status:** 19 | A complete content structure with: 20 | - ✅ Course hierarchy 21 | - ✅ Content creation tools 22 | - ✅ Assessment system 23 | - ✅ Progress monitoring 24 | - ✅ Student enrollment 25 | 26 | ## 2.1 Course Management (✅ Completed) 27 | 28 | ### Collections Implemented 29 | - ✅ Courses 30 | - Multi-tenant isolation 31 | - Instructor assignment 32 | - Module organization 33 | - Prerequisites handling 34 | - Schedule management 35 | - Enrollment settings 36 | 37 | - ✅ Modules 38 | - Course relationship 39 | - Lesson sequencing 40 | - Completion criteria 41 | - Progress tracking 42 | 43 | - ✅ Lessons 44 | - Multiple content types: 45 | - Video lessons 46 | - Reading materials 47 | - Quizzes 48 | - Assignments 49 | - Discussions 50 | - Rich text content 51 | - Media embedding 52 | 53 | ## 2.2 Enrollment System (✅ Completed) 54 | 55 | ### Features Implemented 56 | - ✅ Student enrollment tracking 57 | - ✅ Course capacity management 58 | - ✅ Self-enrollment options 59 | - ✅ Enrollment status tracking 60 | - ✅ Prerequisites verification 61 | - ✅ Progress record creation 62 | 63 | ### Access Control 64 | - 👤 Students: Self-enroll in available courses 65 | - 👨‍🏫 Instructors: Manage enrollments for their courses 66 | - 👑 Admins: Full enrollment management 67 | 68 | ## 2.3 Progress Tracking (✅ Completed) 69 | 70 | ### Database Schema 71 | Implemented tables: 72 | - ✅ Progress tracking 73 | - ✅ Quiz attempts 74 | - ✅ Assignment submissions 75 | - ✅ Discussion participation 76 | 77 | ### Progress Collection Features 78 | - ✅ Course completion tracking 79 | - ✅ Lesson progress 80 | - ✅ Quiz results 81 | - ✅ Assignment grades 82 | - ✅ Discussion participation 83 | 84 | ## Current Dependencies 85 | ```json 86 | { 87 | "dependencies": { 88 | "@payloadcms/richtext-lexical": "^0.5.0", 89 | "drizzle-orm": "^0.37.0", 90 | "@neondatabase/serverless": "^0.9.0", 91 | "zod": "^3.22.4", 92 | "drizzle-zod": "^0.5.0" 93 | } 94 | } 95 | ``` 96 | 97 | ## Database Schema Updates 98 | New tables added: 99 | - courses 100 | - modules 101 | - lessons 102 | - enrollments 103 | - progress 104 | - quiz_attempts 105 | - assignments 106 | - discussions 107 | 108 | ## Access Control 109 | Implemented role-based access for: 110 | - 👤 Students: View enrolled courses and track progress 111 | - 👨‍🏫 Instructors: Manage their courses and content 112 | - 👑 Admins: Full system access 113 | 114 | ## Next Steps 115 | 1. 📝 Implement gamification system 116 | - Points system 117 | - Badges 118 | - Achievements 119 | - Leaderboards 120 | 121 | 2. 📝 Set up user interfaces 122 | - Course catalog 123 | - Learning dashboard 124 | - Progress tracking UI 125 | - Quiz interface 126 | 127 | 3. 📝 Configure analytics tracking 128 | - Learning progress 129 | - Engagement metrics 130 | - Completion rates 131 | - Performance analytics 132 | 133 | 4. 📝 Set up notification system 134 | - Course updates 135 | - Assignment deadlines 136 | - Quiz reminders 137 | - Discussion notifications 138 | 139 | ## Known Issues 140 | - 🐛 Need to optimize rich text editor for large content 141 | - 🐛 Progress calculation needs caching for performance 142 | - 🐛 Quiz system needs better randomization 143 | - 🐛 Assignment submission needs file type validation 144 | 145 | ## Performance Considerations 146 | - Implement caching for course content 147 | - Optimize progress calculations 148 | - Lazy load course modules 149 | - Implement proper pagination 150 | 151 | -------------------------------------------------------------------------------- /docs/phase-4.md: -------------------------------------------------------------------------------- 1 | # Phase 4: User Experience and Interfaces 2 | 3 | ## Summary 4 | 5 | This phase implements the user interfaces using: 6 | 7 | 1. Next.js App Router 8 | 2. React Server Components 9 | 3. Shadcn UI (for core components) 10 | 4. Magic UI (for enhanced interactions) 11 | 5. Aceternity UI (for advanced animations) 12 | 6. Tailwind CSS 13 | 14 | **Key Components:** 15 | 16 | - Student dashboard with animated transitions 17 | - Instructor interface with floating elements 18 | - Admin controls with morphing effects 19 | - Course viewer with smooth animations 20 | - Authentication flows with motion effects 21 | 22 | **Expected Outcome:** A complete UI system with: 23 | 24 | - Beautiful, animated layouts 25 | - Interactive, floating components 26 | - Smooth transitions 27 | - Micro-interactions 28 | - Role-based interfaces 29 | - Accessible design 30 | 31 | ## 4.1 UI Dependencies Setup 32 | 33 | ### Install Shadcn UI 34 | 35 | ```bash 36 | # Initialize Shadcn UI 37 | pnpm dlx shadcn-ui@latest init 38 | 39 | # Install core components 40 | pnpm dlx shadcn-ui@latest add button 41 | pnpm dlx shadcn-ui@latest add card 42 | pnpm dlx shadcn-ui@latest add form 43 | pnpm dlx shadcn-ui@latest add input 44 | pnpm dlx shadcn-ui@latest add label 45 | pnpm dlx shadcn-ui@latest add toast 46 | pnpm dlx shadcn-ui@latest add dialog 47 | pnpm dlx shadcn-ui@latest add dropdown-menu 48 | pnpm dlx shadcn-ui@latest add avatar 49 | pnpm dlx shadcn-ui@latest add tabs 50 | ``` 51 | 52 | ### Install Aceternity UI 53 | 54 | ```bash 55 | # Install Aceternity UI dependencies 56 | pnpm add framer-motion @tabler/icons-react clsx tailwind-merge 57 | pnpm add @legendapp/motion 58 | pnpm add @formkit/auto-animate 59 | 60 | # Add Aceternity components 61 | pnpm add aceternity-ui 62 | ``` 63 | 64 | ### Install Magic UI 65 | 66 | ```bash 67 | # Install Magic UI and its dependencies 68 | pnpm add magic-ui 69 | pnpm add @magic-ui/animations 70 | pnpm add @magic-ui/transitions 71 | ``` 72 | 73 | ### Configure Tailwind CSS 74 | 75 | Update `tailwind.config.ts`: 76 | 77 | ```typescript 78 | import { withTV } from "tailwind-variants"; 79 | import animatePlugin from "tailwindcss-animate"; 80 | 81 | export default withTV({ 82 | content: [ 83 | "./app/**/*.{js,ts,jsx,tsx,mdx}", 84 | "./components/**/*.{js,ts,jsx,tsx,mdx}", 85 | ], 86 | darkMode: ["class"], 87 | theme: { 88 | container: { 89 | center: true, 90 | padding: "2rem", 91 | screens: { 92 | "2xl": "1400px", 93 | }, 94 | }, 95 | extend: { 96 | animation: { 97 | "accordion-down": "accordion-down 0.2s ease-out", 98 | "accordion-up": "accordion-up 0.2s ease-out", 99 | spotlight: "spotlight 2s ease .75s 1 forwards", 100 | shimmer: "shimmer 2s linear infinite", 101 | "meteor-effect": "meteor 5s linear infinite", 102 | }, 103 | keyframes: { 104 | spotlight: { 105 | "0%": { opacity: "0", transform: "scale(0.9)" }, 106 | "100%": { opacity: "1", transform: "scale(1)" }, 107 | }, 108 | shimmer: { 109 | from: { backgroundPosition: "0 0" }, 110 | to: { backgroundPosition: "-200% 0" }, 111 | }, 112 | meteor: { 113 | "0%": { 114 | transform: "rotate(215deg) translateX(0)", 115 | opacity: "1", 116 | }, 117 | "70%": { opacity: "1" }, 118 | "100%": { 119 | transform: "rotate(215deg) translateX(-500px)", 120 | opacity: "0", 121 | }, 122 | }, 123 | }, 124 | }, 125 | }, 126 | plugins: [ 127 | animatePlugin, 128 | require("@tailwindcss/typography"), 129 | require("tailwindcss-animate"), 130 | ], 131 | }); 132 | ``` 133 | 134 | ## 4.2 Enhanced Components 135 | 136 | ### Import Aceternity Components 137 | 138 | Create `components/ui/aceternity/index.ts`: 139 | 140 | ```typescript 141 | "use client"; 142 | 143 | export { 144 | AnimatedCard, 145 | BackgroundBeams, 146 | FloatingNavbar, 147 | SparklesCore, 148 | TextGenerateEffect, 149 | TypewriterEffect, 150 | WavyBackground, 151 | } from "aceternity-ui"; 152 | ``` 153 | 154 | ### Import Shadcn Components 155 | 156 | Create `components/ui/shadcn/index.ts`: 157 | 158 | ```typescript 159 | "use client"; 160 | 161 | export { Button } from "./button"; 162 | export { Card } from "./card"; 163 | export { Form } from "./form"; 164 | export { Input } from "./input"; 165 | export { Label } from "./label"; 166 | export { Toast } from "./toast"; 167 | export { Dialog } from "./dialog"; 168 | export { DropdownMenu } from "./dropdown-menu"; 169 | export { Avatar } from "./avatar"; 170 | export { Tabs } from "./tabs"; 171 | ``` 172 | 173 | ### Import Magic UI Components 174 | 175 | Create `components/ui/magic/index.ts`: 176 | 177 | ```typescript 178 | "use client"; 179 | 180 | export { 181 | FloatingEffect, 182 | GlowEffect, 183 | MagicCard, 184 | MagicContainer, 185 | ParallaxText, 186 | SmoothTransition, 187 | } from "magic-ui"; 188 | ``` 189 | 190 | ## 4.3 Enhanced Page Components 191 | 192 | ### Create Animated Login Page 193 | 194 | Update `app/(auth)/login/page.tsx`: 195 | 196 | ```typescript 197 | import { Metadata } from "next"; 198 | import { 199 | AnimatedCard, 200 | BackgroundBeams, 201 | } from "@/components/ui/aceternity"; 202 | import { MagicCard } from "@/components/ui/magic"; 203 | import { 204 | Button, 205 | Form, 206 | Input, 207 | } from "@/components/ui/shadcn"; 208 | import { Logo } from "@/components/ui/logo"; 209 | 210 | export const metadata: Metadata = { 211 | title: "Login | LMS Platform", 212 | description: "Login to your account", 213 | }; 214 | 215 | export default function LoginPage() { 216 | return ( 217 |
218 | 219 |
220 | 221 | 222 |
223 | 224 |

225 | Welcome back 226 |

227 |

228 | Enter your credentials to sign in 229 |

230 |
231 | 232 |
233 |
234 |
235 |
236 | ); 237 | } 238 | ``` 239 | 240 | ### Create Animated Dashboard 241 | 242 | Update `app/(dashboard)/student/page.tsx`: 243 | 244 | ```typescript 245 | import { Suspense } from "react"; 246 | import { getCurrentUser } from "@/lib/session"; 247 | import { redirect } from "next/navigation"; 248 | import { 249 | AnimatedCard, 250 | BackgroundBeams, 251 | SparklesCore, 252 | } from "@/components/ui/aceternity"; 253 | import { 254 | MagicCard, 255 | MagicContainer, 256 | ParallaxText, 257 | } from "@/components/ui/magic"; 258 | import { Card, Tabs, Button } from "@/components/ui/shadcn"; 259 | import { CourseGrid } from "@/components/course/grid"; 260 | import { ProgressStats } from "@/components/progress/stats"; 261 | import { AchievementList } from "@/components/achievement/list"; 262 | import { LeaderboardCard } from "@/components/leaderboard/card"; 263 | import { LoadingSkeleton } from "@/components/ui/loading"; 264 | 265 | export default async function StudentDashboard() { 266 | const user = await getCurrentUser(); 267 | 268 | if (!user || user.role !== "student") { 269 | redirect("/"); 270 | } 271 | 272 | return ( 273 | 274 |
275 | 276 | 284 |
285 | 286 |

287 | Welcome back, {user.name}! 288 |

289 |
290 | 291 |
292 | }> 293 | 294 | 295 |
296 | 297 |
298 |
299 | 300 | 301 |

302 | Your Courses 303 |

304 | }> 305 | 306 | 307 |
308 |
309 |
310 | 311 |
312 | 313 | 314 |

315 | Achievements 316 |

317 | }> 318 | 319 | 320 |
321 |
322 | 323 | 324 | 325 |

326 | Leaderboard 327 |

328 | }> 329 | 330 | 331 |
332 |
333 |
334 |
335 |
336 |
337 |
338 | ); 339 | } 340 | ``` 341 | 342 | ### Create Course Viewer 343 | 344 | Update `app/(dashboard)/courses/[courseId]/page.tsx`: 345 | 346 | ```typescript 347 | import { Suspense } from "react"; 348 | import { notFound } from "next/navigation"; 349 | import { getCurrentUser } from "@/lib/session"; 350 | import { getCourse } from "@/lib/courses"; 351 | import { 352 | AnimatedCard, 353 | TextGenerateEffect, 354 | WavyBackground, 355 | } from "@/components/ui/aceternity"; 356 | import { 357 | MagicCard, 358 | FloatingEffect, 359 | } from "@/components/ui/magic"; 360 | import { Card, Tabs, Button } from "@/components/ui/shadcn"; 361 | import { ModuleList } from "@/components/module/list"; 362 | import { CourseProgress } from "@/components/course/progress"; 363 | import { CourseInfo } from "@/components/course/info"; 364 | import { LoadingSkeleton } from "@/components/ui/loading"; 365 | 366 | export default async function CoursePage({ 367 | params, 368 | }: { 369 | params: { courseId: string }; 370 | }) { 371 | const user = await getCurrentUser(); 372 | const course = await getCourse(params.courseId); 373 | 374 | if (!course) { 375 | notFound(); 376 | } 377 | 378 | return ( 379 |
380 | 381 |
382 | 383 |
384 | 385 |

386 | {course.description} 387 |

388 |
389 |
390 | 391 |
392 |
393 | 394 | 395 | 396 | 397 | 398 | Modules 399 | 400 | 401 | Overview 402 | 403 | 404 | 405 | } 407 | > 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 |
418 | 419 |
420 | 421 | 422 |

423 | Your Progress 424 |

425 | }> 426 | 430 | 431 |
432 |
433 |
434 |
435 |
436 |
437 | ); 438 | } 439 | ``` 440 | 441 | ## 4.4 Testing 442 | 443 | ### Component Testing 444 | 445 | Create `__tests__/components/ui/card.test.tsx`: 446 | 447 | ```typescript 448 | import { render, screen } from "@testing-library/react"; 449 | import { MagicCard } from "@/components/ui/magic"; 450 | import { Card } from "@/components/ui/shadcn"; 451 | 452 | describe("Card Components", () => { 453 | it("renders MagicCard with children", () => { 454 | render( 455 | 456 |
Test Content
457 |
458 | ); 459 | 460 | expect( 461 | screen.getByText("Test Content") 462 | ).toBeInTheDocument(); 463 | }); 464 | 465 | it("renders Shadcn Card with proper styling", () => { 466 | render( 467 | 468 |
Card Content
469 |
470 | ); 471 | 472 | const card = 473 | screen.getByText("Card Content").parentElement; 474 | expect(card).toHaveClass("test-class"); 475 | }); 476 | }); 477 | ``` 478 | 479 | ### Accessibility Testing 480 | 481 | Create `__tests__/accessibility/navigation.test.tsx`: 482 | 483 | ```typescript 484 | import { render, screen } from "@testing-library/react"; 485 | import { axe, toHaveNoViolations } from "jest-axe"; 486 | import { FloatingNavbar } from "@/components/ui/aceternity"; 487 | 488 | expect.extend(toHaveNoViolations); 489 | 490 | describe("Navigation Accessibility", () => { 491 | it("floating navbar has no accessibility violations", async () => { 492 | const { container } = render( 493 | 494 | 498 | 499 | ); 500 | 501 | const results = await axe(container); 502 | expect(results).toHaveNoViolations(); 503 | }); 504 | 505 | it("has correct ARIA labels", () => { 506 | render( 507 | 508 | 511 | 512 | ); 513 | 514 | expect(screen.getByRole("navigation")).toHaveAttribute( 515 | "aria-label", 516 | "Main navigation" 517 | ); 518 | }); 519 | }); 520 | ``` 521 | 522 | ## Next Steps 523 | 524 | - Implement quiz interface with animated feedback using 525 | Aceternity's AnimatedNumber component 526 | - Create assignment submission UI with Magic UI's 527 | DragAndDrop component 528 | - Set up discussion forums with real-time animations using 529 | Aceternity's AnimatedChat 530 | - Add notification system with Shadcn's Toast and Magic UI's 531 | PopIn animation 532 | - Enhance course content viewer with Aceternity's 533 | PageTransition effects 534 | -------------------------------------------------------------------------------- /docs/phase-9.md: -------------------------------------------------------------------------------- 1 | # Phase 9: Deployment, Scaling, and Monitoring 2 | 3 | ## Summary 4 | This phase focuses on deploying and scaling the LMS platform using: 5 | 1. Vercel for Next.js deployment 6 | 2. Neon for serverless Postgres 7 | 3. Payload Cloud for CMS hosting 8 | 4. Monitoring and observability 9 | 5. Backup and disaster recovery 10 | 11 | **Key Components:** 12 | - Production deployment 13 | - Database scaling 14 | - Performance monitoring 15 | - Security hardening 16 | - Backup strategies 17 | 18 | **Expected Outcome:** 19 | A production-ready platform with: 20 | - Automated deployments 21 | - Scalable infrastructure 22 | - Comprehensive monitoring 23 | - Disaster recovery 24 | - Performance optimization 25 | 26 | ## 9.1 Vercel Deployment Setup 27 | 28 | ### Configure Vercel Project 29 | Create `vercel.json`: 30 | 31 | ```json 32 | { 33 | "buildCommand": "pnpm run build", 34 | "devCommand": "pnpm run dev", 35 | "installCommand": "pnpm install", 36 | "framework": "nextjs", 37 | "regions": ["iad1"], 38 | "git": { 39 | "deploymentEnabled": { 40 | "main": true, 41 | "dev": true, 42 | "preview": true 43 | } 44 | }, 45 | "crons": [ 46 | { 47 | "path": "/api/cron/refresh-materialized-views", 48 | "schedule": "*/15 * * * *" 49 | }, 50 | { 51 | "path": "/api/cron/analytics-rollup", 52 | "schedule": "0 */6 * * *" 53 | } 54 | ] 55 | } 56 | ``` 57 | 58 | ### Environment Configuration 59 | Update `.env.production`: 60 | 61 | ```bash 62 | # Database 63 | DATABASE_URL=postgres://${NEON_USER}:${NEON_PASSWORD}@${NEON_HOST}/${NEON_DATABASE}?sslmode=require 64 | DIRECT_URL=${DIRECT_URL} # For Neon serverless driver 65 | 66 | # Payload 67 | PAYLOAD_SECRET=your-production-secret 68 | PAYLOAD_CONFIG_PATH=src/payload.config.ts 69 | PAYLOAD_CLOUD_API=https://api.payloadcms.com/v1 70 | 71 | # Storage 72 | S3_BUCKET_NAME=your-bucket-name 73 | S3_REGION=your-region 74 | S3_ACCESS_KEY_ID=your-access-key 75 | S3_SECRET_ACCESS_KEY=your-secret-key 76 | 77 | # Redis 78 | REDIS_URL=your-redis-url 79 | 80 | # Security 81 | NEXTAUTH_URL=https://your-domain.com 82 | NEXTAUTH_SECRET=your-nextauth-secret 83 | 84 | # Monitoring 85 | GOOGLE_CLOUD_PROJECT=your-project-id 86 | GOOGLE_APPLICATION_CREDENTIALS=path/to/credentials.json 87 | ``` 88 | 89 | ## 9.2 Neon Database Setup 90 | 91 | ### Configure Connection Pool 92 | Create `src/lib/db/pool.ts`: 93 | 94 | ```typescript 95 | import { Pool } from '@neondatabase/serverless' 96 | import { drizzle } from 'drizzle-orm/neon-serverless' 97 | 98 | // Configure connection pool 99 | const pool = new Pool({ 100 | connectionString: process.env.DATABASE_URL, 101 | maxSize: 10, 102 | idleTimeout: 30, 103 | connectionTimeoutMillis: 10_000, 104 | }) 105 | 106 | // Create Drizzle instance 107 | export const db = drizzle(pool) 108 | 109 | // Healthcheck function 110 | export async function checkDatabaseConnection() { 111 | try { 112 | const client = await pool.connect() 113 | await client.query('SELECT 1') 114 | client.release() 115 | return true 116 | } catch (error) { 117 | console.error('Database connection error:', error) 118 | return false 119 | } 120 | } 121 | ``` 122 | 123 | ### Configure Read Replicas 124 | Create `src/lib/db/replicas.ts`: 125 | 126 | ```typescript 127 | import { Pool } from '@neondatabase/serverless' 128 | import { drizzle } from 'drizzle-orm/neon-serverless' 129 | 130 | // Read replica pool 131 | const readPool = new Pool({ 132 | connectionString: process.env.DATABASE_READ_URL, 133 | maxSize: 20, 134 | idleTimeout: 30, 135 | }) 136 | 137 | export const readDb = drizzle(readPool) 138 | 139 | // Query router 140 | export function getQueryDb(operation: 'read' | 'write') { 141 | return operation === 'read' ? readDb : db 142 | } 143 | ``` 144 | 145 | ## 9.3 Payload Cloud Configuration 146 | 147 | ### Update Payload Config 148 | Update `src/payload.config.ts`: 149 | 150 | ```typescript 151 | import { buildConfig } from 'payload/config' 152 | import { cloudStorage } from '@payloadcms/plugin-cloud-storage' 153 | import { s3Adapter } from '@payloadcms/plugin-cloud-storage/s3' 154 | 155 | const adapter = s3Adapter({ 156 | config: { 157 | credentials: { 158 | accessKeyId: process.env.S3_ACCESS_KEY_ID, 159 | secretAccessKey: process.env.S3_SECRET_ACCESS_KEY, 160 | }, 161 | region: process.env.S3_REGION, 162 | bucket: process.env.S3_BUCKET_NAME, 163 | }, 164 | prefix: 'media', 165 | }) 166 | 167 | export default buildConfig({ 168 | serverURL: process.env.PAYLOAD_PUBLIC_SERVER_URL, 169 | collections: [/* ... existing collections ... */], 170 | plugins: [ 171 | cloudStorage({ 172 | collections: { 173 | media: { 174 | adapter, 175 | generateFileURL: (file) => `https://${process.env.S3_BUCKET_NAME}.s3.amazonaws.com/media/${file.filename}`, 176 | }, 177 | }, 178 | }), 179 | ], 180 | db: { 181 | pool: { 182 | min: 2, 183 | max: 10, 184 | }, 185 | }, 186 | rateLimit: { 187 | window: 15 * 60 * 1000, // 15 minutes 188 | max: 100, // limit each IP to 100 requests per window 189 | }, 190 | graphQL: { 191 | schemaOutputFile: path.resolve(__dirname, 'generated-schema.graphql'), 192 | disablePlaygroundInProduction: true, 193 | }, 194 | cors: [ 195 | 'https://your-domain.com', 196 | 'https://admin.your-domain.com', 197 | ], 198 | }) 199 | ``` 200 | 201 | ## 9.4 Monitoring and Observability 202 | 203 | ### Configure Application Monitoring 204 | Create `src/lib/monitoring/index.ts`: 205 | 206 | ```typescript 207 | import { Logging } from '@google-cloud/logging' 208 | import { MetricsExporter } from '@google-cloud/opentelemetry-cloud-monitoring-exporter' 209 | import { TraceExporter } from '@google-cloud/opentelemetry-cloud-trace-exporter' 210 | import { Resource } from '@opentelemetry/resources' 211 | import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions' 212 | 213 | // Configure logging 214 | const logging = new Logging() 215 | const log = logging.log('lms-application') 216 | 217 | // Configure metrics 218 | const metricsExporter = new MetricsExporter() 219 | 220 | // Configure tracing 221 | const traceExporter = new TraceExporter() 222 | 223 | // Resource configuration 224 | const resource = new Resource({ 225 | [SemanticResourceAttributes.SERVICE_NAME]: 'lms-platform', 226 | [SemanticResourceAttributes.SERVICE_VERSION]: process.env.NEXT_PUBLIC_APP_VERSION, 227 | environment: process.env.NODE_ENV, 228 | }) 229 | 230 | export async function logEvent( 231 | severity: 'INFO' | 'WARNING' | 'ERROR', 232 | event: string, 233 | metadata: Record 234 | ) { 235 | const entry = log.entry( 236 | { 237 | resource: { 238 | type: 'global', 239 | }, 240 | severity, 241 | }, 242 | { 243 | event, 244 | ...metadata, 245 | timestamp: new Date().toISOString(), 246 | } 247 | ) 248 | 249 | await log.write(entry) 250 | } 251 | 252 | export async function recordMetric( 253 | name: string, 254 | value: number, 255 | labels: Record = {} 256 | ) { 257 | await metricsExporter.export({ 258 | resource, 259 | metrics: [ 260 | { 261 | name, 262 | value, 263 | labels, 264 | timestamp: new Date(), 265 | }, 266 | ], 267 | }) 268 | } 269 | 270 | export async function recordTrace( 271 | name: string, 272 | duration: number, 273 | attributes: Record = {} 274 | ) { 275 | await traceExporter.export({ 276 | resource, 277 | spans: [ 278 | { 279 | name, 280 | duration, 281 | attributes, 282 | startTime: new Date(), 283 | }, 284 | ], 285 | }) 286 | } 287 | ``` 288 | 289 | ### Create Health Check API 290 | Create `app/api/health/route.ts`: 291 | 292 | ```typescript 293 | import { NextResponse } from 'next/server' 294 | import { checkDatabaseConnection } from '@/lib/db/pool' 295 | import { redis } from '@/lib/redis' 296 | import { logEvent } from '@/lib/monitoring' 297 | 298 | export async function GET() { 299 | try { 300 | // Check database connection 301 | const dbHealthy = await checkDatabaseConnection() 302 | 303 | // Check Redis connection 304 | const redisHealthy = await redis.ping() 305 | 306 | // Check Payload CMS 307 | const payloadHealthy = await fetch( 308 | `${process.env.PAYLOAD_PUBLIC_SERVER_URL}/api/health` 309 | ).then((res) => res.ok) 310 | 311 | const status = dbHealthy && redisHealthy && payloadHealthy 312 | ? 'healthy' 313 | : 'unhealthy' 314 | 315 | await logEvent('INFO', 'health_check', { 316 | database: dbHealthy, 317 | redis: redisHealthy, 318 | payload: payloadHealthy, 319 | }) 320 | 321 | return NextResponse.json( 322 | { 323 | status, 324 | checks: { 325 | database: dbHealthy, 326 | redis: redisHealthy, 327 | payload: payloadHealthy, 328 | }, 329 | }, 330 | { 331 | status: status === 'healthy' ? 200 : 503, 332 | } 333 | ) 334 | } catch (error) { 335 | await logEvent('ERROR', 'health_check_failed', { error }) 336 | return NextResponse.json( 337 | { status: 'error', message: error.message }, 338 | { status: 500 } 339 | ) 340 | } 341 | } 342 | ``` 343 | 344 | ## 9.5 Scaling Configuration 345 | 346 | ### Configure Auto-scaling 347 | Create `src/lib/scaling/index.ts`: 348 | 349 | ```typescript 350 | import { redis } from '@/lib/redis' 351 | import { logEvent } from '@/lib/monitoring' 352 | 353 | // Rate limiting configuration 354 | const RATE_LIMIT_WINDOW = 60 * 1000 // 1 minute 355 | const RATE_LIMIT_MAX = 100 // requests per window 356 | 357 | export async function checkRateLimit(ip: string): Promise { 358 | const key = `rate_limit:${ip}` 359 | const count = await redis.incr(key) 360 | 361 | if (count === 1) { 362 | await redis.expire(key, RATE_LIMIT_WINDOW / 1000) 363 | } 364 | 365 | if (count > RATE_LIMIT_MAX) { 366 | await logEvent('WARNING', 'rate_limit_exceeded', { ip }) 367 | return false 368 | } 369 | 370 | return true 371 | } 372 | 373 | // Connection pool scaling 374 | export async function adjustPoolSize(metrics: { 375 | activeConnections: number 376 | waitingRequests: number 377 | }) { 378 | const { activeConnections, waitingRequests } = metrics 379 | 380 | // Scale up if we have waiting requests 381 | if (waitingRequests > 0 && activeConnections >= pool.maxSize) { 382 | await pool.resize(pool.maxSize + 5) 383 | await logEvent('INFO', 'pool_scaled_up', metrics) 384 | } 385 | 386 | // Scale down if we have idle connections 387 | if (waitingRequests === 0 && activeConnections < pool.maxSize / 2) { 388 | await pool.resize(Math.max(pool.minSize, pool.maxSize - 5)) 389 | await logEvent('INFO', 'pool_scaled_down', metrics) 390 | } 391 | } 392 | ``` 393 | 394 | ## 9.6 Backup and Disaster Recovery 395 | 396 | ### Configure Database Backups 397 | Create `src/lib/backup/database.ts`: 398 | 399 | ```typescript 400 | import { exec } from 'child_process' 401 | import { promisify } from 'util' 402 | import { S3 } from 'aws-sdk' 403 | import { logEvent } from '@/lib/monitoring' 404 | 405 | const execAsync = promisify(exec) 406 | const s3 = new S3() 407 | 408 | export async function createDatabaseBackup() { 409 | try { 410 | // Create backup using pg_dump 411 | const timestamp = new Date().toISOString() 412 | const filename = `backup-${timestamp}.sql` 413 | 414 | await execAsync( 415 | `pg_dump ${process.env.DATABASE_URL} > /tmp/${filename}` 416 | ) 417 | 418 | // Upload to S3 419 | await s3 420 | .upload({ 421 | Bucket: process.env.BACKUP_BUCKET_NAME, 422 | Key: `database/${filename}`, 423 | Body: require('fs').createReadStream(`/tmp/${filename}`), 424 | }) 425 | .promise() 426 | 427 | await logEvent('INFO', 'database_backup_created', { 428 | filename, 429 | timestamp, 430 | }) 431 | 432 | // Cleanup 433 | await execAsync(`rm /tmp/${filename}`) 434 | } catch (error) { 435 | await logEvent('ERROR', 'database_backup_failed', { error }) 436 | throw error 437 | } 438 | } 439 | 440 | export async function restoreFromBackup(backupFile: string) { 441 | try { 442 | // Download from S3 443 | const { Body } = await s3 444 | .getObject({ 445 | Bucket: process.env.BACKUP_BUCKET_NAME, 446 | Key: `database/${backupFile}`, 447 | }) 448 | .promise() 449 | 450 | // Save to temp file 451 | await require('fs').promises.writeFile( 452 | `/tmp/${backupFile}`, 453 | Body 454 | ) 455 | 456 | // Restore using pg_restore 457 | await execAsync( 458 | `pg_restore -d ${process.env.DATABASE_URL} /tmp/${backupFile}` 459 | ) 460 | 461 | await logEvent('INFO', 'database_restored', { backupFile }) 462 | 463 | // Cleanup 464 | await execAsync(`rm /tmp/${backupFile}`) 465 | } catch (error) { 466 | await logEvent('ERROR', 'database_restore_failed', { error }) 467 | throw error 468 | } 469 | } 470 | ``` 471 | 472 | ### Configure Media Backups 473 | Create `src/lib/backup/media.ts`: 474 | 475 | ```typescript 476 | import { S3 } from 'aws-sdk' 477 | import { logEvent } from '@/lib/monitoring' 478 | 479 | const sourceS3 = new S3({ 480 | region: process.env.S3_REGION, 481 | credentials: { 482 | accessKeyId: process.env.S3_ACCESS_KEY_ID, 483 | secretAccessKey: process.env.S3_SECRET_ACCESS_KEY, 484 | }, 485 | }) 486 | 487 | const backupS3 = new S3({ 488 | region: process.env.BACKUP_S3_REGION, 489 | credentials: { 490 | accessKeyId: process.env.BACKUP_S3_ACCESS_KEY_ID, 491 | secretAccessKey: process.env.BACKUP_S3_SECRET_ACCESS_KEY, 492 | }, 493 | }) 494 | 495 | export async function backupMediaFiles() { 496 | try { 497 | const timestamp = new Date().toISOString() 498 | 499 | // List all objects in source bucket 500 | const { Contents } = await sourceS3 501 | .listObjectsV2({ 502 | Bucket: process.env.S3_BUCKET_NAME, 503 | }) 504 | .promise() 505 | 506 | // Copy each object to backup bucket 507 | await Promise.all( 508 | Contents.map((object) => 509 | backupS3 510 | .copyObject({ 511 | Bucket: process.env.BACKUP_BUCKET_NAME, 512 | CopySource: `${process.env.S3_BUCKET_NAME}/${object.Key}`, 513 | Key: `media/${timestamp}/${object.Key}`, 514 | }) 515 | .promise() 516 | ) 517 | ) 518 | 519 | await logEvent('INFO', 'media_backup_created', { 520 | timestamp, 521 | fileCount: Contents.length, 522 | }) 523 | } catch (error) { 524 | await logEvent('ERROR', 'media_backup_failed', { error }) 525 | throw error 526 | } 527 | } 528 | ``` 529 | 530 | ## 9.7 Testing 531 | 532 | 1. Test deployment: 533 | - Verify build process 534 | - Check environment variables 535 | - Test deployment hooks 536 | - Validate domain configuration 537 | - Monitor deployment logs 538 | - Test rollback procedures 539 | 540 | 2. Test scaling: 541 | - Load test application 542 | - Monitor auto-scaling 543 | - Test connection pooling 544 | - Verify rate limiting 545 | - Check resource utilization 546 | - Test failover scenarios 547 | 548 | 3. Test monitoring: 549 | - Verify logging setup 550 | - Check metrics collection 551 | - Test tracing functionality 552 | - Monitor error reporting 553 | - Validate alerts 554 | - Test dashboard access 555 | 556 | 4. Test backup/recovery: 557 | - Verify backup creation 558 | - Test backup integrity 559 | - Validate restore process 560 | - Check backup scheduling 561 | - Test disaster recovery 562 | - Verify data consistency 563 | 564 | ## Next Steps 565 | - Set up CI/CD pipelines 566 | - Configure automated testing 567 | - Implement security scanning 568 | - Set up performance monitoring 569 | - Create runbooks 570 | - Document operations procedures 571 | - Train support team 572 | -------------------------------------------------------------------------------- /docs/uml-diagram.puml: -------------------------------------------------------------------------------- 1 | @startuml LMS System 2 | 3 | ' Style configurations 4 | skinparam class { 5 | BackgroundColor White 6 | ArrowColor #666666 7 | BorderColor #666666 8 | AttributeFontSize 11 9 | AttributeIconSize 11 10 | } 11 | 12 | ' Core entities 13 | class Tenants { 14 | +name: string <> 15 | +logo: Media 16 | +contactEmail: string <> 17 | +subscriptionStatus: enum 18 | +domain: string <> 19 | +users: List 20 | +courses: List 21 | +settings: TenantSettings 22 | } 23 | 24 | class Users { 25 | +name: string <> 26 | +email: string <> 27 | +role: UserRole 28 | +tenant: Tenants <> 29 | +lastActive: date 30 | +password: string <> 31 | +settings: StudentSettings 32 | +achievements: List 33 | +points: List 34 | +notifications: List 35 | } 36 | 37 | class Courses { 38 | +title: string <> 39 | +description: text 40 | +instructor: Users 41 | +tenant: Tenants <> 42 | +status: CourseStatus 43 | +modules: List 44 | +startDate: date 45 | +endDate: date 46 | +announcements: List 47 | +assignments: List 48 | +certificates: List 49 | } 50 | 51 | class Modules { 52 | +title: string <> 53 | +course: Courses <> 54 | +description: richText 55 | +order: number <> 56 | +lessons: List 57 | +status: PublishStatus 58 | +prerequisites: List 59 | } 60 | 61 | class Lessons { 62 | +title: string <> 63 | +module: Modules <> 64 | +content: richText <> 65 | +media: List 66 | +order: number 67 | +quizzes: List 68 | +duration: number 69 | +status: PublishStatus 70 | } 71 | 72 | ' Learning content 73 | class Quizzes { 74 | +title: string <> 75 | +lesson: Lessons <> 76 | +questions: List 77 | +attemptsAllowed: number 78 | +timeLimit: number 79 | +passingScore: number 80 | +shuffleQuestions: boolean 81 | +showCorrectAnswers: boolean 82 | } 83 | 84 | class Assignments { 85 | +title: string <> 86 | +description: richText 87 | +course: Courses <> 88 | +lesson: Lessons 89 | +dueDate: date 90 | +maxScore: number 91 | +submissions: List 92 | +rubric: Map 93 | +attachments: List 94 | } 95 | 96 | class Submissions { 97 | +student: Users <> 98 | +assignment: Assignments <> 99 | +content: richText <> 100 | +files: List 101 | +submissionDate: date <> 102 | +score: number 103 | +feedback: richText 104 | +status: SubmissionStatus 105 | } 106 | 107 | ' Gamification 108 | class Points { 109 | +student: Users <> 110 | +type: PointType 111 | +amount: number <> 112 | +source: SourceType 113 | +metadata: Map 114 | +timestamp: date 115 | } 116 | 117 | class Badges { 118 | +name: string <> 119 | +description: text 120 | +icon: Media 121 | +requiredPoints: number 122 | +criteria: BadgeCriteria 123 | +tenant: Tenants <> 124 | +levelRequired: number 125 | +pointValues: Map 126 | } 127 | 128 | class Achievements { 129 | +user: Users <> 130 | +badge: Badges <> 131 | +dateEarned: date <> 132 | +progress: number 133 | +metadata: Map 134 | } 135 | 136 | class Streaks { 137 | +student: Users <> 138 | +type: StreakType 139 | +currentStreak: number 140 | +longestStreak: number 141 | +lastActivity: date <> 142 | +nextRequired: date <> 143 | +history: List 144 | } 145 | 146 | class Leaderboard { 147 | +user: Users <> 148 | +tenant: Tenants <> 149 | +points: number <> 150 | +level: number <> 151 | +currentStreak: number 152 | +longestStreak: number 153 | +lastActivityDate: date 154 | +timeframe: TimeframeType 155 | } 156 | 157 | ' Communication & Collaboration 158 | class Notifications { 159 | +subject: string <> 160 | +content: richText <> 161 | +type: NotificationType 162 | +priority: PriorityLevel 163 | +recipient: Users <> 164 | +read: boolean 165 | +reference: Reference 166 | +tenant: Tenants <> 167 | } 168 | 169 | class Collaborations { 170 | +name: string <> 171 | +type: CollaborationType 172 | +course: Courses <> 173 | +participants: List <> 174 | +features: List 175 | +status: CollabStatus 176 | } 177 | 178 | class Announcements { 179 | +title: string <> 180 | +content: richText <> 181 | +course: Courses <> 182 | +dateCreated: date <> 183 | +notifyUsers: List 184 | +attachments: List 185 | } 186 | 187 | ' Settings & Support 188 | class TenantSettings { 189 | +tenant: Tenants <> 190 | +branding: BrandingConfig 191 | +featureToggles: FeatureConfig 192 | +notificationPreferences: NotificationConfig 193 | } 194 | 195 | class StudentSettings { 196 | +user: Users <> 197 | +notificationPreferences: NotificationConfig 198 | +accessibility: AccessibilityConfig 199 | +language: string 200 | +timezone: string 201 | } 202 | 203 | class SupportTickets { 204 | +tenant: Tenants <> 205 | +user: Users <> 206 | +description: text <> 207 | +status: TicketStatus 208 | +priority: PriorityLevel 209 | +category: string 210 | +attachments: List 211 | +responses: List 212 | } 213 | 214 | class Media { 215 | +filename: string <> 216 | +mimeType: string <> 217 | +filesize: number 218 | +width: number 219 | +height: number 220 | +alt: string 221 | +tenant: Tenants 222 | } 223 | 224 | class Certificates { 225 | +course: Courses <> 226 | +student: Users <> 227 | +issueDate: date <> 228 | +certificateUrl: string <> 229 | +template: Media 230 | +metadata: Map 231 | } 232 | 233 | ' Enums 234 | enum UserRole { 235 | ADMIN 236 | INSTRUCTOR 237 | STUDENT 238 | } 239 | 240 | enum CourseStatus { 241 | PUBLISHED 242 | DRAFT 243 | ARCHIVED 244 | } 245 | 246 | enum PublishStatus { 247 | PUBLISHED 248 | DRAFT 249 | } 250 | 251 | enum SubmissionStatus { 252 | SUBMITTED 253 | GRADED 254 | RETURNED 255 | } 256 | 257 | enum PointType { 258 | LESSON_COMPLETE 259 | QUIZ_SCORE 260 | ASSIGNMENT_SUBMIT 261 | STREAK_BONUS 262 | } 263 | 264 | enum StreakType { 265 | LOGIN 266 | PROGRESS 267 | QUIZ 268 | ASSIGNMENT 269 | } 270 | 271 | enum TimeframeType { 272 | WEEKLY 273 | MONTHLY 274 | ALL_TIME 275 | } 276 | 277 | enum PriorityLevel { 278 | LOW 279 | MEDIUM 280 | HIGH 281 | } 282 | 283 | enum CollabStatus { 284 | ACTIVE 285 | ARCHIVED 286 | } 287 | 288 | enum TicketStatus { 289 | OPEN 290 | IN_PROGRESS 291 | RESOLVED 292 | } 293 | 294 | ' Relationships 295 | Tenants "1" -- "*" Users : owns > 296 | Tenants "1" -- "*" Courses : manages > 297 | Tenants "1" -- "1" TenantSettings : configures > 298 | Tenants "1" -- "*" Media : owns > 299 | 300 | Users "1" -- "1" StudentSettings : has > 301 | Users "1" -- "*" Points : earns > 302 | Users "1" -- "*" Achievements : unlocks > 303 | Users "1" -- "*" Notifications : receives > 304 | Users "*" -- "*" Collaborations : participates > 305 | Users "1" -- "*" Certificates : earns > 306 | Users "1" -- "*" SupportTickets : creates > 307 | 308 | Courses "1" -- "*" Modules : contains > 309 | Courses "1" -- "*" Announcements : publishes > 310 | Courses "1" -- "*" Assignments : includes > 311 | Courses "1" -- "*" Certificates : issues > 312 | Courses "*" -- "*" Users : enrolls > 313 | 314 | Modules "1" -- "*" Lessons : contains > 315 | Modules "*" -- "*" Modules : requires > 316 | 317 | Lessons "1" -- "*" Quizzes : contains > 318 | Lessons "1" -- "*" Media : uses > 319 | 320 | Assignments "1" -- "*" Submissions : receives > 321 | Assignments "1" -- "*" Media : includes > 322 | 323 | Submissions "1" -- "*" Media : attaches > 324 | 325 | Badges "*" -- "*" Users : awards > 326 | Streaks "1" -- "1" Users : tracks > 327 | Leaderboard "*" -- "1" Users : ranks > 328 | 329 | Announcements "*" -- "*" Users : notifies > 330 | Announcements "1" -- "*" Media : attaches > 331 | 332 | @enduml -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'drizzle-kit' 2 | import * as dotenv from 'dotenv' 3 | dotenv.config() 4 | 5 | if (!process.env.DATABASE_URL) { 6 | throw new Error('DATABASE_URL is not set in environment variables') 7 | } 8 | 9 | const connectionString = process.env.DATABASE_URL 10 | const [protocol, rest] = connectionString.split('://') 11 | const [credentials, hostAndDb] = rest.split('@') 12 | const [user, password] = credentials.split(':') 13 | const [hostWithPort, database] = hostAndDb.split('/') 14 | const [host] = hostWithPort.split(':') 15 | 16 | export default { 17 | schema: './src/lib/db/schema/*', 18 | out: './drizzle', 19 | dialect: 'postgresql', 20 | dbCredentials: { 21 | host, 22 | user, 23 | password, 24 | database: database.split('?')[0], 25 | ssl: true, 26 | }, 27 | verbose: true, 28 | strict: true, 29 | } satisfies Config -------------------------------------------------------------------------------- /drizzle/0000_flippant_silver_surfer.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS "tenants" ( 2 | "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, 3 | "name" text NOT NULL, 4 | "slug" text NOT NULL, 5 | "domain" text, 6 | "status" text DEFAULT 'active' NOT NULL, 7 | "created_at" timestamp DEFAULT now() NOT NULL, 8 | "updated_at" timestamp DEFAULT now() NOT NULL, 9 | CONSTRAINT "tenants_slug_unique" UNIQUE("slug"), 10 | CONSTRAINT "tenants_domain_unique" UNIQUE("domain") 11 | ); 12 | -------------------------------------------------------------------------------- /drizzle/meta/0000_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "a5585edc-fbf5-443d-bf29-467b4fa6ae4a", 3 | "prevId": "00000000-0000-0000-0000-000000000000", 4 | "version": "7", 5 | "dialect": "postgresql", 6 | "tables": { 7 | "public.tenants": { 8 | "name": "tenants", 9 | "schema": "", 10 | "columns": { 11 | "id": { 12 | "name": "id", 13 | "type": "uuid", 14 | "primaryKey": true, 15 | "notNull": true, 16 | "default": "gen_random_uuid()" 17 | }, 18 | "name": { 19 | "name": "name", 20 | "type": "text", 21 | "primaryKey": false, 22 | "notNull": true 23 | }, 24 | "slug": { 25 | "name": "slug", 26 | "type": "text", 27 | "primaryKey": false, 28 | "notNull": true 29 | }, 30 | "domain": { 31 | "name": "domain", 32 | "type": "text", 33 | "primaryKey": false, 34 | "notNull": false 35 | }, 36 | "status": { 37 | "name": "status", 38 | "type": "text", 39 | "primaryKey": false, 40 | "notNull": true, 41 | "default": "'active'" 42 | }, 43 | "created_at": { 44 | "name": "created_at", 45 | "type": "timestamp", 46 | "primaryKey": false, 47 | "notNull": true, 48 | "default": "now()" 49 | }, 50 | "updated_at": { 51 | "name": "updated_at", 52 | "type": "timestamp", 53 | "primaryKey": false, 54 | "notNull": true, 55 | "default": "now()" 56 | } 57 | }, 58 | "indexes": {}, 59 | "foreignKeys": {}, 60 | "compositePrimaryKeys": {}, 61 | "uniqueConstraints": { 62 | "tenants_slug_unique": { 63 | "name": "tenants_slug_unique", 64 | "nullsNotDistinct": false, 65 | "columns": [ 66 | "slug" 67 | ] 68 | }, 69 | "tenants_domain_unique": { 70 | "name": "tenants_domain_unique", 71 | "nullsNotDistinct": false, 72 | "columns": [ 73 | "domain" 74 | ] 75 | } 76 | }, 77 | "policies": {}, 78 | "checkConstraints": {}, 79 | "isRLSEnabled": false 80 | } 81 | }, 82 | "enums": {}, 83 | "schemas": {}, 84 | "sequences": {}, 85 | "roles": {}, 86 | "policies": {}, 87 | "views": {}, 88 | "_meta": { 89 | "columns": {}, 90 | "schemas": {}, 91 | "tables": {} 92 | } 93 | } -------------------------------------------------------------------------------- /drizzle/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "7", 3 | "dialect": "postgresql", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "7", 8 | "when": 1733421902050, 9 | "tag": "0000_flippant_silver_surfer", 10 | "breakpoints": true 11 | } 12 | ] 13 | } -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | import { withPayload } from '@payloadcms/next/withPayload' 2 | 3 | /** @type {import('next').NextConfig} */ 4 | const nextConfig = { 5 | experimental: { 6 | reactCompiler: false, 7 | }, 8 | images: { 9 | domains: [ 10 | 'localhost', 11 | // Add your production domains here 12 | ], 13 | }, 14 | typescript: { 15 | ignoreBuildErrors: false, 16 | }, 17 | eslint: { 18 | ignoreDuringBuilds: false, 19 | }, 20 | } 21 | 22 | export default withPayload(nextConfig) 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "louminous", 3 | "version": "1.0.0", 4 | "description": "A blank template to get started with Payload", 5 | "license": "MIT", 6 | "type": "module", 7 | "scripts": { 8 | "build": "cross-env NODE_OPTIONS=--no-deprecation next build", 9 | "dev": "cross-env NODE_OPTIONS=--no-deprecation next dev", 10 | "generate:importmap": "cross-env NODE_OPTIONS=--no-deprecation payload generate:importmap", 11 | "generate:schema": "payload-graphql generate:schema", 12 | "generate:types": "payload generate:types", 13 | "payload": "cross-env NODE_OPTIONS=--no-deprecation payload", 14 | "seed": "npm run payload migrate:fresh", 15 | "start": "cross-env NODE_OPTIONS=--no-deprecation next start" 16 | }, 17 | "dependencies": { 18 | "@payloadcms/db-postgres": "latest", 19 | "@payloadcms/next": "latest", 20 | "@payloadcms/richtext-lexical": "latest", 21 | "@payloadcms/ui": "latest", 22 | "cross-env": "^7.0.3", 23 | "dotenv": "^8.2.0", 24 | "graphql": "^16.9.0", 25 | "next": "^15.0.0", 26 | "payload": "latest", 27 | "qs-esm": "7.0.2", 28 | "react": "19.0.0", 29 | "react-dom": "19.0.0", 30 | "sharp": "0.32.6" 31 | }, 32 | "devDependencies": { 33 | "@payloadcms/graphql": "latest", 34 | "@swc/core": "^1.6.13", 35 | "@types/react": "19.0.1", 36 | "@types/react-dom": "19.0.1", 37 | "eslint": "^8.57.0", 38 | "eslint-config-next": "^15.0.0", 39 | "tsx": "^4.16.2", 40 | "typescript": "5.5.2" 41 | }, 42 | "engines": { 43 | "node": "^18.20.2 || >=20.9.0" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/access/roles.ts: -------------------------------------------------------------------------------- 1 | import type { Access } from 'payload' 2 | 3 | export const isAdmin: Access = async ({ req }) => { 4 | return req.user?.role === 'admin' 5 | } 6 | 7 | export const isInstructor: Access = async ({ req }) => { 8 | return req.user?.role === 'instructor' 9 | } 10 | 11 | export const isStudent: Access = async ({ req }) => { 12 | return req.user?.role === 'student' 13 | } 14 | 15 | export const hasRole: Access = async ({ req }, role?: string) => { 16 | if (!req.user || !role) return false 17 | return req.user.role === role 18 | } 19 | 20 | export const isSameUser: Access = async ({ req, id }) => { 21 | if (!req.user || !id || typeof id !== 'string') return false 22 | return req.user.id.toString() === id 23 | } 24 | 25 | export const isSameTenant: Access = async ({ req }, tenantId?: string) => { 26 | if (!req.user || !tenantId) return false 27 | return req.user.tenant?.toString() === tenantId 28 | } 29 | 30 | export const isAdminOrInstructor: Access = async ({ req }) => { 31 | return req.user?.role === 'admin' || req.user?.role === 'instructor' 32 | } 33 | 34 | export const isAdminOrSelf: Access = async ({ req, id }) => { 35 | if (!req.user) return false 36 | if (req.user.role === 'admin') return true 37 | return req.user.id.toString() === id?.toString() 38 | } 39 | 40 | export const isAdminOrInstructorOrSelf: Access = async ({ req, id }) => { 41 | if (!id || typeof id !== 'string') return false 42 | return ( 43 | req.user?.role === 'admin' || 44 | req.user?.role === 'instructor' || 45 | (req.user?.role === 'student' && req.user.id.toString() === id) 46 | ) 47 | } 48 | -------------------------------------------------------------------------------- /src/app/(payload)/admin/[[...segments]]/not-found.tsx: -------------------------------------------------------------------------------- 1 | /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ 2 | /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ 3 | import type { Metadata } from 'next' 4 | 5 | import config from '../../../../payload.config' 6 | import { NotFoundPage, generatePageMetadata } from '@payloadcms/next/views' 7 | import { importMap } from '../importMap' 8 | 9 | type Args = { 10 | params: Promise<{ 11 | segments: string[] 12 | }> 13 | searchParams: Promise<{ 14 | [key: string]: string | string[] 15 | }> 16 | } 17 | 18 | export const generateMetadata = ({ params, searchParams }: Args): Promise => 19 | generatePageMetadata({ config, params, searchParams }) 20 | 21 | const NotFound = ({ params, searchParams }: Args) => 22 | NotFoundPage({ config, params, searchParams, importMap }) 23 | 24 | export default NotFound 25 | -------------------------------------------------------------------------------- /src/app/(payload)/admin/[[...segments]]/page.tsx: -------------------------------------------------------------------------------- 1 | /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ 2 | /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ 3 | import type { Metadata } from 'next' 4 | 5 | import config from '../../../../payload.config' 6 | import { RootPage, generatePageMetadata } from '@payloadcms/next/views' 7 | import { importMap } from '../importMap' 8 | 9 | type Args = { 10 | params: Promise<{ 11 | segments: string[] 12 | }> 13 | searchParams: Promise<{ 14 | [key: string]: string | string[] 15 | }> 16 | } 17 | 18 | export const generateMetadata = ({ params, searchParams }: Args): Promise => 19 | generatePageMetadata({ config, params, searchParams }) 20 | 21 | const Page = ({ params, searchParams }: Args) => 22 | RootPage({ config, params, searchParams, importMap }) 23 | 24 | export default Page 25 | -------------------------------------------------------------------------------- /src/app/(payload)/admin/importMap.js: -------------------------------------------------------------------------------- 1 | import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc' 2 | import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc' 3 | import { BlocksFeatureClient as BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' 4 | import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' 5 | import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' 6 | import { UploadFeatureClient as UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' 7 | import { BlockquoteFeatureClient as BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' 8 | import { RelationshipFeatureClient as RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' 9 | import { LinkFeatureClient as LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' 10 | import { ChecklistFeatureClient as ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' 11 | import { OrderedListFeatureClient as OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' 12 | import { UnorderedListFeatureClient as UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' 13 | import { IndentFeatureClient as IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' 14 | import { AlignFeatureClient as AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' 15 | import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' 16 | import { ParagraphFeatureClient as ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' 17 | import { InlineCodeFeatureClient as InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' 18 | import { SuperscriptFeatureClient as SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' 19 | import { SubscriptFeatureClient as SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' 20 | import { StrikethroughFeatureClient as StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' 21 | import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' 22 | import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' 23 | import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' 24 | 25 | export const importMap = { 26 | "@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e, 27 | "@payloadcms/richtext-lexical/rsc#RscEntryLexicalField": RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e, 28 | "@payloadcms/richtext-lexical/client#BlocksFeatureClient": BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, 29 | "@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient": InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, 30 | "@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient": HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, 31 | "@payloadcms/richtext-lexical/client#UploadFeatureClient": UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, 32 | "@payloadcms/richtext-lexical/client#BlockquoteFeatureClient": BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, 33 | "@payloadcms/richtext-lexical/client#RelationshipFeatureClient": RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, 34 | "@payloadcms/richtext-lexical/client#LinkFeatureClient": LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, 35 | "@payloadcms/richtext-lexical/client#ChecklistFeatureClient": ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, 36 | "@payloadcms/richtext-lexical/client#OrderedListFeatureClient": OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, 37 | "@payloadcms/richtext-lexical/client#UnorderedListFeatureClient": UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, 38 | "@payloadcms/richtext-lexical/client#IndentFeatureClient": IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, 39 | "@payloadcms/richtext-lexical/client#AlignFeatureClient": AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, 40 | "@payloadcms/richtext-lexical/client#HeadingFeatureClient": HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, 41 | "@payloadcms/richtext-lexical/client#ParagraphFeatureClient": ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, 42 | "@payloadcms/richtext-lexical/client#InlineCodeFeatureClient": InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, 43 | "@payloadcms/richtext-lexical/client#SuperscriptFeatureClient": SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, 44 | "@payloadcms/richtext-lexical/client#SubscriptFeatureClient": SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, 45 | "@payloadcms/richtext-lexical/client#StrikethroughFeatureClient": StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, 46 | "@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, 47 | "@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, 48 | "@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 49 | } 50 | -------------------------------------------------------------------------------- /src/app/(payload)/api/[...slug]/route.ts: -------------------------------------------------------------------------------- 1 | /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ 2 | /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ 3 | import config from '../../../../payload.config' 4 | import '@payloadcms/next/css' 5 | import { 6 | REST_DELETE, 7 | REST_GET, 8 | REST_OPTIONS, 9 | REST_PATCH, 10 | REST_POST, 11 | REST_PUT, 12 | } from '@payloadcms/next/routes' 13 | 14 | export const GET = REST_GET(config) 15 | export const POST = REST_POST(config) 16 | export const DELETE = REST_DELETE(config) 17 | export const PATCH = REST_PATCH(config) 18 | export const PUT = REST_PUT(config) 19 | export const OPTIONS = REST_OPTIONS(config) 20 | -------------------------------------------------------------------------------- /src/app/(payload)/api/graphql-playground/route.ts: -------------------------------------------------------------------------------- 1 | /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ 2 | /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ 3 | import config from '../../../../payload.config' 4 | import '@payloadcms/next/css' 5 | import { GRAPHQL_PLAYGROUND_GET } from '@payloadcms/next/routes' 6 | 7 | export const GET = GRAPHQL_PLAYGROUND_GET(config) 8 | -------------------------------------------------------------------------------- /src/app/(payload)/api/graphql/route.ts: -------------------------------------------------------------------------------- 1 | /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ 2 | /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ 3 | import config from '../../../../payload.config' 4 | import { GRAPHQL_POST, REST_OPTIONS } from '@payloadcms/next/routes' 5 | 6 | export const POST = GRAPHQL_POST(config) 7 | 8 | export const OPTIONS = REST_OPTIONS(config) 9 | -------------------------------------------------------------------------------- /src/app/(payload)/custom.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thorski1/LouMinouS/3157b13233cc36588ec85d9c09d2dd802617db79/src/app/(payload)/custom.scss -------------------------------------------------------------------------------- /src/app/(payload)/layout.tsx: -------------------------------------------------------------------------------- 1 | /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ 2 | /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ 3 | import config from '../../payload.config' 4 | import '@payloadcms/next/css' 5 | import type { ServerFunctionClient } from 'payload' 6 | import { handleServerFunctions, RootLayout } from '@payloadcms/next/layouts' 7 | import React from 'react' 8 | 9 | import { importMap } from './admin/importMap.js' 10 | import './custom.scss' 11 | 12 | type Args = { 13 | children: React.ReactNode 14 | } 15 | 16 | const serverFunction: ServerFunctionClient = async function (args) { 17 | 'use server' 18 | return handleServerFunctions({ 19 | ...args, 20 | config, 21 | importMap, 22 | }) 23 | } 24 | 25 | const Layout = ({ children }: Args) => ( 26 | 27 | {children} 28 | 29 | ) 30 | 31 | export default Layout 32 | -------------------------------------------------------------------------------- /src/app/actions/achievements.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import { db } from '@/lib/db' 3 | import { achievements } from '@/lib/db/schema/achievements' 4 | import { userAchievements } from '@/lib/db/schema/userAchievements' 5 | import { eq, and, inArray } from 'drizzle-orm' 6 | import { checkAchievementProgress } from '@/lib/achievements/checkProgress' 7 | import { auth } from '@/lib/auth' 8 | 9 | const checkProgressSchema = z.object({ 10 | achievementId: z.string().uuid(), 11 | }) 12 | 13 | export async function checkProgress(input: z.infer) { 14 | const { userId, tenantId } = await auth() 15 | if (!userId || !tenantId) { 16 | throw new Error('Unauthorized') 17 | } 18 | 19 | await checkAchievementProgress({ 20 | achievementId: input.achievementId, 21 | userId, 22 | tenantId, 23 | }) 24 | } 25 | 26 | export async function getUserAchievements() { 27 | const { userId, tenantId } = await auth() 28 | if (!userId || !tenantId) { 29 | throw new Error('Unauthorized') 30 | } 31 | 32 | const userAchievementsList = await db.query.userAchievements.findMany({ 33 | where: eq(userAchievements.userId, userId), 34 | with: { 35 | achievement: { 36 | with: { 37 | badge: true, 38 | }, 39 | }, 40 | }, 41 | orderBy: (achievements, { desc }) => [ 42 | desc(achievements.completedAt), 43 | ], 44 | }) 45 | 46 | return userAchievementsList 47 | } 48 | 49 | export async function getAvailableAchievements() { 50 | const { userId, tenantId } = await auth() 51 | if (!userId || !tenantId) { 52 | throw new Error('Unauthorized') 53 | } 54 | 55 | // Get all achievements for tenant 56 | const availableAchievements = await db.query.achievements.findMany({ 57 | where: and( 58 | eq(achievements.tenantId, tenantId), 59 | eq(achievements.isGlobal, false) 60 | ), 61 | with: { 62 | badge: true, 63 | prerequisites: true, 64 | }, 65 | orderBy: (achievements, { asc }) => [ 66 | asc(achievements.category), 67 | asc(achievements.order), 68 | ], 69 | }) 70 | 71 | // Get user's completed achievements 72 | const completedAchievements = await db.query.userAchievements.findMany({ 73 | where: eq(userAchievements.userId, userId), 74 | }) 75 | 76 | const completedIds = new Set(completedAchievements.map(a => a.achievementId)) 77 | 78 | // Filter out completed achievements and check prerequisites 79 | const filteredAchievements = availableAchievements.map(achievement => ({ 80 | ...achievement, 81 | isCompleted: completedIds.has(achievement.id), 82 | prerequisitesMet: achievement.prerequisites.every(p => completedIds.has(p.id)), 83 | })) 84 | 85 | return filteredAchievements 86 | } 87 | 88 | export async function getAchievementProgress(achievementId: string) { 89 | const { userId, tenantId } = await auth() 90 | if (!userId || !tenantId) { 91 | throw new Error('Unauthorized') 92 | } 93 | 94 | const achievement = await db.query.achievements.findFirst({ 95 | where: and( 96 | eq(achievements.id, achievementId), 97 | eq(achievements.tenantId, tenantId) 98 | ), 99 | }) 100 | 101 | if (!achievement) { 102 | throw new Error('Achievement not found') 103 | } 104 | 105 | const progress = await checkAchievementProgress({ 106 | achievementId, 107 | userId, 108 | tenantId, 109 | }) 110 | 111 | return { 112 | achievement, 113 | progress, 114 | } 115 | } -------------------------------------------------------------------------------- /src/collections/Achievements.ts: -------------------------------------------------------------------------------- 1 | import type { CollectionConfig, Where, WhereField, FilterOptionsProps } from 'payload' 2 | import type { User } from '../payload-types' 3 | 4 | type AccessArgs = { 5 | req: { 6 | user?: User | null 7 | } 8 | } 9 | 10 | export const Achievements: CollectionConfig = { 11 | slug: 'achievements', 12 | admin: { 13 | useAsTitle: 'name', 14 | group: 'Gamification', 15 | defaultColumns: ['name', 'type', 'badge', 'points'], 16 | description: 'Achievement definitions and criteria', 17 | }, 18 | fields: [ 19 | { 20 | name: 'name', 21 | type: 'text', 22 | required: true, 23 | admin: { 24 | description: 'Name of the achievement', 25 | }, 26 | index: true, 27 | }, 28 | { 29 | name: 'tenant', 30 | type: 'relationship', 31 | relationTo: 'tenants', 32 | admin: { 33 | description: 'Tenant this achievement belongs to', 34 | }, 35 | index: true, 36 | }, 37 | { 38 | name: 'description', 39 | type: 'textarea', 40 | required: true, 41 | admin: { 42 | description: 'Detailed description of how to earn this achievement', 43 | }, 44 | }, 45 | { 46 | name: 'type', 47 | type: 'select', 48 | required: true, 49 | options: [ 50 | { label: 'Course Progress', value: 'course_progress' }, 51 | { label: 'Quiz Score', value: 'quiz_score' }, 52 | { label: 'Assignment', value: 'assignment' }, 53 | { label: 'Streak', value: 'streak' }, 54 | { label: 'Discussion', value: 'discussion' }, 55 | { label: 'Custom', value: 'custom' }, 56 | ], 57 | admin: { 58 | description: 'Type of activity tracked', 59 | }, 60 | }, 61 | { 62 | name: 'criteria', 63 | type: 'group', 64 | fields: [ 65 | { 66 | name: 'metric', 67 | type: 'select', 68 | required: true, 69 | options: [ 70 | { label: 'Count', value: 'count' }, 71 | { label: 'Score', value: 'score' }, 72 | { label: 'Duration', value: 'duration' }, 73 | { label: 'Custom', value: 'custom' }, 74 | ], 75 | admin: { 76 | description: 'What to measure', 77 | }, 78 | }, 79 | { 80 | name: 'threshold', 81 | type: 'number', 82 | required: true, 83 | min: 0, 84 | admin: { 85 | description: 'Target value to achieve', 86 | }, 87 | }, 88 | { 89 | name: 'timeframe', 90 | type: 'select', 91 | options: [ 92 | { label: 'All Time', value: 'all_time' }, 93 | { label: 'Daily', value: 'daily' }, 94 | { label: 'Weekly', value: 'weekly' }, 95 | { label: 'Monthly', value: 'monthly' }, 96 | ], 97 | defaultValue: 'all_time', 98 | admin: { 99 | description: 'Time period to measure over', 100 | }, 101 | }, 102 | { 103 | name: 'customRule', 104 | type: 'code', 105 | admin: { 106 | language: 'typescript', 107 | description: 'Custom achievement criteria logic', 108 | condition: (data, siblingData) => siblingData?.metric === 'custom', 109 | }, 110 | }, 111 | ], 112 | }, 113 | { 114 | name: 'badge', 115 | type: 'relationship', 116 | relationTo: 'badges' as const, 117 | required: true, 118 | admin: { 119 | description: 'Badge awarded for this achievement', 120 | }, 121 | filterOptions: ({ user }: FilterOptionsProps): Where => { 122 | if (user?.role === 'admin') return {} as Where 123 | return { 124 | or: [ 125 | { 126 | tenant: { 127 | equals: user?.tenant, 128 | } as WhereField, 129 | }, 130 | { 131 | isGlobal: { 132 | equals: true, 133 | } as WhereField, 134 | }, 135 | ], 136 | } as Where 137 | }, 138 | }, 139 | { 140 | name: 'points', 141 | type: 'number', 142 | required: true, 143 | min: 0, 144 | admin: { 145 | description: 'Points awarded for this achievement', 146 | }, 147 | }, 148 | { 149 | name: 'secret', 150 | type: 'checkbox', 151 | defaultValue: false, 152 | admin: { 153 | description: 'Hide this achievement until unlocked', 154 | }, 155 | }, 156 | { 157 | name: 'isGlobal', 158 | type: 'checkbox', 159 | defaultValue: false, 160 | admin: { 161 | description: 'Make this achievement available to all tenants', 162 | condition: (data) => data.user?.role === 'admin', 163 | }, 164 | }, 165 | ], 166 | hooks: { 167 | beforeChange: [ 168 | async ({ data, req, operation }) => { 169 | if (operation === 'create' && !data.tenant && !data.isGlobal && req.user) { 170 | data.tenant = req.user.tenant 171 | } 172 | 173 | if (data.isGlobal) { 174 | data.tenant = undefined 175 | } 176 | 177 | return data 178 | }, 179 | ], 180 | }, 181 | } -------------------------------------------------------------------------------- /src/collections/Badges.ts: -------------------------------------------------------------------------------- 1 | import type { CollectionConfig, Where, WhereField } from 'payload' 2 | import type { User } from '../payload-types' 3 | 4 | type AccessArgs = { 5 | req: { 6 | user?: User | null 7 | } 8 | } 9 | 10 | export const Badges: CollectionConfig = { 11 | slug: 'badges', 12 | admin: { 13 | useAsTitle: 'name', 14 | group: 'Gamification', 15 | defaultColumns: ['name', 'rarity', 'category'], 16 | description: 'Achievement badges for gamification', 17 | }, 18 | fields: [ 19 | { 20 | name: 'name', 21 | type: 'text', 22 | required: true, 23 | admin: { 24 | description: 'Name of the badge', 25 | }, 26 | index: true, 27 | }, 28 | { 29 | name: 'description', 30 | type: 'textarea', 31 | required: true, 32 | admin: { 33 | description: 'Detailed description of how to earn this badge', 34 | }, 35 | }, 36 | { 37 | name: 'icon', 38 | type: 'upload', 39 | relationTo: 'media', 40 | required: true, 41 | admin: { 42 | description: 'Badge icon image', 43 | }, 44 | }, 45 | { 46 | name: 'rarity', 47 | type: 'select', 48 | required: true, 49 | options: [ 50 | { label: 'Common', value: 'common' }, 51 | { label: 'Uncommon', value: 'uncommon' }, 52 | { label: 'Rare', value: 'rare' }, 53 | { label: 'Epic', value: 'epic' }, 54 | { label: 'Legendary', value: 'legendary' }, 55 | ], 56 | admin: { 57 | description: 'Badge rarity level', 58 | }, 59 | index: true, 60 | }, 61 | { 62 | name: 'category', 63 | type: 'select', 64 | required: true, 65 | options: [ 66 | { label: 'Progress', value: 'progress' }, 67 | { label: 'Performance', value: 'performance' }, 68 | { label: 'Engagement', value: 'engagement' }, 69 | { label: 'Special', value: 'special' }, 70 | ], 71 | admin: { 72 | description: 'Badge category', 73 | }, 74 | index: true, 75 | }, 76 | { 77 | name: 'tenant', 78 | type: 'relationship', 79 | relationTo: 'tenants', 80 | admin: { 81 | description: 'Tenant this badge belongs to', 82 | condition: (data) => !data.isGlobal, 83 | }, 84 | index: true, 85 | }, 86 | { 87 | name: 'isGlobal', 88 | type: 'checkbox', 89 | defaultValue: false, 90 | admin: { 91 | description: 'Make this badge available to all tenants', 92 | }, 93 | }, 94 | ], 95 | access: { 96 | read: ({ req: { user } }: AccessArgs): boolean | Where => { 97 | if (!user) return false 98 | if (user.role === 'admin') return true 99 | return { 100 | or: [ 101 | { 102 | 'tenant.id': { 103 | equals: user.tenant, 104 | } as WhereField, 105 | }, 106 | { 107 | isGlobal: { 108 | equals: true, 109 | } as WhereField, 110 | }, 111 | ], 112 | } 113 | }, 114 | create: ({ req: { user } }: AccessArgs) => user?.role === 'admin', 115 | update: ({ req: { user } }: AccessArgs) => user?.role === 'admin', 116 | delete: ({ req: { user } }: AccessArgs) => user?.role === 'admin', 117 | }, 118 | hooks: { 119 | beforeChange: [ 120 | async ({ data, req, operation }) => { 121 | if (operation === 'create' && !data.tenant && !data.isGlobal && req.user) { 122 | data.tenant = req.user.tenant 123 | } 124 | if (data.isGlobal) { 125 | data.tenant = undefined 126 | } 127 | return data 128 | }, 129 | ], 130 | }, 131 | } -------------------------------------------------------------------------------- /src/collections/Courses.ts: -------------------------------------------------------------------------------- 1 | import type { CollectionConfig, CollectionSlug } from 'payload' 2 | import type { User } from '../payload-types' 3 | 4 | type AccessArgs = { 5 | req: { 6 | user?: User | null 7 | } 8 | } 9 | 10 | // Add this type to handle the enrollments collection 11 | type EnrollmentsSlug = 'enrollments' 12 | type AllCollectionSlugs = CollectionSlug | EnrollmentsSlug 13 | 14 | export const Courses: CollectionConfig = { 15 | slug: 'courses', 16 | admin: { 17 | useAsTitle: 'title', 18 | group: 'Learning', 19 | defaultColumns: ['title', 'instructor', 'status', 'updatedAt'], 20 | description: 'Course content and structure', 21 | listSearchableFields: ['title', 'slug'], 22 | }, 23 | versions: { 24 | drafts: true, 25 | }, 26 | access: { 27 | read: ({ req: { user } }: AccessArgs) => { 28 | if (user?.role === 'admin') return true 29 | return { 30 | tenant: { 31 | equals: user?.tenant, 32 | }, 33 | } 34 | }, 35 | create: ({ req: { user } }: AccessArgs) => 36 | user?.role === 'admin' || user?.role === 'instructor', 37 | update: ({ req: { user } }: AccessArgs) => { 38 | if (user?.role === 'admin') return true 39 | if (user?.role === 'instructor') { 40 | return { 41 | instructor: { 42 | equals: user?.id, 43 | }, 44 | } 45 | } 46 | return false 47 | }, 48 | delete: ({ req: { user } }: AccessArgs) => user?.role === 'admin', 49 | }, 50 | fields: [ 51 | { 52 | name: 'title', 53 | type: 'text', 54 | required: true, 55 | admin: { 56 | description: 'The title of the course', 57 | placeholder: 'e.g., Introduction to Programming', 58 | }, 59 | }, 60 | { 61 | name: 'slug', 62 | type: 'text', 63 | required: true, 64 | unique: true, 65 | admin: { 66 | description: 'URL-friendly version of the title (auto-generated if not provided)', 67 | placeholder: 'e.g., intro-to-programming', 68 | }, 69 | }, 70 | { 71 | name: 'tenant', 72 | type: 'relationship', 73 | relationTo: 'tenants' as CollectionSlug, 74 | required: true, 75 | admin: { 76 | description: 'The organization this course belongs to', 77 | condition: (data) => !data.isGlobal, 78 | }, 79 | }, 80 | { 81 | name: 'isGlobal', 82 | type: 'checkbox', 83 | defaultValue: false, 84 | admin: { 85 | description: 'Make this course available to all tenants', 86 | condition: (data) => data.user?.role === 'admin', 87 | }, 88 | }, 89 | { 90 | name: 'instructor', 91 | type: 'relationship', 92 | relationTo: 'users' as CollectionSlug, 93 | required: true, 94 | filterOptions: { 95 | role: { 96 | equals: 'instructor', 97 | }, 98 | }, 99 | admin: { 100 | description: 'The instructor responsible for this course', 101 | }, 102 | }, 103 | { 104 | name: 'description', 105 | type: 'richText', 106 | required: true, 107 | admin: { 108 | description: 'Detailed description of the course content and objectives', 109 | }, 110 | }, 111 | { 112 | name: 'thumbnail', 113 | type: 'upload', 114 | relationTo: 'media' as CollectionSlug, 115 | required: true, 116 | admin: { 117 | description: 'Course thumbnail image (16:9 ratio recommended)', 118 | }, 119 | }, 120 | { 121 | name: 'modules', 122 | type: 'relationship', 123 | relationTo: 'modules' as CollectionSlug, 124 | hasMany: true, 125 | admin: { 126 | description: 'Course modules in sequential order', 127 | }, 128 | }, 129 | { 130 | name: 'prerequisites', 131 | type: 'relationship', 132 | relationTo: 'courses' as CollectionSlug, 133 | hasMany: true, 134 | filterOptions: ({ user }) => { 135 | if (!user?.tenant) return false 136 | return { 137 | tenant: { 138 | equals: user.tenant, 139 | }, 140 | } 141 | }, 142 | }, 143 | { 144 | name: 'duration', 145 | type: 'group', 146 | fields: [ 147 | { 148 | name: 'hours', 149 | type: 'number', 150 | required: true, 151 | min: 0, 152 | }, 153 | { 154 | name: 'minutes', 155 | type: 'number', 156 | required: true, 157 | min: 0, 158 | max: 59, 159 | }, 160 | ], 161 | }, 162 | { 163 | name: 'schedule', 164 | type: 'group', 165 | fields: [ 166 | { 167 | name: 'startDate', 168 | type: 'date', 169 | required: true, 170 | admin: { 171 | date: { 172 | pickerAppearance: 'dayAndTime', 173 | }, 174 | }, 175 | }, 176 | { 177 | name: 'endDate', 178 | type: 'date', 179 | required: true, 180 | admin: { 181 | date: { 182 | pickerAppearance: 'dayAndTime', 183 | }, 184 | }, 185 | }, 186 | { 187 | name: 'enrollmentDeadline', 188 | type: 'date', 189 | admin: { 190 | date: { 191 | pickerAppearance: 'dayAndTime', 192 | }, 193 | }, 194 | }, 195 | ], 196 | }, 197 | { 198 | name: 'status', 199 | type: 'select', 200 | required: true, 201 | defaultValue: 'draft', 202 | options: [ 203 | { label: 'Draft', value: 'draft' }, 204 | { label: 'Published', value: 'published' }, 205 | { label: 'Archived', value: 'archived' }, 206 | ], 207 | }, 208 | { 209 | name: 'settings', 210 | type: 'group', 211 | fields: [ 212 | { 213 | name: 'allowLateSubmissions', 214 | type: 'checkbox', 215 | defaultValue: true, 216 | }, 217 | { 218 | name: 'requirePrerequisites', 219 | type: 'checkbox', 220 | defaultValue: true, 221 | }, 222 | { 223 | name: 'showProgress', 224 | type: 'checkbox', 225 | defaultValue: true, 226 | }, 227 | ], 228 | }, 229 | { 230 | name: 'enrollment', 231 | type: 'group', 232 | fields: [ 233 | { 234 | name: 'capacity', 235 | type: 'number', 236 | min: 0, 237 | admin: { 238 | description: 'Maximum number of students (0 for unlimited)', 239 | }, 240 | }, 241 | { 242 | name: 'allowSelfEnrollment', 243 | type: 'checkbox', 244 | defaultValue: true, 245 | admin: { 246 | description: 'Allow students to enroll themselves', 247 | }, 248 | }, 249 | { 250 | name: 'requirePrerequisiteCompletion', 251 | type: 'checkbox', 252 | defaultValue: true, 253 | admin: { 254 | description: 'Enforce prerequisite course completion', 255 | }, 256 | }, 257 | { 258 | name: 'enrollmentStart', 259 | type: 'date', 260 | admin: { 261 | description: 'When enrollment opens', 262 | date: { 263 | pickerAppearance: 'dayAndTime', 264 | }, 265 | }, 266 | }, 267 | { 268 | name: 'enrollmentEnd', 269 | type: 'date', 270 | admin: { 271 | description: 'When enrollment closes', 272 | date: { 273 | pickerAppearance: 'dayAndTime', 274 | }, 275 | }, 276 | }, 277 | ], 278 | }, 279 | ], 280 | hooks: { 281 | beforeChange: [ 282 | async ({ data, req, operation }) => { 283 | // Generate slug from title if not provided 284 | if (operation === 'create' && data.title && !data.slug) { 285 | data.slug = data.title 286 | .toLowerCase() 287 | .replace(/[^a-z0-9]+/g, '-') 288 | .replace(/^-|-$/g, '') 289 | } 290 | 291 | // Set tenant from user if not provided and not global 292 | if (operation === 'create' && !data.tenant && !data.isGlobal && req.user) { 293 | data.tenant = req.user.tenant 294 | } 295 | 296 | // Set instructor to current user if not provided and user is instructor 297 | if (operation === 'create' && !data.instructor && req.user?.role === 'instructor') { 298 | data.instructor = req.user.id 299 | } 300 | 301 | // Validate enrollment capacity 302 | if (operation === 'create' || operation === 'update') { 303 | if (data.enrollment?.capacity > 0) { 304 | const currentEnrollments = await req.payload.find({ 305 | collection: 'enrollments' as AllCollectionSlugs, 306 | where: { 307 | course: { 308 | equals: data.id, 309 | }, 310 | status: { 311 | equals: 'active', 312 | }, 313 | }, 314 | }) 315 | 316 | if (currentEnrollments.totalDocs >= data.enrollment.capacity) { 317 | throw new Error('Course has reached maximum enrollment capacity') 318 | } 319 | } 320 | } 321 | 322 | return data 323 | }, 324 | ], 325 | }, 326 | } 327 | -------------------------------------------------------------------------------- /src/collections/Enrollments.ts: -------------------------------------------------------------------------------- 1 | import type { CollectionConfig, Access, Where } from 'payload' 2 | import type { User } from '../payload-types' 3 | 4 | type AccessArgs = { 5 | req: { 6 | user?: User | null 7 | } 8 | } 9 | 10 | export const Enrollments: CollectionConfig = { 11 | slug: 'enrollments', 12 | admin: { 13 | useAsTitle: 'id', 14 | group: 'Learning', 15 | defaultColumns: ['student', 'course', 'status', 'enrolledAt'], 16 | description: 'Student course enrollments', 17 | }, 18 | access: { 19 | read: ({ req: { user } }: AccessArgs) => { 20 | if (!user) return false 21 | if (user.role === 'admin') return true 22 | if (user.role === 'instructor') { 23 | return { 24 | and: [ 25 | { 26 | 'course.tenant': { 27 | equals: user.tenant, 28 | }, 29 | }, 30 | ], 31 | } 32 | } 33 | return { 34 | and: [ 35 | { 36 | student: { 37 | equals: user.id, 38 | }, 39 | }, 40 | ], 41 | } 42 | }, 43 | create: ({ req: { user } }: AccessArgs) => { 44 | if (!user) return false 45 | // Allow admins and instructors to enroll students 46 | if (user.role === 'admin' || user.role === 'instructor') return true 47 | // Allow students to self-enroll if the course allows it 48 | if (user.role === 'student') { 49 | return { 50 | and: [ 51 | { 52 | 'course.allowSelfEnrollment': { 53 | equals: true, 54 | }, 55 | }, 56 | ], 57 | } 58 | } 59 | return false 60 | }, 61 | update: ({ req: { user } }: AccessArgs) => { 62 | if (!user) return false 63 | if (user.role === 'admin') return true 64 | if (user.role === 'instructor') { 65 | return { 66 | and: [ 67 | { 68 | 'course.tenant': { 69 | equals: user.tenant, 70 | }, 71 | }, 72 | ], 73 | } 74 | } 75 | // Students can only update their own enrollment status 76 | return { 77 | and: [ 78 | { 79 | student: { 80 | equals: user.id, 81 | }, 82 | }, 83 | ], 84 | } 85 | }, 86 | delete: ({ req: { user } }: AccessArgs) => { 87 | if (!user) return false 88 | return user.role === 'admin' 89 | }, 90 | }, 91 | fields: [ 92 | { 93 | name: 'student', 94 | type: 'relationship', 95 | relationTo: 'users', 96 | required: true, 97 | admin: { 98 | description: 'The enrolled student', 99 | }, 100 | }, 101 | { 102 | name: 'course', 103 | type: 'relationship', 104 | relationTo: 'courses', 105 | required: true, 106 | admin: { 107 | description: 'The course being enrolled in', 108 | }, 109 | }, 110 | { 111 | name: 'status', 112 | type: 'select', 113 | required: true, 114 | defaultValue: 'active', 115 | options: [ 116 | { label: 'Active', value: 'active' }, 117 | { label: 'Completed', value: 'completed' }, 118 | { label: 'Dropped', value: 'dropped' }, 119 | { label: 'Pending', value: 'pending' }, 120 | ], 121 | }, 122 | { 123 | name: 'enrolledAt', 124 | type: 'date', 125 | required: true, 126 | admin: { 127 | description: 'When the enrollment was created', 128 | }, 129 | }, 130 | { 131 | name: 'startedAt', 132 | type: 'date', 133 | admin: { 134 | description: 'When the student started the course', 135 | }, 136 | }, 137 | { 138 | name: 'completedAt', 139 | type: 'date', 140 | admin: { 141 | description: 'When the student completed the course', 142 | }, 143 | }, 144 | { 145 | name: 'droppedAt', 146 | type: 'date', 147 | admin: { 148 | description: 'When the student dropped the course', 149 | }, 150 | }, 151 | { 152 | name: 'expiresAt', 153 | type: 'date', 154 | admin: { 155 | description: 'When the enrollment expires', 156 | }, 157 | }, 158 | ], 159 | hooks: { 160 | beforeChange: [ 161 | async ({ data, operation }) => { 162 | // Set enrolledAt on creation 163 | if (operation === 'create') { 164 | data.enrolledAt = new Date().toISOString() 165 | } 166 | 167 | // Handle status changes 168 | if (operation === 'update' && data.status) { 169 | const now = new Date().toISOString() 170 | switch (data.status) { 171 | case 'active': 172 | if (!data.startedAt) data.startedAt = now 173 | break 174 | case 'completed': 175 | data.completedAt = now 176 | break 177 | case 'dropped': 178 | data.droppedAt = now 179 | break 180 | } 181 | } 182 | 183 | return data 184 | }, 185 | ], 186 | afterChange: [ 187 | async ({ doc, operation, req }) => { 188 | // Create initial progress record on enrollment 189 | if (operation === 'create') { 190 | await req.payload.create({ 191 | collection: 'progress', 192 | data: { 193 | student: doc.student, 194 | course: doc.course, 195 | startedAt: new Date().toISOString(), 196 | lastAccessed: new Date().toISOString(), 197 | status: 'not_started', 198 | overallProgress: 0, 199 | pointsEarned: 0, 200 | totalPoints: 0, 201 | isGlobal: false, 202 | }, 203 | }) 204 | } 205 | }, 206 | ], 207 | }, 208 | } 209 | -------------------------------------------------------------------------------- /src/collections/Leaderboards.ts: -------------------------------------------------------------------------------- 1 | import type { CollectionConfig, Where, WhereField } from 'payload' 2 | import type { User } from '../payload-types' 3 | 4 | type AccessArgs = { 5 | req: { 6 | user?: User | null 7 | } 8 | } 9 | 10 | export const Leaderboards: CollectionConfig = { 11 | slug: 'leaderboards', 12 | admin: { 13 | useAsTitle: 'name', 14 | group: 'Gamification', 15 | defaultColumns: ['name', 'type', 'timeframe'], 16 | description: 'Leaderboard configurations', 17 | }, 18 | fields: [ 19 | { 20 | name: 'name', 21 | type: 'text', 22 | required: true, 23 | admin: { 24 | description: 'Display name for the leaderboard', 25 | }, 26 | }, 27 | { 28 | name: 'tenant', 29 | type: 'relationship', 30 | relationTo: 'tenants', 31 | admin: { 32 | description: 'Tenant this leaderboard belongs to', 33 | condition: (data) => !data?.isGlobal, 34 | }, 35 | index: true, 36 | }, 37 | { 38 | name: 'isGlobal', 39 | type: 'checkbox', 40 | defaultValue: false, 41 | admin: { 42 | description: 'Make this leaderboard available to all tenants', 43 | }, 44 | }, 45 | { 46 | name: 'type', 47 | type: 'select', 48 | required: true, 49 | options: [ 50 | { label: 'Points', value: 'points' }, 51 | { label: 'Progress', value: 'progress' }, 52 | { label: 'Achievements', value: 'achievements' }, 53 | { label: 'Custom', value: 'custom' }, 54 | ], 55 | admin: { 56 | description: 'What to rank users by', 57 | }, 58 | index: true, 59 | }, 60 | { 61 | name: 'timeframe', 62 | type: 'select', 63 | required: true, 64 | options: [ 65 | { label: 'All Time', value: 'all_time' }, 66 | { label: 'Daily', value: 'daily' }, 67 | { label: 'Weekly', value: 'weekly' }, 68 | { label: 'Monthly', value: 'monthly' }, 69 | ], 70 | admin: { 71 | description: 'Time period to rank over', 72 | }, 73 | index: true, 74 | }, 75 | { 76 | name: 'scope', 77 | type: 'group', 78 | fields: [ 79 | { 80 | name: 'course', 81 | type: 'relationship', 82 | relationTo: 'courses', 83 | admin: { 84 | description: 'Limit to specific course', 85 | }, 86 | index: true, 87 | }, 88 | { 89 | name: 'pointType', 90 | type: 'select', 91 | options: [ 92 | { label: 'All', value: 'all' }, 93 | { label: 'Lesson', value: 'lesson' }, 94 | { label: 'Quiz', value: 'quiz' }, 95 | { label: 'Assignment', value: 'assignment' }, 96 | ], 97 | defaultValue: 'all', 98 | admin: { 99 | condition: (data, siblingData) => data?.type === 'points', 100 | }, 101 | }, 102 | { 103 | name: 'achievementType', 104 | type: 'select', 105 | options: [ 106 | { label: 'All', value: 'all' }, 107 | { label: 'Course', value: 'course' }, 108 | { label: 'Quiz', value: 'quiz' }, 109 | { label: 'Streak', value: 'streak' }, 110 | ], 111 | defaultValue: 'all', 112 | admin: { 113 | condition: (data, siblingData) => data?.type === 'achievements', 114 | }, 115 | }, 116 | ], 117 | }, 118 | { 119 | name: 'customLogic', 120 | type: 'code', 121 | admin: { 122 | language: 'typescript', 123 | description: 'Custom ranking logic', 124 | condition: (data) => data?.type === 'custom', 125 | }, 126 | }, 127 | { 128 | name: 'displayLimit', 129 | type: 'number', 130 | required: true, 131 | min: 1, 132 | max: 100, 133 | defaultValue: 10, 134 | admin: { 135 | description: 'Number of top ranks to display', 136 | }, 137 | }, 138 | { 139 | name: 'refreshInterval', 140 | type: 'number', 141 | required: true, 142 | min: 300, 143 | defaultValue: 3600, 144 | admin: { 145 | description: 'How often to refresh rankings (in seconds)', 146 | }, 147 | }, 148 | ], 149 | access: { 150 | read: ({ req: { user } }: AccessArgs): boolean | Where => { 151 | if (!user) return false 152 | if (user.role === 'admin') return true 153 | return { 154 | or: [ 155 | { 156 | 'tenant.id': { 157 | equals: user.tenant, 158 | } as WhereField, 159 | }, 160 | { 161 | isGlobal: { 162 | equals: true, 163 | } as WhereField, 164 | }, 165 | ], 166 | } 167 | }, 168 | create: ({ req: { user } }: AccessArgs) => user?.role === 'admin', 169 | update: ({ req: { user } }: AccessArgs) => user?.role === 'admin', 170 | delete: ({ req: { user } }: AccessArgs) => user?.role === 'admin', 171 | }, 172 | hooks: { 173 | beforeValidate: [ 174 | ({ data, operation }) => { 175 | if (operation === 'create' || operation === 'update') { 176 | if (!data?.isGlobal && !data?.tenant) { 177 | throw new Error('Tenant is required when leaderboard is not global') 178 | } 179 | } 180 | return data 181 | }, 182 | ], 183 | beforeChange: [ 184 | async ({ data, req, operation }) => { 185 | if (operation === 'create' && !data.tenant && !data.isGlobal && req.user) { 186 | data.tenant = req.user.tenant 187 | } 188 | if (data.isGlobal) { 189 | data.tenant = undefined 190 | } 191 | return data 192 | }, 193 | ], 194 | }, 195 | } 196 | -------------------------------------------------------------------------------- /src/collections/Lessons.ts: -------------------------------------------------------------------------------- 1 | import type { CollectionConfig, CollectionSlug } from 'payload' 2 | import type { User } from '../payload-types' 3 | 4 | type AccessArgs = { 5 | req: { 6 | user?: User | null 7 | } 8 | } 9 | 10 | export const Lessons: CollectionConfig = { 11 | slug: 'lessons', 12 | admin: { 13 | useAsTitle: 'title', 14 | group: 'Learning', 15 | defaultColumns: ['title', 'module', 'type', 'status'], 16 | description: 'Individual lessons within modules', 17 | listSearchableFields: ['title'], 18 | }, 19 | versions: { 20 | drafts: true, 21 | }, 22 | access: { 23 | read: ({ req: { user } }: AccessArgs) => { 24 | if (user?.role === 'admin') return true 25 | return { 26 | 'module.course.tenant': { 27 | equals: user?.tenant, 28 | }, 29 | } 30 | }, 31 | create: ({ req: { user } }: AccessArgs) => 32 | user?.role === 'admin' || user?.role === 'instructor', 33 | update: ({ req: { user } }: AccessArgs) => { 34 | if (user?.role === 'admin') return true 35 | if (user?.role === 'instructor') { 36 | return { 37 | 'module.course.instructor': { 38 | equals: user?.id, 39 | }, 40 | } 41 | } 42 | return false 43 | }, 44 | delete: ({ req: { user } }: AccessArgs) => user?.role === 'admin', 45 | }, 46 | fields: [ 47 | { 48 | name: 'title', 49 | type: 'text', 50 | required: true, 51 | admin: { 52 | description: 'The title of the lesson', 53 | }, 54 | }, 55 | { 56 | name: 'module', 57 | type: 'relationship', 58 | relationTo: 'modules' as CollectionSlug, 59 | required: true, 60 | admin: { 61 | description: 'The module this lesson belongs to', 62 | }, 63 | }, 64 | { 65 | name: 'order', 66 | type: 'number', 67 | required: true, 68 | min: 0, 69 | admin: { 70 | description: 'Order in which this lesson appears in the module', 71 | }, 72 | }, 73 | { 74 | name: 'type', 75 | type: 'select', 76 | required: true, 77 | options: [ 78 | { label: 'Video', value: 'video' }, 79 | { label: 'Reading', value: 'reading' }, 80 | { label: 'Quiz', value: 'quiz' }, 81 | { label: 'Assignment', value: 'assignment' }, 82 | { label: 'Discussion', value: 'discussion' }, 83 | ], 84 | admin: { 85 | description: 'The type of lesson content', 86 | }, 87 | }, 88 | { 89 | name: 'description', 90 | type: 'richText', 91 | admin: { 92 | description: 'Brief overview of the lesson', 93 | }, 94 | }, 95 | // Video specific fields 96 | { 97 | name: 'video', 98 | type: 'group', 99 | admin: { 100 | condition: (data) => data.type === 'video', 101 | }, 102 | fields: [ 103 | { 104 | name: 'url', 105 | type: 'text', 106 | required: true, 107 | admin: { 108 | description: 'URL of the video (YouTube, Vimeo, etc.)', 109 | }, 110 | }, 111 | { 112 | name: 'duration', 113 | type: 'number', 114 | required: true, 115 | min: 0, 116 | admin: { 117 | description: 'Duration in minutes', 118 | }, 119 | }, 120 | { 121 | name: 'transcript', 122 | type: 'textarea', 123 | admin: { 124 | description: 'Video transcript for accessibility', 125 | }, 126 | }, 127 | ], 128 | }, 129 | // Reading specific fields 130 | { 131 | name: 'content', 132 | type: 'richText', 133 | required: true, 134 | admin: { 135 | description: 'Lesson content in rich text format', 136 | condition: (data) => data.type === 'reading', 137 | }, 138 | }, 139 | // Quiz specific fields 140 | { 141 | name: 'quiz', 142 | type: 'group', 143 | admin: { 144 | condition: (data) => data.type === 'quiz', 145 | }, 146 | fields: [ 147 | { 148 | name: 'questions', 149 | type: 'array', 150 | required: true, 151 | fields: [ 152 | { 153 | name: 'question', 154 | type: 'text', 155 | required: true, 156 | }, 157 | { 158 | name: 'type', 159 | type: 'select', 160 | required: true, 161 | options: [ 162 | { label: 'Multiple Choice', value: 'multiple' }, 163 | { label: 'True/False', value: 'boolean' }, 164 | { label: 'Short Answer', value: 'text' }, 165 | ], 166 | }, 167 | { 168 | name: 'options', 169 | type: 'array', 170 | required: true, 171 | admin: { 172 | condition: (data, siblingData) => siblingData?.type === 'multiple', 173 | }, 174 | fields: [ 175 | { 176 | name: 'text', 177 | type: 'text', 178 | required: true, 179 | }, 180 | { 181 | name: 'correct', 182 | type: 'checkbox', 183 | required: true, 184 | }, 185 | ], 186 | }, 187 | { 188 | name: 'answer', 189 | type: 'text', 190 | required: true, 191 | admin: { 192 | condition: (data, siblingData) => 193 | siblingData?.type === 'boolean' || siblingData?.type === 'text', 194 | }, 195 | }, 196 | { 197 | name: 'points', 198 | type: 'number', 199 | required: true, 200 | min: 0, 201 | }, 202 | { 203 | name: 'explanation', 204 | type: 'richText', 205 | admin: { 206 | description: 'Explanation of the correct answer', 207 | }, 208 | }, 209 | ], 210 | }, 211 | { 212 | name: 'settings', 213 | type: 'group', 214 | fields: [ 215 | { 216 | name: 'timeLimit', 217 | type: 'number', 218 | min: 0, 219 | admin: { 220 | description: 'Time limit in minutes (0 for no limit)', 221 | }, 222 | }, 223 | { 224 | name: 'attempts', 225 | type: 'number', 226 | min: 1, 227 | defaultValue: 1, 228 | admin: { 229 | description: 'Number of attempts allowed', 230 | }, 231 | }, 232 | { 233 | name: 'passingScore', 234 | type: 'number', 235 | min: 0, 236 | max: 100, 237 | required: true, 238 | defaultValue: 70, 239 | admin: { 240 | description: 'Minimum score required to pass (%)', 241 | }, 242 | }, 243 | { 244 | name: 'randomizeQuestions', 245 | type: 'checkbox', 246 | defaultValue: false, 247 | }, 248 | { 249 | name: 'showCorrectAnswers', 250 | type: 'select', 251 | options: [ 252 | { label: 'Never', value: 'never' }, 253 | { label: 'After Each Question', value: 'after_each' }, 254 | { label: 'After Submission', value: 'after_submit' }, 255 | { label: 'After All Attempts', value: 'after_all' }, 256 | ], 257 | defaultValue: 'after_submit', 258 | }, 259 | ], 260 | }, 261 | ], 262 | }, 263 | // Assignment specific fields 264 | { 265 | name: 'assignment', 266 | type: 'group', 267 | admin: { 268 | condition: (data) => data.type === 'assignment', 269 | }, 270 | fields: [ 271 | { 272 | name: 'instructions', 273 | type: 'richText', 274 | required: true, 275 | }, 276 | { 277 | name: 'dueDate', 278 | type: 'date', 279 | required: true, 280 | admin: { 281 | date: { 282 | pickerAppearance: 'dayAndTime', 283 | }, 284 | }, 285 | }, 286 | { 287 | name: 'points', 288 | type: 'number', 289 | required: true, 290 | min: 0, 291 | }, 292 | { 293 | name: 'rubric', 294 | type: 'array', 295 | fields: [ 296 | { 297 | name: 'criterion', 298 | type: 'text', 299 | required: true, 300 | }, 301 | { 302 | name: 'points', 303 | type: 'number', 304 | required: true, 305 | min: 0, 306 | }, 307 | { 308 | name: 'description', 309 | type: 'textarea', 310 | }, 311 | ], 312 | }, 313 | { 314 | name: 'allowedFileTypes', 315 | type: 'select', 316 | hasMany: true, 317 | options: [ 318 | { label: 'PDF', value: 'pdf' }, 319 | { label: 'Word', value: 'doc' }, 320 | { label: 'Images', value: 'image' }, 321 | { label: 'ZIP', value: 'zip' }, 322 | { label: 'Code', value: 'code' }, 323 | ], 324 | }, 325 | ], 326 | }, 327 | // Discussion specific fields 328 | { 329 | name: 'discussion', 330 | type: 'group', 331 | admin: { 332 | condition: (data) => data.type === 'discussion', 333 | }, 334 | fields: [ 335 | { 336 | name: 'prompt', 337 | type: 'richText', 338 | required: true, 339 | }, 340 | { 341 | name: 'guidelines', 342 | type: 'array', 343 | fields: [ 344 | { 345 | name: 'text', 346 | type: 'text', 347 | required: true, 348 | }, 349 | ], 350 | }, 351 | { 352 | name: 'settings', 353 | type: 'group', 354 | fields: [ 355 | { 356 | name: 'requireResponse', 357 | type: 'checkbox', 358 | defaultValue: true, 359 | }, 360 | { 361 | name: 'requireReplies', 362 | type: 'number', 363 | min: 0, 364 | defaultValue: 2, 365 | admin: { 366 | description: 'Number of replies required (0 for none)', 367 | }, 368 | }, 369 | { 370 | name: 'minimumWords', 371 | type: 'number', 372 | min: 0, 373 | admin: { 374 | description: 'Minimum words required per post', 375 | }, 376 | }, 377 | { 378 | name: 'dueDate', 379 | type: 'date', 380 | admin: { 381 | date: { 382 | pickerAppearance: 'dayAndTime', 383 | }, 384 | }, 385 | }, 386 | ], 387 | }, 388 | ], 389 | }, 390 | { 391 | name: 'status', 392 | type: 'select', 393 | required: true, 394 | defaultValue: 'draft', 395 | options: [ 396 | { label: 'Draft', value: 'draft' }, 397 | { label: 'Published', value: 'published' }, 398 | { label: 'Archived', value: 'archived' }, 399 | ], 400 | }, 401 | ], 402 | hooks: { 403 | beforeChange: [ 404 | ({ data, req, operation }) => { 405 | // Ensure order is set for new lessons 406 | if (operation === 'create' && typeof data.order === 'undefined') { 407 | data.order = 0 408 | } 409 | return data 410 | }, 411 | ], 412 | }, 413 | } -------------------------------------------------------------------------------- /src/collections/Levels.ts: -------------------------------------------------------------------------------- 1 | import type { CollectionConfig, Where } from 'payload' 2 | import type { User, Config } from '../payload-types' 3 | 4 | type AccessArgs = { 5 | req: { 6 | user?: User | null 7 | } 8 | } 9 | 10 | type Level = Config['collections']['levels'] 11 | 12 | export const Levels: CollectionConfig = { 13 | slug: 'levels', 14 | admin: { 15 | useAsTitle: 'name', 16 | group: 'Gamification', 17 | defaultColumns: ['name', 'level', 'pointsRequired', 'tenant'], 18 | description: 'Level definitions and rewards', 19 | }, 20 | access: { 21 | read: ({ req: { user } }: AccessArgs): boolean | Where => { 22 | if (!user) return false 23 | if (user.role === 'admin') return true 24 | return { 25 | or: [ 26 | { 27 | 'tenant.id': { 28 | equals: user.tenant 29 | } 30 | }, 31 | { 32 | isGlobal: { 33 | equals: true 34 | } 35 | } 36 | ] 37 | } 38 | }, 39 | create: ({ req: { user } }: AccessArgs) => user?.role === 'admin', 40 | update: ({ req: { user } }: AccessArgs) => user?.role === 'admin', 41 | delete: ({ req: { user } }: AccessArgs) => user?.role === 'admin', 42 | }, 43 | fields: [ 44 | { 45 | name: 'name', 46 | type: 'text', 47 | required: true, 48 | admin: { 49 | description: 'Display name for the level', 50 | }, 51 | }, 52 | { 53 | name: 'level', 54 | type: 'number', 55 | required: true, 56 | min: 1, 57 | unique: false, 58 | admin: { 59 | description: 'Numeric level value (unique per tenant)', 60 | }, 61 | }, 62 | { 63 | name: 'description', 64 | type: 'textarea', 65 | admin: { 66 | description: 'Detailed description of the level', 67 | }, 68 | }, 69 | { 70 | name: 'pointsRequired', 71 | type: 'number', 72 | required: true, 73 | min: 0, 74 | admin: { 75 | description: 'Points needed to reach this level', 76 | }, 77 | }, 78 | { 79 | name: 'tenant', 80 | type: 'relationship', 81 | relationTo: 'tenants', 82 | required: true, 83 | admin: { 84 | condition: (data) => !data.isGlobal, 85 | description: 'The tenant this level belongs to', 86 | }, 87 | }, 88 | { 89 | name: 'isGlobal', 90 | type: 'checkbox', 91 | defaultValue: false, 92 | admin: { 93 | description: 'Make this level available to all tenants', 94 | condition: (data) => data.user?.role === 'admin', 95 | }, 96 | }, 97 | { 98 | name: 'icon', 99 | type: 'upload', 100 | relationTo: 'media', 101 | required: true, 102 | admin: { 103 | description: 'Level icon image', 104 | }, 105 | filterOptions: { 106 | mimeType: { 107 | contains: 'image', 108 | }, 109 | }, 110 | }, 111 | { 112 | name: 'rewards', 113 | type: 'array', 114 | admin: { 115 | description: 'Rewards earned at this level', 116 | }, 117 | fields: [ 118 | { 119 | name: 'type', 120 | type: 'select', 121 | required: true, 122 | options: [ 123 | { label: 'Badge', value: 'badge' }, 124 | { label: 'Feature Unlock', value: 'feature' }, 125 | { label: 'Custom', value: 'custom' }, 126 | ], 127 | }, 128 | { 129 | name: 'badge', 130 | type: 'relationship', 131 | relationTo: 'badges', 132 | admin: { 133 | condition: (data, siblingData) => siblingData?.type === 'badge', 134 | description: 'Badge awarded at this level', 135 | }, 136 | }, 137 | { 138 | name: 'feature', 139 | type: 'text', 140 | admin: { 141 | condition: (data, siblingData) => siblingData?.type === 'feature', 142 | description: 'Feature unlocked at this level', 143 | }, 144 | }, 145 | { 146 | name: 'customData', 147 | type: 'json', 148 | admin: { 149 | condition: (data, siblingData) => siblingData?.type === 'custom', 150 | description: 'Custom reward data', 151 | }, 152 | }, 153 | ], 154 | }, 155 | ], 156 | hooks: { 157 | beforeChange: [ 158 | async ({ data, req, operation }) => { 159 | if (operation === 'create' && !data.tenant && !data.isGlobal && req.user) { 160 | data.tenant = req.user.tenant 161 | } 162 | 163 | if (operation === 'create') { 164 | const existingLevel = await req.payload.find({ 165 | collection: 'levels', 166 | where: { 167 | and: [ 168 | { 169 | level: { 170 | equals: data.level, 171 | }, 172 | }, 173 | { 174 | or: [ 175 | { 176 | 'tenant.id': { 177 | equals: data.tenant, 178 | }, 179 | }, 180 | { 181 | isGlobal: { 182 | equals: true, 183 | }, 184 | }, 185 | ], 186 | }, 187 | ], 188 | }, 189 | }) 190 | 191 | if (existingLevel.totalDocs > 0) { 192 | throw new Error(`Level ${data.level} already exists for this tenant`) 193 | } 194 | } 195 | 196 | if (data.level > 1) { 197 | const previousLevel = await req.payload.find({ 198 | collection: 'levels', 199 | where: { 200 | and: [ 201 | { 202 | level: { 203 | equals: data.level - 1, 204 | }, 205 | }, 206 | { 207 | or: [ 208 | { 209 | 'tenant.id': { 210 | equals: data.tenant, 211 | }, 212 | }, 213 | { 214 | isGlobal: { 215 | equals: true, 216 | }, 217 | }, 218 | ], 219 | }, 220 | ], 221 | }, 222 | }) 223 | 224 | if (previousLevel.docs.length > 0) { 225 | const prev = previousLevel.docs[0] as Level 226 | if (prev.pointsRequired >= data.pointsRequired) { 227 | throw new Error('Points required must be greater than previous level') 228 | } 229 | } 230 | } 231 | 232 | return data 233 | }, 234 | ], 235 | afterChange: [ 236 | async ({ doc, operation, req }) => { 237 | if (operation === 'create' || operation === 'update') { 238 | const students = await req.payload.find({ 239 | collection: 'progress', 240 | where: { 241 | and: [ 242 | { 243 | totalPoints: { 244 | greater_than_equal: doc.pointsRequired, 245 | }, 246 | }, 247 | { 248 | or: [ 249 | { 250 | 'student.tenant.id': { 251 | equals: doc.tenant, 252 | }, 253 | }, 254 | { 255 | isGlobal: { 256 | equals: true, 257 | }, 258 | }, 259 | ], 260 | }, 261 | ], 262 | }, 263 | }) 264 | 265 | // TODO: Implement level up notifications 266 | // TODO: Award level rewards 267 | } 268 | }, 269 | ], 270 | }, 271 | } -------------------------------------------------------------------------------- /src/collections/Media.ts: -------------------------------------------------------------------------------- 1 | import { CollectionConfig } from 'payload' 2 | import type { User } from '../payload-types' 3 | 4 | type MediaAccessArgs = { 5 | req: { 6 | user?: User | null 7 | } 8 | } 9 | 10 | type MediaData = { 11 | isGlobal?: boolean 12 | mimeType?: string 13 | user?: User 14 | tenant?: string | { id: string } 15 | } 16 | 17 | export const Media: CollectionConfig = { 18 | slug: 'media', 19 | admin: { 20 | useAsTitle: 'filename', 21 | group: 'Content', 22 | defaultColumns: ['filename', 'mimeType', 'tenant'], 23 | description: 'Media files and images', 24 | listSearchableFields: ['filename', 'alt'], 25 | }, 26 | access: { 27 | read: ({ req: { user } }: MediaAccessArgs) => { 28 | if (user?.role === 'admin') return true 29 | return { 30 | tenant: { 31 | equals: user?.tenant, 32 | }, 33 | } 34 | }, 35 | create: ({ req: { user } }: MediaAccessArgs) => !!user, 36 | update: ({ req: { user } }: MediaAccessArgs) => { 37 | if (user?.role === 'admin') return true 38 | return { 39 | tenant: { 40 | equals: user?.tenant, 41 | }, 42 | } 43 | }, 44 | delete: ({ req: { user } }: MediaAccessArgs) => { 45 | if (user?.role === 'admin') return true 46 | return { 47 | tenant: { 48 | equals: user?.tenant, 49 | }, 50 | } 51 | }, 52 | }, 53 | upload: { 54 | staticDir: 'media', 55 | imageSizes: [ 56 | { 57 | name: 'thumbnail', 58 | width: 400, 59 | height: 300, 60 | position: 'centre', 61 | }, 62 | { 63 | name: 'card', 64 | width: 768, 65 | height: 1024, 66 | position: 'centre', 67 | }, 68 | ], 69 | adminThumbnail: 'thumbnail', 70 | mimeTypes: ['image/*', 'application/pdf'], 71 | }, 72 | fields: [ 73 | { 74 | name: 'tenant', 75 | type: 'relationship', 76 | relationTo: 'tenants', 77 | required: true, 78 | admin: { 79 | description: 'The tenant this media belongs to', 80 | condition: (data: MediaData): boolean => Boolean(!data.isGlobal), 81 | }, 82 | }, 83 | { 84 | name: 'isGlobal', 85 | type: 'checkbox', 86 | defaultValue: false, 87 | admin: { 88 | description: 'Make this media available to all tenants', 89 | condition: (data: MediaData): boolean => Boolean(data.user?.role === 'admin'), 90 | }, 91 | }, 92 | { 93 | name: 'alt', 94 | type: 'text', 95 | admin: { 96 | description: 'Alternative text for the image', 97 | condition: (data: MediaData): boolean => Boolean(data.mimeType?.includes('image')), 98 | }, 99 | }, 100 | ], 101 | hooks: { 102 | beforeChange: [ 103 | ({ req, data }: { req: any; data: any }) => { 104 | if (!data.tenant && !data.isGlobal && req.user) { 105 | data.tenant = req.user.tenant 106 | } 107 | return data 108 | }, 109 | ], 110 | }, 111 | } 112 | -------------------------------------------------------------------------------- /src/collections/Modules.ts: -------------------------------------------------------------------------------- 1 | import type { CollectionConfig, CollectionSlug } from 'payload' 2 | import type { User } from '../payload-types' 3 | 4 | type AccessArgs = { 5 | req: { 6 | user?: User | null 7 | } 8 | } 9 | 10 | export const Modules: CollectionConfig = { 11 | slug: 'modules' as CollectionSlug, 12 | admin: { 13 | useAsTitle: 'title', 14 | group: 'Learning', 15 | defaultColumns: ['title', 'course', 'status'], 16 | description: 'Course modules and sections', 17 | listSearchableFields: ['title'], 18 | }, 19 | versions: { 20 | drafts: true, 21 | }, 22 | access: { 23 | read: ({ req: { user } }: AccessArgs) => { 24 | if (user?.role === 'admin') return true 25 | return { 26 | 'course.tenant': { 27 | equals: user?.tenant, 28 | }, 29 | } 30 | }, 31 | create: ({ req: { user } }: AccessArgs) => 32 | user?.role === 'admin' || user?.role === 'instructor', 33 | update: ({ req: { user } }: AccessArgs) => { 34 | if (user?.role === 'admin') return true 35 | if (user?.role === 'instructor') { 36 | return { 37 | 'course.instructor': { 38 | equals: user?.id, 39 | }, 40 | } 41 | } 42 | return false 43 | }, 44 | delete: ({ req: { user } }: AccessArgs) => user?.role === 'admin', 45 | }, 46 | fields: [ 47 | { 48 | name: 'title', 49 | type: 'text', 50 | required: true, 51 | }, 52 | { 53 | name: 'description', 54 | type: 'richText', 55 | }, 56 | { 57 | name: 'course', 58 | type: 'relationship', 59 | relationTo: 'courses' as CollectionSlug, 60 | required: true, 61 | hasMany: false, 62 | admin: { 63 | description: 'The course this module belongs to', 64 | }, 65 | }, 66 | { 67 | name: 'order', 68 | type: 'number', 69 | required: true, 70 | min: 0, 71 | admin: { 72 | description: 'Order in which this module appears in the course', 73 | }, 74 | }, 75 | { 76 | name: 'lessons', 77 | type: 'relationship', 78 | relationTo: 'lessons' as CollectionSlug, 79 | hasMany: true, 80 | admin: { 81 | description: 'Lessons within this module', 82 | }, 83 | }, 84 | { 85 | name: 'duration', 86 | type: 'group', 87 | fields: [ 88 | { 89 | name: 'hours', 90 | type: 'number', 91 | required: true, 92 | min: 0, 93 | defaultValue: 0, 94 | }, 95 | { 96 | name: 'minutes', 97 | type: 'number', 98 | required: true, 99 | min: 0, 100 | max: 59, 101 | defaultValue: 0, 102 | }, 103 | ], 104 | }, 105 | { 106 | name: 'status', 107 | type: 'select', 108 | required: true, 109 | defaultValue: 'draft', 110 | options: [ 111 | { label: 'Draft', value: 'draft' }, 112 | { label: 'Published', value: 'published' }, 113 | { label: 'Archived', value: 'archived' }, 114 | ], 115 | }, 116 | { 117 | name: 'completionCriteria', 118 | type: 'group', 119 | fields: [ 120 | { 121 | name: 'type', 122 | type: 'select', 123 | required: true, 124 | defaultValue: 'all_lessons', 125 | options: [ 126 | { label: 'All Lessons', value: 'all_lessons' }, 127 | { label: 'Minimum Score', value: 'min_score' }, 128 | { label: 'Custom', value: 'custom' }, 129 | ], 130 | }, 131 | { 132 | name: 'minimumScore', 133 | type: 'number', 134 | min: 0, 135 | max: 100, 136 | admin: { 137 | condition: (data, siblingData) => siblingData?.type === 'min_score', 138 | description: 'Minimum score required to complete this module', 139 | }, 140 | }, 141 | { 142 | name: 'customRule', 143 | type: 'textarea', 144 | admin: { 145 | condition: (data, siblingData) => siblingData?.type === 'custom', 146 | description: 'Custom completion rule (evaluated at runtime)', 147 | }, 148 | }, 149 | ], 150 | }, 151 | ], 152 | hooks: { 153 | beforeChange: [ 154 | ({ data, req, operation }) => { 155 | // Ensure order is set for new modules 156 | if (operation === 'create' && typeof data.order === 'undefined') { 157 | data.order = 0 158 | } 159 | return data 160 | }, 161 | ], 162 | }, 163 | } -------------------------------------------------------------------------------- /src/collections/Points.ts: -------------------------------------------------------------------------------- 1 | import type { CollectionConfig, Where, WhereField, ValidateOptions } from 'payload' 2 | import type { User } from '../payload-types' 3 | 4 | type AccessArgs = { 5 | req: { 6 | user?: User | null 7 | } 8 | } 9 | 10 | export const Points: CollectionConfig = { 11 | slug: 'points', 12 | admin: { 13 | useAsTitle: 'id', 14 | group: 'Gamification', 15 | defaultColumns: ['student', 'type', 'amount', 'createdAt'], 16 | description: 'Point transactions for gamification', 17 | }, 18 | fields: [ 19 | { 20 | name: 'student', 21 | type: 'relationship', 22 | relationTo: 'users', 23 | required: true, 24 | admin: { 25 | description: 'Student who earned the points', 26 | }, 27 | index: true, 28 | }, 29 | { 30 | name: 'type', 31 | type: 'select', 32 | required: true, 33 | options: [ 34 | { label: 'Lesson Completion', value: 'lesson_complete' }, 35 | { label: 'Quiz Score', value: 'quiz_score' }, 36 | { label: 'Assignment Submit', value: 'assignment_submit' }, 37 | { label: 'Discussion Post', value: 'discussion_post' }, 38 | { label: 'Streak Bonus', value: 'streak_bonus' }, 39 | { label: 'Achievement Unlock', value: 'achievement_unlock' }, 40 | ], 41 | admin: { 42 | description: 'Type of activity that earned points', 43 | }, 44 | index: true, 45 | }, 46 | { 47 | name: 'amount', 48 | type: 'number', 49 | required: true, 50 | min: 0, 51 | admin: { 52 | description: 'Number of points earned', 53 | }, 54 | }, 55 | { 56 | name: 'source', 57 | type: 'group', 58 | admin: { 59 | description: 'Source that generated these points', 60 | }, 61 | fields: [ 62 | { 63 | name: 'type', 64 | type: 'select', 65 | required: true, 66 | options: [ 67 | { label: 'Lesson', value: 'lessons' }, 68 | { label: 'Achievement', value: 'achievements' }, 69 | { label: 'Streak', value: 'streaks' }, 70 | ], 71 | index: true, 72 | }, 73 | { 74 | name: 'lesson', 75 | type: 'relationship', 76 | relationTo: 'lessons', 77 | admin: { 78 | condition: (data, siblingData) => siblingData?.type === 'lessons', 79 | }, 80 | index: true, 81 | }, 82 | { 83 | name: 'achievement', 84 | type: 'relationship', 85 | relationTo: 'achievements', 86 | admin: { 87 | condition: (data, siblingData) => siblingData?.type === 'achievements', 88 | }, 89 | index: true, 90 | }, 91 | { 92 | name: 'streak', 93 | type: 'relationship', 94 | relationTo: 'streaks', 95 | admin: { 96 | condition: (data, siblingData) => siblingData?.type === 'streaks', 97 | }, 98 | index: true, 99 | }, 100 | ], 101 | }, 102 | { 103 | name: 'metadata', 104 | type: 'json', 105 | admin: { 106 | description: 'Additional context about the points earned', 107 | }, 108 | }, 109 | ], 110 | access: { 111 | read: ({ req: { user } }: AccessArgs): boolean | Where => { 112 | if (!user) return false 113 | if (user.role === 'admin') return true 114 | if (user.role === 'instructor') { 115 | return { 116 | 'student.tenant.id': { 117 | equals: user.tenant, 118 | } as WhereField, 119 | } 120 | } 121 | return { 122 | student: { 123 | equals: user.id, 124 | } as WhereField, 125 | } 126 | }, 127 | create: () => false, // Only created by system 128 | update: () => false, // Points are immutable 129 | delete: ({ req: { user } }: AccessArgs) => user?.role === 'admin', 130 | }, 131 | hooks: { 132 | beforeChange: [ 133 | async ({ data, operation }) => { 134 | if (operation === 'update') { 135 | throw new Error('Points cannot be modified after creation') 136 | } 137 | return data 138 | }, 139 | ], 140 | afterChange: [ 141 | async ({ doc, operation, req }) => { 142 | if (operation === 'create') { 143 | // Get the source document to find the course 144 | const sourceDoc = await req.payload.findByID({ 145 | collection: doc.source.type, 146 | id: doc.source[doc.source.type]?.id, 147 | }) 148 | 149 | if (!sourceDoc?.course) { 150 | console.warn('No course found for source document') 151 | return 152 | } 153 | 154 | // Find the progress record for this student and course 155 | const progress = await req.payload.find({ 156 | collection: 'progress', 157 | where: { 158 | and: [ 159 | { 160 | student: { 161 | equals: doc.student, 162 | }, 163 | }, 164 | { 165 | course: { 166 | equals: sourceDoc.course, 167 | }, 168 | }, 169 | ], 170 | }, 171 | }) 172 | 173 | if (progress.docs.length > 0) { 174 | await req.payload.update({ 175 | collection: 'progress', 176 | id: progress.docs[0].id, 177 | data: { 178 | pointsEarned: (progress.docs[0].pointsEarned || 0) + doc.amount, 179 | }, 180 | }) 181 | } else { 182 | console.warn('No progress record found for student and course') 183 | } 184 | } 185 | }, 186 | ], 187 | }, 188 | } 189 | -------------------------------------------------------------------------------- /src/collections/Progress.ts: -------------------------------------------------------------------------------- 1 | import type { CollectionConfig } from 'payload' 2 | import { isAdmin, isAdminOrInstructor, isAdminOrInstructorOrSelf } from '../access/roles' 3 | 4 | export const Progress: CollectionConfig = { 5 | slug: 'progress', 6 | admin: { 7 | useAsTitle: 'id', 8 | group: 'Learning', 9 | defaultColumns: ['student', 'course', 'status', 'overallProgress'], 10 | description: 'Student progress in courses', 11 | }, 12 | access: { 13 | read: isAdminOrInstructorOrSelf, 14 | create: isAdminOrInstructor, 15 | update: isAdminOrInstructorOrSelf, 16 | delete: isAdmin, 17 | }, 18 | fields: [ 19 | { 20 | name: 'student', 21 | type: 'relationship', 22 | relationTo: 'users', 23 | required: true, 24 | admin: { 25 | description: 'The student whose progress this is', 26 | }, 27 | }, 28 | { 29 | name: 'course', 30 | type: 'relationship', 31 | relationTo: 'courses', 32 | required: true, 33 | admin: { 34 | description: 'The course this progress is for', 35 | }, 36 | }, 37 | { 38 | name: 'status', 39 | type: 'select', 40 | required: true, 41 | defaultValue: 'not_started', 42 | options: [ 43 | { label: 'Not Started', value: 'not_started' }, 44 | { label: 'In Progress', value: 'in_progress' }, 45 | { label: 'Completed', value: 'completed' }, 46 | ], 47 | }, 48 | { 49 | name: 'overallProgress', 50 | type: 'number', 51 | required: true, 52 | min: 0, 53 | max: 100, 54 | defaultValue: 0, 55 | admin: { 56 | description: 'Overall progress percentage in the course', 57 | }, 58 | }, 59 | { 60 | name: 'pointsEarned', 61 | type: 'number', 62 | required: true, 63 | min: 0, 64 | defaultValue: 0, 65 | admin: { 66 | description: 'Total points earned in this course', 67 | }, 68 | }, 69 | { 70 | name: 'totalPoints', 71 | type: 'number', 72 | required: true, 73 | min: 0, 74 | defaultValue: 0, 75 | admin: { 76 | description: 'Total points earned across all courses', 77 | }, 78 | }, 79 | { 80 | name: 'isGlobal', 81 | type: 'checkbox', 82 | defaultValue: false, 83 | admin: { 84 | description: 'Whether this progress record is available globally', 85 | }, 86 | }, 87 | { 88 | name: 'startedAt', 89 | type: 'date', 90 | required: true, 91 | admin: { 92 | description: 'When the student started the course', 93 | }, 94 | }, 95 | { 96 | name: 'completedAt', 97 | type: 'date', 98 | admin: { 99 | description: 'When the student completed the course', 100 | }, 101 | }, 102 | { 103 | name: 'lastAccessed', 104 | type: 'date', 105 | required: true, 106 | admin: { 107 | description: 'When the student last accessed the course', 108 | }, 109 | }, 110 | { 111 | name: 'moduleProgress', 112 | type: 'array', 113 | admin: { 114 | description: 'Progress in individual modules', 115 | }, 116 | fields: [ 117 | { 118 | name: 'module', 119 | type: 'relationship', 120 | relationTo: 'modules', 121 | required: true, 122 | }, 123 | { 124 | name: 'status', 125 | type: 'select', 126 | required: true, 127 | options: [ 128 | { label: 'Not Started', value: 'not_started' }, 129 | { label: 'In Progress', value: 'in_progress' }, 130 | { label: 'Completed', value: 'completed' }, 131 | ], 132 | }, 133 | { 134 | name: 'progress', 135 | type: 'number', 136 | required: true, 137 | min: 0, 138 | max: 100, 139 | }, 140 | ], 141 | }, 142 | { 143 | name: 'quizAttempts', 144 | type: 'array', 145 | admin: { 146 | description: 'Quiz attempts and scores', 147 | }, 148 | fields: [ 149 | { 150 | name: 'lesson', 151 | type: 'relationship', 152 | relationTo: 'lessons', 153 | required: true, 154 | admin: { 155 | description: 'The lesson containing the quiz', 156 | condition: (data, siblingData) => { 157 | return data?.type === 'quiz' 158 | }, 159 | }, 160 | }, 161 | { 162 | name: 'score', 163 | type: 'number', 164 | required: true, 165 | min: 0, 166 | max: 100, 167 | }, 168 | { 169 | name: 'completedAt', 170 | type: 'date', 171 | required: true, 172 | }, 173 | ], 174 | }, 175 | { 176 | name: 'discussions', 177 | type: 'array', 178 | admin: { 179 | description: 'Discussion participation', 180 | }, 181 | fields: [ 182 | { 183 | name: 'lesson', 184 | type: 'relationship', 185 | relationTo: 'lessons', 186 | required: true, 187 | admin: { 188 | description: 'The lesson containing the discussion', 189 | condition: (data, siblingData) => { 190 | return data?.type === 'discussion' 191 | }, 192 | }, 193 | }, 194 | { 195 | name: 'participatedAt', 196 | type: 'date', 197 | required: true, 198 | }, 199 | ], 200 | }, 201 | ], 202 | hooks: { 203 | beforeChange: [ 204 | ({ data }) => { 205 | data.lastAccessed = new Date().toISOString() 206 | return data 207 | }, 208 | ], 209 | }, 210 | } 211 | -------------------------------------------------------------------------------- /src/collections/Streaks.ts: -------------------------------------------------------------------------------- 1 | import type { CollectionConfig, Where, WhereField } from 'payload' 2 | import type { User } from '../payload-types' 3 | 4 | type AccessArgs = { 5 | req: { 6 | user?: User | null 7 | } 8 | } 9 | 10 | export const Streaks: CollectionConfig = { 11 | slug: 'streaks', 12 | admin: { 13 | useAsTitle: 'id', 14 | group: 'Gamification', 15 | defaultColumns: ['student', 'type', 'currentStreak', 'longestStreak', 'lastActivity'], 16 | description: 'User activity streaks and history', 17 | }, 18 | access: { 19 | read: ({ req: { user } }: AccessArgs): boolean | Where => { 20 | if (!user) return false 21 | if (user.role === 'admin') return true 22 | if (user.role === 'instructor') { 23 | return { 24 | 'student.tenant': { 25 | equals: user.tenant, 26 | } as WhereField, 27 | } 28 | } 29 | return { 30 | student: { 31 | equals: user.id, 32 | } as WhereField, 33 | } 34 | }, 35 | create: () => false, // Only created by system 36 | update: () => false, 37 | delete: ({ req: { user } }: AccessArgs) => user?.role === 'admin', 38 | }, 39 | fields: [ 40 | { 41 | name: 'student', 42 | type: 'relationship', 43 | relationTo: 'users', 44 | required: true, 45 | admin: { 46 | description: 'The student this streak belongs to', 47 | }, 48 | index: true, 49 | }, 50 | { 51 | name: 'type', 52 | type: 'select', 53 | required: true, 54 | options: [ 55 | { label: 'Daily Login', value: 'login' }, 56 | { label: 'Course Progress', value: 'progress' }, 57 | { label: 'Quiz Completion', value: 'quiz' }, 58 | { label: 'Assignment Submission', value: 'assignment' }, 59 | ], 60 | admin: { 61 | description: 'Type of activity being tracked', 62 | }, 63 | index: true, 64 | }, 65 | { 66 | name: 'currentStreak', 67 | type: 'number', 68 | required: true, 69 | min: 0, 70 | defaultValue: 0, 71 | admin: { 72 | description: 'Current consecutive days of activity', 73 | }, 74 | }, 75 | { 76 | name: 'longestStreak', 77 | type: 'number', 78 | required: true, 79 | min: 0, 80 | defaultValue: 0, 81 | admin: { 82 | description: 'Longest streak achieved', 83 | }, 84 | }, 85 | { 86 | name: 'lastActivity', 87 | type: 'date', 88 | required: true, 89 | admin: { 90 | description: 'Last time this streak was updated', 91 | }, 92 | index: true, 93 | }, 94 | { 95 | name: 'nextRequired', 96 | type: 'date', 97 | required: true, 98 | admin: { 99 | description: 'When the next activity is required to maintain streak', 100 | }, 101 | }, 102 | { 103 | name: 'history', 104 | type: 'array', 105 | fields: [ 106 | { 107 | name: 'date', 108 | type: 'date', 109 | required: true, 110 | }, 111 | { 112 | name: 'activity', 113 | type: 'relationship', 114 | relationTo: ['courses', 'lessons', 'modules'], 115 | required: true, 116 | admin: { 117 | description: 'The activity that contributed to this streak', 118 | }, 119 | }, 120 | { 121 | name: 'points', 122 | type: 'number', 123 | required: true, 124 | min: 0, 125 | }, 126 | ], 127 | admin: { 128 | description: 'History of activities that contributed to this streak', 129 | }, 130 | }, 131 | ], 132 | hooks: { 133 | beforeChange: [ 134 | async ({ data, operation }) => { 135 | if (operation === 'update') { 136 | // Update longest streak if current streak is higher 137 | if (data.currentStreak > data.longestStreak) { 138 | data.longestStreak = data.currentStreak 139 | } 140 | } 141 | return data 142 | }, 143 | ], 144 | }, 145 | } -------------------------------------------------------------------------------- /src/collections/StudentSettings.ts: -------------------------------------------------------------------------------- 1 | import type { CollectionConfig, PayloadRequest } from 'payload' 2 | import type { User } from '../payload-types' 3 | 4 | type AccessArgs = { 5 | req: { 6 | user?: User | null 7 | } 8 | } 9 | 10 | interface BeforeChangeHookData { 11 | data: { 12 | user?: string | number 13 | [key: string]: any 14 | } 15 | req: PayloadRequest 16 | } 17 | 18 | export const StudentSettings: CollectionConfig = { 19 | slug: 'student-settings', 20 | admin: { 21 | useAsTitle: 'user', 22 | group: 'System', 23 | defaultColumns: ['user', 'theme', 'language'], 24 | description: 'Student preferences and settings', 25 | listSearchableFields: ['user'], 26 | }, 27 | access: { 28 | read: ({ req: { user } }: AccessArgs) => { 29 | if (user?.role === 'admin') return true 30 | return { 31 | user: { 32 | equals: user?.id, 33 | }, 34 | } 35 | }, 36 | create: ({ req: { user } }: AccessArgs) => !!user, 37 | update: ({ req: { user } }: AccessArgs) => { 38 | if (user?.role === 'admin') return true 39 | return { 40 | user: { 41 | equals: user?.id, 42 | }, 43 | } 44 | }, 45 | delete: ({ req: { user } }: AccessArgs) => user?.role === 'admin', 46 | }, 47 | fields: [ 48 | { 49 | name: 'user', 50 | type: 'relationship', 51 | relationTo: 'users', 52 | required: true, 53 | unique: true, 54 | admin: { 55 | description: 'The user this settings belong to', 56 | }, 57 | }, 58 | { 59 | name: 'preferences', 60 | type: 'group', 61 | fields: [ 62 | { 63 | name: 'theme', 64 | type: 'select', 65 | options: [ 66 | { label: 'Light', value: 'light' }, 67 | { label: 'Dark', value: 'dark' }, 68 | { label: 'System', value: 'system' }, 69 | ], 70 | defaultValue: 'system', 71 | admin: { 72 | description: 'The default theme for the user', 73 | }, 74 | }, 75 | { 76 | name: 'emailNotifications', 77 | type: 'group', 78 | fields: [ 79 | { 80 | name: 'assignments', 81 | type: 'checkbox', 82 | defaultValue: true, 83 | label: 'Assignment notifications', 84 | }, 85 | { 86 | name: 'courseUpdates', 87 | type: 'checkbox', 88 | defaultValue: true, 89 | label: 'Course update notifications', 90 | }, 91 | { 92 | name: 'achievements', 93 | type: 'checkbox', 94 | defaultValue: true, 95 | label: 'Achievement notifications', 96 | }, 97 | ], 98 | admin: { 99 | description: 'Email notifications for the user', 100 | }, 101 | }, 102 | ], 103 | }, 104 | ], 105 | hooks: { 106 | beforeChange: [ 107 | ({ req, data }: BeforeChangeHookData) => { 108 | if (!data.user && req.user?.id) { 109 | if (typeof req.user.id === 'string' || typeof req.user.id === 'number') { 110 | data.user = req.user.id 111 | } 112 | } 113 | return data 114 | }, 115 | ], 116 | }, 117 | } 118 | -------------------------------------------------------------------------------- /src/collections/Tenants.ts: -------------------------------------------------------------------------------- 1 | import type { CollectionConfig } from 'payload' 2 | import { isAdmin } from '../access/roles' 3 | 4 | interface BeforeValidateHookData { 5 | data?: { 6 | name?: string 7 | slug?: string 8 | [key: string]: any 9 | } 10 | } 11 | 12 | export const Tenants: CollectionConfig = { 13 | slug: 'tenants', 14 | admin: { 15 | useAsTitle: 'name', 16 | group: 'System', 17 | defaultColumns: ['name', 'status', 'plan', 'domain'], 18 | description: 'Organizations using the platform', 19 | listSearchableFields: ['name', 'slug', 'domain'], 20 | }, 21 | access: { 22 | read: ({ req: { user } }) => { 23 | if (!user) return false 24 | if (user.role === 'admin') return true 25 | return { 26 | id: { 27 | equals: user.tenant, 28 | }, 29 | } 30 | }, 31 | create: isAdmin, 32 | update: isAdmin, 33 | delete: isAdmin, 34 | }, 35 | fields: [ 36 | // Basic Info 37 | { 38 | name: 'name', 39 | type: 'text', 40 | required: true, 41 | admin: { 42 | description: 'The display name of the tenant', 43 | }, 44 | }, 45 | { 46 | name: 'slug', 47 | type: 'text', 48 | required: true, 49 | unique: true, 50 | admin: { 51 | description: 'URL-friendly identifier for the tenant', 52 | }, 53 | }, 54 | { 55 | name: 'domain', 56 | type: 'text', 57 | unique: true, 58 | admin: { 59 | description: 'Custom domain for the tenant', 60 | }, 61 | }, 62 | 63 | // Status & Plan 64 | { 65 | name: 'status', 66 | type: 'select', 67 | required: true, 68 | defaultValue: 'active', 69 | options: [ 70 | { label: 'Active', value: 'active' }, 71 | { label: 'Suspended', value: 'suspended' }, 72 | { label: 'Pending', value: 'pending' }, 73 | ], 74 | admin: { 75 | description: 'Current status of the tenant', 76 | }, 77 | }, 78 | { 79 | name: 'plan', 80 | type: 'select', 81 | required: true, 82 | defaultValue: 'basic', 83 | options: [ 84 | { label: 'Basic', value: 'basic' }, 85 | { label: 'Pro', value: 'pro' }, 86 | { label: 'Enterprise', value: 'enterprise' }, 87 | ], 88 | admin: { 89 | description: 'Subscription plan level', 90 | }, 91 | }, 92 | { 93 | name: 'subscription', 94 | type: 'group', 95 | admin: { 96 | description: 'Subscription details', 97 | }, 98 | fields: [ 99 | { 100 | name: 'startDate', 101 | type: 'date', 102 | admin: { 103 | description: 'When the subscription started', 104 | }, 105 | }, 106 | { 107 | name: 'renewalDate', 108 | type: 'date', 109 | admin: { 110 | description: 'When the subscription renews', 111 | }, 112 | }, 113 | { 114 | name: 'stripeCustomerId', 115 | type: 'text', 116 | admin: { 117 | description: 'Stripe customer ID', 118 | readOnly: true, 119 | }, 120 | }, 121 | ], 122 | }, 123 | 124 | // Branding & UI 125 | { 126 | name: 'settings', 127 | type: 'group', 128 | fields: [ 129 | { 130 | name: 'theme', 131 | type: 'select', 132 | options: [ 133 | { label: 'Light', value: 'light' }, 134 | { label: 'Dark', value: 'dark' }, 135 | { label: 'System', value: 'system' }, 136 | ], 137 | defaultValue: 'system', 138 | }, 139 | { 140 | name: 'logo', 141 | type: 'upload', 142 | relationTo: 'media', 143 | filterOptions: { 144 | mimeType: { contains: 'image' }, 145 | }, 146 | }, 147 | { 148 | name: 'colors', 149 | type: 'group', 150 | fields: [ 151 | { 152 | name: 'primary', 153 | type: 'text', 154 | defaultValue: '#000000', 155 | }, 156 | { 157 | name: 'secondary', 158 | type: 'text', 159 | defaultValue: '#ffffff', 160 | }, 161 | ], 162 | }, 163 | ], 164 | }, 165 | 166 | // Feature Flags 167 | { 168 | name: 'features', 169 | type: 'group', 170 | fields: [ 171 | { 172 | name: 'gamification', 173 | type: 'checkbox', 174 | defaultValue: true, 175 | admin: { 176 | description: 'Enable gamification features', 177 | }, 178 | }, 179 | { 180 | name: 'adaptiveLearning', 181 | type: 'checkbox', 182 | defaultValue: false, 183 | admin: { 184 | description: 'Enable adaptive learning features', 185 | }, 186 | }, 187 | { 188 | name: 'analytics', 189 | type: 'checkbox', 190 | defaultValue: true, 191 | admin: { 192 | description: 'Enable analytics tracking', 193 | }, 194 | }, 195 | { 196 | name: 'api', 197 | type: 'checkbox', 198 | defaultValue: false, 199 | admin: { 200 | description: 'Enable API access', 201 | }, 202 | }, 203 | ], 204 | }, 205 | 206 | // Analytics & Metrics 207 | { 208 | name: 'analytics', 209 | type: 'group', 210 | admin: { 211 | description: 'Usage metrics and analytics', 212 | }, 213 | fields: [ 214 | { 215 | name: 'totalUsers', 216 | type: 'number', 217 | admin: { 218 | description: 'Total number of users', 219 | readOnly: true, 220 | }, 221 | }, 222 | { 223 | name: 'totalCourses', 224 | type: 'number', 225 | admin: { 226 | description: 'Total number of courses', 227 | readOnly: true, 228 | }, 229 | }, 230 | { 231 | name: 'storageUsed', 232 | type: 'number', 233 | admin: { 234 | description: 'Storage space used in bytes', 235 | readOnly: true, 236 | }, 237 | }, 238 | { 239 | name: 'lastActivityAt', 240 | type: 'date', 241 | admin: { 242 | description: 'Last activity in the tenant', 243 | readOnly: true, 244 | }, 245 | }, 246 | ], 247 | }, 248 | ], 249 | hooks: { 250 | beforeValidate: [ 251 | ({ data = {} }: BeforeValidateHookData) => { 252 | if (data?.name && !data?.slug) { 253 | data.slug = data.name.toLowerCase().replace(/\s+/g, '-') 254 | } 255 | return data 256 | }, 257 | ], 258 | beforeChange: [ 259 | async ({ data, operation, req }) => { 260 | // Set subscription start date on creation 261 | if (operation === 'create') { 262 | if (!data.subscription) data.subscription = {} 263 | data.subscription.startDate = new Date().toISOString() 264 | data.subscription.renewalDate = new Date( 265 | Date.now() + 30 * 24 * 60 * 60 * 1000, 266 | ).toISOString() // 30 days 267 | } 268 | return data 269 | }, 270 | ], 271 | }, 272 | } 273 | -------------------------------------------------------------------------------- /src/collections/Users.ts: -------------------------------------------------------------------------------- 1 | import type { CollectionConfig, PayloadRequest } from 'payload' 2 | import type { User } from '../payload-types' 3 | import { isAdmin, isAdminOrSelf } from '../access/roles' 4 | 5 | type EmailTemplateArgs = { 6 | req?: PayloadRequest 7 | token?: string 8 | user?: User 9 | } 10 | 11 | interface BeforeValidateHookData { 12 | data?: { 13 | email?: string 14 | tenant?: string 15 | id?: string | number 16 | [key: string]: any 17 | } 18 | req: PayloadRequest 19 | operation: 'create' | 'update' 20 | } 21 | 22 | export const Users: CollectionConfig = { 23 | slug: 'users', 24 | auth: { 25 | tokenExpiration: 7200, // 2 hours 26 | verify: false, // Disable email verification 27 | maxLoginAttempts: 5, 28 | lockTime: 600000, // 10 minutes 29 | useAPIKey: true, 30 | depth: 2, 31 | cookies: { 32 | secure: process.env.NODE_ENV === 'production', 33 | sameSite: 'Lax', 34 | domain: process.env.COOKIE_DOMAIN, 35 | }, 36 | forgotPassword: { 37 | generateEmailHTML: ({ token }: EmailTemplateArgs = { token: '' }) => { 38 | return `Reset your password using this token: ${token}` 39 | }, 40 | }, 41 | }, 42 | admin: { 43 | useAsTitle: 'email', 44 | group: 'System', 45 | defaultColumns: ['email', 'name', 'role', 'tenant'], 46 | description: 'Users of the platform', 47 | listSearchableFields: ['email', 'name'], 48 | pagination: { 49 | defaultLimit: 10, 50 | limits: [10, 20, 50, 100], 51 | }, 52 | }, 53 | access: { 54 | read: ({ req: { user } }) => { 55 | if (!user) return false 56 | if (user.role === 'admin') return true 57 | return { 58 | tenant: { 59 | equals: user.tenant, 60 | }, 61 | } 62 | }, 63 | create: isAdmin, 64 | update: isAdminOrSelf, 65 | delete: isAdmin, 66 | }, 67 | fields: [ 68 | // Authentication Fields 69 | { 70 | name: 'email', 71 | type: 'email', 72 | required: true, 73 | unique: true, 74 | admin: { 75 | description: 'Email address used for login', 76 | }, 77 | }, 78 | { 79 | name: 'resetPasswordToken', 80 | type: 'text', 81 | hidden: true, 82 | admin: { 83 | disabled: true, 84 | }, 85 | }, 86 | { 87 | name: 'resetPasswordExpiration', 88 | type: 'date', 89 | hidden: true, 90 | admin: { 91 | disabled: true, 92 | }, 93 | }, 94 | { 95 | name: 'loginAttempts', 96 | type: 'number', 97 | hidden: true, 98 | admin: { 99 | disabled: true, 100 | }, 101 | }, 102 | { 103 | name: 'lockUntil', 104 | type: 'date', 105 | hidden: true, 106 | admin: { 107 | disabled: true, 108 | }, 109 | }, 110 | 111 | // Profile Fields 112 | { 113 | name: 'name', 114 | type: 'text', 115 | required: true, 116 | admin: { 117 | description: 'Full name of the user', 118 | }, 119 | }, 120 | { 121 | name: 'avatar', 122 | type: 'upload', 123 | relationTo: 'media', 124 | admin: { 125 | description: 'Maximum size: 4MB. Accepted formats: .jpg, .jpeg, .png, .gif', 126 | }, 127 | }, 128 | 129 | // Role & Access Fields 130 | { 131 | name: 'role', 132 | type: 'select', 133 | required: true, 134 | options: [ 135 | { label: 'Admin', value: 'admin' }, 136 | { label: 'Instructor', value: 'instructor' }, 137 | { label: 'Student', value: 'student' }, 138 | ], 139 | defaultValue: 'student', 140 | admin: { 141 | description: 'User role determines permissions', 142 | }, 143 | }, 144 | { 145 | name: 'tenant', 146 | type: 'relationship', 147 | relationTo: 'tenants', 148 | required: true, 149 | admin: { 150 | description: 'Organization this user belongs to', 151 | condition: (data) => data?.role !== 'admin', 152 | }, 153 | }, 154 | 155 | // Settings & Preferences 156 | { 157 | name: 'settings', 158 | type: 'relationship', 159 | relationTo: 'student-settings', 160 | admin: { 161 | description: 'User preferences and settings', 162 | condition: (data) => data?.role === 'student', 163 | }, 164 | }, 165 | { 166 | name: 'lastActive', 167 | type: 'date', 168 | admin: { 169 | description: 'Last time user was active', 170 | readOnly: true, 171 | }, 172 | }, 173 | ], 174 | hooks: { 175 | beforeValidate: [ 176 | async ({ data, req, operation }) => { 177 | // Ensure email uniqueness within tenant 178 | if (operation === 'create' && data?.email && data?.tenant) { 179 | const existing = await req.payload.find({ 180 | collection: 'users', 181 | where: { 182 | email: { equals: data.email }, 183 | tenant: { equals: data.tenant }, 184 | }, 185 | }) 186 | if (existing.totalDocs > 0) { 187 | throw new Error('Email must be unique within tenant') 188 | } 189 | } 190 | 191 | // Auto-generate name from email if not provided 192 | if (operation === 'create' && data?.email && !data?.name) { 193 | data.name = data.email.split('@')[0] 194 | } 195 | 196 | return data 197 | }, 198 | ], 199 | beforeChange: [ 200 | ({ data }) => { 201 | // Update lastActive timestamp 202 | data.lastActive = new Date().toISOString() 203 | return data 204 | }, 205 | ], 206 | }, 207 | } 208 | -------------------------------------------------------------------------------- /src/collections/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Core System Collections 3 | * These collections handle fundamental platform functionality 4 | */ 5 | import { Users } from './Users' 6 | import { Media } from './Media' 7 | import { Tenants } from './Tenants' 8 | import { StudentSettings } from './StudentSettings' 9 | 10 | /** 11 | * Learning Management Collections 12 | * These collections handle course content and student progress 13 | */ 14 | import { Courses } from './Courses' 15 | import { Modules } from './Modules' 16 | import { Lessons } from './Lessons' 17 | import { Progress } from './Progress' 18 | import { Enrollments } from './Enrollments' 19 | 20 | /** 21 | * Gamification Collections 22 | * These collections handle points, rewards, and engagement features 23 | */ 24 | import { Points } from './Points' 25 | import { Badges } from './Badges' 26 | import { Achievements } from './Achievements' 27 | import { Levels } from './Levels' 28 | import { Streaks } from './Streaks' 29 | import { Leaderboards } from './Leaderboards' 30 | 31 | export const collections = [ 32 | // Core System 33 | Users, 34 | Media, 35 | Tenants, 36 | StudentSettings, 37 | 38 | // Learning Management 39 | Courses, 40 | Modules, 41 | Lessons, 42 | Progress, 43 | Enrollments, 44 | 45 | // Gamification 46 | Points, 47 | Badges, 48 | Achievements, 49 | Levels, 50 | Streaks, 51 | Leaderboards, 52 | ] 53 | -------------------------------------------------------------------------------- /src/hooks/useAchievements.ts: -------------------------------------------------------------------------------- 1 | import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' 2 | import { checkProgress, getUserAchievements, getAvailableAchievements, getAchievementProgress } from '@/app/actions/achievements' 3 | import { toast } from 'sonner' 4 | 5 | export function useUserAchievements() { 6 | return useQuery({ 7 | queryKey: ['user-achievements'], 8 | queryFn: getUserAchievements, 9 | }) 10 | } 11 | 12 | export function useAvailableAchievements() { 13 | return useQuery({ 14 | queryKey: ['available-achievements'], 15 | queryFn: getAvailableAchievements, 16 | }) 17 | } 18 | 19 | export function useAchievementProgress(achievementId: string) { 20 | return useQuery({ 21 | queryKey: ['achievement-progress', achievementId], 22 | queryFn: () => getAchievementProgress(achievementId), 23 | enabled: !!achievementId, 24 | }) 25 | } 26 | 27 | export function useCheckProgress() { 28 | const queryClient = useQueryClient() 29 | 30 | return useMutation({ 31 | mutationFn: checkProgress, 32 | onSuccess: () => { 33 | queryClient.invalidateQueries({ queryKey: ['user-achievements'] }) 34 | queryClient.invalidateQueries({ queryKey: ['available-achievements'] }) 35 | toast.success('Achievement progress updated') 36 | }, 37 | onError: (error) => { 38 | toast.error('Failed to check achievement progress') 39 | console.error('Achievement progress check error:', error) 40 | }, 41 | }) 42 | } -------------------------------------------------------------------------------- /src/lib/achievements/awardAchievement.ts: -------------------------------------------------------------------------------- 1 | 'use server' 2 | 3 | import payload from 'payload' 4 | import { createNotification } from '../notifications/createNotification' 5 | 6 | type AwardAchievementParams = { 7 | achievementId: number 8 | userId: number 9 | tenantId: number 10 | points: number 11 | badgeId: number 12 | } 13 | 14 | export async function awardAchievement({ 15 | achievementId, 16 | userId, 17 | tenantId, 18 | points: pointsToAward, 19 | badgeId, 20 | }: AwardAchievementParams): Promise { 21 | // Award points 22 | await payload.create({ 23 | collection: 'points', 24 | data: { 25 | student: userId, 26 | type: 'achievement_unlock', 27 | amount: pointsToAward, 28 | source: { 29 | type: 'achievements', 30 | achievement: achievementId, 31 | }, 32 | metadata: { 33 | badgeId, 34 | }, 35 | }, 36 | }) 37 | 38 | // Send notification (non-blocking) 39 | await createNotification({ 40 | userId: userId.toString(), 41 | type: 'achievement_unlocked', 42 | data: { 43 | achievementId: achievementId.toString(), 44 | badgeId: badgeId.toString(), 45 | points: pointsToAward, 46 | }, 47 | }).catch(console.error) 48 | } 49 | -------------------------------------------------------------------------------- /src/lib/achievements/checkPrerequisites.ts: -------------------------------------------------------------------------------- 1 | 'use server' 2 | 3 | import payload from 'payload' 4 | 5 | type CheckPrerequisitesParams = { 6 | userId: number 7 | achievementId: number 8 | tenantId: number 9 | } 10 | 11 | export async function checkPrerequisites({ 12 | userId, 13 | achievementId, 14 | tenantId, 15 | }: CheckPrerequisitesParams): Promise { 16 | // Get user's completed achievements 17 | const { docs } = await payload.find({ 18 | collection: 'points', 19 | where: { 20 | and: [{ student: { equals: userId } }, { type: { equals: 'achievement_unlock' } }], 21 | }, 22 | }) 23 | 24 | const unlockedAchievements = docs.map((doc) => doc.source?.achievement).filter(Boolean) 25 | return unlockedAchievements.length > 0 26 | } 27 | -------------------------------------------------------------------------------- /src/lib/achievements/checkProgress.ts: -------------------------------------------------------------------------------- 1 | 'use server' 2 | 3 | import payload from 'payload' 4 | 5 | type CheckProgressParams = { 6 | userId: number 7 | achievementId: number 8 | tenantId: number 9 | } 10 | 11 | export async function checkProgress({ 12 | userId, 13 | achievementId, 14 | tenantId, 15 | }: CheckProgressParams): Promise { 16 | // Get achievement criteria 17 | const achievement = await payload.findByID({ 18 | collection: 'achievements', 19 | id: achievementId, 20 | }) 21 | 22 | if (!achievement) { 23 | throw new Error('Achievement not found') 24 | } 25 | 26 | // Get user's progress 27 | const { 28 | docs: [userProgress], 29 | } = await payload.find({ 30 | collection: 'progress', 31 | where: { 32 | student: { equals: userId }, 33 | }, 34 | limit: 1, 35 | }) 36 | 37 | if (!userProgress) { 38 | return false 39 | } 40 | 41 | // Check criteria based on type 42 | switch (achievement.type) { 43 | case 'course_progress': 44 | return userProgress.overallProgress >= achievement.criteria.threshold 45 | case 'quiz_score': { 46 | const quizScores = userProgress.quizAttempts?.map((attempt) => attempt.score) || [] 47 | const averageScore = 48 | quizScores.length > 0 49 | ? quizScores.reduce((a: number, b: number) => a + b, 0) / quizScores.length 50 | : 0 51 | return averageScore >= achievement.criteria.threshold 52 | } 53 | case 'streak': { 54 | const { 55 | docs: [userStreak], 56 | } = await payload.find({ 57 | collection: 'streaks', 58 | where: { 59 | student: { equals: userId }, 60 | }, 61 | limit: 1, 62 | }) 63 | return (userStreak?.currentStreak || 0) >= achievement.criteria.threshold 64 | } 65 | case 'custom': 66 | // Custom criteria would be handled here 67 | return false 68 | default: 69 | return false 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/lib/achievements/getProgress.ts: -------------------------------------------------------------------------------- 1 | import payload from 'payload' 2 | import type { Progress, Streak } from '../../payload-types' 3 | 4 | type GetProgressParams = { 5 | userId: string 6 | achievementType: string 7 | metric: 'count' | 'score' | 'duration' | 'custom' 8 | timeframe?: 'all_time' | 'daily' | 'weekly' | 'monthly' 9 | } 10 | 11 | type QuizAttempt = { 12 | lesson?: number | { id: number } | null 13 | score: number 14 | completedAt: string 15 | id?: string | null 16 | } 17 | 18 | type Discussion = { 19 | lesson?: number | { id: number } | null 20 | participatedAt: string 21 | id?: string | null 22 | } 23 | 24 | export async function getProgress({ 25 | userId, 26 | achievementType, 27 | metric, 28 | timeframe = 'all_time', 29 | }: GetProgressParams): Promise { 30 | const timeframeFilter = getTimeframeFilter(timeframe) 31 | 32 | switch (achievementType) { 33 | case 'course_progress': 34 | return await getCourseProgress(userId, metric, timeframeFilter) 35 | case 'quiz_score': 36 | return await getQuizProgress(userId, metric, timeframeFilter) 37 | case 'assignment': 38 | return await getAssignmentProgress(userId, metric, timeframeFilter) 39 | case 'streak': 40 | return await getStreakProgress(userId, metric) 41 | case 'discussion': 42 | return await getDiscussionProgress(userId, metric, timeframeFilter) 43 | case 'custom': 44 | throw new Error('Custom achievement progress must be handled separately') 45 | default: 46 | throw new Error(`Unknown achievement type: ${achievementType}`) 47 | } 48 | } 49 | 50 | function getTimeframeFilter(timeframe: string): Date { 51 | const now = new Date() 52 | switch (timeframe) { 53 | case 'daily': 54 | return new Date(now.setHours(0, 0, 0, 0)) 55 | case 'weekly': 56 | const startOfWeek = new Date(now) 57 | startOfWeek.setDate(now.getDate() - now.getDay()) 58 | startOfWeek.setHours(0, 0, 0, 0) 59 | return startOfWeek 60 | case 'monthly': 61 | return new Date(now.getFullYear(), now.getMonth(), 1) 62 | default: 63 | return new Date(0) // Beginning of time for 'all_time' 64 | } 65 | } 66 | 67 | async function getCourseProgress( 68 | userId: string, 69 | metric: string, 70 | timeframeFilter: Date, 71 | ): Promise { 72 | const { docs, totalDocs } = await payload.find({ 73 | collection: 'progress', 74 | where: { 75 | and: [ 76 | { student: { equals: userId } }, 77 | { completedAt: { greater_than: timeframeFilter.toISOString() } }, 78 | ], 79 | }, 80 | }) 81 | 82 | switch (metric) { 83 | case 'count': 84 | return totalDocs || 0 85 | 86 | case 'score': 87 | const scores = docs.map((doc: Progress) => doc.overallProgress || 0) 88 | return scores.length ? scores.reduce((a: number, b: number) => a + b, 0) / scores.length : 0 89 | 90 | default: 91 | throw new Error(`Unsupported metric for course progress: ${metric}`) 92 | } 93 | } 94 | 95 | async function getQuizProgress( 96 | userId: string, 97 | metric: string, 98 | timeframeFilter: Date, 99 | ): Promise { 100 | const { docs } = await payload.find({ 101 | collection: 'progress', 102 | where: { 103 | student: { equals: userId }, 104 | }, 105 | }) 106 | 107 | const quizAttempts = docs 108 | .flatMap((doc: Progress) => doc.quizAttempts || []) 109 | .filter((attempt: QuizAttempt) => new Date(attempt.completedAt) > timeframeFilter) 110 | 111 | switch (metric) { 112 | case 'count': 113 | return quizAttempts.length 114 | 115 | case 'score': 116 | return quizAttempts.length 117 | ? quizAttempts.reduce((sum: number, attempt: QuizAttempt) => sum + attempt.score, 0) / 118 | quizAttempts.length 119 | : 0 120 | 121 | default: 122 | throw new Error(`Unsupported metric for quiz progress: ${metric}`) 123 | } 124 | } 125 | 126 | async function getAssignmentProgress( 127 | userId: string, 128 | metric: string, 129 | timeframeFilter: Date, 130 | ): Promise { 131 | const { docs, totalDocs } = await payload.find({ 132 | collection: 'progress', 133 | where: { 134 | and: [ 135 | { student: { equals: userId } }, 136 | { completedAt: { greater_than: timeframeFilter.toISOString() } }, 137 | ], 138 | }, 139 | }) 140 | 141 | switch (metric) { 142 | case 'count': 143 | return totalDocs || 0 144 | 145 | case 'score': 146 | const scores = docs.map((doc: Progress) => doc.overallProgress || 0) 147 | return scores.length ? scores.reduce((a: number, b: number) => a + b, 0) / scores.length : 0 148 | 149 | default: 150 | throw new Error(`Unsupported metric for assignment progress: ${metric}`) 151 | } 152 | } 153 | 154 | async function getStreakProgress(userId: string, metric: string): Promise { 155 | const { docs } = await payload.find({ 156 | collection: 'streaks', 157 | where: { 158 | student: { equals: userId }, 159 | }, 160 | limit: 1, 161 | }) 162 | const streak = docs[0] 163 | 164 | switch (metric) { 165 | case 'count': 166 | return streak?.currentStreak || 0 167 | case 'duration': 168 | return streak?.longestStreak || 0 169 | default: 170 | throw new Error(`Unsupported metric for streak progress: ${metric}`) 171 | } 172 | } 173 | 174 | async function getDiscussionProgress( 175 | userId: string, 176 | metric: string, 177 | timeframeFilter: Date, 178 | ): Promise { 179 | const { docs } = await payload.find({ 180 | collection: 'progress', 181 | where: { 182 | student: { equals: userId }, 183 | }, 184 | limit: 1, 185 | }) 186 | const progress = docs[0] 187 | 188 | const discussions = progress?.discussions || [] 189 | const filteredDiscussions = discussions.filter( 190 | (d: Discussion) => new Date(d.participatedAt) > timeframeFilter, 191 | ) 192 | 193 | switch (metric) { 194 | case 'count': 195 | return filteredDiscussions.length 196 | default: 197 | throw new Error(`Unsupported metric for discussion progress: ${metric}`) 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /src/lib/notifications/createNotification.ts: -------------------------------------------------------------------------------- 1 | 'use server' 2 | 3 | import { pusher } from '../pusher' 4 | 5 | type NotificationData = { 6 | achievementId?: string 7 | badgeId?: string 8 | points?: number 9 | [key: string]: any 10 | } 11 | 12 | type CreateNotificationParams = { 13 | userId: string 14 | type: 15 | | 'achievement_unlocked' 16 | | 'badge_awarded' 17 | | 'level_up' 18 | | 'points_awarded' 19 | | 'streak_milestone' 20 | data: NotificationData 21 | } 22 | 23 | export async function createNotification({ 24 | userId, 25 | type, 26 | data, 27 | }: CreateNotificationParams): Promise { 28 | // Send realtime notification 29 | await pusher.trigger(`user-${userId}`, 'notification', { 30 | type, 31 | data, 32 | createdAt: new Date().toISOString(), 33 | }) 34 | } 35 | -------------------------------------------------------------------------------- /src/lib/payload/editor.ts: -------------------------------------------------------------------------------- 1 | import { lexicalEditor } from '@payloadcms/richtext-lexical' 2 | import { BlocksFeature } from '@payloadcms/richtext-lexical' 3 | 4 | // Basic editor config for nested rich text fields 5 | export const basicEditor = lexicalEditor({}) 6 | 7 | export const editorConfig = lexicalEditor({ 8 | features: ({ defaultFeatures }) => [ 9 | ...defaultFeatures, 10 | BlocksFeature({ 11 | blocks: [ 12 | { 13 | slug: 'callout', 14 | fields: [ 15 | { 16 | name: 'type', 17 | type: 'select', 18 | required: true, 19 | options: [ 20 | { label: 'Info', value: 'info' }, 21 | { label: 'Warning', value: 'warning' }, 22 | { label: 'Success', value: 'success' }, 23 | { label: 'Error', value: 'error' }, 24 | ], 25 | }, 26 | { 27 | name: 'content', 28 | type: 'richText', 29 | editor: basicEditor, 30 | }, 31 | ], 32 | }, 33 | { 34 | slug: 'code', 35 | fields: [ 36 | { 37 | name: 'language', 38 | type: 'select', 39 | required: true, 40 | options: [ 41 | { label: 'JavaScript', value: 'javascript' }, 42 | { label: 'TypeScript', value: 'typescript' }, 43 | { label: 'Python', value: 'python' }, 44 | { label: 'HTML', value: 'html' }, 45 | { label: 'CSS', value: 'css' }, 46 | { label: 'SQL', value: 'sql' }, 47 | ], 48 | }, 49 | { 50 | name: 'code', 51 | type: 'textarea', 52 | required: true, 53 | }, 54 | ], 55 | }, 56 | ], 57 | }), 58 | ], 59 | }) -------------------------------------------------------------------------------- /src/lib/pusher.ts: -------------------------------------------------------------------------------- 1 | import PusherServer from 'pusher' 2 | import PusherClient from 'pusher-js' 3 | 4 | export const pusher = new PusherServer({ 5 | appId: process.env.PUSHER_APP_ID!, 6 | key: process.env.NEXT_PUBLIC_PUSHER_KEY!, 7 | secret: process.env.PUSHER_SECRET!, 8 | cluster: process.env.NEXT_PUBLIC_PUSHER_CLUSTER!, 9 | useTLS: true, 10 | }) 11 | 12 | export const pusherClient = new PusherClient( 13 | process.env.NEXT_PUBLIC_PUSHER_KEY!, 14 | { 15 | cluster: process.env.NEXT_PUBLIC_PUSHER_CLUSTER!, 16 | } 17 | ) -------------------------------------------------------------------------------- /src/payload.config.ts: -------------------------------------------------------------------------------- 1 | import { buildConfig } from 'payload' 2 | import { postgresAdapter } from '@payloadcms/db-postgres' 3 | import { Users } from './collections/Users' 4 | import { Media } from './collections/Media' 5 | import { Tenants } from './collections/Tenants' 6 | import { StudentSettings } from './collections/StudentSettings' 7 | import { Courses } from './collections/Courses' 8 | import { Modules } from './collections/Modules' 9 | import { Lessons } from './collections/Lessons' 10 | import { editorConfig } from './lib/payload/editor' 11 | import sharp from 'sharp' 12 | import { Progress } from './collections/Progress' 13 | import { Enrollments } from './collections/Enrollments' 14 | import { Levels } from './collections/Levels' 15 | import { Points } from './collections/Points' 16 | import { Badges } from './collections/Badges' 17 | import { Achievements } from './collections/Achievements' 18 | import { Leaderboards } from './collections/Leaderboards' 19 | import { Streaks } from './collections/Streaks' 20 | 21 | export default buildConfig({ 22 | serverURL: process.env.PAYLOAD_PUBLIC_SERVER_URL || 'http://localhost:3000', 23 | secret: process.env.PAYLOAD_SECRET || 'YOUR-SECRET-KEY', 24 | admin: { 25 | user: Users.slug, 26 | meta: { 27 | titleSuffix: '- LMS Admin', 28 | }, 29 | components: { 30 | beforeDashboard: [], 31 | afterDashboard: [], 32 | beforeLogin: [], 33 | afterLogin: [], 34 | }, 35 | dateFormat: 'MMMM do yyyy, h:mm a', 36 | }, 37 | editor: editorConfig, 38 | collections: [ 39 | Tenants, 40 | StudentSettings, 41 | Users, 42 | Progress, 43 | Points, 44 | Levels, 45 | Achievements, 46 | Badges, 47 | Streaks, 48 | Leaderboards, 49 | Enrollments, 50 | Courses, 51 | Modules, 52 | Lessons, 53 | Media, 54 | ], 55 | db: postgresAdapter({ 56 | pool: { 57 | connectionString: process.env.DATABASE_URL, 58 | max: 10, 59 | }, 60 | }), 61 | typescript: { 62 | outputFile: 'src/payload-types.ts', 63 | }, 64 | graphQL: { 65 | schemaOutputFile: 'src/generated-schema.graphql', 66 | }, 67 | upload: { 68 | limits: { 69 | fileSize: 5000000, // 5MB 70 | }, 71 | }, 72 | csrf: [process.env.PAYLOAD_PUBLIC_SERVER_URL || 'http://localhost:3000'], 73 | cors: [process.env.PAYLOAD_PUBLIC_SERVER_URL || 'http://localhost:3000'], 74 | sharp, 75 | }) 76 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "bundler", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "plugins": [ 18 | { 19 | "name": "next" 20 | } 21 | ], 22 | "paths": { 23 | "@/*": ["./src/*"] 24 | } 25 | }, 26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 27 | "exclude": ["node_modules"] 28 | } 29 | --------------------------------------------------------------------------------