Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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"
2 changes: 2 additions & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
32 changes: 32 additions & 0 deletions apps/api/prisma/migrations/20260316095939_resources/migration.sql
Original file line number Diff line number Diff line change
@@ -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;
53 changes: 29 additions & 24 deletions apps/api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down Expand Up @@ -243,11 +248,11 @@ enum SemesterNumber {
EIGHTH
}

// enum ResourceType {
// DOCUMENT
// LINK
// NOTES
// }
enum ResourceType {
DOCUMENT
IMAGE
LINK
}

enum AssignmentStatus {
DRAFT
Expand Down
4 changes: 4 additions & 0 deletions apps/api/src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -36,6 +38,8 @@ import { DashboardModule } from 'src/dashboard/dashboard.module'
AnnouncementModule,
ChatModule,
DashboardModule,
StorageModule,
ResourceModule,
ConfigModule.forRoot({
isGlobal: true,
validate: (config) => {
Expand Down
9 changes: 9 additions & 0 deletions apps/api/src/common/storage/storage.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common'
import { StorageService } from './storage.service'

@Global()
@Module({
providers: [StorageService],
exports: [StorageService],
})
export class StorageModule {}
77 changes: 77 additions & 0 deletions apps/api/src/common/storage/storage.service.ts
Original file line number Diff line number Diff line change
@@ -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<Env>) {
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<string> {
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<string> {
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<void> {
const command = new DeleteObjectCommand({
Bucket: this.bucket,
Key: key,
})

await this.s3Client.send(command)
this.logger.log(`Deleted S3 object: ${key}`)
}
}
4 changes: 4 additions & 0 deletions apps/api/src/config/env.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof envSchema>
4 changes: 4 additions & 0 deletions apps/api/src/resource/dto/create-resource.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { CreateResourceSchema } from '@repo/schemas'
import { createZodDto } from 'nestjs-zod'

export class CreateResourceDto extends createZodDto(CreateResourceSchema) {}
2 changes: 2 additions & 0 deletions apps/api/src/resource/dto/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './create-resource.dto'
export * from './update-resource.dto'
4 changes: 4 additions & 0 deletions apps/api/src/resource/dto/update-resource.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { UpdateResourceSchema } from '@repo/schemas'
import { createZodDto } from 'nestjs-zod'

export class UpdateResourceDto extends createZodDto(UpdateResourceSchema) {}
141 changes: 141 additions & 0 deletions apps/api/src/resource/resource.controller.ts
Original file line number Diff line number Diff line change
@@ -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,
}
}
}
Loading
Loading