├── .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 |
--------------------------------------------------------------------------------