diff --git a/apps/api/.env.example b/apps/api/.env.example index e9e8b1b..5fc2a1c 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -21,3 +21,9 @@ FRONTEND_URL="http://localhost:3000" # Development: redis://:yourpassword@localhost:6379 # Upstash: rediss://default:yourtoken@your-endpoint.upstash.io:6379 REDIS_URL=redis://:yourpassword@localhost:6379 + +# AWS S3 Configuration +AWS_S3_BUCKET="your-s3-bucket-name" +AWS_S3_REGION="ap-south-1" +AWS_ACCESS_KEY_ID="your-aws-access-key-id" +AWS_SECRET_ACCESS_KEY="your-aws-secret-access-key" diff --git a/apps/api/package.json b/apps/api/package.json index 938767a..aa03cd0 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -29,6 +29,8 @@ "check-types": "tsc --noEmit" }, "dependencies": { + "@aws-sdk/client-s3": "^3.1009.0", + "@aws-sdk/s3-request-presigner": "^3.1009.0", "@nestjs/bullmq": "^11.0.4", "@nestjs/common": "^11.0.1", "@nestjs/config": "^4.0.2", diff --git a/apps/api/prisma/migrations/20260316095939_resources/migration.sql b/apps/api/prisma/migrations/20260316095939_resources/migration.sql new file mode 100644 index 0000000..e3c7a20 --- /dev/null +++ b/apps/api/prisma/migrations/20260316095939_resources/migration.sql @@ -0,0 +1,32 @@ +-- CreateEnum +CREATE TYPE "public"."ResourceType" AS ENUM ('DOCUMENT', 'IMAGE', 'LINK'); + +-- CreateTable +CREATE TABLE "public"."Resource" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT, + "resourceType" "public"."ResourceType" NOT NULL, + "fileUrl" TEXT, + "fileName" TEXT, + "fileSize" INTEGER, + "mimeType" TEXT, + "externalLink" TEXT, + "isUploaded" BOOLEAN NOT NULL DEFAULT false, + "isPublished" BOOLEAN NOT NULL DEFAULT false, + "publishedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "subjectTeacherId" TEXT NOT NULL, + + CONSTRAINT "Resource_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "Resource_subjectTeacherId_idx" ON "public"."Resource"("subjectTeacherId"); + +-- CreateIndex +CREATE INDEX "Resource_isPublished_idx" ON "public"."Resource"("isPublished"); + +-- AddForeignKey +ALTER TABLE "public"."Resource" ADD CONSTRAINT "Resource_subjectTeacherId_fkey" FOREIGN KEY ("subjectTeacherId") REFERENCES "public"."SubjectTeacher"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 32da3ad..38da227 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -108,29 +108,34 @@ model SubjectTeacher { teacher User @relation(fields: [teacherId], references: [id], onDelete: Cascade) assignments Assignment[] + resources Resource[] @@unique([subjectId, teacherId]) } -// model Resource { -// id String @id @default(cuid()) -// title String -// description String? -// resourceType ResourceType -// fileUrl String? -// externalLink String? -// isPublished Boolean @default(false) -// publishedAt DateTime? -// createdAt DateTime @default(now()) -// updatedAt DateTime @updatedAt -// subjectTeacherId String - -// // Relations -// subjectTeacher SubjectTeacher @relation(fields: [subjectTeacherId], references: [id], onDelete: Cascade) -// assignments Assignment[] - -// @@index([id]) -// } +model Resource { + id String @id @default(cuid()) + title String + description String? + resourceType ResourceType + fileUrl String? + fileName String? + fileSize Int? + mimeType String? + externalLink String? + isUploaded Boolean @default(false) + isPublished Boolean @default(false) + publishedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + subjectTeacherId String + + // Relations + subjectTeacher SubjectTeacher @relation(fields: [subjectTeacherId], references: [id], onDelete: Cascade) + + @@index([subjectTeacherId]) + @@index([isPublished]) +} model Assignment { id String @id @default(cuid()) @@ -243,11 +248,11 @@ enum SemesterNumber { EIGHTH } -// enum ResourceType { -// DOCUMENT -// LINK -// NOTES -// } +enum ResourceType { + DOCUMENT + IMAGE + LINK +} enum AssignmentStatus { DRAFT diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index 0ba106a..14b1bc4 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -19,6 +19,8 @@ import { AnnouncementModule } from 'src/announcement/announcement.module' import { ChatModule } from '@src/chat/chat.module' import { QueueModule } from 'src/common/queue/queue.module' import { DashboardModule } from 'src/dashboard/dashboard.module' +import { StorageModule } from 'src/common/storage/storage.module' +import { ResourceModule } from 'src/resource/resource.module' @Module({ imports: [ @@ -36,6 +38,8 @@ import { DashboardModule } from 'src/dashboard/dashboard.module' AnnouncementModule, ChatModule, DashboardModule, + StorageModule, + ResourceModule, ConfigModule.forRoot({ isGlobal: true, validate: (config) => { diff --git a/apps/api/src/common/storage/storage.module.ts b/apps/api/src/common/storage/storage.module.ts new file mode 100644 index 0000000..380adb5 --- /dev/null +++ b/apps/api/src/common/storage/storage.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common' +import { StorageService } from './storage.service' + +@Global() +@Module({ + providers: [StorageService], + exports: [StorageService], +}) +export class StorageModule {} diff --git a/apps/api/src/common/storage/storage.service.ts b/apps/api/src/common/storage/storage.service.ts new file mode 100644 index 0000000..630185a --- /dev/null +++ b/apps/api/src/common/storage/storage.service.ts @@ -0,0 +1,77 @@ +import { Injectable, Logger } from '@nestjs/common' +import { ConfigService } from '@nestjs/config' +import { + S3Client, + PutObjectCommand, + GetObjectCommand, + DeleteObjectCommand, +} from '@aws-sdk/client-s3' +import { getSignedUrl } from '@aws-sdk/s3-request-presigner' +import type { Env } from 'src/config/env.config' + +@Injectable() +export class StorageService { + private readonly logger = new Logger(StorageService.name) + private readonly s3Client: S3Client + private readonly bucket: string + + constructor(private readonly configService: ConfigService) { + this.bucket = this.configService.get('AWS_S3_BUCKET', { infer: true })! + const region = this.configService.get('AWS_S3_REGION', { infer: true })! + + this.s3Client = new S3Client({ + region, + credentials: { + accessKeyId: this.configService.get('AWS_ACCESS_KEY_ID', { infer: true })!, + secretAccessKey: this.configService.get('AWS_SECRET_ACCESS_KEY', { infer: true })!, + }, + }) + + this.logger.log(`S3 storage initialized with bucket: ${this.bucket} in region: ${region}`) + } + + //build a file path + getObjectKey(subjectTeacherId: string, resourceId: string, fileName: string): string { + const sanitized = fileName.replace(/[^a-zA-Z0-9.\-_]/g, '_') + return `resources/${subjectTeacherId}/${resourceId}/${sanitized}` + } + + //Creates a temporary URL the client can use to upload a file directly to S3, bypassing your server + async generatePresignedUploadUrl( + key: string, + contentType: string, + expiresIn = 300, // 5 minutes + ): Promise { + const command = new PutObjectCommand({ + Bucket: this.bucket, + Key: key, + ContentType: contentType, + }) + + const url = await getSignedUrl(this.s3Client, command, { expiresIn }) + this.logger.log(`Generated presigned upload URL for key: ${key}`) + return url + } + + // Creates a temporary URL the client can use to download a file directly from S3, bypassing your server + async generatePresignedDownloadUrl(key: string, expiresIn = 3600): Promise { + const command = new GetObjectCommand({ + Bucket: this.bucket, + Key: key, + }) + + const url = await getSignedUrl(this.s3Client, command, { expiresIn }) + this.logger.log(`Generated presigned download URL for key: ${key}`) + return url + } + + async deleteObject(key: string): Promise { + const command = new DeleteObjectCommand({ + Bucket: this.bucket, + Key: key, + }) + + await this.s3Client.send(command) + this.logger.log(`Deleted S3 object: ${key}`) + } +} diff --git a/apps/api/src/config/env.config.ts b/apps/api/src/config/env.config.ts index adf741f..6260240 100644 --- a/apps/api/src/config/env.config.ts +++ b/apps/api/src/config/env.config.ts @@ -19,6 +19,10 @@ export const envSchema = z.object({ COOKIE_DOMAIN: z.string().optional(), NODE_ENV: z.enum(['development', 'production']).default('development'), BE_PORT: z.coerce.number().optional().default(8000), + AWS_S3_BUCKET: z.string().min(1), + AWS_S3_REGION: z.string().min(1), + AWS_ACCESS_KEY_ID: z.string().min(1), + AWS_SECRET_ACCESS_KEY: z.string().min(1), }) export type Env = z.infer diff --git a/apps/api/src/resource/dto/create-resource.dto.ts b/apps/api/src/resource/dto/create-resource.dto.ts new file mode 100644 index 0000000..e4582f0 --- /dev/null +++ b/apps/api/src/resource/dto/create-resource.dto.ts @@ -0,0 +1,4 @@ +import { CreateResourceSchema } from '@repo/schemas' +import { createZodDto } from 'nestjs-zod' + +export class CreateResourceDto extends createZodDto(CreateResourceSchema) {} diff --git a/apps/api/src/resource/dto/index.ts b/apps/api/src/resource/dto/index.ts new file mode 100644 index 0000000..34d9a41 --- /dev/null +++ b/apps/api/src/resource/dto/index.ts @@ -0,0 +1,2 @@ +export * from './create-resource.dto' +export * from './update-resource.dto' diff --git a/apps/api/src/resource/dto/update-resource.dto.ts b/apps/api/src/resource/dto/update-resource.dto.ts new file mode 100644 index 0000000..f116187 --- /dev/null +++ b/apps/api/src/resource/dto/update-resource.dto.ts @@ -0,0 +1,4 @@ +import { UpdateResourceSchema } from '@repo/schemas' +import { createZodDto } from 'nestjs-zod' + +export class UpdateResourceDto extends createZodDto(UpdateResourceSchema) {} diff --git a/apps/api/src/resource/resource.controller.ts b/apps/api/src/resource/resource.controller.ts new file mode 100644 index 0000000..913874b --- /dev/null +++ b/apps/api/src/resource/resource.controller.ts @@ -0,0 +1,141 @@ +import { Controller, Get, Post, Patch, Delete, Body, Param, Query } from '@nestjs/common' +import { ApiTags, ApiOperation, ApiResponse, ApiParam } from '@nestjs/swagger' +import { Role } from '@prisma/client' +import type { AuthUser } from '@repo/schemas' +import { Roles } from 'src/auth/decorators/roles.decorator' +import { CurrentUser } from 'src/auth/decorators/current-user.decorator' +import { ResourceService } from './resource.service' +import { CreateResourceDto, UpdateResourceDto } from './dto' + +@ApiTags('Resource Management') +@Controller('resources') +export class ResourceController { + constructor(private readonly resourceService: ResourceService) {} + + @Post() + @Roles(Role.TEACHER, Role.ADMIN) + @ApiOperation({ summary: 'Create a new resource (returns presigned upload URL for file types)' }) + @ApiResponse({ status: 201, description: 'Resource created successfully' }) + @ApiResponse({ status: 403, description: 'Teacher not assigned to this subject' }) + async create(@Body() createResourceDto: CreateResourceDto, @CurrentUser() user: AuthUser) { + const result = await this.resourceService.create(createResourceDto, user) + return { + message: 'Resource created successfully', + data: result, + } + } + + @Patch(':id/confirm') + @Roles(Role.TEACHER, Role.ADMIN) + @ApiOperation({ summary: 'Confirm file upload completed for a resource' }) + @ApiParam({ name: 'id', description: 'Resource ID' }) + @ApiResponse({ status: 200, description: 'Upload confirmed' }) + @ApiResponse({ + status: 400, + description: 'Invalid confirmation (LINK type or already confirmed)', + }) + async confirmUpload(@Param('id') id: string, @CurrentUser() user: AuthUser) { + const resource = await this.resourceService.confirmUpload(id, user) + return { + message: 'Upload confirmed successfully', + data: resource, + } + } + + @Get() + @ApiOperation({ summary: 'Get resources based on user role' }) + @ApiResponse({ status: 200, description: 'List of resources' }) + async findAll(@Query('subjectId') subjectId: string | undefined, @CurrentUser() user: AuthUser) { + const resources = await this.resourceService.findAll(user, subjectId) + return { + message: 'Resources retrieved successfully', + data: resources, + } + } + + @Get('my-subjects') + @Roles(Role.TEACHER) + @ApiOperation({ summary: 'Get subject-teacher records for the authenticated teacher' }) + @ApiResponse({ status: 200, description: 'List of subject-teacher records' }) + async getTeacherSubjects(@CurrentUser('id') teacherId: string) { + const subjects = await this.resourceService.getTeacherSubjects(teacherId) + return { + message: 'Teacher subjects retrieved successfully', + data: subjects, + } + } + + @Get('all-subject-teachers') + @Roles(Role.ADMIN) + @ApiOperation({ summary: 'Get all subject-teacher records (admin only)' }) + @ApiResponse({ status: 200, description: 'List of all subject-teacher records' }) + async getAllSubjectTeachers() { + const subjects = await this.resourceService.getAllSubjectTeachers() + return { + message: 'All subject-teacher records retrieved successfully', + data: subjects, + } + } + + @Get(':id') + @ApiOperation({ summary: 'Get a single resource by ID' }) + @ApiParam({ name: 'id', description: 'Resource ID' }) + @ApiResponse({ status: 200, description: 'Resource details' }) + @ApiResponse({ status: 404, description: 'Resource not found' }) + @ApiResponse({ status: 403, description: 'Access denied' }) + async findOne(@Param('id') id: string, @CurrentUser() user: AuthUser) { + const resource = await this.resourceService.findOne(id, user) + return { + message: 'Resource retrieved successfully', + data: resource, + } + } + + @Get(':id/download') + @ApiOperation({ summary: 'Get a presigned download URL for a resource file' }) + @ApiParam({ name: 'id', description: 'Resource ID' }) + @ApiResponse({ status: 200, description: 'Presigned download URL' }) + @ApiResponse({ status: 400, description: 'No file to download' }) + @ApiResponse({ status: 404, description: 'Resource not found' }) + async getDownloadUrl(@Param('id') id: string, @CurrentUser() user: AuthUser) { + const result = await this.resourceService.getDownloadUrl(id, user) + return { + message: 'Download URL generated successfully', + data: result, + } + } + + @Patch(':id') + @Roles(Role.TEACHER, Role.ADMIN) + @ApiOperation({ summary: 'Update resource metadata' }) + @ApiParam({ name: 'id', description: 'Resource ID' }) + @ApiResponse({ status: 200, description: 'Resource updated successfully' }) + @ApiResponse({ status: 404, description: 'Resource not found' }) + @ApiResponse({ status: 403, description: 'Access denied' }) + async update( + @Param('id') id: string, + @Body() updateResourceDto: UpdateResourceDto, + @CurrentUser() user: AuthUser, + ) { + const resource = await this.resourceService.update(id, updateResourceDto, user) + return { + message: 'Resource updated successfully', + data: resource, + } + } + + @Delete(':id') + @Roles(Role.TEACHER, Role.ADMIN) + @ApiOperation({ summary: 'Delete a resource and its S3 file' }) + @ApiParam({ name: 'id', description: 'Resource ID' }) + @ApiResponse({ status: 200, description: 'Resource deleted successfully' }) + @ApiResponse({ status: 404, description: 'Resource not found' }) + @ApiResponse({ status: 403, description: 'Access denied' }) + async remove(@Param('id') id: string, @CurrentUser() user: AuthUser) { + const result = await this.resourceService.remove(id, user) + return { + message: 'Resource deleted successfully', + data: result, + } + } +} diff --git a/apps/api/src/resource/resource.module.ts b/apps/api/src/resource/resource.module.ts new file mode 100644 index 0000000..8dcdc4d --- /dev/null +++ b/apps/api/src/resource/resource.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common' +import { ResourceController } from './resource.controller' +import { ResourceService } from './resource.service' + +@Module({ + controllers: [ResourceController], + providers: [ResourceService], + exports: [ResourceService], +}) +export class ResourceModule {} diff --git a/apps/api/src/resource/resource.service.ts b/apps/api/src/resource/resource.service.ts new file mode 100644 index 0000000..26d6abe --- /dev/null +++ b/apps/api/src/resource/resource.service.ts @@ -0,0 +1,406 @@ +import { + Injectable, + Logger, + NotFoundException, + ForbiddenException, + BadRequestException, +} from '@nestjs/common' +import { PrismaService } from 'src/prisma/prisma.service' +import { StorageService } from 'src/common/storage/storage.service' +import { CreateResourceDto, UpdateResourceDto } from './dto' +import { ResourceType, Role, Prisma } from '@prisma/client' +import { ResourceTypeEnum, type AuthUser } from '@repo/schemas' + +@Injectable() +export class ResourceService { + private readonly logger = new Logger(ResourceService.name) + + constructor( + private readonly prisma: PrismaService, + private readonly storage: StorageService, + ) {} + + private readonly RESOURCE_INCLUDE = { + subjectTeacher: { + select: { + id: true, + subject: { + select: { + id: true, + subjectCode: true, + subjectName: true, + }, + }, + teacher: { + select: { + id: true, + name: true, + }, + }, + }, + }, + } as const + + private isAdmin(user: AuthUser) { + return user.role === Role.ADMIN + } + + private async findOneWithAccess(client: Prisma.TransactionClient, id: string, user: AuthUser) { + const resource = await client.resource.findUnique({ + where: { id }, + include: this.RESOURCE_INCLUDE, + }) + + if (!resource) { + throw new NotFoundException(`Resource with id ${id} not found`) + } + + if (this.isAdmin(user)) return resource + + if (user.role === Role.STUDENT) { + // Students can only see published resources for subjects in their batch's current semester + if (!resource.isPublished) { + throw new ForbiddenException('You do not have access to this resource') + } + + const student = await client.user.findUnique({ + where: { id: user.id }, + select: { + batchId: true, + batch: { + select: { currentSemesterId: true }, + }, + }, + }) + + if (!student?.batch?.currentSemesterId) { + throw new ForbiddenException('You do not have access to this resource') + } + + // Check if the resource's subject belongs to the student's current semester + const subjectTeacher = await client.subjectTeacher.findUnique({ + where: { id: resource.subjectTeacherId }, + select: { + subject: { + select: { semesterId: true }, + }, + }, + }) + + if (subjectTeacher?.subject.semesterId !== student.batch.currentSemesterId) { + throw new ForbiddenException('You do not have access to this resource') + } + + return resource + } + + // Teacher: can only access their own resources + if (resource.subjectTeacher.teacher.id !== user.id) { + throw new ForbiddenException('You do not have access to this resource') + } + + return resource + } + + async create(createResourceDto: CreateResourceDto, user: AuthUser) { + this.logger.log(`Creating resource: ${createResourceDto.title} by user: ${user.id}`) + + // Verify subject-teacher access + const subjectTeacher = this.isAdmin(user) + ? await this.prisma.subjectTeacher.findUnique({ + where: { id: createResourceDto.subjectTeacherId }, + }) + : await this.prisma.subjectTeacher.findUnique({ + where: { + id: createResourceDto.subjectTeacherId, + teacherId: user.id, + isActive: true, + }, + }) + + if (!subjectTeacher) { + throw this.isAdmin(user) + ? new NotFoundException( + `SubjectTeacher with id ${createResourceDto.subjectTeacherId} not found`, + ) + : new ForbiddenException( + 'You are not assigned to this subject or the assignment is inactive', + ) + } + + const isFileResource = + createResourceDto.resourceType === ResourceTypeEnum.enum.DOCUMENT || + createResourceDto.resourceType === ResourceTypeEnum.enum.IMAGE + + // For file resources, validate that file details are provided + if (isFileResource) { + if ( + !createResourceDto.fileName || + !createResourceDto.fileSize || + !createResourceDto.mimeType + ) { + throw new BadRequestException( + 'fileName, fileSize, and mimeType are required for file resources', + ) + } + } + + // For link resources, validate that externalLink is provided + if (createResourceDto.resourceType === ResourceTypeEnum.enum.LINK) { + if (!createResourceDto.externalLink) { + throw new BadRequestException('externalLink is required for LINK resources') + } + } + + // Create the resource record + const resource = await this.prisma.resource.create({ + data: { + title: createResourceDto.title, + description: createResourceDto.description ?? null, + resourceType: createResourceDto.resourceType as ResourceType, + subjectTeacherId: createResourceDto.subjectTeacherId, + fileName: createResourceDto.fileName ?? null, + fileSize: createResourceDto.fileSize ?? null, + mimeType: createResourceDto.mimeType ?? null, + externalLink: createResourceDto.externalLink ?? null, + // LINK resources are immediately "uploaded" (no file to upload) + isUploaded: createResourceDto.resourceType === ResourceTypeEnum.enum.LINK, + }, + include: this.RESOURCE_INCLUDE, + }) + + // Generate presigned upload URL for file resources + let uploadUrl: string | null = null + if (isFileResource && createResourceDto.fileName && createResourceDto.mimeType) { + const objectKey = this.storage.getObjectKey( + createResourceDto.subjectTeacherId as string, + resource.id as string, + createResourceDto.fileName as string, + ) + + // Store the S3 object key on the resource + await this.prisma.resource.update({ + where: { id: resource.id }, + data: { fileUrl: objectKey }, + }) + + resource.fileUrl = objectKey + + uploadUrl = await this.storage.generatePresignedUploadUrl( + objectKey, + createResourceDto.mimeType as string, + ) + } + + this.logger.log(`Created resource: ${resource.id}`) + return { resource, uploadUrl } + } + + /** + * Confirm that a file has been successfully uploaded to S3. + */ + async confirmUpload(id: string, user: AuthUser) { + this.logger.log(`Confirming upload for resource: ${id} by user: ${user.id}`) + + return this.prisma.$transaction(async (tx) => { + const resource = await this.findOneWithAccess(tx, id, user) + + if (resource.resourceType === ResourceTypeEnum.enum.LINK) { + throw new BadRequestException('Cannot confirm upload for a LINK resource') + } + + if (resource.isUploaded) { + throw new BadRequestException('Resource is already marked as uploaded') + } + + if (!resource.fileUrl) { + throw new BadRequestException('Resource has no file URL to confirm') + } + + const updated = await tx.resource.update({ + where: { id }, + data: { isUploaded: true }, + include: this.RESOURCE_INCLUDE, + }) + + this.logger.log(`Confirmed upload for resource: ${id}`) + return updated + }) + } + + async findAll(user: AuthUser, subjectId?: string) { + this.logger.log(`Finding all resources for user: ${user.id} (role: ${user.role})`) + + if (this.isAdmin(user)) { + return this.prisma.resource.findMany({ + where: { + isUploaded: true, + ...(subjectId && { + subjectTeacher: { subject: { id: subjectId } }, + }), + }, + include: this.RESOURCE_INCLUDE, + orderBy: { createdAt: 'desc' }, + }) + } + + if (user.role === Role.STUDENT) { + const student = await this.prisma.user.findUnique({ + where: { id: user.id }, + select: { + batchId: true, + batch: { + select: { currentSemesterId: true }, + }, + }, + }) + + if (!student?.batch?.currentSemesterId) return [] + + return this.prisma.resource.findMany({ + where: { + isPublished: true, + isUploaded: true, + subjectTeacher: { + subject: { + semesterId: student.batch.currentSemesterId, + ...(subjectId && { id: subjectId }), + }, + isActive: true, + }, + }, + include: this.RESOURCE_INCLUDE, + orderBy: { createdAt: 'desc' }, + }) + } + + // Teacher: see their own resources (including unpublished) + return this.prisma.resource.findMany({ + where: { + subjectTeacher: { + teacherId: user.id, + ...(subjectId && { subject: { id: subjectId } }), + }, + isUploaded: true, + }, + include: this.RESOURCE_INCLUDE, + orderBy: { createdAt: 'desc' }, + }) + } + + async findOne(id: string, user: AuthUser) { + this.logger.log(`Finding resource: ${id} for user: ${user.id}`) + return this.findOneWithAccess(this.prisma, id, user) + } + + async getDownloadUrl(id: string, user: AuthUser) { + this.logger.log(`Getting download URL for resource: ${id} by user: ${user.id}`) + + const resource = await this.findOneWithAccess(this.prisma, id, user) + + if (resource.resourceType === 'LINK') { + return { downloadUrl: resource.externalLink } + } + + if (!resource.fileUrl) { + throw new BadRequestException('Resource has no file to download') + } + + if (!resource.isUploaded) { + throw new BadRequestException('Resource file has not been uploaded yet') + } + + const downloadUrl = await this.storage.generatePresignedDownloadUrl(resource.fileUrl as string) + return { downloadUrl } + } + + async update(id: string, updateResourceDto: UpdateResourceDto, user: AuthUser) { + this.logger.log(`Updating resource: ${id} by user: ${user.id}`) + + return this.prisma.$transaction(async (tx) => { + await this.findOneWithAccess(tx, id, user) + + const resource = await tx.resource.update({ + where: { id }, + data: { + ...(updateResourceDto.title && { title: updateResourceDto.title }), + ...(updateResourceDto.description !== undefined && { + description: updateResourceDto.description, + }), + ...(updateResourceDto.isPublished !== undefined && { + isPublished: updateResourceDto.isPublished, + publishedAt: updateResourceDto.isPublished ? new Date() : null, + }), + }, + include: this.RESOURCE_INCLUDE, + }) + + this.logger.log(`Updated resource: ${resource.id}`) + return resource + }) + } + + async remove(id: string, user: AuthUser) { + this.logger.log(`Deleting resource: ${id} by user: ${user.id}`) + + return this.prisma.$transaction(async (tx) => { + const resource = await this.findOneWithAccess(tx, id, user) + + // Delete from S3 if there's a file + if (resource.fileUrl && resource.isUploaded) { + await this.storage.deleteObject(resource.fileUrl as string) + } + + await tx.resource.delete({ where: { id } }) + this.logger.log(`Deleted resource: ${id}`) + return { id } + }) + } + + async getTeacherSubjects(teacherId: string) { + this.logger.log(`Getting subject-teacher records for teacher: ${teacherId}`) + + return this.prisma.subjectTeacher.findMany({ + where: { teacherId, isActive: true }, + select: { + id: true, + subject: { + select: { + id: true, + subjectCode: true, + subjectName: true, + }, + }, + }, + orderBy: { subject: { subjectName: 'asc' } }, + }) + } + + /** + * Get all subject-teacher records (for admin form dropdowns). + */ + async getAllSubjectTeachers() { + this.logger.log('Getting all subject-teacher records (admin)') + + return this.prisma.subjectTeacher.findMany({ + where: { isActive: true }, + select: { + id: true, + subject: { + select: { + id: true, + subjectCode: true, + subjectName: true, + }, + }, + teacher: { + select: { + id: true, + name: true, + }, + }, + }, + orderBy: { subject: { subjectName: 'asc' } }, + }) + } +} diff --git a/apps/web/apis/assignment.api.ts b/apps/web/apis/assignment.api.ts index c89234c..e8ac87a 100644 --- a/apps/web/apis/assignment.api.ts +++ b/apps/web/apis/assignment.api.ts @@ -5,30 +5,10 @@ import type { CreateAssignmentDto, UpdateAssignmentDto, UpdateAssignmentStatusDto, + TeacherSubjectRecord, + AllSubjectTeacherRecord, } from '@repo/schemas' -export interface TeacherSubjectRecord { - id: string - subject: { - id: string - subjectCode: string - subjectName: string - } -} - -export interface AllSubjectTeacherRecord { - id: string - subject: { - id: string - subjectCode: string - subjectName: string - } - teacher: { - id: string - name: string - } -} - export const assignmentApi = { getAllAssignments: async (): Promise> => { const { data } = await apiClient.get>('/assignments') diff --git a/apps/web/apis/resource.api.ts b/apps/web/apis/resource.api.ts new file mode 100644 index 0000000..934ba80 --- /dev/null +++ b/apps/web/apis/resource.api.ts @@ -0,0 +1,85 @@ +import apiClient from '@/lib/api' +import type { + ApiResponse, + ResourceResponse, + CreateResourceDto, + UpdateResourceDto, + CreateResourceResponse, + TeacherSubjectRecord, + AllSubjectTeacherRecord, +} from '@repo/schemas' + +export const resourceApi = { + getAllResources: async (subjectId?: string): Promise> => { + const params = subjectId ? { subjectId } : undefined + const { data } = await apiClient.get>('/resources', { params }) + return data + }, + + getResource: async (id: string): Promise> => { + const { data } = await apiClient.get>(`/resources/${id}`) + return data + }, + + createResource: async (dto: CreateResourceDto): Promise> => { + const { data } = await apiClient.post>('/resources', dto) + return data + }, + + confirmUpload: async (id: string): Promise> => { + const { data } = await apiClient.patch>( + `/resources/${id}/confirm`, + ) + return data + }, + + updateResource: async ( + id: string, + dto: UpdateResourceDto, + ): Promise> => { + const { data } = await apiClient.patch>(`/resources/${id}`, dto) + return data + }, + + deleteResource: async (id: string): Promise> => { + const { data } = await apiClient.delete>(`/resources/${id}`) + return data + }, + + getDownloadUrl: async (id: string): Promise> => { + const { data } = await apiClient.get>( + `/resources/${id}/download`, + ) + return data + }, + + getTeacherSubjects: async (): Promise> => { + const { data } = + await apiClient.get>('/resources/my-subjects') + return data + }, + + getAllSubjectTeachers: async (): Promise> => { + const { data } = await apiClient.get>( + '/resources/all-subject-teachers', + ) + return data + }, + + /** + * Upload a file directly to S3 using a presigned PUT URL. + * Uses native fetch instead of axios to bypass the API auth interceptor. + */ + uploadFileToS3: async (presignedUrl: string, file: File, contentType: string): Promise => { + const response = await fetch(presignedUrl, { + method: 'PUT', + body: file, + headers: { + 'Content-Type': contentType, + }, + }) + if (!response.ok) { + throw new Error('Failed to upload file to storage') + } + }, +} diff --git a/apps/web/app/admin/resources/page.tsx b/apps/web/app/admin/resources/page.tsx new file mode 100644 index 0000000..d8972ec --- /dev/null +++ b/apps/web/app/admin/resources/page.tsx @@ -0,0 +1,41 @@ +'use client' + +import { useState } from 'react' +import { Plus } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { PageHeader } from '@/components/ui/page-header' +import { ResourceListTable } from '@/components/resource/resource-list-table' +import { CreateResourceSheet } from '@/components/resource/create-resource-sheet' +import { useResources } from '@/hooks/useResource' + +export default function AdminResourcesPage() { + const { data: resources, isLoading } = useResources() + const [isCreateOpen, setIsCreateOpen] = useState(false) + + return ( +
+
+ setIsCreateOpen(true)}> + + Upload Resource + + } + /> + + + + +
+
+ ) +} diff --git a/apps/web/app/student/courses/[id]/resources/page.tsx b/apps/web/app/student/courses/[id]/resources/page.tsx new file mode 100644 index 0000000..35ba011 --- /dev/null +++ b/apps/web/app/student/courses/[id]/resources/page.tsx @@ -0,0 +1,50 @@ +'use client' + +import { useParams } from 'next/navigation' +import { useSubject } from '@/hooks/useSubject' +import { useResources } from '@/hooks/useResource' +import { PageHeader } from '@/components/ui/page-header' +import { LoadingState } from '@/components/ui/loading-state' +import { NotFoundState } from '@/components/ui/not-found-state' +import { ResourceListTable } from '@/components/resource/resource-list-table' + +export default function StudentCourseResourcesPage() { + const params = useParams() + const subjectId = params.id as string + + const { data: subject, isLoading: isSubjectLoading } = useSubject(subjectId) + const { data: resources, isLoading: isResourcesLoading } = useResources(subjectId) + + if (isSubjectLoading) { + return + } + + if (!subject) { + return ( + + ) + } + + return ( +
+
+ + + +
+
+ ) +} diff --git a/apps/web/app/student/resources/page.tsx b/apps/web/app/student/resources/page.tsx new file mode 100644 index 0000000..8a84fc5 --- /dev/null +++ b/apps/web/app/student/resources/page.tsx @@ -0,0 +1,28 @@ +'use client' + +import { PageHeader } from '@/components/ui/page-header' +import { ResourceListTable } from '@/components/resource/resource-list-table' +import { useResources } from '@/hooks/useResource' + +export default function StudentResourcesPage() { + const { data: resources, isLoading } = useResources() + + return ( +
+
+ + + +
+
+ ) +} diff --git a/apps/web/app/teacher/resources/page.tsx b/apps/web/app/teacher/resources/page.tsx index f198388..fc57e88 100644 --- a/apps/web/app/teacher/resources/page.tsx +++ b/apps/web/app/teacher/resources/page.tsx @@ -1,32 +1,39 @@ 'use client' -import { FolderOpen, Construction } from 'lucide-react' -import { Card, CardContent } from '@/components/ui/card' +import { useState } from 'react' +import { Plus } from 'lucide-react' +import { Button } from '@/components/ui/button' import { PageHeader } from '@/components/ui/page-header' +import { ResourceListTable } from '@/components/resource/resource-list-table' +import { CreateResourceSheet } from '@/components/resource/create-resource-sheet' +import { useResources } from '@/hooks/useResource' export default function TeacherResourcesPage() { + const { data: resources, isLoading } = useResources() + const [isCreateOpen, setIsCreateOpen] = useState(false) + return (
setIsCreateOpen(true)}> + + Upload Resource + + } + /> + + - - -
- - -
-

Coming soon

-

- The resources feature is currently under development. You'll be able to upload - and share teaching materials here. -

-
-
+
) diff --git a/apps/web/components/assignment/create-assignment-dialog.tsx b/apps/web/components/assignment/create-assignment-dialog.tsx index d396c6b..cdfa455 100644 --- a/apps/web/components/assignment/create-assignment-dialog.tsx +++ b/apps/web/components/assignment/create-assignment-dialog.tsx @@ -2,7 +2,11 @@ import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' -import { CreateAssignmentSchema, type CreateAssignmentDto } from '@repo/schemas' +import { + CreateAssignmentSchema, + type CreateAssignmentDto, + type TeacherSubjectRecord, +} from '@repo/schemas' import { Dialog, DialogContent, @@ -31,7 +35,6 @@ import { import { Loader2 } from 'lucide-react' import { useCreateAssignment, useTeacherSubjects } from '@/hooks/useAssignment' import { useBatches } from '@/hooks/useBatch' -import type { TeacherSubjectRecord } from '@/apis/assignment.api' interface CreateAssignmentDialogProps { open: boolean diff --git a/apps/web/components/dashboard/nav-config.ts b/apps/web/components/dashboard/nav-config.ts index fdca7cf..9bd9179 100644 --- a/apps/web/components/dashboard/nav-config.ts +++ b/apps/web/components/dashboard/nav-config.ts @@ -36,6 +36,7 @@ export const adminNavGroups: NavGroup[] = [ { title: 'Batches', url: '/admin/batches', icon: GraduationCap }, { title: 'Semesters', url: '/admin/semesters', icon: BookOpen }, { title: 'Assignments', url: '/admin/assignments', icon: ClipboardList }, + { title: 'Resources', url: '/admin/resources', icon: FolderOpen }, { title: 'Announcements', url: '/admin/announcements', icon: Megaphone }, { title: 'Chat', url: '/admin/chat', icon: MessageCircle }, ], @@ -78,6 +79,7 @@ export const studentNavGroups: NavGroup[] = [ items: [ { title: 'Courses', url: '/student/courses', icon: BookOpen }, { title: 'Assignments', url: '/student/assignments', icon: ClipboardList }, + { title: 'Resources', url: '/student/resources', icon: FolderOpen }, { title: 'Announcements', url: '/student/announcements', icon: Megaphone }, { title: 'Chat', url: '/student/chat', icon: MessageCircle }, ], diff --git a/apps/web/components/resource/create-resource-sheet.tsx b/apps/web/components/resource/create-resource-sheet.tsx new file mode 100644 index 0000000..ca60a71 --- /dev/null +++ b/apps/web/components/resource/create-resource-sheet.tsx @@ -0,0 +1,272 @@ +'use client' + +import { useState, useEffect } from 'react' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { CreateResourceSchema, type CreateResourceDto, RESOURCE_MAX_FILE_SIZE } from '@repo/schemas' +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetDescription, +} from '@/components/ui/sheet' +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form' +import { Input } from '@/components/ui/input' +import { Textarea } from '@/components/ui/textarea' +import { Button } from '@/components/ui/button' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { FileUpload } from '@/components/ui/file-upload' +import { Loader2 } from 'lucide-react' +import { + useCreateResource, + useTeacherSubjectsForResource, + useAllSubjectTeachersForResource, +} from '@/hooks/useResource' + +const DOCUMENT_ACCEPT = + 'application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.ms-powerpoint,application/vnd.openxmlformats-officedocument.presentationml.presentation,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,text/plain' +const IMAGE_ACCEPT = 'image/jpeg,image/png,image/webp' + +interface CreateResourceSheetProps { + open: boolean + onOpenChange: (open: boolean) => void + variant: 'teacher' | 'admin' +} + +export function CreateResourceSheet({ open, onOpenChange, variant }: CreateResourceSheetProps) { + const { mutate: createResource, isPending } = useCreateResource() + const { data: teacherSubjects } = useTeacherSubjectsForResource() + const { data: allSubjectTeachers } = useAllSubjectTeachersForResource() + const [selectedFile, setSelectedFile] = useState(null) + + const form = useForm({ + resolver: zodResolver(CreateResourceSchema), + defaultValues: { + title: '', + description: '', + resourceType: 'DOCUMENT', + subjectTeacherId: '', + fileName: undefined, + fileSize: undefined, + mimeType: undefined, + externalLink: undefined, + }, + }) + + const resourceType = form.watch('resourceType') + const isFileType = resourceType === 'DOCUMENT' || resourceType === 'IMAGE' + const isLink = resourceType === 'LINK' + + // Reset conditional fields when resource type changes + useEffect(() => { + if (isLink) { + setSelectedFile(null) + form.setValue('fileName', undefined) + form.setValue('fileSize', undefined) + form.setValue('mimeType', undefined) + } else { + form.setValue('externalLink', undefined) + } + }, [resourceType, isLink, form]) + + const handleFileSelect = (file: File | null) => { + setSelectedFile(file) + if (file) { + form.setValue('fileName', file.name) + form.setValue('fileSize', file.size) + form.setValue('mimeType', file.type) + form.clearErrors() + } else { + form.setValue('fileName', undefined) + form.setValue('fileSize', undefined) + form.setValue('mimeType', undefined) + } + } + + const onSubmit = (data: CreateResourceDto) => { + createResource( + { dto: data, file: selectedFile ?? undefined }, + { + onSuccess: () => { + form.reset() + setSelectedFile(null) + onOpenChange(false) + }, + }, + ) + } + + const fileAccept = resourceType === 'IMAGE' ? IMAGE_ACCEPT : DOCUMENT_ACCEPT + + return ( + + + + Upload Resource + Share a document, image, or link with your students. + + +
+
+ + ( + + Title + + + + + + )} + /> + + ( + + Description (Optional) + +