From 0738dbbfe184afd8135c0b7d5dd72773ce2e2e22 Mon Sep 17 00:00:00 2001 From: Laurin-Notemann Date: Thu, 25 Jul 2024 12:53:54 +0200 Subject: [PATCH 01/17] feat: add studyplannercollaborator entity and add scope to studyplan --- .env.example | 19 ++++---- backend/entities/studyPlan.entity.ts | 13 +++++ .../entities/studyPlanCollaborator.entity.ts | 47 +++++++++++++++++++ ormconfig.ts | 2 + package.json | 3 ++ 5 files changed, 76 insertions(+), 8 deletions(-) create mode 100644 backend/entities/studyPlanCollaborator.entity.ts diff --git a/.env.example b/.env.example index 6e76e1d..d0facf6 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,13 @@ -DB_HOST="" -DB_PORT="" -DB_NAME="" -DB_USERNAME="" -DB_PASSWORD="" +DB_HOST="localhost" +DB_PORT="5432" +DB_NAME="study" +DB_USERNAME="postgres" +DB_PASSWORD="admin" -ACCESS_TOKEN_SECRET="" -PUBLIC_APP_URL="" +ACCESS_TOKEN_SECRET="arbitrary" +PUBLIC_APP_URL="http://localhost:3000" -NEXT_PUBLIC_SENTRY_DSN="" \ No newline at end of file +NEXT_PUBLIC_SENTRY_DSN="" + +#cid from app.code.berlin +LP_ACCESS_TOKEN="" diff --git a/backend/entities/studyPlan.entity.ts b/backend/entities/studyPlan.entity.ts index a6e2cf1..95c3c83 100644 --- a/backend/entities/studyPlan.entity.ts +++ b/backend/entities/studyPlan.entity.ts @@ -15,6 +15,12 @@ import { ModuleHandbook } from "./moduleHandbook.entity"; import { Semester } from "./semester.entity"; import { User } from "./user.entity"; +export enum StudyPlanScope { + Public = "public", + Faculty = "facultyOnly", + Private = "private", +} + @Entity({ name: "study_plans" }) export class StudyPlan { @PrimaryGeneratedColumn("uuid") @@ -37,6 +43,13 @@ export class StudyPlan { @Column() moduleHandbookId!: string; + @Column({ + type: "enum", + enum: StudyPlanScope, + default: StudyPlanScope.Private + }) + scope!: StudyPlanScope; + @ManyToOne(() => ModuleHandbook, (handbook) => handbook.studyPlans) @JoinColumn({ name: "moduleHandbookId" }) moduleHandbook!: Relation; diff --git a/backend/entities/studyPlanCollaborator.entity.ts b/backend/entities/studyPlanCollaborator.entity.ts new file mode 100644 index 0000000..1f8ad7c --- /dev/null +++ b/backend/entities/studyPlanCollaborator.entity.ts @@ -0,0 +1,47 @@ +import { + Entity, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, + Column, + OneToOne, + type Relation, + OneToMany, +} from "typeorm"; +import { User } from "./user.entity"; +import { Semester } from "./semester.entity"; + +export enum CollaboratorRole { + Viewer = "viewer", + Editor = "editor", + Owner = "owner" +} + +@Entity({ name: "study_plan_collaborator" }) +export class StudyPlanCollaborator { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @CreateDateColumn({ select: false }) + createdAt!: Date; + + @UpdateDateColumn({ select: false }) + updatedAt!: Date; + + @OneToOne(() => User, (user) => user.studyPlan) + user!: Relation; + + @OneToMany(() => Semester, (semester) => semester.studyPlan, { + cascade: ["remove"], + }) + semesters!: Relation[]; + + @Column() + hasAccepted!: boolean; + + @Column({ + type: "enum", + enum: CollaboratorRole, + }) + role!: CollaboratorRole; +} diff --git a/ormconfig.ts b/ormconfig.ts index ebea535..83a04fb 100644 --- a/ormconfig.ts +++ b/ormconfig.ts @@ -8,6 +8,7 @@ import { StudyPlan } from "./backend/entities/studyPlan.entity"; import { StudyProgram } from "./backend/entities/studyProgram.entity"; import { ModuleHandbook } from "./backend/entities/moduleHandbook.entity"; import { CompulsoryElectivePairing } from "./backend/entities/compulsoryElectivePairing.entity"; +import { StudyPlanCollaborator } from "./backend/entities/studyPlanCollaborator.entity"; const dataSourceOptions: DataSourceOptions = { type: "postgres", @@ -25,6 +26,7 @@ const dataSourceOptions: DataSourceOptions = { User, StudyPlan, StudyProgram, + StudyPlanCollaborator, ModuleHandbook, CompulsoryElectivePairing, ], diff --git a/package.json b/package.json index 632d3b8..121d255 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,9 @@ "name": "code-study-planner", "version": "0.1.0", "private": true, + "engines": { + "node": ">=20" + }, "scripts": { "dev": "next dev", "build": "next build", From 61b4f1c11f896b2fc8323522486319086d401b0f Mon Sep 17 00:00:00 2001 From: Laurin-Notemann Date: Fri, 26 Jul 2024 12:00:15 +0200 Subject: [PATCH 02/17] feat: update the queries for studyPlan using new studyPlanCollaborator entity and add DTO types --- app/api/auth/login/route.ts | 16 ++++++++- app/api/study-plan/route.ts | 35 ++++++++++++++++--- .../dtos/compulsory-elective-pairing.dto.ts | 7 ++++ backend/dtos/module.dto.ts | 4 +++ backend/dtos/semester-module.dto.ts | 14 ++++++++ backend/dtos/semester.dto.ts | 23 ++++++++++++ backend/dtos/study-plan-collaborator.dto.ts | 10 ++++++ backend/dtos/study-plan.dto.ts | 18 ++++++++++ backend/dtos/user.dto.ts | 4 +++ backend/entities/studyPlan.entity.ts | 27 +++++++++----- .../entities/studyPlanCollaborator.entity.ts | 23 ++++++------ backend/entities/user.entity.ts | 19 ++++++---- components/SemestersList/useSemestersList.ts | 13 ++++--- .../SuggestionsPanel/getMissingMandatory.ts | 21 +++-------- services/apiClient/index.ts | 32 ++++------------- 15 files changed, 187 insertions(+), 79 deletions(-) create mode 100644 backend/dtos/compulsory-elective-pairing.dto.ts create mode 100644 backend/dtos/module.dto.ts create mode 100644 backend/dtos/semester-module.dto.ts create mode 100644 backend/dtos/semester.dto.ts create mode 100644 backend/dtos/study-plan-collaborator.dto.ts create mode 100644 backend/dtos/study-plan.dto.ts create mode 100644 backend/dtos/user.dto.ts diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts index c61631a..923ec93 100644 --- a/app/api/auth/login/route.ts +++ b/app/api/auth/login/route.ts @@ -8,6 +8,10 @@ import { Semester } from "@/backend/entities/semester.entity"; import { StudyPlan } from "@/backend/entities/studyPlan.entity"; import { User } from "@/backend/entities/user.entity"; import { issueAccessToken } from "@/backend/jwt"; +import { + CollaboratorRole, + StudyPlanCollaborator, +} from "@/backend/entities/studyPlanCollaborator.entity"; import { isDefined } from "@/services/learningPlatform/util/isDefined"; export async function POST(req: NextRequest) { @@ -86,7 +90,17 @@ export async function POST(req: NextRequest) { .getRepository(StudyPlan) .save(studyPlan); - newUser.studyPlanId = newStudyPlan.id; + const studyPlanCollaborator = new StudyPlanCollaborator(); + studyPlanCollaborator.hasAccepted = true; + studyPlanCollaborator.role = CollaboratorRole.Owner; + + studyPlanCollaborator.studyPlanId = newStudyPlan.id; + + await transaction + .getRepository(StudyPlanCollaborator) + .save(studyPlanCollaborator); + + newUser.studyPlanCollaboratorId = studyPlanCollaborator.id; await transaction.getRepository(User).save(newUser); diff --git a/app/api/study-plan/route.ts b/app/api/study-plan/route.ts index f65f4f3..d45a309 100644 --- a/app/api/study-plan/route.ts +++ b/app/api/study-plan/route.ts @@ -2,8 +2,14 @@ import dayjs from "dayjs"; import { NextRequest, NextResponse } from "next/server"; import { AppDataSource } from "@/backend/datasource"; +import { StudyPlanDTO } from "@/backend/dtos/study-plan.dto"; import { Semester } from "@/backend/entities/semester.entity"; import { SemesterModule } from "@/backend/entities/semesterModule.entity"; +import { StudyPlan, StudyPlanScope } from "@/backend/entities/studyPlan.entity"; +import { + CollaboratorRole, + StudyPlanCollaborator, +} from "@/backend/entities/studyPlanCollaborator.entity"; import { getUser } from "@/backend/getUser"; const byIndex = (a: SemesterModule, b: SemesterModule) => a.index - b.index; @@ -16,14 +22,31 @@ export async function GET(req: NextRequest) { if (!user) { return NextResponse.json({}, { status: 401 }); } + const collaboratorRepository = AppDataSource.getRepository( + StudyPlanCollaborator, + ); + + const studyPlanCollaborator = await collaboratorRepository.findOne({ + where: { + user: { + id: user.id, + }, + }, + }); + + if (!studyPlanCollaborator) { + return NextResponse.json({}, { status: 401 }); + } const semesterRepository = AppDataSource.getRepository(Semester); const semesters = await semesterRepository.find({ where: { studyPlan: { - user: { - id: user.id, + studyPlanCollaborator: { + hasAccepted: true, + role: CollaboratorRole.Owner, + id: studyPlanCollaborator.id, }, }, }, @@ -60,8 +83,12 @@ export async function GET(req: NextRequest) { }; }); - const res = NextResponse.json({ + const studyPlan: StudyPlanDTO = { + scope: StudyPlanScope.Private, + studyPlanCollaborator: [], semesters: mappedSemesters, - }); + }; + + const res = NextResponse.json(studyPlan); return res; } diff --git a/backend/dtos/compulsory-elective-pairing.dto.ts b/backend/dtos/compulsory-elective-pairing.dto.ts new file mode 100644 index 0000000..3977141 --- /dev/null +++ b/backend/dtos/compulsory-elective-pairing.dto.ts @@ -0,0 +1,7 @@ +import { CompulsoryElectivePairing } from "../entities/compulsoryElectivePairing.entity"; +import { ModuleDTO } from "./module.dto"; + +export interface CompulsoryElectivePairingDTO + extends Omit { + Modules: ModuleDTO[] + } diff --git a/backend/dtos/module.dto.ts b/backend/dtos/module.dto.ts new file mode 100644 index 0000000..7131c88 --- /dev/null +++ b/backend/dtos/module.dto.ts @@ -0,0 +1,4 @@ +import { Module } from "../entities/module.entity"; + +export interface ModuleDTO + extends Omit { } diff --git a/backend/dtos/semester-module.dto.ts b/backend/dtos/semester-module.dto.ts new file mode 100644 index 0000000..9829817 --- /dev/null +++ b/backend/dtos/semester-module.dto.ts @@ -0,0 +1,14 @@ +import { SemesterModule } from "../entities/semesterModule.entity"; + +export interface SemesterModuleDTO + extends Omit< + SemesterModule, + | "id" + | "createdAt" + | "updatedAt" + | "semester" + | "semesterId" + | "module" + | "assessmentType" + | "index" + > {} diff --git a/backend/dtos/semester.dto.ts b/backend/dtos/semester.dto.ts new file mode 100644 index 0000000..adaec93 --- /dev/null +++ b/backend/dtos/semester.dto.ts @@ -0,0 +1,23 @@ +import { Semester } from "../entities/semester.entity"; +import { SemesterModuleDTO } from "./semester-module.dto"; + +export interface SemesterDTO + extends Omit< + Semester, + | "createdAt" + | "updatedAt" + | "studyPlan" + | "studyPlanId" + | "semesterModules" + | "lpId" + > { + modules: ModulesRecordDTO; + lpId: string | null; +} + +export interface ModulesRecordDTO { + earlyAssessments: SemesterModuleDTO[]; + standardAssessments: SemesterModuleDTO[]; + alternativeAssessments: SemesterModuleDTO[]; + reassessments: SemesterModuleDTO[]; +} diff --git a/backend/dtos/study-plan-collaborator.dto.ts b/backend/dtos/study-plan-collaborator.dto.ts new file mode 100644 index 0000000..0c9b5c0 --- /dev/null +++ b/backend/dtos/study-plan-collaborator.dto.ts @@ -0,0 +1,10 @@ +import { StudyPlanCollaborator } from "../entities/studyPlanCollaborator.entity"; +import { UserDTO } from "./user.dto"; + +export interface StudyPlanCollaboratorDTO + extends Omit< + StudyPlanCollaborator, + "createdAt" | "updatedAt" | "studyPlan" | "user" + > { + user: UserDTO; +} diff --git a/backend/dtos/study-plan.dto.ts b/backend/dtos/study-plan.dto.ts new file mode 100644 index 0000000..83359bc --- /dev/null +++ b/backend/dtos/study-plan.dto.ts @@ -0,0 +1,18 @@ +import { StudyPlan } from "../entities/studyPlan.entity"; +import { SemesterDTO } from "./semester.dto"; +import { StudyPlanCollaboratorDTO } from "./study-plan-collaborator.dto"; + +export interface StudyPlanDTO + extends Omit< + StudyPlan, + | "id" + | "createdAt" + | "updatedAt" + | "moduleHandbook" + | "moduleHandbookId" + | "semesters" + | "studyPlanCollaborator" + > { + semesters: SemesterDTO[]; + studyPlanCollaborator: StudyPlanCollaboratorDTO[]; +} diff --git a/backend/dtos/user.dto.ts b/backend/dtos/user.dto.ts new file mode 100644 index 0000000..2e000de --- /dev/null +++ b/backend/dtos/user.dto.ts @@ -0,0 +1,4 @@ +import { User } from "../entities/user.entity"; + +export interface UserDTO + extends Omit {} diff --git a/backend/entities/studyPlan.entity.ts b/backend/entities/studyPlan.entity.ts index 95c3c83..f807d35 100644 --- a/backend/entities/studyPlan.entity.ts +++ b/backend/entities/studyPlan.entity.ts @@ -9,11 +9,14 @@ import { PrimaryGeneratedColumn, type Relation, UpdateDateColumn, + type Relation, + ManyToOne, + JoinColumn, + Column, } from "typeorm"; - -import { ModuleHandbook } from "./moduleHandbook.entity"; import { Semester } from "./semester.entity"; -import { User } from "./user.entity"; +import { ModuleHandbook } from "./moduleHandbook.entity"; +import { StudyPlanCollaborator } from "./studyPlanCollaborator.entity"; export enum StudyPlanScope { Public = "public", @@ -32,24 +35,30 @@ export class StudyPlan { @UpdateDateColumn({ select: false }) updatedAt!: Date; - @OneToOne(() => User, (user) => user.studyPlan) - user!: Relation; - @OneToMany(() => Semester, (semester) => semester.studyPlan, { cascade: ["remove"], }) semesters!: Relation[]; - @Column() - moduleHandbookId!: string; + @OneToMany( + () => StudyPlanCollaborator, + (studyPlanCollaborator) => studyPlanCollaborator.studyPlan, + { + cascade: ["remove"], + }, + ) + studyPlanCollaborator!: Relation[]; @Column({ type: "enum", enum: StudyPlanScope, - default: StudyPlanScope.Private + default: StudyPlanScope.Private, }) scope!: StudyPlanScope; + @Column() + moduleHandbookId!: string; + @ManyToOne(() => ModuleHandbook, (handbook) => handbook.studyPlans) @JoinColumn({ name: "moduleHandbookId" }) moduleHandbook!: Relation; diff --git a/backend/entities/studyPlanCollaborator.entity.ts b/backend/entities/studyPlanCollaborator.entity.ts index 1f8ad7c..f580992 100644 --- a/backend/entities/studyPlanCollaborator.entity.ts +++ b/backend/entities/studyPlanCollaborator.entity.ts @@ -6,18 +6,19 @@ import { Column, OneToOne, type Relation, - OneToMany, + ManyToOne, + JoinColumn, } from "typeorm"; import { User } from "./user.entity"; -import { Semester } from "./semester.entity"; +import { StudyPlan } from "./studyPlan.entity"; export enum CollaboratorRole { Viewer = "viewer", Editor = "editor", - Owner = "owner" + Owner = "owner", } -@Entity({ name: "study_plan_collaborator" }) +@Entity({ name: "study_plan_collaborators" }) export class StudyPlanCollaborator { @PrimaryGeneratedColumn("uuid") id!: string; @@ -28,14 +29,9 @@ export class StudyPlanCollaborator { @UpdateDateColumn({ select: false }) updatedAt!: Date; - @OneToOne(() => User, (user) => user.studyPlan) + @OneToOne(() => User, (user) => user.studyPlanCollaborator) user!: Relation; - @OneToMany(() => Semester, (semester) => semester.studyPlan, { - cascade: ["remove"], - }) - semesters!: Relation[]; - @Column() hasAccepted!: boolean; @@ -44,4 +40,11 @@ export class StudyPlanCollaborator { enum: CollaboratorRole, }) role!: CollaboratorRole; + + @Column() + studyPlanId!: string; + + @ManyToOne(() => StudyPlan, (studyPlan) => studyPlan.studyPlanCollaborator) + @JoinColumn({ name: "studyPlanId" }) + studyPlan!: Relation; } diff --git a/backend/entities/user.entity.ts b/backend/entities/user.entity.ts index 8552ffd..30c120d 100644 --- a/backend/entities/user.entity.ts +++ b/backend/entities/user.entity.ts @@ -10,6 +10,7 @@ import { } from "typeorm"; import { StudyPlan } from "./studyPlan.entity"; +import { StudyPlanCollaborator } from "./studyPlanCollaborator.entity"; @Entity({ name: "users" }) export class User { @@ -31,11 +32,15 @@ export class User { lpId!: string; @Column() - studyPlanId!: string; - - @OneToOne(() => StudyPlan, (studyPlan) => studyPlan.user, { - cascade: ["remove"], - }) - @JoinColumn({ name: "studyPlanId" }) - studyPlan!: Relation; + studyPlanCollaboratorId!: string; + + @OneToOne( + () => StudyPlanCollaborator, + (studyPlanCollaborator) => studyPlanCollaborator.user, + { + cascade: ["remove"], + }, + ) + @JoinColumn({ name: "studyPlanCollaboratorId" }) + studyPlanCollaborator!: Relation; } diff --git a/components/SemestersList/useSemestersList.ts b/components/SemestersList/useSemestersList.ts index 310bbc0..bb00acc 100644 --- a/components/SemestersList/useSemestersList.ts +++ b/components/SemestersList/useSemestersList.ts @@ -1,19 +1,18 @@ -import { useQuery } from "@tanstack/react-query"; import dayjs from "dayjs"; import { Semester, SemesterModule } from "@/components/util/types"; import { ApiSemesterModule } from "@/services/apiClient"; -import { useStudyPlan } from "@/services/apiClient/hooks/useStudyPlan"; -import { useLearningPlatformAssessmentTable } from "@/services/learningPlatform/hooks/useLearningPlatformAssessmentTable"; -import { useLearningPlatformSemesters } from "@/services/learningPlatform/hooks/useLearningPlatformSemesters"; import { getSemesterName, getUserUrl, } from "@/services/learningPlatform/mapping"; import { getGradeInfo } from "@/services/learningPlatform/util/getGradeInfo"; - -import { SemestersListProps } from "."; +import { useStudyPlan } from "@/services/apiClient/hooks/useStudyPlan"; +import { useLearningPlatformAssessmentTable } from "@/services/learningPlatform/hooks/useLearningPlatformAssessmentTable"; import { useModulesInScope } from "../util/useModulesInScope"; +import { useLearningPlatformSemesters } from "@/services/learningPlatform/hooks/useLearningPlatformSemesters"; +import { useQuery } from "@tanstack/react-query"; +import { SemesterModuleDTO } from "@/backend/dtos/semester-module.dto"; /** * aggregates the data for the kanban view of the study plan from both learning platform data and our own backend @@ -31,7 +30,7 @@ export function useSemestersList(): SemestersListProps { const assessmentTableQuery = useLearningPlatformAssessmentTable(); - const toPlannedModule = (i: ApiSemesterModule): SemesterModule => ({ + const toPlannedModule = (i: SemesterModuleDTO): SemesterModule => ({ type: "planned", id: i.moduleId, diff --git a/components/SuggestionsPanel/getMissingMandatory.ts b/components/SuggestionsPanel/getMissingMandatory.ts index 46fb31c..77c87e5 100644 --- a/components/SuggestionsPanel/getMissingMandatory.ts +++ b/components/SuggestionsPanel/getMissingMandatory.ts @@ -2,24 +2,13 @@ import { getGradeInfo } from "@/services/learningPlatform/util/getGradeInfo"; import { Module, Semester } from "../util/types"; import { Issue } from "./issues"; - -export interface CompulsoryElectivePairing { - id: string; - moduleHandbookId: string; - modules: { - id: string; - lpId: string; - proficiency: number; - possiblyOutdated: boolean; - moduleIdentifier: string; - }[]; -} +import { CompulsoryElectivePairingDTO } from "@/backend/dtos/compulsory-elective-pairing.dto"; export function getMissingMandatory( semesters: Semester[], modules: Module[], mandatoryModuleIds: string[], - compulsoryElectivePairings: CompulsoryElectivePairing[], + compulsoryElectivePairings: CompulsoryElectivePairingDTO[], ) { let issues: Issue[] = []; @@ -44,9 +33,9 @@ export function getMissingMandatory( const partnerModuleIdentifiers = isCompulsoryElective ? (compulsoryElectivePairings - .find((i) => i.modules[0]?.moduleIdentifier === moduleIdentifier) - ?.modules.slice(1) - .map((i) => i.moduleIdentifier) ?? []) + .find((i) => i.modules[0]?.moduleIdentifier === moduleIdentifier) + ?.modules.slice(1) + .map((i) => i.moduleIdentifier) ?? []) : []; const partnerModules = modules.filter( diff --git a/services/apiClient/index.ts b/services/apiClient/index.ts index 7f5096e..1e2cd5a 100644 --- a/services/apiClient/index.ts +++ b/services/apiClient/index.ts @@ -1,26 +1,8 @@ -import { CompulsoryElectivePairing } from "@/backend/entities/compulsoryElectivePairing.entity"; +import { CompulsoryElectivePairingDTO } from "@/backend/dtos/compulsory-elective-pairing.dto"; +import { ModulesRecordDTO } from "@/backend/dtos/semester.dto"; +import { StudyPlanDTO } from "@/backend/dtos/study-plan.dto"; import { Module } from "@/backend/entities/module.entity"; -export interface ApiSemesterModule { - moduleId: string; -} - -interface ModulesRecord { - earlyAssessments: ApiSemesterModule[]; - standardAssessments: ApiSemesterModule[]; - alternativeAssessments: ApiSemesterModule[]; - reassessments: ApiSemesterModule[]; -} - -export interface StudyPlan { - semesters: { - id: string; - lpId: string | null; - startDate: string; - modules: ModulesRecord; - }[]; -} - export class StudyPlannerApiClient { constructor( private readonly accessToken: string, @@ -31,7 +13,7 @@ export class StudyPlannerApiClient { public async getModules(): Promise<{ modules: Module[]; - compulsoryElective: CompulsoryElectivePairing[]; + compulsoryElective: CompulsoryElectivePairingDTO[]; }> { const res = await fetch(this.url + "/modules", { headers: { @@ -44,14 +26,14 @@ export class StudyPlannerApiClient { return data; } - public async getStudyPlan(): Promise { + public async getStudyPlan(): Promise { const res = await fetch(this.url + "/study-plan", { headers: { "Content-Type": "application/json", Authorization: this.accessToken, }, }); - const data: StudyPlan = await res.json(); + const data: StudyPlanDTO = await res.json(); return data; } @@ -76,7 +58,7 @@ export class StudyPlannerApiClient { } } -export type UpdateSemesterModuleInput = Record; +export type UpdateSemesterModuleInput = Record; export type SemesterModuleCategory = | "earlyAssessments" From cdf48684556ad00adca204f7c4ab8e543f4c908e Mon Sep 17 00:00:00 2001 From: Laurin-Notemann Date: Fri, 26 Jul 2024 16:52:15 +0200 Subject: [PATCH 03/17] feat: add PUT to change scope of studyPlan --- app/api/auth/login/route.ts | 12 +- app/api/learning-platform-proxy/route.ts | 4 +- .../study-plan/[id]/collaborators/route.ts | 3 + app/api/study-plan/route.ts | 125 +++++++++++------- .../semester-modules/batch-update/route.ts | 13 +- .../dtos/compulsory-elective-pairing.dto.ts | 9 +- backend/dtos/module.dto.ts | 5 +- backend/dtos/study-plan-collaborator.dto.ts | 4 +- backend/dtos/study-plan.dto.ts | 6 +- backend/dtos/user.dto.ts | 5 +- backend/entities/studyPlan.entity.ts | 8 +- .../entities/studyPlanCollaborator.entity.ts | 25 ++-- backend/entities/user.entity.ts | 12 +- backend/queries/semester.query.ts | 21 +++ .../queries/study-plan-collaborator.query.ts | 38 ++++++ backend/queries/study-plan.query.ts | 47 +++++++ components/SemestersList/useSemestersList.ts | 13 +- .../SuggestionsPanel/getMissingMandatory.ts | 8 +- .../hooks/useUpdateSemesterModules.ts | 5 +- services/apiClient/index.ts | 14 ++ 20 files changed, 269 insertions(+), 108 deletions(-) create mode 100644 app/api/study-plan/[id]/collaborators/route.ts create mode 100644 backend/queries/semester.query.ts create mode 100644 backend/queries/study-plan-collaborator.query.ts create mode 100644 backend/queries/study-plan.query.ts diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts index 923ec93..526f9e7 100644 --- a/app/api/auth/login/route.ts +++ b/app/api/auth/login/route.ts @@ -6,12 +6,12 @@ import { AppDataSource, connectToDatabase } from "@/backend/datasource"; import { ModuleHandbook } from "@/backend/entities/moduleHandbook.entity"; import { Semester } from "@/backend/entities/semester.entity"; import { StudyPlan } from "@/backend/entities/studyPlan.entity"; -import { User } from "@/backend/entities/user.entity"; -import { issueAccessToken } from "@/backend/jwt"; import { CollaboratorRole, StudyPlanCollaborator, } from "@/backend/entities/studyPlanCollaborator.entity"; +import { User } from "@/backend/entities/user.entity"; +import { issueAccessToken } from "@/backend/jwt"; import { isDefined } from "@/services/learningPlatform/util/isDefined"; export async function POST(req: NextRequest) { @@ -82,6 +82,8 @@ export async function POST(req: NextRequest) { newUser.lpId = learningPlatformUser.me.id; + await transaction.getRepository(User).save(newUser); + const studyPlan = new StudyPlan(); studyPlan.moduleHandbookId = moduleHandbook.id; @@ -96,14 +98,12 @@ export async function POST(req: NextRequest) { studyPlanCollaborator.studyPlanId = newStudyPlan.id; + studyPlanCollaborator.userId = newUser.id; + await transaction .getRepository(StudyPlanCollaborator) .save(studyPlanCollaborator); - newUser.studyPlanCollaboratorId = studyPlanCollaborator.id; - - await transaction.getRepository(User).save(newUser); - const myStudiesData = await learningPlatform.raw.query( `query myStudies($filter: ModuleFilter) { myStudies(filter: $filter) { diff --git a/app/api/learning-platform-proxy/route.ts b/app/api/learning-platform-proxy/route.ts index cb5ca70..5e7352a 100644 --- a/app/api/learning-platform-proxy/route.ts +++ b/app/api/learning-platform-proxy/route.ts @@ -34,9 +34,9 @@ export async function POST(req: NextRequest) { if (init?.body) { // @ts-ignore - console.log("fetched learning platform:", JSON.parse(init.body), data); + //console.log("fetched learning platform:", JSON.parse(init.body), data); } else { - console.log("fetched learning platform:", data); + //console.log("fetched learning platform:", data); } const res = NextResponse.json(data, { diff --git a/app/api/study-plan/[id]/collaborators/route.ts b/app/api/study-plan/[id]/collaborators/route.ts new file mode 100644 index 0000000..6d4a9e3 --- /dev/null +++ b/app/api/study-plan/[id]/collaborators/route.ts @@ -0,0 +1,3 @@ +import { NextRequest } from "next/server"; + +export async function DELETE(req: NextRequest) {} diff --git a/app/api/study-plan/route.ts b/app/api/study-plan/route.ts index d45a309..7d7b070 100644 --- a/app/api/study-plan/route.ts +++ b/app/api/study-plan/route.ts @@ -1,59 +1,27 @@ import dayjs from "dayjs"; import { NextRequest, NextResponse } from "next/server"; -import { AppDataSource } from "@/backend/datasource"; -import { StudyPlanDTO } from "@/backend/dtos/study-plan.dto"; +import { SemesterDTO } from "@/backend/dtos/semester.dto"; +import { + StudyPlanDTO, + StudyPlanUpdateScopeDTO, +} from "@/backend/dtos/study-plan.dto"; import { Semester } from "@/backend/entities/semester.entity"; import { SemesterModule } from "@/backend/entities/semesterModule.entity"; -import { StudyPlan, StudyPlanScope } from "@/backend/entities/studyPlan.entity"; +import { CollaboratorRole } from "@/backend/entities/studyPlanCollaborator.entity"; +import { getSemesterByStudyPlanId } from "@/backend/queries/semester.query"; +import { getCollaborator } from "@/backend/queries/study-plan-collaborator.query"; import { - CollaboratorRole, - StudyPlanCollaborator, -} from "@/backend/entities/studyPlanCollaborator.entity"; -import { getUser } from "@/backend/getUser"; + getStudyPlanByCollaboratorId, + updateStudyPlanScopeByCollabId, +} from "@/backend/queries/study-plan.query"; const byIndex = (a: SemesterModule, b: SemesterModule) => a.index - b.index; const toModule = (module: SemesterModule) => ({ moduleId: module.module.lpId }); -export async function GET(req: NextRequest) { - const user = await getUser(req); - - if (!user) { - return NextResponse.json({}, { status: 401 }); - } - const collaboratorRepository = AppDataSource.getRepository( - StudyPlanCollaborator, - ); - - const studyPlanCollaborator = await collaboratorRepository.findOne({ - where: { - user: { - id: user.id, - }, - }, - }); - - if (!studyPlanCollaborator) { - return NextResponse.json({}, { status: 401 }); - } - - const semesterRepository = AppDataSource.getRepository(Semester); - - const semesters = await semesterRepository.find({ - where: { - studyPlan: { - studyPlanCollaborator: { - hasAccepted: true, - role: CollaboratorRole.Owner, - id: studyPlanCollaborator.id, - }, - }, - }, - relations: ["semesterModules", "semesterModules.module"], - }); - - const mappedSemesters = semesters +const mapSemster = (semesters: Semester[]): SemesterDTO[] => { + return semesters .toSorted((a, b) => dayjs(a.startDate).unix() - dayjs(b.startDate).unix()) .map((semester) => { return { @@ -82,13 +50,72 @@ export async function GET(req: NextRequest) { }, }; }); +}; + +export async function GET(req: NextRequest) { + const studyPlanCollaborator = await getCollaborator(req); + + if (!studyPlanCollaborator || studyPlanCollaborator.length === 0) { + return NextResponse.json({}, { status: 401 }); + } + + // TODO: Another todo, we are always taking the studyplanner with index 0, possibly needs be some kind of use preference or last viewed or something + const currentCollab = studyPlanCollaborator[0]; + + const currentStudyPlan = await getStudyPlanByCollaboratorId(currentCollab.id); + + if (!currentStudyPlan) { + return NextResponse.json({}, { status: 401 }); + } + + /* + TODO: maybe add error if list is empty or return something currently if find function errors returns empty list + Prev. on error it would automatically return response 500 rn this wouldnt happen + */ + const semesters = await getSemesterByStudyPlanId(currentStudyPlan.id); + + const mappedSemesters = mapSemster(semesters); const studyPlan: StudyPlanDTO = { - scope: StudyPlanScope.Private, - studyPlanCollaborator: [], + scope: currentStudyPlan.scope, + studyPlanCollaborator: currentStudyPlan.studyPlanCollaborator, semesters: mappedSemesters, }; - const res = NextResponse.json(studyPlan); - return res; + return NextResponse.json(studyPlan); +} + +export async function PUT(req: NextRequest) { + const studyPlanCollaborator = await getCollaborator(req); + + if ( + !studyPlanCollaborator || + studyPlanCollaborator.length === 0 || + studyPlanCollaborator[0].role != CollaboratorRole.Owner + ) + return NextResponse.json({}, { status: 401 }); + + // TODO: Another todo, we are always taking the studyplanner with index 0, possibly needs be some kind of use preference or last viewed or something + const currentCollab = studyPlanCollaborator[0]; + if (currentCollab.role != CollaboratorRole.Owner) + return NextResponse.json({}, { status: 401 }); + + const body: StudyPlanUpdateScopeDTO = await req.json(); + + const updatePlan = await updateStudyPlanScopeByCollabId( + currentCollab.id, + body, + ); + + // TODO: Think about better error handling lol + if (!updatePlan) return NextResponse.json({}, { status: 500 }); + + //TODO: What should the Respone be? return StudyPlan? means the semester needs to be remapped, and if react query, refetches GET /api/study-plan that redundant <- really doesn't make sense to return the studyPlan here I think + // const studyPlan: StudyPlanDTO = { + // scope: updatePlan.scope, + // studyPlanCollaborator: updatePlan.studyPlanCollaborator, + // semesters: mappedSemesters, + // }; + + return NextResponse.json(true); } diff --git a/app/api/study-plan/semester-modules/batch-update/route.ts b/app/api/study-plan/semester-modules/batch-update/route.ts index cb5233e..325ff70 100644 --- a/app/api/study-plan/semester-modules/batch-update/route.ts +++ b/app/api/study-plan/semester-modules/batch-update/route.ts @@ -8,18 +8,21 @@ import { AssessmentType, SemesterModule, } from "@/backend/entities/semesterModule.entity"; -import { getUser } from "@/backend/getUser"; +import { getCollaborator } from "@/backend/queries/study-plan-collaborator.query"; /** * note: both `semesterId` and `moduleId` are learning platform ids, not the ids in our database */ export async function PUT(req: NextRequest) { - const user = await getUser(req); + const studyPlanCollaborator = await getCollaborator(req); - if (!user) { + if (!studyPlanCollaborator || studyPlanCollaborator.length === 0) { return NextResponse.json({}, { status: 401 }); } + // TODO: Another todo, we are always taking the studyplanner with index 0, possibly needs be some kind of use preference or last viewed or something + const currentCollab = studyPlanCollaborator[0]; + const moduleSchema = z.object({ moduleId: z.string(), }); @@ -48,7 +51,7 @@ export async function PUT(req: NextRequest) { "semester.id IN (:...semesterIds) and semester.studyPlanId = :studyPlanId", { semesterIds, - studyPlanId: user.studyPlanId, + studyPlanId: currentCollab.studyPlanId, }, ) .getCount(); @@ -71,7 +74,7 @@ export async function PUT(req: NextRequest) { .where( "semester.studyPlanId = :studyPlanId AND semesterModule.semesterId IN (:...semesterIds)", { - studyPlanId: user.studyPlanId, + studyPlanId: currentCollab.studyPlanId, semesterIds, }, ) diff --git a/backend/dtos/compulsory-elective-pairing.dto.ts b/backend/dtos/compulsory-elective-pairing.dto.ts index 3977141..9645b80 100644 --- a/backend/dtos/compulsory-elective-pairing.dto.ts +++ b/backend/dtos/compulsory-elective-pairing.dto.ts @@ -2,6 +2,9 @@ import { CompulsoryElectivePairing } from "../entities/compulsoryElectivePairing import { ModuleDTO } from "./module.dto"; export interface CompulsoryElectivePairingDTO - extends Omit { - Modules: ModuleDTO[] - } + extends Omit< + CompulsoryElectivePairing, + "createdAt" | "updatedAt" | "moduleHandbook" + > { + Modules: ModuleDTO[]; +} diff --git a/backend/dtos/module.dto.ts b/backend/dtos/module.dto.ts index 7131c88..2cde3e4 100644 --- a/backend/dtos/module.dto.ts +++ b/backend/dtos/module.dto.ts @@ -1,4 +1,7 @@ import { Module } from "../entities/module.entity"; export interface ModuleDTO - extends Omit { } + extends Omit< + Module, + "createdAt" | "updatedAt" | "compulsoryElectivePairings" + > {} diff --git a/backend/dtos/study-plan-collaborator.dto.ts b/backend/dtos/study-plan-collaborator.dto.ts index 0c9b5c0..b52b17f 100644 --- a/backend/dtos/study-plan-collaborator.dto.ts +++ b/backend/dtos/study-plan-collaborator.dto.ts @@ -5,6 +5,4 @@ export interface StudyPlanCollaboratorDTO extends Omit< StudyPlanCollaborator, "createdAt" | "updatedAt" | "studyPlan" | "user" - > { - user: UserDTO; -} + > {} diff --git a/backend/dtos/study-plan.dto.ts b/backend/dtos/study-plan.dto.ts index 83359bc..d608fe7 100644 --- a/backend/dtos/study-plan.dto.ts +++ b/backend/dtos/study-plan.dto.ts @@ -1,4 +1,4 @@ -import { StudyPlan } from "../entities/studyPlan.entity"; +import { StudyPlan, StudyPlanScope } from "../entities/studyPlan.entity"; import { SemesterDTO } from "./semester.dto"; import { StudyPlanCollaboratorDTO } from "./study-plan-collaborator.dto"; @@ -16,3 +16,7 @@ export interface StudyPlanDTO semesters: SemesterDTO[]; studyPlanCollaborator: StudyPlanCollaboratorDTO[]; } + +export type StudyPlanUpdateScopeDTO = { + scope: StudyPlanScope; +}; diff --git a/backend/dtos/user.dto.ts b/backend/dtos/user.dto.ts index 2e000de..7cbadb2 100644 --- a/backend/dtos/user.dto.ts +++ b/backend/dtos/user.dto.ts @@ -1,4 +1,7 @@ import { User } from "../entities/user.entity"; +import { StudyPlanCollaboratorDTO } from "./study-plan-collaborator.dto"; export interface UserDTO - extends Omit {} + extends Omit { + studyPlanCollaborator: StudyPlanCollaboratorDTO[]; +} diff --git a/backend/entities/studyPlan.entity.ts b/backend/entities/studyPlan.entity.ts index f807d35..8ef210c 100644 --- a/backend/entities/studyPlan.entity.ts +++ b/backend/entities/studyPlan.entity.ts @@ -5,17 +5,13 @@ import { JoinColumn, ManyToOne, OneToMany, - OneToOne, PrimaryGeneratedColumn, type Relation, UpdateDateColumn, - type Relation, - ManyToOne, - JoinColumn, - Column, } from "typeorm"; -import { Semester } from "./semester.entity"; + import { ModuleHandbook } from "./moduleHandbook.entity"; +import { Semester } from "./semester.entity"; import { StudyPlanCollaborator } from "./studyPlanCollaborator.entity"; export enum StudyPlanScope { diff --git a/backend/entities/studyPlanCollaborator.entity.ts b/backend/entities/studyPlanCollaborator.entity.ts index f580992..620e357 100644 --- a/backend/entities/studyPlanCollaborator.entity.ts +++ b/backend/entities/studyPlanCollaborator.entity.ts @@ -1,16 +1,17 @@ import { - Entity, - PrimaryGeneratedColumn, - CreateDateColumn, - UpdateDateColumn, Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, OneToOne, + PrimaryGeneratedColumn, type Relation, - ManyToOne, - JoinColumn, + UpdateDateColumn, } from "typeorm"; -import { User } from "./user.entity"; + import { StudyPlan } from "./studyPlan.entity"; +import { User } from "./user.entity"; export enum CollaboratorRole { Viewer = "viewer", @@ -29,9 +30,6 @@ export class StudyPlanCollaborator { @UpdateDateColumn({ select: false }) updatedAt!: Date; - @OneToOne(() => User, (user) => user.studyPlanCollaborator) - user!: Relation; - @Column() hasAccepted!: boolean; @@ -47,4 +45,11 @@ export class StudyPlanCollaborator { @ManyToOne(() => StudyPlan, (studyPlan) => studyPlan.studyPlanCollaborator) @JoinColumn({ name: "studyPlanId" }) studyPlan!: Relation; + + @Column() + userId!: string; + + @ManyToOne(() => User, (user) => user.studyPlanCollaborator) + @JoinColumn({ name: "userId" }) + user!: Relation; } diff --git a/backend/entities/user.entity.ts b/backend/entities/user.entity.ts index 30c120d..3ab182d 100644 --- a/backend/entities/user.entity.ts +++ b/backend/entities/user.entity.ts @@ -2,14 +2,12 @@ import { Column, CreateDateColumn, Entity, - JoinColumn, - OneToOne, + OneToMany, PrimaryGeneratedColumn, type Relation, UpdateDateColumn, } from "typeorm"; -import { StudyPlan } from "./studyPlan.entity"; import { StudyPlanCollaborator } from "./studyPlanCollaborator.entity"; @Entity({ name: "users" }) @@ -31,16 +29,12 @@ export class User { }) lpId!: string; - @Column() - studyPlanCollaboratorId!: string; - - @OneToOne( + @OneToMany( () => StudyPlanCollaborator, (studyPlanCollaborator) => studyPlanCollaborator.user, { cascade: ["remove"], }, ) - @JoinColumn({ name: "studyPlanCollaboratorId" }) - studyPlanCollaborator!: Relation; + studyPlanCollaborator!: Relation[]; } diff --git a/backend/queries/semester.query.ts b/backend/queries/semester.query.ts new file mode 100644 index 0000000..ce78163 --- /dev/null +++ b/backend/queries/semester.query.ts @@ -0,0 +1,21 @@ +import { AppDataSource } from "../datasource"; +import { Semester } from "../entities/semester.entity"; + +export async function getSemesterByStudyPlanId(studyPlanId: string) { + try { + const semesterRepository = AppDataSource.getRepository(Semester); + + return await semesterRepository.find({ + where: { + studyPlan: { + id: studyPlanId, + }, + }, + relations: ["semesterModules", "semesterModules.module"], + }); + } catch (error) { + console.error("GetSemesterByCollab: ", error); + + return []; + } +} diff --git a/backend/queries/study-plan-collaborator.query.ts b/backend/queries/study-plan-collaborator.query.ts new file mode 100644 index 0000000..a100b7f --- /dev/null +++ b/backend/queries/study-plan-collaborator.query.ts @@ -0,0 +1,38 @@ +import { NextRequest } from "next/server"; + +import { AppDataSource } from "../datasource"; +import { + CollaboratorRole, + StudyPlanCollaborator, +} from "../entities/studyPlanCollaborator.entity"; +import { getUser } from "../getUser"; + +export async function getAllCollaboratorOwnerByUserId( + userId: string, +): Promise { + try { + const collaboratorRepository = AppDataSource.getRepository( + StudyPlanCollaborator, + ); + + return await collaboratorRepository.find({ + where: { + hasAccepted: true, + role: CollaboratorRole.Owner, + user: { + id: userId, + }, + }, + }); + } catch (error) { + return []; + } +} + +export const getCollaborator = async (req: NextRequest) => { + const user = await getUser(req); + + if (!user) return null; + + return await getAllCollaboratorOwnerByUserId(user.id); +}; diff --git a/backend/queries/study-plan.query.ts b/backend/queries/study-plan.query.ts new file mode 100644 index 0000000..c8cf2fb --- /dev/null +++ b/backend/queries/study-plan.query.ts @@ -0,0 +1,47 @@ +import { AppDataSource } from "../datasource"; +import { StudyPlanUpdateScopeDTO } from "../dtos/study-plan.dto"; +import { StudyPlan } from "../entities/studyPlan.entity"; +import { CollaboratorRole } from "../entities/studyPlanCollaborator.entity"; + +export async function updateStudyPlanScopeByCollabId( + collabId: string, + body: StudyPlanUpdateScopeDTO, +): Promise { + try { + const studyPlanRepository = AppDataSource.getRepository(StudyPlan); + + const studyPlan = await getStudyPlanByCollaboratorId(collabId); + + if (!studyPlan) return null; + + studyPlan.scope = body.scope; + + await studyPlanRepository.save(studyPlan); + + return studyPlan; + } catch (error) { + console.error("UpdateStudyPlanScope: ", error); + + return null; + } +} + +export async function getStudyPlanByCollaboratorId(collabId: string) { + try { + const studyPlanRepository = AppDataSource.getRepository(StudyPlan); + + return await studyPlanRepository.findOne({ + where: { + studyPlanCollaborator: { + hasAccepted: true, + role: CollaboratorRole.Owner, + id: collabId, + }, + }, + }); + } catch (error) { + console.error("GetStudyPlanByCollaboratorId: ", error); + + return null; + } +} diff --git a/components/SemestersList/useSemestersList.ts b/components/SemestersList/useSemestersList.ts index bb00acc..898921a 100644 --- a/components/SemestersList/useSemestersList.ts +++ b/components/SemestersList/useSemestersList.ts @@ -1,18 +1,19 @@ +import { useQuery } from "@tanstack/react-query"; import dayjs from "dayjs"; +import { SemesterModuleDTO } from "@/backend/dtos/semester-module.dto"; import { Semester, SemesterModule } from "@/components/util/types"; -import { ApiSemesterModule } from "@/services/apiClient"; +import { useStudyPlan } from "@/services/apiClient/hooks/useStudyPlan"; +import { useLearningPlatformAssessmentTable } from "@/services/learningPlatform/hooks/useLearningPlatformAssessmentTable"; +import { useLearningPlatformSemesters } from "@/services/learningPlatform/hooks/useLearningPlatformSemesters"; import { getSemesterName, getUserUrl, } from "@/services/learningPlatform/mapping"; import { getGradeInfo } from "@/services/learningPlatform/util/getGradeInfo"; -import { useStudyPlan } from "@/services/apiClient/hooks/useStudyPlan"; -import { useLearningPlatformAssessmentTable } from "@/services/learningPlatform/hooks/useLearningPlatformAssessmentTable"; + +import { SemestersListProps } from "."; import { useModulesInScope } from "../util/useModulesInScope"; -import { useLearningPlatformSemesters } from "@/services/learningPlatform/hooks/useLearningPlatformSemesters"; -import { useQuery } from "@tanstack/react-query"; -import { SemesterModuleDTO } from "@/backend/dtos/semester-module.dto"; /** * aggregates the data for the kanban view of the study plan from both learning platform data and our own backend diff --git a/components/SuggestionsPanel/getMissingMandatory.ts b/components/SuggestionsPanel/getMissingMandatory.ts index 77c87e5..3c15af1 100644 --- a/components/SuggestionsPanel/getMissingMandatory.ts +++ b/components/SuggestionsPanel/getMissingMandatory.ts @@ -1,8 +1,8 @@ +import { CompulsoryElectivePairingDTO } from "@/backend/dtos/compulsory-elective-pairing.dto"; import { getGradeInfo } from "@/services/learningPlatform/util/getGradeInfo"; import { Module, Semester } from "../util/types"; import { Issue } from "./issues"; -import { CompulsoryElectivePairingDTO } from "@/backend/dtos/compulsory-elective-pairing.dto"; export function getMissingMandatory( semesters: Semester[], @@ -33,9 +33,9 @@ export function getMissingMandatory( const partnerModuleIdentifiers = isCompulsoryElective ? (compulsoryElectivePairings - .find((i) => i.modules[0]?.moduleIdentifier === moduleIdentifier) - ?.modules.slice(1) - .map((i) => i.moduleIdentifier) ?? []) + .find((i) => i.modules[0]?.moduleIdentifier === moduleIdentifier) + ?.modules.slice(1) + .map((i) => i.moduleIdentifier) ?? []) : []; const partnerModules = modules.filter( diff --git a/services/apiClient/hooks/useUpdateSemesterModules.ts b/services/apiClient/hooks/useUpdateSemesterModules.ts index ef8e200..400bd03 100644 --- a/services/apiClient/hooks/useUpdateSemesterModules.ts +++ b/services/apiClient/hooks/useUpdateSemesterModules.ts @@ -1,8 +1,9 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { StudyPlanDTO } from "@/backend/dtos/study-plan.dto"; import { useMessages } from "@/components/util/useMessages"; -import { StudyPlan, StudyPlannerApiClient } from ".."; +import { StudyPlannerApiClient } from ".."; import useSession from "../useSession"; export const useUpdateSemesterModule = () => { @@ -19,7 +20,7 @@ export const useUpdateSemesterModule = () => { >(api), mutationKey: ["studyPlanner", "studyPlan"], onMutate(variables) { - const prev = queryClient.getQueryData([ + const prev = queryClient.getQueryData([ "studyPlanner", "studyPlan", ]); diff --git a/services/apiClient/index.ts b/services/apiClient/index.ts index 1e2cd5a..07ed757 100644 --- a/services/apiClient/index.ts +++ b/services/apiClient/index.ts @@ -38,6 +38,20 @@ export class StudyPlannerApiClient { return data; } + public async putStudyPlanScope(): Promise { + const res = await fetch(this.url + "/study-plan", { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: this.accessToken, + }, + }); + + const data: boolean = await res.json(); + + return data; + } + public async updateSemesterModules( body: UpdateSemesterModuleInput, ): Promise<{}> { From 486af07705214d262eb1e435f6a3d4672fe9b0cb Mon Sep 17 00:00:00 2001 From: Laurin-Notemann Date: Mon, 29 Jul 2024 13:21:55 +0200 Subject: [PATCH 04/17] fix: change route to /study-plan/[id] --- app/api/study-plan/{ => [id]}/route.ts | 52 ++++++++----------- app/api/utils.ts | 5 ++ .../entities/studyPlanCollaborator.entity.ts | 3 +- .../queries/study-plan-collaborator.query.ts | 16 +++++- services/apiClient/index.ts | 8 +-- 5 files changed, 49 insertions(+), 35 deletions(-) rename app/api/study-plan/{ => [id]}/route.ts (63%) create mode 100644 app/api/utils.ts diff --git a/app/api/study-plan/route.ts b/app/api/study-plan/[id]/route.ts similarity index 63% rename from app/api/study-plan/route.ts rename to app/api/study-plan/[id]/route.ts index 7d7b070..90c6c63 100644 --- a/app/api/study-plan/route.ts +++ b/app/api/study-plan/[id]/route.ts @@ -15,6 +15,7 @@ import { getStudyPlanByCollaboratorId, updateStudyPlanScopeByCollabId, } from "@/backend/queries/study-plan.query"; +import { internalServerErrorResponse, successResponse, unauthorizedResponse } from "../../utils"; const byIndex = (a: SemesterModule, b: SemesterModule) => a.index - b.index; @@ -52,20 +53,23 @@ const mapSemster = (semesters: Semester[]): SemesterDTO[] => { }); }; -export async function GET(req: NextRequest) { - const studyPlanCollaborator = await getCollaborator(req); - - if (!studyPlanCollaborator || studyPlanCollaborator.length === 0) { - return NextResponse.json({}, { status: 401 }); +type StudyPlanParams = { + params: { + id: string } +} + +export async function GET(req: NextRequest, { params }: StudyPlanParams) { + const studyPlanCollaborator = await getCollaborator(req, params.id); - // TODO: Another todo, we are always taking the studyplanner with index 0, possibly needs be some kind of use preference or last viewed or something - const currentCollab = studyPlanCollaborator[0]; + if (!studyPlanCollaborator) { + return unauthorizedResponse(); + } - const currentStudyPlan = await getStudyPlanByCollaboratorId(currentCollab.id); + const currentStudyPlan = await getStudyPlanByCollaboratorId(studyPlanCollaborator.id); if (!currentStudyPlan) { - return NextResponse.json({}, { status: 401 }); + return unauthorizedResponse(); } /* @@ -85,37 +89,27 @@ export async function GET(req: NextRequest) { return NextResponse.json(studyPlan); } -export async function PUT(req: NextRequest) { - const studyPlanCollaborator = await getCollaborator(req); +/** + * Sucessfull response for PUT/POST/DELETE is {ok: true} + */ +export async function PUT(req: NextRequest, { params }: StudyPlanParams) { + const studyPlanCollaborator = await getCollaborator(req, params.id); if ( !studyPlanCollaborator || - studyPlanCollaborator.length === 0 || - studyPlanCollaborator[0].role != CollaboratorRole.Owner + studyPlanCollaborator.role != CollaboratorRole.Owner ) - return NextResponse.json({}, { status: 401 }); - - // TODO: Another todo, we are always taking the studyplanner with index 0, possibly needs be some kind of use preference or last viewed or something - const currentCollab = studyPlanCollaborator[0]; - if (currentCollab.role != CollaboratorRole.Owner) - return NextResponse.json({}, { status: 401 }); + return unauthorizedResponse(); const body: StudyPlanUpdateScopeDTO = await req.json(); const updatePlan = await updateStudyPlanScopeByCollabId( - currentCollab.id, + studyPlanCollaborator.id, body, ); // TODO: Think about better error handling lol - if (!updatePlan) return NextResponse.json({}, { status: 500 }); - - //TODO: What should the Respone be? return StudyPlan? means the semester needs to be remapped, and if react query, refetches GET /api/study-plan that redundant <- really doesn't make sense to return the studyPlan here I think - // const studyPlan: StudyPlanDTO = { - // scope: updatePlan.scope, - // studyPlanCollaborator: updatePlan.studyPlanCollaborator, - // semesters: mappedSemesters, - // }; + if (!updatePlan) return internalServerErrorResponse(); - return NextResponse.json(true); + return successResponse(); } diff --git a/app/api/utils.ts b/app/api/utils.ts new file mode 100644 index 0000000..95bf98a --- /dev/null +++ b/app/api/utils.ts @@ -0,0 +1,5 @@ +import { NextResponse } from "next/server" + +export const successResponse = () => NextResponse.json({ ok: true }) +export const unauthorizedResponse = () => NextResponse.json({ ok: false }, { status: 401 }) +export const internalServerErrorResponse = () => NextResponse.json({ ok: false }, { status: 500 }) diff --git a/backend/entities/studyPlanCollaborator.entity.ts b/backend/entities/studyPlanCollaborator.entity.ts index 620e357..34a3cdc 100644 --- a/backend/entities/studyPlanCollaborator.entity.ts +++ b/backend/entities/studyPlanCollaborator.entity.ts @@ -4,8 +4,8 @@ import { Entity, JoinColumn, ManyToOne, - OneToOne, PrimaryGeneratedColumn, + Unique, type Relation, UpdateDateColumn, } from "typeorm"; @@ -20,6 +20,7 @@ export enum CollaboratorRole { } @Entity({ name: "study_plan_collaborators" }) +@Unique(["studyPlanId", "userId"]) export class StudyPlanCollaborator { @PrimaryGeneratedColumn("uuid") id!: string; diff --git a/backend/queries/study-plan-collaborator.query.ts b/backend/queries/study-plan-collaborator.query.ts index a100b7f..1f877d8 100644 --- a/backend/queries/study-plan-collaborator.query.ts +++ b/backend/queries/study-plan-collaborator.query.ts @@ -29,10 +29,22 @@ export async function getAllCollaboratorOwnerByUserId( } } -export const getCollaborator = async (req: NextRequest) => { +export const getCollaborator = async (req: NextRequest, studyPlanId: string): Promise => { const user = await getUser(req); if (!user) return null; - return await getAllCollaboratorOwnerByUserId(user.id); + const collaborators = await getAllCollaboratorOwnerByUserId(user.id); + + const collaborator = collaborators.length === 0 ? collaborators[0] : null + + return collaborator + + // TODO: currently not using studyPlanId, waiting for frontend Integration + // const collaborator = collaborators.filter(i => i.studyPlanId === studyPlanId); + // + // if (collaborator.length !== 1) + // return null + // + // return collaborator[0]; }; diff --git a/services/apiClient/index.ts b/services/apiClient/index.ts index 07ed757..b8e3535 100644 --- a/services/apiClient/index.ts +++ b/services/apiClient/index.ts @@ -27,7 +27,9 @@ export class StudyPlannerApiClient { } public async getStudyPlan(): Promise { - const res = await fetch(this.url + "/study-plan", { + //TODO: need to be added as a parameter, currently not used in backend either + const studyPlanId = "foo" + const res = await fetch(this.url + "/study-plan/" + studyPlanId, { headers: { "Content-Type": "application/json", Authorization: this.accessToken, @@ -38,8 +40,8 @@ export class StudyPlannerApiClient { return data; } - public async putStudyPlanScope(): Promise { - const res = await fetch(this.url + "/study-plan", { + public async putStudyPlanScope(studyPlanId = "foo"): Promise { + const res = await fetch(this.url + "/study-plan/" + studyPlanId, { method: "PUT", headers: { "Content-Type": "application/json", From 28ed5e5c21249944c5e873c9964439f995f7a933 Mon Sep 17 00:00:00 2001 From: Laurin-Notemann Date: Mon, 29 Jul 2024 13:29:30 +0200 Subject: [PATCH 05/17] fix: add missing getCollaborator refernce --- app/api/study-plan/[id]/route.ts | 21 ++++++++++++------- .../semester-modules/batch-update/route.ts | 13 ++++++------ app/api/utils.ts | 10 +++++---- .../entities/studyPlanCollaborator.entity.ts | 2 +- .../queries/study-plan-collaborator.query.ts | 9 +++++--- services/apiClient/index.ts | 2 +- 6 files changed, 34 insertions(+), 23 deletions(-) diff --git a/app/api/study-plan/[id]/route.ts b/app/api/study-plan/[id]/route.ts index 90c6c63..7336522 100644 --- a/app/api/study-plan/[id]/route.ts +++ b/app/api/study-plan/[id]/route.ts @@ -15,7 +15,12 @@ import { getStudyPlanByCollaboratorId, updateStudyPlanScopeByCollabId, } from "@/backend/queries/study-plan.query"; -import { internalServerErrorResponse, successResponse, unauthorizedResponse } from "../../utils"; + +import { + internalServerErrorResponse, + successResponse, + unauthorizedResponse, +} from "../../utils"; const byIndex = (a: SemesterModule, b: SemesterModule) => a.index - b.index; @@ -55,9 +60,9 @@ const mapSemster = (semesters: Semester[]): SemesterDTO[] => { type StudyPlanParams = { params: { - id: string - } -} + id: string; + }; +}; export async function GET(req: NextRequest, { params }: StudyPlanParams) { const studyPlanCollaborator = await getCollaborator(req, params.id); @@ -66,7 +71,9 @@ export async function GET(req: NextRequest, { params }: StudyPlanParams) { return unauthorizedResponse(); } - const currentStudyPlan = await getStudyPlanByCollaboratorId(studyPlanCollaborator.id); + const currentStudyPlan = await getStudyPlanByCollaboratorId( + studyPlanCollaborator.id, + ); if (!currentStudyPlan) { return unauthorizedResponse(); @@ -90,8 +97,8 @@ export async function GET(req: NextRequest, { params }: StudyPlanParams) { } /** - * Sucessfull response for PUT/POST/DELETE is {ok: true} - */ + * Sucessfull response for PUT/POST/DELETE is {ok: true} + */ export async function PUT(req: NextRequest, { params }: StudyPlanParams) { const studyPlanCollaborator = await getCollaborator(req, params.id); diff --git a/app/api/study-plan/semester-modules/batch-update/route.ts b/app/api/study-plan/semester-modules/batch-update/route.ts index 325ff70..b4f4180 100644 --- a/app/api/study-plan/semester-modules/batch-update/route.ts +++ b/app/api/study-plan/semester-modules/batch-update/route.ts @@ -14,15 +14,14 @@ import { getCollaborator } from "@/backend/queries/study-plan-collaborator.query * note: both `semesterId` and `moduleId` are learning platform ids, not the ids in our database */ export async function PUT(req: NextRequest) { - const studyPlanCollaborator = await getCollaborator(req); + // TODO: Temp until studyPlanId gets used + const tempStudyPlanId = "foo"; + const studyPlanCollaborator = await getCollaborator(req, tempStudyPlanId); - if (!studyPlanCollaborator || studyPlanCollaborator.length === 0) { + if (!studyPlanCollaborator) { return NextResponse.json({}, { status: 401 }); } - // TODO: Another todo, we are always taking the studyplanner with index 0, possibly needs be some kind of use preference or last viewed or something - const currentCollab = studyPlanCollaborator[0]; - const moduleSchema = z.object({ moduleId: z.string(), }); @@ -51,7 +50,7 @@ export async function PUT(req: NextRequest) { "semester.id IN (:...semesterIds) and semester.studyPlanId = :studyPlanId", { semesterIds, - studyPlanId: currentCollab.studyPlanId, + studyPlanId: studyPlanCollaborator.studyPlanId, }, ) .getCount(); @@ -74,7 +73,7 @@ export async function PUT(req: NextRequest) { .where( "semester.studyPlanId = :studyPlanId AND semesterModule.semesterId IN (:...semesterIds)", { - studyPlanId: currentCollab.studyPlanId, + studyPlanId: studyPlanCollaborator.studyPlanId, semesterIds, }, ) diff --git a/app/api/utils.ts b/app/api/utils.ts index 95bf98a..aa3e2d5 100644 --- a/app/api/utils.ts +++ b/app/api/utils.ts @@ -1,5 +1,7 @@ -import { NextResponse } from "next/server" +import { NextResponse } from "next/server"; -export const successResponse = () => NextResponse.json({ ok: true }) -export const unauthorizedResponse = () => NextResponse.json({ ok: false }, { status: 401 }) -export const internalServerErrorResponse = () => NextResponse.json({ ok: false }, { status: 500 }) +export const successResponse = () => NextResponse.json({ ok: true }); +export const unauthorizedResponse = () => + NextResponse.json({ ok: false }, { status: 401 }); +export const internalServerErrorResponse = () => + NextResponse.json({ ok: false }, { status: 500 }); diff --git a/backend/entities/studyPlanCollaborator.entity.ts b/backend/entities/studyPlanCollaborator.entity.ts index 34a3cdc..fdbc38e 100644 --- a/backend/entities/studyPlanCollaborator.entity.ts +++ b/backend/entities/studyPlanCollaborator.entity.ts @@ -5,8 +5,8 @@ import { JoinColumn, ManyToOne, PrimaryGeneratedColumn, - Unique, type Relation, + Unique, UpdateDateColumn, } from "typeorm"; diff --git a/backend/queries/study-plan-collaborator.query.ts b/backend/queries/study-plan-collaborator.query.ts index 1f877d8..bb4e80c 100644 --- a/backend/queries/study-plan-collaborator.query.ts +++ b/backend/queries/study-plan-collaborator.query.ts @@ -29,16 +29,19 @@ export async function getAllCollaboratorOwnerByUserId( } } -export const getCollaborator = async (req: NextRequest, studyPlanId: string): Promise => { +export const getCollaborator = async ( + req: NextRequest, + studyPlanId: string, +): Promise => { const user = await getUser(req); if (!user) return null; const collaborators = await getAllCollaboratorOwnerByUserId(user.id); - const collaborator = collaborators.length === 0 ? collaborators[0] : null + const collaborator = collaborators.length === 0 ? collaborators[0] : null; - return collaborator + return collaborator; // TODO: currently not using studyPlanId, waiting for frontend Integration // const collaborator = collaborators.filter(i => i.studyPlanId === studyPlanId); diff --git a/services/apiClient/index.ts b/services/apiClient/index.ts index b8e3535..7f80cc0 100644 --- a/services/apiClient/index.ts +++ b/services/apiClient/index.ts @@ -28,7 +28,7 @@ export class StudyPlannerApiClient { public async getStudyPlan(): Promise { //TODO: need to be added as a parameter, currently not used in backend either - const studyPlanId = "foo" + const studyPlanId = "foo"; const res = await fetch(this.url + "/study-plan/" + studyPlanId, { headers: { "Content-Type": "application/json", From b8b224a7c595ee0453b6927c669b8001c5534e26 Mon Sep 17 00:00:00 2001 From: Laurin-Notemann Date: Tue, 30 Jul 2024 09:52:40 +0200 Subject: [PATCH 06/17] feat: add id param to batch-update for semester-module --- app/api/study-plan/[id]/route.ts | 14 ++------ .../semester-modules/batch-update/route.ts | 25 ++++++++------ app/api/utils.ts | 8 +++++ backend/dtos/semester-module.dto.ts | 3 ++ backend/dtos/study-plan.dto.ts | 2 +- backend/queries/study-plan.query.ts | 4 +-- services/apiClient/index.ts | 33 ++++++++++++------- 7 files changed, 54 insertions(+), 35 deletions(-) rename app/api/study-plan/{ => [id]}/semester-modules/batch-update/route.ts (90%) diff --git a/app/api/study-plan/[id]/route.ts b/app/api/study-plan/[id]/route.ts index 7336522..0746eac 100644 --- a/app/api/study-plan/[id]/route.ts +++ b/app/api/study-plan/[id]/route.ts @@ -2,10 +2,7 @@ import dayjs from "dayjs"; import { NextRequest, NextResponse } from "next/server"; import { SemesterDTO } from "@/backend/dtos/semester.dto"; -import { - StudyPlanDTO, - StudyPlanUpdateScopeDTO, -} from "@/backend/dtos/study-plan.dto"; +import { StudyPlanDTO, StudyPlanPutDTO } from "@/backend/dtos/study-plan.dto"; import { Semester } from "@/backend/entities/semester.entity"; import { SemesterModule } from "@/backend/entities/semesterModule.entity"; import { CollaboratorRole } from "@/backend/entities/studyPlanCollaborator.entity"; @@ -18,6 +15,7 @@ import { import { internalServerErrorResponse, + StudyPlanParams, successResponse, unauthorizedResponse, } from "../../utils"; @@ -58,12 +56,6 @@ const mapSemster = (semesters: Semester[]): SemesterDTO[] => { }); }; -type StudyPlanParams = { - params: { - id: string; - }; -}; - export async function GET(req: NextRequest, { params }: StudyPlanParams) { const studyPlanCollaborator = await getCollaborator(req, params.id); @@ -108,7 +100,7 @@ export async function PUT(req: NextRequest, { params }: StudyPlanParams) { ) return unauthorizedResponse(); - const body: StudyPlanUpdateScopeDTO = await req.json(); + const body: StudyPlanPutDTO = await req.json(); const updatePlan = await updateStudyPlanScopeByCollabId( studyPlanCollaborator.id, diff --git a/app/api/study-plan/semester-modules/batch-update/route.ts b/app/api/study-plan/[id]/semester-modules/batch-update/route.ts similarity index 90% rename from app/api/study-plan/semester-modules/batch-update/route.ts rename to app/api/study-plan/[id]/semester-modules/batch-update/route.ts index b4f4180..e9650fa 100644 --- a/app/api/study-plan/semester-modules/batch-update/route.ts +++ b/app/api/study-plan/[id]/semester-modules/batch-update/route.ts @@ -1,6 +1,13 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; +import { + badRequestResponse, + internalServerErrorResponse, + StudyPlanParams, + successResponse, + unauthorizedResponse, +} from "@/app/api/utils"; import { AppDataSource, connectToDatabase } from "@/backend/datasource"; import { Module } from "@/backend/entities/module.entity"; import { Semester } from "@/backend/entities/semester.entity"; @@ -13,13 +20,13 @@ import { getCollaborator } from "@/backend/queries/study-plan-collaborator.query /** * note: both `semesterId` and `moduleId` are learning platform ids, not the ids in our database */ -export async function PUT(req: NextRequest) { - // TODO: Temp until studyPlanId gets used - const tempStudyPlanId = "foo"; - const studyPlanCollaborator = await getCollaborator(req, tempStudyPlanId); +export async function PUT(req: NextRequest, { params }: StudyPlanParams) { + console.log("params", params); + + const studyPlanCollaborator = await getCollaborator(req, params.id); if (!studyPlanCollaborator) { - return NextResponse.json({}, { status: 401 }); + return unauthorizedResponse(); } const moduleSchema = z.object({ @@ -37,7 +44,7 @@ export async function PUT(req: NextRequest) { try { body = bodySchema.parse(await req.json()); } catch (err) { - return NextResponse.json({}, { status: 400 }); + return badRequestResponse(); } try { await connectToDatabase(); @@ -146,9 +153,7 @@ export async function PUT(req: NextRequest) { } }); - const res = NextResponse.json({}); - - return res; + return successResponse(); } catch (err) { const isTypeormConstraintViolation = (err as Record).code === "23505"; @@ -164,6 +169,6 @@ export async function PUT(req: NextRequest) { console.error(err); - return NextResponse.json({}, { status: 500 }); + return internalServerErrorResponse(); } } diff --git a/app/api/utils.ts b/app/api/utils.ts index aa3e2d5..ea20e50 100644 --- a/app/api/utils.ts +++ b/app/api/utils.ts @@ -1,7 +1,15 @@ import { NextResponse } from "next/server"; export const successResponse = () => NextResponse.json({ ok: true }); +export const badRequestResponse = () => + NextResponse.json({ ok: false }, { status: 400 }); export const unauthorizedResponse = () => NextResponse.json({ ok: false }, { status: 401 }); export const internalServerErrorResponse = () => NextResponse.json({ ok: false }, { status: 500 }); + +export type StudyPlanParams = { + params: { + id: string; + }; +}; diff --git a/backend/dtos/semester-module.dto.ts b/backend/dtos/semester-module.dto.ts index 9829817..eafa965 100644 --- a/backend/dtos/semester-module.dto.ts +++ b/backend/dtos/semester-module.dto.ts @@ -1,4 +1,5 @@ import { SemesterModule } from "../entities/semesterModule.entity"; +import { ModulesRecordDTO } from "./semester.dto"; export interface SemesterModuleDTO extends Omit< @@ -12,3 +13,5 @@ export interface SemesterModuleDTO | "assessmentType" | "index" > {} + +export type SemesterModulePutDTO = Record; diff --git a/backend/dtos/study-plan.dto.ts b/backend/dtos/study-plan.dto.ts index d608fe7..ac13ba7 100644 --- a/backend/dtos/study-plan.dto.ts +++ b/backend/dtos/study-plan.dto.ts @@ -17,6 +17,6 @@ export interface StudyPlanDTO studyPlanCollaborator: StudyPlanCollaboratorDTO[]; } -export type StudyPlanUpdateScopeDTO = { +export type StudyPlanPutDTO = { scope: StudyPlanScope; }; diff --git a/backend/queries/study-plan.query.ts b/backend/queries/study-plan.query.ts index c8cf2fb..e951c6b 100644 --- a/backend/queries/study-plan.query.ts +++ b/backend/queries/study-plan.query.ts @@ -1,11 +1,11 @@ import { AppDataSource } from "../datasource"; -import { StudyPlanUpdateScopeDTO } from "../dtos/study-plan.dto"; +import { StudyPlanPutDTO } from "../dtos/study-plan.dto"; import { StudyPlan } from "../entities/studyPlan.entity"; import { CollaboratorRole } from "../entities/studyPlanCollaborator.entity"; export async function updateStudyPlanScopeByCollabId( collabId: string, - body: StudyPlanUpdateScopeDTO, + body: StudyPlanPutDTO, ): Promise { try { const studyPlanRepository = AppDataSource.getRepository(StudyPlan); diff --git a/services/apiClient/index.ts b/services/apiClient/index.ts index 7f80cc0..d816614 100644 --- a/services/apiClient/index.ts +++ b/services/apiClient/index.ts @@ -1,8 +1,12 @@ import { CompulsoryElectivePairingDTO } from "@/backend/dtos/compulsory-elective-pairing.dto"; -import { ModulesRecordDTO } from "@/backend/dtos/semester.dto"; -import { StudyPlanDTO } from "@/backend/dtos/study-plan.dto"; +import { SemesterModulePutDTO } from "@/backend/dtos/semester-module.dto"; +import { StudyPlanDTO, StudyPlanPutDTO } from "@/backend/dtos/study-plan.dto"; import { Module } from "@/backend/entities/module.entity"; +type SuccessResponse = { + ok: boolean; +}; + export class StudyPlannerApiClient { constructor( private readonly accessToken: string, @@ -27,7 +31,7 @@ export class StudyPlannerApiClient { } public async getStudyPlan(): Promise { - //TODO: need to be added as a parameter, currently not used in backend either + //TODO: need to be added as a parameter (currently issue with the hooks), currently not used in backend either const studyPlanId = "foo"; const res = await fetch(this.url + "/study-plan/" + studyPlanId, { headers: { @@ -40,7 +44,10 @@ export class StudyPlannerApiClient { return data; } - public async putStudyPlanScope(studyPlanId = "foo"): Promise { + public async putStudyPlanScope( + body: StudyPlanPutDTO, + studyPlanId = "foo", + ): Promise { const res = await fetch(this.url + "/study-plan/" + studyPlanId, { method: "PUT", headers: { @@ -49,16 +56,22 @@ export class StudyPlannerApiClient { }, }); - const data: boolean = await res.json(); + const data: SuccessResponse = await res.json(); return data; } public async updateSemesterModules( - body: UpdateSemesterModuleInput, - ): Promise<{}> { + body: SemesterModulePutDTO, + //studyPlanId: string + ): Promise { + //TODO: need to be added as a parameter (currently issue with the hooks), currently not used in backend either + const studyPlanId = "foo"; const res = await fetch( - this.url + "/study-plan/semester-modules/batch-update", + this.url + + "/study-plan/" + + studyPlanId + + "/semester-modules/batch-update", { headers: { "Content-Type": "application/json", @@ -68,14 +81,12 @@ export class StudyPlannerApiClient { body: JSON.stringify(body), }, ); - const data: {} = await res.json(); + const data: SuccessResponse = await res.json(); return data; } } -export type UpdateSemesterModuleInput = Record; - export type SemesterModuleCategory = | "earlyAssessments" | "standardAssessments" From 436c6ea1496bd5d48eba63317770ccd8b31080cd Mon Sep 17 00:00:00 2001 From: Laurin-Notemann Date: Tue, 30 Jul 2024 09:55:48 +0200 Subject: [PATCH 07/17] fix: change colaborator to colaborators --- app/api/study-plan/[id]/route.ts | 2 +- backend/dtos/study-plan.dto.ts | 4 ++-- backend/entities/studyPlan.entity.ts | 2 +- backend/entities/studyPlanCollaborator.entity.ts | 2 +- backend/queries/study-plan.query.ts | 2 +- components/util/useDragDropContext.ts | 8 +++----- 6 files changed, 9 insertions(+), 11 deletions(-) diff --git a/app/api/study-plan/[id]/route.ts b/app/api/study-plan/[id]/route.ts index 0746eac..692266f 100644 --- a/app/api/study-plan/[id]/route.ts +++ b/app/api/study-plan/[id]/route.ts @@ -81,7 +81,7 @@ export async function GET(req: NextRequest, { params }: StudyPlanParams) { const studyPlan: StudyPlanDTO = { scope: currentStudyPlan.scope, - studyPlanCollaborator: currentStudyPlan.studyPlanCollaborator, + studyPlanCollaborators: currentStudyPlan.studyPlanCollaborators, semesters: mappedSemesters, }; diff --git a/backend/dtos/study-plan.dto.ts b/backend/dtos/study-plan.dto.ts index ac13ba7..163acf3 100644 --- a/backend/dtos/study-plan.dto.ts +++ b/backend/dtos/study-plan.dto.ts @@ -11,10 +11,10 @@ export interface StudyPlanDTO | "moduleHandbook" | "moduleHandbookId" | "semesters" - | "studyPlanCollaborator" + | "studyPlanCollaborators" > { semesters: SemesterDTO[]; - studyPlanCollaborator: StudyPlanCollaboratorDTO[]; + studyPlanCollaborators: StudyPlanCollaboratorDTO[]; } export type StudyPlanPutDTO = { diff --git a/backend/entities/studyPlan.entity.ts b/backend/entities/studyPlan.entity.ts index 8ef210c..152786d 100644 --- a/backend/entities/studyPlan.entity.ts +++ b/backend/entities/studyPlan.entity.ts @@ -43,7 +43,7 @@ export class StudyPlan { cascade: ["remove"], }, ) - studyPlanCollaborator!: Relation[]; + studyPlanCollaborators!: Relation[]; @Column({ type: "enum", diff --git a/backend/entities/studyPlanCollaborator.entity.ts b/backend/entities/studyPlanCollaborator.entity.ts index fdbc38e..079d618 100644 --- a/backend/entities/studyPlanCollaborator.entity.ts +++ b/backend/entities/studyPlanCollaborator.entity.ts @@ -43,7 +43,7 @@ export class StudyPlanCollaborator { @Column() studyPlanId!: string; - @ManyToOne(() => StudyPlan, (studyPlan) => studyPlan.studyPlanCollaborator) + @ManyToOne(() => StudyPlan, (studyPlan) => studyPlan.studyPlanCollaborators) @JoinColumn({ name: "studyPlanId" }) studyPlan!: Relation; diff --git a/backend/queries/study-plan.query.ts b/backend/queries/study-plan.query.ts index e951c6b..c646b7d 100644 --- a/backend/queries/study-plan.query.ts +++ b/backend/queries/study-plan.query.ts @@ -32,7 +32,7 @@ export async function getStudyPlanByCollaboratorId(collabId: string) { return await studyPlanRepository.findOne({ where: { - studyPlanCollaborator: { + studyPlanCollaborators: { hasAccepted: true, role: CollaboratorRole.Owner, id: collabId, diff --git a/components/util/useDragDropContext.ts b/components/util/useDragDropContext.ts index 1770635..42a2f99 100644 --- a/components/util/useDragDropContext.ts +++ b/components/util/useDragDropContext.ts @@ -1,11 +1,9 @@ import { OnDragEndResponder, OnDragStartResponder } from "@hello-pangea/dnd"; +import { SemesterModulePutDTO } from "@/backend/dtos/semester-module.dto"; import { getChatSelectionState } from "@/components/util/useChatSelection"; import { useModulesInScope } from "@/components/util/useModulesInScope"; -import { - SemesterModuleCategory, - UpdateSemesterModuleInput, -} from "@/services/apiClient"; +import { SemesterModuleCategory } from "@/services/apiClient"; import { useStudyPlan } from "@/services/apiClient/hooks/useStudyPlan"; import { useUpdateSemesterModule } from "@/services/apiClient/hooks/useUpdateSemesterModules"; @@ -158,7 +156,7 @@ export function useUpdateStudyPlan() { ) { if (!studyPlan.isSuccess) return; - const body = studyPlan.data.semesters.reduce( + const body = studyPlan.data.semesters.reduce( (acc, semester) => { acc[semester.id] = semester.modules; From 864034ce93de070d11b9d11e75fdbe4721e6d3fd Mon Sep 17 00:00:00 2001 From: Laurin-Notemann Date: Tue, 30 Jul 2024 15:49:41 +0200 Subject: [PATCH 08/17] feat: add invite entity --- app/api/auth/login/route.ts | 15 ++--- .../[id]/collaborators/invites/route.ts | 26 ++++++++ app/api/study-plan/[id]/route.ts | 2 +- backend/dtos/invite.dto.ts | 4 ++ backend/dtos/study-plan.dto.ts | 2 + backend/dtos/user.dto.ts | 15 ++++- backend/entities/enums.ts | 6 ++ backend/entities/invite.entity.ts | 62 +++++++++++++++++++ backend/entities/studyPlan.entity.ts | 25 +++++--- .../entities/studyPlanCollaborator.entity.ts | 12 +--- backend/entities/user.entity.ts | 21 ++++++- .../queries/study-plan-collaborator.query.ts | 30 +++++++-- backend/queries/study-plan.query.ts | 3 +- backend/queries/user.query.ts | 34 ++++++++++ ormconfig.ts | 2 + 15 files changed, 221 insertions(+), 38 deletions(-) create mode 100644 app/api/study-plan/[id]/collaborators/invites/route.ts create mode 100644 backend/dtos/invite.dto.ts create mode 100644 backend/entities/enums.ts create mode 100644 backend/entities/invite.entity.ts create mode 100644 backend/queries/user.query.ts diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts index 526f9e7..f2bc256 100644 --- a/app/api/auth/login/route.ts +++ b/app/api/auth/login/route.ts @@ -3,13 +3,11 @@ import dayjs from "dayjs"; import { NextRequest, NextResponse } from "next/server"; import { AppDataSource, connectToDatabase } from "@/backend/datasource"; +import { CollaboratorRole } from "@/backend/entities/enums"; import { ModuleHandbook } from "@/backend/entities/moduleHandbook.entity"; import { Semester } from "@/backend/entities/semester.entity"; -import { StudyPlan } from "@/backend/entities/studyPlan.entity"; -import { - CollaboratorRole, - StudyPlanCollaborator, -} from "@/backend/entities/studyPlanCollaborator.entity"; +import { StudyPlan, StudyPlanScope } from "@/backend/entities/studyPlan.entity"; +import { StudyPlanCollaborator } from "@/backend/entities/studyPlanCollaborator.entity"; import { User } from "@/backend/entities/user.entity"; import { issueAccessToken } from "@/backend/jwt"; import { isDefined } from "@/services/learningPlatform/util/isDefined"; @@ -79,25 +77,22 @@ export async function POST(req: NextRequest) { if (isSignup) { await AppDataSource.transaction(async (transaction) => { newUser = new User(); - newUser.lpId = learningPlatformUser.me.id; await transaction.getRepository(User).save(newUser); const studyPlan = new StudyPlan(); - studyPlan.moduleHandbookId = moduleHandbook.id; + studyPlan.subjectId = newUser.id; + studyPlan.scope = StudyPlanScope.Private; const newStudyPlan = await transaction .getRepository(StudyPlan) .save(studyPlan); const studyPlanCollaborator = new StudyPlanCollaborator(); - studyPlanCollaborator.hasAccepted = true; studyPlanCollaborator.role = CollaboratorRole.Owner; - studyPlanCollaborator.studyPlanId = newStudyPlan.id; - studyPlanCollaborator.userId = newUser.id; await transaction diff --git a/app/api/study-plan/[id]/collaborators/invites/route.ts b/app/api/study-plan/[id]/collaborators/invites/route.ts new file mode 100644 index 0000000..3a3daf0 --- /dev/null +++ b/app/api/study-plan/[id]/collaborators/invites/route.ts @@ -0,0 +1,26 @@ +import { NextRequest } from "next/server"; + +import { StudyPlanParams, unauthorizedResponse } from "@/app/api/utils"; +import { CollaboratorRole } from "@/backend/entities/enums"; +import { getCollaborator } from "@/backend/queries/study-plan-collaborator.query"; + +export type CollaboratorInvitePostDTO = { + lpId: string; + role: CollaboratorRole; +}; + +export async function POST(req: NextRequest, { params }: StudyPlanParams) { + const studyPlanCollaborator = await getCollaborator(req, params.id); + + if ( + !studyPlanCollaborator || + studyPlanCollaborator.role != CollaboratorRole.Owner + ) + return unauthorizedResponse(); + + const body: CollaboratorInvitePostDTO = await req.json(); +} + +const findUserByEmail = (email: string) => {}; + +const createStudyPlanCollaborator = (userId: string, studyPlanId: string) => {}; diff --git a/app/api/study-plan/[id]/route.ts b/app/api/study-plan/[id]/route.ts index 692266f..a79b603 100644 --- a/app/api/study-plan/[id]/route.ts +++ b/app/api/study-plan/[id]/route.ts @@ -3,9 +3,9 @@ import { NextRequest, NextResponse } from "next/server"; import { SemesterDTO } from "@/backend/dtos/semester.dto"; import { StudyPlanDTO, StudyPlanPutDTO } from "@/backend/dtos/study-plan.dto"; +import { CollaboratorRole } from "@/backend/entities/enums"; import { Semester } from "@/backend/entities/semester.entity"; import { SemesterModule } from "@/backend/entities/semesterModule.entity"; -import { CollaboratorRole } from "@/backend/entities/studyPlanCollaborator.entity"; import { getSemesterByStudyPlanId } from "@/backend/queries/semester.query"; import { getCollaborator } from "@/backend/queries/study-plan-collaborator.query"; import { diff --git a/backend/dtos/invite.dto.ts b/backend/dtos/invite.dto.ts new file mode 100644 index 0000000..e869da2 --- /dev/null +++ b/backend/dtos/invite.dto.ts @@ -0,0 +1,4 @@ +import { Invite } from "../entities/invite.entity"; + +export interface InviteDTO + extends Omit {} diff --git a/backend/dtos/study-plan.dto.ts b/backend/dtos/study-plan.dto.ts index 163acf3..c5b2262 100644 --- a/backend/dtos/study-plan.dto.ts +++ b/backend/dtos/study-plan.dto.ts @@ -12,6 +12,8 @@ export interface StudyPlanDTO | "moduleHandbookId" | "semesters" | "studyPlanCollaborators" + | "subject" + | "subjectId" > { semesters: SemesterDTO[]; studyPlanCollaborators: StudyPlanCollaboratorDTO[]; diff --git a/backend/dtos/user.dto.ts b/backend/dtos/user.dto.ts index 7cbadb2..f75bde9 100644 --- a/backend/dtos/user.dto.ts +++ b/backend/dtos/user.dto.ts @@ -1,7 +1,18 @@ import { User } from "../entities/user.entity"; +import { InviteDTO } from "./invite.dto"; import { StudyPlanCollaboratorDTO } from "./study-plan-collaborator.dto"; +import { StudyPlanDTO } from "./study-plan.dto"; export interface UserDTO - extends Omit { - studyPlanCollaborator: StudyPlanCollaboratorDTO[]; + extends Omit< + User, + | "createdAt" + | "updatedAt" + | "studyPlanCollaborators" + | "studyPlans" + | "invites" + > { + studyPlanCollaborators: StudyPlanCollaboratorDTO[]; + studyPlans: StudyPlanDTO[]; + invites: InviteDTO[]; } diff --git a/backend/entities/enums.ts b/backend/entities/enums.ts new file mode 100644 index 0000000..b7698c2 --- /dev/null +++ b/backend/entities/enums.ts @@ -0,0 +1,6 @@ +export enum CollaboratorRole { + Viewer = "viewer", // can't edit, can't invite and remove collaborators + Editor = "editor", // can edit, can't invite and remove collaborators + Admin = "amdin", // can edit, can invite and remove collaborators + Owner = "owner", // can edit, can invite and remove collaborators, can't be removed, this role can't be assigend, only one who can delete the plan +} diff --git a/backend/entities/invite.entity.ts b/backend/entities/invite.entity.ts new file mode 100644 index 0000000..3ca610a --- /dev/null +++ b/backend/entities/invite.entity.ts @@ -0,0 +1,62 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + type Relation, + UpdateDateColumn, +} from "typeorm"; + +import { CollaboratorRole } from "./enums"; +import { StudyPlan } from "./studyPlan.entity"; +import "./studyPlanCollaborator.entity"; +import { User } from "./user.entity"; + +export enum InviteStatus { + Pending = "pending", + Declined = "declined", + Accepted = "accepted", +} + +@Entity({ name: "invites" }) +export class Invite { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @CreateDateColumn({ select: false }) + createdAt!: Date; + + @UpdateDateColumn({ select: false }) + updatedAt!: Date; + + @Column({ + comment: + "the id of the corresponding resource of the CODE learning platform.", + }) + inviteeLpId!: string; + + @Column({ + type: "enum", + enum: CollaboratorRole, + }) + role!: CollaboratorRole; + + @Column({ type: "enum", enum: InviteStatus }) + status!: InviteStatus; + + @Column() + studyPlanId!: string; + + @ManyToOne(() => StudyPlan, (studyPlan) => studyPlan.studyPlanCollaborators) + @JoinColumn({ name: "studyPlanId" }) + studyPlan!: Relation; + + @Column() + invitedById!: string; + + @ManyToOne(() => User, (user) => user.studyPlanCollaborators) + @JoinColumn({ name: "userId" }) + invitedBy!: Relation; +} diff --git a/backend/entities/studyPlan.entity.ts b/backend/entities/studyPlan.entity.ts index 152786d..c0c44c6 100644 --- a/backend/entities/studyPlan.entity.ts +++ b/backend/entities/studyPlan.entity.ts @@ -13,6 +13,7 @@ import { import { ModuleHandbook } from "./moduleHandbook.entity"; import { Semester } from "./semester.entity"; import { StudyPlanCollaborator } from "./studyPlanCollaborator.entity"; +import { User } from "./user.entity"; export enum StudyPlanScope { Public = "public", @@ -20,6 +21,9 @@ export enum StudyPlanScope { Private = "private", } +/** + * @param subjectId refers to the user which the study plan is about + */ @Entity({ name: "study_plans" }) export class StudyPlan { @PrimaryGeneratedColumn("uuid") @@ -31,6 +35,13 @@ export class StudyPlan { @UpdateDateColumn({ select: false }) updatedAt!: Date; + @Column({ + type: "enum", + enum: StudyPlanScope, + default: StudyPlanScope.Private, + }) + scope!: StudyPlanScope; + @OneToMany(() => Semester, (semester) => semester.studyPlan, { cascade: ["remove"], }) @@ -45,17 +56,17 @@ export class StudyPlan { ) studyPlanCollaborators!: Relation[]; - @Column({ - type: "enum", - enum: StudyPlanScope, - default: StudyPlanScope.Private, - }) - scope!: StudyPlanScope; - @Column() moduleHandbookId!: string; @ManyToOne(() => ModuleHandbook, (handbook) => handbook.studyPlans) @JoinColumn({ name: "moduleHandbookId" }) moduleHandbook!: Relation; + + @Column() + subjectId!: string; + + @ManyToOne(() => User, (user) => user.studyPlans) + @JoinColumn({ name: "subjectId" }) + subject!: Relation; } diff --git a/backend/entities/studyPlanCollaborator.entity.ts b/backend/entities/studyPlanCollaborator.entity.ts index 079d618..79f041d 100644 --- a/backend/entities/studyPlanCollaborator.entity.ts +++ b/backend/entities/studyPlanCollaborator.entity.ts @@ -10,15 +10,10 @@ import { UpdateDateColumn, } from "typeorm"; +import { CollaboratorRole } from "./enums"; import { StudyPlan } from "./studyPlan.entity"; import { User } from "./user.entity"; -export enum CollaboratorRole { - Viewer = "viewer", - Editor = "editor", - Owner = "owner", -} - @Entity({ name: "study_plan_collaborators" }) @Unique(["studyPlanId", "userId"]) export class StudyPlanCollaborator { @@ -31,9 +26,6 @@ export class StudyPlanCollaborator { @UpdateDateColumn({ select: false }) updatedAt!: Date; - @Column() - hasAccepted!: boolean; - @Column({ type: "enum", enum: CollaboratorRole, @@ -50,7 +42,7 @@ export class StudyPlanCollaborator { @Column() userId!: string; - @ManyToOne(() => User, (user) => user.studyPlanCollaborator) + @ManyToOne(() => User, (user) => user.studyPlanCollaborators) @JoinColumn({ name: "userId" }) user!: Relation; } diff --git a/backend/entities/user.entity.ts b/backend/entities/user.entity.ts index 3ab182d..73a5165 100644 --- a/backend/entities/user.entity.ts +++ b/backend/entities/user.entity.ts @@ -5,12 +5,16 @@ import { OneToMany, PrimaryGeneratedColumn, type Relation, + Unique, UpdateDateColumn, } from "typeorm"; +import { Invite } from "./invite.entity"; +import { StudyPlan } from "./studyPlan.entity"; import { StudyPlanCollaborator } from "./studyPlanCollaborator.entity"; @Entity({ name: "users" }) +@Unique(["lpId"]) export class User { @PrimaryGeneratedColumn("uuid") id!: string; @@ -36,5 +40,20 @@ export class User { cascade: ["remove"], }, ) - studyPlanCollaborator!: Relation[]; + studyPlanCollaborators!: Relation[]; + + @OneToMany( + () => StudyPlan, + (studyPlan) => studyPlan.subject, + + { + cascade: ["remove"], + }, + ) + studyPlans!: Relation[]; + + @OneToMany(() => Invite, (invite) => invite.invitedBy, { + cascade: ["remove"], + }) + invites!: Relation[]; } diff --git a/backend/queries/study-plan-collaborator.query.ts b/backend/queries/study-plan-collaborator.query.ts index bb4e80c..943bc3a 100644 --- a/backend/queries/study-plan-collaborator.query.ts +++ b/backend/queries/study-plan-collaborator.query.ts @@ -1,10 +1,8 @@ import { NextRequest } from "next/server"; import { AppDataSource } from "../datasource"; -import { - CollaboratorRole, - StudyPlanCollaborator, -} from "../entities/studyPlanCollaborator.entity"; +import { CollaboratorRole } from "../entities/enums"; +import { StudyPlanCollaborator } from "../entities/studyPlanCollaborator.entity"; import { getUser } from "../getUser"; export async function getAllCollaboratorOwnerByUserId( @@ -17,7 +15,6 @@ export async function getAllCollaboratorOwnerByUserId( return await collaboratorRepository.find({ where: { - hasAccepted: true, role: CollaboratorRole.Owner, user: { id: userId, @@ -29,6 +26,29 @@ export async function getAllCollaboratorOwnerByUserId( } } +export const inviteStudyPlanCollaborator = ( + userId: string, + studyPlanId: string, + role: CollaboratorRole, +): StudyPlanCollaborator | null => { + try { + const collaboratorRepository = AppDataSource.getRepository( + StudyPlanCollaborator, + ); + + const studyPlanCollaborator = collaboratorRepository.create({ + role, + userId, + studyPlanId, + }); + + return studyPlanCollaborator; + } catch (error) { + console.error("inviteStudyPlanCollab error: ", error); + return null; + } +}; + export const getCollaborator = async ( req: NextRequest, studyPlanId: string, diff --git a/backend/queries/study-plan.query.ts b/backend/queries/study-plan.query.ts index c646b7d..167455a 100644 --- a/backend/queries/study-plan.query.ts +++ b/backend/queries/study-plan.query.ts @@ -1,7 +1,7 @@ import { AppDataSource } from "../datasource"; import { StudyPlanPutDTO } from "../dtos/study-plan.dto"; +import { CollaboratorRole } from "../entities/enums"; import { StudyPlan } from "../entities/studyPlan.entity"; -import { CollaboratorRole } from "../entities/studyPlanCollaborator.entity"; export async function updateStudyPlanScopeByCollabId( collabId: string, @@ -33,7 +33,6 @@ export async function getStudyPlanByCollaboratorId(collabId: string) { return await studyPlanRepository.findOne({ where: { studyPlanCollaborators: { - hasAccepted: true, role: CollaboratorRole.Owner, id: collabId, }, diff --git a/backend/queries/user.query.ts b/backend/queries/user.query.ts new file mode 100644 index 0000000..7fd5731 --- /dev/null +++ b/backend/queries/user.query.ts @@ -0,0 +1,34 @@ +import { AppDataSource } from "../datasource"; +import { User } from "../entities/user.entity"; + +export async function getUserByLpId(lpId: string) { + try { + const userRepository = AppDataSource.getRepository(User); + + return await userRepository.findOne({ + where: { + lpId, + }, + }); + } catch (error) { + console.error("getUserByLpId: ", error); + + return null; + } +} + +export async function createUser(lpId: string) { + try { + const userRepository = AppDataSource.getRepository(User); + + return await userRepository.findOne({ + where: { + lpId, + }, + }); + } catch (error) { + console.error("getUserByLpId: ", error); + + return null; + } +} diff --git a/ormconfig.ts b/ormconfig.ts index 83a04fb..d3c9015 100644 --- a/ormconfig.ts +++ b/ormconfig.ts @@ -9,6 +9,7 @@ import { StudyProgram } from "./backend/entities/studyProgram.entity"; import { ModuleHandbook } from "./backend/entities/moduleHandbook.entity"; import { CompulsoryElectivePairing } from "./backend/entities/compulsoryElectivePairing.entity"; import { StudyPlanCollaborator } from "./backend/entities/studyPlanCollaborator.entity"; +import { Invite } from "./backend/entities/invite.entity"; const dataSourceOptions: DataSourceOptions = { type: "postgres", @@ -29,6 +30,7 @@ const dataSourceOptions: DataSourceOptions = { StudyPlanCollaborator, ModuleHandbook, CompulsoryElectivePairing, + Invite ], subscribers: [], migrations: [], From c478826f14cc676867da7ce0e3a8f4e75355c20a Mon Sep 17 00:00:00 2001 From: Laurin-Notemann Date: Tue, 30 Jul 2024 16:11:16 +0200 Subject: [PATCH 09/17] feat: add POST /collaborators/invite --- .../[id]/collaborators/invites/route.ts | 25 ++++++++++++++----- backend/entities/invite.entity.ts | 2 +- backend/queries/invite.query.ts | 22 ++++++++++++++++ backend/queries/user.query.ts | 16 ------------ 4 files changed, 42 insertions(+), 23 deletions(-) create mode 100644 backend/queries/invite.query.ts diff --git a/app/api/study-plan/[id]/collaborators/invites/route.ts b/app/api/study-plan/[id]/collaborators/invites/route.ts index 3a3daf0..a9eeb1e 100644 --- a/app/api/study-plan/[id]/collaborators/invites/route.ts +++ b/app/api/study-plan/[id]/collaborators/invites/route.ts @@ -1,11 +1,17 @@ import { NextRequest } from "next/server"; -import { StudyPlanParams, unauthorizedResponse } from "@/app/api/utils"; +import { + badRequestResponse, + StudyPlanParams, + successResponse, + unauthorizedResponse, +} from "@/app/api/utils"; import { CollaboratorRole } from "@/backend/entities/enums"; +import { createInvite } from "@/backend/queries/invite.query"; import { getCollaborator } from "@/backend/queries/study-plan-collaborator.query"; export type CollaboratorInvitePostDTO = { - lpId: string; + inviteeLpId: string; role: CollaboratorRole; }; @@ -18,9 +24,16 @@ export async function POST(req: NextRequest, { params }: StudyPlanParams) { ) return unauthorizedResponse(); - const body: CollaboratorInvitePostDTO = await req.json(); -} + const { inviteeLpId, role }: CollaboratorInvitePostDTO = await req.json(); + + const invite = createInvite({ + invitedById: studyPlanCollaborator.id, + studyPlanId: params.id, + inviteeLpId, + role, + }); -const findUserByEmail = (email: string) => {}; + if (!invite) return badRequestResponse(); -const createStudyPlanCollaborator = (userId: string, studyPlanId: string) => {}; + return successResponse(); +} diff --git a/backend/entities/invite.entity.ts b/backend/entities/invite.entity.ts index 3ca610a..9608d7e 100644 --- a/backend/entities/invite.entity.ts +++ b/backend/entities/invite.entity.ts @@ -43,7 +43,7 @@ export class Invite { }) role!: CollaboratorRole; - @Column({ type: "enum", enum: InviteStatus }) + @Column({ type: "enum", enum: InviteStatus, default: InviteStatus.Pending }) status!: InviteStatus; @Column() diff --git a/backend/queries/invite.query.ts b/backend/queries/invite.query.ts new file mode 100644 index 0000000..054e5ae --- /dev/null +++ b/backend/queries/invite.query.ts @@ -0,0 +1,22 @@ +import { AppDataSource } from "../datasource"; +import { CollaboratorRole } from "../entities/enums"; +import { Invite } from "../entities/invite.entity"; + +export type CreateInvite = { + invitedById: string; + studyPlanId: string; + inviteeLpId: string; + role: CollaboratorRole; +}; + +export const createInvite = (createInviteBody: CreateInvite): Invite | null => { + try { + const inviteRepository = AppDataSource.getRepository(Invite); + + return inviteRepository.create(createInviteBody); + } catch (error) { + console.error("createInvite: ", error); + + return null; + } +}; diff --git a/backend/queries/user.query.ts b/backend/queries/user.query.ts index 7fd5731..26c4f33 100644 --- a/backend/queries/user.query.ts +++ b/backend/queries/user.query.ts @@ -16,19 +16,3 @@ export async function getUserByLpId(lpId: string) { return null; } } - -export async function createUser(lpId: string) { - try { - const userRepository = AppDataSource.getRepository(User); - - return await userRepository.findOne({ - where: { - lpId, - }, - }); - } catch (error) { - console.error("getUserByLpId: ", error); - - return null; - } -} From 3abcbd8ac2e5e5e351c0a0db822b27c29d085ceb Mon Sep 17 00:00:00 2001 From: Laurin-Notemann Date: Tue, 30 Jul 2024 16:35:42 +0200 Subject: [PATCH 10/17] feat: add postInvite to apiClient --- .../[id]/collaborators/invites/route.ts | 8 +-- backend/dtos/invite.dto.ts | 6 ++ services/apiClient/index.ts | 70 ++++++++++++------- 3 files changed, 53 insertions(+), 31 deletions(-) diff --git a/app/api/study-plan/[id]/collaborators/invites/route.ts b/app/api/study-plan/[id]/collaborators/invites/route.ts index a9eeb1e..0e8bf11 100644 --- a/app/api/study-plan/[id]/collaborators/invites/route.ts +++ b/app/api/study-plan/[id]/collaborators/invites/route.ts @@ -6,15 +6,11 @@ import { successResponse, unauthorizedResponse, } from "@/app/api/utils"; +import { InvitePostDTO } from "@/backend/dtos/invite.dto"; import { CollaboratorRole } from "@/backend/entities/enums"; import { createInvite } from "@/backend/queries/invite.query"; import { getCollaborator } from "@/backend/queries/study-plan-collaborator.query"; -export type CollaboratorInvitePostDTO = { - inviteeLpId: string; - role: CollaboratorRole; -}; - export async function POST(req: NextRequest, { params }: StudyPlanParams) { const studyPlanCollaborator = await getCollaborator(req, params.id); @@ -24,7 +20,7 @@ export async function POST(req: NextRequest, { params }: StudyPlanParams) { ) return unauthorizedResponse(); - const { inviteeLpId, role }: CollaboratorInvitePostDTO = await req.json(); + const { inviteeLpId, role }: InvitePostDTO = await req.json(); const invite = createInvite({ invitedById: studyPlanCollaborator.id, diff --git a/backend/dtos/invite.dto.ts b/backend/dtos/invite.dto.ts index e869da2..0878baa 100644 --- a/backend/dtos/invite.dto.ts +++ b/backend/dtos/invite.dto.ts @@ -1,4 +1,10 @@ +import { CollaboratorRole } from "../entities/enums"; import { Invite } from "../entities/invite.entity"; export interface InviteDTO extends Omit {} + +export type InvitePostDTO = { + inviteeLpId: string; + role: CollaboratorRole; +}; diff --git a/services/apiClient/index.ts b/services/apiClient/index.ts index d816614..1c51b87 100644 --- a/services/apiClient/index.ts +++ b/services/apiClient/index.ts @@ -1,4 +1,5 @@ import { CompulsoryElectivePairingDTO } from "@/backend/dtos/compulsory-elective-pairing.dto"; +import { InvitePostDTO } from "@/backend/dtos/invite.dto"; import { SemesterModulePutDTO } from "@/backend/dtos/semester-module.dto"; import { StudyPlanDTO, StudyPlanPutDTO } from "@/backend/dtos/study-plan.dto"; import { Module } from "@/backend/entities/module.entity"; @@ -33,12 +34,7 @@ export class StudyPlannerApiClient { public async getStudyPlan(): Promise { //TODO: need to be added as a parameter (currently issue with the hooks), currently not used in backend either const studyPlanId = "foo"; - const res = await fetch(this.url + "/study-plan/" + studyPlanId, { - headers: { - "Content-Type": "application/json", - Authorization: this.accessToken, - }, - }); + const res = await this.fetchStudyPlan(studyPlanId, "", "GET", {}); const data: StudyPlanDTO = await res.json(); return data; @@ -48,13 +44,7 @@ export class StudyPlannerApiClient { body: StudyPlanPutDTO, studyPlanId = "foo", ): Promise { - const res = await fetch(this.url + "/study-plan/" + studyPlanId, { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: this.accessToken, - }, - }); + const res = await this.fetchStudyPlan(studyPlanId, "", "PUT", body); const data: SuccessResponse = await res.json(); @@ -67,23 +57,53 @@ export class StudyPlannerApiClient { ): Promise { //TODO: need to be added as a parameter (currently issue with the hooks), currently not used in backend either const studyPlanId = "foo"; - const res = await fetch( - this.url + - "/study-plan/" + - studyPlanId + - "/semester-modules/batch-update", - { + const res = await this.fetchStudyPlan( + studyPlanId, + "/semester-modules/batch-update", + "PUT", + body, + ); + const data: SuccessResponse = await res.json(); + + return data; + } + public async postInviteCollaborator( + body: InvitePostDTO, + //studyPlanId: string + ): Promise { + //TODO: need to be added as a parameter (currently issue with the hooks), currently not used in backend either + const studyPlanId = "foo"; + const res = await this.fetchStudyPlan( + studyPlanId, + "/collaborators/invites", + "POST", + body, + ); + const data: SuccessResponse = await res.json(); + + return data; + } + private async fetchStudyPlan( + studyPlanId: string, + url: string, + method: "PUT" | "GET" | "POST" | "DELETE", + body: any, + ) { + if (method === "GET") + return await fetch(this.url + "/study-plan/" + studyPlanId + url, { headers: { "Content-Type": "application/json", Authorization: this.accessToken, }, - method: "PUT", - body: JSON.stringify(body), + }); + return await fetch(this.url + "/study-plan/" + studyPlanId + url, { + headers: { + "Content-Type": "application/json", + Authorization: this.accessToken, }, - ); - const data: SuccessResponse = await res.json(); - - return data; + method, + body: JSON.stringify(body), + }); } } From 3ae96aa7b08e327231e1fbc92c4541e5f6b3fc4d Mon Sep 17 00:00:00 2001 From: Laurin-Notemann Date: Tue, 30 Jul 2024 17:25:31 +0200 Subject: [PATCH 11/17] feat: add /collaborators/invites/inviteid/accept and decline routes --- .../invites/[inviteId]/accept/route.ts | 44 +++++++++++++++++ .../invites/[inviteId]/decline/route.ts | 38 +++++++++++++++ .../collaborators/invites/[inviteId]/utils.ts | 6 +++ backend/queries/invite.query.ts | 47 ++++++++++++++++++- .../queries/study-plan-collaborator.query.ts | 11 +++-- 5 files changed, 140 insertions(+), 6 deletions(-) create mode 100644 app/api/study-plan/[id]/collaborators/invites/[inviteId]/accept/route.ts create mode 100644 app/api/study-plan/[id]/collaborators/invites/[inviteId]/decline/route.ts create mode 100644 app/api/study-plan/[id]/collaborators/invites/[inviteId]/utils.ts diff --git a/app/api/study-plan/[id]/collaborators/invites/[inviteId]/accept/route.ts b/app/api/study-plan/[id]/collaborators/invites/[inviteId]/accept/route.ts new file mode 100644 index 0000000..935f321 --- /dev/null +++ b/app/api/study-plan/[id]/collaborators/invites/[inviteId]/accept/route.ts @@ -0,0 +1,44 @@ +import { NextRequest } from "next/server"; + +import { + badRequestResponse, + successResponse, + unauthorizedResponse, +} from "@/app/api/utils"; +import { InviteStatus } from "@/backend/entities/invite.entity"; +import { getUser } from "@/backend/getUser"; +import { + getInviteById, + updateInviteStatus, +} from "@/backend/queries/invite.query"; +import { createStudyPlanCollaborator } from "@/backend/queries/study-plan-collaborator.query"; + +import { CollabParams } from "../utils"; + +export async function PUT( + req: NextRequest, + { params: { id, inviteId } }: CollabParams, +) { + const user = await getUser(req); + if (!user) return unauthorizedResponse(); + + const invite = await getInviteById(inviteId); + if (!invite) return badRequestResponse(); + + if (user.lpId !== invite.inviteeLpId) return unauthorizedResponse(); + + const updatedInvite = await updateInviteStatus( + inviteId, + InviteStatus.Accepted, + ); + if (!updatedInvite) return badRequestResponse(); + + const studyPlanCollaborator = createStudyPlanCollaborator( + user.id, + id, + updatedInvite.role, + ); + if (!studyPlanCollaborator) return badRequestResponse(); + + return successResponse(); +} diff --git a/app/api/study-plan/[id]/collaborators/invites/[inviteId]/decline/route.ts b/app/api/study-plan/[id]/collaborators/invites/[inviteId]/decline/route.ts new file mode 100644 index 0000000..83b312c --- /dev/null +++ b/app/api/study-plan/[id]/collaborators/invites/[inviteId]/decline/route.ts @@ -0,0 +1,38 @@ +import { NextRequest } from "next/server"; + +import { + badRequestResponse, + successResponse, + unauthorizedResponse, +} from "@/app/api/utils"; +import { InviteStatus } from "@/backend/entities/invite.entity"; +import { getUser } from "@/backend/getUser"; +import { + getInviteById, + updateInviteStatus, +} from "@/backend/queries/invite.query"; + +import { CollabParams } from "../utils"; + +export async function PUT( + req: NextRequest, + { params: { inviteId } }: CollabParams, +) { + const user = await getUser(req); + + if (!user) return unauthorizedResponse(); + + const invite = await getInviteById(inviteId); + + if (!invite) return badRequestResponse(); + + if (user.lpId !== invite.inviteeLpId) return unauthorizedResponse(); + + const updatedInvite = await updateInviteStatus( + inviteId, + InviteStatus.Declined, + ); + if (!updatedInvite) return badRequestResponse(); + + return successResponse(); +} diff --git a/app/api/study-plan/[id]/collaborators/invites/[inviteId]/utils.ts b/app/api/study-plan/[id]/collaborators/invites/[inviteId]/utils.ts new file mode 100644 index 0000000..b8ea095 --- /dev/null +++ b/app/api/study-plan/[id]/collaborators/invites/[inviteId]/utils.ts @@ -0,0 +1,6 @@ +export type CollabParams = { + params: { + id: string; + inviteId: string; + }; +}; diff --git a/backend/queries/invite.query.ts b/backend/queries/invite.query.ts index 054e5ae..31f8032 100644 --- a/backend/queries/invite.query.ts +++ b/backend/queries/invite.query.ts @@ -1,6 +1,6 @@ import { AppDataSource } from "../datasource"; import { CollaboratorRole } from "../entities/enums"; -import { Invite } from "../entities/invite.entity"; +import { Invite, InviteStatus } from "../entities/invite.entity"; export type CreateInvite = { invitedById: string; @@ -20,3 +20,48 @@ export const createInvite = (createInviteBody: CreateInvite): Invite | null => { return null; } }; + +export const getInviteById = async ( + inviteId: string, +): Promise => { + try { + const inviteRepository = AppDataSource.getRepository(Invite); + + return await inviteRepository.findOne({ + where: { + id: inviteId, + }, + }); + } catch (error) { + console.error("createInvite: ", error); + + return null; + } +}; + +export const updateInviteStatus = async ( + inviteId: string, + status: InviteStatus, +): Promise => { + try { + const inviteRepository = AppDataSource.getRepository(Invite); + + const invite = await inviteRepository.findOne({ + where: { + id: inviteId, + }, + }); + + if (!invite) return null; + + invite.status = status; + + await inviteRepository.save(invite); + + return invite; + } catch (error) { + console.error("createInvite: ", error); + + return null; + } +}; diff --git a/backend/queries/study-plan-collaborator.query.ts b/backend/queries/study-plan-collaborator.query.ts index 943bc3a..474ce56 100644 --- a/backend/queries/study-plan-collaborator.query.ts +++ b/backend/queries/study-plan-collaborator.query.ts @@ -26,25 +26,26 @@ export async function getAllCollaboratorOwnerByUserId( } } -export const inviteStudyPlanCollaborator = ( +export const createStudyPlanCollaborator = ( userId: string, studyPlanId: string, role: CollaboratorRole, ): StudyPlanCollaborator | null => { + // returns null because it is not allowed to set a new owner + if (role === CollaboratorRole.Owner) return null; + try { const collaboratorRepository = AppDataSource.getRepository( StudyPlanCollaborator, ); - const studyPlanCollaborator = collaboratorRepository.create({ + return collaboratorRepository.create({ role, userId, studyPlanId, }); - - return studyPlanCollaborator; } catch (error) { - console.error("inviteStudyPlanCollab error: ", error); + console.error("createStudyPlanCollaborator error: ", error); return null; } }; From 9ea88972818ecc6a6b09d1bada8c9d57877001e1 Mon Sep 17 00:00:00 2001 From: Laurin-Notemann Date: Tue, 30 Jul 2024 17:29:43 +0200 Subject: [PATCH 12/17] feat: add invite accept and decline routes to apiClient --- services/apiClient/index.ts | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/services/apiClient/index.ts b/services/apiClient/index.ts index 1c51b87..b347000 100644 --- a/services/apiClient/index.ts +++ b/services/apiClient/index.ts @@ -83,6 +83,41 @@ export class StudyPlannerApiClient { return data; } + + public async putInviteAccept() //studyPlanId: string + //inviteId: string + : Promise { + //TODO: need to be added as a parameter (currently issue with the hooks), currently not used in backend either + const studyPlanId = "foo"; + const inviteId = "foo"; + const res = await this.fetchStudyPlan( + studyPlanId, + "/collaborators/invites/" + inviteId + "/accept", + "PUT", + {}, + ); + const data: SuccessResponse = await res.json(); + + return data; + } + + public async putInviteDecline() //studyPlanId: string + //inviteId: string + : Promise { + //TODO: need to be added as a parameter (currently issue with the hooks), currently not used in backend either + const studyPlanId = "foo"; + const inviteId = "foo"; + const res = await this.fetchStudyPlan( + studyPlanId, + "/collaborators/invites/" + inviteId + "/decline", + "PUT", + {}, + ); + const data: SuccessResponse = await res.json(); + + return data; + } + private async fetchStudyPlan( studyPlanId: string, url: string, From 7c4fed303327ad1f8e464d47c4c6cfc551c865c4 Mon Sep 17 00:00:00 2001 From: Laurin-Notemann Date: Tue, 30 Jul 2024 17:51:38 +0200 Subject: [PATCH 13/17] feat: add GET /collaborators to get all collaborator for a study plan --- .../study-plan/[id]/collaborators/route.ts | 29 ++++++++++++++++++- .../queries/study-plan-collaborator.query.ts | 18 ++++++++++++ services/apiClient/index.ts | 17 +++++++++++ 3 files changed, 63 insertions(+), 1 deletion(-) diff --git a/app/api/study-plan/[id]/collaborators/route.ts b/app/api/study-plan/[id]/collaborators/route.ts index 6d4a9e3..c236023 100644 --- a/app/api/study-plan/[id]/collaborators/route.ts +++ b/app/api/study-plan/[id]/collaborators/route.ts @@ -1,3 +1,30 @@ -import { NextRequest } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; + +import { + badRequestResponse, + internalServerErrorResponse, + StudyPlanParams, + unauthorizedResponse, +} from "@/app/api/utils"; +import { StudyPlanCollaboratorDTO } from "@/backend/dtos/study-plan-collaborator.dto"; +import { getUser } from "@/backend/getUser"; +import { getAllCollaboratorsByStudyPlanId } from "@/backend/queries/study-plan-collaborator.query"; export async function DELETE(req: NextRequest) {} +export async function GET(req: NextRequest, { params }: StudyPlanParams) { + const user = await getUser(req); + if (!user) return unauthorizedResponse(); + + const studyPlanCollaborators = await getAllCollaboratorsByStudyPlanId( + params.id, + ); + if (studyPlanCollaborators.length === 0) return internalServerErrorResponse(); + + const userIsCollab = studyPlanCollaborators.find((i) => i.userId === user.id); + + if (!userIsCollab) return unauthorizedResponse(); + + const collabs: StudyPlanCollaboratorDTO[] = studyPlanCollaborators; + + return NextResponse.json(collabs); +} diff --git a/backend/queries/study-plan-collaborator.query.ts b/backend/queries/study-plan-collaborator.query.ts index 474ce56..6381af7 100644 --- a/backend/queries/study-plan-collaborator.query.ts +++ b/backend/queries/study-plan-collaborator.query.ts @@ -26,6 +26,24 @@ export async function getAllCollaboratorOwnerByUserId( } } +export async function getAllCollaboratorsByStudyPlanId( + studyPlanId: string, +): Promise { + try { + const collaboratorRepository = AppDataSource.getRepository( + StudyPlanCollaborator, + ); + + return await collaboratorRepository.find({ + where: { + studyPlanId, + }, + }); + } catch (error) { + return []; + } +} + export const createStudyPlanCollaborator = ( userId: string, studyPlanId: string, diff --git a/services/apiClient/index.ts b/services/apiClient/index.ts index b347000..4cf441f 100644 --- a/services/apiClient/index.ts +++ b/services/apiClient/index.ts @@ -1,6 +1,7 @@ import { CompulsoryElectivePairingDTO } from "@/backend/dtos/compulsory-elective-pairing.dto"; import { InvitePostDTO } from "@/backend/dtos/invite.dto"; import { SemesterModulePutDTO } from "@/backend/dtos/semester-module.dto"; +import { StudyPlanCollaboratorDTO } from "@/backend/dtos/study-plan-collaborator.dto"; import { StudyPlanDTO, StudyPlanPutDTO } from "@/backend/dtos/study-plan.dto"; import { Module } from "@/backend/entities/module.entity"; @@ -40,6 +41,22 @@ export class StudyPlannerApiClient { return data; } + public async getStudyPlanCollaborators(): Promise< + StudyPlanCollaboratorDTO[] + > { + //TODO: need to be added as a parameter (currently issue with the hooks), currently not used in backend either + const studyPlanId = "foo"; + const res = await this.fetchStudyPlan( + studyPlanId, + "/collaborators", + "GET", + {}, + ); + const data: StudyPlanCollaboratorDTO[] = await res.json(); + + return data; + } + public async putStudyPlanScope( body: StudyPlanPutDTO, studyPlanId = "foo", From 71917d4f108482c0c3184a8055ed0f4edfedde72 Mon Sep 17 00:00:00 2001 From: Laurin-Notemann Date: Tue, 30 Jul 2024 19:45:11 +0200 Subject: [PATCH 14/17] feat: add collaborators/id PUT and DELETE routes --- .../[id]/collaborators/[collabId]/route.ts | 74 +++++++++++++++++++ .../study-plan/[id]/collaborators/route.ts | 1 - backend/dtos/study-plan-collaborator.dto.ts | 5 ++ .../queries/study-plan-collaborator.query.ts | 34 +++++++++ services/apiClient/index.ts | 37 +++++++++- 5 files changed, 149 insertions(+), 2 deletions(-) create mode 100644 app/api/study-plan/[id]/collaborators/[collabId]/route.ts diff --git a/app/api/study-plan/[id]/collaborators/[collabId]/route.ts b/app/api/study-plan/[id]/collaborators/[collabId]/route.ts new file mode 100644 index 0000000..9d3c1b8 --- /dev/null +++ b/app/api/study-plan/[id]/collaborators/[collabId]/route.ts @@ -0,0 +1,74 @@ +import { NextRequest } from "next/server"; + +import { + internalServerErrorResponse, + successResponse, + unauthorizedResponse, +} from "@/app/api/utils"; +import { StudyPlanCollaboratorPutDTO } from "@/backend/dtos/study-plan-collaborator.dto"; +import { CollaboratorRole } from "@/backend/entities/enums"; +import { getUser } from "@/backend/getUser"; +import { + deleteCollaboratorById, + getAllCollaboratorsByStudyPlanId, + updateCollaboratorById, +} from "@/backend/queries/study-plan-collaborator.query"; + +export type CollaborationParams = { + params: { + id: string; + collabId: string; + }; +}; + +export async function DELETE( + req: NextRequest, + { params: { id: studyPlanId, collabId } }: CollaborationParams, +) { + const user = await getUser(req); + if (!user) return unauthorizedResponse(); + + const studyPlanCollaborators = + await getAllCollaboratorsByStudyPlanId(studyPlanId); + const userCollaborator = studyPlanCollaborators.find( + (collaborator) => collaborator.userId === user.id, + ); + if ( + !userCollaborator || + userCollaborator.role === CollaboratorRole.Viewer || + userCollaborator.role === CollaboratorRole.Editor + ) + return unauthorizedResponse(); + + const deleteCollaborator = await deleteCollaboratorById(collabId); + if (!deleteCollaborator) return internalServerErrorResponse(); + + return successResponse(); +} + +export async function PUT( + req: NextRequest, + { params: { id: studyPlanId, collabId } }: CollaborationParams, +) { + const user = await getUser(req); + if (!user) return unauthorizedResponse(); + + const studyPlanCollaborators = + await getAllCollaboratorsByStudyPlanId(studyPlanId); + const userCollaborator = studyPlanCollaborators.find( + (collaborator) => collaborator.userId === user.id, + ); + if ( + !userCollaborator || + userCollaborator.role === CollaboratorRole.Viewer || + userCollaborator.role === CollaboratorRole.Editor + ) + return unauthorizedResponse(); + + const body: StudyPlanCollaboratorPutDTO = await req.json(); + const updatedCollaborator = await updateCollaboratorById(collabId, body); + + if (!updatedCollaborator) return internalServerErrorResponse(); + + return successResponse(); +} diff --git a/app/api/study-plan/[id]/collaborators/route.ts b/app/api/study-plan/[id]/collaborators/route.ts index c236023..6e05cc3 100644 --- a/app/api/study-plan/[id]/collaborators/route.ts +++ b/app/api/study-plan/[id]/collaborators/route.ts @@ -1,7 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { - badRequestResponse, internalServerErrorResponse, StudyPlanParams, unauthorizedResponse, diff --git a/backend/dtos/study-plan-collaborator.dto.ts b/backend/dtos/study-plan-collaborator.dto.ts index b52b17f..1da9992 100644 --- a/backend/dtos/study-plan-collaborator.dto.ts +++ b/backend/dtos/study-plan-collaborator.dto.ts @@ -1,3 +1,4 @@ +import { CollaboratorRole } from "../entities/enums"; import { StudyPlanCollaborator } from "../entities/studyPlanCollaborator.entity"; import { UserDTO } from "./user.dto"; @@ -6,3 +7,7 @@ export interface StudyPlanCollaboratorDTO StudyPlanCollaborator, "createdAt" | "updatedAt" | "studyPlan" | "user" > {} + +export type StudyPlanCollaboratorPutDTO = { + role: CollaboratorRole; +}; diff --git a/backend/queries/study-plan-collaborator.query.ts b/backend/queries/study-plan-collaborator.query.ts index 6381af7..123b219 100644 --- a/backend/queries/study-plan-collaborator.query.ts +++ b/backend/queries/study-plan-collaborator.query.ts @@ -1,6 +1,7 @@ import { NextRequest } from "next/server"; import { AppDataSource } from "../datasource"; +import { StudyPlanCollaboratorPutDTO } from "../dtos/study-plan-collaborator.dto"; import { CollaboratorRole } from "../entities/enums"; import { StudyPlanCollaborator } from "../entities/studyPlanCollaborator.entity"; import { getUser } from "../getUser"; @@ -68,6 +69,39 @@ export const createStudyPlanCollaborator = ( } }; +export const deleteCollaboratorById = async (collabId: string) => { + try { + const collaboratorRepository = AppDataSource.getRepository( + StudyPlanCollaborator, + ); + + return await collaboratorRepository.delete({ + id: collabId, + }); + } catch (error) { + console.error("createStudyPlanCollaborator error: ", error); + return null; + } +}; + +export const updateCollaboratorById = async ( + collabId: string, + body: StudyPlanCollaboratorPutDTO, +) => { + try { + const collaboratorRepository = AppDataSource.getRepository( + StudyPlanCollaborator, + ); + + await collaboratorRepository.update({ id: collabId }, body); + + return await collaboratorRepository.findOne({ where: { id: collabId } }); + } catch (error) { + console.error("createStudyPlanCollaborator error: ", error); + return null; + } +}; + export const getCollaborator = async ( req: NextRequest, studyPlanId: string, diff --git a/services/apiClient/index.ts b/services/apiClient/index.ts index 4cf441f..78c26c5 100644 --- a/services/apiClient/index.ts +++ b/services/apiClient/index.ts @@ -1,7 +1,10 @@ import { CompulsoryElectivePairingDTO } from "@/backend/dtos/compulsory-elective-pairing.dto"; import { InvitePostDTO } from "@/backend/dtos/invite.dto"; import { SemesterModulePutDTO } from "@/backend/dtos/semester-module.dto"; -import { StudyPlanCollaboratorDTO } from "@/backend/dtos/study-plan-collaborator.dto"; +import { + StudyPlanCollaboratorDTO, + StudyPlanCollaboratorPutDTO, +} from "@/backend/dtos/study-plan-collaborator.dto"; import { StudyPlanDTO, StudyPlanPutDTO } from "@/backend/dtos/study-plan.dto"; import { Module } from "@/backend/entities/module.entity"; @@ -57,6 +60,38 @@ export class StudyPlannerApiClient { return data; } + public async deleteStudyPlanCollaborator(): Promise { + //TODO: need to be added as a parameter (currently issue with the hooks), currently not used in backend either + const studyPlanId = "foo"; + const collabId = "foo"; + const res = await this.fetchStudyPlan( + studyPlanId, + "/collaborators/" + collabId, + "DELETE", + {}, + ); + const data: SuccessResponse = await res.json(); + + return data; + } + + public async putStudyPlanCollaboratorRole( + body: StudyPlanCollaboratorPutDTO, + ): Promise { + //TODO: need to be added as a parameter (currently issue with the hooks), currently not used in backend either + const studyPlanId = "foo"; + const collabId = "foo"; + const res = await this.fetchStudyPlan( + studyPlanId, + "/collaborators/" + collabId, + "PUT", + body, + ); + const data: SuccessResponse = await res.json(); + + return data; + } + public async putStudyPlanScope( body: StudyPlanPutDTO, studyPlanId = "foo", From 6b0d1f119ed4699574334e80a6cd72b762bd8b6b Mon Sep 17 00:00:00 2001 From: Laurin-Notemann Date: Tue, 30 Jul 2024 21:50:39 +0200 Subject: [PATCH 15/17] fix: !== instead of === --- backend/queries/study-plan-collaborator.query.ts | 2 +- services/apiClient/index.ts | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/backend/queries/study-plan-collaborator.query.ts b/backend/queries/study-plan-collaborator.query.ts index 123b219..cdb86cf 100644 --- a/backend/queries/study-plan-collaborator.query.ts +++ b/backend/queries/study-plan-collaborator.query.ts @@ -112,7 +112,7 @@ export const getCollaborator = async ( const collaborators = await getAllCollaboratorOwnerByUserId(user.id); - const collaborator = collaborators.length === 0 ? collaborators[0] : null; + const collaborator = collaborators.length !== 0 ? collaborators[0] : null; return collaborator; diff --git a/services/apiClient/index.ts b/services/apiClient/index.ts index 78c26c5..23d617c 100644 --- a/services/apiClient/index.ts +++ b/services/apiClient/index.ts @@ -183,6 +183,14 @@ export class StudyPlannerApiClient { Authorization: this.accessToken, }, }); + if (method === "DELETE") + return await fetch(this.url + "/study-plan/" + studyPlanId + url, { + headers: { + "Content-Type": "application/json", + Authorization: this.accessToken, + }, + method: "DELETE", + }); return await fetch(this.url + "/study-plan/" + studyPlanId + url, { headers: { "Content-Type": "application/json", From 5a2c7d86ef4a2f90804ee0c196b3d338fc7f7520 Mon Sep 17 00:00:00 2001 From: Laurin-Notemann Date: Wed, 31 Jul 2024 15:18:30 +0200 Subject: [PATCH 16/17] fix: add save to typeorm queries --- .../[id]/collaborators/invites/[inviteId]/accept/route.ts | 2 +- app/api/study-plan/[id]/collaborators/invites/route.ts | 2 +- backend/queries/invite.query.ts | 5 +++-- backend/queries/study-plan-collaborator.query.ts | 7 ++++--- backend/queries/study-plan.query.ts | 4 +--- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/app/api/study-plan/[id]/collaborators/invites/[inviteId]/accept/route.ts b/app/api/study-plan/[id]/collaborators/invites/[inviteId]/accept/route.ts index 935f321..ec96dc2 100644 --- a/app/api/study-plan/[id]/collaborators/invites/[inviteId]/accept/route.ts +++ b/app/api/study-plan/[id]/collaborators/invites/[inviteId]/accept/route.ts @@ -33,7 +33,7 @@ export async function PUT( ); if (!updatedInvite) return badRequestResponse(); - const studyPlanCollaborator = createStudyPlanCollaborator( + const studyPlanCollaborator = await createStudyPlanCollaborator( user.id, id, updatedInvite.role, diff --git a/app/api/study-plan/[id]/collaborators/invites/route.ts b/app/api/study-plan/[id]/collaborators/invites/route.ts index 0e8bf11..a69aff0 100644 --- a/app/api/study-plan/[id]/collaborators/invites/route.ts +++ b/app/api/study-plan/[id]/collaborators/invites/route.ts @@ -22,7 +22,7 @@ export async function POST(req: NextRequest, { params }: StudyPlanParams) { const { inviteeLpId, role }: InvitePostDTO = await req.json(); - const invite = createInvite({ + const invite = await createInvite({ invitedById: studyPlanCollaborator.id, studyPlanId: params.id, inviteeLpId, diff --git a/backend/queries/invite.query.ts b/backend/queries/invite.query.ts index 31f8032..2affa12 100644 --- a/backend/queries/invite.query.ts +++ b/backend/queries/invite.query.ts @@ -9,11 +9,12 @@ export type CreateInvite = { role: CollaboratorRole; }; -export const createInvite = (createInviteBody: CreateInvite): Invite | null => { +export const createInvite = async (createInviteBody: CreateInvite): Promise => { try { const inviteRepository = AppDataSource.getRepository(Invite); - return inviteRepository.create(createInviteBody); + const invite = inviteRepository.create(createInviteBody) + return await inviteRepository.save(invite); } catch (error) { console.error("createInvite: ", error); diff --git a/backend/queries/study-plan-collaborator.query.ts b/backend/queries/study-plan-collaborator.query.ts index cdb86cf..d196ae9 100644 --- a/backend/queries/study-plan-collaborator.query.ts +++ b/backend/queries/study-plan-collaborator.query.ts @@ -45,11 +45,11 @@ export async function getAllCollaboratorsByStudyPlanId( } } -export const createStudyPlanCollaborator = ( +export const createStudyPlanCollaborator = async ( userId: string, studyPlanId: string, role: CollaboratorRole, -): StudyPlanCollaborator | null => { +): Promise => { // returns null because it is not allowed to set a new owner if (role === CollaboratorRole.Owner) return null; @@ -58,11 +58,12 @@ export const createStudyPlanCollaborator = ( StudyPlanCollaborator, ); - return collaboratorRepository.create({ + const collaborator = collaboratorRepository.create({ role, userId, studyPlanId, }); + return await collaboratorRepository.save(collaborator) } catch (error) { console.error("createStudyPlanCollaborator error: ", error); return null; diff --git a/backend/queries/study-plan.query.ts b/backend/queries/study-plan.query.ts index 167455a..c6d032e 100644 --- a/backend/queries/study-plan.query.ts +++ b/backend/queries/study-plan.query.ts @@ -16,9 +16,7 @@ export async function updateStudyPlanScopeByCollabId( studyPlan.scope = body.scope; - await studyPlanRepository.save(studyPlan); - - return studyPlan; + return await studyPlanRepository.save(studyPlan); } catch (error) { console.error("UpdateStudyPlanScope: ", error); From 5125a449e16650421a8fd9d737e0966c4bf39a68 Mon Sep 17 00:00:00 2001 From: Linus Balls <82451500+LinusBolls@users.noreply.github.com> Date: Sat, 3 Aug 2024 00:39:33 +0200 Subject: [PATCH 17/17] refactor: extract collaborator permissions into seperate class (#4) --- .../[id]/collaborators/[collabId]/route.ts | 34 +++---------------- .../[id]/collaborators/invites/route.ts | 11 ++---- .../study-plan/[id]/collaborators/route.ts | 21 ++++-------- app/api/study-plan/[id]/route.ts | 19 ++++------- .../semester-modules/batch-update/route.ts | 8 ++--- .../entities/studyPlanCollaborator.entity.ts | 31 +++++++++++++++++ backend/queries/invite.query.ts | 6 ++-- .../queries/study-plan-collaborator.query.ts | 23 +++++++------ 8 files changed, 72 insertions(+), 81 deletions(-) diff --git a/app/api/study-plan/[id]/collaborators/[collabId]/route.ts b/app/api/study-plan/[id]/collaborators/[collabId]/route.ts index 9d3c1b8..17ef97d 100644 --- a/app/api/study-plan/[id]/collaborators/[collabId]/route.ts +++ b/app/api/study-plan/[id]/collaborators/[collabId]/route.ts @@ -6,11 +6,9 @@ import { unauthorizedResponse, } from "@/app/api/utils"; import { StudyPlanCollaboratorPutDTO } from "@/backend/dtos/study-plan-collaborator.dto"; -import { CollaboratorRole } from "@/backend/entities/enums"; -import { getUser } from "@/backend/getUser"; import { deleteCollaboratorById, - getAllCollaboratorsByStudyPlanId, + getCollaborator, updateCollaboratorById, } from "@/backend/queries/study-plan-collaborator.query"; @@ -25,20 +23,9 @@ export async function DELETE( req: NextRequest, { params: { id: studyPlanId, collabId } }: CollaborationParams, ) { - const user = await getUser(req); - if (!user) return unauthorizedResponse(); + const collaborator = await getCollaborator(req, studyPlanId); - const studyPlanCollaborators = - await getAllCollaboratorsByStudyPlanId(studyPlanId); - const userCollaborator = studyPlanCollaborators.find( - (collaborator) => collaborator.userId === user.id, - ); - if ( - !userCollaborator || - userCollaborator.role === CollaboratorRole.Viewer || - userCollaborator.role === CollaboratorRole.Editor - ) - return unauthorizedResponse(); + if (!collaborator?.canManageCollaborators) return unauthorizedResponse(); const deleteCollaborator = await deleteCollaboratorById(collabId); if (!deleteCollaborator) return internalServerErrorResponse(); @@ -50,20 +37,9 @@ export async function PUT( req: NextRequest, { params: { id: studyPlanId, collabId } }: CollaborationParams, ) { - const user = await getUser(req); - if (!user) return unauthorizedResponse(); + const collaborator = await getCollaborator(req, studyPlanId); - const studyPlanCollaborators = - await getAllCollaboratorsByStudyPlanId(studyPlanId); - const userCollaborator = studyPlanCollaborators.find( - (collaborator) => collaborator.userId === user.id, - ); - if ( - !userCollaborator || - userCollaborator.role === CollaboratorRole.Viewer || - userCollaborator.role === CollaboratorRole.Editor - ) - return unauthorizedResponse(); + if (!collaborator?.canManageCollaborators) return unauthorizedResponse(); const body: StudyPlanCollaboratorPutDTO = await req.json(); const updatedCollaborator = await updateCollaboratorById(collabId, body); diff --git a/app/api/study-plan/[id]/collaborators/invites/route.ts b/app/api/study-plan/[id]/collaborators/invites/route.ts index a69aff0..89d8ff0 100644 --- a/app/api/study-plan/[id]/collaborators/invites/route.ts +++ b/app/api/study-plan/[id]/collaborators/invites/route.ts @@ -7,23 +7,18 @@ import { unauthorizedResponse, } from "@/app/api/utils"; import { InvitePostDTO } from "@/backend/dtos/invite.dto"; -import { CollaboratorRole } from "@/backend/entities/enums"; import { createInvite } from "@/backend/queries/invite.query"; import { getCollaborator } from "@/backend/queries/study-plan-collaborator.query"; export async function POST(req: NextRequest, { params }: StudyPlanParams) { - const studyPlanCollaborator = await getCollaborator(req, params.id); + const collaborator = await getCollaborator(req, params.id); - if ( - !studyPlanCollaborator || - studyPlanCollaborator.role != CollaboratorRole.Owner - ) - return unauthorizedResponse(); + if (!collaborator?.canManageCollaborators) return unauthorizedResponse(); const { inviteeLpId, role }: InvitePostDTO = await req.json(); const invite = await createInvite({ - invitedById: studyPlanCollaborator.id, + invitedById: collaborator.id, studyPlanId: params.id, inviteeLpId, role, diff --git a/app/api/study-plan/[id]/collaborators/route.ts b/app/api/study-plan/[id]/collaborators/route.ts index 6e05cc3..e17c006 100644 --- a/app/api/study-plan/[id]/collaborators/route.ts +++ b/app/api/study-plan/[id]/collaborators/route.ts @@ -1,27 +1,20 @@ import { NextRequest, NextResponse } from "next/server"; -import { - internalServerErrorResponse, - StudyPlanParams, - unauthorizedResponse, -} from "@/app/api/utils"; +import { StudyPlanParams, unauthorizedResponse } from "@/app/api/utils"; import { StudyPlanCollaboratorDTO } from "@/backend/dtos/study-plan-collaborator.dto"; -import { getUser } from "@/backend/getUser"; -import { getAllCollaboratorsByStudyPlanId } from "@/backend/queries/study-plan-collaborator.query"; +import { + getAllCollaboratorsByStudyPlanId, + getCollaborator, +} from "@/backend/queries/study-plan-collaborator.query"; export async function DELETE(req: NextRequest) {} export async function GET(req: NextRequest, { params }: StudyPlanParams) { - const user = await getUser(req); - if (!user) return unauthorizedResponse(); + const collaborator = await getCollaborator(req, params.id); const studyPlanCollaborators = await getAllCollaboratorsByStudyPlanId( params.id, ); - if (studyPlanCollaborators.length === 0) return internalServerErrorResponse(); - - const userIsCollab = studyPlanCollaborators.find((i) => i.userId === user.id); - - if (!userIsCollab) return unauthorizedResponse(); + if (!collaborator?.canViewCollaborators) return unauthorizedResponse(); const collabs: StudyPlanCollaboratorDTO[] = studyPlanCollaborators; diff --git a/app/api/study-plan/[id]/route.ts b/app/api/study-plan/[id]/route.ts index a79b603..dd9ff31 100644 --- a/app/api/study-plan/[id]/route.ts +++ b/app/api/study-plan/[id]/route.ts @@ -3,7 +3,6 @@ import { NextRequest, NextResponse } from "next/server"; import { SemesterDTO } from "@/backend/dtos/semester.dto"; import { StudyPlanDTO, StudyPlanPutDTO } from "@/backend/dtos/study-plan.dto"; -import { CollaboratorRole } from "@/backend/entities/enums"; import { Semester } from "@/backend/entities/semester.entity"; import { SemesterModule } from "@/backend/entities/semesterModule.entity"; import { getSemesterByStudyPlanId } from "@/backend/queries/semester.query"; @@ -57,15 +56,13 @@ const mapSemster = (semesters: Semester[]): SemesterDTO[] => { }; export async function GET(req: NextRequest, { params }: StudyPlanParams) { - const studyPlanCollaborator = await getCollaborator(req, params.id); + const collaborator = await getCollaborator(req, params.id); - if (!studyPlanCollaborator) { + if (!collaborator?.canViewStudyPlan) { return unauthorizedResponse(); } - const currentStudyPlan = await getStudyPlanByCollaboratorId( - studyPlanCollaborator.id, - ); + const currentStudyPlan = await getStudyPlanByCollaboratorId(collaborator.id); if (!currentStudyPlan) { return unauthorizedResponse(); @@ -92,18 +89,14 @@ export async function GET(req: NextRequest, { params }: StudyPlanParams) { * Sucessfull response for PUT/POST/DELETE is {ok: true} */ export async function PUT(req: NextRequest, { params }: StudyPlanParams) { - const studyPlanCollaborator = await getCollaborator(req, params.id); + const collaborator = await getCollaborator(req, params.id); - if ( - !studyPlanCollaborator || - studyPlanCollaborator.role != CollaboratorRole.Owner - ) - return unauthorizedResponse(); + if (!collaborator?.canChangeStudyPlanScope) return unauthorizedResponse(); const body: StudyPlanPutDTO = await req.json(); const updatePlan = await updateStudyPlanScopeByCollabId( - studyPlanCollaborator.id, + collaborator.id, body, ); diff --git a/app/api/study-plan/[id]/semester-modules/batch-update/route.ts b/app/api/study-plan/[id]/semester-modules/batch-update/route.ts index e9650fa..6d0f37f 100644 --- a/app/api/study-plan/[id]/semester-modules/batch-update/route.ts +++ b/app/api/study-plan/[id]/semester-modules/batch-update/route.ts @@ -23,9 +23,9 @@ import { getCollaborator } from "@/backend/queries/study-plan-collaborator.query export async function PUT(req: NextRequest, { params }: StudyPlanParams) { console.log("params", params); - const studyPlanCollaborator = await getCollaborator(req, params.id); + const collaborator = await getCollaborator(req, params.id); - if (!studyPlanCollaborator) { + if (!collaborator?.canModifyStudyPlan) { return unauthorizedResponse(); } @@ -57,7 +57,7 @@ export async function PUT(req: NextRequest, { params }: StudyPlanParams) { "semester.id IN (:...semesterIds) and semester.studyPlanId = :studyPlanId", { semesterIds, - studyPlanId: studyPlanCollaborator.studyPlanId, + studyPlanId: collaborator.studyPlanId, }, ) .getCount(); @@ -80,7 +80,7 @@ export async function PUT(req: NextRequest, { params }: StudyPlanParams) { .where( "semester.studyPlanId = :studyPlanId AND semesterModule.semesterId IN (:...semesterIds)", { - studyPlanId: studyPlanCollaborator.studyPlanId, + studyPlanId: collaborator.studyPlanId, semesterIds, }, ) diff --git a/backend/entities/studyPlanCollaborator.entity.ts b/backend/entities/studyPlanCollaborator.entity.ts index 79f041d..47fb8ae 100644 --- a/backend/entities/studyPlanCollaborator.entity.ts +++ b/backend/entities/studyPlanCollaborator.entity.ts @@ -45,4 +45,35 @@ export class StudyPlanCollaborator { @ManyToOne(() => User, (user) => user.studyPlanCollaborators) @JoinColumn({ name: "userId" }) user!: Relation; + + private get isViewer() { + return this.role === CollaboratorRole.Viewer; + } + private get isEditor() { + return this.role === CollaboratorRole.Editor; + } + private get isAdmin() { + return this.role === CollaboratorRole.Admin; + } + private get isOwner() { + return this.role === CollaboratorRole.Owner; + } + + public get canViewStudyPlan() { + return true; + } + public get canModifyStudyPlan() { + return this.isEditor || this.isAdmin || this.isOwner; + } + + public get canViewCollaborators() { + return true; + } + public get canManageCollaborators() { + return this.isAdmin || this.isOwner; + } + + public get canChangeStudyPlanScope() { + return this.isOwner; + } } diff --git a/backend/queries/invite.query.ts b/backend/queries/invite.query.ts index 2affa12..a3351cf 100644 --- a/backend/queries/invite.query.ts +++ b/backend/queries/invite.query.ts @@ -9,11 +9,13 @@ export type CreateInvite = { role: CollaboratorRole; }; -export const createInvite = async (createInviteBody: CreateInvite): Promise => { +export const createInvite = async ( + createInviteBody: CreateInvite, +): Promise => { try { const inviteRepository = AppDataSource.getRepository(Invite); - const invite = inviteRepository.create(createInviteBody) + const invite = inviteRepository.create(createInviteBody); return await inviteRepository.save(invite); } catch (error) { console.error("createInvite: ", error); diff --git a/backend/queries/study-plan-collaborator.query.ts b/backend/queries/study-plan-collaborator.query.ts index d196ae9..71498d7 100644 --- a/backend/queries/study-plan-collaborator.query.ts +++ b/backend/queries/study-plan-collaborator.query.ts @@ -6,6 +6,8 @@ import { CollaboratorRole } from "../entities/enums"; import { StudyPlanCollaborator } from "../entities/studyPlanCollaborator.entity"; import { getUser } from "../getUser"; +const WIP_EXPERIMENTAL_STUDY_PLAN_ID_ENFORCEMENT = false; + export async function getAllCollaboratorOwnerByUserId( userId: string, ): Promise { @@ -63,7 +65,7 @@ export const createStudyPlanCollaborator = async ( userId, studyPlanId, }); - return await collaboratorRepository.save(collaborator) + return await collaboratorRepository.save(collaborator); } catch (error) { console.error("createStudyPlanCollaborator error: ", error); return null; @@ -111,17 +113,16 @@ export const getCollaborator = async ( if (!user) return null; - const collaborators = await getAllCollaboratorOwnerByUserId(user.id); + const studyPlanCollaborators = WIP_EXPERIMENTAL_STUDY_PLAN_ID_ENFORCEMENT + ? await getAllCollaboratorsByStudyPlanId(studyPlanId) + : await getAllCollaboratorOwnerByUserId(user.id); - const collaborator = collaborators.length !== 0 ? collaborators[0] : null; + const userCollaborator = + studyPlanCollaborators.find( + (collaborator) => collaborator.userId === user.id, + ) ?? null; - return collaborator; + if (!userCollaborator) return null; - // TODO: currently not using studyPlanId, waiting for frontend Integration - // const collaborator = collaborators.filter(i => i.studyPlanId === studyPlanId); - // - // if (collaborator.length !== 1) - // return null - // - // return collaborator[0]; + return userCollaborator; };