Spaces:
Sleeping
Sleeping
| import { | |
| Controller, | |
| Get, | |
| Post, | |
| Put, | |
| Delete, | |
| Patch, | |
| Body, | |
| Param, | |
| Query, | |
| Req, | |
| UseGuards, | |
| ParseIntPipe, | |
| HttpCode, | |
| HttpStatus, | |
| UseInterceptors, | |
| UploadedFile, | |
| } from '@nestjs/common'; | |
| import { FileInterceptor } from '@nestjs/platform-express'; | |
| import { | |
| ApiTags, | |
| ApiOperation, | |
| ApiParam, | |
| ApiBody, | |
| ApiResponse, | |
| ApiBearerAuth, | |
| ApiConsumes, | |
| } from '@nestjs/swagger'; | |
| import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; | |
| import { RolesGuard } from '../../auth/guards/roles.guard'; | |
| import { Roles } from '../../auth/roles.decorator'; | |
| import { RoleName } from '../../auth/entities/role.entity'; | |
| import { MaterialsService } from '../services'; | |
| import { | |
| CreateMaterialDto, | |
| UpdateMaterialDto, | |
| QueryMaterialsDto, | |
| ToggleVisibilityDto, | |
| UploadVideoMaterialDto, | |
| UploadDocumentMaterialDto, | |
| BulkCreateMaterialDto, | |
| } from '../dto'; | |
| import { MaterialType } from '../enums'; | |
| ('📚 Course Materials') | |
| ('JWT-auth') | |
| ('api/courses/:courseId/materials') | |
| (JwtAuthGuard, RolesGuard) | |
| export class MaterialsController { | |
| constructor(private readonly materialsService: MaterialsService) {} | |
| () | |
| ({ | |
| summary: 'List course materials', | |
| description: ` | |
| ## List Course Materials | |
| Returns paginated list of materials for a course. | |
| ### Access Control | |
| - **Authentication Required**: ✅ Yes (Bearer Token) | |
| - **Roles**: ALL (filtered by role) | |
| ### Role-Based Visibility | |
| - **Students**: See only published materials | |
| - **Instructors/TAs**: See all materials (including drafts) | |
| - **Admins**: See all materials | |
| ### Query Parameters | |
| - \`materialType\`: Filter by type (lecture, slide, video, etc.) | |
| - \`search\`: Search in title and description | |
| - \`isPublished\`: Filter by visibility (instructors/admins only) | |
| `, | |
| }) | |
| ({ name: 'courseId', description: 'Course ID', type: Number, example: 1 }) | |
| ({ status: 200, description: 'Paginated list of materials' }) | |
| ({ status: 401, description: 'Unauthorized' }) | |
| async findAll( | |
| ('courseId', ParseIntPipe) courseId: number, | |
| () query: QueryMaterialsDto, | |
| () req: any, | |
| ) { | |
| const userId = req.user.userId || req.user.id; | |
| const roles = this.extractRoles(req.user); | |
| return this.materialsService.findAll(courseId, query, userId, roles); | |
| } | |
| () | |
| (RoleName.INSTRUCTOR, RoleName.TA, RoleName.ADMIN, RoleName.IT_ADMIN) | |
| (HttpStatus.CREATED) | |
| ({ | |
| summary: 'Create course material', | |
| description: ` | |
| ## Create Course Material | |
| Adds a new material to the course. | |
| ### Access Control | |
| - **Authentication Required**: ✅ Yes (Bearer Token) | |
| - **Roles**: INSTRUCTOR, TA, ADMIN | |
| ### Material Types | |
| - \`lecture\`: Lecture content | |
| - \`slide\`: Presentation slides | |
| - \`video\`: Video content (can use YouTube integration) | |
| - \`reading\`: Reading material | |
| - \`link\`: External link | |
| - \`document\`: Generic document | |
| ### File Upload | |
| For file-based materials, upload the file first using the Files API and pass the \`fileId\`. | |
| `, | |
| }) | |
| ({ name: 'courseId', description: 'Course ID', type: Number, example: 1 }) | |
| ({ type: CreateMaterialDto }) | |
| ({ status: 201, description: 'Material created successfully' }) | |
| ({ status: 400, description: 'Invalid input data' }) | |
| ({ status: 403, description: 'Forbidden' }) | |
| async create( | |
| ('courseId', ParseIntPipe) courseId: number, | |
| () dto: CreateMaterialDto, | |
| () req: any, | |
| ) { | |
| const userId = req.user.userId || req.user.id; | |
| const roles = this.extractRoles(req.user); | |
| return this.materialsService.create(courseId, dto, userId, roles); | |
| } | |
| ('bulk') | |
| (RoleName.INSTRUCTOR, RoleName.TA, RoleName.ADMIN, RoleName.IT_ADMIN) | |
| (HttpStatus.CREATED) | |
| ({ | |
| summary: 'Bulk create materials', | |
| description: ` | |
| ## Bulk Create Course Materials | |
| Creates multiple materials in a single request. Maximum 50 materials per request. | |
| ### Access Control | |
| - **Authentication Required**: ✅ Yes (Bearer Token) | |
| - **Roles**: INSTRUCTOR, TA, ADMIN | |
| ### Use Cases | |
| - Setting up course content at the beginning of a semester | |
| - Importing materials from another course | |
| - Uploading multiple lecture notes at once | |
| ### Request Body | |
| \`\`\`json | |
| { | |
| "materials": [ | |
| { "title": "Lecture 1", "materialType": "lecture", "weekNumber": 1 }, | |
| { "title": "Lecture 2", "materialType": "lecture", "weekNumber": 2 } | |
| ] | |
| } | |
| \`\`\` | |
| `, | |
| }) | |
| ({ name: 'courseId', description: 'Course ID', type: Number, example: 1 }) | |
| ({ type: BulkCreateMaterialDto }) | |
| ({ status: 201, description: 'Materials created successfully' }) | |
| ({ status: 400, description: 'Invalid input data' }) | |
| ({ status: 403, description: 'Forbidden' }) | |
| async bulkCreate( | |
| ('courseId', ParseIntPipe) courseId: number, | |
| () dto: BulkCreateMaterialDto, | |
| () req: any, | |
| ) { | |
| const userId = req.user.userId || req.user.id; | |
| const roles = this.extractRoles(req.user); | |
| return this.materialsService.bulkCreate(courseId, dto.materials, userId, roles); | |
| } | |
| ('video') | |
| (RoleName.INSTRUCTOR, RoleName.TA, RoleName.ADMIN, RoleName.IT_ADMIN) | |
| (HttpStatus.CREATED) | |
| (FileInterceptor('video')) | |
| ('multipart/form-data') | |
| ({ | |
| summary: 'Upload video material to YouTube', | |
| description: ` | |
| ## Upload Video Material via YouTube | |
| Uploads a video file to YouTube (as unlisted) and creates a course material record. | |
| ### Access Control | |
| - **Authentication Required**: ✅ Yes (Bearer Token) | |
| - **Roles**: INSTRUCTOR, TA, ADMIN, IT_ADMIN | |
| - **Authorization**: Must be assigned to the course (or be admin) | |
| ### Supported Video Formats | |
| \`mp4\`, \`avi\`, \`mov\`, \`webm\`, \`mkv\`, \`flv\`, \`wmv\` | |
| ### Upload Flow | |
| 1. Backend validates user is authorized for this course | |
| 2. Video is uploaded to YouTube (unlisted privacy) | |
| 3. YouTube returns video ID and URL | |
| 4. Material record created with: | |
| - \`externalUrl\`: YouTube embed URL | |
| - \`youtubeVideoId\`: Original YouTube video ID (for future updates/deletes) | |
| - Other metadata (weekNumber, orderIndex, isPublished) | |
| ### Form Data Fields | |
| | Field | Type | Required | Description | | |
| |-------|------|----------|-------------| | |
| | video | file | ✅ | Video file to upload | | |
| | title | string | ✅ | Video title (max 255 chars) | | |
| | description | string | ❌ | Video description | | |
| | tags | string[] | ❌ | Tags for the video | | |
| | weekNumber | number | ❌ | Week to assign (1-52) | | |
| | orderIndex | number | ❌ | Sort order (default: 0) | | |
| | isPublished | boolean | ❌ | Publish immediately (default: false/draft) | | |
| ### Response | |
| Returns material object with: | |
| - \`externalUrl\`: YouTube embed URL for iframe | |
| - \`youtubeVideoId\`: YouTube video ID | |
| - \`youtubeUrl\`: Full YouTube watch URL | |
| - \`embedUrl\`: Embed URL for iframe usage | |
| `, | |
| }) | |
| ({ name: 'courseId', description: 'Course ID', type: Number, example: 1 }) | |
| ({ | |
| schema: { | |
| type: 'object', | |
| required: ['video', 'title'], | |
| properties: { | |
| video: { | |
| type: 'string', | |
| format: 'binary', | |
| description: 'Video file (mp4, avi, mov, webm, mkv)', | |
| }, | |
| title: { | |
| type: 'string', | |
| example: 'Lecture 1: Introduction to Data Structures', | |
| maxLength: 255, | |
| }, | |
| description: { | |
| type: 'string', | |
| example: 'This video covers the basics of data structures.', | |
| }, | |
| tags: { | |
| type: 'array', | |
| items: { type: 'string' }, | |
| example: ['lecture', 'data-structures', 'cs101'], | |
| }, | |
| weekNumber: { | |
| type: 'integer', | |
| example: 1, | |
| minimum: 1, | |
| maximum: 52, | |
| }, | |
| orderIndex: { | |
| type: 'integer', | |
| example: 0, | |
| default: 0, | |
| }, | |
| isPublished: { | |
| type: 'boolean', | |
| example: false, | |
| default: false, | |
| }, | |
| }, | |
| }, | |
| }) | |
| ({ | |
| status: 201, | |
| description: 'Video uploaded to YouTube and material created', | |
| schema: { | |
| example: { | |
| materialId: 1, | |
| courseId: 1, | |
| title: 'Lecture 1: Introduction', | |
| materialType: 'video', | |
| externalUrl: 'https://www.youtube.com/embed/dQw4w9WgXcQ', | |
| youtubeVideoId: 'dQw4w9WgXcQ', | |
| weekNumber: 1, | |
| orderIndex: 0, | |
| isPublished: false, | |
| youtubeUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', | |
| embedUrl: 'https://www.youtube.com/embed/dQw4w9WgXcQ', | |
| }, | |
| }, | |
| }) | |
| ({ status: 400, description: 'Invalid input data or YouTube upload failed' }) | |
| ({ status: 403, description: 'Forbidden - not assigned to course' }) | |
| async uploadVideo( | |
| ('courseId', ParseIntPipe) courseId: number, | |
| () file: Express.Multer.File, | |
| () dto: UploadVideoMaterialDto, | |
| () req: any, | |
| ) { | |
| const userId = req.user.userId || req.user.id; | |
| const roles = this.extractRoles(req.user); | |
| return this.materialsService.uploadVideoMaterial( | |
| courseId, | |
| file, | |
| dto.title, | |
| dto.description || '', | |
| dto.tags || [], | |
| userId, | |
| roles, | |
| dto.weekNumber, | |
| dto.orderIndex, | |
| dto.isPublished, | |
| ); | |
| } | |
| ('document') | |
| (RoleName.INSTRUCTOR, RoleName.TA, RoleName.ADMIN, RoleName.IT_ADMIN) | |
| (HttpStatus.CREATED) | |
| (FileInterceptor('document')) | |
| ('multipart/form-data') | |
| ({ | |
| summary: 'Upload document material to Google Drive', | |
| description: ` | |
| ## Upload Document Material via Google Drive | |
| Uploads a document file (PDF, PPT, Word, etc.) to Google Drive and creates a course material record. | |
| ### Access Control | |
| - **Authentication Required**: ✅ Yes (Bearer Token) | |
| - **Roles**: INSTRUCTOR, TA, ADMIN, IT_ADMIN | |
| - **Authorization**: Must be assigned to the course (or be admin) | |
| ### Supported Document Formats | |
| \`pdf\`, \`ppt\`, \`pptx\`, \`doc\`, \`docx\`, \`xls\`, \`xlsx\`, \`txt\`, \`md\`, \`zip\` | |
| ### Upload Flow | |
| 1. Backend validates user is authorized for this course | |
| 2. Course folder hierarchy is created/verified in Google Drive | |
| 3. Document is uploaded to the appropriate folder (Lectures or General) | |
| 4. Material record created with Drive metadata | |
| ### Form Data Fields | |
| | Field | Type | Required | Description | | |
| |-------|------|----------|-------------| | |
| | document | file | ✅ | Document file to upload | | |
| | title | string | ✅ | Document title (max 255 chars) | | |
| | description | string | ❌ | Document description | | |
| | materialType | enum | ❌ | Type: lecture, slide, reading, document (default: document) | | |
| | weekNumber | number | ❌ | Week to assign (1-52) | | |
| | orderIndex | number | ❌ | Sort order (default: 0) | | |
| | isPublished | boolean | ❌ | Publish immediately (default: false/draft) | | |
| ### Folder Placement | |
| - \`lecture\` and \`slide\` types → Course/Lectures/ folder | |
| - \`reading\`, \`document\`, \`link\` → Course/General/ folder | |
| ### Response | |
| Returns material object with: | |
| - \`driveId\`: Google Drive file ID | |
| - \`driveViewUrl\`: URL to view in Google Drive | |
| - \`driveDownloadUrl\`: Direct download URL | |
| `, | |
| }) | |
| ({ name: 'courseId', description: 'Course ID', type: Number, example: 1 }) | |
| ({ | |
| schema: { | |
| type: 'object', | |
| required: ['document', 'title'], | |
| properties: { | |
| document: { | |
| type: 'string', | |
| format: 'binary', | |
| description: 'Document file (pdf, ppt, pptx, doc, docx, xls, xlsx)', | |
| }, | |
| title: { | |
| type: 'string', | |
| example: 'Week 1 Lecture Notes: Introduction to Data Structures', | |
| maxLength: 255, | |
| }, | |
| description: { | |
| type: 'string', | |
| example: 'Comprehensive lecture notes covering the basics.', | |
| }, | |
| materialType: { | |
| type: 'string', | |
| enum: ['lecture', 'slide', 'reading', 'document', 'link'], | |
| example: 'lecture', | |
| default: 'document', | |
| }, | |
| weekNumber: { | |
| type: 'integer', | |
| example: 1, | |
| minimum: 1, | |
| maximum: 52, | |
| }, | |
| orderIndex: { | |
| type: 'integer', | |
| example: 0, | |
| default: 0, | |
| }, | |
| isPublished: { | |
| type: 'boolean', | |
| example: false, | |
| default: false, | |
| }, | |
| }, | |
| }, | |
| }) | |
| ({ | |
| status: 201, | |
| description: 'Document uploaded to Google Drive and material created', | |
| schema: { | |
| example: { | |
| materialId: 1, | |
| courseId: 1, | |
| title: 'Week 1 Lecture Notes', | |
| materialType: 'lecture', | |
| externalUrl: 'https://drive.google.com/file/d/abc123/view', | |
| driveId: 'abc123', | |
| driveViewUrl: 'https://drive.google.com/file/d/abc123/view', | |
| driveDownloadUrl: 'https://drive.google.com/uc?id=abc123&export=download', | |
| weekNumber: 1, | |
| orderIndex: 0, | |
| isPublished: false, | |
| fileName: 'Week01_Week_1_Lecture_Notes_v1.pdf', | |
| }, | |
| }, | |
| }) | |
| ({ status: 400, description: 'Invalid input data or Drive upload failed' }) | |
| ({ status: 403, description: 'Forbidden - not assigned to course' }) | |
| async uploadDocument( | |
| ('courseId', ParseIntPipe) courseId: number, | |
| () file: Express.Multer.File, | |
| () dto: UploadDocumentMaterialDto, | |
| () req: any, | |
| ) { | |
| const userId = req.user.userId || req.user.id; | |
| const roles = this.extractRoles(req.user); | |
| return this.materialsService.uploadDocumentMaterial( | |
| courseId, | |
| file, | |
| dto.title, | |
| dto.description || '', | |
| dto.materialType || MaterialType.DOCUMENT, | |
| userId, | |
| roles, | |
| dto.weekNumber, | |
| dto.orderIndex, | |
| dto.isPublished, | |
| ); | |
| } | |
| (':id') | |
| ({ | |
| summary: 'Get material by ID', | |
| description: ` | |
| ## Get Material Details | |
| Returns detailed information about a specific material. | |
| ### Access Control | |
| - **Authentication Required**: ✅ Yes (Bearer Token) | |
| - **Roles**: ALL (students can only see published materials) | |
| `, | |
| }) | |
| ({ name: 'courseId', description: 'Course ID', type: Number, example: 1 }) | |
| ({ name: 'id', description: 'Material ID', type: Number, example: 1 }) | |
| ({ status: 200, description: 'Material details' }) | |
| ({ status: 404, description: 'Material not found' }) | |
| async findById( | |
| ('courseId', ParseIntPipe) courseId: number, | |
| ('id', ParseIntPipe) id: number, | |
| () req: any, | |
| ) { | |
| const userId = req.user.userId || req.user.id; | |
| const roles = this.extractRoles(req.user); | |
| return this.materialsService.findById(id, userId, roles); | |
| } | |
| (':id') | |
| (RoleName.INSTRUCTOR, RoleName.TA, RoleName.ADMIN, RoleName.IT_ADMIN) | |
| ({ | |
| summary: 'Update material', | |
| description: ` | |
| ## Update Material | |
| Updates an existing material's metadata. | |
| ### Access Control | |
| - **Authentication Required**: ✅ Yes (Bearer Token) | |
| - **Roles**: INSTRUCTOR, TA, ADMIN | |
| `, | |
| }) | |
| ({ name: 'courseId', description: 'Course ID', type: Number, example: 1 }) | |
| ({ name: 'id', description: 'Material ID', type: Number, example: 1 }) | |
| ({ type: UpdateMaterialDto }) | |
| ({ status: 200, description: 'Material updated successfully' }) | |
| ({ status: 404, description: 'Material not found' }) | |
| ({ status: 403, description: 'Forbidden' }) | |
| async update( | |
| ('courseId', ParseIntPipe) courseId: number, | |
| ('id', ParseIntPipe) id: number, | |
| () dto: UpdateMaterialDto, | |
| () req: any, | |
| ) { | |
| const userId = req.user.userId || req.user.id; | |
| const roles = this.extractRoles(req.user); | |
| return this.materialsService.update(id, dto, userId, roles); | |
| } | |
| (':id') | |
| (RoleName.INSTRUCTOR, RoleName.TA, RoleName.ADMIN, RoleName.IT_ADMIN) | |
| (HttpStatus.OK) | |
| ({ | |
| summary: 'Delete material', | |
| description: ` | |
| ## Delete Material | |
| Removes a material from the course. | |
| ### Access Control | |
| - **Authentication Required**: ✅ Yes (Bearer Token) | |
| - **Roles**: INSTRUCTOR, ADMIN only (TAs cannot delete) | |
| `, | |
| }) | |
| ({ name: 'courseId', description: 'Course ID', type: Number, example: 1 }) | |
| ({ name: 'id', description: 'Material ID', type: Number, example: 1 }) | |
| ({ status: 200, description: 'Material deleted successfully' }) | |
| ({ status: 404, description: 'Material not found' }) | |
| ({ status: 403, description: 'Forbidden' }) | |
| async delete( | |
| ('courseId', ParseIntPipe) courseId: number, | |
| ('id', ParseIntPipe) id: number, | |
| () req: any, | |
| ) { | |
| const userId = req.user.userId || req.user.id; | |
| const roles = this.extractRoles(req.user); | |
| return this.materialsService.delete(id, userId, roles); | |
| } | |
| (':id/visibility') | |
| (RoleName.INSTRUCTOR, RoleName.TA, RoleName.ADMIN, RoleName.IT_ADMIN) | |
| ({ | |
| summary: 'Toggle material visibility', | |
| description: ` | |
| ## Toggle Material Visibility | |
| Show or hide a material from students. | |
| ### Access Control | |
| - **Authentication Required**: ✅ Yes (Bearer Token) | |
| - **Roles**: INSTRUCTOR, TA, ADMIN | |
| ### Visibility States | |
| - \`isPublished: true\`: Visible to students | |
| - \`isPublished: false\`: Hidden from students (draft mode) | |
| `, | |
| }) | |
| ({ name: 'courseId', description: 'Course ID', type: Number, example: 1 }) | |
| ({ name: 'id', description: 'Material ID', type: Number, example: 1 }) | |
| ({ type: ToggleVisibilityDto }) | |
| ({ status: 200, description: 'Visibility updated' }) | |
| ({ status: 404, description: 'Material not found' }) | |
| async toggleVisibility( | |
| ('courseId', ParseIntPipe) courseId: number, | |
| ('id', ParseIntPipe) id: number, | |
| () dto: ToggleVisibilityDto, | |
| () req: any, | |
| ) { | |
| const userId = req.user.userId || req.user.id; | |
| const roles = this.extractRoles(req.user); | |
| return this.materialsService.toggleVisibility(id, dto, userId, roles); | |
| } | |
| (':id/download') | |
| ({ | |
| summary: 'Download material', | |
| description: ` | |
| ## Download Material File | |
| Get download information for a material's associated file. | |
| ### Access Control | |
| - **Authentication Required**: ✅ Yes (Bearer Token) | |
| - **Roles**: ALL (students can only download published materials) | |
| ### Response | |
| Returns file information including download URL. | |
| `, | |
| }) | |
| ({ name: 'courseId', description: 'Course ID', type: Number, example: 1 }) | |
| ({ name: 'id', description: 'Material ID', type: Number, example: 1 }) | |
| ({ status: 200, description: 'Download information' }) | |
| ({ status: 400, description: 'Material has no downloadable file' }) | |
| ({ status: 404, description: 'Material not found' }) | |
| async download( | |
| ('courseId', ParseIntPipe) courseId: number, | |
| ('id', ParseIntPipe) id: number, | |
| () req: any, | |
| ) { | |
| const userId = req.user.userId || req.user.id; | |
| const roles = this.extractRoles(req.user); | |
| return this.materialsService.download(id, userId, roles); | |
| } | |
| (':id/view') | |
| (HttpStatus.OK) | |
| ({ | |
| summary: 'Track material view', | |
| description: ` | |
| ## Track Material View | |
| Records that a user viewed a material and increments the view counter. | |
| ### Access Control | |
| - **Authentication Required**: ✅ Yes (Bearer Token) | |
| - **Roles**: ALL | |
| ### Use Cases | |
| - Analytics tracking for course engagement | |
| - Identifying popular materials | |
| - Measuring student participation | |
| ### Response | |
| Returns updated view count for the material. | |
| `, | |
| }) | |
| ({ name: 'courseId', description: 'Course ID', type: Number, example: 1 }) | |
| ({ name: 'id', description: 'Material ID', type: Number, example: 1 }) | |
| ({ status: 200, description: 'View tracked successfully' }) | |
| ({ status: 404, description: 'Material not found' }) | |
| async trackView( | |
| ('courseId', ParseIntPipe) courseId: number, | |
| ('id', ParseIntPipe) id: number, | |
| () req: any, | |
| ) { | |
| const userId = req.user.userId || req.user.id; | |
| const roles = this.extractRoles(req.user); | |
| return this.materialsService.trackView(id, userId, roles); | |
| } | |
| (':id/embed') | |
| ({ | |
| summary: 'Get embed URL', | |
| description: ` | |
| ## Get YouTube Embed URL | |
| For video materials, returns the YouTube embed URL and iframe HTML. | |
| ### Access Control | |
| - **Authentication Required**: ✅ Yes (Bearer Token) | |
| - **Roles**: ALL | |
| ### Response | |
| - \`videoId\`: YouTube video ID | |
| - \`embedUrl\`: URL for iframe src | |
| - \`iframeHtml\`: Ready-to-use iframe HTML | |
| `, | |
| }) | |
| ({ name: 'courseId', description: 'Course ID', type: Number, example: 1 }) | |
| ({ name: 'id', description: 'Material ID', type: Number, example: 1 }) | |
| ({ status: 200, description: 'Embed information' }) | |
| ({ status: 400, description: 'Material is not a video' }) | |
| ({ status: 404, description: 'Material not found' }) | |
| async getEmbedUrl( | |
| ('courseId', ParseIntPipe) courseId: number, | |
| ('id', ParseIntPipe) id: number, | |
| () req: any, | |
| ) { | |
| const userId = req.user.userId || req.user.id; | |
| const roles = this.extractRoles(req.user); | |
| return this.materialsService.getEmbedUrl(id, userId, roles); | |
| } | |
| private extractRoles(user: any): string[] { | |
| if (Array.isArray(user.roles)) { | |
| return user.roles.map((r: any) => { | |
| const roleStr = typeof r === 'string' ? r : r.name || r.roleName; | |
| return roleStr ? String(roleStr).toLowerCase() : ''; | |
| }).filter(Boolean); | |
| } | |
| return []; | |
| } | |
| } | |