diff --git a/prisma/migrations/20250602053249_modify_assignment_custom/migration.sql b/prisma/migrations/20250602053249_modify_assignment_custom/migration.sql new file mode 100644 index 0000000..bcd8d8e --- /dev/null +++ b/prisma/migrations/20250602053249_modify_assignment_custom/migration.sql @@ -0,0 +1,21 @@ +/* + Warnings: + + - The primary key for the `assignment` table will be changed. If it partially fails, the table could be left without primary key constraint. + +*/ +-- DropForeignKey +ALTER TABLE "user_assignment" DROP CONSTRAINT "user_assignment_assignment_id_fkey"; + +-- AlterTable +ALTER TABLE "assignment" DROP CONSTRAINT "assignment_pkey", +ADD COLUMN "starts_at" TIMESTAMP(3), +ALTER COLUMN "id" SET DATA TYPE TEXT, +ALTER COLUMN "week" DROP NOT NULL, +ADD CONSTRAINT "assignment_pkey" PRIMARY KEY ("id"); + +-- AlterTable +ALTER TABLE "user_assignment" ALTER COLUMN "assignment_id" SET DATA TYPE TEXT; + +-- AddForeignKey +ALTER TABLE "user_assignment" ADD CONSTRAINT "user_assignment_assignment_id_fkey" FOREIGN KEY ("assignment_id") REFERENCES "assignment"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema/attendance/assignment.prisma b/prisma/schema/attendance/assignment.prisma index 39e9bbd..96b6a45 100644 --- a/prisma/schema/attendance/assignment.prisma +++ b/prisma/schema/attendance/assignment.prisma @@ -1,9 +1,10 @@ model Assignment { - id Int @id + id String @id @default(uuid()) courseId String @map("course_id") courseOldId String? @map("course_old_id") name String - week Int + week Int? + startsAt DateTime? @map("starts_at") endsAt DateTime? @map("ends_at") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") diff --git a/prisma/schema/attendance/user-assignment.prisma b/prisma/schema/attendance/user-assignment.prisma index 7a80a27..0169a89 100644 --- a/prisma/schema/attendance/user-assignment.prisma +++ b/prisma/schema/attendance/user-assignment.prisma @@ -1,7 +1,7 @@ model UserAssignment { id Int @id @default(autoincrement()) studentId String @map("student_id") - assignmentId Int @map("assignment_id") + assignmentId String @map("assignment_id") isDone Boolean @map("is_done") createdAt DateTime @default(now()) @map("created_at") user User @relation(fields: [studentId], references: [studentId]) diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 25781a1..2e61986 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -12,6 +12,7 @@ import { AppController } from './app.controller'; import { AppService } from './app.service'; import { configModule } from './modules/config.module'; import { BatchModule } from 'src/batch/batch.module'; +import { AssignmentModule } from 'src/assignment/assignment.module'; @Module({ imports: [ @@ -25,6 +26,7 @@ import { BatchModule } from 'src/batch/batch.module'; GodokModule, NoticeModule, BatchModule, + AssignmentModule ], controllers: [AppController], providers: [AppService], diff --git a/src/assignment/assignment.controller.ts b/src/assignment/assignment.controller.ts new file mode 100644 index 0000000..93ce0bb --- /dev/null +++ b/src/assignment/assignment.controller.ts @@ -0,0 +1,75 @@ +import { Body, Controller, Delete, Get, Param, Post, Put, UseGuards, Version } from "@nestjs/common"; +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; +import { JwtAuthGuard } from "src/auth/guard/jwt-auth.guard"; +import { CreateUpdateAssignmentPayload } from "./payload/create-update-assignment.payload"; +import { CurrentUser } from "src/auth/decorator/user.decorator"; +import { UserInfo } from "src/auth/types/user-info.type"; +import { AssignmentService } from "./assignment.service"; +import { Assignment } from "@prisma/client"; + +@ApiTags("과제 API") +@Controller('assignment') +export class AssignmentController { + constructor( + private readonly assignmentService: AssignmentService, + ) {} + + @Version('1') + @ApiOperation({ + summary: '과제 생성 API', + description: '과제를 생성합니다.', + }) + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @Post('') + async createAssignment(@CurrentUser() user: UserInfo, @Body() payload: CreateUpdateAssignmentPayload): Promise { + console.log(payload) + return await this.assignmentService.createAssignment(user, payload); + } + + @Version('1') + @ApiOperation({ + summary: '과제 단일 조회 API', + description: '과제를 조회합니다.', + }) + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @Get(':id') + async findAssignmentById( + @CurrentUser() user: UserInfo, + @Param('id') id: string, + ): Promise { + return await this.assignmentService.findAssignmentById(user, id); + } + + @Version('1') + @ApiOperation({ + summary: '과제 수정 API', + description: '과제를 수정합니다.', + }) + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @Put(':id') + async updateAssignment( + @CurrentUser() user: UserInfo, + @Param('id') id: string, + @Body() payload: CreateUpdateAssignmentPayload, + ): Promise { + return await this.assignmentService.updateAssignment(user, id, payload); + } + + @Version('1') + @ApiOperation({ + summary: '과제 삭제 API', + description: '과제를 삭제합니다.', + }) + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @Delete(':id') + async deleteAssignment( + @CurrentUser() user: UserInfo, + @Param('id') id: string, + ): Promise { + return await this.assignmentService.deleteAssignment(user, id); + } +} \ No newline at end of file diff --git a/src/assignment/assignment.module.ts b/src/assignment/assignment.module.ts new file mode 100644 index 0000000..3211da0 --- /dev/null +++ b/src/assignment/assignment.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { AssignmentController } from './assignment.controller'; +import { AssignmentRepository } from './assignment.repository'; +import { AssignmentService } from './assignment.service'; + +@Module({ + providers: [ + AssignmentService, + AssignmentRepository, + ], + controllers: [AssignmentController], +}) +export class AssignmentModule {} diff --git a/src/assignment/assignment.repository.ts b/src/assignment/assignment.repository.ts new file mode 100644 index 0000000..9bf7ad3 --- /dev/null +++ b/src/assignment/assignment.repository.ts @@ -0,0 +1,118 @@ +import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; +import { PrismaService } from "src/common/services/prisma.service"; +import { CreateUpdateAssignmentPayload } from "./payload/create-update-assignment.payload"; +import { Assignment } from "@prisma/client"; + +@Injectable() +export class AssignmentRepository { + constructor(private readonly prisma: PrismaService) { } + + async createAssignment(userId: string, payload: CreateUpdateAssignmentPayload): Promise { + const course = await this.prisma.course.findFirst({ + where: { id: payload.courseId }, + }); + if (!course) { + throw new NotFoundException(`해당 ID의 강의가 존재하지 않습니다.`); + } + const newAssignment = await this.prisma.assignment.create({ + data: { + name: payload.name, + endsAt: payload.endsAt, + startsAt: payload.startsAt, + course: { + connect: { + id: payload.courseId + }, + }, + } + }) + + await this.prisma.userAssignment.create({ + data: { + isDone: false, + studentId: userId, + assignmentId: newAssignment.id, + } + }); + } + + async findAssignmentById(userId: string, id: string): Promise { + const assignment = await this.prisma.assignment.findUnique({ + where: { id }, + }); + + if (!assignment) { + throw new NotFoundException(`해당 ID의 과제가 존재하지 않습니다.`); + } + const userAssignment = await this.prisma.userAssignment.findFirst({ + where: { + studentId: userId, + assignmentId: id, + }, + }); + + if (!userAssignment || userAssignment.studentId !== userId) { + throw new BadRequestException(`해당 과제를 조회할 권한이 없습니다.`); + } + + return assignment; + } + + async updateAssignment(userId: string, id: string, payload: CreateUpdateAssignmentPayload): Promise { + const assignment = await this.prisma.assignment.findUnique({ + where: { id }, + }); + + if (!assignment) { + throw new NotFoundException(`해당 ID의 과제가 존재하지 않습니다.`); + } + const userAssignment = await this.prisma.userAssignment.findFirst({ + where: { + studentId: userId, + assignmentId: id, + }, + }); + if (userAssignment.studentId !== userId) { + throw new BadRequestException(`해당 과제를 수정할 권한이 없습니다.`); + } + + + await this.prisma.assignment.update({ + where: { id }, + data: { + courseId: payload.courseId, + startsAt: payload.startsAt, + name: payload.name, + endsAt: payload.endsAt, + }, + }); + } + + async deleteAssignment(userId: string, id: string): Promise { + const assignment = await this.prisma.assignment.findUnique({ + where: { id }, + }); + + if (!assignment) { + throw new NotFoundException(`해당 ID의 과제가 존재하지 않습니다.`); + } + const userAssignment = await this.prisma.userAssignment.findFirst({ + where: { + studentId: userId, + assignmentId: id, + }, + }); + if (!userAssignment || userAssignment.studentId !== userId) { + throw new BadRequestException(`해당 과제를 삭제할 권한이 없습니다.`); + } + + await this.prisma.$transaction(async (tx) => { + await tx.userAssignment.delete({ + where: { id: userAssignment.id }, + }); + await tx.assignment.delete({ + where: { id }, + }); + }); + } +} \ No newline at end of file diff --git a/src/assignment/assignment.service.ts b/src/assignment/assignment.service.ts new file mode 100644 index 0000000..dc90d3e --- /dev/null +++ b/src/assignment/assignment.service.ts @@ -0,0 +1,28 @@ +import { Injectable } from "@nestjs/common"; +import { CreateUpdateAssignmentPayload } from "./payload/create-update-assignment.payload"; +import { UserInfo } from "src/auth/types/user-info.type"; +import { AssignmentRepository } from "./assignment.repository"; +import { Assignment } from "@prisma/client"; + +@Injectable() +export class AssignmentService { + constructor( + private readonly assignmentRepository: AssignmentRepository, + ) {} + + async createAssignment(user: UserInfo, payload: CreateUpdateAssignmentPayload): Promise { + return await this.assignmentRepository.createAssignment(user.studentId, payload); + } + + async findAssignmentById(user: UserInfo, id: string): Promise { + return await this.assignmentRepository.findAssignmentById(user.studentId, id); + } + + async updateAssignment(user: UserInfo, id: string, payload: CreateUpdateAssignmentPayload): Promise { + return await this.assignmentRepository.updateAssignment(user.studentId, id, payload); + } + + async deleteAssignment(user: UserInfo, id: string): Promise { + return await this.assignmentRepository.deleteAssignment(user.studentId, id); + } +} \ No newline at end of file diff --git a/src/assignment/payload/create-update-assignment.payload.ts b/src/assignment/payload/create-update-assignment.payload.ts new file mode 100644 index 0000000..326f4d3 --- /dev/null +++ b/src/assignment/payload/create-update-assignment.payload.ts @@ -0,0 +1,39 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsDate, IsString } from "class-validator"; + +export class CreateUpdateAssignmentPayload { + @ApiProperty({ + description: '과제의 원본 강의 id', + example: '2a22cfb9-86b1-4279-8fa7-d7383ede0c3e', + type: String, + }) + @IsString() + courseId?: string; + + @ApiProperty({ + description: '과제 제목', + example: 'Chapter 1 예제 풀이', + type: String, + }) + @IsString() + name!: string; + + @ApiProperty({ + description: '과제 시작일', + example: '2024-01-31T10:13:09.004Z', + type: Date, + }) + @Type(() => Date) + @IsDate() + startsAt!: Date; + + @ApiProperty({ + description: '과제 마감일', + example: '2024-02-28T10:13:09.004Z', + type: Date, + }) + @Type(() => Date) + @IsDate() + endsAt!: Date; +} \ No newline at end of file diff --git a/src/attendance/attendance.module.ts b/src/attendance/attendance.module.ts index a9b6fd4..430413c 100644 --- a/src/attendance/attendance.module.ts +++ b/src/attendance/attendance.module.ts @@ -9,7 +9,6 @@ import { EcampusService } from './ecampus.service'; EcampusService, AttendanceService, AttendanceRepository, - EcampusService, ], controllers: [AttendanceController], }) diff --git a/src/attendance/attendance.repository.ts b/src/attendance/attendance.repository.ts index 76fd29c..5f8f4b4 100644 --- a/src/attendance/attendance.repository.ts +++ b/src/attendance/attendance.repository.ts @@ -11,7 +11,7 @@ import { getCurrentSemester } from './utils/getCurrentSemester'; @Injectable() export class AttendanceRepository { - constructor(private readonly prismaService: PrismaService) {} + constructor(private readonly prismaService: PrismaService) { } async getCourseAttendanceList(user: UserInfo): Promise { return await this.prismaService.course.findMany({ @@ -504,7 +504,7 @@ export class AttendanceRepository { (assignment) => assignment.id, ); const newAssignmentIds = rawCourse.assignments.map((assignment) => - parseInt(assignment.id), + assignment.id.toString() ); const existingAssignmentIds = _.intersection( @@ -521,11 +521,11 @@ export class AttendanceRepository { ); const createdAssignments = rawCourse.assignments.filter( - (assignment) => createdAssignmentIds.includes(assignment.id), + (assignment) => createdAssignmentIds.includes(assignment.id.toString()), ); const existingAssignments = rawCourse.assignments.filter( - (assignment) => existingAssignmentIds.includes(assignment.id), + (assignment) => existingAssignmentIds.includes(assignment.id.toString()), ); // 사라진 과제들 삭제 @@ -533,6 +533,9 @@ export class AttendanceRepository { where: { assignmentId: { in: deletedAssignmentIds, + not: { + contains: '-', // 커스텀 과제는 유지 + } }, studentId: user.studentId, }, @@ -542,7 +545,7 @@ export class AttendanceRepository { for (const assignment of existingAssignments) { await tx.assignment.update({ where: { - id: assignment.id, + id: assignment.id.toString(), }, data: { name: assignment.name, @@ -555,7 +558,7 @@ export class AttendanceRepository { where: { studentId_assignmentId: { studentId: user.studentId, - assignmentId: assignment.id, + assignmentId: assignment.id.toString(), }, }, data: { @@ -566,35 +569,49 @@ export class AttendanceRepository { // 새로운 과제들 추가 for (const assignment of createdAssignments) { + const courseId = courseEntities.find( + (course) => course.courseId === rawCourse.id, + ).id; + await tx.assignment.upsert({ - where: { - id: assignment.id, - }, + where: { id: assignment.id.toString() }, update: { name: assignment.name, week: assignment.week, endsAt: assignment.endsAt, + courseId, }, create: { - id: assignment.id, + id: assignment.id.toString(), name: assignment.name, week: assignment.week, endsAt: assignment.endsAt, - course: { - connect: { - id: courseEntities.find( - (course) => course.courseId === rawCourse.id, - ).id, - }, - }, + courseId, }, }); } + + // 커스텀 과제는 마감일이 지나면 완료표시 + const now = new Date(); + + await tx.userAssignment.updateMany({ + where: { + studentId: user.studentId, + assignmentId: { contains: '-' }, + assignment: { + endsAt: { lt: now }, + }, + }, + data: { + isDone: true, + }, + }); + await tx.userAssignment.createMany({ data: createdAssignments.map((assignment) => { return { studentId: user.studentId, - assignmentId: assignment.id, + assignmentId: assignment.id.toString(), isDone: assignment.isDone, }; }), diff --git a/src/attendance/dto/assignment-attendance.dto.ts b/src/attendance/dto/assignment-attendance.dto.ts index ad58387..23c99eb 100644 --- a/src/attendance/dto/assignment-attendance.dto.ts +++ b/src/attendance/dto/assignment-attendance.dto.ts @@ -6,7 +6,7 @@ export class AssignmentAttendanceDto { description: '과제 id. Ecampus에서 사용하는 id와 같습니다.', type: Number, }) - id!: number; + id!: string; @ApiProperty({ description: '과제명', diff --git a/src/attendance/types/assignment-data.d.ts b/src/attendance/types/assignment-data.d.ts index cbe3a08..5fa62de 100644 --- a/src/attendance/types/assignment-data.d.ts +++ b/src/attendance/types/assignment-data.d.ts @@ -1,5 +1,5 @@ export type AssignmentData = { - id: number; + id: string; name: string; week: number; endsAt?: Date;