diff --git a/mobile/services/student-service.ts b/mobile/services/student-service.ts index 3063b15..8c9b912 100644 --- a/mobile/services/student-service.ts +++ b/mobile/services/student-service.ts @@ -13,6 +13,24 @@ export interface Student { // Add other fields as needed } +export type StudentGender = 'MALE' | 'FEMALE' | 'OTHER'; +export type StudentRole = 'STUDENT'; + +export interface CreateStudentPayload { + name: string; + email: string; + password: string; + gender: StudentGender; + academicYearId: string; + role?: StudentRole; + phone?: string; + address?: string; + dateOfBirth?: string; // ISO date string + image?: string; + parentId?: string; + classId?: string; +} + export async function getStudents(page = 1, limit = 10): Promise> { return apiRequest>(`/students?page=${page}&limit=${limit}`); } @@ -20,3 +38,10 @@ export async function getStudents(page = 1, limit = 10): Promise { return apiRequest(`/students/${id}`); } + +export async function createStudent(payload: CreateStudentPayload): Promise { + return apiRequest('/students', { + method: 'POST', + body: payload, + }); +} diff --git a/server/AGENT.md b/server/AGENT.md index d621bca..7c065dc 100644 --- a/server/AGENT.md +++ b/server/AGENT.md @@ -1,137 +1,150 @@ -# Agent Task: Centralized Academic Year Context - -## Context - -This is a NestJS backend (`server/`) using Prisma + pnpm, in a monorepo with a mobile app (Expo/React Native) and a web app (Vite/React). We follow a strict workflow: - -- **One git branch per module/feature, one squashed commit per branch.** -- Branch naming: `//` — scope is `server`, `mobile`, or `repo`; type follows Conventional Commits (`feat`, `fix`, `refactor`, etc.). -- Commit messages follow **Conventional Commits**. -- The `academic-years` module is the **canonical reference implementation** for DTOs, Swagger decorators, Prisma usage, error handling, test coverage, and linting. Match its patterns exactly — don't invent new conventions. -- Swagger: class-factory pattern for `PaginatedResponse(itemType)`, per-controller `@ApiExtraModels()`, `isArray: true` on `@ApiOkResponse` for raw arrays, `PaginationMetaDto` for pagination metadata. - -## Goal - -Right now, every module that needs an `academicYearId` (Class, Enrollment, TeachingAssignment, Attendance, Exam, Result) requires the caller to pass it explicitly. We want a **centralized "active academic year" context** so: - -1. The frontend can set a global "session year" (like a header dropdown showing available years, with the current one marked as default), and every subsequent API call is implicitly scoped to that year via an `x-academic-year-id` header. -2. If no header is sent, requests fall back to whichever `AcademicYear` row has `isCurrent = true`. -3. Callers can still explicitly pass `academicYearId` in a DTO/query to override — this is not a hard global-only restriction, just a smart default with fallback resolution. - -The `AcademicYear` model already exists in `schema.prisma` with an `isCurrent` boolean. - -## Work Breakdown (execute as separate branches, in this order) - ---- - -### Branch 1 — `server/feat/academic-year-context` - -Create a shared, request-scoped context module that resolves the active academic year for the current request. - -**Files to create** under `src/common/academic-year/`: - -- `academic-year-context.service.ts` -- `academic-year-context.module.ts` - -**Requirements:** - -- `AcademicYearContextService` is `@Injectable({ scope: Scope.REQUEST })`, injects `REQUEST` (from `@nestjs/core`) and `PrismaService`. -- Exposes `async getActiveId(): Promise`: - 1. Reads header `x-academic-year-id` (export this string as a named constant `ACADEMIC_YEAR_HEADER` so it isn't a magic string anywhere else in the codebase). - 2. If present, validate the id exists in `academic_years` via `findUnique`. If it doesn't exist, throw `NotFoundException` with a clear message referencing the header name. - 3. If absent, query `academicYear.findFirst({ where: { isCurrent: true } })`. If none exists, throw `NotFoundException` explaining that no year is marked current and no header was provided. - 4. Cache the resolved id on the instance for the lifetime of the request (it's request-scoped, so this is safe) to avoid resolving twice in the same request. -- `AcademicYearContextModule` is `@Global()`, imports `PrismaModule`, provides and exports `AcademicYearContextService`. Register it in `AppModule.imports` (one-time addition — call this out explicitly in the PR/commit description since it touches `app.module.ts`, which other branches also touch). - -**Tests:** Follow the `academic-years` module's existing test conventions. Cover: - -- Resolves from header when present and valid. -- Throws `NotFoundException` when header id doesn't exist. -- Falls back to `isCurrent: true` row when no header. -- Throws `NotFoundException` when no header and no current year exists. -- Caches the result — verify Prisma is only queried once even if `getActiveId()` is called twice within the same (mocked) request scope. - -**Commit:** `feat(server): add request-scoped academic year context resolver` - ---- - -### Branch 2 — `server/feat/academic-years-set-current` - -Extend the existing `academic-years` module (service + controller) with the ability to mark a year as current, and to fetch the current one directly. - -**Service additions:** - -- `setCurrent(id: string): Promise` — verify the id exists (reuse whatever `findOneOrThrow`-style helper the module already has; if none exists, follow the module's existing not-found error pattern). Inside a `prisma.$transaction`, first `updateMany({ where: { isCurrent: true }, data: { isCurrent: false } })`, then `update({ where: { id }, data: { isCurrent: true } })`, returning the updated row. -- `getCurrent(): Promise` — `findFirst({ where: { isCurrent: true } })`; throw the module's standard not-found error if none exists. - -**Controller additions** (match existing Swagger decorator style in this controller exactly): - -- `PATCH /academic-years/:id/set-current` → calls `setCurrent`. `@ApiOperation`, `@ApiOkResponse({ type: AcademicYearResponseDto })`. -- `GET /academic-years/current` → calls `getCurrent`. **Route ordering matters** — this static route must be declared _before_ any `GET /academic-years/:id` route in the controller, or Nest will try to match `"current"` as an `:id` param. - -**Also add a migration note (do not auto-generate a migration that changes indexes) in the PR description** flagging that Prisma cannot express a partial unique index, and that a DB-level guard for "only one `isCurrent = true` row" would need a manual SQL addition to a migration: - -```sql -CREATE UNIQUE INDEX academic_years_one_current_idx ON academic_years ((true)) WHERE "isCurrent" = true; -``` - -Do not add this automatically — just leave the note. The app-level transaction in `setCurrent` is the enforced guarantee for now. - -**Tests:** cover `setCurrent` (including that it un-sets any previously current year) and `getCurrent` (including the not-found case), following existing module test patterns. - -**Commit:** `feat(server): add set-current and get-current endpoints to academic-years module` - ---- - -### Branch 3 — `server/feat/class-academic-year-default` - -Wire the `Class` module onto the context resolver as the first consumer (this becomes the template for later modules). - -**Changes:** - -- In `CreateClassDto`, make `academicYearId` optional (`@IsOptional() @IsString()`), update its `@ApiPropertyOptional` description to state it defaults to the active academic year when omitted. -- In `ClassesService`, inject `AcademicYearContextService`. In `create()`: - ```ts - const academicYearId = - dto.academicYearId ?? (await this.academicYearContext.getActiveId()); - ``` -- In the list/query DTO for `findAll`, keep `academicYearId` as an optional filter — same fallback pattern (explicit query param wins, else active context). Don't force every list call through the header; the query param override must keep working for admins deliberately browsing another year. -- Do **not** change the `@@unique([name, academicYearId])` constraint or anything else in `schema.prisma`. - -**Tests:** add/update service tests to cover: - -- `create()` uses `dto.academicYearId` when explicitly provided (context resolver not called, or called and ignored — assert via mock that the explicit value wins). -- `create()` falls back to `academicYearContext.getActiveId()` when omitted. -- `findAll()` same override/fallback behavior for the query filter. - -**Commit:** `feat(server): default Class academicYearId to active academic year context` - ---- - -### Branches 4+ — repeat the Branch 3 pattern per remaining module - -One branch each, same two-line pattern (`dto.academicYearId ?? await this.academicYearContext.getActiveId()`) applied to `create` (and `findAll` filters where applicable): - -1. `server/feat/enrollment-academic-year-default` -2. `server/feat/teaching-assignment-academic-year-default` -3. `server/feat/attendance-academic-year-default` -4. `server/feat/exam-academic-year-default` -5. `server/feat/result-academic-year-default` - -For each: inject `AcademicYearContextService`, apply the fallback in `create`, keep any existing `academicYearId` query filters overridable, add/update tests mirroring Branch 3, and use commit message `feat(server): default academicYearId to active academic year context`. - -## Constraints / Things Not To Do - -- Do not touch `mobile/` or the web app in any of these branches — backend only. -- Do not modify `schema.prisma` (no new fields, no new migrations) — `isCurrent` already exists. -- Do not remove the ability to pass an explicit `academicYearId` anywhere — this is an additive default, not a breaking change. -- Do not add the partial unique index migration automatically — flag it in the PR description only, as noted above. -- Match the `academic-years` module's existing file layout, DTO validation style, error classes, and test structure exactly. If something in this prompt conflicts with an established pattern in that module, follow the module's existing pattern and note the deviation in your summary. -- Each branch should be independently reviewable and buildable — Branch 1 must land (or at least exist) before Branches 2–8 can compile, since they all depend on `AcademicYearContextService`. - -## Definition of Done (per branch) - -- `pnpm lint` and `pnpm test` pass. -- New/changed public methods have Swagger decorators matching the reference module. -- Squashed into a single commit on the branch, Conventional Commits format, as specified above. -- Short PR/commit description summarizing what changed and calling out the two flags above (the `AppModule` import in Branch 1, and the manual index SQL note in Branch 2). +# Module Alignment Task — `students` + +## Objective + +Bring `server/src/modules/students/` into full structural, stylistic, and +behavioral conformance with the two reference implementations: + +- **`academic-years`** — canonical reference for DTO shape, Swagger decorators, + Prisma query style, error handling, and test structure. +- **`classes`** — canonical reference for hydrated-response GET endpoints, + flat-ID write DTOs, and reusable response DTOs across POST/GET. + +Do not invent new patterns. Every deviation you fix should be justified by +pointing at the specific line/pattern in one of the two reference modules. + +This is a single-module pass. Do not touch any other module. + +## Before you touch anything + +1. Read `server/src/modules/academic-years/` in full: controller, service, + module, all DTOs, and both spec files. +2. Read `server/src/modules/classes/` in full, same scope. +3. Read `server/CONTRIBUTING.md` (or root `CONTRIBUTION-GUIDELINE.md`) for + branch naming and commit conventions. +4. Read `server/src/common/academic-year-context/academic-year-context.service.ts` + to confirm the current fallback contract: + `dto.academicYearId ?? await this.academicYearContext.getActiveId()`. + +Do not start editing target modules until you can articulate, in your own +words, why each reference file looks the way it does. + +## Target module + +`students` only — controller, service, module, all DTOs +(`student.dto.ts`, `create-student.dto.ts`, `update-student.dto.ts`, +`query-student-dto.ts`, `student-response.dto.ts`), and both spec files. + +Do this work directly on the current branch — do not create a new branch. +One squashed commit on merge. + +## Alignment checklist (apply to `students`) + +### DTOs + +- [ ] Request DTOs are **flat**: relations are referenced by ID + (`studentIds: string[]`, `academicYearId: string`, etc.), never nested + objects, on both create and update DTOs. +- [ ] Response DTOs are **fully hydrated**: nested relation objects, not bare + IDs, matching what `classes`' `ClassResponseDto` does. +- [ ] Where a POST (201) and GET (200) return the same shape, they share one + response DTO class — do not duplicate it. +- [ ] `academicYearId` handling follows the two-line fallback pattern + exactly where the entity is year-scoped: + `dto.academicYearId ?? await this.academicYearContext.getActiveId()`. +- [ ] Query/filter DTOs extend the common `QueryDto` / pagination DTO the + same way `academic-years`' does — same param names, same decorators. +- [ ] All DTOs have complete `@ApiProperty` / `@ApiPropertyOptional` and + `class-validator` decorators — no bare fields copied without + validation. + +### Swagger + +- [ ] Controller endpoints have the full decorator set used in + `academic-years.controller.ts` / `classes.controller.ts`: + `@ApiOperation`, `@ApiResponse` (success + relevant error codes), + `@ApiParam`/`@ApiQuery` where applicable. +- [ ] Any paginated list endpoint uses the `PaginatedResponse(ItemDto)` + class-factory from `server/src/common/dto/paginated-response.dto.ts`, + with `@ApiExtraModels` on the controller — never a raw generic. + +### Prisma query style + +- [ ] Reads use `select` (recursively, down through nested relations), not + `include`, so the Prisma result shape matches the response DTO shape + without a manual mapping/formatter step doing the heavy lifting. +- [ ] Where a formatter in `server/src/common/formatters/` already exists + for this entity, the service uses it rather than re-implementing + shaping logic inline. If one doesn't exist and the module needs one, + create it following the shape of `class.formatter.ts` / + `student.formatter.ts`. +- [ ] Many-to-many relations with any metadata use explicit join models + (à la `ClassTeacher`), not Prisma implicit many-to-many. Flag (don't + silently fix) any implicit m2m you find — that's a schema/migration + change, out of scope for a pure alignment pass unless instructed. +- [ ] Cascade behavior matches the established convention: + `SetNull` where dependent rows should survive a parent delete, + `Restrict`/`NoAction` where deletion must be blocked until refs are + cleared. Flag any inconsistency rather than changing schema + unilaterally. + +### Error handling + +- [ ] Uses the shared `PrismaClientExceptionFilter` / `AllExceptionsFilter` + path — no ad hoc try/catch swallowing Prisma errors inside services. +- [ ] Not-found / conflict cases throw the same Nest exception types + (`NotFoundException`, `ConflictException`, etc.) that + `academic-years.service.ts` throws for equivalent cases. + +### Tests + +- [ ] `*.service.spec.ts` and `*.controller.spec.ts` exist and mirror the + structure/coverage of `academic-years.service.spec.ts` and + `academic-years.controller.spec.ts` — same describe-block + organization, same use of mocked Prisma service, same edge cases + covered (not-found, validation failure, academic-year fallback). +- [ ] No test is deleted or weakened to make the alignment pass — if a + behavior change breaks a test, the test should be updated to assert + the new (correct) behavior, not removed. + +### Naming & structure + +- [ ] File and class naming matches the convention exactly: + `.dto.ts`, `create-.dto.ts`, `update-.dto.ts`, + `query--dto.ts` or `query-.dto.ts` (match whichever + the reference modules use — check both, they may differ slightly; + note the discrepancy if so rather than guessing). +- [ ] No dead code, no leftover TODOs from earlier scaffolding, no unused + imports. + +## Process + +1. Diff `students`' DTOs/service/controller against the reference pattern. + Write a short list of concrete deviations before changing code. +2. Fix deviations in small, reviewable commits on the branch (these get + squashed on merge, but keep them logically separable while working). +3. Run the module's existing tests; update/extend them to cover whatever + you changed. +4. Run `pnpm lint` and `pnpm build` (or the Makefile targets) before + considering the branch done. +5. Squash to one commit, Conventional Commits format, e.g.: + `refactor(server): align students module with academic-years pattern` +6. Open the PR against `development`, squash-merge per `CONTRIBUTING.md`. + +## Explicitly out of scope for this pass + +- Any module other than `students`. +- Schema/migration changes (new fields, new join tables, cascade behavior + changes) — flag these as findings, don't implement them here. +- The open term/semester-layer question — not part of this alignment work. +- Mobile app wiring — backend only. + +## Deliverable + +Once complete on the current branch, open a squashed PR targeting the +`development` branch that brings `students` to parity with +`academic-years`/`classes`, plus a short findings note (in the PR +description) for anything you deliberately left unchanged because it needed +a schema decision rather than a pure refactor. + +Once merged, delete this file. diff --git a/server/MODULE_ALIGNMENT.md b/server/MODULE_ALIGNMENT.md deleted file mode 100644 index 57edd14..0000000 --- a/server/MODULE_ALIGNMENT.md +++ /dev/null @@ -1,88 +0,0 @@ -You are working in the `server/` NestJS backend of a School Management System monorepo. -The `academic-years` module is the reference implementation — fully polished, with Prisma -integration, Swagger docs, DTOs, and tests. Your job is to bring every other module up to -that same standard, one module at a time. - -## Step 0 — Study the reference module first - -Before touching anything else, thoroughly read every file in the `academic-years` module -(controller, service, module, DTOs, entities/mappers, any Swagger decorators, validation -pipes, error handling, and its spec/e2e tests). Extract and internalize its conventions, -including but not limited to: - -- Folder/file structure and naming -- DTO structure (request/response, validation decorators from class-validator) -- Swagger decorators: @ApiTags, @ApiOperation, @ApiOkResponse (note: raw arrays use - `@ApiOkResponse({ type: XDto, isArray: true })` at the controller level), - @ApiExtraModels usage per-controller (not accumulated in main.ts), date fields typed - as `string` with `format: 'date-time'` -- Prisma query patterns in the service layer (select/include shape, error handling, - use of transactions if any) -- How relations are exposed in responses (e.g. StudentEnrollment-style join handling) -- Response wrapping/pagination pattern, if present, and note that NestJS Swagger doesn't - support true generics — check if a class-factory pattern is used for paginated responses -- Linting/formatting conventions (this repo's eslint.config.mjs and .prettierrc — - singleQuote, trailingComma: all) -- Test coverage style (unit + e2e) for the module - -Do not proceed to Step 1 until you can summarize these conventions back concisely. - -## Step 1 — Inventory the target modules - -List every other module under `server/src` (or wherever modules live) that needs to be -aligned. Cross-reference against the schema models: AcademicYear (done), StudentEnrollment, -ClassTeacher, Class, Attendance, Exam, Assignment, Result — plus any others that already -exist in the codebase (e.g. Students, Teachers, Subjects if present on the server side). -Confirm the current state of each (stub-only, partially built, or mismatched conventions) -before making changes. - -## Step 2 — Git workflow: strictly one branch = one commit = one module - -Branches for these modules already exist. For each module: - -1. Check out its existing branch (do not create a new one). -2. Make ALL changes needed to bring that module fully in line with the academic-years - conventions — and only that module. Do not touch files belonging to other modules in - this branch/commit. -3. Stage and create exactly ONE commit for the whole module, using Conventional Commits - format, e.g.: - `refactor(server): align class-teacher module with academic-years conventions` - or `feat(server): add swagger docs to attendance module` - (use `fix`/`feat`/`refactor` as appropriate to what was actually done) -4. Do not push. Leave each branch with its single commit for review. -5. Move to the next module's branch and repeat. - -If a branch's existing work is already partially aligned, still land the remaining changes -as a single commit rather than multiple incremental commits. - -## Step 3 — Per-module checklist (apply to each) - -For every module, ensure: - -- [ ] DTOs exist for all request/response shapes with proper class-validator decorators -- [ ] Swagger decorators applied consistently (@ApiTags, @ApiOperation, @ApiOkResponse, - @ApiExtraModels per-controller) matching the academic-years pattern exactly -- [ ] Array responses use `isArray: true` at the controller decorator level, not wrapped - DTOs unless the reference module does that -- [ ] Date fields typed as `string` + `format: 'date-time'` -- [ ] Many-to-many relationships use explicit join models (already the case for - ClassTeacher) rather than implicit Prisma join tables -- [ ] Unique constraints match schema intent (e.g. compound `@@unique` where a global - unique would be wrong — mirror the Class.name + academicYearId pattern for anything - analogous) -- [ ] Denormalized fields (e.g. academicYearId on Result) are handled the same way in - the service layer as the reference module -- [ ] Error handling (NotFoundException, etc.) matches the reference module's approach -- [ ] Code passes `pnpm lint` and `pnpm format` with no new warnings -- [ ] Existing tests still pass; add/update unit tests to match reference module's - test coverage style - -## Step 4 — Report back - -After each module's commit, give a short summary of what changed and flag anything where -the module's requirements genuinely diverge from academic-years (e.g. Result's -denormalized academicYearId) so those intentional differences aren't mistaken for -inconsistency. - -Do not restructure the academic-years module itself — it's the source of truth. -Do not squash or rewrite other branches' existing history beyond adding this one commit. diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index a8787bf..803ee58 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -163,9 +163,6 @@ model AcademicYear { results Result[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - // classes Class[] - // attendances Attendance[] - // exams Exam[] @@map("academic_years") } @@ -177,12 +174,6 @@ model Class { teachingAllocations TeachingAllocation[] announcements Announcement[] deletedAt DateTime? - // academicYearId String - // academicYear AcademicYear @relation(fields: [academicYearId], references: [id]) - // gradeId String - // grade Grade @relation(fields: [gradeId], references: [id]) - // @@unique([gradeId, name]) - // @@unique([name, academicYearId]) @@unique([name]) @@map("classes") @@ -228,7 +219,6 @@ model TeachingAllocation { academicYear AcademicYear @relation(fields: [academicYearId], references: [id]) timetableEntries Timetable[] exams Exam[] - // assignments Assignment[] @@unique([teacherId, subjectId, classId, academicYearId]) @@map("teaching_assignments") diff --git a/server/src/app.module.ts b/server/src/app.module.ts index 1094e9a..3062b4c 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -17,7 +17,6 @@ import { AuthModule as LocalAuthModule } from './modules/auth/auth.module.js'; import { ClassesModule } from './modules/classes/classes.module'; import { EnrollmentsModule } from './modules/enrollments/enrollments.module.js'; import { ExamsModule } from './modules/exams/exams.module'; -import { ParentsModule } from './modules/parents/parents.module'; import { ResultsModule } from './modules/results/results.module'; import { StudentsModule } from './modules/students/students.module'; import { SubjectsModule } from './modules/subjects/subjects.module'; @@ -59,7 +58,6 @@ const rateLimiter = rateLimit({ StudentsModule, AcademicYearsModule, ClassesModule, - ParentsModule, TeachersModule, AdminsModule, SubjectsModule, diff --git a/server/src/common/dto/create-user.dto.ts b/server/src/common/dto/create-user.dto.ts index 245f96a..c2bb539 100644 --- a/server/src/common/dto/create-user.dto.ts +++ b/server/src/common/dto/create-user.dto.ts @@ -1,3 +1,4 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsDateString, IsEmail, @@ -5,46 +6,89 @@ import { IsNotEmpty, IsOptional, IsString, + IsStrongPassword, MaxLength, } from 'class-validator'; -import { UserGender, UserRole } from '../../../prisma/generated/prisma/client'; +import { UserGender } from '../../../prisma/generated/prisma/client'; export class CreateUserDto { + @ApiProperty({ + type: String, + example: 'john doe', + }) @IsString() @IsNotEmpty() - @MaxLength(200) + @MaxLength(400) name!: string; + @ApiProperty({ + type: String, + example: 'johndoe@example.com', + }) @IsNotEmpty() @IsEmail() email!: string; + @ApiProperty({ + type: String, + example: 'password123', + }) @IsString() + @IsStrongPassword() @IsNotEmpty() password!: string; + @ApiPropertyOptional({ + type: String, + example: '123456789', + nullable: true, + }) @IsString() - @IsNotEmpty() + @IsOptional() @MaxLength(15) - phone!: string | null; + phone?: string | null; + @ApiPropertyOptional({ + type: String, + example: '123 Main St, Anytown', + nullable: true, + }) @IsString() @IsOptional() - @MaxLength(500) - address!: string | null; + @MaxLength(200) + address?: string | null; + @ApiProperty({ + type: String, + example: 'MALE', + }) + @IsNotEmpty() @IsEnum(UserGender) gender!: UserGender; + @ApiPropertyOptional({ + type: String, + example: '2005-08-24T00:00:00.000Z', + nullable: true, + }) @IsDateString() @IsOptional() - dateOfBirth!: string | null; + dateOfBirth?: string | null; + @ApiPropertyOptional({ + type: String, + example: 'http://example.com/image.png', + nullable: true, + }) @IsString() @IsOptional() - image!: string | null; + image?: string | null; - @IsEnum(UserRole) - @IsOptional() - role!: UserRole; + // @ApiProperty({ + // type: String, + // example: 'STUDENT', + // }) + // @IsNotEmpty() + // @IsEnum(UserRole) + // role!: UserRole; } diff --git a/server/src/common/formatters/parent.formatter.ts b/server/src/common/formatters/parent.formatter.ts deleted file mode 100644 index 59a4a0d..0000000 --- a/server/src/common/formatters/parent.formatter.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Parent } from '../../../prisma/generated/prisma/client.js'; -import { ParentResponseDto } from '../../modules/parents/dto/parent-response.dto.js'; -// import { ParentWithRelations } from '../types/parent.type.js'; - -export function formatParent(parent: Parent): ParentResponseDto { - return { - id: parent.id, - name: parent.name, - phone: parent.phone, - address: parent.address, - // students: parent.students - // ? parent.students.map((ps) => ({ - // id: ps.student.id, - // name: ps.student.user.name, - // })) - // : null, - createdAt: parent.createdAt, - updatedAt: parent.updatedAt, - }; -} diff --git a/server/src/common/formatters/student.formatter.ts b/server/src/common/formatters/student.formatter.ts index 6a17519..e11db7b 100644 --- a/server/src/common/formatters/student.formatter.ts +++ b/server/src/common/formatters/student.formatter.ts @@ -5,21 +5,27 @@ export function formatStudent( student: StudentWithRelations, ): StudentResponseDto { return { - id: student.user.id, + id: student.id, + userId: student.user.id, name: student.user.name, email: student.user.email, - studentId: student.studentNumber, + studentNumber: student.studentNumber, image: student.user.image, + phone: student.phone, + address: student.address, + gender: student.gender, + dateOfBirth: student.dateOfBirth ? student.dateOfBirth.toISOString() : null, + parent_student_relationship: student.parents?.[0]?.relationship ?? null, parent: student.parents?.[0]?.parent ? { - id: student.parents[0].parent.id, name: student.parents[0].parent.name, + phone: student.parents[0].parent.phone, + address: student.parents[0].parent.address, } : null, class: student.enrollments?.[0]?.class ? { - id: student.enrollments[0].class.id, - name: student.enrollments[0].class.name, + name: student.enrollments?.[0].class.name, } : null, setPasswordToken: student.user.setPasswordToken, diff --git a/server/src/common/formatters/user.formatter.ts b/server/src/common/formatters/user.formatter.ts index ef82853..01f9a78 100644 --- a/server/src/common/formatters/user.formatter.ts +++ b/server/src/common/formatters/user.formatter.ts @@ -1,5 +1,5 @@ import { UserGender } from '../../../prisma/generated/prisma/client'; -export function formatGender(gender: string): UserGender { +export function formatGender(gender: UserGender): UserGender { return gender.toUpperCase() as UserGender; } diff --git a/server/src/database/seeders/seed-students.ts b/server/src/database/seeders/seed-students.ts index b1e47f9..faf1686 100644 --- a/server/src/database/seeders/seed-students.ts +++ b/server/src/database/seeders/seed-students.ts @@ -1,12 +1,16 @@ import { INestApplicationContext } from '@nestjs/common'; import { UserGender } from '../../../prisma/generated/prisma/client.js'; -import { UserRole } from '../../common/constants/role.constant.js'; +import { AcademicYearContextService } from '../../common/academic-year-context/academic-year-context.service.js'; +import { CreateStudentDto } from '../../modules/students/dto/create-student.dto.js'; import { StudentsService } from '../../modules/students/students.service.js'; import { PrismaService } from '../prisma/prisma.service.js'; export async function seedStudents(appContext: INestApplicationContext) { const prismaService = appContext.get(PrismaService); const studentsService = appContext.get(StudentsService); + const academicYearContextService = appContext.get(AcademicYearContextService); + const effectiveAcademicYearId = + await academicYearContextService.getActiveId(); try { console.log('Seeding students...'); @@ -16,86 +20,106 @@ export async function seedStudents(appContext: INestApplicationContext) { return; } - const studentsToCreate = [ + if (!effectiveAcademicYearId) + throw new Error( + 'No current academic year found. Seed academic years first.', + ); + + const studentsToCreate: CreateStudentDto[] = [ { name: 'Michael Brown', email: 'michael.brown@example.com', password: 'password123', - role: UserRole.STUDENT, gender: UserGender.MALE, phone: null, address: null, dateOfBirth: null, image: null, - parentId: null, - classId: null, - newParentName: null, - newParentPhone: null, - newParentAddress: null, + parent: { + name: 'parent-1', + phone: '123123123', + address: 'Yangon', + }, + class: { + name: 'class-1', + }, + academicYearId: effectiveAcademicYearId, }, { name: 'Emily Davis', email: 'emily.davis@example.com', password: 'password123', - role: UserRole.STUDENT, gender: UserGender.FEMALE, phone: null, address: null, dateOfBirth: null, image: null, - parentId: null, - classId: null, - newParentName: null, - newParentPhone: null, - newParentAddress: null, + parent: { + name: 'parent-1', + phone: '123123123', + address: 'Yangon', + }, + class: { + name: 'class-1', + }, + academicYearId: effectiveAcademicYearId, }, { name: 'Christopher Wilson', email: 'christopher.wilson@example.com', password: 'password123', - role: UserRole.STUDENT, gender: UserGender.MALE, phone: null, address: null, dateOfBirth: null, image: null, - parentId: null, - classId: null, - newParentName: null, - newParentPhone: null, - newParentAddress: null, + parent: { + name: 'parent-1', + phone: '123123123', + address: 'Yangon', + }, + class: { + name: 'class-1', + }, + academicYearId: effectiveAcademicYearId, }, { name: 'Jessica Martinez', email: 'jessica.martinez@example.com', password: 'password123', - role: UserRole.STUDENT, gender: UserGender.FEMALE, phone: null, address: null, dateOfBirth: null, image: null, - parentId: null, - classId: null, - newParentName: null, - newParentPhone: null, - newParentAddress: null, + parent: { + name: 'parent-1', + phone: '123123123', + address: 'Yangon', + }, + class: { + name: 'class-1', + }, + academicYearId: effectiveAcademicYearId, }, { name: 'David Anderson', email: 'david.anderson@example.com', password: 'password123', - role: UserRole.STUDENT, gender: UserGender.MALE, phone: null, address: null, dateOfBirth: null, image: null, - parentId: null, - classId: null, - newParentName: null, - newParentPhone: null, - newParentAddress: null, + parent: { + name: 'parent-1', + phone: '123123123', + address: 'Yangon', + }, + class: { + name: 'class-1', + }, + academicYearId: effectiveAcademicYearId, }, ]; diff --git a/server/src/database/seeders/seed-teachers.ts b/server/src/database/seeders/seed-teachers.ts index 10295ac..a11947b 100644 --- a/server/src/database/seeders/seed-teachers.ts +++ b/server/src/database/seeders/seed-teachers.ts @@ -1,11 +1,16 @@ import { INestApplicationContext } from '@nestjs/common'; import { UserGender } from '../../../prisma/generated/prisma/client.js'; +import { AcademicYearContextService } from '../../common/academic-year-context/academic-year-context.service.js'; +import { CreateTeacherDto } from '../../modules/teachers/dto/create-teacher.dto.js'; import { TeachersService } from '../../modules/teachers/teachers.service.js'; import { PrismaService } from '../prisma/prisma.service.js'; export async function seedTeachers(appContext: INestApplicationContext) { const prismaService = appContext.get(PrismaService); const teachersService = appContext.get(TeachersService); + const academicYearContextService = appContext.get(AcademicYearContextService); + const effectiveAcademicYearId = + await academicYearContextService.getActiveId(); try { console.log('Seeding teachers...'); @@ -15,7 +20,12 @@ export async function seedTeachers(appContext: INestApplicationContext) { return; } - const teachersToCreate = [ + if (!effectiveAcademicYearId) + throw new Error( + 'No current academic year found. Seed academic years first.', + ); + + const teachersToCreate: CreateTeacherDto[] = [ { name: 'John Doe', email: 'john.doe@example.com', @@ -25,6 +35,7 @@ export async function seedTeachers(appContext: INestApplicationContext) { address: null, dateOfBirth: null, image: null, + academicYearId: effectiveAcademicYearId, }, { name: 'Jane Smith', @@ -35,6 +46,7 @@ export async function seedTeachers(appContext: INestApplicationContext) { address: null, dateOfBirth: null, image: null, + academicYearId: effectiveAcademicYearId, }, { name: 'Peter Jones', @@ -45,6 +57,7 @@ export async function seedTeachers(appContext: INestApplicationContext) { address: null, dateOfBirth: null, image: null, + academicYearId: effectiveAcademicYearId, }, ]; diff --git a/server/src/modules/academic-years/academic-years.service.spec.ts b/server/src/modules/academic-years/academic-years.service.spec.ts index 268ee94..06e9194 100644 --- a/server/src/modules/academic-years/academic-years.service.spec.ts +++ b/server/src/modules/academic-years/academic-years.service.spec.ts @@ -1,7 +1,8 @@ import { NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { PrismaService } from '../../database/prisma/prisma.service.js'; -import { AcademicYearsService } from './academic-years.service'; +import { AcademicYearContextService } from '../../common/academic-year-context/academic-year-context.service.js'; +import { AcademicYearsService } from './academic-years.service.js'; describe('AcademicYearsService', () => { let service: AcademicYearsService; @@ -41,6 +42,12 @@ describe('AcademicYearsService', () => { provide: PrismaService, useValue: prisma, }, + { + provide: AcademicYearContextService, + useValue: { + getActiveId: jest.fn(), + }, + }, ], }).compile(); diff --git a/server/src/modules/admins/admins.service.ts b/server/src/modules/admins/admins.service.ts index 30e2200..e89de87 100644 --- a/server/src/modules/admins/admins.service.ts +++ b/server/src/modules/admins/admins.service.ts @@ -7,7 +7,6 @@ import { import { hashPassword } from 'better-auth/crypto'; import { randomUUID } from 'node:crypto'; import { Prisma, UserRole } from '../../../prisma/generated/prisma/client.js'; -import { APP_CONSTANT } from '../../common/constants/app.constant.js'; import { PaginatedResponseDto } from '../../common/dto/paginated-response.dto.js'; import { formatAdmin } from '../../common/formatters/admin.formatter.js'; import { checkDuplicate } from '../../common/utils/db.util.js'; @@ -54,7 +53,7 @@ export class AdminsService { accounts: { create: { id: randomUUID(), - accountId: `${APP_CONSTANT.APP_NAME}-${userId}`, + accountId: userId, providerId: 'credential', password: hashedPwd, }, diff --git a/server/src/modules/parents/dto/create-parent.dto.ts b/server/src/modules/parents/dto/create-parent.dto.ts deleted file mode 100644 index 3f3d060..0000000 --- a/server/src/modules/parents/dto/create-parent.dto.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator'; - -export class CreateParentDto { - @IsString() - @IsNotEmpty() - @MaxLength(200) - name!: string; - - @IsString() - @IsOptional() - @MaxLength(15) - phone!: string | null; - - @IsString() - @IsOptional() - @MaxLength(500) - address!: string | null; -} diff --git a/server/src/modules/parents/dto/parent-response.dto.ts b/server/src/modules/parents/dto/parent-response.dto.ts deleted file mode 100644 index 339ae32..0000000 --- a/server/src/modules/parents/dto/parent-response.dto.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; - -// class ParentStudentDto { -// @ApiProperty({ example: 'ckx123studentid' }) -// id!: string; -// @ApiProperty({ example: 'John Doe' }) -// name!: string; -// } - -export class ParentResponseDto { - @ApiProperty({ example: 'ckx123parentid' }) - id!: string; - - @ApiProperty({ example: 'Jane Doe' }) - name!: string; - - @ApiProperty({ example: '123-456-7890', nullable: true }) - phone!: string | null; - - @ApiProperty({ example: '123 Main St, Anytown, USA', nullable: true }) - address!: string | null; - - // @ApiProperty({ - // type: () => [ParentStudentDto], - // nullable: true, - // example: [{ id: 'ckx123studentid', name: 'John Doe' }], - // }) - // students!: { id: string; name: string }[] | null; - - @ApiProperty({ example: '2025-07-03T00:00:00.000Z' }) - createdAt!: Date; - - @ApiProperty({ example: '2025-07-03T00:00:00.000Z' }) - updatedAt!: Date; -} diff --git a/server/src/modules/parents/dto/query-parent-dto.ts b/server/src/modules/parents/dto/query-parent-dto.ts deleted file mode 100644 index fbb767c..0000000 --- a/server/src/modules/parents/dto/query-parent-dto.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Type } from 'class-transformer'; -import { IsNumber, IsOptional, IsString, Min } from 'class-validator'; - -export class QueryParentDto { - @IsOptional() - @IsString() - search?: string; - - @IsOptional() - @Type(() => Number) - @IsNumber() - @Min(1) - page?: number = 1; - - @IsOptional() - @Type(() => Number) - @IsNumber() - @Min(1) - limit?: number = 10; -} diff --git a/server/src/modules/parents/dto/update-parent.dto.ts b/server/src/modules/parents/dto/update-parent.dto.ts deleted file mode 100644 index 15735fe..0000000 --- a/server/src/modules/parents/dto/update-parent.dto.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { PartialType } from '@nestjs/mapped-types'; -import { CreateParentDto } from './create-parent.dto.js'; - -export class UpdateParentDto extends PartialType(CreateParentDto) {} diff --git a/server/src/modules/parents/parents.controller.spec.ts b/server/src/modules/parents/parents.controller.spec.ts deleted file mode 100644 index 8fed2e9..0000000 --- a/server/src/modules/parents/parents.controller.spec.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { ParentsController } from './parents.controller'; -import { ParentsService } from './parents.service'; - -describe('ParentsController', () => { - let controller: ParentsController; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - controllers: [ParentsController], - providers: [ - { - provide: ParentsService, - useValue: { - create: jest.fn(), - findAll: jest.fn(), - findOne: jest.fn(), - update: jest.fn(), - remove: jest.fn(), - }, - }, - ], - }).compile(); - - controller = module.get(ParentsController); - }); - it('should be defined', () => { - expect(controller).toBeDefined(); - }); -}); diff --git a/server/src/modules/parents/parents.controller.ts b/server/src/modules/parents/parents.controller.ts deleted file mode 100644 index 247600a..0000000 --- a/server/src/modules/parents/parents.controller.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { Roles } from '@thallesp/nestjs-better-auth'; -import { - Body, - Controller, - Delete, - Get, - Param, - Patch, - Post, - Query, -} from '@nestjs/common'; -import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; -import { - ApiPaginatedResponse, - PaginatedResponseDto, -} from '../../common/dto/paginated-response.dto.js'; -import { ADMIN_ROLES } from '../../common/constants/role.constant.js'; -import { CreateParentDto } from './dto/create-parent.dto.js'; -import { ParentResponseDto } from './dto/parent-response.dto.js'; -import { QueryParentDto } from './dto/query-parent-dto.js'; -import { UpdateParentDto } from './dto/update-parent.dto.js'; -import { ParentsService } from './parents.service.js'; - -@ApiTags('Parents') -@Roles(ADMIN_ROLES) -@Controller('parents') -export class ParentsController { - constructor(private readonly parentsService: ParentsService) {} - - @ApiOperation({ summary: 'Create a parent' }) - @ApiOkResponse({ type: ParentResponseDto }) - @Post() - async create( - @Body() createParentDto: CreateParentDto, - ): Promise { - return this.parentsService.create(createParentDto); - } - - @ApiOperation({ summary: 'List parents' }) - @ApiPaginatedResponse(ParentResponseDto) - @Get() - async findAll( - @Query() queryParentDto: QueryParentDto, - ): Promise> { - return this.parentsService.findAll(queryParentDto); - } - - @ApiOperation({ summary: 'Get a parent by id' }) - @ApiOkResponse({ type: ParentResponseDto }) - @Get(':id') - async findOne(@Param('id') id: string): Promise { - return this.parentsService.findOne(id); - } - - @ApiOperation({ summary: 'Update a parent' }) - @ApiOkResponse({ type: ParentResponseDto }) - @Patch(':id') - async update( - @Param('id') id: string, - @Body() updateParentDto: UpdateParentDto, - ): Promise { - return this.parentsService.update(id, updateParentDto); - } - - @ApiOperation({ summary: 'Delete a parent' }) - @ApiOkResponse({ - schema: { example: { message: 'Parent deleted successfully' } }, - }) - @Delete(':id') - async remove(@Param('id') id: string): Promise<{ message: string }> { - return this.parentsService.remove(id); - } -} diff --git a/server/src/modules/parents/parents.module.ts b/server/src/modules/parents/parents.module.ts deleted file mode 100644 index 604212b..0000000 --- a/server/src/modules/parents/parents.module.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Module } from '@nestjs/common'; -import { PrismaModule } from '../../database/prisma/prisma.module.js'; -import { ParentsController } from './parents.controller.js'; -import { ParentsService } from './parents.service.js'; - -@Module({ - imports: [PrismaModule], - controllers: [ParentsController], - providers: [ParentsService], - exports: [ParentsService], -}) -export class ParentsModule {} diff --git a/server/src/modules/parents/parents.service.spec.ts b/server/src/modules/parents/parents.service.spec.ts deleted file mode 100644 index 1ac099d..0000000 --- a/server/src/modules/parents/parents.service.spec.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { PrismaService } from '../../database/prisma/prisma.service.js'; -import { ParentsService } from './parents.service'; - -describe('ParentsService', () => { - let service: ParentsService; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - ParentsService, - { - provide: PrismaService, - useValue: { - parent: { - findUnique: jest.fn(), - findMany: jest.fn(), - create: jest.fn(), - update: jest.fn(), - delete: jest.fn(), - count: jest.fn(), - }, - }, - }, - ], - }).compile(); - - service = module.get(ParentsService); - }); - it('should be defined', () => { - expect(service).toBeDefined(); - }); -}); diff --git a/server/src/modules/parents/parents.service.ts b/server/src/modules/parents/parents.service.ts deleted file mode 100644 index 75b6ca6..0000000 --- a/server/src/modules/parents/parents.service.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; -import { Prisma } from '../../../prisma/generated/prisma/client.js'; -import { PaginatedResponseDto } from '../../common/dto/paginated-response.dto.js'; -import { formatParent } from '../../common/formatters/parent.formatter.js'; -import { checkDuplicate } from '../../common/utils/db.util.js'; -import { PrismaService } from '../../database/prisma/prisma.service.js'; -import { CreateParentDto } from './dto/create-parent.dto.js'; -import { ParentResponseDto } from './dto/parent-response.dto.js'; -import { QueryParentDto } from './dto/query-parent-dto.js'; -import { UpdateParentDto } from './dto/update-parent.dto.js'; - -@Injectable() -export class ParentsService { - constructor(private readonly prisma: PrismaService) {} - - async create(createParentDto: CreateParentDto): Promise { - await checkDuplicate( - this.prisma.parent, - 'name', - createParentDto.name, - null, - 'Parent with this name already exists', - ); - - if (createParentDto.phone) { - await checkDuplicate( - this.prisma.parent, - 'phone', - createParentDto.phone, - null, - 'Parent with this phone already exists', - ); - } - - const parent = await this.prisma.parent.create({ - data: { - name: createParentDto.name.trim(), - phone: createParentDto.phone?.trim() || null, - address: createParentDto.address?.trim() || null, - }, - // include: { - // students: { - // include: { - // student: { - // include: { - // user: true, - // }, - // }, - // }, - // }, - // }, - }); - - return formatParent(parent); - } - - async findAll( - queryParentDto: QueryParentDto, - ): Promise> { - const { search, page = 1, limit = 10 } = queryParentDto; - - const where: Prisma.ParentWhereInput = {}; - - if (search) { - where.OR = [{ name: { contains: search, mode: 'insensitive' } }]; - } - - const total = await this.prisma.parent.count({ where }); - - const parents = await this.prisma.parent.findMany({ - where, - skip: (page - 1) * limit, - take: limit, - orderBy: { name: 'asc' }, - include: { - students: { - include: { - student: { - include: { - user: true, - }, - }, - }, - }, - }, - }); - - return { - data: parents.map((parent) => formatParent(parent)), - meta: { - total, - page, - limit, - totalPages: Math.ceil(total / limit), - }, - }; - } - - async findOne(id: string): Promise { - const parent = await this.prisma.parent.findUnique({ - where: { id }, - include: { - students: { - include: { - student: { - include: { - user: true, - }, - }, - }, - }, - }, - }); - - if (!parent) throw new NotFoundException('Parent is not found'); - - return formatParent(parent); - } - - async update( - id: string, - updateParentDto: UpdateParentDto, - ): Promise { - const existingParent = await this.prisma.parent.findUnique({ - where: { id }, - }); - if (!existingParent) throw new NotFoundException('Parent is not found'); - - // Validate name - if ( - updateParentDto.name && - existingParent.name !== updateParentDto.name.trim() - ) { - await checkDuplicate( - this.prisma.parent, - 'name', - updateParentDto.name.trim(), - id, - 'Parent with this name already exists', - ); - } - - // Validate phone - if ( - updateParentDto.phone && - existingParent.phone !== updateParentDto.phone.trim() - ) { - await checkDuplicate( - this.prisma.parent, - 'phone', - updateParentDto.phone.trim(), - id, - 'Parent with this phone number already exists', - ); - } - - const parent = await this.prisma.parent.update({ - where: { id }, - data: { - name: updateParentDto.name?.trim(), - phone: updateParentDto.phone?.trim(), - address: - updateParentDto.address === undefined - ? undefined - : updateParentDto.address?.trim() || null, - }, - include: { - students: { - include: { - student: { - include: { - user: true, - }, - }, - }, - }, - }, - }); - - return formatParent(parent); - } - - async remove(id: string): Promise<{ message: string }> { - const existingParent = await this.prisma.parent.findUnique({ - where: { id }, - }); - if (!existingParent) throw new NotFoundException('Parent is not found'); - - await this.prisma.parent.delete({ where: { id } }); - - return { message: 'Parent deleted successfully' }; - } -} diff --git a/server/src/modules/students/dto/create-student.dto.ts b/server/src/modules/students/dto/create-student.dto.ts index 4d23ca7..fd1b030 100644 --- a/server/src/modules/students/dto/create-student.dto.ts +++ b/server/src/modules/students/dto/create-student.dto.ts @@ -1,28 +1,44 @@ -import { IsOptional, IsString } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { ParentRelationship } from '../../../../prisma/generated/prisma/client.js'; import { CreateUserDto } from '../../../common/dto/create-user.dto.js'; export class CreateStudentDto extends CreateUserDto { - @IsString() - @IsOptional() - parentId!: string | null; - - @IsString() - @IsOptional() - classId!: string | null; + @ApiPropertyOptional({ + enum: ParentRelationship, + example: 'GUARDIAN', + nullable: true, + }) + parent_student_relationship?: ParentRelationship | null; - // @IsString() - // @IsOptional() - // academicYearId!: string; + @ApiPropertyOptional({ + type: Object, + example: { + name: 'John Doe Sr.', + phone: '1234567890', + address: '123 Main St', + }, + nullable: true, + }) + parent?: { + name: string; + phone?: string | null; + address?: string | null; + } | null; + @ApiPropertyOptional({ + type: String, + example: { name: 'Grade-10' }, + nullable: true, + }) @IsString() @IsOptional() - newParentName!: string | null; + class?: { + name: string; + } | null; + @ApiPropertyOptional({ type: String, example: 'academicYearId' }) @IsString() - @IsOptional() - newParentPhone!: string | null; - - @IsString() - @IsOptional() - newParentAddress!: string | null; + @IsNotEmpty() + academicYearId!: string; } diff --git a/server/src/modules/students/dto/query-student-dto.ts b/server/src/modules/students/dto/query-student-dto.ts index 1857298..2b8498d 100644 --- a/server/src/modules/students/dto/query-student-dto.ts +++ b/server/src/modules/students/dto/query-student-dto.ts @@ -1,12 +1,18 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; import { IsEnum, IsOptional, IsString } from 'class-validator'; import { UserGender } from '../../../../prisma/generated/prisma/client'; import { QueryDto } from '../../../common/dto/query.dto'; export class QueryStudentDto extends QueryDto { + @ApiPropertyOptional({ description: 'Filter by class ID.' }) @IsOptional() @IsString() - class?: string; + classId?: string; + @ApiPropertyOptional({ + description: 'Filter by gender.', + enum: UserGender, + }) @IsOptional() @IsEnum(UserGender) gender?: UserGender; diff --git a/server/src/modules/students/dto/student-response.dto.ts b/server/src/modules/students/dto/student-response.dto.ts index f8db953..d3310b9 100644 --- a/server/src/modules/students/dto/student-response.dto.ts +++ b/server/src/modules/students/dto/student-response.dto.ts @@ -1,60 +1,123 @@ -import { ApiProperty } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + ParentRelationship, + UserGender, +} from '../../../../prisma/generated/prisma/client'; -class ParentDto { - @ApiProperty({ example: 'ckx123parentid' }) +export class StudentResponseDto { + @ApiProperty({ type: String, example: 'studentid123' }) id!: string; - @ApiProperty({ example: 'Jane Doe' }) - name!: string; -} -class ClassDto { - @ApiProperty({ example: 'ckx123classid' }) - id!: string; + @ApiProperty({ type: String, example: 'user123' }) + userId!: string; - @ApiProperty({ example: 'Class 1' }) + @ApiProperty({ type: String, example: 'John Doe' }) name!: string; -} -class EnrollmentDto { - @ApiProperty({ example: 'enrollmentid123' }) - id!: string; -} -export class StudentResponseDto { - @ApiProperty({ example: 'userid123' }) - id!: string; + @ApiProperty({ type: String, example: 'john.doe@example.com' }) + email!: string; - @ApiProperty({ example: 'John Doe' }) - name!: string; + @ApiProperty({ type: String, example: 'STU-studentos123' }) + studentNumber!: string; - @ApiProperty({ example: 'john@example.com' }) - email!: string; + @ApiPropertyOptional({ + type: String, + example: 'http://example.com/image.png', + nullable: true, + }) + image?: string | null; + + @ApiPropertyOptional({ type: String, example: '1234567890', nullable: true }) + phone?: string | null; + + @ApiPropertyOptional({ + type: String, + example: '123 Main St, Anytown', + nullable: true, + }) + address?: string | null; + + @ApiProperty({ enum: UserGender, example: UserGender.MALE }) + gender!: UserGender; - @ApiProperty({ example: 'STU-12345' }) - studentId!: string; + @ApiPropertyOptional({ + example: '2005-08-24T00:00:00.000Z', + type: String, + // format: 'date-time', + nullable: true, + }) + dateOfBirth?: string | null; - @ApiProperty({ example: 'http://example.com/image.png', nullable: true }) - image!: string | null; + @ApiPropertyOptional({ + enum: ParentRelationship, + example: 'GUARDIAN', + nullable: true, + }) + parent_student_relationship?: ParentRelationship | null; - @ApiProperty({ type: ParentDto, nullable: true }) - parent!: ParentDto | null; + @ApiPropertyOptional({ + type: Object, + example: { + name: 'John Doe Sr.', + phone: '1234567890', + address: '123 Main St', + }, + nullable: true, + }) + parent?: { + name: string; + phone?: string | null; + address?: string | null; + } | null; - // @ApiProperty({ type: EnrollmentDto, nullable: true }) - // enrollment!: EnrollmentDto | null; - @ApiProperty({ type: EnrollmentDto, nullable: true }) - class!: ClassDto | null; + @ApiPropertyOptional({ + type: String, + example: { + name: 'Grade 10', + }, + nullable: true, + }) + class?: { + name: string; + } | null; - @ApiProperty({ nullable: true }) - setPasswordToken!: string | null; + @ApiPropertyOptional({ + type: String, + example: 'setPasswordToken123', + nullable: true, + readOnly: true, + }) + setPasswordToken?: string | null; - @ApiProperty({ nullable: true }) - setPasswordTokenExpires!: Date | null; + @ApiPropertyOptional({ + type: String, + example: '2005-08-24T00:00:00.000Z', + nullable: true, + readOnly: true, + }) + setPasswordTokenExpires?: Date | null; - @ApiProperty({ nullable: true }) - resetPasswordToken!: string | null; + @ApiPropertyOptional({ + type: String, + example: 'resetPasswordToken123', + nullable: true, + readOnly: true, + }) + resetPasswordToken?: string | null; - @ApiProperty({ nullable: true }) - resetPasswordTokenExpires!: Date | null; + @ApiPropertyOptional({ + type: String, + example: '2005-08-24T00:00:00.000Z', + nullable: true, + readOnly: true, + }) + resetPasswordTokenExpires?: Date | null; - @ApiProperty({ nullable: true }) - lastLoginAt!: Date | null; + @ApiPropertyOptional({ + type: String, + example: '2005-08-24T00:00:00.000Z', + nullable: true, + readOnly: true, + }) + lastLoginAt?: Date | null; } diff --git a/server/src/modules/students/dto/update-student.dto.ts b/server/src/modules/students/dto/update-student.dto.ts index 4eeef11..739f33a 100644 --- a/server/src/modules/students/dto/update-student.dto.ts +++ b/server/src/modules/students/dto/update-student.dto.ts @@ -1,4 +1,38 @@ -import { PartialType } from '@nestjs/mapped-types'; -import { CreateStudentDto } from './create-student.dto'; +import { ApiPropertyOptional, PartialType } from '@nestjs/swagger'; +import { IsOptional, IsString } from 'class-validator'; +import { CreateUserDto } from '../../../common/dto/create-user.dto.js'; -export class UpdateStudentDto extends PartialType(CreateStudentDto) {} +export class UpdateStudentDto extends PartialType(CreateUserDto) { + @ApiPropertyOptional({ type: String, example: 'John Doe Sr.', nullable: true }) + @IsString() + @IsOptional() + parentName?: string | null; + + @ApiPropertyOptional({ type: String, example: '1234567890', nullable: true }) + @IsString() + @IsOptional() + parentPhone?: string | null; + + @ApiPropertyOptional({ type: String, example: '123 Main St', nullable: true }) + @IsString() + @IsOptional() + parentAddress?: string | null; + + @ApiPropertyOptional({ + type: String, + example: 'classId123', + nullable: true, + }) + @IsString() + @IsOptional() + classId?: string | null; + + @ApiPropertyOptional({ + type: String, + example: 'academicYearId', + nullable: true, + }) + @IsString() + @IsOptional() + academicYearId?: string; +} diff --git a/server/src/modules/students/students.controller.spec.ts b/server/src/modules/students/students.controller.spec.ts index e88af39..703c970 100644 --- a/server/src/modules/students/students.controller.spec.ts +++ b/server/src/modules/students/students.controller.spec.ts @@ -1,30 +1,77 @@ import { Test, TestingModule } from '@nestjs/testing'; import { StudentsController } from './students.controller'; import { StudentsService } from './students.service'; +import { CreateStudentDto } from './dto/create-student.dto'; +import { UpdateStudentDto } from './dto/update-student.dto'; +import { QueryStudentDto } from './dto/query-student-dto'; describe('StudentsController', () => { let controller: StudentsController; + const mockStudentsService = { + create: jest.fn(), + findAll: jest.fn(), + findOne: jest.fn(), + update: jest.fn(), + remove: jest.fn(), + }; + beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [StudentsController], providers: [ { provide: StudentsService, - useValue: { - create: jest.fn(), - findAll: jest.fn(), - findOne: jest.fn(), - update: jest.fn(), - remove: jest.fn(), - }, + useValue: mockStudentsService, }, ], }).compile(); controller = module.get(StudentsController); }); + it('should be defined', () => { expect(controller).toBeDefined(); }); + + describe('create', () => { + it('should call the service create method', async () => { + const dto = new CreateStudentDto(); + await controller.create(dto); + expect(mockStudentsService.create).toHaveBeenCalledWith(dto); + }); + }); + + describe('findAll', () => { + it('should call the service findAll method', async () => { + const query = new QueryStudentDto(); + await controller.findAll(query); + expect(mockStudentsService.findAll).toHaveBeenCalledWith(query); + }); + }); + + describe('findOne', () => { + it('should call the service findOne method', async () => { + const id = 'some-id'; + await controller.findOne(id); + expect(mockStudentsService.findOne).toHaveBeenCalledWith(id); + }); + }); + + describe('update', () => { + it('should call the service update method', async () => { + const id = 'some-id'; + const dto = new UpdateStudentDto(); + await controller.update(id, dto); + expect(mockStudentsService.update).toHaveBeenCalledWith(id, dto); + }); + }); + + describe('remove', () => { + it('should call the service remove method', async () => { + const id = 'some-id'; + await controller.remove(id); + expect(mockStudentsService.remove).toHaveBeenCalledWith(id); + }); + }); }); diff --git a/server/src/modules/students/students.controller.ts b/server/src/modules/students/students.controller.ts index 6b5fbf7..dcba1d3 100644 --- a/server/src/modules/students/students.controller.ts +++ b/server/src/modules/students/students.controller.ts @@ -8,7 +8,12 @@ import { Post, Query, } from '@nestjs/common'; -import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { + ApiCreatedResponse, + ApiOkResponse, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; import { Roles } from '@thallesp/nestjs-better-auth'; import { ADMIN_ROLES } from '../../common/constants/role.constant.js'; import { @@ -28,7 +33,7 @@ export class StudentsController { constructor(private readonly studentsService: StudentsService) {} @ApiOperation({ summary: 'Create a student' }) - @ApiOkResponse({ type: StudentResponseDto }) + @ApiCreatedResponse({ type: StudentResponseDto }) @Post() async create( @Body() createStudentDto: CreateStudentDto, diff --git a/server/src/modules/students/students.module.ts b/server/src/modules/students/students.module.ts index 1cb81b5..f9ef847 100644 --- a/server/src/modules/students/students.module.ts +++ b/server/src/modules/students/students.module.ts @@ -8,5 +8,6 @@ import { StudentsService } from './students.service'; imports: [PrismaModule], controllers: [StudentsController], providers: [StudentsService, CloudinaryService], + // exports: [StudentsService], }) export class StudentsModule {} diff --git a/server/src/modules/students/students.service.spec.ts b/server/src/modules/students/students.service.spec.ts index f9b6424..57159eb 100644 --- a/server/src/modules/students/students.service.spec.ts +++ b/server/src/modules/students/students.service.spec.ts @@ -1,10 +1,17 @@ import { Test, TestingModule } from '@nestjs/testing'; +import { UserGender, UserRole } from '../../../prisma/generated/prisma/client'; +import { AcademicYearContextService } from '../../common/academic-year-context/academic-year-context.service.js'; +import * as studentFormatter from '../../common/formatters/student.formatter.js'; import { PrismaService } from '../../database/prisma/prisma.service.js'; import { CloudinaryService } from '../../integrations/cloudinary/cloudinary.service.js'; +import { CreateStudentDto } from './dto/create-student.dto.js'; +import { StudentResponseDto } from './dto/student-response.dto.js'; import { StudentsService } from './students.service'; describe('StudentsService', () => { let service: StudentsService; + let prisma: PrismaService; + let academicYearContext: AcademicYearContextService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -15,29 +22,313 @@ describe('StudentsService', () => { useValue: { student: { findUnique: jest.fn(), + findFirst: jest.fn(), findMany: jest.fn(), create: jest.fn(), update: jest.fn(), delete: jest.fn(), count: jest.fn(), + findUniqueOrThrow: jest.fn(), }, user: { + findFirst: jest.fn(), delete: jest.fn(), + create: jest.fn(), + }, + enrollment: { + upsert: jest.fn(), + deleteMany: jest.fn(), }, + $transaction: jest + .fn() + .mockImplementation((callback: (tx: PrismaService) => any) => + callback(prisma), + ), }, }, { provide: CloudinaryService, useValue: { deleteFromCloudinary: jest.fn(), + resolveImageUrl: jest.fn(), + }, + }, + { + provide: AcademicYearContextService, + useValue: { + getActiveId: jest.fn(), }, }, ], }).compile(); service = module.get(StudentsService); + prisma = module.get(PrismaService); + academicYearContext = module.get( + AcademicYearContextService, + ); }); + + afterEach(() => { + jest.clearAllMocks(); + }); + it('should be defined', () => { expect(service).toBeDefined(); }); + + describe('create', () => { + it('should create a student and use active academic year if not provided', async () => { + const createStudentDto: CreateStudentDto = { + name: 'Test Student', + email: 'test@student.com', + password: 'password123', + gender: 'MALE', + parentName: 'Test Parent', + academicYearId: 'year-123', + }; + + const mockUser = { + id: 'user-1', + name: 'Test Student', + email: 'test@student.com', + role: UserRole.STUDENT, + image: null, + }; + const mockStudent = { + id: 'student-1', + userId: 'user-1', + studentNumber: 'STU-123', + phone: null, + address: null, + gender: UserGender.MALE, + dateOfBirth: new Date(), + parents: [], + enrollments: [], + }; + + (prisma.user.findFirst as jest.Mock).mockResolvedValue(null); + (prisma.student.findFirst as jest.Mock).mockResolvedValue(null); + (prisma.user.create as jest.Mock).mockResolvedValue(mockUser); + (prisma.student.create as jest.Mock).mockResolvedValue(mockStudent); + (academicYearContext.getActiveId as jest.Mock).mockResolvedValue( + 'active-year-id', + ); + + const expectedResult: Partial = { + id: 'user-1', + name: 'Test Student', + }; + const formatStudentSpy = jest + .spyOn(studentFormatter, 'formatStudent') + .mockReturnValue(expectedResult as StudentResponseDto); + + const result = await service.create(createStudentDto); + + expect(academicYearContext.getActiveId).toHaveBeenCalled(); + expect(prisma.student.create).toHaveBeenCalled(); + expect(formatStudentSpy).toHaveBeenCalledWith(mockStudent); + expect(result).toEqual(expectedResult); + }); + + it('should use provided academic year id', async () => { + const createStudentDto: CreateStudentDto = { + name: 'Test Student 2', + email: 'test2@student.com', + password: 'password123', + academicYearId: 'provided-year-id', + classId: 'class-1', + gender: 'MALE', + parentName: 'Test Parent 2', + }; + + const mockUser = { + id: 'user-2', + name: 'Test Student 2', + email: 'test2@student.com', + role: UserRole.STUDENT, + image: null, + }; + const mockStudent = { + id: 'student-2', + userId: 'user-2', + studentNumber: 'STU-456', + }; + + (prisma.user.findFirst as jest.Mock).mockResolvedValue(null); + (prisma.student.findFirst as jest.Mock).mockResolvedValue(null); + (prisma.user.create as jest.Mock).mockResolvedValue(mockUser); + (prisma.student.create as jest.Mock).mockResolvedValue(mockStudent); + + const expectedResult: Partial = { + id: 'user-2', + name: 'Test Student 2', + }; + jest + .spyOn(studentFormatter, 'formatStudent') + .mockReturnValue(expectedResult as StudentResponseDto); + + await service.create(createStudentDto); + + expect(academicYearContext.getActiveId).not.toHaveBeenCalled(); + expect(prisma.student.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + enrollments: { + create: { + classId: 'class-1', + academicYearId: 'provided-year-id', + }, + }, + }), + }), + ); + }); + }); + + describe('update', () => { + it('updates a student without resolving active academic year when class is unchanged', async () => { + const existingStudent = { + id: 'student-1', + userId: 'user-1', + user: { id: 'user-1', name: 'Old Name', email: 'old@student.com' }, + }; + const updatedStudent = { + ...existingStudent, + user: { ...existingStudent.user, name: 'New Name' }, + }; + + (prisma.student.findUnique as jest.Mock).mockResolvedValue( + existingStudent, + ); + (prisma.user.findFirst as jest.Mock).mockResolvedValue(null); + (prisma.student.update as jest.Mock).mockResolvedValue(updatedStudent); + (prisma.student.findUniqueOrThrow as jest.Mock).mockResolvedValue( + updatedStudent, + ); + jest + .spyOn(studentFormatter, 'formatStudent') + .mockReturnValue(updatedStudent as any); + + const result = await service.update('student-1', { + name: 'New Name', + } as any); + + expect(result).toEqual(updatedStudent); + expect(academicYearContext.getActiveId).not.toHaveBeenCalled(); + expect(prisma.student.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'student-1' }, + data: expect.objectContaining({ + user: expect.any(Object), + }), + }), + ); + }); + + it('fetches active academic year when updating class enrollment without academicYearId', async () => { + const existingStudent = { + id: 'student-1', + userId: 'user-1', + user: { id: 'user-1', name: 'Old Name', email: 'old@student.com' }, + }; + const updatedStudent = { + ...existingStudent, + user: { ...existingStudent.user, name: 'New Name' }, + }; + + (prisma.student.findUnique as jest.Mock).mockResolvedValue( + existingStudent, + ); + (prisma.user.findFirst as jest.Mock).mockResolvedValue(null); + (prisma.student.update as jest.Mock).mockResolvedValue(updatedStudent); + (prisma.enrollment.upsert as jest.Mock).mockResolvedValue({}); + (prisma.student.findUniqueOrThrow as jest.Mock).mockResolvedValue( + updatedStudent, + ); + (academicYearContext.getActiveId as jest.Mock).mockResolvedValue( + 'active-year-id', + ); + jest + .spyOn(studentFormatter, 'formatStudent') + .mockReturnValue(updatedStudent as any); + + const result = await service.update('student-1', { + name: 'New Name', + classId: 'class-1', + } as any); + + expect(result).toEqual(updatedStudent); + expect(academicYearContext.getActiveId).toHaveBeenCalled(); + expect(prisma.enrollment.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + studentId_academicYearId: { + studentId: 'student-1', + academicYearId: 'active-year-id', + }, + }, + update: { + classId: 'class-1', + }, + create: { + studentId: 'student-1', + classId: 'class-1', + academicYearId: 'active-year-id', + }, + }), + ); + }); + + it('updates email and preserves unchanged optional fields', async () => { + const existingStudent = { + id: 'student-1', + userId: 'user-1', + user: { + id: 'user-1', + name: 'Old Name', + email: 'old@student.com', + image: 'http://example.com/old-image.png', + }, + phone: '1234567890', + address: '123 Old St', + dateOfBirth: new Date('2005-01-01'), + }; + const updatedStudent = { + ...existingStudent, + user: { ...existingStudent.user, email: 'new@student.com' }, + }; + + (prisma.student.findUnique as jest.Mock).mockResolvedValue( + existingStudent, + ); + (prisma.user.findFirst as jest.Mock).mockResolvedValue(null); + (prisma.student.update as jest.Mock).mockResolvedValue(updatedStudent); + (prisma.student.findUniqueOrThrow as jest.Mock).mockResolvedValue( + updatedStudent, + ); + jest + .spyOn(studentFormatter, 'formatStudent') + .mockReturnValue(updatedStudent as any); + + const result = await service.update('student-1', { + email: 'new@student.com', + } as any); + + expect(result).toEqual(updatedStudent); + expect(prisma.student.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'student-1' }, + data: expect.objectContaining({ + user: expect.objectContaining({ + email: 'new@student.com', + image: undefined, + }), + address: undefined, + dateOfBirth: undefined, + }), + }), + ); + }); + }); }); diff --git a/server/src/modules/students/students.service.ts b/server/src/modules/students/students.service.ts index fea3639..a871b75 100644 --- a/server/src/modules/students/students.service.ts +++ b/server/src/modules/students/students.service.ts @@ -6,7 +6,7 @@ import { Prisma, UserRole, } from '../../../prisma/generated/prisma/client.js'; -import { APP_CONSTANT } from '../../common/constants/app.constant.js'; +import { AcademicYearContextService } from '../../common/academic-year-context/academic-year-context.service.js'; import { PaginatedResponseDto } from '../../common/dto/paginated-response.dto.js'; import { formatStudent } from '../../common/formatters/student.formatter.js'; import { formatGender } from '../../common/formatters/user.formatter.js'; @@ -19,6 +19,22 @@ import { QueryStudentDto } from './dto/query-student-dto.js'; import { StudentResponseDto } from './dto/student-response.dto.js'; import { UpdateStudentDto } from './dto/update-student.dto.js'; +type StudentCreateResult = Prisma.StudentGetPayload<{ + include: { + user: true; + parents: { + include: { + parent: true; + }; + }; + enrollments: { + include: { + class: { select: { id: true; name: true; deletedAt: true } }; + }; + }; + }; +}>; + @Injectable() export class StudentsService { private readonly logger = new Logger(StudentsService.name); @@ -26,6 +42,7 @@ export class StudentsService { constructor( private readonly prisma: PrismaService, private readonly cloudinary: CloudinaryService, + private readonly academicYearContext: AcademicYearContextService, ) {} async create( @@ -40,10 +57,9 @@ export class StudentsService { image, gender, dateOfBirth, - classId, - newParentName, - newParentPhone, - newParentAddress, + parent, + parent_student_relationship, + class: studentClass, } = createStudentDto; await checkDuplicate( @@ -76,100 +92,92 @@ export class StudentsService { const hashedPwd = await hashPassword(password); const userId = randomUUID(); - const student = await this.prisma.$transaction(async (tx) => { - const createdUser = await tx.user.create({ - data: { - id: userId, - email: email.trim(), - name: name.trim(), - role: UserRole.STUDENT, - image: imageUrl, - accounts: { - create: { - id: randomUUID(), - accountId: `${APP_CONSTANT.APP_NAME}-${userId}`, - providerId: 'credential', - password: hashedPwd, - }, - }, - }, - }); - - let parentIdToUse = createStudentDto.parentId; - if (newParentName) { - const newParent = await tx.parent.create({ + const student = await this.prisma.$transaction( + async (tx): Promise => { + const createdUser = await tx.user.create({ data: { - name: newParentName, - phone: newParentPhone || null, - address: newParentAddress || null, + id: userId, + email: email.trim(), + name: name.trim(), + role: UserRole.STUDENT, + image: imageUrl, + accounts: { + create: { + id: randomUUID(), + accountId: userId, + providerId: 'credential', + password: hashedPwd, + }, + }, }, }); - parentIdToUse = newParent.id; - } - const studentId = `STU-${createdUser.id.slice(-12)}`; + const studentId = `STU-${createdUser.id.slice(-12)}`; - const currentYear = await tx.academicYear.findFirst({ - where: { isCurrent: true }, - }); - const academicYearId = - currentYear?.id || (await tx.academicYear.findFirst())?.id; + const academicYearId = + createStudentDto.academicYearId ?? + (await this.academicYearContext.getActiveId()); - return tx.student.create({ - data: { - studentNumber: studentId, - userId: createdUser.id, - phone, - address, - gender, - dateOfBirth: dateOfBirth ? new Date(dateOfBirth) : null, - ...(classId && - academicYearId && { - enrollments: { + return tx.student.create({ + data: { + studentNumber: studentId, + userId: createdUser.id, + phone, + address, + gender, + dateOfBirth: dateOfBirth ? new Date(dateOfBirth) : null, + ...(studentClass && + academicYearId && { + enrollments: { + create: { + class: { + create: { name: studentClass.name }, + }, + academicYear: { + connect: { id: academicYearId }, + }, + }, + }, + }), + ...(parent && { + parents: { create: { - classId, - academicYearId, + relationship: + parent_student_relationship ?? ParentRelationship.GUARDIAN, + parent: { + create: { + name: parent.name, + phone: parent.phone, + address: parent.address, + }, + }, }, }, }), - ...(parentIdToUse && { + }, + include: { + user: true, parents: { - create: { - parentId: parentIdToUse, - relationship: ParentRelationship.GUARDIAN, + include: { + parent: true, }, }, - }), - }, - include: { - user: true, - parents: { - include: { - parent: true, - }, - }, - enrollments: { - include: { - class: true, + enrollments: { + include: { + class: { select: { id: true, name: true, deletedAt: true } }, + }, }, }, - }, - }); - }); + }); + }, + ); return formatStudent(student); } - async findAll( queryStudentDto: QueryStudentDto, ): Promise> { - const { - class: classId, - gender, - search, - page = 1, - limit = 10, - } = queryStudentDto; + const { classId, gender, search, page = 1, limit = 10 } = queryStudentDto; const where: Prisma.StudentWhereInput = {}; @@ -217,7 +225,7 @@ export class StudentsService { }, enrollments: { include: { - class: true, + class: { select: { id: true, name: true, deletedAt: true } }, }, }, }, @@ -246,7 +254,7 @@ export class StudentsService { }, enrollments: { include: { - class: true, + class: { select: { id: true, name: true, deletedAt: true } }, }, }, }, @@ -307,24 +315,74 @@ export class StudentsService { ); } - const currentYear = await this.prisma.academicYear.findFirst({ - where: { isCurrent: true }, - }); - const academicYearId = - currentYear?.id || (await this.prisma.academicYear.findFirst())?.id; const classId = updateStudentDto.classId === undefined ? undefined : updateStudentDto.classId?.trim() || null; + const academicYearId = + updateStudentDto.academicYearId ?? + (classId !== undefined + ? await this.academicYearContext.getActiveId() + : undefined); + const student = await this.prisma.$transaction(async (tx) => { + if (updateStudentDto.parentName !== undefined) { + const existingParentStudent = await tx.parentStudent.findFirst({ + where: { studentId: id, relationship: ParentRelationship.GUARDIAN }, + }); + + if (updateStudentDto.parentName) { + if (existingParentStudent) { + await tx.parent.update({ + where: { id: existingParentStudent.parentId }, + data: { + name: updateStudentDto.parentName, + phone: + updateStudentDto.parentPhone !== undefined + ? updateStudentDto.parentPhone + : undefined, + address: + updateStudentDto.parentAddress !== undefined + ? updateStudentDto.parentAddress + : undefined, + }, + }); + } else { + await tx.parentStudent.create({ + data: { + student: { connect: { id } }, + relationship: ParentRelationship.GUARDIAN, + parent: { + create: { + name: updateStudentDto.parentName, + phone: updateStudentDto.parentPhone, + address: updateStudentDto.parentAddress, + }, + }, + }, + }); + } + } else if ( + updateStudentDto.parentName === null || + updateStudentDto.parentName === '' + ) { + if (existingParentStudent) { + await tx.parentStudent.delete({ + where: { id: existingParentStudent.id }, + }); + await tx.parent.delete({ + where: { id: existingParentStudent.parentId }, + }); + } + } + } await tx.student.update({ where: { id }, data: { user: { update: { email: updateStudentDto.email?.trim(), - role: updateStudentDto.role, name: updateStudentDto.name?.trim(), image: updateStudentDto.image === undefined @@ -332,7 +390,10 @@ export class StudentsService { : resolveImageUrl(updateStudentDto.image, this.cloudinary), }, }, - phone: updateStudentDto.phone?.trim(), + phone: + updateStudentDto.phone === undefined + ? undefined + : updateStudentDto.phone?.trim() || null, address: updateStudentDto.address === undefined ? undefined @@ -346,19 +407,6 @@ export class StudentsService { gender: updateStudentDto.gender ? formatGender(updateStudentDto.gender) : undefined, - ...(updateStudentDto.parentId !== undefined && { - parents: updateStudentDto.parentId - ? { - deleteMany: {}, - create: { - parentId: updateStudentDto.parentId, - relationship: 'GUARDIAN', - }, - } - : { - deleteMany: {}, - }, - }), }, }); @@ -401,7 +449,9 @@ export class StudentsService { }, enrollments: { include: { - class: true, + class: { + select: { id: true, name: true, deletedAt: true }, + }, }, }, }, @@ -428,8 +478,26 @@ export class StudentsService { } } - // Delete user which will cascade delete the student - await this.prisma.user.delete({ where: { id: existingStudent.userId } }); + const parentStudents = await this.prisma.parentStudent.findMany({ + where: { studentId: id }, + }); + const parentIds = parentStudents.map((ps) => ps.parentId); + + await this.prisma.$transaction([ + this.prisma.parentStudent.deleteMany({ + where: { studentId: id }, + }), + this.prisma.parent.deleteMany({ + where: { id: { in: parentIds } }, + }), + this.prisma.enrollment.deleteMany({ + where: { studentId: id }, + }), + this.prisma.account.deleteMany({ + where: { userId: existingStudent.userId }, + }), + this.prisma.user.delete({ where: { id: existingStudent.userId } }), + ]); return { message: 'Student deleted successfully' }; } diff --git a/server/src/modules/teachers/dto/create-teacher.dto.ts b/server/src/modules/teachers/dto/create-teacher.dto.ts index c91f1d9..9ee3fdf 100644 --- a/server/src/modules/teachers/dto/create-teacher.dto.ts +++ b/server/src/modules/teachers/dto/create-teacher.dto.ts @@ -1,6 +1,18 @@ -import { OmitType } from '@nestjs/mapped-types'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; import { CreateUserDto } from '../../../common/dto/create-user.dto.js'; -export class CreateTeacherDto extends OmitType(CreateUserDto, [ - 'role', -] as const) {} +export class CreateTeacherDto extends CreateUserDto { + @ApiPropertyOptional({ + type: String, + example: 'classId123', + }) + @IsString() + @IsOptional() + classId?: string | null; + + @ApiPropertyOptional({ type: String, example: 'academicYearId' }) + @IsString() + @IsNotEmpty() + academicYearId!: string; +} diff --git a/server/src/modules/teachers/teachers.service.spec.ts b/server/src/modules/teachers/teachers.service.spec.ts index 977af82..767f78e 100644 --- a/server/src/modules/teachers/teachers.service.spec.ts +++ b/server/src/modules/teachers/teachers.service.spec.ts @@ -1,7 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { PrismaService } from '../../database/prisma/prisma.service.js'; import { CloudinaryService } from '../../integrations/cloudinary/cloudinary.service.js'; -import { TeachersService } from './teachers.service'; +import { TeachersService } from './teachers.service.js'; describe('TeachersService', () => { let service: TeachersService; diff --git a/server/src/modules/teachers/teachers.service.ts b/server/src/modules/teachers/teachers.service.ts index 1fa3f78..67d4b59 100644 --- a/server/src/modules/teachers/teachers.service.ts +++ b/server/src/modules/teachers/teachers.service.ts @@ -2,7 +2,6 @@ import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { hashPassword } from 'better-auth/crypto'; import { randomUUID } from 'node:crypto'; import { Prisma, UserRole } from '../../../prisma/generated/prisma/client.js'; -import { APP_CONSTANT } from '../../common/constants/app.constant.js'; import { PaginatedResponseDto } from '../../common/dto/paginated-response.dto.js'; import { formatTeacher } from '../../common/formatters/teacher.formatter.js'; import { formatGender } from '../../common/formatters/user.formatter.js'; @@ -70,7 +69,7 @@ export class TeachersService { accounts: { create: { id: randomUUID(), - accountId: `${APP_CONSTANT.APP_NAME}-${userId}`, + accountId: userId, providerId: 'credential', password: hashedPwd, },