From 9c49109b4ff479f2ca2b5eb36f544d8a5c68ae5e Mon Sep 17 00:00:00 2001 From: dinhdat07 Date: Sun, 28 Dec 2025 14:12:23 +0700 Subject: [PATCH 1/2] feat: add tutor calendar, class schedule, and verification pages with API integration and UI enhancements --- mentorme_backend/PHASE2_PLAN.md | 39 ++ mentorme_backend/README.md | 18 + mentorme_backend/package.json | 11 +- .../migration.sql | 24 + .../migration.sql | 72 +++ .../migration.sql | 35 ++ .../migration.sql | 55 +++ mentorme_backend/prisma/schema.prisma | 127 +++++ mentorme_backend/src/app.ts | 6 + mentorme_backend/src/domain/matchingEngine.ts | 27 +- mentorme_backend/src/jobs/sessionReminders.ts | 99 ++++ mentorme_backend/src/routes/adminRoutes.ts | 163 ++++++- mentorme_backend/src/routes/bookingRoutes.ts | 24 + mentorme_backend/src/routes/calendarRoutes.ts | 98 ++++ mentorme_backend/src/routes/classRoutes.ts | 341 +++++++++++++- mentorme_backend/src/routes/matchingRoutes.ts | 15 +- .../src/routes/notificationRoutes.ts | 74 +++ mentorme_backend/src/routes/sessionRoutes.ts | 344 ++++++++++++++ mentorme_backend/src/routes/tutorRoutes.ts | 217 ++++++++- .../src/services/notifications.ts | 59 +++ mentorme_backend/src/services/scheduling.ts | 132 ++++++ .../src/services/tutorVerification.ts | 92 ++++ mentorme_backend/src/utils/tutorSanitizer.ts | 43 ++ .../tests/api/bookingAdmin.test.ts | 72 ++- mentorme_backend/tests/api/classes.test.ts | 29 ++ mentorme_backend/tests/api/matching.test.ts | 42 ++ .../tests/api/notifications.test.ts | 81 ++++ mentorme_backend/tests/api/reminders.test.ts | 38 ++ mentorme_backend/tests/api/scheduling.test.ts | 199 ++++++++ .../tests/api/sessionLifecycle.test.ts | 173 +++++++ mentorme_backend/tests/api/tutorClass.test.ts | 10 +- .../tests/api/tutorVisibility.test.ts | 63 +++ .../tests/api/verificationAccess.test.ts | 78 +++ .../tests/api/verificationService.test.ts | 120 +++++ mentorme_backend/tests/setup.ts | 9 + mentorme_backend/tests/utils/mockPrisma.ts | 20 + .../dashboard/admin/verifications/page.tsx | 308 ++++++++++++ .../app/dashboard/student/calendar/page.tsx | 128 +++++ .../app/dashboard/tutor/calendar/page.tsx | 130 +++++ .../tutor/classes/[id]/schedule/page.tsx | 292 ++++++++++++ .../app/dashboard/tutor/classes/page.tsx | 14 + .../app/dashboard/tutor/verification/page.tsx | 444 ++++++++++++++++++ mentorme_frontend/app/notifications/page.tsx | 90 ++++ mentorme_frontend/components/sidebar-nav.tsx | 19 +- mentorme_frontend/lib/types.ts | 49 ++ 45 files changed, 4497 insertions(+), 26 deletions(-) create mode 100644 mentorme_backend/PHASE2_PLAN.md create mode 100644 mentorme_backend/prisma/migrations/20251207120000_verification_phase1/migration.sql create mode 100644 mentorme_backend/prisma/migrations/20251221120000_phase2_scheduling/migration.sql create mode 100644 mentorme_backend/prisma/migrations/20251222123000_phase3_session_lifecycle/migration.sql create mode 100644 mentorme_backend/prisma/migrations/20251223120000_phase4_notifications/migration.sql create mode 100644 mentorme_backend/src/jobs/sessionReminders.ts create mode 100644 mentorme_backend/src/routes/calendarRoutes.ts create mode 100644 mentorme_backend/src/routes/notificationRoutes.ts create mode 100644 mentorme_backend/src/routes/sessionRoutes.ts create mode 100644 mentorme_backend/src/services/notifications.ts create mode 100644 mentorme_backend/src/services/scheduling.ts create mode 100644 mentorme_backend/src/services/tutorVerification.ts create mode 100644 mentorme_backend/src/utils/tutorSanitizer.ts create mode 100644 mentorme_backend/tests/api/notifications.test.ts create mode 100644 mentorme_backend/tests/api/reminders.test.ts create mode 100644 mentorme_backend/tests/api/scheduling.test.ts create mode 100644 mentorme_backend/tests/api/sessionLifecycle.test.ts create mode 100644 mentorme_backend/tests/api/tutorVisibility.test.ts create mode 100644 mentorme_backend/tests/api/verificationAccess.test.ts create mode 100644 mentorme_backend/tests/api/verificationService.test.ts create mode 100644 mentorme_frontend/app/dashboard/admin/verifications/page.tsx create mode 100644 mentorme_frontend/app/dashboard/student/calendar/page.tsx create mode 100644 mentorme_frontend/app/dashboard/tutor/calendar/page.tsx create mode 100644 mentorme_frontend/app/dashboard/tutor/classes/[id]/schedule/page.tsx create mode 100644 mentorme_frontend/app/dashboard/tutor/verification/page.tsx create mode 100644 mentorme_frontend/app/notifications/page.tsx diff --git a/mentorme_backend/PHASE2_PLAN.md b/mentorme_backend/PHASE2_PLAN.md new file mode 100644 index 0000000..cf33c7c --- /dev/null +++ b/mentorme_backend/PHASE2_PLAN.md @@ -0,0 +1,39 @@ +# Phase 2 – Scheduling & Sessions (Prep Only) + +This document outlines the intended design for scheduling/sessions before implementation. No code has been added yet. + +## Data Model (planned) +- `TutorAvailability`: day_of_week, start_minute, end_minute, timezone, locationType. +- `TutorUnavailability` (new): date/time ranges to block off-time. +- `ClassSchedule`: belongs to Class; recurrence rule (weekly slots) or ad-hoc sessions list; timezone; total_sessions. +- `Session`: belongs to Class; scheduled_start/end (UTC), status (SCHEDULED/IN_PROGRESS/COMPLETED/MISSED/CANCELLED), flags for tutor/student start/complete confirmations, dispute_flagged_at. + +## Proposed Endpoints (names only) +- Tutor: + - `GET /api/tutors/me/availability` / `PUT /api/tutors/me/availability` + - `GET /api/tutors/me/unavailability` / `PUT /api/tutors/me/unavailability` +- Class scheduling: + - `POST /api/classes/:id/schedule` (create recurring or explicit sessions) + - `GET /api/classes/:id/schedule` (view schedule + generated sessions) +- Session lifecycle: + - `PATCH /api/sessions/:id/start` (tutor/student confirm start) + - `PATCH /api/sessions/:id/complete` (tutor/student confirm completion) + - `PATCH /api/sessions/:id/cancel` +- Calendars: + - `GET /api/calendar/tutor` / `GET /api/calendar/student` (upcoming sessions merged) + +## Conflict Rules (to enforce) +- Tutor cannot schedule/accept sessions overlapping: + - other ACTIVE/CONFIRMED sessions + - tutor unavailability blocks +- Student cannot schedule overlapping sessions across their ACTIVE classes. +- Timezone-aware comparison (store UTC, keep user timezone for display). + +## Integration Points +- Booking → once accepted, require schedule creation before class becomes ACTIVE. +- Class status progression uses session completion counts. +- Notifications/reminders to be wired via existing or new job runner (outside Phase 1.5 scope). + +## Open Questions +- Whether verified tutors can edit availability while PENDING suspension. +- Payment/escrow hooks on session confirmation (if needed later). diff --git a/mentorme_backend/README.md b/mentorme_backend/README.md index 28ff882..b0b8f74 100644 --- a/mentorme_backend/README.md +++ b/mentorme_backend/README.md @@ -67,6 +67,24 @@ The API listens on `http://localhost:4000` by default. A basic health probe is a Use any REST client (Insomnia, Postman, VS Code `rest` files) to call the endpoints. Enable an `Authorization: Bearer ` header when exercising protected routes such as students/tutors CRUD. +## Verification API (Phase 1) + +- Tutor: + - `GET /api/tutors/me/verification` – view status + masked CCCD and submitted documents. + - `PUT /api/tutors/me/verification` – submit/resubmit; sets status to `PENDING`. Duplicate CCCD returns `409 Conflict`. +- Admin (role `ADMIN` only): + - `GET /api/admin/tutor-verifications?status=PENDING|VERIFIED|REJECTED` – list requests (CCCD masked). +- `PATCH /api/admin/tutor-verifications/:id/approve` – approve (status → VERIFIED). +- `PATCH /api/admin/tutor-verifications/:id/reject` – reject with optional note. +- Public surfaces (search/matching/class detail) only include `VERIFIED` tutors and never expose CCCD or document URLs. + +## Scheduling API (Phase 2) + +- Tutor availability: `GET/PUT /api/tutors/me/availability`, `GET/PUT /api/tutors/me/unavailability` +- Class schedule: `POST /api/classes/:id/schedule` (recurrence or explicit sessions, conflict-checked), `GET /api/classes/:id/schedule` +- Calendars: `GET /api/calendar/tutor`, `GET /api/calendar/student` (upcoming sessions) +- Session statuses currently: `SCHEDULED`, `CANCELLED`, `MISSED` (start/complete flow deferred to Phase 3). + ## Deploying to AWS (ECS + RDS) This backend is ready to run as a container on ECS Fargate behind an Application Load Balancer (ALB), with PostgreSQL hosted on RDS and secrets in SSM/Secrets Manager. diff --git a/mentorme_backend/package.json b/mentorme_backend/package.json index 9e9f226..8e65527 100644 --- a/mentorme_backend/package.json +++ b/mentorme_backend/package.json @@ -5,11 +5,12 @@ "main": "dist/index.js", "scripts": { "dev": "ts-node-dev --respawn --transpile-only src/index.ts", - "build": "tsc", - "start": "node dist/index.js", - "lint": "tsc --noEmit", - "test": "jest --runInBand", - "embeddings:backfill": "ts-node-dev --transpile-only src/scripts/backfillTutorEmbeddings.ts" + "build": "tsc", + "start": "node dist/index.js", + "lint": "tsc --noEmit", + "test": "jest --runInBand", + "embeddings:backfill": "ts-node-dev --transpile-only src/scripts/backfillTutorEmbeddings.ts", + "reminders:run": "ts-node-dev --transpile-only src/jobs/sessionReminders.ts" }, "keywords": [], "author": "", diff --git a/mentorme_backend/prisma/migrations/20251207120000_verification_phase1/migration.sql b/mentorme_backend/prisma/migrations/20251207120000_verification_phase1/migration.sql new file mode 100644 index 0000000..377791c --- /dev/null +++ b/mentorme_backend/prisma/migrations/20251207120000_verification_phase1/migration.sql @@ -0,0 +1,24 @@ +-- Add verification status enum +CREATE TYPE "VerificationStatus" AS ENUM ('UNVERIFIED', 'PENDING', 'VERIFIED', 'REJECTED'); + +-- Extend TutorProfile for verification data +ALTER TABLE "TutorProfile" + ADD COLUMN "verificationStatus" "VerificationStatus" NOT NULL DEFAULT 'UNVERIFIED', + ADD COLUMN "nationalIdNumber" TEXT, + ADD COLUMN "nationalIdFrontImageUrl" TEXT, + ADD COLUMN "nationalIdBackImageUrl" TEXT, + ADD COLUMN "proofDocuments" JSONB, + ADD COLUMN "certificatesDetail" JSONB, + ADD COLUMN "verificationSubmittedAt" TIMESTAMP(3), + ADD COLUMN "verificationReviewedAt" TIMESTAMP(3), + ADD COLUMN "verificationNotes" TEXT; + +-- Keep backward compatibility with existing verified flag +UPDATE "TutorProfile" +SET "verificationStatus" = CASE WHEN "verified" = true THEN 'VERIFIED' ELSE 'UNVERIFIED' END; + +-- Unique constraint for national ID (allows NULLs) +CREATE UNIQUE INDEX "TutorProfile_nationalIdNumber_key" ON "TutorProfile"("nationalIdNumber"); + +-- Helpful index for moderation/status filtering +CREATE INDEX "TutorProfile_verificationStatus_idx" ON "TutorProfile"("verificationStatus"); diff --git a/mentorme_backend/prisma/migrations/20251221120000_phase2_scheduling/migration.sql b/mentorme_backend/prisma/migrations/20251221120000_phase2_scheduling/migration.sql new file mode 100644 index 0000000..f486634 --- /dev/null +++ b/mentorme_backend/prisma/migrations/20251221120000_phase2_scheduling/migration.sql @@ -0,0 +1,72 @@ +-- Phase 2 scheduling additions +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- Add timezone to tutor availability +ALTER TABLE "TutorAvailability" ADD COLUMN "timezone" TEXT NOT NULL DEFAULT 'UTC'; + +-- Tutor unavailability blocks +CREATE TABLE "TutorUnavailability" ( + "id" TEXT PRIMARY KEY DEFAULT uuid_generate_v4(), + "tutorId" TEXT NOT NULL, + "startAt" TIMESTAMPTZ NOT NULL, + "endAt" TIMESTAMPTZ NOT NULL, + "createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "updatedAt" TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX "TutorUnavailability_tutorId_startAt_endAt_idx" ON "TutorUnavailability" ("tutorId", "startAt", "endAt"); + +ALTER TABLE "TutorUnavailability" + ADD CONSTRAINT "TutorUnavailability_tutorId_fkey" + FOREIGN KEY ("tutorId") REFERENCES "TutorProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Session status enum +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'SessionStatus') THEN + CREATE TYPE "SessionStatus" AS ENUM ('SCHEDULED', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED', 'MISSED'); + ELSE + -- add missing values if enum already exists + IF NOT EXISTS (SELECT 1 FROM pg_enum WHERE enumlabel = 'IN_PROGRESS' AND enumtypid = 'SessionStatus'::regtype) THEN + ALTER TYPE "SessionStatus" ADD VALUE 'IN_PROGRESS'; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_enum WHERE enumlabel = 'COMPLETED' AND enumtypid = 'SessionStatus'::regtype) THEN + ALTER TYPE "SessionStatus" ADD VALUE 'COMPLETED'; + END IF; + END IF; +END$$; + +-- Class schedule per class +CREATE TABLE "ClassSchedule" ( + "id" TEXT PRIMARY KEY DEFAULT uuid_generate_v4(), + "classId" TEXT NOT NULL UNIQUE, + "timezone" TEXT NOT NULL DEFAULT 'UTC', + "recurrenceRule" JSONB, + "explicitSessions" JSONB, + "totalSessions" INTEGER NOT NULL, + "createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "updatedAt" TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +ALTER TABLE "ClassSchedule" + ADD CONSTRAINT "ClassSchedule_classId_fkey" + FOREIGN KEY ("classId") REFERENCES "Class"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Sessions generated from schedules +CREATE TABLE "Session" ( + "id" TEXT PRIMARY KEY DEFAULT uuid_generate_v4(), + "classId" TEXT NOT NULL, + "scheduledStartAt" TIMESTAMPTZ NOT NULL, + "scheduledEndAt" TIMESTAMPTZ NOT NULL, + "status" "SessionStatus" NOT NULL DEFAULT 'SCHEDULED', + "disputeFlaggedAt" TIMESTAMPTZ, + "createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "updatedAt" TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX "Session_classId_scheduledStartAt_idx" ON "Session" ("classId", "scheduledStartAt"); +CREATE INDEX "Session_scheduledStartAt_scheduledEndAt_idx" ON "Session" ("scheduledStartAt", "scheduledEndAt"); + +ALTER TABLE "Session" + ADD CONSTRAINT "Session_classId_fkey" + FOREIGN KEY ("classId") REFERENCES "Class"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/mentorme_backend/prisma/migrations/20251222123000_phase3_session_lifecycle/migration.sql b/mentorme_backend/prisma/migrations/20251222123000_phase3_session_lifecycle/migration.sql new file mode 100644 index 0000000..03ce0c8 --- /dev/null +++ b/mentorme_backend/prisma/migrations/20251222123000_phase3_session_lifecycle/migration.sql @@ -0,0 +1,35 @@ +-- Phase 3: session lifecycle and class progress +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- Extend SessionStatus enum if needed +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_enum WHERE enumlabel = 'IN_PROGRESS' AND enumtypid = 'SessionStatus'::regtype) THEN + ALTER TYPE "SessionStatus" ADD VALUE 'IN_PROGRESS'; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_enum WHERE enumlabel = 'COMPLETED' AND enumtypid = 'SessionStatus'::regtype) THEN + ALTER TYPE "SessionStatus" ADD VALUE 'COMPLETED'; + END IF; +END$$; + +-- Class lifecycle status enum +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'ClassLifecycleStatus') THEN + CREATE TYPE "ClassLifecycleStatus" AS ENUM ('PENDING', 'ACTIVE', 'COMPLETED', 'CANCELLED'); + END IF; +END$$; + +ALTER TABLE "Class" + ADD COLUMN IF NOT EXISTS "lifecycleStatus" "ClassLifecycleStatus" NOT NULL DEFAULT 'PENDING', + ADD COLUMN IF NOT EXISTS "totalSessions" INTEGER NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS "sessionsCompleted" INTEGER NOT NULL DEFAULT 0; + +ALTER TABLE "Session" + ADD COLUMN IF NOT EXISTS "tutorStartConfirmedAt" TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS "studentStartConfirmedAt" TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS "tutorCompleteConfirmedAt" TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS "studentCompleteConfirmedAt" TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS "startedAt" TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS "completedAt" TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS "disputeFlaggedAt" TIMESTAMPTZ; diff --git a/mentorme_backend/prisma/migrations/20251223120000_phase4_notifications/migration.sql b/mentorme_backend/prisma/migrations/20251223120000_phase4_notifications/migration.sql new file mode 100644 index 0000000..85fdc1f --- /dev/null +++ b/mentorme_backend/prisma/migrations/20251223120000_phase4_notifications/migration.sql @@ -0,0 +1,55 @@ +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- NotificationChannel enum +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'NotificationChannel') THEN + CREATE TYPE "NotificationChannel" AS ENUM ('IN_APP', 'EMAIL'); + END IF; +END$$; + +-- ReminderType enum +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'ReminderType') THEN + CREATE TYPE "ReminderType" AS ENUM ('REMINDER_24H', 'REMINDER_1H'); + END IF; +END$$; + +CREATE TABLE "Notification" ( + "id" TEXT PRIMARY KEY DEFAULT uuid_generate_v4(), + "userId" TEXT NOT NULL, + "type" TEXT NOT NULL, + "title" TEXT NOT NULL, + "body" TEXT NOT NULL, + "metadata" JSONB, + "channel" "NotificationChannel" NOT NULL DEFAULT 'IN_APP', + "dedupKey" TEXT NOT NULL, + "readAt" TIMESTAMPTZ, + "createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX "Notification_userId_dedupKey_key" ON "Notification"("userId", "dedupKey"); +CREATE INDEX "Notification_userId_createdAt_idx" ON "Notification"("userId", "createdAt" DESC); + +ALTER TABLE "Notification" + ADD CONSTRAINT "Notification_userId_fkey" + FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +CREATE TABLE "ReminderLog" ( + "id" TEXT PRIMARY KEY DEFAULT uuid_generate_v4(), + "userId" TEXT NOT NULL, + "sessionId" TEXT NOT NULL, + "reminderType" "ReminderType" NOT NULL, + "sentAt" TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +ALTER TABLE "ReminderLog" + ADD CONSTRAINT "ReminderLog_userId_fkey" + FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "ReminderLog" + ADD CONSTRAINT "ReminderLog_sessionId_fkey" + FOREIGN KEY ("sessionId") REFERENCES "Session"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +CREATE UNIQUE INDEX "ReminderLog_userId_sessionId_reminderType_key" ON "ReminderLog"("userId", "sessionId", "reminderType"); diff --git a/mentorme_backend/prisma/schema.prisma b/mentorme_backend/prisma/schema.prisma index 414c24c..1ae0fcc 100644 --- a/mentorme_backend/prisma/schema.prisma +++ b/mentorme_backend/prisma/schema.prisma @@ -25,12 +25,36 @@ enum ClassStatus { ARCHIVED } +enum ClassLifecycleStatus { + PENDING + ACTIVE + COMPLETED + CANCELLED +} + +enum NotificationChannel { + IN_APP + EMAIL +} + +enum ReminderType { + REMINDER_24H + REMINDER_1H +} + enum LocationType { ONLINE AT_STUDENT AT_TUTOR } +enum VerificationStatus { + UNVERIFIED + PENDING + VERIFIED + REJECTED +} + enum BookingStatus { PENDING CONFIRMED @@ -84,6 +108,7 @@ model TutorProfile { bio String? education String? certificates String[] + proofDocuments Json? yearsOfExperience Int @default(0) hourlyRateMin Float? hourlyRateMax Float? @@ -91,6 +116,14 @@ model TutorProfile { city String? district String? verified Boolean @default(false) + verificationStatus VerificationStatus @default(UNVERIFIED) + nationalIdNumber String? @unique + nationalIdFrontImageUrl String? + nationalIdBackImageUrl String? + certificatesDetail Json? + verificationSubmittedAt DateTime? + verificationReviewedAt DateTime? + verificationNotes String? trustScore Float @default(0) totalBookings Int @default(0) averageRating Float @default(0) @@ -111,6 +144,7 @@ model TutorProfile { bookings Booking[] reviews Review[] availabilities TutorAvailability[] + unavailabilities TutorUnavailability[] } model TutorAvailability { @@ -119,6 +153,7 @@ model TutorAvailability { dayOfWeek Int startMinute Int endMinute Int + timezone String @default("UTC") locationType LocationType createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -128,6 +163,93 @@ model TutorAvailability { @@index([tutorId, dayOfWeek]) } +model TutorUnavailability { + id String @id @default(uuid()) + tutorId String + startAt DateTime + endAt DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + tutor TutorProfile @relation(fields: [tutorId], references: [id], onDelete: Cascade) + + @@index([tutorId, startAt, endAt]) +} + +model ClassSchedule { + id String @id @default(uuid()) + classId String @unique + timezone String @default("UTC") + recurrenceRule Json? + explicitSessions Json? + totalSessions Int + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + class Class @relation(fields: [classId], references: [id], onDelete: Cascade) +} + +enum SessionStatus { + SCHEDULED + IN_PROGRESS + COMPLETED + CANCELLED + MISSED +} + +model Session { + id String @id @default(uuid()) + classId String + scheduledStartAt DateTime + scheduledEndAt DateTime + status SessionStatus @default(SCHEDULED) + tutorStartConfirmedAt DateTime? + studentStartConfirmedAt DateTime? + tutorCompleteConfirmedAt DateTime? + studentCompleteConfirmedAt DateTime? + startedAt DateTime? + completedAt DateTime? + disputeFlaggedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + class Class @relation(fields: [classId], references: [id], onDelete: Cascade) + + @@index([classId, scheduledStartAt]) + @@index([scheduledStartAt, scheduledEndAt]) +} + +model Notification { + id String @id @default(uuid()) + userId String + type String + title String + body String + metadata Json? + channel NotificationChannel @default(IN_APP) + dedupKey String + readAt DateTime? + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, dedupKey]) + @@index([userId, createdAt]) +} + +model ReminderLog { + id String @id @default(uuid()) + userId String + sessionId String + reminderType ReminderType + sentAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + session Session @relation(fields: [sessionId], references: [id], onDelete: Cascade) + + @@unique([userId, sessionId, reminderType]) +} + model Subject { id String @id @default(uuid()) name String @@ -153,6 +275,9 @@ model Class { city String? district String? status ClassStatus @default(DRAFT) + lifecycleStatus ClassLifecycleStatus @default(PENDING) + totalSessions Int @default(0) + sessionsCompleted Int @default(0) isDeleted Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -160,6 +285,8 @@ model Class { tutor TutorProfile @relation(fields: [tutorId], references: [id]) subject Subject @relation(fields: [subjectId], references: [id]) bookings Booking[] + schedule ClassSchedule? + sessions Session[] @@index([subjectId, status]) @@index([tutorId]) diff --git a/mentorme_backend/src/app.ts b/mentorme_backend/src/app.ts index 8ad3606..e411b60 100644 --- a/mentorme_backend/src/app.ts +++ b/mentorme_backend/src/app.ts @@ -10,6 +10,9 @@ import bookingRoutes from "./routes/bookingRoutes"; import reviewRoutes from "./routes/reviewRoutes"; import matchingRoutes from "./routes/matchingRoutes"; import adminRoutes from "./routes/adminRoutes"; +import calendarRoutes from "./routes/calendarRoutes"; +import sessionRoutes from "./routes/sessionRoutes"; +import notificationRoutes from "./routes/notificationRoutes"; const app = express(); @@ -34,5 +37,8 @@ app.use("/api/bookings", bookingRoutes); app.use("/api/reviews", reviewRoutes); app.use("/api/matching", matchingRoutes); app.use("/api/admin", adminRoutes); +app.use("/api/calendar", calendarRoutes); +app.use("/api/sessions", sessionRoutes); +app.use("/api/notifications", notificationRoutes); export default app; diff --git a/mentorme_backend/src/domain/matchingEngine.ts b/mentorme_backend/src/domain/matchingEngine.ts index 8a66a8c..c320e17 100644 --- a/mentorme_backend/src/domain/matchingEngine.ts +++ b/mentorme_backend/src/domain/matchingEngine.ts @@ -6,8 +6,18 @@ import { TutorAvailability, TutorProfile, UserStatus, + VerificationStatus, } from "@prisma/client"; import { cosineSimilarity, generateEmbedding } from "./embeddings"; +import { maskNationalId, sanitizeTutorForPublic } from "../utils/tutorSanitizer"; + +const VS = + VerificationStatus ?? { + UNVERIFIED: "UNVERIFIED", + PENDING: "PENDING", + VERIFIED: "VERIFIED", + REJECTED: "REJECTED", + }; export interface TimeSlot { dayOfWeek: number; @@ -157,10 +167,10 @@ function computeMatchingScore(params: { return ( 0.25 * params.subjectMatch + 0.1 * params.gradeMatch + - 0.1 * params.timeOverlapScore + + 0.15 * params.timeOverlapScore + 0.1 * params.priceScore + 0.1 * params.locationScore + - 0.1 * trustComponent + + 0.15 * trustComponent + 0.25 * semanticComponent ); } @@ -186,6 +196,7 @@ export async function matchTutors( const baseWhere: Prisma.TutorProfileWhereInput = { classes: { some: classFilter }, user: { status: UserStatus.ACTIVE }, + verificationStatus: VS.VERIFIED as any, }; let where: Prisma.TutorProfileWhereInput = { ...baseWhere }; if (request.city) { @@ -221,7 +232,11 @@ export async function matchTutors( ? await generateEmbedding(descriptionText) : null; - const results: MatchedTutor[] = tutors.map((tutor) => { + const verifiedTutors = tutors.filter( + (tutor) => tutor.verificationStatus === VS.VERIFIED + ); + + const results: MatchedTutor[] = verifiedTutors.map((tutor) => { const subjectMatch = tutor.classes.some((cls) => cls.subjectId === request.subjectId) ? 1 : 0; const gradeMatch = tutor.classes.some((cls) => @@ -263,7 +278,11 @@ export async function matchTutors( if (locationScore >= 0.7) reasons.push("Location preference matched"); if (semanticScore > 0.6) reasons.push("Profile aligns with description"); - return { tutor, score, reasons }; + return { + tutor: sanitizeTutorForPublic(tutor, { maskNationalId }), + score, + reasons, + }; }); return results.sort((a, b) => b.score - a.score).slice(0, limit); diff --git a/mentorme_backend/src/jobs/sessionReminders.ts b/mentorme_backend/src/jobs/sessionReminders.ts new file mode 100644 index 0000000..980ace2 --- /dev/null +++ b/mentorme_backend/src/jobs/sessionReminders.ts @@ -0,0 +1,99 @@ +import { prisma } from "../lib/prisma"; +import { ReminderType, SessionStatus } from "@prisma/client"; +import { createReminderIfNeeded } from "../services/notifications"; + +const SS = + SessionStatus ?? { + SCHEDULED: "SCHEDULED", + }; +const RT = + ReminderType ?? { + REMINDER_24H: "REMINDER_24H", + REMINDER_1H: "REMINDER_1H", + }; + +const windowMinutes = 5; + +const isWithinWindow = (target: Date, minutesFromNow: number) => { + const now = Date.now(); + const targetTime = target.getTime(); + const min = minutesFromNow * 60 * 1000; + return targetTime >= now + (minutesFromNow - windowMinutes) * 60 * 1000 && + targetTime <= now + (minutesFromNow + windowMinutes) * 60 * 1000; +}; + +export async function runSessionReminderJob(client = prisma) { + const now = new Date(); + const sessions = await client.session.findMany({ + where: { + status: SS.SCHEDULED as any, + scheduledStartAt: { + gte: new Date(now.getTime() - 24 * 60 * 60 * 1000), + lte: new Date(now.getTime() + 25 * 60 * 60 * 1000), + }, + }, + include: { + class: { + include: { + tutor: true, + bookings: { + where: { status: { not: "CANCELLED" } }, + select: { studentId: true }, + }, + }, + }, + }, + }); + + for (const session of sessions) { + const start = new Date(session.scheduledStartAt); + const shouldSend24h = isWithinWindow(start, 24 * 60); + const shouldSend1h = isWithinWindow(start, 60); + + if (!shouldSend24h && !shouldSend1h) continue; + const classId = session.classId; + const tutorId = session.class?.tutor?.userId; + const studentIds = session.class?.bookings?.map((b) => b.studentId) ?? []; + const studentProfiles = await client.studentProfile.findMany({ + where: { id: { in: studentIds } }, + select: { userId: true, id: true }, + }); + const recipients = [ + tutorId, + ...studentProfiles.map((s) => s.userId), + ].filter(Boolean) as string[]; + + for (const userId of recipients) { + if (shouldSend24h) { + await createReminderIfNeeded(client, { + userId, + sessionId: session.id, + reminderType: RT.REMINDER_24H as any, + title: "Nhắc lịch học trong 24h", + body: `Buổi học sẽ bắt đầu lúc ${start.toISOString()} (class ${classId})`, + }); + } + if (shouldSend1h) { + await createReminderIfNeeded(client, { + userId, + sessionId: session.id, + reminderType: RT.REMINDER_1H as any, + title: "Nhắc lịch học trong 1h", + body: `Buổi học sẽ bắt đầu lúc ${start.toISOString()} (class ${classId})`, + }); + } + } + } +} + +if (require.main === module) { + runSessionReminderJob() + .then(() => { + console.log("Reminder job done"); + process.exit(0); + }) + .catch((err) => { + console.error("Reminder job failed", err); + process.exit(1); + }); +} diff --git a/mentorme_backend/src/routes/adminRoutes.ts b/mentorme_backend/src/routes/adminRoutes.ts index 672dc93..a5edfc0 100644 --- a/mentorme_backend/src/routes/adminRoutes.ts +++ b/mentorme_backend/src/routes/adminRoutes.ts @@ -3,14 +3,26 @@ import { authGuard } from "../middleware/auth"; import { BookingStatus, ClassStatus, + ClassLifecycleStatus, Prisma, UserRole, UserStatus, + VerificationStatus, + SessionStatus, } from "@prisma/client"; import { prisma } from "../lib/prisma"; import { z } from "zod"; +import { maskNationalId } from "../utils/tutorSanitizer"; +import { createNotification } from "../services/notifications"; const router = Router(); +const VerificationStatusEnum = + VerificationStatus ?? { + UNVERIFIED: "UNVERIFIED", + PENDING: "PENDING", + VERIFIED: "VERIFIED", + REJECTED: "REJECTED", + }; router.use(authGuard([UserRole.ADMIN])); @@ -18,8 +30,8 @@ router.get("/tutors/pending", async (_req, res) => { try { const tutors = await prisma.tutorProfile.findMany({ where: { - verified: false, - user: { status: UserStatus.PENDING }, + verificationStatus: VerificationStatusEnum.PENDING, + user: { status: { in: [UserStatus.PENDING, UserStatus.ACTIVE] } }, }, include: { user: true, @@ -37,6 +49,14 @@ const verifySchema = z.object({ note: z.string().optional(), }); +const verificationFilterSchema = z.object({ + status: z.nativeEnum(VerificationStatusEnum).optional(), +}); + +const verificationDecisionSchema = z.object({ + note: z.string().optional(), +}); + router.patch("/tutors/:id/verify", async (req, res) => { try { const payload = verifySchema.parse(req.body); @@ -52,6 +72,11 @@ router.patch("/tutors/:id/verify", async (req, res) => { where: { id: tutor.id }, data: { verified: payload.approved, + verificationStatus: payload.approved + ? VerificationStatusEnum.VERIFIED + : VerificationStatusEnum.REJECTED, + verificationReviewedAt: new Date(), + verificationNotes: payload.note ?? null, moderationNote: payload.note ?? null, }, }), @@ -72,6 +97,140 @@ router.patch("/tutors/:id/verify", async (req, res) => { } }); +router.get("/tutor-verifications", async (req, res) => { + try { + const query = verificationFilterSchema.parse({ status: req.query.status }); + const where: Prisma.TutorProfileWhereInput = {}; + if (query.status) where.verificationStatus = query.status; + + const tutors = await prisma.tutorProfile.findMany({ + where, + include: { user: true }, + orderBy: { verificationSubmittedAt: "desc" }, + }); + const sanitized = tutors.map((tutor) => ({ + ...tutor, + nationalIdNumber: maskNationalId(tutor.nationalIdNumber), + })); + return res.json(sanitized); + } catch (error) { + if (error instanceof z.ZodError) { + return res.status(400).json({ message: "Invalid filters", issues: error.issues }); + } + return res.status(500).json({ message: "Internal server error" }); + } +}); + +router.get("/tutor-verifications/:id", async (req, res) => { + try { + const tutor = await prisma.tutorProfile.findUnique({ + where: { id: req.params.id }, + include: { user: true }, + }); + if (!tutor) { + return res.status(404).json({ message: "Tutor not found" }); + } + return res.json(tutor); + } catch (error) { + return res.status(500).json({ message: "Internal server error" }); + } +}); + +router.patch("/tutor-verifications/:id/approve", async (req, res) => { + try { + const payload = verificationDecisionSchema.parse(req.body); + const tutor = await prisma.tutorProfile.findUnique({ where: { id: req.params.id } }); + if (!tutor) { + return res.status(404).json({ message: "Tutor not found" }); + } + await prisma.$transaction([ + prisma.tutorProfile.update({ + where: { id: tutor.id }, + data: { + verificationStatus: VerificationStatusEnum.VERIFIED, + verificationReviewedAt: new Date(), + verificationNotes: payload.note ?? null, + verified: true, + }, + }), + prisma.user.update({ + where: { id: tutor.userId }, + data: { status: UserStatus.ACTIVE }, + }), + ]); + + await createNotification(prisma, { + userId: tutor.userId, + type: "VERIFICATION_APPROVED", + title: "Hồ sơ đã được duyệt", + body: "Hồ sơ gia sư của bạn đã được phê duyệt.", + metadata: { tutorId: tutor.id }, + dedupKey: `verification:${tutor.id}:approved`, + }); + return res.json({ message: "Tutor verification approved" }); + } catch (error) { + if (error instanceof z.ZodError) { + return res.status(400).json({ message: "Invalid payload", issues: error.issues }); + } + return res.status(500).json({ message: "Internal server error" }); + } +}); + +router.patch("/tutor-verifications/:id/reject", async (req, res) => { + try { + const payload = verificationDecisionSchema.parse(req.body); + const tutor = await prisma.tutorProfile.findUnique({ where: { id: req.params.id } }); + if (!tutor) { + return res.status(404).json({ message: "Tutor not found" }); + } + await prisma.$transaction([ + prisma.tutorProfile.update({ + where: { id: tutor.id }, + data: { + verificationStatus: VerificationStatusEnum.REJECTED, + verificationReviewedAt: new Date(), + verificationNotes: payload.note ?? null, + verified: false, + }, + }), + prisma.user.update({ + where: { id: tutor.userId }, + data: { status: UserStatus.SUSPENDED }, + }), + ]); + + await createNotification(prisma, { + userId: tutor.userId, + type: "VERIFICATION_REJECTED", + title: "Hồ sơ bị từ chối", + body: "Hồ sơ gia sư của bạn bị từ chối, vui lòng cập nhật và gửi lại.", + metadata: { tutorId: tutor.id }, + dedupKey: `verification:${tutor.id}:rejected`, + }); + return res.json({ message: "Tutor verification rejected" }); + } catch (error) { + if (error instanceof z.ZodError) { + return res.status(400).json({ message: "Invalid payload", issues: error.issues }); + } + return res.status(500).json({ message: "Internal server error" }); + } +}); + +router.get("/sessions/flagged", async (_req, res) => { + try { + const sessions = await prisma.session.findMany({ + where: { disputeFlaggedAt: { not: null } }, + orderBy: { disputeFlaggedAt: "desc" }, + include: { + class: true, + }, + }); + return res.json(sessions); + } catch (error) { + return res.status(500).json({ message: "Internal server error" }); + } +}); + const banSchema = z.object({ banned: z.boolean(), }); diff --git a/mentorme_backend/src/routes/bookingRoutes.ts b/mentorme_backend/src/routes/bookingRoutes.ts index b48a3d3..213b00b 100644 --- a/mentorme_backend/src/routes/bookingRoutes.ts +++ b/mentorme_backend/src/routes/bookingRoutes.ts @@ -6,7 +6,13 @@ import { CancelledBy, ClassStatus, UserRole, + VerificationStatus, } from "@prisma/client"; + +const VS = + VerificationStatus ?? { + VERIFIED: "VERIFIED", + }; import { z } from "zod"; import { recalculateTutorStats } from "../services/tutorStats"; @@ -32,6 +38,13 @@ const getTutorIdByUser = async (userId: string) => { return tutor?.id; }; +const ensureTutorVerified = async (tutorId: string) => { + const tutor = await prisma.tutorProfile.findUnique({ where: { id: tutorId } }); + if (!tutor || tutor.verificationStatus !== VS.VERIFIED) { + return false; + } + return true; +}; router.post("/", authGuard([UserRole.STUDENT]), async (req, res) => { try { @@ -182,6 +195,13 @@ router.patch("/:id/confirm", authGuard([UserRole.TUTOR]), async (req, res) => { return res.status(400).json({ message: "Only pending bookings can be confirmed" }); } + const isVerifiedTutor = await ensureTutorVerified(tutorId); + if (!isVerifiedTutor) { + return res + .status(403) + .json({ message: "Tutor must be verified before confirming a class" }); + } + const updated = await prisma.booking.update({ where: { id: booking.id }, data: { @@ -326,6 +346,10 @@ router.patch("/:id/complete", authGuard([UserRole.TUTOR, UserRole.ADMIN]), async if (!tutorId || tutorId !== booking.tutorId) { return res.status(403).json({ message: "Forbidden" }); } + const isVerifiedTutor = await ensureTutorVerified(tutorId); + if (!isVerifiedTutor) { + return res.status(403).json({ message: "Tutor must be verified to complete a class" }); + } } const updated = await prisma.booking.update({ diff --git a/mentorme_backend/src/routes/calendarRoutes.ts b/mentorme_backend/src/routes/calendarRoutes.ts new file mode 100644 index 0000000..e3447e5 --- /dev/null +++ b/mentorme_backend/src/routes/calendarRoutes.ts @@ -0,0 +1,98 @@ +import { Router } from "express"; +import { authGuard } from "../middleware/auth"; +import { prisma } from "../lib/prisma"; +import { BookingStatus, ClassStatus, SessionStatus, UserRole } from "@prisma/client"; + +const router = Router(); +const SS = + SessionStatus ?? { + SCHEDULED: "SCHEDULED", + CANCELLED: "CANCELLED", + MISSED: "MISSED", + }; + +router.get("/tutor", authGuard([UserRole.TUTOR]), async (req, res) => { + try { + const tutor = await prisma.tutorProfile.findUnique({ where: { userId: req.user!.id } }); + if (!tutor) return res.status(404).json({ message: "Tutor profile not found" }); + + const now = new Date(); + const sessions = await prisma.session.findMany({ + where: { + status: { not: SS.CANCELLED as any }, + scheduledEndAt: { gte: now }, + class: { + tutorId: tutor.id, + isDeleted: false, + status: { not: ClassStatus.ARCHIVED }, + }, + }, + include: { class: true }, + orderBy: { scheduledStartAt: "asc" }, + }); + + return res.json( + sessions.map((s) => ({ + id: s.id, + classId: s.classId, + scheduledStartAt: s.scheduledStartAt, + scheduledEndAt: s.scheduledEndAt, + status: s.status, + classTitle: s.class?.title ?? "", + locationType: s.class?.locationType, + })) + ); + } catch (error) { + return res.status(500).json({ message: "Internal server error" }); + } +}); + +router.get("/student", authGuard([UserRole.STUDENT]), async (req, res) => { + try { + const student = await prisma.studentProfile.findUnique({ where: { userId: req.user!.id } }); + if (!student) return res.status(404).json({ message: "Student profile not found" }); + + const activeBookings = await prisma.booking.findMany({ + where: { + studentId: student.id, + status: { in: [BookingStatus.CONFIRMED, BookingStatus.TRIAL] }, + }, + select: { classId: true }, + }); + const classIds = Array.from(new Set(activeBookings.map((b) => b.classId))); + if (classIds.length === 0) { + return res.json([]); + } + + const now = new Date(); + const sessions = await prisma.session.findMany({ + where: { + status: { not: SS.CANCELLED as any }, + scheduledEndAt: { gte: now }, + classId: { in: classIds }, + class: { + isDeleted: false, + status: { not: ClassStatus.ARCHIVED }, + }, + }, + include: { class: true }, + orderBy: { scheduledStartAt: "asc" }, + }); + + return res.json( + sessions.map((s) => ({ + id: s.id, + classId: s.classId, + scheduledStartAt: s.scheduledStartAt, + scheduledEndAt: s.scheduledEndAt, + status: s.status, + classTitle: s.class?.title ?? "", + locationType: s.class?.locationType, + })) + ); + } catch (error) { + return res.status(500).json({ message: "Internal server error" }); + } +}); + +export default router; diff --git a/mentorme_backend/src/routes/classRoutes.ts b/mentorme_backend/src/routes/classRoutes.ts index 4db845e..6edf81c 100644 --- a/mentorme_backend/src/routes/classRoutes.ts +++ b/mentorme_backend/src/routes/classRoutes.ts @@ -4,13 +4,42 @@ import { authGuard } from "../middleware/auth"; import { BookingStatus, ClassStatus, + ClassLifecycleStatus, LocationType, Prisma, UserRole, + UserStatus, + VerificationStatus, + SessionStatus, } from "@prisma/client"; import { z } from "zod"; +import { maskNationalId, sanitizeTutorForPublic } from "../utils/tutorSanitizer"; +import { checkScheduleConflicts, normalizeSessions, SessionInput } from "../services/scheduling"; +import { createNotification } from "../services/notifications"; const router = Router(); +const VS = + VerificationStatus ?? { + UNVERIFIED: "UNVERIFIED", + PENDING: "PENDING", + VERIFIED: "VERIFIED", + REJECTED: "REJECTED", + }; +const SS = + SessionStatus ?? { + SCHEDULED: "SCHEDULED", + IN_PROGRESS: "IN_PROGRESS", + COMPLETED: "COMPLETED", + CANCELLED: "CANCELLED", + MISSED: "MISSED", + }; +const CL = + ClassLifecycleStatus ?? { + PENDING: "PENDING", + ACTIVE: "ACTIVE", + COMPLETED: "COMPLETED", + CANCELLED: "CANCELLED", + }; const listSchema = z.object({ tutorId: z.string().uuid().optional(), @@ -28,6 +57,10 @@ router.get("/", async (req, res) => { const query = listSchema.parse(req.query); const where: Prisma.ClassWhereInput = { status: query.status ?? ClassStatus.PUBLISHED, + tutor: { + verificationStatus: (VerificationStatus ?? {}).VERIFIED ?? VerificationStatus.VERIFIED, + user: { status: UserStatus.ACTIVE }, + }, }; if (!query.includeDeleted) { where.isDeleted = false; @@ -73,6 +106,70 @@ const baseClassSchema = z.object({ district: z.string().optional(), }); +const recurrenceSlotSchema = z.object({ + dayOfWeek: z.number().int().min(0).max(6), + startMinute: z.number().int().min(0).max(1440), + endMinute: z.number().int().min(1).max(1440), +}); + +const scheduleCreateSchema = z + .object({ + timezone: z.string().min(1).default("UTC"), + recurrence: z + .object({ + startDate: z.string().datetime(), + weeks: z.number().int().min(1).max(52), + slots: z.array(recurrenceSlotSchema).min(1), + }) + .optional(), + explicitSessions: z + .array( + z.object({ + startAt: z.string().datetime(), + endAt: z.string().datetime(), + }) + ) + .optional(), + }) + .refine( + (val) => + (val.recurrence && val.recurrence.slots.length > 0) || + (val.explicitSessions && val.explicitSessions.length > 0), + { message: "Provide recurrence or explicit sessions" } + ); + +const toSessionInputs = (payload: z.infer): SessionInput[] => { + const sessions: SessionInput[] = []; + if (payload.recurrence) { + const base = new Date(payload.recurrence.startDate); + const baseStartOfDay = Date.UTC(base.getUTCFullYear(), base.getUTCMonth(), base.getUTCDate()); + const baseDay = base.getUTCDay(); + for (let week = 0; week < payload.recurrence.weeks; week++) { + payload.recurrence.slots.forEach((slot) => { + if (slot.endMinute <= slot.startMinute) return; + const dayOffset = ((slot.dayOfWeek - baseDay + 7) % 7) + week * 7; + const start = new Date(baseStartOfDay + dayOffset * 24 * 60 * 60 * 1000 + slot.startMinute * 60 * 1000); + const end = new Date(baseStartOfDay + dayOffset * 24 * 60 * 60 * 1000 + slot.endMinute * 60 * 1000); + sessions.push({ start, end }); + }); + } + } + + if (payload.explicitSessions) { + payload.explicitSessions.forEach((s) => { + const start = new Date(s.startAt); + const end = new Date(s.endAt); + if (end > start) { + sessions.push({ start, end }); + } + }); + } + + return normalizeSessions( + sessions.filter((s) => s.start < s.end) + ); +}; + router.post("/", authGuard([UserRole.TUTOR]), async (req, res) => { try { const payload = baseClassSchema.parse(req.body); @@ -305,6 +402,243 @@ router.get("/:id/students", authGuard([UserRole.TUTOR, UserRole.ADMIN]), async ( } }); +router.get("/:id/sessions", authGuard(), async (req, res) => { + try { + const classId = req.params.id ?? ""; + if (!classId) return res.status(400).json({ message: "Class id required" }); + const classListing = await prisma.class.findUnique({ + where: { id: classId }, + include: { tutor: true, schedule: true }, + }); + if (!classListing || classListing.isDeleted) { + return res.status(404).json({ message: "Class not found" }); + } + if (req.user?.role === UserRole.TUTOR) { + const tutor = await prisma.tutorProfile.findUnique({ where: { userId: req.user.id } }); + if (!tutor || tutor.id !== classListing.tutorId) { + return res.status(403).json({ message: "Forbidden" }); + } + } + if (req.user?.role === UserRole.STUDENT) { + const student = await prisma.studentProfile.findUnique({ where: { userId: req.user.id } }); + if (!student) return res.status(400).json({ message: "Student profile not found" }); + const booking = await prisma.booking.findFirst({ + where: { + classId, + studentId: student.id, + status: { in: [BookingStatus.CONFIRMED, BookingStatus.TRIAL] }, + }, + }); + if (!booking) return res.status(403).json({ message: "Forbidden" }); + } + + const sessions = await prisma.session.findMany({ + where: { classId }, + orderBy: { scheduledStartAt: "asc" }, + }); + return res.json({ + class: { + id: classListing.id, + title: classListing.title, + lifecycleStatus: classListing.lifecycleStatus, + totalSessions: classListing.totalSessions, + sessionsCompleted: classListing.sessionsCompleted, + }, + schedule: classListing.schedule, + sessions, + }); + } catch (error) { + return res.status(500).json({ message: "Internal server error" }); + } +}); + +router.get("/:id/schedule", authGuard(), async (req, res) => { + try { + const classId = req.params.id ?? ""; + if (!classId) { + return res.status(400).json({ message: "Class id required" }); + } + + const schedule = await prisma.classSchedule.findUnique({ + where: { classId }, + include: { + sessions: { + orderBy: { scheduledStartAt: "asc" }, + }, + class: true, + }, + }); + + if (!schedule) { + const cls = await prisma.class.findUnique({ where: { id: classId } }); + return res.json({ schedule: null, sessions: [], class: cls }); + } + + return res.json({ + schedule: { + id: schedule.id, + classId: schedule.classId, + timezone: schedule.timezone, + recurrenceRule: schedule.recurrenceRule, + explicitSessions: schedule.explicitSessions, + totalSessions: schedule.totalSessions, + }, + class: schedule.class + ? { + id: schedule.class.id, + title: schedule.class.title, + lifecycleStatus: (schedule.class as any).lifecycleStatus, + totalSessions: (schedule.class as any).totalSessions, + sessionsCompleted: (schedule.class as any).sessionsCompleted, + } + : null, + sessions: schedule.sessions, + }); + } catch (error) { + return res.status(500).json({ message: "Internal server error" }); + } +}); + +router.post( + "/:id/schedule", + authGuard([UserRole.TUTOR, UserRole.ADMIN]), + async (req, res) => { + try { + const classId = req.params.id ?? ""; + if (!classId) { + return res.status(400).json({ message: "Class id required" }); + } + + const payload = scheduleCreateSchema.parse(req.body); + + const classListing = await prisma.class.findUnique({ + where: { id: classId }, + include: { tutor: true }, + }); + if (!classListing || classListing.isDeleted) { + return res.status(404).json({ message: "Class not found" }); + } + + if (req.user!.role === UserRole.TUTOR) { + const tutor = await prisma.tutorProfile.findUnique({ + where: { userId: req.user!.id }, + }); + if (!tutor || tutor.id !== classListing.tutorId) { + return res.status(403).json({ message: "Forbidden" }); + } + if (tutor.verificationStatus !== VS.VERIFIED) { + return res + .status(403) + .json({ message: "Tutor must be verified before scheduling" }); + } + } + + const sessions = toSessionInputs(payload); + if (sessions.length === 0) { + return res.status(400).json({ message: "No valid sessions generated" }); + } + + const bookings = await prisma.booking.findMany({ + where: { + classId: classListing.id, + status: { in: [BookingStatus.CONFIRMED, BookingStatus.TRIAL] }, + }, + include: { student: { select: { id: true, userId: true } } }, + }); + const studentIds = Array.from(new Set(bookings.map((b) => b.studentId ?? b.student?.id).filter(Boolean))) as string[]; + const studentUserIds = bookings.map((b) => b.student?.userId).filter(Boolean) as string[]; + + const conflict = await checkScheduleConflicts(prisma, { + classId: classListing.id, + tutorId: classListing.tutorId, + studentIds, + sessions, + }); + if (conflict) { + return res.status(409).json({ + message: "Scheduling conflict detected", + type: conflict.type, + conflict: conflict.conflict, + }); + } + + const scheduleData = { + timezone: payload.timezone, + recurrenceRule: payload.recurrence ?? null, + explicitSessions: payload.explicitSessions ?? null, + totalSessions: sessions.length, + }; + + const createdSessions = sessions.map((s) => ({ + classId: classListing.id, + scheduledStartAt: s.start, + scheduledEndAt: s.end, + status: SS.SCHEDULED as any, + })); + + const result = await prisma.$transaction(async (tx) => { + await tx.session.deleteMany({ where: { classId: classListing.id } }); + const schedule = await tx.classSchedule.upsert({ + where: { classId: classListing.id }, + update: { + ...scheduleData, + }, + create: { + classId: classListing.id, + ...scheduleData, + }, + }); + + if (createdSessions.length > 0) { + await tx.session.createMany({ data: createdSessions }); + } + + await tx.class.update({ + where: { id: classListing.id }, + data: { + totalSessions: createdSessions.length, + sessionsCompleted: 0, + lifecycleStatus: CL.ACTIVE as any, + }, + }); + + const sessionsData = await tx.session.findMany({ + where: { classId: classListing.id }, + orderBy: { scheduledStartAt: "asc" }, + }); + + return { schedule, sessionsData }; + }); + + const tutorUserId = classListing.tutor?.userId; + const recipients = [ + tutorUserId, + ...studentUserIds, + ].filter(Boolean) as string[]; + for (const uid of recipients) { + await createNotification(prisma, { + userId: uid, + type: "SCHEDULE_CREATED", + title: "Lịch học đã được cập nhật", + body: "Lịch học của lớp đã được tạo hoặc cập nhật.", + metadata: { classId: classListing.id, scheduleId: result.schedule.id }, + dedupKey: `class:${classListing.id}:schedule:${uid}`, + }); + } + + return res.status(200).json({ + schedule: result.schedule, + sessions: result.sessionsData, + }); + } catch (error) { + if (error instanceof z.ZodError) { + return res.status(400).json({ message: "Invalid schedule payload", issues: error.issues }); + } + return res.status(500).json({ message: "Internal server error" }); + } + } +); + router.get("/:id", async (req, res) => { try { const classId = req.params.id ?? ""; @@ -323,7 +657,12 @@ router.get("/:id", async (req, res) => { return res.status(404).json({ message: "Class not found" }); } - return res.json(classListing); + const result = { ...classListing } as any; + if (result.tutor) { + result.tutor = sanitizeTutorForPublic(result.tutor, { maskNationalId }); + } + + return res.json(result); } catch (error) { return res.status(500).json({ message: "Internal server error" }); } diff --git a/mentorme_backend/src/routes/matchingRoutes.ts b/mentorme_backend/src/routes/matchingRoutes.ts index 3c3ee3d..dd2277f 100644 --- a/mentorme_backend/src/routes/matchingRoutes.ts +++ b/mentorme_backend/src/routes/matchingRoutes.ts @@ -1,10 +1,18 @@ import { Router } from "express"; import { z } from "zod"; -import { ClassStatus, Prisma, UserStatus } from "@prisma/client"; +import { ClassStatus, Prisma, UserStatus, VerificationStatus } from "@prisma/client"; import { prisma } from "../lib/prisma"; import { matchTutors } from "../domain/matchingEngine"; +import { maskNationalId, sanitizeTutorForPublic } from "../utils/tutorSanitizer"; const router = Router(); +const VS = + VerificationStatus ?? { + UNVERIFIED: "UNVERIFIED", + PENDING: "PENDING", + VERIFIED: "VERIFIED", + REJECTED: "REJECTED", + }; const timeSlotSchema = z .object({ @@ -66,7 +74,7 @@ router.get("/tutors", async (req, res) => { } const where: Prisma.TutorProfileWhereInput = { - verified: true, + verificationStatus: VS.VERIFIED as any, user: { status: UserStatus.ACTIVE }, }; if (effectiveCity) { @@ -144,6 +152,7 @@ router.get("/tutors", async (req, res) => { }); const results = tutors + .filter((tutor) => tutor.verificationStatus === VS.VERIFIED) .map((tutor) => { const subjectMatch = effectiveSubjectId ? tutor.classes.some((cls) => cls.subjectId === effectiveSubjectId) @@ -183,7 +192,7 @@ router.get("/tutors", async (req, res) => { (gradeMatch ? 4 : 0) - pricePenalty; - return { tutor, matchScore }; + return { tutor: sanitizeTutorForPublic(tutor, { maskNationalId }), matchScore }; }) .sort((a, b) => b.matchScore - a.matchScore); diff --git a/mentorme_backend/src/routes/notificationRoutes.ts b/mentorme_backend/src/routes/notificationRoutes.ts new file mode 100644 index 0000000..b56cdda --- /dev/null +++ b/mentorme_backend/src/routes/notificationRoutes.ts @@ -0,0 +1,74 @@ +import { Router } from "express"; +import { authGuard } from "../middleware/auth"; +import { prisma } from "../lib/prisma"; +import { z } from "zod"; +import { UserRole } from "@prisma/client"; + +const router = Router(); + +router.get("/me", authGuard(), async (req, res) => { + try { + const page = Number(req.query.page ?? 1); + const pageSize = Math.min(Number(req.query.pageSize ?? 20), 50); + const skip = (page - 1) * pageSize; + const [items, total] = await Promise.all([ + prisma.notification.findMany({ + where: { userId: req.user!.id }, + orderBy: { createdAt: "desc" }, + skip, + take: pageSize, + }), + prisma.notification.count({ where: { userId: req.user!.id } }), + ]); + const unread = await prisma.notification.count({ where: { userId: req.user!.id, readAt: null } }); + return res.json({ data: items, total, page, pageSize, unread }); + } catch (error) { + return res.status(500).json({ message: "Internal server error" }); + } +}); + +router.patch("/:id/read", authGuard(), async (req, res) => { + try { + const notif = await prisma.notification.findUnique({ where: { id: req.params.id } }); + if (!notif || notif.userId !== req.user!.id) { + return res.status(404).json({ message: "Notification not found" }); + } + const updated = await prisma.notification.update({ + where: { id: notif.id }, + data: { readAt: new Date() }, + }); + return res.json(updated); + } catch (error) { + return res.status(500).json({ message: "Internal server error" }); + } +}); + +router.patch("/read-all", authGuard(), async (req, res) => { + try { + await prisma.notification.updateMany({ + where: { userId: req.user!.id, readAt: null }, + data: { readAt: new Date() }, + }); + return res.json({ message: "OK" }); + } catch (error) { + return res.status(500).json({ message: "Internal server error" }); + } +}); + +router.get("/", authGuard([UserRole.ADMIN]), async (req, res) => { + try { + const type = req.query.type as string | undefined; + const where: any = {}; + if (type) where.type = type; + const items = await prisma.notification.findMany({ + where, + orderBy: { createdAt: "desc" }, + take: 100, + }); + return res.json(items); + } catch (error) { + return res.status(500).json({ message: "Internal server error" }); + } +}); + +export default router; diff --git a/mentorme_backend/src/routes/sessionRoutes.ts b/mentorme_backend/src/routes/sessionRoutes.ts new file mode 100644 index 0000000..468c1ba --- /dev/null +++ b/mentorme_backend/src/routes/sessionRoutes.ts @@ -0,0 +1,344 @@ +import { Router } from "express"; +import { prisma } from "../lib/prisma"; +import { authGuard } from "../middleware/auth"; +import { BookingStatus, ClassLifecycleStatus, SessionStatus, UserRole, VerificationStatus } from "@prisma/client"; +import { z } from "zod"; +import { createNotification } from "../services/notifications"; + +const router = Router(); + +const VS = + VerificationStatus ?? { + UNVERIFIED: "UNVERIFIED", + PENDING: "PENDING", + VERIFIED: "VERIFIED", + REJECTED: "REJECTED", + }; + +const SS = + SessionStatus ?? { + SCHEDULED: "SCHEDULED", + IN_PROGRESS: "IN_PROGRESS", + COMPLETED: "COMPLETED", + CANCELLED: "CANCELLED", + MISSED: "MISSED", + }; +const CL = + ClassLifecycleStatus ?? { + PENDING: "PENDING", + ACTIVE: "ACTIVE", + COMPLETED: "COMPLETED", + CANCELLED: "CANCELLED", + }; + +const START_WINDOW_MINUTES_BEFORE = 15; +const START_WINDOW_MINUTES_AFTER = 60; +const DISPUTE_HOURS = 6; + +const startSchema = z.object({}); +const completeSchema = z.object({}); + +const getSessionWithClass = async (id: string) => { + return prisma.session.findUnique({ + where: { id }, + include: { + class: { + include: { + tutor: { select: { id: true, userId: true, verificationStatus: true } }, + bookings: { + where: { status: { in: [BookingStatus.CONFIRMED, BookingStatus.TRIAL] } }, + include: { student: { select: { id: true, userId: true } } }, + }, + }, + }, + }, + }); +}; + +const ensureActorAllowed = async (req: any, session: any) => { + if (!session.class) return false; + if (req.user!.role === UserRole.TUTOR) { + const tutor = await prisma.tutorProfile.findUnique({ where: { userId: req.user!.id } }); + if (!tutor || tutor.id !== session.class.tutorId) return false; + if (tutor.verificationStatus !== VS.VERIFIED) return false; + return true; + } + if (req.user!.role === UserRole.STUDENT) { + const student = await prisma.studentProfile.findUnique({ where: { userId: req.user!.id } }); + if (!student) return false; + const hasBooking = session.class.bookings.some((b: any) => (b.studentId ?? b.student?.id) === student.id); + return hasBooking; + } + return false; +}; + +const withinStartWindow = (session: any) => { + const start = new Date(session.scheduledStartAt).getTime(); + const now = Date.now(); + const from = start - START_WINDOW_MINUTES_BEFORE * 60 * 1000; + const to = start + START_WINDOW_MINUTES_AFTER * 60 * 1000; + return now >= from && now <= to; +}; + +const canComplete = (session: any) => { + const now = Date.now(); + const end = new Date(session.scheduledEndAt).getTime(); + if (now < end) return false; + if (session.status === SS.CANCELLED || session.status === SS.MISSED) return false; + return true; +}; + +const maybeFlagDispute = (session: any, updates: Record) => { + const startConfirms = [ + updates.tutorStartConfirmedAt ?? session.tutorStartConfirmedAt, + updates.studentStartConfirmedAt ?? session.studentStartConfirmedAt, + ].filter(Boolean); + const completeConfirms = [ + updates.tutorCompleteConfirmedAt ?? session.tutorCompleteConfirmedAt, + updates.studentCompleteConfirmedAt ?? session.studentCompleteConfirmedAt, + ].filter(Boolean); + + const firstStart = startConfirms.length === 1 ? startConfirms[0] : null; + const firstComplete = completeConfirms.length === 1 ? completeConfirms[0] : null; + + const now = new Date(); + if (!session.disputeFlaggedAt) { + if (firstStart) { + const diffHours = (now.getTime() - new Date(firstStart).getTime()) / (1000 * 60 * 60); + if (diffHours >= DISPUTE_HOURS) { + updates.disputeFlaggedAt = now; + } + } + if (!updates.disputeFlaggedAt && firstComplete) { + const diffHours = (now.getTime() - new Date(firstComplete).getTime()) / (1000 * 60 * 60); + if (diffHours >= DISPUTE_HOURS) { + updates.disputeFlaggedAt = now; + } + } + } +}; + +router.patch("/:id/start", authGuard([UserRole.TUTOR, UserRole.STUDENT]), async (req, res) => { + try { + startSchema.parse(req.body || {}); + const session = await getSessionWithClass(req.params.id); + if (!session) return res.status(404).json({ message: "Session not found" }); + if (!(await ensureActorAllowed(req, session))) { + return res.status(403).json({ message: "Forbidden" }); + } + + if (!withinStartWindow(session)) { + return res.status(400).json({ message: "Outside start window" }); + } + + const updates: any = {}; + const now = new Date(); + if (req.user!.role === UserRole.TUTOR) { + updates.tutorStartConfirmedAt = session.tutorStartConfirmedAt ?? now; + } else { + updates.studentStartConfirmedAt = session.studentStartConfirmedAt ?? now; + } + + const tutorConfirmed = updates.tutorStartConfirmedAt || session.tutorStartConfirmedAt; + const studentConfirmed = updates.studentStartConfirmedAt || session.studentStartConfirmedAt; + if (tutorConfirmed && studentConfirmed) { + updates.status = SS.IN_PROGRESS; + updates.startedAt = session.startedAt ?? now; + } + + maybeFlagDispute(session, updates); + + const updated = await prisma.session.update({ + where: { id: session.id }, + data: updates, + }); + + const tutorUserId = session.class?.tutor?.userId; + const studentUserIds = + session.class?.bookings?.map((b: any) => b.student?.userId).filter(Boolean) ?? []; + + // notify other party to confirm start + if ((updates.tutorStartConfirmedAt && !studentConfirmed) && studentUserIds.length > 0) { + for (const uid of studentUserIds) { + await createNotification(prisma, { + userId: uid, + type: "SESSION_WAIT_START", + title: "Xác nhận bắt đầu buổi học", + body: "Gia sư đã xác nhận bắt đầu, vui lòng xác nhận.", + metadata: { sessionId: session.id, classId: session.classId }, + dedupKey: `session:${session.id}:wait-start:${uid}`, + }); + } + } + if ((updates.studentStartConfirmedAt && !tutorConfirmed) && tutorUserId) { + await createNotification(prisma, { + userId: tutorUserId, + type: "SESSION_WAIT_START", + title: "Xác nhận bắt đầu buổi học", + body: "Học viên đã xác nhận bắt đầu, vui lòng xác nhận.", + metadata: { sessionId: session.id, classId: session.classId }, + dedupKey: `session:${session.id}:wait-start:${tutorUserId}`, + }); + } + + if (updates.status === SS.IN_PROGRESS) { + const recipients = [...studentUserIds, tutorUserId].filter(Boolean) as string[]; + for (const uid of recipients) { + await createNotification(prisma, { + userId: uid, + type: "SESSION_STARTED", + title: "Buổi học đã bắt đầu", + body: "Buổi học của bạn đã được bắt đầu.", + metadata: { sessionId: session.id, classId: session.classId }, + dedupKey: `session:${session.id}:started:${uid}`, + }); + } + } + + return res.json(updated); + } catch (error) { + if (error instanceof z.ZodError) { + return res.status(400).json({ message: "Invalid payload", issues: error.issues }); + } + return res.status(500).json({ message: "Internal server error" }); + } +}); + +router.patch("/:id/complete", authGuard([UserRole.TUTOR, UserRole.STUDENT]), async (req, res) => { + try { + completeSchema.parse(req.body || {}); + const session = await getSessionWithClass(req.params.id); + if (!session) return res.status(404).json({ message: "Session not found" }); + if (!(await ensureActorAllowed(req, session))) { + return res.status(403).json({ message: "Forbidden" }); + } + if (!canComplete(session)) { + return res.status(400).json({ message: "Cannot complete yet" }); + } + + const updates: any = {}; + const now = new Date(); + if (req.user!.role === UserRole.TUTOR) { + updates.tutorCompleteConfirmedAt = session.tutorCompleteConfirmedAt ?? now; + } else { + updates.studentCompleteConfirmedAt = session.studentCompleteConfirmedAt ?? now; + } + + const tutorConfirmed = updates.tutorCompleteConfirmedAt || session.tutorCompleteConfirmedAt; + const studentConfirmed = updates.studentCompleteConfirmedAt || session.studentCompleteConfirmedAt; + const shouldComplete = tutorConfirmed && studentConfirmed; + + maybeFlagDispute(session, updates); + + const result = await prisma.$transaction(async (tx) => { + let newStatus = session.status; + let completedAt = session.completedAt; + let classProgressUpdated = false; + + if (shouldComplete && session.status !== SS.COMPLETED) { + newStatus = SS.COMPLETED; + completedAt = completedAt ?? now; + } + + const updatedSession = await tx.session.update({ + where: { id: session.id }, + data: { + ...updates, + status: newStatus, + startedAt: session.startedAt ?? now, + completedAt, + }, + }); + + if (newStatus === SS.COMPLETED && session.status !== SS.COMPLETED) { + await tx.class.update({ + where: { id: session.classId }, + data: { + sessionsCompleted: { increment: 1 }, + }, + }); + classProgressUpdated = true; + + const cls = await tx.class.findUnique({ where: { id: session.classId } }); + const total = cls?.totalSessions ?? 0; + if (cls && total > 0 && (cls.sessionsCompleted + 1) >= total) { + await tx.class.update({ + where: { id: cls.id }, + data: { lifecycleStatus: CL.COMPLETED as any }, + }); + } + } + + return updatedSession; + }); + + const tutorUserId = session.class?.tutor?.userId; + const studentUserIds = + session.class?.bookings?.map((b: any) => b.student?.userId).filter(Boolean) ?? []; + + if (updates.tutorCompleteConfirmedAt && !studentConfirmed && studentUserIds.length > 0) { + for (const uid of studentUserIds) { + await createNotification(prisma, { + userId: uid, + type: "SESSION_WAIT_COMPLETE", + title: "Xác nhận hoàn thành buổi học", + body: "Gia sư đã xác nhận hoàn thành, vui lòng xác nhận.", + metadata: { sessionId: session.id, classId: session.classId }, + dedupKey: `session:${session.id}:wait-complete:${uid}`, + }); + } + } + if (updates.studentCompleteConfirmedAt && !tutorConfirmed && tutorUserId) { + await createNotification(prisma, { + userId: tutorUserId, + type: "SESSION_WAIT_COMPLETE", + title: "Xác nhận hoàn thành buổi học", + body: "Học viên đã xác nhận hoàn thành, vui lòng xác nhận.", + metadata: { sessionId: session.id, classId: session.classId }, + dedupKey: `session:${session.id}:wait-complete:${tutorUserId}`, + }); + } + + if (result.status === SS.COMPLETED) { + const recipients = [...studentUserIds, tutorUserId].filter(Boolean) as string[]; + for (const uid of recipients) { + await createNotification(prisma, { + userId: uid, + type: "SESSION_COMPLETED", + title: "Buổi học đã hoàn thành", + body: "Buổi học của bạn đã hoàn thành.", + metadata: { sessionId: session.id, classId: session.classId }, + dedupKey: `session:${session.id}:completed:${uid}`, + }); + } + } + + if (result.disputeFlaggedAt && !session.disputeFlaggedAt) { + const adminUsers = await prisma.user.findMany({ where: { role: "ADMIN" }, select: { id: true } }); + const recipients = [ + tutorUserId, + ...studentUserIds, + ...adminUsers.map((a) => a.id), + ].filter(Boolean) as string[]; + for (const uid of recipients) { + await createNotification(prisma, { + userId: uid, + type: "SESSION_DISPUTE", + title: "Phiên học cần xem xét", + body: "Buổi học bị đánh dấu cần xem xét.", + metadata: { sessionId: session.id, classId: session.classId }, + dedupKey: `session:${session.id}:dispute:${uid}`, + }); + } + } + + return res.json(result); + } catch (error) { + if (error instanceof z.ZodError) { + return res.status(400).json({ message: "Invalid payload", issues: error.issues }); + } + return res.status(500).json({ message: "Internal server error" }); + } +}); + +export default router; diff --git a/mentorme_backend/src/routes/tutorRoutes.ts b/mentorme_backend/src/routes/tutorRoutes.ts index d46b4d5..8e26a13 100644 --- a/mentorme_backend/src/routes/tutorRoutes.ts +++ b/mentorme_backend/src/routes/tutorRoutes.ts @@ -1,9 +1,30 @@ import { Router } from "express"; import { authGuard } from "../middleware/auth"; -import { ClassStatus, Prisma, UserRole, UserStatus } from "@prisma/client"; +import { + ClassStatus, + LocationType, + Prisma, + UserRole, + UserStatus, + VerificationStatus, +} from "@prisma/client"; import { prisma } from "../lib/prisma"; import { z } from "zod"; import { updateTutorProfileEmbedding } from "../domain/embeddings"; +import { + approveTutorVerification, + rejectTutorVerification, + submitTutorVerification, +} from "../services/tutorVerification"; +import { maskNationalId, sanitizeTutorForPublic } from "../utils/tutorSanitizer"; + +const VS = + VerificationStatus ?? { + UNVERIFIED: "UNVERIFIED", + PENDING: "PENDING", + VERIFIED: "VERIFIED", + REJECTED: "REJECTED", + }; const router = Router(); @@ -35,6 +56,31 @@ const updateSchema = z.object({ district: z.string().optional(), }); +const verificationSubmitSchema = z.object({ + nationalIdNumber: z.string().min(6).max(30), + nationalIdFrontImageUrl: z.string().url(), + nationalIdBackImageUrl: z.string().url(), + proofDocuments: z.any().optional(), + certificatesDetail: z.any().optional(), +}); + +const availabilitySchema = z.array( + z.object({ + dayOfWeek: z.number().int().min(0).max(6), + startMinute: z.number().int().min(0).max(1440), + endMinute: z.number().int().min(1).max(1440), + timezone: z.string().min(1).default("UTC"), + locationType: z.nativeEnum(LocationType), + }) +); + +const unavailabilitySchema = z.array( + z.object({ + startAt: z.string().datetime(), + endAt: z.string().datetime(), + }) +); + router.patch("/me", authGuard([UserRole.TUTOR]), async (req, res) => { try { const payload = updateSchema.parse(req.body); @@ -74,6 +120,164 @@ router.patch("/me", authGuard([UserRole.TUTOR]), async (req, res) => { } }); +router.get("/me/availability", authGuard([UserRole.TUTOR]), async (req, res) => { + try { + const tutor = await prisma.tutorProfile.findUnique({ + where: { userId: req.user!.id }, + }); + if (!tutor) return res.status(404).json({ message: "Tutor profile not found" }); + + const availabilities = await prisma.tutorAvailability.findMany({ + where: { tutorId: tutor.id }, + orderBy: [{ dayOfWeek: "asc" }, { startMinute: "asc" }], + }); + return res.json(availabilities); + } catch (error) { + return res.status(500).json({ message: "Internal server error" }); + } +}); + +router.put("/me/availability", authGuard([UserRole.TUTOR]), async (req, res) => { + try { + const entries = availabilitySchema.parse(req.body); + const tutor = await prisma.tutorProfile.findUnique({ where: { userId: req.user!.id } }); + if (!tutor) return res.status(404).json({ message: "Tutor profile not found" }); + + await prisma.$transaction(async (tx) => { + await tx.tutorAvailability.deleteMany({ where: { tutorId: tutor.id } }); + if (entries.length > 0) { + await tx.tutorAvailability.createMany({ + data: entries.map((e) => ({ + tutorId: tutor.id, + dayOfWeek: e.dayOfWeek, + startMinute: e.startMinute, + endMinute: e.endMinute, + timezone: e.timezone, + locationType: e.locationType, + })), + }); + } + }); + + const updated = await prisma.tutorAvailability.findMany({ + where: { tutorId: tutor.id }, + orderBy: [{ dayOfWeek: "asc" }, { startMinute: "asc" }], + }); + return res.json(updated); + } catch (error) { + if (error instanceof z.ZodError) { + return res.status(400).json({ message: "Invalid payload", issues: error.issues }); + } + return res.status(500).json({ message: "Internal server error" }); + } +}); + +router.get("/me/unavailability", authGuard([UserRole.TUTOR]), async (req, res) => { + try { + const tutor = await prisma.tutorProfile.findUnique({ + where: { userId: req.user!.id }, + }); + if (!tutor) return res.status(404).json({ message: "Tutor profile not found" }); + + const blocks = await prisma.tutorUnavailability.findMany({ + where: { tutorId: tutor.id }, + orderBy: { startAt: "asc" }, + }); + return res.json(blocks); + } catch (error) { + return res.status(500).json({ message: "Internal server error" }); + } +}); + +router.put("/me/unavailability", authGuard([UserRole.TUTOR]), async (req, res) => { + try { + const blocks = unavailabilitySchema.parse(req.body); + const tutor = await prisma.tutorProfile.findUnique({ where: { userId: req.user!.id } }); + if (!tutor) return res.status(404).json({ message: "Tutor profile not found" }); + + await prisma.$transaction(async (tx) => { + await tx.tutorUnavailability.deleteMany({ where: { tutorId: tutor.id } }); + if (blocks.length > 0) { + await tx.tutorUnavailability.createMany({ + data: blocks + .map((b) => ({ + tutorId: tutor.id, + startAt: new Date(b.startAt), + endAt: new Date(b.endAt), + })) + .filter((b) => b.endAt > b.startAt), + }); + } + }); + + const updated = await prisma.tutorUnavailability.findMany({ + where: { tutorId: tutor.id }, + orderBy: { startAt: "asc" }, + }); + return res.json(updated); + } catch (error) { + if (error instanceof z.ZodError) { + return res.status(400).json({ message: "Invalid payload", issues: error.issues }); + } + return res.status(500).json({ message: "Internal server error" }); + } +}); + +router.get("/me/verification", authGuard([UserRole.TUTOR]), async (req, res) => { + try { + const tutor = await prisma.tutorProfile.findUnique({ + where: { userId: req.user!.id }, + }); + if (!tutor) { + return res.status(404).json({ message: "Tutor profile not found" }); + } + const masked = { + verificationStatus: tutor.verificationStatus, + nationalIdNumber: maskNationalId(tutor.nationalIdNumber), + nationalIdFrontImageUrl: tutor.nationalIdFrontImageUrl, + nationalIdBackImageUrl: tutor.nationalIdBackImageUrl, + proofDocuments: tutor.proofDocuments, + certificatesDetail: tutor.certificatesDetail, + verificationSubmittedAt: tutor.verificationSubmittedAt, + verificationReviewedAt: tutor.verificationReviewedAt, + verificationNotes: tutor.verificationNotes, + }; + return res.json(masked); + } catch (error) { + return res.status(500).json({ message: "Internal server error" }); + } +}); + +router.put("/me/verification", authGuard([UserRole.TUTOR]), async (req, res) => { + try { + const payload = verificationSubmitSchema.parse(req.body); + const tutor = await prisma.tutorProfile.findUnique({ + where: { userId: req.user!.id }, + }); + if (!tutor) { + return res.status(404).json({ message: "Tutor profile not found" }); + } + await submitTutorVerification(prisma, tutor.id, payload); + const updated = await prisma.tutorProfile.findUnique({ where: { id: tutor.id } }); + return res.json({ + verificationStatus: updated?.verificationStatus, + verificationSubmittedAt: updated?.verificationSubmittedAt, + nationalIdNumber: maskNationalId(updated?.nationalIdNumber ?? null), + }); + } catch (error: any) { + if (error instanceof z.ZodError) { + return res.status(400).json({ message: "Invalid payload", issues: error.issues }); + } + if (error.code === "P2002") { + return res.status(409).json({ message: "National ID is already used by another tutor" }); + } + if (error.message && error.message.includes("not allowed")) { + return res.status(400).json({ message: error.message }); + } + return res.status(500).json({ message: "Internal server error" }); + } +}); + router.get("/me/classes", authGuard([UserRole.TUTOR]), async (req, res) => { try { const tutor = await prisma.tutorProfile.findUnique({ @@ -138,7 +342,7 @@ router.get("/", async (req, res) => { } const where: Prisma.TutorProfileWhereInput = { - verified: true, + verificationStatus: VS.VERIFIED as any, user: { status: UserStatus.ACTIVE }, }; if (query.trustScoreMin !== undefined) { @@ -192,8 +396,11 @@ router.get("/", async (req, res) => { prisma.tutorProfile.count({ where }), ]); + const verifiedItems = items.filter((tutor) => tutor.verificationStatus === VS.VERIFIED); + const safeItems = verifiedItems.map((tutor) => sanitizeTutorForPublic(tutor, { maskNationalId })); + return res.json({ - data: items, + data: safeItems, total, page: query.page, pageSize: query.pageSize, @@ -272,7 +479,7 @@ router.get("/:id", async (req, res) => { const tutor = await prisma.tutorProfile.findFirst({ where: { id: req.params.id, - verified: true, + verificationStatus: VS.VERIFIED as any, user: { status: UserStatus.ACTIVE }, }, include: { @@ -285,7 +492,7 @@ router.get("/:id", async (req, res) => { if (!tutor) { return res.status(404).json({ message: "Tutor not found" }); } - return res.json(tutor); + return res.json(sanitizeTutorForPublic(tutor, { maskNationalId })); } catch (error) { return res.status(500).json({ message: "Internal server error" }); } diff --git a/mentorme_backend/src/services/notifications.ts b/mentorme_backend/src/services/notifications.ts new file mode 100644 index 0000000..802d6c0 --- /dev/null +++ b/mentorme_backend/src/services/notifications.ts @@ -0,0 +1,59 @@ +import { PrismaClient, ReminderType } from "@prisma/client"; + +export type NotificationInput = { + userId: string; + type: string; + title: string; + body: string; + metadata?: Record; + dedupKey: string; +}; + +export async function createNotification(prisma: PrismaClient, input: NotificationInput) { + try { + return await prisma.notification.create({ + data: { + userId: input.userId, + type: input.type, + title: input.title, + body: input.body, + metadata: input.metadata ?? null, + dedupKey: input.dedupKey, + }, + }); + } catch (err: any) { + if (err.code === "P2002") { + // dedup violation, ignore + return null; + } + throw err; + } +} + +export async function createReminderIfNeeded( + prisma: PrismaClient, + opts: { userId: string; sessionId: string; reminderType: ReminderType; title: string; body: string } +) { + try { + await prisma.reminderLog.create({ + data: { + userId: opts.userId, + sessionId: opts.sessionId, + reminderType: opts.reminderType, + }, + }); + } catch (err: any) { + if (err.code === "P2002") { + return null; + } + throw err; + } + return createNotification(prisma, { + userId: opts.userId, + type: "SESSION_REMINDER", + title: opts.title, + body: opts.body, + metadata: { sessionId: opts.sessionId, reminderType: opts.reminderType }, + dedupKey: `session:${opts.sessionId}:reminder:${opts.reminderType}:${opts.userId}`, + }); +} diff --git a/mentorme_backend/src/services/scheduling.ts b/mentorme_backend/src/services/scheduling.ts new file mode 100644 index 0000000..2263a13 --- /dev/null +++ b/mentorme_backend/src/services/scheduling.ts @@ -0,0 +1,132 @@ +import { + BookingStatus, + ClassStatus, + PrismaClient, + SessionStatus, +} from "@prisma/client"; + +const SS = + SessionStatus ?? { + SCHEDULED: "SCHEDULED", + CANCELLED: "CANCELLED", + MISSED: "MISSED", + }; + +export type SessionInput = { start: Date; end: Date }; + +export function overlaps(a: SessionInput, b: SessionInput): boolean { + return a.start < b.end && a.end > b.start; +} + +export function normalizeSessions(sessions: SessionInput[]): SessionInput[] { + const unique = new Map(); + sessions.forEach((s) => { + const key = `${s.start.toISOString()}|${s.end.toISOString()}`; + if (!unique.has(key)) unique.set(key, s); + }); + return Array.from(unique.values()); +} + +export async function checkScheduleConflicts( + prisma: PrismaClient, + opts: { + classId: string; + tutorId: string; + studentIds: string[]; + sessions: SessionInput[]; + } +) { + const { classId, tutorId, studentIds, sessions } = opts; + if (sessions.length === 0) return null; + + const overlapConditions = sessions.map((s) => ({ + AND: [ + { scheduledStartAt: { lt: s.end } }, + { scheduledEndAt: { gt: s.start } }, + ], + })); + + // Tutor conflicts (other classes) + const tutorConflicts = await prisma.session.findMany({ + where: { + status: { not: SS.CANCELLED as any }, + class: { + tutorId, + id: { not: classId }, + isDeleted: false, + status: { not: ClassStatus.ARCHIVED }, + }, + OR: overlapConditions, + }, + include: { class: true }, + take: 1, + }); + if (tutorConflicts.length > 0) { + const c = tutorConflicts[0]; + return { + type: "tutor_conflict" as const, + conflict: { + start: c.scheduledStartAt, + end: c.scheduledEndAt, + classId: c.classId, + }, + }; + } + + // Student conflicts + if (studentIds.length > 0) { + const studentConflicts = await prisma.session.findMany({ + where: { + status: { not: SS.CANCELLED as any }, + class: { + id: { not: classId }, + isDeleted: false, + status: { not: ClassStatus.ARCHIVED }, + bookings: { + some: { + studentId: { in: studentIds }, + status: { in: [BookingStatus.CONFIRMED, BookingStatus.TRIAL] }, + }, + }, + }, + OR: overlapConditions, + }, + include: { class: true }, + take: 1, + }); + if (studentConflicts.length > 0) { + const c = studentConflicts[0]; + return { + type: "student_conflict" as const, + conflict: { + start: c.scheduledStartAt, + end: c.scheduledEndAt, + classId: c.classId, + }, + }; + } + } + + // Tutor unavailability + const unavailable = await prisma.tutorUnavailability.findMany({ + where: { + tutorId, + OR: sessions.map((s) => ({ + AND: [{ startAt: { lt: s.end } }, { endAt: { gt: s.start } }], + })), + }, + take: 1, + }); + if (unavailable.length > 0) { + const u = unavailable[0]; + return { + type: "unavailable" as const, + conflict: { + start: u.startAt, + end: u.endAt, + }, + }; + } + + return null; +} diff --git a/mentorme_backend/src/services/tutorVerification.ts b/mentorme_backend/src/services/tutorVerification.ts new file mode 100644 index 0000000..b8939c5 --- /dev/null +++ b/mentorme_backend/src/services/tutorVerification.ts @@ -0,0 +1,92 @@ +import { PrismaClient, VerificationStatus } from "@prisma/client"; + +const VS = + VerificationStatus ?? { + UNVERIFIED: "UNVERIFIED", + PENDING: "PENDING", + VERIFIED: "VERIFIED", + REJECTED: "REJECTED", + }; + +export type VerificationSubmitPayload = { + nationalIdNumber: string; + nationalIdFrontImageUrl: string; + nationalIdBackImageUrl: string; + proofDocuments?: unknown; + certificatesDetail?: unknown; +}; + +export async function submitTutorVerification( + prisma: PrismaClient, + tutorId: string, + payload: VerificationSubmitPayload +) { + const current = await prisma.tutorProfile.findUnique({ where: { id: tutorId } }); + if (!current) { + throw new Error("Tutor not found"); + } + const status = (current.verificationStatus as any) ?? VS.UNVERIFIED; + const allowed = + status === VS.UNVERIFIED || status === VS.REJECTED || status === VS.VERIFIED; + if (!allowed) { + throw new Error("Verification submission not allowed in current status"); + } + // If VERIFIED submits again, revert to PENDING + return prisma.tutorProfile.update({ + where: { id: tutorId }, + data: { + nationalIdNumber: payload.nationalIdNumber, + nationalIdFrontImageUrl: payload.nationalIdFrontImageUrl, + nationalIdBackImageUrl: payload.nationalIdBackImageUrl, + proofDocuments: payload.proofDocuments ?? null, + certificatesDetail: payload.certificatesDetail ?? null, + verificationStatus: VS.PENDING as any, + verificationSubmittedAt: new Date(), + verificationReviewedAt: null, + verificationNotes: null, + verified: false, + }, + }); +} + +export async function approveTutorVerification( + prisma: PrismaClient, + tutorId: string, + note?: string +) { + const current = await prisma.tutorProfile.findUnique({ where: { id: tutorId } }); + if (!current) throw new Error("Tutor not found"); + if (current.verificationStatus !== VS.PENDING) { + throw new Error("Only pending verifications can be approved"); + } + return prisma.tutorProfile.update({ + where: { id: tutorId }, + data: { + verificationStatus: VS.VERIFIED as any, + verificationReviewedAt: new Date(), + verificationNotes: note ?? null, + verified: true, + }, + }); +} + +export async function rejectTutorVerification( + prisma: PrismaClient, + tutorId: string, + note?: string +) { + const current = await prisma.tutorProfile.findUnique({ where: { id: tutorId } }); + if (!current) throw new Error("Tutor not found"); + if (current.verificationStatus !== VS.PENDING) { + throw new Error("Only pending verifications can be rejected"); + } + return prisma.tutorProfile.update({ + where: { id: tutorId }, + data: { + verificationStatus: VS.REJECTED as any, + verificationReviewedAt: new Date(), + verificationNotes: note ?? null, + verified: false, + }, + }); +} diff --git a/mentorme_backend/src/utils/tutorSanitizer.ts b/mentorme_backend/src/utils/tutorSanitizer.ts new file mode 100644 index 0000000..0c60000 --- /dev/null +++ b/mentorme_backend/src/utils/tutorSanitizer.ts @@ -0,0 +1,43 @@ +import { VerificationStatus } from "@prisma/client"; + +export function maskNationalId(value?: string | null): string | null { + if (!value) return null; + const len = value.length; + if (len <= 4) return "*".repeat(len); + const visible = value.slice(-4); + const masked = "*".repeat(len - 4); + return `${masked}${visible}`; +} + +type TutorLike = { + nationalIdNumber?: string | null; + nationalIdFrontImageUrl?: string | null; + nationalIdBackImageUrl?: string | null; + proofDocuments?: unknown; + certificatesDetail?: unknown; + verificationNotes?: string | null; + verificationSubmittedAt?: Date | null; + verificationReviewedAt?: Date | null; + verificationStatus?: VerificationStatus; + verified?: boolean; + user?: any; + [key: string]: any; +}; + +export function sanitizeTutorForPublic( + tutor: T, + opts?: { maskNationalId?: (value?: string | null) => string | null } +): T { + const clone: TutorLike = { ...tutor }; + const masker = opts?.maskNationalId ?? maskNationalId; + if ("nationalIdNumber" in clone) { + clone.nationalIdNumber = masker(clone.nationalIdNumber); + } + // Remove sensitive artifacts for public exposure + delete clone.nationalIdFrontImageUrl; + delete clone.nationalIdBackImageUrl; + delete clone.proofDocuments; + delete clone.certificatesDetail; + delete clone.verificationNotes; + return clone as T; +} diff --git a/mentorme_backend/tests/api/bookingAdmin.test.ts b/mentorme_backend/tests/api/bookingAdmin.test.ts index 72e4ee4..4e8c071 100644 --- a/mentorme_backend/tests/api/bookingAdmin.test.ts +++ b/mentorme_backend/tests/api/bookingAdmin.test.ts @@ -15,13 +15,20 @@ describe("Booking and admin routes", () => { }); test("tutor confirms booking", async () => { - mockPrisma.tutorProfile.findUnique.mockResolvedValue({ id: "tutor-1", userId: "user-tutor" }); + mockPrisma.tutorProfile.findUnique + .mockResolvedValueOnce({ id: "tutor-1", userId: "user-tutor" } as any) // getTutorIdByUser + .mockResolvedValueOnce({ id: "tutor-1", verificationStatus: "VERIFIED" } as any); // ensureTutorVerified mockPrisma.booking.findUnique.mockResolvedValue({ id: BOOKING_ID, tutorId: "tutor-1", status: BookingStatus.PENDING, isTrial: false, } as any); + mockPrisma.tutorProfile.findUnique.mockResolvedValueOnce({ + id: "tutor-1", + userId: "user-tutor", + verificationStatus: "VERIFIED", + } as any); mockPrisma.booking.update.mockResolvedValue({ id: BOOKING_ID, status: BookingStatus.CONFIRMED } as any); const res = await request(app) @@ -32,12 +39,32 @@ describe("Booking and admin routes", () => { expect(mockPrisma.booking.update).toHaveBeenCalled(); }); + test("unverified tutor cannot confirm booking", async () => { + mockPrisma.tutorProfile.findUnique + .mockResolvedValueOnce({ id: "tutor-1", userId: "user-tutor" } as any) // getTutorIdByUser + .mockResolvedValueOnce({ id: "tutor-1", verificationStatus: "PENDING" } as any); // ensureTutorVerified + mockPrisma.booking.findUnique.mockResolvedValue({ + id: BOOKING_ID, + tutorId: "tutor-1", + status: BookingStatus.PENDING, + isTrial: false, + } as any); + + const res = await request(app) + .patch(`/api/bookings/${BOOKING_ID}/confirm`) + .set("Authorization", `Bearer ${tutorToken}`); + + expect(res.status).toBe(403); + expect(mockPrisma.booking.update).not.toHaveBeenCalled(); + }); + test("student cancels booking", async () => { mockPrisma.booking.findUnique.mockResolvedValue({ id: BOOKING_ID, tutorId: "tutor-1", studentId: "student-1", - status: BookingStatus.CONFIRMED, + status: BookingStatus.PENDING, + isTrial: false, } as any); mockPrisma.studentProfile.findUnique.mockResolvedValue({ id: "student-1", userId: "user-student" }); mockPrisma.booking.update.mockResolvedValue({ id: BOOKING_ID, status: BookingStatus.CANCELLED } as any); @@ -51,9 +78,46 @@ describe("Booking and admin routes", () => { expect(mockPrisma.booking.update).toHaveBeenCalled(); }); + test("unverified tutor cannot complete booking", async () => { + mockPrisma.booking.findUnique.mockResolvedValue({ + id: BOOKING_ID, + tutorId: "tutor-1", + studentId: "student-1", + status: BookingStatus.PENDING, + isTrial: false, + } as any); + mockPrisma.tutorProfile.findUnique + .mockResolvedValueOnce({ id: "tutor-1", userId: "user-tutor" } as any) // getTutorIdByUser + .mockResolvedValueOnce({ id: "tutor-1", verificationStatus: "PENDING" } as any); // ensureTutorVerified + + const res = await request(app) + .patch(`/api/bookings/${BOOKING_ID}/complete`) + .set("Authorization", `Bearer ${tutorToken}`); + + expect(res.status).toBe(403); + expect(mockPrisma.booking.update).not.toHaveBeenCalled(); + }); + test("admin verifies tutor and lists bookings", async () => { - mockPrisma.tutorProfile.findUnique.mockResolvedValue({ id: "tutor-1", userId: "user-tutor" }); - mockPrisma.$transaction.mockResolvedValue([]); + mockPrisma.tutorProfile.findUnique + .mockResolvedValueOnce({ + id: "tutor-1", + userId: "user-tutor", + verificationStatus: "PENDING", + } as any) // verify route fetch + .mockResolvedValueOnce({ + id: "tutor-1", + userId: "user-tutor", + verificationStatus: "VERIFIED", + } as any); // ensureTutorVerified follow-ups if any + mockPrisma.user.update.mockResolvedValue({ status: "ACTIVE" } as any); + mockPrisma.tutorProfile.update.mockResolvedValue({ + id: "tutor-1", + verificationStatus: "VERIFIED", + } as any); + mockPrisma.$transaction.mockImplementation(async (actions: any[]) => { + return Promise.all(actions.map((fn) => fn)); + }); const verifyRes = await request(app) .patch("/api/admin/tutors/tutor-1/verify") diff --git a/mentorme_backend/tests/api/classes.test.ts b/mentorme_backend/tests/api/classes.test.ts index d6172d8..e691cfe 100644 --- a/mentorme_backend/tests/api/classes.test.ts +++ b/mentorme_backend/tests/api/classes.test.ts @@ -83,4 +83,33 @@ describe("Class routes", () => { expect(res.status).toBe(200); expect(res.body).toHaveProperty("title", "Toán"); }); + + test("public class detail does not leak tutor verification artifacts", async () => { + mockPrisma.class.findUnique.mockResolvedValue({ + id: CLASS_ID, + tutorId: "tutor-1", + isDeleted: false, + status: ClassStatus.PUBLISHED, + subject: {}, + tutor: { + id: "tutor-1", + verificationStatus: "VERIFIED", + nationalIdNumber: "123456789", + nationalIdFrontImageUrl: "https://front", + nationalIdBackImageUrl: "https://back", + proofDocuments: { studentCardUrl: "https://secret" }, + certificatesDetail: [{ imageUrl: "https://cert" }], + }, + title: "Toán", + pricePerHour: 200000, + locationType: LocationType.ONLINE, + } as any); + + const res = await request(app).get(`/api/classes/${CLASS_ID}`); + expect(res.status).toBe(200); + expect(res.body?.tutor?.nationalIdFrontImageUrl).toBeUndefined(); + expect(res.body?.tutor?.nationalIdBackImageUrl).toBeUndefined(); + expect(res.body?.tutor?.proofDocuments).toBeUndefined(); + expect(res.body?.tutor?.certificatesDetail).toBeUndefined(); + }); }); diff --git a/mentorme_backend/tests/api/matching.test.ts b/mentorme_backend/tests/api/matching.test.ts index 2cfbd8d..c7ea9ab 100644 --- a/mentorme_backend/tests/api/matching.test.ts +++ b/mentorme_backend/tests/api/matching.test.ts @@ -2,6 +2,9 @@ import request from "supertest"; import app from "../../src/app"; import { mockPrisma } from "../utils/mockPrisma"; import { ClassStatus, UserStatus } from "@prisma/client"; +const VerificationStatus = { + VERIFIED: "VERIFIED", +}; describe("Matching routes", () => { beforeEach(() => { @@ -13,6 +16,7 @@ describe("Matching routes", () => { { id: "t1", verified: true, + verificationStatus: VerificationStatus.VERIFIED as any, user: { status: UserStatus.ACTIVE }, trustScore: 60, averageRating: 4.5, @@ -31,6 +35,7 @@ describe("Matching routes", () => { { id: "t2", verified: true, + verificationStatus: VerificationStatus.VERIFIED as any, user: { status: UserStatus.ACTIVE }, trustScore: 80, averageRating: 4.0, @@ -57,4 +62,41 @@ describe("Matching routes", () => { // Should be sorted by matchScore descending; tutor with closer price and higher rating should lead expect(res.body[0].tutor.id).toBe("t1"); }); + + test("matching response strips PII", async () => { + mockPrisma.tutorProfile.findMany.mockResolvedValue([ + { + id: "t1", + verified: true, + verificationStatus: VerificationStatus.VERIFIED as any, + user: { status: UserStatus.ACTIVE }, + trustScore: 60, + averageRating: 4.5, + totalCompletedBookings: 10, + nationalIdNumber: "123456789", + nationalIdFrontImageUrl: "https://front", + proofDocuments: { studentCardUrl: "https://secret" }, + classes: [ + { + id: "11111111-1111-4111-8111-111111111111", + subjectId: "22222222-2222-4222-8222-222222222222", + pricePerHour: 200000, + targetGrade: "Lớp 9", + status: ClassStatus.PUBLISHED, + isDeleted: false, + }, + ], + }, + ] as any); + + const res = await request(app).get( + "/api/matching/tutors?subjectId=22222222-2222-4222-8222-222222222222&priceMin=150000&priceMax=250000&gradeLevel=Lop%209" + ); + + expect(res.status).toBe(200); + const tutor = res.body[0].tutor; + expect(tutor.nationalIdFrontImageUrl).toBeUndefined(); + expect(tutor.proofDocuments).toBeUndefined(); + expect(String(tutor.nationalIdNumber || "")).toMatch(/\*+6789/); + }); }); diff --git a/mentorme_backend/tests/api/notifications.test.ts b/mentorme_backend/tests/api/notifications.test.ts new file mode 100644 index 0000000..a775f0a --- /dev/null +++ b/mentorme_backend/tests/api/notifications.test.ts @@ -0,0 +1,81 @@ +import request from "supertest"; +import app from "../../src/app"; +import { mockPrisma } from "../utils/mockPrisma"; +import { signToken } from "../../src/utils/jwt"; +import { UserRole } from "@prisma/client"; + +const adminToken = signToken({ userId: "admin", role: UserRole.ADMIN }); +const tutorToken = signToken({ userId: "user-tutor", role: UserRole.TUTOR }); + +describe("Notifications", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("verification approve creates notification", async () => { + mockPrisma.tutorProfile.findUnique.mockResolvedValue({ + id: "t1", + userId: "user-tutor", + verificationStatus: "PENDING", + } as any); + mockPrisma.user.update.mockResolvedValue({ status: "ACTIVE" } as any); + mockPrisma.tutorProfile.update.mockResolvedValue({ id: "t1" } as any); + mockPrisma.notification.create.mockResolvedValue({ id: "n1" } as any); + + const res = await request(app) + .patch("/api/admin/tutor-verifications/t1/approve") + .set("Authorization", `Bearer ${adminToken}`) + .send({}); + + expect(res.status).toBe(200); + expect(mockPrisma.notification.create).toHaveBeenCalled(); + }); + + test("session start one-sided notifies other", async () => { + jest.useFakeTimers().setSystemTime(new Date("2025-01-01T10:05:00.000Z")); + mockPrisma.session.findUnique.mockResolvedValue({ + id: "s1", + classId: "c1", + scheduledStartAt: new Date("2025-01-01T10:00:00.000Z"), + scheduledEndAt: new Date("2025-01-01T11:00:00.000Z"), + status: "SCHEDULED", + class: { + tutorId: "t1", + tutor: { id: "t1", userId: "user-tutor", verificationStatus: "VERIFIED" }, + bookings: [{ student: { id: "stu1", userId: "user-student" } }], + }, + } as any); + mockPrisma.tutorProfile.findUnique.mockResolvedValue({ id: "t1", userId: "user-tutor", verificationStatus: "VERIFIED" } as any); + mockPrisma.session.update.mockResolvedValue({ + id: "s1", + status: "SCHEDULED", + tutorStartConfirmedAt: new Date(), + } as any); + mockPrisma.notification.create.mockResolvedValue({ id: "n1" } as any); + + const res = await request(app) + .patch("/api/sessions/s1/start") + .set("Authorization", `Bearer ${tutorToken}`); + + expect(res.status).toBe(200); + expect(mockPrisma.notification.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ userId: "user-student" }), + }) + ); + jest.useRealTimers(); + }); + + test("notification list and mark read", async () => { + const token = signToken({ userId: "u1", role: UserRole.TUTOR }); + mockPrisma.notification.findMany.mockResolvedValue([{ id: "n1", userId: "u1" } as any]); + mockPrisma.notification.count.mockResolvedValue(1 as any); + mockPrisma.notification.update.mockResolvedValue({ id: "n1", readAt: new Date() } as any); + mockPrisma.notification.findUnique.mockResolvedValue({ id: "n1", userId: "u1" } as any); + + const list = await request(app).get("/api/notifications/me").set("Authorization", `Bearer ${token}`); + expect(list.status).toBe(200); + const read = await request(app).patch("/api/notifications/n1/read").set("Authorization", `Bearer ${token}`); + expect(read.status).toBe(200); + }); +}); diff --git a/mentorme_backend/tests/api/reminders.test.ts b/mentorme_backend/tests/api/reminders.test.ts new file mode 100644 index 0000000..cd1625b --- /dev/null +++ b/mentorme_backend/tests/api/reminders.test.ts @@ -0,0 +1,38 @@ +import { runSessionReminderJob } from "../../src/jobs/sessionReminders"; +import { mockPrisma } from "../utils/mockPrisma"; + +describe("Session reminders", () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers().setSystemTime(new Date("2025-01-01T09:00:00.000Z")); + }); + afterEach(() => jest.useRealTimers()); + + test("sends 24h and 1h reminders once", async () => { + mockPrisma.session.findMany.mockResolvedValue([ + { + id: "s1", + classId: "c1", + scheduledStartAt: new Date("2025-01-02T09:00:00.000Z"), + status: "SCHEDULED", + class: { + tutor: { userId: "tutor-user" }, + bookings: [{ studentId: "stu1" }], + }, + }, + ] as any); + mockPrisma.studentProfile.findMany.mockResolvedValue([{ id: "stu1", userId: "student-user" }]); + mockPrisma.reminderLog.create.mockResolvedValue({ id: "r1" } as any); + mockPrisma.notification.create.mockResolvedValue({ id: "n1" } as any); + + await runSessionReminderJob(mockPrisma as any); + expect(mockPrisma.notification.create).toHaveBeenCalledTimes(2); + + // second run should not duplicate due to P2002 + const dupErr: any = new Error("dup"); + dupErr.code = "P2002"; + mockPrisma.reminderLog.create.mockRejectedValue(dupErr); + await runSessionReminderJob(mockPrisma as any); + expect(mockPrisma.notification.create).toHaveBeenCalledTimes(2); + }); +}); diff --git a/mentorme_backend/tests/api/scheduling.test.ts b/mentorme_backend/tests/api/scheduling.test.ts new file mode 100644 index 0000000..0b2ca5d --- /dev/null +++ b/mentorme_backend/tests/api/scheduling.test.ts @@ -0,0 +1,199 @@ +import request from "supertest"; +import app from "../../src/app"; +import { mockPrisma } from "../utils/mockPrisma"; +import { signToken } from "../../src/utils/jwt"; +import { BookingStatus, ClassStatus, UserRole } from "@prisma/client"; + +const tutorToken = signToken({ userId: "user-tutor", role: UserRole.TUTOR }); +const studentToken = signToken({ userId: "user-student", role: UserRole.STUDENT }); + +describe("Scheduling & calendar", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const baseClass = { + id: "class-1", + tutorId: "tutor-1", + isDeleted: false, + status: ClassStatus.PUBLISHED, + }; + + const baseSchedulePayload = { + timezone: "UTC", + recurrence: { + startDate: "2025-01-06T00:00:00.000Z", + weeks: 1, + slots: [{ dayOfWeek: 1, startMinute: 600, endMinute: 660 }], + }, + }; + + test("schedule creation succeeds without conflicts", async () => { + mockPrisma.class.findUnique.mockResolvedValue({ ...baseClass }); + mockPrisma.class.update.mockResolvedValue({ ...baseClass, totalSessions: 1, sessionsCompleted: 0 } as any); + mockPrisma.tutorProfile.findUnique.mockResolvedValue({ + id: "tutor-1", + userId: "user-tutor", + verificationStatus: "VERIFIED", + }); + mockPrisma.booking.findMany.mockResolvedValue([]); + mockPrisma.session.findMany + .mockResolvedValueOnce([]) // tutor conflict check + .mockResolvedValueOnce([]) // student conflict check + .mockResolvedValueOnce([ + { + id: "sess-1", + classId: "class-1", + scheduledStartAt: new Date("2025-01-06T10:00:00.000Z"), + scheduledEndAt: new Date("2025-01-06T11:00:00.000Z"), + status: "SCHEDULED", + }, + ] as any); + mockPrisma.tutorUnavailability.findMany.mockResolvedValue([]); + mockPrisma.classSchedule.upsert.mockResolvedValue({ + id: "sched-1", + classId: "class-1", + totalSessions: 1, + timezone: "UTC", + } as any); + mockPrisma.session.deleteMany.mockResolvedValue({ count: 0 } as any); + mockPrisma.session.createMany.mockResolvedValue({ count: 1 } as any); + mockPrisma.$transaction.mockImplementation(async (cb: any) => cb(mockPrisma as any)); + + const res = await request(app) + .post("/api/classes/class-1/schedule") + .set("Authorization", `Bearer ${tutorToken}`) + .send(baseSchedulePayload); + + expect(res.status).toBe(200); + expect(mockPrisma.session.createMany).toHaveBeenCalled(); + expect(Array.isArray(res.body.sessions)).toBe(true); + }); + + test("tutor conflict returns 409", async () => { + mockPrisma.class.findUnique.mockResolvedValue({ ...baseClass }); + mockPrisma.class.update.mockResolvedValue({ ...baseClass } as any); + mockPrisma.tutorProfile.findUnique.mockResolvedValue({ + id: "tutor-1", + userId: "user-tutor", + verificationStatus: "VERIFIED", + }); + mockPrisma.booking.findMany.mockResolvedValue([]); + mockPrisma.session.findMany.mockResolvedValueOnce([ + { + id: "other-session", + classId: "class-2", + scheduledStartAt: new Date("2025-01-06T10:30:00.000Z"), + scheduledEndAt: new Date("2025-01-06T11:30:00.000Z"), + status: "SCHEDULED", + class: { id: "class-2" }, + }, + ] as any); + mockPrisma.tutorUnavailability.findMany.mockResolvedValue([]); + + const res = await request(app) + .post("/api/classes/class-1/schedule") + .set("Authorization", `Bearer ${tutorToken}`) + .send(baseSchedulePayload); + + expect(res.status).toBe(409); + expect(res.body.type).toBe("tutor_conflict"); + }); + + test("student conflict returns 409", async () => { + mockPrisma.class.findUnique.mockResolvedValue({ ...baseClass }); + mockPrisma.class.update.mockResolvedValue({ ...baseClass } as any); + mockPrisma.tutorProfile.findUnique.mockResolvedValue({ + id: "tutor-1", + userId: "user-tutor", + verificationStatus: "VERIFIED", + }); + mockPrisma.booking.findMany.mockResolvedValue([{ studentId: "student-1" }]); + mockPrisma.session.findMany + .mockResolvedValueOnce([]) // tutor conflicts + .mockResolvedValueOnce([ + { + id: "stud-session", + classId: "class-9", + scheduledStartAt: new Date("2025-01-06T10:30:00.000Z"), + scheduledEndAt: new Date("2025-01-06T11:30:00.000Z"), + status: "SCHEDULED", + class: { id: "class-9" }, + }, + ] as any); + mockPrisma.tutorUnavailability.findMany.mockResolvedValue([]); + + const res = await request(app) + .post("/api/classes/class-1/schedule") + .set("Authorization", `Bearer ${tutorToken}`) + .send(baseSchedulePayload); + + expect(res.status).toBe(409); + expect(res.body.type).toBe("student_conflict"); + }); + + test("unavailability conflict returns 409", async () => { + mockPrisma.class.findUnique.mockResolvedValue({ ...baseClass }); + mockPrisma.tutorProfile.findUnique.mockResolvedValue({ + id: "tutor-1", + userId: "user-tutor", + verificationStatus: "VERIFIED", + }); + mockPrisma.booking.findMany.mockResolvedValue([]); + mockPrisma.session.findMany.mockResolvedValueOnce([]); // tutor conflicts + mockPrisma.tutorUnavailability.findMany.mockResolvedValue([ + { startAt: new Date("2025-01-06T09:00:00.000Z"), endAt: new Date("2025-01-06T12:00:00.000Z") }, + ]); + + const res = await request(app) + .post("/api/classes/class-1/schedule") + .set("Authorization", `Bearer ${tutorToken}`) + .send(baseSchedulePayload); + + expect(res.status).toBe(409); + expect(res.body.type).toBe("unavailable"); + }); + + test("tutor calendar returns upcoming sessions", async () => { + mockPrisma.tutorProfile.findUnique.mockResolvedValue({ id: "tutor-1", userId: "user-tutor" }); + mockPrisma.session.findMany.mockResolvedValue([ + { + id: "sess-1", + classId: "class-1", + scheduledStartAt: new Date("2025-01-06T10:00:00.000Z"), + scheduledEndAt: new Date("2025-01-06T11:00:00.000Z"), + status: "SCHEDULED", + class: { id: "class-1", title: "Math", locationType: "ONLINE" }, + }, + ] as any); + + const res = await request(app) + .get("/api/calendar/tutor") + .set("Authorization", `Bearer ${tutorToken}`); + + expect(res.status).toBe(200); + expect(res.body[0].classTitle).toBe("Math"); + }); + + test("student conflict prevented across classes", async () => { + mockPrisma.studentProfile.findUnique.mockResolvedValue({ id: "student-1", userId: "user-student" }); + mockPrisma.booking.findMany.mockResolvedValue([{ classId: "class-1" }, { classId: "class-2" }]); + mockPrisma.session.findMany.mockResolvedValue([ + { + id: "sess-1", + classId: "class-1", + scheduledStartAt: new Date("2025-01-06T10:00:00.000Z"), + scheduledEndAt: new Date("2025-01-06T11:00:00.000Z"), + status: "SCHEDULED", + class: { id: "class-1", title: "Math", locationType: "ONLINE" }, + }, + ] as any); + + const res = await request(app) + .get("/api/calendar/student") + .set("Authorization", `Bearer ${studentToken}`); + + expect(res.status).toBe(200); + expect(res.body.length).toBe(1); + }); +}); diff --git a/mentorme_backend/tests/api/sessionLifecycle.test.ts b/mentorme_backend/tests/api/sessionLifecycle.test.ts new file mode 100644 index 0000000..c01bb02 --- /dev/null +++ b/mentorme_backend/tests/api/sessionLifecycle.test.ts @@ -0,0 +1,173 @@ +import request from "supertest"; +import app from "../../src/app"; +import { mockPrisma } from "../utils/mockPrisma"; +import { signToken } from "../../src/utils/jwt"; +import { UserRole } from "@prisma/client"; + +const tutorToken = signToken({ userId: "user-tutor", role: UserRole.TUTOR }); +const studentToken = signToken({ userId: "user-student", role: UserRole.STUDENT }); +const SESSION_ID = "sess-1"; + +const baseSession = { + id: SESSION_ID, + classId: "class-1", + scheduledStartAt: new Date("2025-01-01T10:00:00.000Z"), + scheduledEndAt: new Date("2025-01-01T11:00:00.000Z"), + status: "SCHEDULED", + tutorStartConfirmedAt: null, + studentStartConfirmedAt: null, + tutorCompleteConfirmedAt: null, + studentCompleteConfirmedAt: null, + startedAt: null, + completedAt: null, + disputeFlaggedAt: null, +}; + +const classWithRelations = { + id: "class-1", + tutorId: "tutor-1", + totalSessions: 2, + sessionsCompleted: 0, + lifecycleStatus: "ACTIVE", + tutor: { id: "tutor-1", verificationStatus: "VERIFIED" }, + bookings: [{ studentId: "student-1", status: "CONFIRMED" }], +}; + +describe("Session lifecycle", () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers().setSystemTime(new Date("2025-01-01T10:05:00.000Z")); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + const mockSessionFetch = (sessionOverrides: any = {}) => { + mockPrisma.session.findUnique.mockResolvedValue({ + ...baseSession, + ...sessionOverrides, + class: sessionOverrides.class ?? classWithRelations, + } as any); + }; + + const mockTutorProfile = () => { + mockPrisma.tutorProfile.findUnique.mockResolvedValue({ id: "tutor-1", userId: "user-tutor", verificationStatus: "VERIFIED" } as any); + }; + + const mockStudentProfile = () => { + mockPrisma.studentProfile.findUnique.mockResolvedValue({ id: "student-1", userId: "user-student" } as any); + }; + + test("tutor start confirmation only keeps session scheduled", async () => { + mockSessionFetch(); + mockTutorProfile(); + mockPrisma.session.update.mockResolvedValue({ ...baseSession, tutorStartConfirmedAt: new Date(), status: "SCHEDULED" } as any); + + const res = await request(app) + .patch(`/api/sessions/${SESSION_ID}/start`) + .set("Authorization", `Bearer ${tutorToken}`); + + expect(res.status).toBe(200); + expect(mockPrisma.session.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ tutorStartConfirmedAt: expect.any(Date) }), + }) + ); + expect(res.body.status).toBe("SCHEDULED"); + }); + + test("both start confirmations move to IN_PROGRESS", async () => { + mockSessionFetch({ studentStartConfirmedAt: new Date("2025-01-01T10:00:00.000Z") }); + mockTutorProfile(); + mockPrisma.session.update.mockResolvedValue({ ...baseSession, tutorStartConfirmedAt: new Date(), studentStartConfirmedAt: new Date("2025-01-01T10:00:00.000Z"), status: "IN_PROGRESS", startedAt: new Date() } as any); + + const res = await request(app) + .patch(`/api/sessions/${SESSION_ID}/start`) + .set("Authorization", `Bearer ${tutorToken}`); + + expect(res.status).toBe(200); + expect(res.body.status).toBe("IN_PROGRESS"); + expect(res.body.startedAt).toBeDefined(); + }); + + test("complete by both transitions to COMPLETED and increments class progress", async () => { + jest.setSystemTime(new Date("2025-01-01T12:00:00.000Z")); + mockSessionFetch({ + status: "IN_PROGRESS", + tutorStartConfirmedAt: new Date("2025-01-01T10:05:00.000Z"), + studentStartConfirmedAt: new Date("2025-01-01T10:06:00.000Z"), + studentCompleteConfirmedAt: new Date("2025-01-01T11:40:00.000Z"), + }); + mockTutorProfile(); + mockPrisma.session.update.mockResolvedValue({ ...baseSession, status: "COMPLETED", tutorCompleteConfirmedAt: new Date(), studentCompleteConfirmedAt: new Date(), completedAt: new Date() } as any); + mockPrisma.class.update.mockResolvedValue({ id: "class-1", sessionsCompleted: 1, totalSessions: 2 } as any); + mockPrisma.class.findUnique.mockResolvedValue({ id: "class-1", sessionsCompleted: 1, totalSessions: 2 } as any); + mockPrisma.$transaction.mockResolvedValue({ ...baseSession, status: "COMPLETED" } as any); + + const res = await request(app) + .patch(`/api/sessions/${SESSION_ID}/complete`) + .set("Authorization", `Bearer ${tutorToken}`); + + expect(res.status).toBe(200); + expect(res.body.status).toBe("COMPLETED"); + }); + + test("start outside window is rejected", async () => { + jest.setSystemTime(new Date("2025-01-01T08:00:00.000Z")); + mockSessionFetch(); + mockTutorProfile(); + + const res = await request(app) + .patch(`/api/sessions/${SESSION_ID}/start`) + .set("Authorization", `Bearer ${tutorToken}`); + + expect(res.status).toBe(400); + }); + + test("unauthorized user cannot confirm", async () => { + mockSessionFetch({ class: { ...classWithRelations, bookings: [] } }); + mockStudentProfile(); + + const res = await request(app) + .patch(`/api/sessions/${SESSION_ID}/start`) + .set("Authorization", `Bearer ${studentToken}`); + + expect(res.status).toBe(403); + }); + + test("dispute is flagged after 6h gap on completion", async () => { + jest.setSystemTime(new Date("2025-01-01T18:30:00.000Z")); + mockSessionFetch({ + status: "IN_PROGRESS", + tutorStartConfirmedAt: new Date("2025-01-01T10:00:00.000Z"), + studentStartConfirmedAt: new Date("2025-01-01T10:00:00.000Z"), + tutorCompleteConfirmedAt: new Date("2025-01-01T11:30:00.000Z"), + scheduledEndAt: new Date("2025-01-01T12:00:00.000Z"), + }); + mockStudentProfile(); + mockPrisma.session.update.mockResolvedValue({ + ...baseSession, + tutorCompleteConfirmedAt: new Date("2025-01-01T11:30:00.000Z"), + studentCompleteConfirmedAt: new Date(), + disputeFlaggedAt: new Date(), + status: "IN_PROGRESS", + } as any); + mockPrisma.class.update.mockResolvedValue({ id: "class-1" } as any); + mockPrisma.class.findUnique.mockResolvedValue({ id: "class-1", sessionsCompleted: 0, totalSessions: 2 } as any); + mockPrisma.notification.create.mockResolvedValue({ id: "n1" } as any); + mockPrisma.user.findMany.mockResolvedValue([]); + mockPrisma.$transaction.mockResolvedValue({ + ...baseSession, + disputeFlaggedAt: new Date(), + status: "IN_PROGRESS", + } as any); + + const res = await request(app) + .patch(`/api/sessions/${SESSION_ID}/complete`) + .set("Authorization", `Bearer ${studentToken}`); + + expect(res.status).toBe(200); + expect(res.body.disputeFlaggedAt).toBeDefined(); + }); +}); diff --git a/mentorme_backend/tests/api/tutorClass.test.ts b/mentorme_backend/tests/api/tutorClass.test.ts index 7f5b547..6a7c2eb 100644 --- a/mentorme_backend/tests/api/tutorClass.test.ts +++ b/mentorme_backend/tests/api/tutorClass.test.ts @@ -16,7 +16,15 @@ describe("Tutor profile and class routes", () => { }); test("updates tutor profile", async () => { - const tutorProfile = { id: "tutor-1", userId: "user-tutor" } as any; + const tutorProfile = { + id: "tutor-1", + userId: "user-tutor", + verificationStatus: "VERIFIED", + bio: "", + education: "", + certificates: [], + } as any; + mockPrisma.tutorProfile.findUnique.mockResolvedValue(tutorProfile); mockPrisma.tutorProfile.update.mockResolvedValue({ ...tutorProfile, bio: "Updated" }); const res = await request(app) diff --git a/mentorme_backend/tests/api/tutorVisibility.test.ts b/mentorme_backend/tests/api/tutorVisibility.test.ts new file mode 100644 index 0000000..fbbd39d --- /dev/null +++ b/mentorme_backend/tests/api/tutorVisibility.test.ts @@ -0,0 +1,63 @@ +import request from "supertest"; +import app from "../../src/app"; +import { mockPrisma } from "../utils/mockPrisma"; +import { ClassStatus, UserStatus } from "@prisma/client"; +const VerificationStatus = { + VERIFIED: "VERIFIED", + PENDING: "PENDING", +}; + +describe("Tutor visibility rules", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("unverified tutor is excluded from search results", async () => { + mockPrisma.tutorProfile.findMany.mockResolvedValue([ + { + id: "v1", + verificationStatus: VerificationStatus.VERIFIED as any, + user: { status: UserStatus.ACTIVE }, + trustScore: 50, + averageRating: 4.2, + totalCompletedBookings: 5, + classes: [ + { + id: "c1", + subjectId: "s1", + pricePerHour: 200000, + targetGrade: "Lop 9", + status: ClassStatus.PUBLISHED, + isDeleted: false, + }, + ], + }, + { + id: "u1", + verificationStatus: VerificationStatus.PENDING as any, + user: { status: UserStatus.ACTIVE }, + trustScore: 70, + averageRating: 4.8, + totalCompletedBookings: 8, + classes: [ + { + id: "c2", + subjectId: "s1", + pricePerHour: 150000, + targetGrade: "Lop 9", + status: ClassStatus.PUBLISHED, + isDeleted: false, + }, + ], + }, + ] as any); + mockPrisma.tutorProfile.count.mockResolvedValue(2 as any); + + const res = await request(app).get("/api/tutors?subjectId=11111111-1111-4111-8111-111111111111"); + + expect(res.status).toBe(200); + expect(Array.isArray(res.body.data)).toBe(true); + expect(res.body.data.find((t: any) => t.id === "u1")).toBeUndefined(); + expect(res.body.data.find((t: any) => t.id === "v1")).toBeDefined(); + }); +}); diff --git a/mentorme_backend/tests/api/verificationAccess.test.ts b/mentorme_backend/tests/api/verificationAccess.test.ts new file mode 100644 index 0000000..bd5edb3 --- /dev/null +++ b/mentorme_backend/tests/api/verificationAccess.test.ts @@ -0,0 +1,78 @@ +import request from "supertest"; +import app from "../../src/app"; +import { signToken } from "../../src/utils/jwt"; +import { mockPrisma } from "../utils/mockPrisma"; +import { UserRole, UserStatus, ClassStatus } from "@prisma/client"; +const VerificationStatus = { + VERIFIED: "VERIFIED", + PENDING: "PENDING", +}; + +const adminToken = signToken({ userId: "admin", role: UserRole.ADMIN }); +const studentToken = signToken({ userId: "student", role: UserRole.STUDENT }); +const tutorToken = signToken({ userId: "tutor", role: UserRole.TUTOR }); + +describe("Verification access & leakage", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("non-admin cannot access admin verification list", async () => { + const res = await request(app) + .get("/api/admin/tutor-verifications") + .set("Authorization", `Bearer ${studentToken}`); + expect(res.status).toBe(403); + }); + + test("duplicate CCCD returns 409", async () => { + mockPrisma.tutorProfile.findUnique + .mockResolvedValueOnce({ id: "t1", verificationStatus: VerificationStatus.UNVERIFIED } as any) // userId lookup + .mockResolvedValueOnce({ id: "t1", verificationStatus: VerificationStatus.UNVERIFIED } as any); // service lookup + const error: any = new Error("Unique constraint"); + error.code = "P2002"; + mockPrisma.tutorProfile.update.mockRejectedValue(error); + const res = await request(app) + .put("/api/tutors/me/verification") + .set("Authorization", `Bearer ${tutorToken}`) + .send({ + nationalIdNumber: "123456789", + nationalIdFrontImageUrl: "https://front", + nationalIdBackImageUrl: "https://back", + }); + expect(res.status).toBe(409); + }); + + test("public search masks PII", async () => { + mockPrisma.tutorProfile.findMany.mockResolvedValue([ + { + id: "t1", + verificationStatus: VerificationStatus.VERIFIED, + user: { status: UserStatus.ACTIVE, fullName: "Tutor 1" }, + nationalIdNumber: "123456789", + nationalIdFrontImageUrl: "secret-front", + proofDocuments: { studentCardUrl: "secret" }, + classes: [ + { + id: "c1", + subjectId: "s1", + pricePerHour: 200000, + status: ClassStatus.PUBLISHED, + isDeleted: false, + targetGrade: "9", + }, + ], + trustScore: 10, + averageRating: 5, + totalCompletedBookings: 0, + }, + ] as any); + mockPrisma.tutorProfile.count.mockResolvedValue(1 as any); + + const res = await request(app).get("/api/tutors?subjectId=11111111-1111-4111-8111-111111111111"); + expect(res.status).toBe(200); + const tutor = res.body.data[0]; + expect(tutor.nationalIdNumber).toMatch(/\*+6789/); + expect(tutor.nationalIdFrontImageUrl).toBeUndefined(); + expect(tutor.proofDocuments).toBeUndefined(); + }); +}); diff --git a/mentorme_backend/tests/api/verificationService.test.ts b/mentorme_backend/tests/api/verificationService.test.ts new file mode 100644 index 0000000..0e526ac --- /dev/null +++ b/mentorme_backend/tests/api/verificationService.test.ts @@ -0,0 +1,120 @@ +const VerificationStatus = { + UNVERIFIED: "UNVERIFIED", + PENDING: "PENDING", + VERIFIED: "VERIFIED", + REJECTED: "REJECTED", +} as const; +import { mockPrisma } from "../utils/mockPrisma"; +import { + submitTutorVerification, + approveTutorVerification, + rejectTutorVerification, +} from "../../src/services/tutorVerification"; + +describe("Tutor verification service", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("submit sets status to PENDING and clears review", async () => { + mockPrisma.tutorProfile.findUnique.mockResolvedValue({ + id: "t1", + verificationStatus: VerificationStatus.UNVERIFIED, + } as any); + mockPrisma.tutorProfile.update.mockResolvedValue({ + id: "t1", + verificationStatus: VerificationStatus.PENDING, + verificationReviewedAt: null, + verified: false, + } as any); + + await submitTutorVerification(mockPrisma as any, "t1", { + nationalIdNumber: "123456789", + nationalIdFrontImageUrl: "http://front", + nationalIdBackImageUrl: "http://back", + }); + + expect(mockPrisma.tutorProfile.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: "t1" }, + data: expect.objectContaining({ verificationStatus: VerificationStatus.PENDING }), + }) + ); + }); + + test("submit throws when status is PENDING", async () => { + mockPrisma.tutorProfile.findUnique.mockResolvedValue({ + id: "t1", + verificationStatus: VerificationStatus.PENDING, + } as any); + + await expect( + submitTutorVerification(mockPrisma as any, "t1", { + nationalIdNumber: "123456789", + nationalIdFrontImageUrl: "http://front", + nationalIdBackImageUrl: "http://back", + }) + ).rejects.toThrow("not allowed"); + }); + + test("verified tutor resubmission is allowed and reverts to pending", async () => { + mockPrisma.tutorProfile.findUnique.mockResolvedValue({ + id: "t1", + verificationStatus: VerificationStatus.VERIFIED, + } as any); + mockPrisma.tutorProfile.update.mockResolvedValue({ + id: "t1", + verificationStatus: VerificationStatus.PENDING, + } as any); + + await submitTutorVerification(mockPrisma as any, "t1", { + nationalIdNumber: "123456789", + nationalIdFrontImageUrl: "http://front", + nationalIdBackImageUrl: "http://back", + }); + + expect(mockPrisma.tutorProfile.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ verificationStatus: VerificationStatus.PENDING }), + }) + ); + }); + + test("approve moves to VERIFIED and sets flag", async () => { + mockPrisma.tutorProfile.findUnique.mockResolvedValue({ + id: "t1", + verificationStatus: VerificationStatus.PENDING, + } as any); + mockPrisma.tutorProfile.update.mockResolvedValue({ + id: "t1", + verificationStatus: VerificationStatus.VERIFIED, + verified: true, + } as any); + + await approveTutorVerification(mockPrisma as any, "t1", "ok"); + expect(mockPrisma.tutorProfile.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ verificationStatus: VerificationStatus.VERIFIED, verified: true }), + }) + ); + }); + + test("reject moves to REJECTED and clears verified flag", async () => { + mockPrisma.tutorProfile.findUnique.mockResolvedValue({ + id: "t1", + verificationStatus: VerificationStatus.PENDING, + } as any); + mockPrisma.tutorProfile.update.mockResolvedValue({ + id: "t1", + verificationStatus: VerificationStatus.REJECTED, + verified: false, + } as any); + + await rejectTutorVerification(mockPrisma as any, "t1", "bad"); + expect(mockPrisma.tutorProfile.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ verificationStatus: VerificationStatus.REJECTED, verified: false }), + }) + ); + }); +}); diff --git a/mentorme_backend/tests/setup.ts b/mentorme_backend/tests/setup.ts index 09ad078..5e44844 100644 --- a/mentorme_backend/tests/setup.ts +++ b/mentorme_backend/tests/setup.ts @@ -7,4 +7,13 @@ jest.mock("../src/lib/prisma", () => ({ beforeEach(() => { resetMockPrisma(); + mockPrisma.$transaction.mockImplementation(async (arg: any) => { + if (typeof arg === "function") { + return arg(mockPrisma as any); + } + if (Array.isArray(arg)) { + return Promise.all(arg); + } + return arg; + }); }); diff --git a/mentorme_backend/tests/utils/mockPrisma.ts b/mentorme_backend/tests/utils/mockPrisma.ts index 9d2bbda..9179d6f 100644 --- a/mentorme_backend/tests/utils/mockPrisma.ts +++ b/mentorme_backend/tests/utils/mockPrisma.ts @@ -10,6 +10,12 @@ type PrismaMock = { booking: ModelMock; review: ModelMock; subject: ModelMock; + tutorAvailability: ModelMock; + tutorUnavailability: ModelMock; + classSchedule: ModelMock; + session: ModelMock; + notification: ModelMock; + reminderLog: ModelMock; $transaction: jest.Mock; prisma?: never; }; @@ -19,9 +25,11 @@ const createModelMock = () => ({ findUnique: jest.fn(), findMany: jest.fn(), create: jest.fn(), + createMany: jest.fn(), update: jest.fn(), count: jest.fn(), updateMany: jest.fn(), + deleteMany: jest.fn(), upsert: jest.fn(), aggregate: jest.fn(), }); @@ -34,6 +42,12 @@ export const mockPrisma: PrismaMock = { booking: createModelMock(), review: createModelMock(), subject: createModelMock(), + tutorAvailability: createModelMock(), + tutorUnavailability: createModelMock(), + classSchedule: createModelMock(), + session: createModelMock(), + notification: createModelMock(), + reminderLog: createModelMock(), $transaction: jest.fn(), }; @@ -48,5 +62,11 @@ export const resetMockPrisma = () => { resetModel(mockPrisma.booking); resetModel(mockPrisma.review); resetModel(mockPrisma.subject); + resetModel(mockPrisma.tutorAvailability); + resetModel(mockPrisma.tutorUnavailability); + resetModel(mockPrisma.classSchedule); + resetModel(mockPrisma.session); + resetModel(mockPrisma.notification); + resetModel(mockPrisma.reminderLog); mockPrisma.$transaction.mockReset(); }; diff --git a/mentorme_frontend/app/dashboard/admin/verifications/page.tsx b/mentorme_frontend/app/dashboard/admin/verifications/page.tsx new file mode 100644 index 0000000..5638908 --- /dev/null +++ b/mentorme_frontend/app/dashboard/admin/verifications/page.tsx @@ -0,0 +1,308 @@ +'use client'; + +import { useState } from 'react'; +import useSWR from 'swr'; +import { DashboardLayout } from '@/components/dashboard-layout'; +import { apiClient } from '@/lib/api-client'; +import { useUISettings } from '@/components/ui-settings-context'; +import { ShieldCheck, Clock, XCircle, CheckCircle, FileText, Eye } from 'lucide-react'; + +type ThemeMode = 'dark' | 'light'; +type Language = 'vi' | 'en'; +type VerificationStatus = 'UNVERIFIED' | 'PENDING' | 'VERIFIED' | 'REJECTED'; + +const translations: Record = { + vi: { + title: 'Duyệt xác minh gia sư', + filter: 'Lọc theo trạng thái', + statuses: { + ALL: 'Tất cả', + PENDING: 'Đang duyệt', + VERIFIED: 'Đã duyệt', + REJECTED: 'Từ chối', + UNVERIFIED: 'Chưa gửi', + }, + submittedAt: 'Ngày gửi', + reviewedAt: 'Ngày duyệt', + approve: 'Duyệt', + reject: 'Từ chối', + note: 'Ghi chú', + placeholderNote: 'Lý do hoặc ghi chú cho gia sư', + detail: 'Chi tiết', + idNumber: 'CCCD/ID', + front: 'Ảnh mặt trước', + back: 'Ảnh mặt sau', + proof: 'Giấy tờ', + certificates: 'Chứng chỉ', + noData: 'Không có yêu cầu', + }, + en: { + title: 'Tutor Verification Review', + filter: 'Filter by status', + statuses: { + ALL: 'All', + PENDING: 'Pending', + VERIFIED: 'Verified', + REJECTED: 'Rejected', + UNVERIFIED: 'Unverified', + }, + submittedAt: 'Submitted at', + reviewedAt: 'Reviewed at', + approve: 'Approve', + reject: 'Reject', + note: 'Notes', + placeholderNote: 'Reason or note to tutor', + detail: 'Details', + idNumber: 'National ID', + front: 'Front image', + back: 'Back image', + proof: 'Proof documents', + certificates: 'Certificates', + noData: 'No verification requests', + }, +}; + +const themeStyles: Record> = { + dark: { + card: 'bg-slate-900/70 border border-slate-800', + text: 'text-white', + muted: 'text-white/70', + input: 'bg-slate-800 border border-slate-700 text-white placeholder-white/50 focus:ring-purple-500 focus:border-purple-500', + badge: 'bg-white/10 text-white border border-white/20', + }, + light: { + card: 'bg-white border border-slate-200 shadow-sm', + text: 'text-slate-900', + muted: 'text-slate-600', + input: 'bg-white border border-slate-200 text-slate-900 placeholder-slate-400 focus:ring-purple-400 focus:border-purple-200', + badge: 'bg-slate-100 text-slate-700 border border-slate-200', + }, +}; + +const statusBadge = (status: VerificationStatus | 'ALL') => { + const base = 'px-2 py-1 rounded-full text-xs font-semibold border'; + if (status === 'VERIFIED') return `${base} bg-green-100 text-green-700 border-green-200`; + if (status === 'PENDING') return `${base} bg-amber-100 text-amber-700 border-amber-200`; + if (status === 'REJECTED') return `${base} bg-red-100 text-red-700 border-red-200`; + return `${base} bg-slate-100 text-slate-700 border-slate-200`; +}; + +export default function AdminVerificationsPage() { + const { theme, language } = useUISettings(); + const styles = themeStyles[theme]; + const t = translations[language]; + const [statusFilter, setStatusFilter] = useState('PENDING'); + const [selectedId, setSelectedId] = useState(null); + const [note, setNote] = useState(''); + const [processing, setProcessing] = useState(false); + + const { data, mutate, isLoading } = useSWR( + `/api/admin/tutor-verifications${statusFilter === 'ALL' ? '' : `?status=${statusFilter}`}`, + apiClient.get + ); + + const selected = data?.find((t: any) => t.id === selectedId) || null; + + const act = async (action: 'approve' | 'reject', id: string) => { + if (action === 'reject' && !note.trim()) { + alert(language === 'vi' ? 'Vui lòng nhập ghi chú khi từ chối' : 'Please add a note to reject'); + return; + } + if (!confirm(action === 'approve' ? 'Approve this tutor?' : 'Reject this tutor?')) return; + setProcessing(true); + try { + await apiClient.patch(`/api/admin/tutor-verifications/${id}/${action}`, action === 'approve' ? {} : { note }); + setNote(''); + await mutate(); + } catch (err) { + console.error('Verification action failed', err); + } finally { + setProcessing(false); + } + }; + + return ( + +
+
+
+

+ {t.title} +

+
+
+ + +
+
+ +
+ {isLoading ? ( +
+
+
+ ) : data && data.length > 0 ? ( +
+ {data.map((tutor: any) => ( + + ))} +
+ ) : ( +

{t.noData}

+ )} +
+ + {selected && ( +
+
+ +

{t.detail}

+ {selected.verificationStatus} +
+
+
+

{t.submittedAt}

+

+ {selected.verificationSubmittedAt ? new Date(selected.verificationSubmittedAt).toLocaleString() : '—'} +

+
+
+

{t.reviewedAt}

+

+ {selected.verificationReviewedAt ? new Date(selected.verificationReviewedAt).toLocaleString() : '—'} +

+
+
+

{t.idNumber}

+

{selected.nationalIdNumber || '—'}

+
+
+ +
+
+

{t.front}

+ {selected.nationalIdFrontImageUrl ? ( + + {selected.nationalIdFrontImageUrl} + + ) : ( +

+ )} +
+
+

{t.back}

+ {selected.nationalIdBackImageUrl ? ( + + {selected.nationalIdBackImageUrl} + + ) : ( +

+ )} +
+
+ +
+

{t.proof}

+
+ {selected.proofDocuments?.studentCardUrl && ( + + Student card + + )} + {selected.proofDocuments?.transcriptUrl && ( + + Transcript + + )} + {!selected.proofDocuments?.studentCardUrl && !selected.proofDocuments?.transcriptUrl && ( +

+ )} +
+
+ +
+

{t.certificates}

+ {Array.isArray(selected.certificatesDetail) && selected.certificatesDetail.length > 0 ? ( +
+ {selected.certificatesDetail.map((c: any, idx: number) => ( +
+

{c.title || c.type || '—'}

+

{c.issuer}

+

{c.issueDate}

+ {c.imageUrl && ( + + {c.imageUrl} + + )} +
+ ))} +
+ ) : ( +

+ )} +
+ +
+ +