feat(school): organization + class + assignment schema and API — 9.1

This commit is contained in:
reloop
2026-04-12 15:33:16 +09:00
parent e26fe14898
commit f4b80d380a
16 changed files with 1955 additions and 108 deletions

View File

@@ -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

View File

@@ -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;

View File

@@ -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())

View File

@@ -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()

View File

@@ -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: [
{

View 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);
}
}

View 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 {}

View 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;
}

View 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);
}
}

View 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 {}

View 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");
}
}
}

View 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];

View 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);
}
}

View 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 {}

View 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");
}
}

View File

@@ -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 "미분류";
}