feat(school): organization + class + assignment schema and API — 9.1
This commit is contained in:
3
backend/pnpm-lock.yaml
generated
3
backend/pnpm-lock.yaml
generated
@@ -41,6 +41,9 @@ importers:
|
||||
class-validator:
|
||||
specifier: ^0.15.1
|
||||
version: 0.15.1
|
||||
express:
|
||||
specifier: ^4.22.1
|
||||
version: 4.22.1
|
||||
helmet:
|
||||
specifier: ^8.1.0
|
||||
version: 8.1.0
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE `organizations` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`name` VARCHAR(191) NOT NULL,
|
||||
`type` ENUM('school', 'academy') NOT NULL DEFAULT 'academy',
|
||||
`inviteCode` VARCHAR(12) NOT NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updatedAt` DATETIME(3) NOT NULL,
|
||||
|
||||
UNIQUE INDEX `organizations_inviteCode_key`(`inviteCode`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `organization_members` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`userId` INTEGER NOT NULL,
|
||||
`organizationId` INTEGER NOT NULL,
|
||||
`role` ENUM('admin', 'teacher', 'student') NOT NULL DEFAULT 'student',
|
||||
`joinedAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
|
||||
INDEX `organization_members_organizationId_idx`(`organizationId`),
|
||||
UNIQUE INDEX `organization_members_userId_organizationId_key`(`userId`, `organizationId`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `classes` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`name` VARCHAR(191) NOT NULL,
|
||||
`organizationId` INTEGER NOT NULL,
|
||||
`teacherId` INTEGER NOT NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
|
||||
INDEX `classes_organizationId_idx`(`organizationId`),
|
||||
INDEX `classes_teacherId_idx`(`teacherId`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `class_members` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`userId` INTEGER NOT NULL,
|
||||
`classId` INTEGER NOT NULL,
|
||||
`joinedAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
|
||||
INDEX `class_members_classId_idx`(`classId`),
|
||||
UNIQUE INDEX `class_members_userId_classId_key`(`userId`, `classId`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `assignments` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`title` VARCHAR(191) NOT NULL,
|
||||
`description` TEXT NULL,
|
||||
`classId` INTEGER NOT NULL,
|
||||
`problemSetId` INTEGER NOT NULL,
|
||||
`status` ENUM('active', 'closed', 'draft') NOT NULL DEFAULT 'active',
|
||||
`dueDate` DATETIME(3) NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
|
||||
INDEX `assignments_classId_idx`(`classId`),
|
||||
INDEX `assignments_problemSetId_idx`(`problemSetId`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `assignment_submissions` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`userId` INTEGER NOT NULL,
|
||||
`assignmentId` INTEGER NOT NULL,
|
||||
`score` DOUBLE NULL,
|
||||
`totalProblems` INTEGER NULL,
|
||||
`correctCount` INTEGER NULL,
|
||||
`completedAt` DATETIME(3) NULL,
|
||||
`studyLogIds` JSON NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
|
||||
INDEX `assignment_submissions_assignmentId_idx`(`assignmentId`),
|
||||
UNIQUE INDEX `assignment_submissions_userId_assignmentId_key`(`userId`, `assignmentId`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `organization_members` ADD CONSTRAINT `organization_members_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `organization_members` ADD CONSTRAINT `organization_members_organizationId_fkey` FOREIGN KEY (`organizationId`) REFERENCES `organizations`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `classes` ADD CONSTRAINT `classes_organizationId_fkey` FOREIGN KEY (`organizationId`) REFERENCES `organizations`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `classes` ADD CONSTRAINT `classes_teacherId_fkey` FOREIGN KEY (`teacherId`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `class_members` ADD CONSTRAINT `class_members_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `class_members` ADD CONSTRAINT `class_members_classId_fkey` FOREIGN KEY (`classId`) REFERENCES `classes`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `assignments` ADD CONSTRAINT `assignments_classId_fkey` FOREIGN KEY (`classId`) REFERENCES `classes`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `assignments` ADD CONSTRAINT `assignments_problemSetId_fkey` FOREIGN KEY (`problemSetId`) REFERENCES `problem_sets`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `assignment_submissions` ADD CONSTRAINT `assignment_submissions_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `assignment_submissions` ADD CONSTRAINT `assignment_submissions_assignmentId_fkey` FOREIGN KEY (`assignmentId`) REFERENCES `assignments`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -49,6 +49,23 @@ enum SubscriptionTier {
|
||||
school
|
||||
}
|
||||
|
||||
enum OrgType {
|
||||
school
|
||||
academy
|
||||
}
|
||||
|
||||
enum OrgRole {
|
||||
admin
|
||||
teacher
|
||||
student
|
||||
}
|
||||
|
||||
enum AssignmentStatus {
|
||||
active
|
||||
closed
|
||||
draft
|
||||
}
|
||||
|
||||
// ─── Models ───────────────────────────────────────────────────────
|
||||
|
||||
model User {
|
||||
@@ -69,10 +86,14 @@ model User {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
subjects Subject[]
|
||||
studyLogs StudyLog[]
|
||||
reviewSchedules ReviewSchedule[]
|
||||
skillSnapshots SkillSnapshot[]
|
||||
subjects Subject[]
|
||||
studyLogs StudyLog[]
|
||||
reviewSchedules ReviewSchedule[]
|
||||
skillSnapshots SkillSnapshot[]
|
||||
organizationMembers OrganizationMember[]
|
||||
classMemberships ClassMember[]
|
||||
teacherClasses Class[] @relation("teacherClasses")
|
||||
assignmentSubmissions AssignmentSubmission[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
@@ -109,15 +130,15 @@ model Tag {
|
||||
}
|
||||
|
||||
model StudyLog {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
subjectId Int
|
||||
subject Subject @relation(fields: [subjectId], references: [id])
|
||||
tagId Int?
|
||||
tag Tag? @relation(fields: [tagId], references: [id], onDelete: SetNull)
|
||||
problemId Int?
|
||||
problem Problem? @relation(fields: [problemId], references: [id], onDelete: SetNull)
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
subjectId Int
|
||||
subject Subject @relation(fields: [subjectId], references: [id])
|
||||
tagId Int?
|
||||
tag Tag? @relation(fields: [tagId], references: [id], onDelete: SetNull)
|
||||
problemId Int?
|
||||
problem Problem? @relation(fields: [problemId], references: [id], onDelete: SetNull)
|
||||
|
||||
title String
|
||||
difficulty Float
|
||||
@@ -138,11 +159,11 @@ model StudyLog {
|
||||
}
|
||||
|
||||
model ReviewSchedule {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
studyLogId Int
|
||||
studyLog StudyLog @relation(fields: [studyLogId], references: [id], onDelete: Cascade)
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
studyLogId Int
|
||||
studyLog StudyLog @relation(fields: [studyLogId], references: [id], onDelete: Cascade)
|
||||
|
||||
scheduledAt DateTime
|
||||
reviewedAt DateTime?
|
||||
@@ -151,8 +172,8 @@ model ReviewSchedule {
|
||||
predictedP Float?
|
||||
status ReviewStatus @default(pending)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([userId, status, scheduledAt])
|
||||
@@index([studyLogId])
|
||||
@@ -176,11 +197,107 @@ model ProblemSet {
|
||||
|
||||
problems Problem[]
|
||||
passages Passage[]
|
||||
assignments Assignment[]
|
||||
|
||||
@@unique([year, examType, subjectName])
|
||||
@@map("problem_sets")
|
||||
}
|
||||
|
||||
model Organization {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
type OrgType @default(academy)
|
||||
inviteCode String @unique @db.VarChar(12)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
members OrganizationMember[]
|
||||
classes Class[]
|
||||
|
||||
@@map("organizations")
|
||||
}
|
||||
|
||||
model OrganizationMember {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
organizationId Int
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
role OrgRole @default(student)
|
||||
joinedAt DateTime @default(now())
|
||||
|
||||
@@unique([userId, organizationId])
|
||||
@@index([organizationId])
|
||||
@@map("organization_members")
|
||||
}
|
||||
|
||||
model Class {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
organizationId Int
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
teacherId Int
|
||||
teacher User @relation("teacherClasses", fields: [teacherId], references: [id])
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
members ClassMember[]
|
||||
assignments Assignment[]
|
||||
|
||||
@@index([organizationId])
|
||||
@@index([teacherId])
|
||||
@@map("classes")
|
||||
}
|
||||
|
||||
model ClassMember {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
classId Int
|
||||
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
|
||||
joinedAt DateTime @default(now())
|
||||
|
||||
@@unique([userId, classId])
|
||||
@@index([classId])
|
||||
@@map("class_members")
|
||||
}
|
||||
|
||||
model Assignment {
|
||||
id Int @id @default(autoincrement())
|
||||
title String
|
||||
description String? @db.Text
|
||||
classId Int
|
||||
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
|
||||
problemSetId Int
|
||||
problemSet ProblemSet @relation(fields: [problemSetId], references: [id])
|
||||
status AssignmentStatus @default(active)
|
||||
dueDate DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
submissions AssignmentSubmission[]
|
||||
|
||||
@@index([classId])
|
||||
@@index([problemSetId])
|
||||
@@map("assignments")
|
||||
}
|
||||
|
||||
model AssignmentSubmission {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
assignmentId Int
|
||||
assignment Assignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade)
|
||||
score Float?
|
||||
totalProblems Int?
|
||||
correctCount Int?
|
||||
completedAt DateTime?
|
||||
studyLogIds Json?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([userId, assignmentId])
|
||||
@@index([assignmentId])
|
||||
@@map("assignment_submissions")
|
||||
}
|
||||
|
||||
model Problem {
|
||||
id Int @id @default(autoincrement())
|
||||
problemSetId Int
|
||||
@@ -207,7 +324,7 @@ model Problem {
|
||||
needsReview Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
studyLogs StudyLog[]
|
||||
studyLogs StudyLog[]
|
||||
|
||||
@@unique([problemSetId, number])
|
||||
@@index([problemSetId])
|
||||
@@ -218,27 +335,27 @@ model Problem {
|
||||
/// 평가원 기출의 [N~M] 공통 지문을 담는 컨테이너.
|
||||
/// 여러 Problem 이 같은 Passage 를 공유할 수 있음.
|
||||
model Passage {
|
||||
id Int @id @default(autoincrement())
|
||||
problemSetId Int
|
||||
problemSet ProblemSet @relation(fields: [problemSetId], references: [id], onDelete: Cascade)
|
||||
startNumber Int
|
||||
endNumber Int
|
||||
bodyText String @db.Text
|
||||
imageUrl String?
|
||||
createdAt DateTime @default(now())
|
||||
id Int @id @default(autoincrement())
|
||||
problemSetId Int
|
||||
problemSet ProblemSet @relation(fields: [problemSetId], references: [id], onDelete: Cascade)
|
||||
startNumber Int
|
||||
endNumber Int
|
||||
bodyText String @db.Text
|
||||
imageUrl String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
problems Problem[]
|
||||
problems Problem[]
|
||||
|
||||
@@index([problemSetId])
|
||||
@@map("passages")
|
||||
}
|
||||
|
||||
model SkillSnapshot {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
tagId Int?
|
||||
tag Tag? @relation(fields: [tagId], references: [id], onDelete: SetNull)
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
tagId Int?
|
||||
tag Tag? @relation(fields: [tagId], references: [id], onDelete: SetNull)
|
||||
|
||||
s0 Float
|
||||
lastUpdatedAt DateTime @default(now())
|
||||
|
||||
@@ -5,13 +5,19 @@
|
||||
* Idempotent: re-running just upserts.
|
||||
*/
|
||||
|
||||
import { PrismaClient, Persona, ReviewIntensity } from '@prisma/client';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import {
|
||||
OrgRole,
|
||||
OrgType,
|
||||
Persona,
|
||||
PrismaClient,
|
||||
ReviewIntensity,
|
||||
} from "@prisma/client";
|
||||
import * as bcrypt from "bcrypt";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const DEMO_EMAIL = 'demo@reloop.local';
|
||||
const DEMO_PASSWORD = 'demo1234';
|
||||
const DEMO_EMAIL = "demo@reloop.local";
|
||||
const DEMO_PASSWORD = "demo1234";
|
||||
|
||||
interface SubjectSeed {
|
||||
name: string;
|
||||
@@ -21,34 +27,50 @@ interface SubjectSeed {
|
||||
|
||||
const SUBJECTS: SubjectSeed[] = [
|
||||
{
|
||||
name: '국어',
|
||||
color: '#ef4444',
|
||||
tags: ['문학', '독서(비문학)', '화법과작문', '언어와매체', '고전시가'],
|
||||
name: "국어",
|
||||
color: "#ef4444",
|
||||
tags: ["문학", "독서(비문학)", "화법과작문", "언어와매체", "고전시가"],
|
||||
},
|
||||
{
|
||||
name: '수학',
|
||||
color: '#3b82f6',
|
||||
tags: ['미적분', '확률과통계', '기하', '수1 지수로그', '수1 삼각함수', '수2 미분', '수2 적분'],
|
||||
name: "수학",
|
||||
color: "#3b82f6",
|
||||
tags: [
|
||||
"미적분",
|
||||
"확률과통계",
|
||||
"기하",
|
||||
"수1 지수로그",
|
||||
"수1 삼각함수",
|
||||
"수2 미분",
|
||||
"수2 적분",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '영어',
|
||||
color: '#22c55e',
|
||||
tags: ['문법/어법', '어휘', '빈칸추론', '순서배열', '삽입', '주제/제목', '함축의미'],
|
||||
name: "영어",
|
||||
color: "#22c55e",
|
||||
tags: [
|
||||
"문법/어법",
|
||||
"어휘",
|
||||
"빈칸추론",
|
||||
"순서배열",
|
||||
"삽입",
|
||||
"주제/제목",
|
||||
"함축의미",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '사회탐구',
|
||||
color: '#f59e0b',
|
||||
tags: ['생활과윤리', '사회문화', '한국지리', '세계사'],
|
||||
name: "사회탐구",
|
||||
color: "#f59e0b",
|
||||
tags: ["생활과윤리", "사회문화", "한국지리", "세계사"],
|
||||
},
|
||||
{
|
||||
name: '과학탐구',
|
||||
color: '#8b5cf6',
|
||||
tags: ['물리1', '화학1', '생명과학1', '지구과학1'],
|
||||
name: "과학탐구",
|
||||
color: "#8b5cf6",
|
||||
tags: ["물리1", "화학1", "생명과학1", "지구과학1"],
|
||||
},
|
||||
];
|
||||
|
||||
async function main() {
|
||||
console.log('🌱 ReLoop seed start');
|
||||
console.log("🌱 ReLoop seed start");
|
||||
|
||||
// Demo user
|
||||
const hash = await bcrypt.hash(DEMO_PASSWORD, 10);
|
||||
@@ -58,7 +80,7 @@ async function main() {
|
||||
create: {
|
||||
email: DEMO_EMAIL,
|
||||
password: hash,
|
||||
nickname: '데모',
|
||||
nickname: "데모",
|
||||
persona: Persona.mid,
|
||||
currentGrade: 4,
|
||||
targetGrade: 2,
|
||||
@@ -85,7 +107,54 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
console.log('✅ seed done');
|
||||
const organization = await prisma.organization.upsert({
|
||||
where: { inviteCode: "de1a0001" },
|
||||
update: {
|
||||
name: "ReLoop 데모 학원",
|
||||
type: OrgType.academy,
|
||||
},
|
||||
create: {
|
||||
name: "ReLoop 데모 학원",
|
||||
type: OrgType.academy,
|
||||
inviteCode: "de1a0001",
|
||||
},
|
||||
});
|
||||
console.log(` organization: ${organization.name} (id=${organization.id})`);
|
||||
|
||||
await prisma.organizationMember.upsert({
|
||||
where: {
|
||||
userId_organizationId: {
|
||||
userId: user.id,
|
||||
organizationId: organization.id,
|
||||
},
|
||||
},
|
||||
update: { role: OrgRole.admin },
|
||||
create: {
|
||||
userId: user.id,
|
||||
organizationId: organization.id,
|
||||
role: OrgRole.admin,
|
||||
},
|
||||
});
|
||||
|
||||
const existingDemoClass = await prisma.class.findFirst({
|
||||
where: {
|
||||
organizationId: organization.id,
|
||||
teacherId: user.id,
|
||||
name: "고3 A반",
|
||||
},
|
||||
});
|
||||
const demoClass =
|
||||
existingDemoClass ??
|
||||
(await prisma.class.create({
|
||||
data: {
|
||||
name: "고3 A반",
|
||||
organizationId: organization.id,
|
||||
teacherId: user.id,
|
||||
},
|
||||
}));
|
||||
console.log(` class: ${demoClass.name} (id=${demoClass.id})`);
|
||||
|
||||
console.log("✅ seed done");
|
||||
}
|
||||
|
||||
main()
|
||||
|
||||
@@ -1,26 +1,29 @@
|
||||
import { HttpException, HttpStatus, Module } from '@nestjs/common';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { MeModule } from './me/me.module';
|
||||
import { SubjectsModule } from './subjects/subjects.module';
|
||||
import { TagsModule } from './tags/tags.module';
|
||||
import { StudyLogsModule } from './study-logs/study-logs.module';
|
||||
import { ReviewsModule } from './reviews/reviews.module';
|
||||
import { ForgetModule } from './forget/forget.module';
|
||||
import { DashboardModule } from './dashboard/dashboard.module';
|
||||
import { StatsModule } from './stats/stats.module';
|
||||
import { ProblemSetsModule } from './problem-sets/problem-sets.module';
|
||||
import { HttpException, HttpStatus, Module } from "@nestjs/common";
|
||||
import { APP_GUARD } from "@nestjs/core";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import { ThrottlerGuard, ThrottlerModule } from "@nestjs/throttler";
|
||||
import { PrismaModule } from "./prisma/prisma.module";
|
||||
import { HealthModule } from "./health/health.module";
|
||||
import { AuthModule } from "./auth/auth.module";
|
||||
import { MeModule } from "./me/me.module";
|
||||
import { SubjectsModule } from "./subjects/subjects.module";
|
||||
import { TagsModule } from "./tags/tags.module";
|
||||
import { StudyLogsModule } from "./study-logs/study-logs.module";
|
||||
import { ReviewsModule } from "./reviews/reviews.module";
|
||||
import { ForgetModule } from "./forget/forget.module";
|
||||
import { DashboardModule } from "./dashboard/dashboard.module";
|
||||
import { StatsModule } from "./stats/stats.module";
|
||||
import { ProblemSetsModule } from "./problem-sets/problem-sets.module";
|
||||
import { OrganizationsModule } from "./organizations/organizations.module";
|
||||
import { ClassesModule } from "./classes/classes.module";
|
||||
import { AssignmentsModule } from "./assignments/assignments.module";
|
||||
|
||||
class AppThrottlerGuard extends ThrottlerGuard {
|
||||
protected async throwThrottlingException(): Promise<void> {
|
||||
throw new HttpException(
|
||||
{
|
||||
statusCode: HttpStatus.TOO_MANY_REQUESTS,
|
||||
message: '요청이 너무 많아요. 잠시 후 다시 시도해주세요.',
|
||||
message: "요청이 너무 많아요. 잠시 후 다시 시도해주세요.",
|
||||
},
|
||||
HttpStatus.TOO_MANY_REQUESTS,
|
||||
);
|
||||
@@ -43,6 +46,9 @@ class AppThrottlerGuard extends ThrottlerGuard {
|
||||
DashboardModule,
|
||||
StatsModule,
|
||||
ProblemSetsModule,
|
||||
OrganizationsModule,
|
||||
ClassesModule,
|
||||
AssignmentsModule,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
|
||||
162
backend/src/assignments/assignments.controller.ts
Normal file
162
backend/src/assignments/assignments.controller.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { Type } from "class-transformer";
|
||||
import {
|
||||
IsArray,
|
||||
IsDate,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
} from "class-validator";
|
||||
import { AssignmentStatus } from "@prisma/client";
|
||||
import { CurrentUser } from "../auth/current-user.decorator";
|
||||
import { JwtAuthGuard } from "../auth/jwt-auth.guard";
|
||||
import { AuthUser } from "../auth/jwt.strategy";
|
||||
import { AssignmentsService } from "./assignments.service";
|
||||
|
||||
class CreateAssignmentDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(120)
|
||||
title: string;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
classId: number;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
problemSetId: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Date)
|
||||
@IsDate()
|
||||
dueDate?: Date;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
}
|
||||
|
||||
class ListAssignmentsQuery {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
classId?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(AssignmentStatus)
|
||||
status?: AssignmentStatus;
|
||||
}
|
||||
|
||||
class UpdateAssignmentDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(120)
|
||||
title?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Date)
|
||||
@IsDate()
|
||||
dueDate?: Date;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(AssignmentStatus)
|
||||
status?: AssignmentStatus;
|
||||
}
|
||||
|
||||
class SubmitAssignmentDto {
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
score?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
totalProblems?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
correctCount?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@Type(() => Number)
|
||||
@IsInt({ each: true })
|
||||
studyLogIds?: number[];
|
||||
}
|
||||
|
||||
@Controller("assignments")
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class AssignmentsController {
|
||||
constructor(private readonly svc: AssignmentsService) {}
|
||||
|
||||
@Post()
|
||||
create(@CurrentUser() user: AuthUser, @Body() dto: CreateAssignmentDto) {
|
||||
return this.svc.create(user.id, dto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
list(@CurrentUser() user: AuthUser, @Query() q: ListAssignmentsQuery) {
|
||||
return this.svc.list(user.id, q);
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
one(@CurrentUser() user: AuthUser, @Param("id", ParseIntPipe) id: number) {
|
||||
return this.svc.getOne(user.id, id);
|
||||
}
|
||||
|
||||
@Patch(":id")
|
||||
update(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param("id", ParseIntPipe) id: number,
|
||||
@Body() dto: UpdateAssignmentDto,
|
||||
) {
|
||||
return this.svc.update(user.id, id, dto);
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
remove(@CurrentUser() user: AuthUser, @Param("id", ParseIntPipe) id: number) {
|
||||
return this.svc.remove(user.id, id);
|
||||
}
|
||||
|
||||
@Get(":id/submissions")
|
||||
submissions(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param("id", ParseIntPipe) id: number,
|
||||
) {
|
||||
return this.svc.listSubmissions(user.id, id);
|
||||
}
|
||||
|
||||
@Post(":id/submit")
|
||||
submit(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param("id", ParseIntPipe) id: number,
|
||||
@Body() dto: SubmitAssignmentDto,
|
||||
) {
|
||||
return this.svc.submit(user.id, id, dto);
|
||||
}
|
||||
}
|
||||
12
backend/src/assignments/assignments.module.ts
Normal file
12
backend/src/assignments/assignments.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { PrismaModule } from "../prisma/prisma.module";
|
||||
import { OrganizationsModule } from "../organizations/organizations.module";
|
||||
import { AssignmentsController } from "./assignments.controller";
|
||||
import { AssignmentsService } from "./assignments.service";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, OrganizationsModule],
|
||||
controllers: [AssignmentsController],
|
||||
providers: [AssignmentsService],
|
||||
})
|
||||
export class AssignmentsModule {}
|
||||
384
backend/src/assignments/assignments.service.ts
Normal file
384
backend/src/assignments/assignments.service.ts
Normal file
@@ -0,0 +1,384 @@
|
||||
import {
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { AssignmentStatus, OrgRole } from "@prisma/client";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { OrganizationsService } from "../organizations/organizations.service";
|
||||
import {
|
||||
ORG_ADMIN_ROLES,
|
||||
ORG_TEACHER_OR_ADMIN_ROLES,
|
||||
} from "../organizations/org-role.guard";
|
||||
|
||||
@Injectable()
|
||||
export class AssignmentsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly organizations: OrganizationsService,
|
||||
) {}
|
||||
|
||||
async create(
|
||||
userId: number,
|
||||
data: {
|
||||
title: string;
|
||||
classId: number;
|
||||
problemSetId: number;
|
||||
dueDate?: Date;
|
||||
description?: string;
|
||||
},
|
||||
) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const classRoom = await tx.class.findUnique({
|
||||
where: { id: data.classId },
|
||||
select: { id: true, organizationId: true },
|
||||
});
|
||||
if (!classRoom) throw new NotFoundException("class");
|
||||
|
||||
await this.organizations.assertOrgRole(
|
||||
userId,
|
||||
classRoom.organizationId,
|
||||
ORG_TEACHER_OR_ADMIN_ROLES,
|
||||
tx,
|
||||
);
|
||||
|
||||
const problemSet = await tx.problemSet.findUnique({
|
||||
where: { id: data.problemSetId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!problemSet) throw new NotFoundException("problemSet");
|
||||
|
||||
return tx.assignment.create({
|
||||
data: {
|
||||
title: data.title.trim(),
|
||||
description: normalizeOptionalText(data.description),
|
||||
classId: data.classId,
|
||||
problemSetId: data.problemSetId,
|
||||
dueDate: data.dueDate,
|
||||
},
|
||||
include: {
|
||||
class: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
organizationId: true,
|
||||
},
|
||||
},
|
||||
problemSet: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
year: true,
|
||||
examType: true,
|
||||
subjectName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
list(userId: number, opts: { classId?: number; status?: AssignmentStatus }) {
|
||||
return this.prisma.assignment.findMany({
|
||||
where: {
|
||||
...(opts.classId !== undefined && { classId: opts.classId }),
|
||||
...(opts.status !== undefined && { status: opts.status }),
|
||||
class: {
|
||||
OR: [
|
||||
{ teacherId: userId },
|
||||
{ members: { some: { userId } } },
|
||||
{
|
||||
organization: {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
role: OrgRole.admin,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
include: {
|
||||
class: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
organizationId: true,
|
||||
teacher: {
|
||||
select: { id: true, email: true, nickname: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
problemSet: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
year: true,
|
||||
examType: true,
|
||||
subjectName: true,
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: { submissions: true },
|
||||
},
|
||||
},
|
||||
orderBy: [{ createdAt: "desc" }],
|
||||
});
|
||||
}
|
||||
|
||||
async getOne(userId: number, assignmentId: number) {
|
||||
const assignment = await this.prisma.assignment.findUnique({
|
||||
where: { id: assignmentId },
|
||||
include: {
|
||||
class: {
|
||||
include: {
|
||||
teacher: {
|
||||
select: { id: true, email: true, nickname: true },
|
||||
},
|
||||
members: {
|
||||
select: { userId: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
problemSet: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
year: true,
|
||||
examType: true,
|
||||
subjectName: true,
|
||||
},
|
||||
},
|
||||
submissions: {
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, email: true, nickname: true },
|
||||
},
|
||||
},
|
||||
orderBy: [{ completedAt: "desc" }, { createdAt: "desc" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!assignment) throw new NotFoundException("assignment");
|
||||
|
||||
const access = await this.getAssignmentAccess(userId, assignment);
|
||||
if (access === "teacher") {
|
||||
return assignment;
|
||||
}
|
||||
|
||||
const { members, ...classInfo } = assignment.class;
|
||||
|
||||
return {
|
||||
...assignment,
|
||||
class: classInfo,
|
||||
submissions: assignment.submissions.filter(
|
||||
(submission) => submission.userId === userId,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async update(
|
||||
userId: number,
|
||||
assignmentId: number,
|
||||
data: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
dueDate?: Date | null;
|
||||
status?: AssignmentStatus;
|
||||
},
|
||||
) {
|
||||
const assignment = await this.prisma.assignment.findUnique({
|
||||
where: { id: assignmentId },
|
||||
include: {
|
||||
class: {
|
||||
select: { organizationId: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!assignment) throw new NotFoundException("assignment");
|
||||
|
||||
await this.organizations.assertOrgRole(
|
||||
userId,
|
||||
assignment.class.organizationId,
|
||||
ORG_TEACHER_OR_ADMIN_ROLES,
|
||||
);
|
||||
|
||||
return this.prisma.assignment.update({
|
||||
where: { id: assignmentId },
|
||||
data: {
|
||||
...(data.title !== undefined && { title: data.title.trim() }),
|
||||
...(data.description !== undefined && {
|
||||
description: normalizeOptionalText(data.description),
|
||||
}),
|
||||
...(data.dueDate !== undefined && { dueDate: data.dueDate }),
|
||||
...(data.status !== undefined && { status: data.status }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async remove(userId: number, assignmentId: number) {
|
||||
const assignment = await this.prisma.assignment.findUnique({
|
||||
where: { id: assignmentId },
|
||||
include: {
|
||||
class: {
|
||||
select: { organizationId: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!assignment) throw new NotFoundException("assignment");
|
||||
|
||||
await this.organizations.assertOrgRole(
|
||||
userId,
|
||||
assignment.class.organizationId,
|
||||
ORG_ADMIN_ROLES,
|
||||
);
|
||||
|
||||
await this.prisma.assignment.delete({ where: { id: assignmentId } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async listSubmissions(userId: number, assignmentId: number) {
|
||||
const assignment = await this.prisma.assignment.findUnique({
|
||||
where: { id: assignmentId },
|
||||
include: {
|
||||
class: {
|
||||
select: { organizationId: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!assignment) throw new NotFoundException("assignment");
|
||||
|
||||
await this.organizations.assertOrgRole(
|
||||
userId,
|
||||
assignment.class.organizationId,
|
||||
ORG_TEACHER_OR_ADMIN_ROLES,
|
||||
);
|
||||
|
||||
return this.prisma.assignmentSubmission.findMany({
|
||||
where: { assignmentId },
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, email: true, nickname: true },
|
||||
},
|
||||
},
|
||||
orderBy: [{ completedAt: "desc" }, { createdAt: "desc" }],
|
||||
});
|
||||
}
|
||||
|
||||
async submit(
|
||||
userId: number,
|
||||
assignmentId: number,
|
||||
data: {
|
||||
score?: number;
|
||||
totalProblems?: number;
|
||||
correctCount?: number;
|
||||
studyLogIds?: number[];
|
||||
},
|
||||
) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const assignment = await tx.assignment.findUnique({
|
||||
where: { id: assignmentId },
|
||||
include: {
|
||||
class: {
|
||||
select: {
|
||||
id: true,
|
||||
members: {
|
||||
where: { userId },
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!assignment) throw new NotFoundException("assignment");
|
||||
|
||||
if (assignment.status !== AssignmentStatus.active) {
|
||||
throw new ForbiddenException("assignment is not active");
|
||||
}
|
||||
|
||||
if (assignment.class.members.length === 0) {
|
||||
throw new ForbiddenException("class membership required");
|
||||
}
|
||||
|
||||
if (data.studyLogIds && data.studyLogIds.length > 0) {
|
||||
const ownedStudyLogCount = await tx.studyLog.count({
|
||||
where: {
|
||||
userId,
|
||||
id: { in: data.studyLogIds },
|
||||
},
|
||||
});
|
||||
if (ownedStudyLogCount !== data.studyLogIds.length) {
|
||||
throw new ForbiddenException("studyLogIds must belong to the caller");
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
|
||||
return tx.assignmentSubmission.upsert({
|
||||
where: {
|
||||
userId_assignmentId: {
|
||||
userId,
|
||||
assignmentId,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
score: data.score ?? null,
|
||||
totalProblems: data.totalProblems ?? null,
|
||||
correctCount: data.correctCount ?? null,
|
||||
studyLogIds: data.studyLogIds ?? null,
|
||||
completedAt: now,
|
||||
},
|
||||
create: {
|
||||
userId,
|
||||
assignmentId,
|
||||
score: data.score ?? null,
|
||||
totalProblems: data.totalProblems ?? null,
|
||||
correctCount: data.correctCount ?? null,
|
||||
studyLogIds: data.studyLogIds ?? null,
|
||||
completedAt: now,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async getAssignmentAccess(
|
||||
userId: number,
|
||||
assignment: {
|
||||
class: {
|
||||
teacherId: number;
|
||||
organizationId: number;
|
||||
members: Array<{ userId: number }>;
|
||||
};
|
||||
},
|
||||
) {
|
||||
if (assignment.class.teacherId === userId) {
|
||||
return "teacher" as const;
|
||||
}
|
||||
|
||||
const elevated = await this.prisma.organizationMember.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
organizationId: assignment.class.organizationId,
|
||||
role: { in: [...ORG_TEACHER_OR_ADMIN_ROLES] },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (elevated) {
|
||||
return "teacher" as const;
|
||||
}
|
||||
|
||||
if (assignment.class.members.some((member) => member.userId === userId)) {
|
||||
return "student" as const;
|
||||
}
|
||||
|
||||
throw new ForbiddenException("assignment access denied");
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeOptionalText(value: string | undefined): string | null {
|
||||
if (value === undefined) return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
109
backend/src/classes/classes.controller.ts
Normal file
109
backend/src/classes/classes.controller.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { Type } from "class-transformer";
|
||||
import {
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
} from "class-validator";
|
||||
import { CurrentUser } from "../auth/current-user.decorator";
|
||||
import { JwtAuthGuard } from "../auth/jwt-auth.guard";
|
||||
import { AuthUser } from "../auth/jwt.strategy";
|
||||
import { ClassesService } from "./classes.service";
|
||||
|
||||
class CreateClassDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(80)
|
||||
name: string;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
organizationId: number;
|
||||
}
|
||||
|
||||
class UpdateClassDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(80)
|
||||
name?: string;
|
||||
}
|
||||
|
||||
class ListClassesQuery {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
organizationId?: number;
|
||||
}
|
||||
|
||||
class AddClassMemberDto {
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
userId: number;
|
||||
}
|
||||
|
||||
@Controller("classes")
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ClassesController {
|
||||
constructor(private readonly svc: ClassesService) {}
|
||||
|
||||
@Post()
|
||||
create(@CurrentUser() user: AuthUser, @Body() dto: CreateClassDto) {
|
||||
return this.svc.create(user.id, dto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
list(@CurrentUser() user: AuthUser, @Query() q: ListClassesQuery) {
|
||||
return this.svc.list(user.id, q);
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
one(@CurrentUser() user: AuthUser, @Param("id", ParseIntPipe) id: number) {
|
||||
return this.svc.getOne(user.id, id);
|
||||
}
|
||||
|
||||
@Patch(":id")
|
||||
update(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param("id", ParseIntPipe) id: number,
|
||||
@Body() dto: UpdateClassDto,
|
||||
) {
|
||||
return this.svc.update(user.id, id, dto);
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
remove(@CurrentUser() user: AuthUser, @Param("id", ParseIntPipe) id: number) {
|
||||
return this.svc.remove(user.id, id);
|
||||
}
|
||||
|
||||
@Post(":id/members")
|
||||
addMember(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param("id", ParseIntPipe) id: number,
|
||||
@Body() dto: AddClassMemberDto,
|
||||
) {
|
||||
return this.svc.addMember(user.id, id, dto.userId);
|
||||
}
|
||||
|
||||
@Delete(":id/members/:userId")
|
||||
removeMember(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param("id", ParseIntPipe) id: number,
|
||||
@Param("userId", ParseIntPipe) memberUserId: number,
|
||||
) {
|
||||
return this.svc.removeMember(user.id, id, memberUserId);
|
||||
}
|
||||
}
|
||||
12
backend/src/classes/classes.module.ts
Normal file
12
backend/src/classes/classes.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { PrismaModule } from "../prisma/prisma.module";
|
||||
import { OrganizationsModule } from "../organizations/organizations.module";
|
||||
import { ClassesController } from "./classes.controller";
|
||||
import { ClassesService } from "./classes.service";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, OrganizationsModule],
|
||||
controllers: [ClassesController],
|
||||
providers: [ClassesService],
|
||||
})
|
||||
export class ClassesModule {}
|
||||
263
backend/src/classes/classes.service.ts
Normal file
263
backend/src/classes/classes.service.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
import {
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { OrgRole } from "@prisma/client";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { OrganizationsService } from "../organizations/organizations.service";
|
||||
import {
|
||||
ORG_ADMIN_ROLES,
|
||||
ORG_TEACHER_OR_ADMIN_ROLES,
|
||||
} from "../organizations/org-role.guard";
|
||||
|
||||
@Injectable()
|
||||
export class ClassesService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly organizations: OrganizationsService,
|
||||
) {}
|
||||
|
||||
async create(userId: number, data: { name: string; organizationId: number }) {
|
||||
await this.organizations.assertOrgRole(
|
||||
userId,
|
||||
data.organizationId,
|
||||
ORG_TEACHER_OR_ADMIN_ROLES,
|
||||
);
|
||||
|
||||
return this.prisma.class.create({
|
||||
data: {
|
||||
name: data.name.trim(),
|
||||
organizationId: data.organizationId,
|
||||
teacherId: userId,
|
||||
},
|
||||
include: {
|
||||
teacher: {
|
||||
select: { id: true, email: true, nickname: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
list(userId: number, opts: { organizationId?: number }) {
|
||||
return this.prisma.class.findMany({
|
||||
where: {
|
||||
...(opts.organizationId !== undefined && {
|
||||
organizationId: opts.organizationId,
|
||||
}),
|
||||
OR: [
|
||||
{ teacherId: userId },
|
||||
{ members: { some: { userId } } },
|
||||
{
|
||||
organization: {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
role: OrgRole.admin,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
include: {
|
||||
teacher: {
|
||||
select: { id: true, email: true, nickname: true },
|
||||
},
|
||||
organization: {
|
||||
select: { id: true, name: true, type: true },
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
members: true,
|
||||
assignments: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ createdAt: "desc" }],
|
||||
});
|
||||
}
|
||||
|
||||
async getOne(userId: number, classId: number) {
|
||||
const classRoom = await this.prisma.class.findUnique({
|
||||
where: { id: classId },
|
||||
include: {
|
||||
teacher: {
|
||||
select: { id: true, email: true, nickname: true },
|
||||
},
|
||||
organization: {
|
||||
select: { id: true, name: true, type: true },
|
||||
},
|
||||
members: {
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, email: true, nickname: true },
|
||||
},
|
||||
},
|
||||
orderBy: { joinedAt: "asc" },
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
members: true,
|
||||
assignments: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!classRoom) throw new NotFoundException("class");
|
||||
|
||||
await this.assertCanViewClass(userId, classRoom);
|
||||
|
||||
return {
|
||||
...classRoom,
|
||||
assignmentCount: classRoom._count.assignments,
|
||||
membersCount: classRoom._count.members,
|
||||
};
|
||||
}
|
||||
|
||||
async update(userId: number, classId: number, data: { name?: string }) {
|
||||
const classRoom = await this.prisma.class.findUnique({
|
||||
where: { id: classId },
|
||||
select: { id: true, teacherId: true, organizationId: true },
|
||||
});
|
||||
if (!classRoom) throw new NotFoundException("class");
|
||||
|
||||
if (classRoom.teacherId !== userId) {
|
||||
await this.organizations.assertOrgRole(
|
||||
userId,
|
||||
classRoom.organizationId,
|
||||
ORG_ADMIN_ROLES,
|
||||
);
|
||||
}
|
||||
|
||||
return this.prisma.class.update({
|
||||
where: { id: classId },
|
||||
data: {
|
||||
...(data.name !== undefined && { name: data.name.trim() }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async remove(userId: number, classId: number) {
|
||||
const classRoom = await this.prisma.class.findUnique({
|
||||
where: { id: classId },
|
||||
select: { id: true, organizationId: true },
|
||||
});
|
||||
if (!classRoom) throw new NotFoundException("class");
|
||||
|
||||
await this.organizations.assertOrgRole(
|
||||
userId,
|
||||
classRoom.organizationId,
|
||||
ORG_ADMIN_ROLES,
|
||||
);
|
||||
|
||||
await this.prisma.class.delete({ where: { id: classId } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async addMember(userId: number, classId: number, targetUserId: number) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const classRoom = await tx.class.findUnique({
|
||||
where: { id: classId },
|
||||
select: { id: true, organizationId: true },
|
||||
});
|
||||
if (!classRoom) throw new NotFoundException("class");
|
||||
|
||||
await this.organizations.assertOrgRole(
|
||||
userId,
|
||||
classRoom.organizationId,
|
||||
ORG_TEACHER_OR_ADMIN_ROLES,
|
||||
tx,
|
||||
);
|
||||
|
||||
await this.organizations.assertOrganizationMember(
|
||||
targetUserId,
|
||||
classRoom.organizationId,
|
||||
tx,
|
||||
);
|
||||
|
||||
const existing = await tx.classMember.findUnique({
|
||||
where: {
|
||||
userId_classId: {
|
||||
userId: targetUserId,
|
||||
classId,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (existing) throw new ConflictException("class member already exists");
|
||||
|
||||
return tx.classMember.create({
|
||||
data: {
|
||||
userId: targetUserId,
|
||||
classId,
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, email: true, nickname: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async removeMember(userId: number, classId: number, targetUserId: number) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const classRoom = await tx.class.findUnique({
|
||||
where: { id: classId },
|
||||
select: { id: true, organizationId: true },
|
||||
});
|
||||
if (!classRoom) throw new NotFoundException("class");
|
||||
|
||||
await this.organizations.assertOrgRole(
|
||||
userId,
|
||||
classRoom.organizationId,
|
||||
ORG_TEACHER_OR_ADMIN_ROLES,
|
||||
tx,
|
||||
);
|
||||
|
||||
const existing = await tx.classMember.findUnique({
|
||||
where: {
|
||||
userId_classId: {
|
||||
userId: targetUserId,
|
||||
classId,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!existing) throw new NotFoundException("class member");
|
||||
|
||||
await tx.classMember.delete({ where: { id: existing.id } });
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
|
||||
private async assertCanViewClass(
|
||||
userId: number,
|
||||
classRoom: {
|
||||
teacherId: number;
|
||||
organizationId: number;
|
||||
members: Array<{ userId: number }>;
|
||||
},
|
||||
) {
|
||||
if (classRoom.teacherId === userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (classRoom.members.some((member) => member.userId === userId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const elevatedRole = await this.prisma.organizationMember.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
organizationId: classRoom.organizationId,
|
||||
role: { in: [...ORG_TEACHER_OR_ADMIN_ROLES] },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!elevatedRole) {
|
||||
throw new ForbiddenException("class access denied");
|
||||
}
|
||||
}
|
||||
}
|
||||
11
backend/src/organizations/org-role.guard.ts
Normal file
11
backend/src/organizations/org-role.guard.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { OrgRole } from "@prisma/client";
|
||||
|
||||
// Phase 9.1 keeps org-role checks explicit in the service layer instead of
|
||||
// metadata-driven guards. These shared role lists keep the checks consistent.
|
||||
export const ORG_ADMIN_ROLES = [OrgRole.admin] as const;
|
||||
export const ORG_TEACHER_OR_ADMIN_ROLES = [
|
||||
OrgRole.admin,
|
||||
OrgRole.teacher,
|
||||
] as const;
|
||||
|
||||
export type AllowedOrgRole = (typeof ORG_TEACHER_OR_ADMIN_ROLES)[number];
|
||||
145
backend/src/organizations/organizations.controller.ts
Normal file
145
backend/src/organizations/organizations.controller.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { Type } from "class-transformer";
|
||||
import {
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
} from "class-validator";
|
||||
import { OrgRole, OrgType } from "@prisma/client";
|
||||
import { CurrentUser } from "../auth/current-user.decorator";
|
||||
import { JwtAuthGuard } from "../auth/jwt-auth.guard";
|
||||
import { AuthUser } from "../auth/jwt.strategy";
|
||||
import { OrganizationsService } from "./organizations.service";
|
||||
|
||||
class CreateOrganizationDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(80)
|
||||
name: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(OrgType)
|
||||
type?: OrgType;
|
||||
}
|
||||
|
||||
class UpdateOrganizationDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(80)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(OrgType)
|
||||
type?: OrgType;
|
||||
}
|
||||
|
||||
class JoinOrganizationDto {
|
||||
@IsString()
|
||||
@Matches(/^[a-fA-F0-9]{8}$/)
|
||||
inviteCode: string;
|
||||
}
|
||||
|
||||
class AddOrganizationMemberDto {
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
userId: number;
|
||||
|
||||
@IsEnum(OrgRole)
|
||||
role: OrgRole;
|
||||
}
|
||||
|
||||
class UpdateOrganizationMemberDto {
|
||||
@IsEnum(OrgRole)
|
||||
role: OrgRole;
|
||||
}
|
||||
|
||||
@Controller("organizations")
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class OrganizationsController {
|
||||
constructor(private readonly svc: OrganizationsService) {}
|
||||
|
||||
@Post()
|
||||
create(@CurrentUser() user: AuthUser, @Body() dto: CreateOrganizationDto) {
|
||||
return this.svc.create(user.id, dto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
list(@CurrentUser() user: AuthUser) {
|
||||
return this.svc.list(user.id);
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
one(@CurrentUser() user: AuthUser, @Param("id", ParseIntPipe) id: number) {
|
||||
return this.svc.getOne(user.id, id);
|
||||
}
|
||||
|
||||
@Patch(":id")
|
||||
update(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param("id", ParseIntPipe) id: number,
|
||||
@Body() dto: UpdateOrganizationDto,
|
||||
) {
|
||||
return this.svc.update(user.id, id, dto);
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
remove(@CurrentUser() user: AuthUser, @Param("id", ParseIntPipe) id: number) {
|
||||
return this.svc.remove(user.id, id);
|
||||
}
|
||||
|
||||
@Post("join")
|
||||
join(@CurrentUser() user: AuthUser, @Body() dto: JoinOrganizationDto) {
|
||||
return this.svc.join(user.id, dto.inviteCode.toLowerCase());
|
||||
}
|
||||
|
||||
@Post(":id/members")
|
||||
addMember(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param("id", ParseIntPipe) id: number,
|
||||
@Body() dto: AddOrganizationMemberDto,
|
||||
) {
|
||||
return this.svc.addMember(user.id, id, dto);
|
||||
}
|
||||
|
||||
@Patch(":id/members/:memberId")
|
||||
updateMemberRole(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param("id", ParseIntPipe) id: number,
|
||||
@Param("memberId", ParseIntPipe) memberId: number,
|
||||
@Body() dto: UpdateOrganizationMemberDto,
|
||||
) {
|
||||
return this.svc.updateMemberRole(user.id, id, memberId, dto.role);
|
||||
}
|
||||
|
||||
@Delete(":id/members/:memberId")
|
||||
removeMember(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param("id", ParseIntPipe) id: number,
|
||||
@Param("memberId", ParseIntPipe) memberId: number,
|
||||
) {
|
||||
return this.svc.removeMember(user.id, id, memberId);
|
||||
}
|
||||
|
||||
@Post(":id/regenerate-code")
|
||||
regenerateCode(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param("id", ParseIntPipe) id: number,
|
||||
) {
|
||||
return this.svc.regenerateInviteCode(user.id, id);
|
||||
}
|
||||
}
|
||||
12
backend/src/organizations/organizations.module.ts
Normal file
12
backend/src/organizations/organizations.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { PrismaModule } from "../prisma/prisma.module";
|
||||
import { OrganizationsController } from "./organizations.controller";
|
||||
import { OrganizationsService } from "./organizations.service";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [OrganizationsController],
|
||||
providers: [OrganizationsService],
|
||||
exports: [OrganizationsService],
|
||||
})
|
||||
export class OrganizationsModule {}
|
||||
369
backend/src/organizations/organizations.service.ts
Normal file
369
backend/src/organizations/organizations.service.ts
Normal file
@@ -0,0 +1,369 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { OrgRole, OrgType, Prisma } from "@prisma/client";
|
||||
import { randomBytes } from "crypto";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { ORG_ADMIN_ROLES, ORG_TEACHER_OR_ADMIN_ROLES } from "./org-role.guard";
|
||||
|
||||
type DbClient = PrismaService | Prisma.TransactionClient;
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async create(userId: number, data: { name: string; type?: OrgType }) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const organization = await tx.organization.create({
|
||||
data: {
|
||||
name: data.name.trim(),
|
||||
type: data.type ?? OrgType.academy,
|
||||
inviteCode: await this.generateInviteCode(tx),
|
||||
},
|
||||
});
|
||||
|
||||
await tx.organizationMember.create({
|
||||
data: {
|
||||
userId,
|
||||
organizationId: organization.id,
|
||||
role: OrgRole.admin,
|
||||
},
|
||||
});
|
||||
|
||||
return organization;
|
||||
});
|
||||
}
|
||||
|
||||
list(userId: number) {
|
||||
return this.prisma.organizationMember
|
||||
.findMany({
|
||||
where: { userId },
|
||||
orderBy: [{ joinedAt: "desc" }],
|
||||
select: {
|
||||
id: true,
|
||||
role: true,
|
||||
joinedAt: true,
|
||||
organization: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
type: true,
|
||||
inviteCode: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
_count: {
|
||||
select: {
|
||||
members: true,
|
||||
classes: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
.then((memberships) =>
|
||||
memberships.map((membership) => ({
|
||||
...membership,
|
||||
organization: {
|
||||
...membership.organization,
|
||||
inviteCode:
|
||||
membership.role === OrgRole.student
|
||||
? null
|
||||
: membership.organization.inviteCode,
|
||||
},
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
async getOne(userId: number, organizationId: number) {
|
||||
const membership = await this.prisma.organizationMember.findUnique({
|
||||
where: {
|
||||
userId_organizationId: {
|
||||
userId,
|
||||
organizationId,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
role: true,
|
||||
organization: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
type: true,
|
||||
inviteCode: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
_count: {
|
||||
select: {
|
||||
members: true,
|
||||
classes: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) throw new NotFoundException("organization");
|
||||
|
||||
return {
|
||||
...membership.organization,
|
||||
inviteCode:
|
||||
membership.role === OrgRole.student
|
||||
? null
|
||||
: membership.organization.inviteCode,
|
||||
myRole: membership.role,
|
||||
membersCount: membership.organization._count.members,
|
||||
classesCount: membership.organization._count.classes,
|
||||
};
|
||||
}
|
||||
|
||||
async update(
|
||||
userId: number,
|
||||
organizationId: number,
|
||||
data: { name?: string; type?: OrgType },
|
||||
) {
|
||||
await this.assertOrgRole(userId, organizationId, ORG_ADMIN_ROLES);
|
||||
|
||||
return this.prisma.organization.update({
|
||||
where: { id: organizationId },
|
||||
data: {
|
||||
...(data.name !== undefined && { name: data.name.trim() }),
|
||||
...(data.type !== undefined && { type: data.type }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async remove(userId: number, organizationId: number) {
|
||||
await this.assertOrgRole(userId, organizationId, ORG_ADMIN_ROLES);
|
||||
await this.prisma.organization.delete({ where: { id: organizationId } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async join(userId: number, inviteCode: string) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const organization = await tx.organization.findUnique({
|
||||
where: { inviteCode },
|
||||
});
|
||||
if (!organization) throw new NotFoundException("organization");
|
||||
|
||||
const membership = await tx.organizationMember.upsert({
|
||||
where: {
|
||||
userId_organizationId: {
|
||||
userId,
|
||||
organizationId: organization.id,
|
||||
},
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
userId,
|
||||
organizationId: organization.id,
|
||||
role: OrgRole.student,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
role: true,
|
||||
joinedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...membership,
|
||||
organization,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async addMember(
|
||||
userId: number,
|
||||
organizationId: number,
|
||||
data: { userId: number; role: OrgRole },
|
||||
) {
|
||||
const requester = await this.assertOrgRole(
|
||||
userId,
|
||||
organizationId,
|
||||
ORG_TEACHER_OR_ADMIN_ROLES,
|
||||
);
|
||||
|
||||
if (requester.role !== OrgRole.admin && data.role !== OrgRole.student) {
|
||||
throw new ForbiddenException("teacher can only add student members");
|
||||
}
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
await this.requireUser(tx, data.userId);
|
||||
|
||||
const existing = await tx.organizationMember.findUnique({
|
||||
where: {
|
||||
userId_organizationId: {
|
||||
userId: data.userId,
|
||||
organizationId,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (existing) {
|
||||
throw new ConflictException("member already exists");
|
||||
}
|
||||
|
||||
return tx.organizationMember.create({
|
||||
data: {
|
||||
userId: data.userId,
|
||||
organizationId,
|
||||
role: data.role,
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
nickname: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async updateMemberRole(
|
||||
userId: number,
|
||||
organizationId: number,
|
||||
memberId: number,
|
||||
role: OrgRole,
|
||||
) {
|
||||
await this.assertOrgRole(userId, organizationId, ORG_ADMIN_ROLES);
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const member = await tx.organizationMember.findFirst({
|
||||
where: { id: memberId, organizationId },
|
||||
});
|
||||
if (!member) throw new NotFoundException("member");
|
||||
|
||||
if (member.role === OrgRole.admin && role !== OrgRole.admin) {
|
||||
await this.assertAnotherAdminExists(tx, organizationId, member.id);
|
||||
}
|
||||
|
||||
return tx.organizationMember.update({
|
||||
where: { id: memberId },
|
||||
data: { role },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async removeMember(userId: number, organizationId: number, memberId: number) {
|
||||
await this.assertOrgRole(userId, organizationId, ORG_ADMIN_ROLES);
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const member = await tx.organizationMember.findFirst({
|
||||
where: { id: memberId, organizationId },
|
||||
});
|
||||
if (!member) throw new NotFoundException("member");
|
||||
|
||||
if (member.role === OrgRole.admin) {
|
||||
await this.assertAnotherAdminExists(tx, organizationId, member.id);
|
||||
}
|
||||
|
||||
await tx.organizationMember.delete({ where: { id: memberId } });
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
|
||||
async regenerateInviteCode(userId: number, organizationId: number) {
|
||||
await this.assertOrgRole(userId, organizationId, ORG_ADMIN_ROLES);
|
||||
|
||||
return this.prisma.organization.update({
|
||||
where: { id: organizationId },
|
||||
data: {
|
||||
inviteCode: await this.generateInviteCode(this.prisma),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
inviteCode: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async assertOrgRole(
|
||||
userId: number,
|
||||
organizationId: number,
|
||||
roles: readonly OrgRole[],
|
||||
db: DbClient = this.prisma,
|
||||
) {
|
||||
const membership = await db.organizationMember.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
organizationId,
|
||||
role: { in: [...roles] },
|
||||
},
|
||||
});
|
||||
if (!membership) {
|
||||
throw new ForbiddenException("organization role is insufficient");
|
||||
}
|
||||
return membership;
|
||||
}
|
||||
|
||||
async assertOrganizationMember(
|
||||
userId: number,
|
||||
organizationId: number,
|
||||
db: DbClient = this.prisma,
|
||||
) {
|
||||
const membership = await db.organizationMember.findUnique({
|
||||
where: {
|
||||
userId_organizationId: {
|
||||
userId,
|
||||
organizationId,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!membership) {
|
||||
throw new ForbiddenException("organization membership required");
|
||||
}
|
||||
return membership;
|
||||
}
|
||||
|
||||
private async assertAnotherAdminExists(
|
||||
db: DbClient,
|
||||
organizationId: number,
|
||||
excludedMemberId: number,
|
||||
) {
|
||||
const adminCount = await db.organizationMember.count({
|
||||
where: {
|
||||
organizationId,
|
||||
role: OrgRole.admin,
|
||||
id: { not: excludedMemberId },
|
||||
},
|
||||
});
|
||||
|
||||
if (adminCount === 0) {
|
||||
throw new BadRequestException(
|
||||
"organization must keep at least one admin",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async requireUser(db: DbClient, userId: number) {
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!user) throw new NotFoundException("user");
|
||||
return user;
|
||||
}
|
||||
|
||||
private async generateInviteCode(db: DbClient) {
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
const inviteCode = randomBytes(4).toString("hex");
|
||||
const existing = await db.organization.findUnique({
|
||||
where: { inviteCode },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!existing) {
|
||||
return inviteCode;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("failed to generate unique invite code");
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,16 @@ import {
|
||||
NotFoundException,
|
||||
ForbiddenException,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma, Persona, ReviewIntensity, StudyResult } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { PersonaForgetService } from '../forget/persona-forget.service';
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
AssignmentStatus,
|
||||
Prisma,
|
||||
Persona,
|
||||
ReviewIntensity,
|
||||
StudyResult,
|
||||
} from "@prisma/client";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { PersonaForgetService } from "../forget/persona-forget.service";
|
||||
|
||||
export interface CreateStudyLogInput {
|
||||
subjectId: number;
|
||||
@@ -55,7 +61,9 @@ export class StudyLogsService {
|
||||
|
||||
async create(userId: number, input: CreateStudyLogInput) {
|
||||
await this.validateCreateInput(this.prisma, userId, input);
|
||||
const user = await this.prisma.user.findUniqueOrThrow({ where: { id: userId } });
|
||||
const user = await this.prisma.user.findUniqueOrThrow({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
return this.createInTransaction(tx, userId, user, input);
|
||||
@@ -67,10 +75,10 @@ export class StudyLogsService {
|
||||
const problemSet = await tx.problemSet.findUnique({
|
||||
where: { id: input.problemSetId },
|
||||
include: {
|
||||
problems: { orderBy: { number: 'asc' } },
|
||||
problems: { orderBy: { number: "asc" } },
|
||||
},
|
||||
});
|
||||
if (!problemSet) throw new NotFoundException('problemSet');
|
||||
if (!problemSet) throw new NotFoundException("problemSet");
|
||||
|
||||
const user = await tx.user.findUniqueOrThrow({ where: { id: userId } });
|
||||
const subject = await tx.subject.upsert({
|
||||
@@ -90,7 +98,9 @@ export class StudyLogsService {
|
||||
|
||||
const tagCache = new Map<string, { id: number }>();
|
||||
|
||||
const problemById = new Map(problemSet.problems.map((problem) => [problem.id, problem]));
|
||||
const problemById = new Map(
|
||||
problemSet.problems.map((problem) => [problem.id, problem]),
|
||||
);
|
||||
const seenProblemIds = new Set<number>();
|
||||
let correct = 0;
|
||||
let incorrect = 0;
|
||||
@@ -105,13 +115,15 @@ export class StudyLogsService {
|
||||
pageImageUrl: string | null;
|
||||
chosenAnswer: number | null;
|
||||
correctAnswer: number | null;
|
||||
result: StudyResult | 'skipped';
|
||||
result: StudyResult | "skipped";
|
||||
studyLogId: number | null;
|
||||
}> = [];
|
||||
|
||||
for (const answer of input.answers) {
|
||||
if (seenProblemIds.has(answer.problemId)) {
|
||||
throw new BadRequestException(`duplicate problemId: ${answer.problemId}`);
|
||||
throw new BadRequestException(
|
||||
`duplicate problemId: ${answer.problemId}`,
|
||||
);
|
||||
}
|
||||
seenProblemIds.add(answer.problemId);
|
||||
|
||||
@@ -134,7 +146,7 @@ export class StudyLogsService {
|
||||
pageImageUrl: problem.pageImageUrl,
|
||||
chosenAnswer: null,
|
||||
correctAnswer: problem.answerNumber,
|
||||
result: 'skipped',
|
||||
result: "skipped",
|
||||
studyLogId: null,
|
||||
});
|
||||
continue;
|
||||
@@ -142,13 +154,13 @@ export class StudyLogsService {
|
||||
|
||||
const derivedResult =
|
||||
problem.answerNumber === null
|
||||
? 'partial'
|
||||
? "partial"
|
||||
: chosenAnswer === problem.answerNumber
|
||||
? 'correct'
|
||||
: 'incorrect';
|
||||
? "correct"
|
||||
: "incorrect";
|
||||
|
||||
if (derivedResult === 'correct') correct += 1;
|
||||
if (derivedResult === 'incorrect') incorrect += 1;
|
||||
if (derivedResult === "correct") correct += 1;
|
||||
if (derivedResult === "incorrect") incorrect += 1;
|
||||
|
||||
const tagName = normalizeTagName(problem.topic, problemSet.subjectName);
|
||||
let tag = tagCache.get(tagName);
|
||||
@@ -181,7 +193,7 @@ export class StudyLogsService {
|
||||
chosenAnswer,
|
||||
memo:
|
||||
problem.answerNumber === null
|
||||
? '정답 미등록 (자동 채점 불가)'
|
||||
? "정답 미등록 (자동 채점 불가)"
|
||||
: undefined,
|
||||
timeSpent: answer.timeSpent,
|
||||
});
|
||||
@@ -213,13 +225,54 @@ export class StudyLogsService {
|
||||
pageImageUrl: problem.pageImageUrl,
|
||||
chosenAnswer: null,
|
||||
correctAnswer: problem.answerNumber,
|
||||
result: 'skipped',
|
||||
result: "skipped",
|
||||
studyLogId: null,
|
||||
});
|
||||
}
|
||||
|
||||
const total = problemSet.problems.length;
|
||||
const accuracy = total === 0 ? 0 : Math.round((correct / total) * 100);
|
||||
const completedAt = new Date();
|
||||
|
||||
const activeAssignments = await tx.assignment.findMany({
|
||||
where: {
|
||||
problemSetId: input.problemSetId,
|
||||
status: AssignmentStatus.active,
|
||||
class: {
|
||||
members: {
|
||||
some: { userId },
|
||||
},
|
||||
},
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
for (const assignment of activeAssignments) {
|
||||
await tx.assignmentSubmission.upsert({
|
||||
where: {
|
||||
userId_assignmentId: {
|
||||
userId,
|
||||
assignmentId: assignment.id,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
score: accuracy,
|
||||
totalProblems: total,
|
||||
correctCount: correct,
|
||||
studyLogIds,
|
||||
completedAt,
|
||||
},
|
||||
create: {
|
||||
userId,
|
||||
assignmentId: assignment.id,
|
||||
score: accuracy,
|
||||
totalProblems: total,
|
||||
correctCount: correct,
|
||||
studyLogIds,
|
||||
completedAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
total,
|
||||
@@ -236,7 +289,12 @@ export class StudyLogsService {
|
||||
|
||||
list(
|
||||
userId: number,
|
||||
opts: { subjectId?: number; tagId?: number; limit?: number; offset?: number },
|
||||
opts: {
|
||||
subjectId?: number;
|
||||
tagId?: number;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
},
|
||||
) {
|
||||
return this.prisma.studyLog.findMany({
|
||||
where: {
|
||||
@@ -248,11 +306,11 @@ export class StudyLogsService {
|
||||
subject: { select: { id: true, name: true, color: true } },
|
||||
tag: { select: { id: true, name: true } },
|
||||
reviewSchedules: {
|
||||
orderBy: { scheduledAt: 'desc' },
|
||||
orderBy: { scheduledAt: "desc" },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
orderBy: { studiedAt: 'desc' },
|
||||
orderBy: { studiedAt: "desc" },
|
||||
take: opts.limit ?? 50,
|
||||
skip: opts.offset ?? 0,
|
||||
});
|
||||
@@ -278,7 +336,7 @@ export class StudyLogsService {
|
||||
},
|
||||
},
|
||||
},
|
||||
reviewSchedules: { orderBy: { scheduledAt: 'asc' } },
|
||||
reviewSchedules: { orderBy: { scheduledAt: "asc" } },
|
||||
},
|
||||
});
|
||||
if (!log) throw new NotFoundException();
|
||||
@@ -342,7 +400,9 @@ export class StudyLogsService {
|
||||
memo: data.memo.trim() ? data.memo : null,
|
||||
}),
|
||||
...(data.result !== undefined && { result: data.result }),
|
||||
...(data.chosenAnswer !== undefined && { chosenAnswer: data.chosenAnswer }),
|
||||
...(data.chosenAnswer !== undefined && {
|
||||
chosenAnswer: data.chosenAnswer,
|
||||
}),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
@@ -361,20 +421,20 @@ export class StudyLogsService {
|
||||
const subject = await db.subject.findFirst({
|
||||
where: { id: input.subjectId, userId },
|
||||
});
|
||||
if (!subject) throw new ForbiddenException('subject');
|
||||
if (!subject) throw new ForbiddenException("subject");
|
||||
|
||||
if (input.tagId) {
|
||||
const tag = await db.tag.findFirst({
|
||||
where: { id: input.tagId, subjectId: input.subjectId },
|
||||
});
|
||||
if (!tag) throw new NotFoundException('tag');
|
||||
if (!tag) throw new NotFoundException("tag");
|
||||
}
|
||||
|
||||
if (input.problemId) {
|
||||
const problem = await db.problem.findUnique({
|
||||
where: { id: input.problemId },
|
||||
});
|
||||
if (!problem) throw new NotFoundException('problem');
|
||||
if (!problem) throw new NotFoundException("problem");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -455,7 +515,7 @@ export class StudyLogsService {
|
||||
scheduledAt: schedule.scheduledAt,
|
||||
predictedP: schedule.predictedP,
|
||||
iteration: 0,
|
||||
status: 'pending',
|
||||
status: "pending",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -469,14 +529,14 @@ function clamp01(n: number): number {
|
||||
|
||||
function defaultSubjectColor(subjectName: string): string {
|
||||
const colorMap: Record<string, string> = {
|
||||
국어: '#ef4444',
|
||||
수학: '#3b82f6',
|
||||
영어: '#22c55e',
|
||||
한국사: '#f59e0b',
|
||||
'생활과 윤리': '#f59e0b',
|
||||
국어: "#ef4444",
|
||||
수학: "#3b82f6",
|
||||
영어: "#22c55e",
|
||||
한국사: "#f59e0b",
|
||||
"생활과 윤리": "#f59e0b",
|
||||
};
|
||||
|
||||
return colorMap[subjectName] ?? '#6366f1';
|
||||
return colorMap[subjectName] ?? "#6366f1";
|
||||
}
|
||||
|
||||
function normalizeTagName(
|
||||
@@ -489,5 +549,5 @@ function normalizeTagName(
|
||||
const trimmedSubject = subjectName.trim();
|
||||
if (trimmedSubject) return trimmedSubject;
|
||||
|
||||
return '미분류';
|
||||
return "미분류";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user