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..6fa3ffb 100644 --- a/mentorme_backend/README.md +++ b/mentorme_backend/README.md @@ -67,6 +67,34 @@ 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). + +## Sessions & Payments (Phase 3 & 4A/4B) + +- Session lifecycle: `PATCH /api/sessions/:id/start`, `PATCH /api/sessions/:id/complete` (both tutor & student must confirm). Disputed sessions are flagged after long one-sided confirmations. +- Notifications: `GET /api/notifications/me`, `PATCH /api/notifications/:id/read` plus automated reminders at T-24h/T-1h (see `npm run reminders:run`). +- Payments (mock escrow): + - Create intent: `POST /api/classes/:id/payments/intents` (student/admin; requires schedule & booking) + - Confirm mock payment: `POST /api/payments/:intentId/confirm` + - Summary & ledger: `GET /api/classes/:id/payments/summary` + - Escrow auto-releases on session completion (90% tutor / 10% platform); insufficient escrow notifies payer. Cancelling a class (`PATCH /api/classes/:id/cancel`) refunds remaining balance. + ## 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..28ae538 100644 --- a/mentorme_backend/package.json +++ b/mentorme_backend/package.json @@ -5,12 +5,15 @@ "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", + "seed": "ts-node-dev --transpile-only src/scripts/seedDemo.ts", + "demo:reset": "npx prisma migrate reset --force --skip-generate --skip-seed && npm run seed" + }, "keywords": [], "author": "", "license": "ISC", 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..d7d4a98 --- /dev/null +++ b/mentorme_backend/prisma/migrations/20251207120000_verification_phase1/migration.sql @@ -0,0 +1,28 @@ +-- 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'::"VerificationStatus" + ELSE 'UNVERIFIED'::"VerificationStatus" + 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..6eb952c --- /dev/null +++ b/mentorme_backend/prisma/migrations/20251222123000_phase3_session_lifecycle/migration.sql @@ -0,0 +1,49 @@ +-- 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 e + JOIN pg_type t ON t.oid = e.enumtypid + WHERE t.typname = 'SessionStatus' + AND e.enumlabel = 'IN_PROGRESS' + ) THEN + ALTER TYPE "SessionStatus" ADD VALUE 'IN_PROGRESS'; + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM pg_enum e + JOIN pg_type t ON t.oid = e.enumtypid + WHERE t.typname = 'SessionStatus' + AND e.enumlabel = 'COMPLETED' + ) 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/migrations/20251224123000_phase4b_escrow/migration.sql b/mentorme_backend/prisma/migrations/20251224123000_phase4b_escrow/migration.sql new file mode 100644 index 0000000..14424c4 --- /dev/null +++ b/mentorme_backend/prisma/migrations/20251224123000_phase4b_escrow/migration.sql @@ -0,0 +1,81 @@ +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- PaymentStatus enum +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'PaymentStatus') THEN + CREATE TYPE "PaymentStatus" AS ENUM ('PENDING', 'PAID', 'FAILED', 'REFUNDED'); + END IF; +END$$; + +-- LedgerType enum +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'LedgerType') THEN + CREATE TYPE "LedgerType" AS ENUM ('DEPOSIT', 'RELEASE_TO_TUTOR', 'PLATFORM_FEE', 'REFUND'); + END IF; +END$$; + +CREATE TABLE IF NOT EXISTS "PaymentIntent" ( + "id" TEXT PRIMARY KEY DEFAULT uuid_generate_v4(), + "classId" TEXT NOT NULL, + "payerId" TEXT NOT NULL, + "amount" DOUBLE PRECISION NOT NULL, + "currency" TEXT NOT NULL DEFAULT 'VND', + "status" "PaymentStatus" NOT NULL DEFAULT 'PENDING', + "provider" TEXT NOT NULL DEFAULT 'MOCK', + "providerRef" TEXT, + "createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "updatedAt" TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS "PaymentIntent_classId_idx" ON "PaymentIntent"("classId"); +CREATE INDEX IF NOT EXISTS "PaymentIntent_payerId_idx" ON "PaymentIntent"("payerId"); + +ALTER TABLE "PaymentIntent" + ADD CONSTRAINT "PaymentIntent_classId_fkey" + FOREIGN KEY ("classId") REFERENCES "Class"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "PaymentIntent" + ADD CONSTRAINT "PaymentIntent_payerId_fkey" + FOREIGN KEY ("payerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +CREATE TABLE IF NOT EXISTS "EscrowAccount" ( + "id" TEXT PRIMARY KEY DEFAULT uuid_generate_v4(), + "classId" TEXT UNIQUE NOT NULL, + "totalDeposited" DOUBLE PRECISION NOT NULL DEFAULT 0, + "availableBalance" DOUBLE PRECISION NOT NULL DEFAULT 0, + "releasedAmount" DOUBLE PRECISION NOT NULL DEFAULT 0, + "refundedAmount" DOUBLE PRECISION NOT NULL DEFAULT 0, + "createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "updatedAt" TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +ALTER TABLE "EscrowAccount" + ADD CONSTRAINT "EscrowAccount_classId_fkey" + FOREIGN KEY ("classId") REFERENCES "Class"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +CREATE TABLE IF NOT EXISTS "LedgerEntry" ( + "id" TEXT PRIMARY KEY DEFAULT uuid_generate_v4(), + "classId" TEXT NOT NULL, + "sessionId" TEXT, + "paymentIntentId" TEXT, + "type" "LedgerType" NOT NULL, + "amount" DOUBLE PRECISION NOT NULL, + "createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +ALTER TABLE "LedgerEntry" + ADD CONSTRAINT "LedgerEntry_classId_fkey" + FOREIGN KEY ("classId") REFERENCES "Class"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "LedgerEntry" + ADD CONSTRAINT "LedgerEntry_sessionId_fkey" + FOREIGN KEY ("sessionId") REFERENCES "Session"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "LedgerEntry" + ADD CONSTRAINT "LedgerEntry_paymentIntentId_fkey" + FOREIGN KEY ("paymentIntentId") REFERENCES "PaymentIntent"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +CREATE INDEX IF NOT EXISTS "LedgerEntry_classId_createdAt_idx" ON "LedgerEntry"("classId", "createdAt" DESC); +CREATE UNIQUE INDEX IF NOT EXISTS "ledger_session_unique" ON "LedgerEntry"("type", "sessionId"); diff --git a/mentorme_backend/prisma/migrations/20251225100000_subject_name_unique/migration.sql b/mentorme_backend/prisma/migrations/20251225100000_subject_name_unique/migration.sql new file mode 100644 index 0000000..60ffdea --- /dev/null +++ b/mentorme_backend/prisma/migrations/20251225100000_subject_name_unique/migration.sql @@ -0,0 +1 @@ +CREATE UNIQUE INDEX "Subject_name_key" ON "Subject"("name"); diff --git a/mentorme_backend/prisma/schema.prisma b/mentorme_backend/prisma/schema.prisma index 414c24c..404a538 100644 --- a/mentorme_backend/prisma/schema.prisma +++ b/mentorme_backend/prisma/schema.prisma @@ -25,12 +25,50 @@ enum ClassStatus { ARCHIVED } +enum ClassLifecycleStatus { + PENDING + ACTIVE + COMPLETED + CANCELLED +} + +enum NotificationChannel { + IN_APP + EMAIL +} + +enum ReminderType { + REMINDER_24H + REMINDER_1H +} + +enum PaymentStatus { + PENDING + PAID + FAILED + REFUNDED +} + +enum LedgerType { + DEPOSIT + RELEASE_TO_TUTOR + PLATFORM_FEE + REFUND +} + enum LocationType { ONLINE AT_STUDENT AT_TUTOR } +enum VerificationStatus { + UNVERIFIED + PENDING + VERIFIED + REJECTED +} + enum BookingStatus { PENDING CONFIRMED @@ -59,6 +97,9 @@ model User { studentProfile StudentProfile? tutorProfile TutorProfile? + notifications Notification[] + reminderLogs ReminderLog[] + paymentIntents PaymentIntent[] } model StudentProfile { @@ -84,6 +125,7 @@ model TutorProfile { bio String? education String? certificates String[] + proofDocuments Json? yearsOfExperience Int @default(0) hourlyRateMin Float? hourlyRateMax Float? @@ -91,6 +133,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 +161,7 @@ model TutorProfile { bookings Booking[] reviews Review[] availabilities TutorAvailability[] + unavailabilities TutorUnavailability[] } model TutorAvailability { @@ -119,6 +170,7 @@ model TutorAvailability { dayOfWeek Int startMinute Int endMinute Int + timezone String @default("UTC") locationType LocationType createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -128,9 +180,148 @@ 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) + reminders ReminderLog[] + ledgerEntries LedgerEntry[] + + @@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 PaymentIntent { + id String @id @default(uuid()) + classId String + payerId String + amount Float + currency String @default("VND") + status PaymentStatus @default(PENDING) + provider String @default("MOCK") + providerRef String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + class Class @relation(fields: [classId], references: [id], onDelete: Cascade) + payer User @relation(fields: [payerId], references: [id], onDelete: Cascade) + ledgerEntries LedgerEntry[] + + @@index([classId]) + @@index([payerId]) +} + +model EscrowAccount { + id String @id @default(uuid()) + classId String @unique + totalDeposited Float @default(0) + availableBalance Float @default(0) + releasedAmount Float @default(0) + refundedAmount Float @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + class Class @relation(fields: [classId], references: [id], onDelete: Cascade) +} + +model LedgerEntry { + id String @id @default(uuid()) + classId String + sessionId String? + paymentIntentId String? + type LedgerType + amount Float + createdAt DateTime @default(now()) + + class Class @relation(fields: [classId], references: [id], onDelete: Cascade) + session Session? @relation(fields: [sessionId], references: [id], onDelete: Cascade) + paymentIntent PaymentIntent? @relation(fields: [paymentIntentId], references: [id], onDelete: Cascade) + + @@index([classId, createdAt]) + @@unique([type, sessionId], map: "ledger_session_unique") // prevents double release per session +} + model Subject { id String @id @default(uuid()) - name String + name String @unique level String description String? createdAt DateTime @default(now()) @@ -153,6 +344,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 +354,11 @@ model Class { tutor TutorProfile @relation(fields: [tutorId], references: [id]) subject Subject @relation(fields: [subjectId], references: [id]) bookings Booking[] + schedule ClassSchedule? + sessions Session[] + paymentIntents PaymentIntent[] + escrowAccount EscrowAccount? + ledgerEntries LedgerEntry[] @@index([subjectId, status]) @@index([tutorId]) diff --git a/mentorme_backend/src/app.ts b/mentorme_backend/src/app.ts index 8ad3606..3587ed3 100644 --- a/mentorme_backend/src/app.ts +++ b/mentorme_backend/src/app.ts @@ -10,6 +10,10 @@ 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"; +import paymentRoutes from "./routes/paymentRoutes"; const app = express(); @@ -34,5 +38,9 @@ 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); +app.use("/api", paymentRoutes); 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..7225a3f 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"; @@ -20,6 +26,12 @@ const createSchema = z.object({ noteFromStudent: z.string().optional(), }); +const ACTIVE_BOOKING_STATUSES = [ + BookingStatus.PENDING, + BookingStatus.CONFIRMED, + BookingStatus.TRIAL, +]; + const getStudentIdByUser = async (userId: string) => { const student = await prisma.studentProfile.findUnique({ where: { userId }, @@ -32,6 +44,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 { @@ -49,6 +68,17 @@ router.post("/", authGuard([UserRole.STUDENT]), async (req, res) => { return res.status(400).json({ message: "Class not available" }); } + const existing = await prisma.booking.findFirst({ + where: { + classId: classListing.id, + studentId, + status: { in: ACTIVE_BOOKING_STATUSES }, + }, + }); + if (existing) { + return res.status(409).json({ message: "Bạn đã đặt lớp này, không thể đặt lại." }); + } + const booking = await prisma.booking.create({ data: { classId: classListing.id, @@ -71,6 +101,32 @@ router.post("/", authGuard([UserRole.STUDENT]), async (req, res) => { } }); +router.get("/me", authGuard([UserRole.STUDENT]), async (req, res) => { + try { + const classId = (req.query.classId as string) || ""; + if (!classId) return res.status(400).json({ message: "classId is required" }); + const studentId = await getStudentIdByUser(req.user!.id); + if (!studentId) return res.status(400).json({ message: "Student profile not found" }); + + const booking = await prisma.booking.findFirst({ + where: { + classId, + studentId, + status: { in: Object.values(BookingStatus) }, + }, + orderBy: { createdAt: "desc" }, + }); + + return res.json({ + bookingExists: !!booking, + bookingStatus: booking?.status ?? null, + bookingId: booking?.id ?? null, + }); + } catch (error) { + return res.status(500).json({ message: "Internal server error" }); + } +}); + router.get("/", authGuard(), async (req, res) => { try { const statusFilter = Array.isArray(req.query.status) @@ -182,6 +238,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 +389,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..348d9b9 100644 --- a/mentorme_backend/src/routes/classRoutes.ts +++ b/mentorme_backend/src/routes/classRoutes.ts @@ -4,13 +4,43 @@ 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"; +import { refundEscrow } from "../services/escrow"; 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 +58,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 +107,136 @@ const baseClassSchema = z.object({ district: z.string().optional(), }); +const recurrenceSlotSchema = z + .object({ + dayOfWeek: z.coerce.number().int().min(0).max(6, { message: "dayOfWeek must be 0-6" }), + startMinute: z.coerce.number().int().min(0).max(1440, { message: "startMinute must be 0-1440" }), + endMinute: z.coerce.number().int().min(1).max(1440, { message: "endMinute must be 1-1440" }), + }) + .refine((val) => val.endMinute > val.startMinute, { message: "endMinute must be greater than startMinute" }); + +const startDateSchema = z + .string() + .min(8, "recurrence.startDate is required") + .refine((v) => !Number.isNaN(new Date(v).getTime()), { message: "recurrence.startDate must be a valid date" }); + +const scheduleCreateSchema = z + .object({ + timezone: z.string().min(1, "timezone is required").default("UTC"), + recurrence: z + .object({ + startDate: startDateSchema, + weeks: z.coerce.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" } + ); + +type DateParts = { year: number; month: number; day: number }; +const pad = (n: number) => n.toString().padStart(2, "0"); +const parseDateParts = (value: string): DateParts | null => { + const datePart = value.split("T")[0]; + const [y, m, d] = datePart.split("-").map((v) => parseInt(v, 10)); + if (!y || !m || !d) return null; + return { year: y, month: m, day: d }; +}; +const addDays = (parts: DateParts, days: number): DateParts => { + const dt = new Date(Date.UTC(parts.year, parts.month - 1, parts.day + days)); + return { year: dt.getUTCFullYear(), month: dt.getUTCMonth() + 1, day: dt.getUTCDate() }; +}; +const datePartsToString = (parts: DateParts) => `${parts.year.toString().padStart(4, "0")}-${pad(parts.month)}-${pad(parts.day)}`; +const getOffsetMinutes = (date: Date, timeZone: string) => { + const dtf = new Intl.DateTimeFormat("en-US", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }); + const parts = dtf.formatToParts(date).reduce>((acc, part) => { + if (part.type !== "literal") acc[part.type] = part.value; + return acc; + }, {}); + const asUTC = Date.UTC( + Number(parts.year), + Number(parts.month) - 1, + Number(parts.day), + Number(parts.hour), + Number(parts.minute), + Number(parts.second) + ); + return (asUTC - date.getTime()) / 60000; +}; + +const getDayInTimezone = (parts: DateParts, timeZone: string) => { + // Use midday to avoid DST edge cases for day calculation + const guessUtc = new Date(Date.UTC(parts.year, parts.month - 1, parts.day, 12, 0, 0)); + const offset = getOffsetMinutes(guessUtc, timeZone); + const localMillis = guessUtc.getTime() - offset * 60_000; + return new Date(localMillis).getUTCDay(); +}; + +const localMinutesToUtcDate = (parts: DateParts, minuteOfDay: number, timeZone: string) => { + const hours = Math.floor(minuteOfDay / 60); + const minutes = minuteOfDay % 60; + const utcGuess = new Date(Date.UTC(parts.year, parts.month - 1, parts.day, hours, minutes, 0)); + const offset = getOffsetMinutes(utcGuess, timeZone); + return new Date(utcGuess.getTime() - offset * 60_000); +}; + +const toSessionInputs = (payload: z.infer): SessionInput[] => { + const sessions: SessionInput[] = []; + if (payload.recurrence) { + const tz = (payload.timezone || "UTC").trim() || "UTC"; + const startParts = parseDateParts(payload.recurrence.startDate); + if (!startParts) { + return []; + } + const baseDay = getDayInTimezone(startParts, tz); + 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 targetDate = addDays(startParts, dayOffset); + const start = localMinutesToUtcDate(targetDate, slot.startMinute, tz); + const end = localMinutesToUtcDate(targetDate, slot.endMinute, tz); + 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); @@ -177,6 +341,10 @@ const statusSchema = z.object({ status: z.nativeEnum(ClassStatus), }); +const cancelSchema = z.object({ + reason: z.string().optional(), +}); + router.patch("/:id/status", authGuard([UserRole.TUTOR, UserRole.ADMIN]), async (req, res) => { try { const payload = statusSchema.parse(req.body); @@ -215,6 +383,44 @@ router.patch("/:id/status", authGuard([UserRole.TUTOR, UserRole.ADMIN]), async ( } }); +router.patch("/:id/cancel", authGuard([UserRole.TUTOR, UserRole.ADMIN]), async (req, res) => { + try { + cancelSchema.parse(req.body || {}); + 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 } }); + 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 (classListing.lifecycleStatus === CL.CANCELLED) { + return res.status(400).json({ message: "Class already cancelled" }); + } + + await prisma.class.update({ + where: { id: classListing.id }, + data: { + lifecycleStatus: CL.CANCELLED as any, + status: ClassStatus.ARCHIVED, + }, + }); + + await refundEscrow(prisma, classListing.id); + + return res.json({ message: "Class cancelled" }); + } 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.delete("/:id", authGuard([UserRole.TUTOR, UserRole.ADMIN]), async (req, res) => { try { const classId = req.params.id ?? ""; @@ -305,6 +511,321 @@ 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, sessions, cls] = await Promise.all([ + prisma.classSchedule.findUnique({ + where: { classId }, + }), + prisma.session.findMany({ + where: { classId }, + orderBy: { scheduledStartAt: "asc" }, + }), + prisma.class.findUnique({ where: { id: classId } }), + ]); + + if (!schedule) { + 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: cls + ? { + id: cls.id, + title: cls.title, + lifecycleStatus: (cls as any).lifecycleStatus, + totalSessions: (cls as any).totalSessions, + sessionsCompleted: (cls as any).sessionsCompleted, + } + : null, + sessions, + }); + } catch (error) { + return res.status(500).json({ message: "Internal server error" }); + } +}); + +router.delete( + "/: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 classListing = await prisma.class.findUnique({ + where: { id: classId }, + }); + 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" }); + } + } + + const result = await prisma.$transaction(async (tx) => { + // keep completed sessions for history; clear others + await tx.session.deleteMany({ + where: { + classId, + status: { not: SS.COMPLETED as any }, + }, + }); + + const completedCount = await tx.session.count({ + where: { classId, status: SS.COMPLETED as any }, + }); + + await tx.classSchedule.deleteMany({ where: { classId } }); + await tx.class.update({ + where: { id: classId }, + data: { + totalSessions: completedCount, + sessionsCompleted: completedCount, + lifecycleStatus: + completedCount > 0 && classListing.totalSessions > 0 && completedCount >= (classListing.totalSessions ?? 0) + ? (CL.COMPLETED as any) + : completedCount > 0 + ? (CL.ACTIVE as any) + : (CL.PENDING as any), + }, + }); + + const remainingSessions = await tx.session.findMany({ + where: { classId }, + orderBy: { scheduledStartAt: "asc" }, + }); + return { completedCount, remainingSessions }; + }); + + return res.json({ message: "Schedule cleared", remainingSessions: result.remainingSessions }); + } 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 timezone = (payload.timezone || "UTC").trim() || "UTC"; + + 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" }); + } + } + + let sessions: SessionInput[] = []; + try { + sessions = toSessionInputs({ ...payload, timezone }); + } catch (err: any) { + return res.status(400).json({ message: err?.message || "Invalid schedule 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, + 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?.map((i) => ({ + path: i.path.join("."), + message: i.message, + })), + }); + } + return res.status(500).json({ message: "Internal server error" }); + } + } +); + router.get("/:id", async (req, res) => { try { const classId = req.params.id ?? ""; @@ -323,7 +844,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/paymentRoutes.ts b/mentorme_backend/src/routes/paymentRoutes.ts new file mode 100644 index 0000000..de9588e --- /dev/null +++ b/mentorme_backend/src/routes/paymentRoutes.ts @@ -0,0 +1,169 @@ +import { Router } from "express"; +import { authGuard } from "../middleware/auth"; +import { prisma } from "../lib/prisma"; +import { UserRole, BookingStatus } from "@prisma/client"; +import { z } from "zod"; +import { + confirmPaymentIntent, + createDepositIntent, + getEscrowSummary, +} from "../services/escrow"; + +const router = Router(); + +const intentSchema = z.object({ + packageSessionsCount: z.number().int().min(1), + amountPerSession: z.number().positive().optional(), + currency: z.string().optional(), +}); + +router.post( + "/classes/:id/payments/intents", + authGuard([UserRole.STUDENT, UserRole.ADMIN]), + async (req, res) => { + try { + const payload = intentSchema.parse(req.body); + const classId = req.params.id; + const student = await prisma.studentProfile.findUnique({ where: { userId: req.user!.id } }); + if (req.user!.role === UserRole.STUDENT && !student) { + return res.status(400).json({ message: "Student profile not found" }); + } + + 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" }); + } + + const schedule = await prisma.classSchedule.findUnique({ where: { classId } }); + if (!schedule) { + return res.status(400).json({ message: "Class must have a schedule before payment" }); + } + + if (req.user!.role === UserRole.STUDENT) { + const booking = await prisma.booking.findFirst({ + where: { + classId, + studentId: student!.id, + status: { in: [BookingStatus.PENDING, BookingStatus.CONFIRMED, BookingStatus.TRIAL] }, + }, + }); + if (!booking) { + return res.status(403).json({ message: "You have no booking for this class" }); + } + } + + const amountPerSession = payload.amountPerSession ?? classListing.pricePerHour; + if (!amountPerSession || amountPerSession <= 0) { + return res.status(400).json({ message: "Invalid amount per session" }); + } + + const intent = await createDepositIntent(prisma, { + classId, + payerId: req.user!.id, + packageSessionsCount: payload.packageSessionsCount, + amountPerSession, + currency: payload.currency, + }); + + return res.status(201).json(intent); + } 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.post( + "/payments/:intentId/confirm", + authGuard([UserRole.STUDENT, UserRole.ADMIN]), + async (req, res) => { + try { + const intentId = req.params.intentId; + const intent = await prisma.paymentIntent.findUnique({ where: { id: intentId } }); + if (!intent) { + return res.status(404).json({ message: "Payment intent not found" }); + } + if (req.user!.role === UserRole.STUDENT && intent.payerId !== req.user!.id) { + return res.status(403).json({ message: "Forbidden" }); + } + const result = await confirmPaymentIntent(prisma, intentId); + return res.json(result); + } catch (error: any) { + if (error.status === 404) { + return res.status(404).json({ message: error.message }); + } + return res.status(500).json({ message: "Internal server error" }); + } + } +); + +router.get( + "/classes/:id/payments/summary", + authGuard([UserRole.TUTOR, UserRole.STUDENT, UserRole.ADMIN]), + async (req, res) => { + try { + const classId = req.params.id; + 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 (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.PENDING, BookingStatus.CONFIRMED, BookingStatus.TRIAL, BookingStatus.COMPLETED] }, + }, + }); + if (!booking) { + return res.status(403).json({ message: "Forbidden" }); + } + } + + const summary = await getEscrowSummary(prisma, classId, { + take: Number(req.query.take ?? 20), + skip: Number(req.query.skip ?? 0), + }); + + const completedSessions = await prisma.session.findMany({ + where: { classId, status: "COMPLETED" as any }, + select: { id: true }, + }); + const paidSessionIds = new Set(summary.paidReleases.filter(Boolean)); + const unpaidCompleted = completedSessions.filter((s) => !paidSessionIds.has(s.id)).length; + + const nextReleaseAmount = classListing.pricePerHour; + + return res.json({ + escrow: summary.escrow, + ledger: summary.ledger, + unpaidCompleted, + nextReleaseAmount, + }); + } 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..7b6bc64 --- /dev/null +++ b/mentorme_backend/src/routes/sessionRoutes.ts @@ -0,0 +1,353 @@ +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"; +import { releaseForCompletedSession } from "../services/escrow"; + +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}`, + }); + } + } + + if (result.status === SS.COMPLETED) { + try { + await releaseForCompletedSession(prisma, session.id); + } catch (err) { + console.error("Failed to release escrow", err); + } + } + + 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/scripts/seedDemo.ts b/mentorme_backend/src/scripts/seedDemo.ts new file mode 100644 index 0000000..25e3aa4 --- /dev/null +++ b/mentorme_backend/src/scripts/seedDemo.ts @@ -0,0 +1,338 @@ +import { PrismaClient, BookingStatus, ClassLifecycleStatus, ClassStatus, UserRole, VerificationStatus } from "@prisma/client"; +import * as bcrypt from "bcryptjs"; +import * as dotenv from "dotenv"; + +dotenv.config(); + +const prisma = new PrismaClient(); + +const VS = + VerificationStatus ?? { + UNVERIFIED: "UNVERIFIED", + PENDING: "PENDING", + VERIFIED: "VERIFIED", + REJECTED: "REJECTED", + }; + +const CL = + ClassLifecycleStatus ?? { + PENDING: "PENDING", + ACTIVE: "ACTIVE", + COMPLETED: "COMPLETED", + CANCELLED: "CANCELLED", + }; + +async function hashPassword(password: string) { + const salt = await bcrypt.genSalt(10); + return bcrypt.hash(password, salt); +} + +async function ensureUser(opts: { email: string; password: string; role: UserRole; fullName: string }) { + const passwordHash = await hashPassword(opts.password); + return prisma.user.upsert({ + where: { email: opts.email }, + update: { passwordHash, role: opts.role, fullName: opts.fullName }, + create: { + email: opts.email, + passwordHash, + role: opts.role, + fullName: opts.fullName, + status: "ACTIVE", + }, + }); +} + +async function main() { + if (process.env.NODE_ENV && process.env.NODE_ENV !== "development" && !process.env.ALLOW_DEMO_SEED) { + console.warn("Skipping seed: NODE_ENV is not development. Set ALLOW_DEMO_SEED=1 to force."); + return; + } + + console.log("Seeding demo data (Phase 1–4B)..."); + + // Subjects + const math = await prisma.subject.upsert({ + where: { name: "Toán 10" }, + update: { level: "K10", description: "Toán lớp 10" }, + create: { name: "Toán 10", level: "K10", description: "Toán lớp 10" }, + }); + + // Users + const admin = await ensureUser({ + email: "admin@mentorme.local", + password: "Admin@123", + role: UserRole.ADMIN, + fullName: "Admin Demo", + }); + + const tutorUser = await ensureUser({ + email: "tutor@mentorme.local", + password: "Tutor@123", + role: UserRole.TUTOR, + fullName: "Gia sư Demo", + }); + + const studentUser = await ensureUser({ + email: "student@mentorme.local", + password: "Student@123", + role: UserRole.STUDENT, + fullName: "Phụ huynh Demo", + }); + + // Profiles + const studentProfile = await prisma.studentProfile.upsert({ + where: { userId: studentUser.id }, + update: {}, + create: { + userId: studentUser.id, + gradeLevel: "10", + goals: "Ôn tập toán 10", + preferredSubjects: ["Toán"], + }, + }); + + const tutorProfile = await prisma.tutorProfile.upsert({ + where: { userId: tutorUser.id }, + update: { + verificationStatus: VS.VERIFIED as any, + verified: true, + nationalIdNumber: "012345678901", + nationalIdFrontImageUrl: "https://placehold.co/600x400/front", + nationalIdBackImageUrl: "https://placehold.co/600x400/back", + certificatesDetail: [ + { type: "Degree", title: "Cử nhân Toán", issuer: "ĐH Demo", issueDate: "2018-06-01", imageUrl: "https://placehold.co/400x300/degree" }, + { type: "Certificate", title: "Chứng chỉ sư phạm", issuer: "Demo Institute", issueDate: "2019-05-01", imageUrl: "https://placehold.co/400x300/cert" }, + ], + proofDocuments: { + studentCard: "https://placehold.co/400x300/student-card", + transcript: "https://placehold.co/400x300/transcript", + }, + verificationSubmittedAt: new Date(), + verificationReviewedAt: new Date(), + verificationNotes: "Seeded as VERIFIED", + city: "Hà Nội", + yearsOfExperience: 5, + hourlyRateMin: 150000, + hourlyRateMax: 300000, + teachingModes: ["ONLINE"], + trustScore: 4.5, + }, + create: { + userId: tutorUser.id, + bio: "Gia sư Toán 10 với 5 năm kinh nghiệm", + education: "Cử nhân Toán", + certificates: [], + certificatesDetail: [ + { type: "Degree", title: "Cử nhân Toán", issuer: "ĐH Demo", issueDate: "2018-06-01", imageUrl: "https://placehold.co/400x300/degree" }, + ], + proofDocuments: { + studentCard: "https://placehold.co/400x300/student-card", + transcript: "https://placehold.co/400x300/transcript", + }, + yearsOfExperience: 5, + hourlyRateMin: 150000, + hourlyRateMax: 300000, + teachingModes: ["ONLINE"], + city: "Hà Nội", + verificationStatus: VS.VERIFIED as any, + verified: true, + nationalIdNumber: "012345678901", + nationalIdFrontImageUrl: "https://placehold.co/600x400/front", + nationalIdBackImageUrl: "https://placehold.co/600x400/back", + verificationSubmittedAt: new Date(), + verificationReviewedAt: new Date(), + trustScore: 4.5, + }, + }); + + // Class + const demoClass = await prisma.class.upsert({ + where: { id: "class-demo-1" }, + update: { + tutorId: tutorProfile.id, + subjectId: math.id, + status: ClassStatus.PUBLISHED, + lifecycleStatus: CL.ACTIVE as any, + pricePerHour: 200000, + isDeleted: false, + city: "Hà Nội", + district: "Cầu Giấy", + }, + create: { + id: "class-demo-1", + tutorId: tutorProfile.id, + subjectId: math.id, + title: "Toán 10 cơ bản", + description: "Ôn tập kiến thức Toán 10 và luyện đề", + pricePerHour: 200000, + locationType: "ONLINE", + status: ClassStatus.PUBLISHED, + lifecycleStatus: CL.ACTIVE as any, + city: "Hà Nội", + district: "Cầu Giấy", + }, + }); + + // Booking + await prisma.booking.upsert({ + where: { id: "booking-demo-1" }, + update: { + status: BookingStatus.CONFIRMED, + isTrial: false, + requestedHoursPerWeek: 3, + startDateExpected: new Date(), + noteFromStudent: "Muốn luyện thi cuối kì", + }, + create: { + id: "booking-demo-1", + classId: demoClass.id, + studentId: studentProfile.id, + tutorId: tutorProfile.id, + status: BookingStatus.CONFIRMED, + isTrial: false, + requestedHoursPerWeek: 3, + startDateExpected: new Date(), + noteFromStudent: "Muốn luyện thi cuối kì", + }, + }); + + // Schedule & Sessions + const now = new Date(); + const sessions = [ + { start: new Date(now.getTime() - 10 * 60 * 1000), end: new Date(now.getTime() - 1 * 60 * 1000) }, // just happened, still in start window + { start: new Date(now.getTime() + 30 * 60 * 1000), end: new Date(now.getTime() + 90 * 60 * 1000) }, // later today + { start: new Date(now.getTime() + 24 * 60 * 60 * 1000), end: new Date(now.getTime() + 25 * 60 * 60 * 1000) }, // tomorrow + { start: new Date(now.getTime() + 3 * 24 * 60 * 60 * 1000), end: new Date(now.getTime() + 3 * 24 * 60 * 60 * 1000 + 60 * 60 * 1000) }, // +3 days + ]; + + await prisma.$transaction(async (tx) => { + await tx.session.deleteMany({ where: { classId: demoClass.id } }); + await tx.classSchedule.upsert({ + where: { classId: demoClass.id }, + update: { + timezone: "Asia/Ho_Chi_Minh", + recurrenceRule: { seeded: true }, + totalSessions: sessions.length, + }, + create: { + classId: demoClass.id, + timezone: "Asia/Ho_Chi_Minh", + recurrenceRule: { seeded: true }, + totalSessions: sessions.length, + }, + }); + await tx.session.createMany({ + data: sessions.map((s, idx) => ({ + id: `sess-demo-${idx + 1}`, + classId: demoClass.id, + scheduledStartAt: s.start, + scheduledEndAt: s.end, + status: "SCHEDULED", + })), + skipDuplicates: true, + }); + await tx.class.update({ + where: { id: demoClass.id }, + data: { totalSessions: sessions.length, sessionsCompleted: 0 }, + }); + }); + + // Escrow: deposit for 4 sessions + const depositAmount = 4 * demoClass.pricePerHour; + const paymentIntent = await prisma.paymentIntent.upsert({ + where: { id: "pi-demo-1" }, + update: { + classId: demoClass.id, + payerId: studentUser.id, + amount: depositAmount, + status: "PAID", + provider: "MOCK", + }, + create: { + id: "pi-demo-1", + classId: demoClass.id, + payerId: studentUser.id, + amount: depositAmount, + status: "PAID", + provider: "MOCK", + }, + }); + + const escrow = await prisma.escrowAccount.upsert({ + where: { classId: demoClass.id }, + update: { + totalDeposited: depositAmount, + availableBalance: depositAmount, + releasedAmount: 0, + refundedAmount: 0, + }, + create: { + classId: demoClass.id, + totalDeposited: depositAmount, + availableBalance: depositAmount, + releasedAmount: 0, + refundedAmount: 0, + }, + }); + + const existingDeposit = await prisma.ledgerEntry.findFirst({ + where: { classId: demoClass.id, type: "DEPOSIT", paymentIntentId: paymentIntent.id }, + }); + if (!existingDeposit) { + await prisma.ledgerEntry.create({ + data: { + classId: demoClass.id, + paymentIntentId: paymentIntent.id, + type: "DEPOSIT", + amount: depositAmount, + }, + }); + } + + // Sample notifications + const notifications = [ + { + userId: studentUser.id, + type: "PAYMENT_DEPOSIT_SUCCESS", + title: "Đã nạp ký quỹ", + body: "Bạn đã nạp ký quỹ cho lớp Toán 10.", + dedupKey: "demo:student:deposit", + }, + { + userId: tutorUser.id, + type: "PAYMENT_DEPOSIT_TUTOR", + title: "Học viên đã nạp ký quỹ", + body: "Lớp Toán 10 đã được nạp ký quỹ.", + dedupKey: "demo:tutor:deposit", + }, + { + userId: studentUser.id, + type: "SCHEDULE_CREATED", + title: "Lịch học đã tạo", + body: "4 buổi học đã được lên lịch.", + dedupKey: "demo:student:schedule", + }, + ]; + + for (const n of notifications) { + try { + await prisma.notification.create({ data: n as any }); + } catch (err: any) { + if (err.code !== "P2002") { + throw err; + } + } + } + + console.log("Seed completed. Admin:", admin.email, "Tutor:", tutorUser.email, "Student:", studentUser.email); + console.log("Class ID:", demoClass.id, "Escrow available:", escrow.availableBalance); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/mentorme_backend/src/services/escrow.ts b/mentorme_backend/src/services/escrow.ts new file mode 100644 index 0000000..40cb469 --- /dev/null +++ b/mentorme_backend/src/services/escrow.ts @@ -0,0 +1,315 @@ +import { + Class, + LedgerType, + PaymentStatus, + PrismaClient, + Session, +} from "@prisma/client"; +import { createNotification } from "./notifications"; + +const PS = + PaymentStatus ?? { + PENDING: "PENDING", + PAID: "PAID", + FAILED: "FAILED", + REFUNDED: "REFUNDED", + }; + +const LT = + LedgerType ?? { + DEPOSIT: "DEPOSIT", + RELEASE_TO_TUTOR: "RELEASE_TO_TUTOR", + PLATFORM_FEE: "PLATFORM_FEE", + REFUND: "REFUND", + }; + +export type DepositIntentInput = { + classId: string; + payerId: string; + packageSessionsCount: number; + amountPerSession: number; + currency?: string; +}; + +export async function createDepositIntent(prisma: PrismaClient, input: DepositIntentInput) { + const totalAmount = input.packageSessionsCount * input.amountPerSession; + return prisma.paymentIntent.create({ + data: { + classId: input.classId, + payerId: input.payerId, + amount: totalAmount, + currency: input.currency || "VND", + status: PS.PENDING as any, + provider: "MOCK", + }, + }); +} + +export async function confirmPaymentIntent(prisma: PrismaClient, intentId: string) { + const intent = await prisma.paymentIntent.findUnique({ + where: { id: intentId }, + }); + if (!intent) { + throw Object.assign(new Error("Payment intent not found"), { status: 404 }); + } + + if (intent.status === PS.PAID) { + const escrow = await prisma.escrowAccount.findUnique({ where: { classId: intent.classId } }); + return { intent, escrow }; + } + + const result = await prisma.$transaction(async (tx) => { + const updatedIntent = await tx.paymentIntent.update({ + where: { id: intent.id }, + data: { status: PS.PAID as any, providerRef: "MOCK_OK" }, + }); + + const escrow = await tx.escrowAccount.upsert({ + where: { classId: intent.classId }, + update: { + totalDeposited: { increment: intent.amount }, + availableBalance: { increment: intent.amount }, + }, + create: { + classId: intent.classId, + totalDeposited: intent.amount, + availableBalance: intent.amount, + }, + }); + + await tx.ledgerEntry.create({ + data: { + classId: intent.classId, + paymentIntentId: intent.id, + type: LT.DEPOSIT as any, + amount: intent.amount, + }, + }); + + return { intent: updatedIntent, escrow }; + }); + + const classListing = await prisma.class.findUnique({ + where: { id: intent.classId }, + include: { tutor: { select: { userId: true } } }, + }); + const tutorUserId = classListing?.tutor?.userId; + const payerId = intent.payerId; + + await createNotification(prisma, { + userId: payerId, + type: "PAYMENT_DEPOSIT_SUCCESS", + title: "Nạp tiền thành công", + body: "Thanh toán giả lập đã được ghi nhận vào tài khoản ký quỹ.", + metadata: { classId: intent.classId, paymentIntentId: intent.id }, + dedupKey: `payment:${intent.id}:payer`, + }); + if (tutorUserId) { + await createNotification(prisma, { + userId: tutorUserId, + type: "PAYMENT_DEPOSIT_TUTOR", + title: "Học viên đã nạp tiền ký quỹ", + body: "Lớp của bạn đã được nạp tiền ký quỹ.", + metadata: { classId: intent.classId, paymentIntentId: intent.id }, + dedupKey: `payment:${intent.id}:tutor`, + }); + } + + return result; +} + +export async function getEscrowSummary( + prisma: PrismaClient, + classId: string, + opts: { take?: number; skip?: number } = {} +) { + const escrow = + (await prisma.escrowAccount.findUnique({ where: { classId } })) ?? + ({ + classId, + totalDeposited: 0, + availableBalance: 0, + releasedAmount: 0, + refundedAmount: 0, + } as any); + + const ledger = await prisma.ledgerEntry.findMany({ + where: { classId }, + orderBy: { createdAt: "desc" }, + skip: opts.skip ?? 0, + take: opts.take ?? 20, + }); + + const paidReleases = ledger.filter((l) => l.type === LT.RELEASE_TO_TUTOR && l.sessionId).map((l) => l.sessionId); + + return { escrow, ledger, paidReleases }; +} + +async function getPayerIds(prisma: PrismaClient, classId: string): Promise { + const intents = await prisma.paymentIntent.findMany({ + where: { classId, status: PS.PAID as any }, + select: { payerId: true }, + }); + return Array.from(new Set(intents.map((i) => i.payerId))); +} + +export type ReleaseResult = + | { released: true; tutorAmount: number; feeAmount: number } + | { released: false; reason: string }; + +export async function releaseForCompletedSession(prisma: PrismaClient, sessionId: string): Promise { + const session = await prisma.session.findUnique({ + where: { id: sessionId }, + include: { class: { include: { tutor: { select: { userId: true } } } } }, + }); + if (!session || !session.class) { + return { released: false, reason: "not_found" }; + } + if ((session as any).status && (session as any).status !== "COMPLETED") { + return { released: false, reason: "not_completed" }; + } + if (session.disputeFlaggedAt) { + return { released: false, reason: "disputed" }; + } + const sessionPrice = (session.class as Class).pricePerHour ?? 0; + if (!sessionPrice || sessionPrice <= 0) { + return { released: false, reason: "no_price" }; + } + + const payerIds = await getPayerIds(prisma, session.classId); + const tutorUserId = (session.class as any).tutor?.userId as string | undefined; + + const txnResult = await prisma.$transaction(async (tx) => { + const alreadyPaid = await tx.ledgerEntry.findFirst({ + where: { sessionId: session.id, type: LT.RELEASE_TO_TUTOR as any }, + }); + if (alreadyPaid) { + return { released: false, reason: "already_paid" } as ReleaseResult; + } + + const escrow = await tx.escrowAccount.findUnique({ where: { classId: session.classId } }); + if (!escrow || escrow.availableBalance < sessionPrice) { + return { released: false, reason: "insufficient" } as ReleaseResult; + } + + const tutorAmount = Number((sessionPrice * 0.9).toFixed(2)); + const feeAmount = Number((sessionPrice - tutorAmount).toFixed(2)); + + await tx.escrowAccount.update({ + where: { classId: session.classId }, + data: { + availableBalance: { decrement: sessionPrice }, + releasedAmount: { increment: tutorAmount }, + }, + }); + + await tx.ledgerEntry.createMany({ + data: [ + { + classId: session.classId, + sessionId: session.id, + type: LT.RELEASE_TO_TUTOR as any, + amount: tutorAmount, + }, + { + classId: session.classId, + sessionId: session.id, + type: LT.PLATFORM_FEE as any, + amount: feeAmount, + }, + ], + }); + + return { released: true, tutorAmount, feeAmount } as ReleaseResult; + }); + + if (!txnResult.released && txnResult.reason === "insufficient" && payerIds.length > 0) { + for (const pid of payerIds) { + await createNotification(prisma, { + userId: pid, + type: "ESCROW_INSUFFICIENT", + title: "Ký quỹ chưa đủ", + body: "Số dư ký quỹ không đủ để thanh toán buổi học đã hoàn thành. Vui lòng nạp thêm.", + metadata: { sessionId: session.id, classId: session.classId }, + dedupKey: `escrow:insufficient:${session.id}:${pid}`, + }); + } + } + + if (txnResult.released) { + const recipients = [...payerIds, tutorUserId].filter(Boolean) as string[]; + for (const uid of recipients) { + await createNotification(prisma, { + userId: uid, + type: "PAYMENT_RELEASED", + title: "Thanh toán đã được giải ngân", + body: "Thanh toán cho buổi học đã được chuyển từ ký quỹ.", + metadata: { sessionId: session.id, classId: session.classId }, + dedupKey: `escrow:released:${session.id}:${uid}`, + }); + } + } + + return txnResult; +} + +export async function refundEscrow(prisma: PrismaClient, classId: string) { + const payerIds = await getPayerIds(prisma, classId); + const tutorUserId = ( + await prisma.class.findUnique({ where: { id: classId }, include: { tutor: { select: { userId: true } } } }) + )?.tutor?.userId; + + const result = await prisma.$transaction(async (tx) => { + const escrow = await tx.escrowAccount.findUnique({ where: { classId } }); + const amount = escrow?.availableBalance ?? 0; + if (!escrow || amount <= 0) { + return { refunded: false, amount: 0 }; + } + await tx.escrowAccount.update({ + where: { classId }, + data: { + availableBalance: 0, + refundedAmount: { increment: amount }, + }, + }); + await tx.ledgerEntry.create({ + data: { + classId, + type: LT.REFUND as any, + amount, + }, + }); + + await tx.paymentIntent.updateMany({ + where: { classId, status: PS.PAID as any }, + data: { status: PS.REFUNDED as any }, + }); + + return { refunded: true, amount }; + }); + + if (result.refunded) { + for (const pid of payerIds) { + await createNotification(prisma, { + userId: pid, + type: "PAYMENT_REFUNDED", + title: "Hoàn tiền ký quỹ", + body: "Số dư ký quỹ của lớp đã được hoàn trả.", + metadata: { classId }, + dedupKey: `escrow:refunded:${classId}:${pid}`, + }); + } + if (tutorUserId) { + await createNotification(prisma, { + userId: tutorUserId, + type: "PAYMENT_REFUNDED_TUTOR", + title: "Lớp đã được hoàn tiền ký quỹ", + body: "Lớp học đã bị hủy và ký quỹ đã được hoàn.", + metadata: { classId }, + dedupKey: `escrow:refunded:${classId}:${tutorUserId}`, + }); + } + } + + return result; +} 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..872b245 100644 --- a/mentorme_backend/tests/api/bookingAdmin.test.ts +++ b/mentorme_backend/tests/api/bookingAdmin.test.ts @@ -14,14 +14,77 @@ describe("Booking and admin routes", () => { jest.clearAllMocks(); }); + test("student cannot book the same class twice", async () => { + mockPrisma.studentProfile.findUnique.mockResolvedValue({ id: "student-1", userId: "user-student" }); + const classId = "22222222-2222-4222-8222-222222222222"; + mockPrisma.class.findUnique.mockResolvedValue({ + id: classId, + isDeleted: false, + status: "PUBLISHED", + tutorId: "tutor-1", + } as any); + mockPrisma.booking.findFirst.mockResolvedValueOnce(null as any); + mockPrisma.booking.create.mockResolvedValue({ id: "bk-1" } as any); + + const first = await request(app) + .post("/api/bookings") + .set("Authorization", `Bearer ${studentToken}`) + .send({ + classId, + isTrial: false, + requestedHoursPerWeek: 2, + startDateExpected: new Date().toISOString(), + }); + expect(first.status).toBe(201); + + mockPrisma.booking.findFirst.mockResolvedValueOnce({ + id: "bk-1", + status: BookingStatus.CONFIRMED, + } as any); + + const second = await request(app) + .post("/api/bookings") + .set("Authorization", `Bearer ${studentToken}`) + .send({ + classId, + isTrial: false, + requestedHoursPerWeek: 2, + startDateExpected: new Date().toISOString(), + }); + expect(second.status).toBe(409); + }); + + test("GET my booking returns status", async () => { + mockPrisma.studentProfile.findUnique.mockResolvedValue({ id: "student-1", userId: "user-student" }); + mockPrisma.booking.findFirst.mockResolvedValue({ + id: "bk-2", + status: BookingStatus.PENDING, + } as any); + + const res = await request(app) + .get("/api/bookings/me?classId=class-1") + .set("Authorization", `Bearer ${studentToken}`); + + expect(res.status).toBe(200); + expect(res.body.bookingExists).toBe(true); + expect(res.body.bookingStatus).toBe(BookingStatus.PENDING); + }); + 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 +95,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 +134,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/payments.test.ts b/mentorme_backend/tests/api/payments.test.ts new file mode 100644 index 0000000..24482d7 --- /dev/null +++ b/mentorme_backend/tests/api/payments.test.ts @@ -0,0 +1,140 @@ +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"; +import { releaseForCompletedSession, refundEscrow } from "../../src/services/escrow"; + +const studentToken = signToken({ userId: "user-student", role: UserRole.STUDENT }); +const tutorToken = signToken({ userId: "user-tutor", role: UserRole.TUTOR }); + +describe("Escrow payments", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("create and confirm deposit intent updates escrow and ledger", async () => { + mockPrisma.studentProfile.findUnique.mockResolvedValue({ id: "stu1", userId: "user-student" } as any); + mockPrisma.class.findUnique.mockResolvedValue({ + id: "class-1", + tutorId: "tutor-1", + pricePerHour: 100000, + isDeleted: false, + tutor: { userId: "user-tutor" }, + } as any); + mockPrisma.classSchedule.findUnique.mockResolvedValue({ id: "sched1", classId: "class-1" } as any); + mockPrisma.booking.findFirst.mockResolvedValue({ id: "bk1", studentId: "stu1", status: "CONFIRMED" } as any); + mockPrisma.paymentIntent.create.mockResolvedValue({ + id: "pi1", + classId: "class-1", + payerId: "user-student", + amount: 400000, + status: "PENDING", + } as any); + + const createRes = await request(app) + .post("/api/classes/class-1/payments/intents") + .set("Authorization", `Bearer ${studentToken}`) + .send({ packageSessionsCount: 4, amountPerSession: 100000 }); + + expect(createRes.status).toBe(201); + expect(mockPrisma.paymentIntent.create).toHaveBeenCalled(); + + mockPrisma.paymentIntent.findUnique.mockResolvedValue({ + id: "pi1", + classId: "class-1", + payerId: "user-student", + amount: 400000, + status: "PENDING", + } as any); + mockPrisma.paymentIntent.update.mockResolvedValue({ + id: "pi1", + classId: "class-1", + payerId: "user-student", + amount: 400000, + status: "PAID", + } as any); + mockPrisma.escrowAccount.upsert.mockResolvedValue({ + id: "esc1", + classId: "class-1", + totalDeposited: 400000, + availableBalance: 400000, + releasedAmount: 0, + refundedAmount: 0, + } as any); + mockPrisma.ledgerEntry.create.mockResolvedValue({ id: "led1" } as any); + mockPrisma.notification.create.mockResolvedValue({ id: "noti1" } as any); + + const confirmRes = await request(app) + .post("/api/payments/pi1/confirm") + .set("Authorization", `Bearer ${studentToken}`); + + expect(confirmRes.status).toBe(200); + expect(mockPrisma.ledgerEntry.create).toHaveBeenCalled(); + expect(mockPrisma.notification.create).toHaveBeenCalled(); + }); + + test("release for completed session uses escrow balance and creates ledger", async () => { + mockPrisma.session.findUnique.mockResolvedValue({ + id: "sess-1", + classId: "class-1", + disputeFlaggedAt: null, + class: { id: "class-1", pricePerHour: 100000, tutor: { userId: "user-tutor" } }, + } as any); + mockPrisma.paymentIntent.findMany.mockResolvedValue([{ payerId: "user-student" }] as any); + mockPrisma.ledgerEntry.findFirst.mockResolvedValue(null as any); + mockPrisma.escrowAccount.findUnique.mockResolvedValue({ + classId: "class-1", + availableBalance: 200000, + } as any); + mockPrisma.escrowAccount.update.mockResolvedValue({} as any); + mockPrisma.ledgerEntry.createMany.mockResolvedValue({} as any); + mockPrisma.notification.create.mockResolvedValue({ id: "n1" } as any); + + const result = await releaseForCompletedSession(mockPrisma as any, "sess-1"); + expect(result.released).toBe(true); + expect(mockPrisma.ledgerEntry.createMany).toHaveBeenCalled(); + }); + + test("insufficient escrow triggers notification and no release", async () => { + mockPrisma.session.findUnique.mockResolvedValue({ + id: "sess-2", + classId: "class-1", + disputeFlaggedAt: null, + class: { id: "class-1", pricePerHour: 100000, tutor: { userId: "user-tutor" } }, + } as any); + mockPrisma.paymentIntent.findMany.mockResolvedValue([{ payerId: "user-student" }] as any); + mockPrisma.ledgerEntry.findFirst.mockResolvedValue(null as any); + mockPrisma.escrowAccount.findUnique.mockResolvedValue({ + classId: "class-1", + availableBalance: 50000, + } as any); + mockPrisma.notification.create.mockResolvedValue({ id: "n2" } as any); + + const result = await releaseForCompletedSession(mockPrisma as any, "sess-2"); + expect(result.released).toBe(false); + expect((result as any).reason).toBe("insufficient"); + expect(mockPrisma.notification.create).toHaveBeenCalled(); + }); + + test("refundEscrow returns remaining balance and creates ledger", async () => { + mockPrisma.paymentIntent.findMany.mockResolvedValue([{ payerId: "user-student" }] as any); + mockPrisma.class.findUnique.mockResolvedValue({ + id: "class-1", + tutor: { userId: "user-tutor" }, + } as any); + mockPrisma.escrowAccount.findUnique.mockResolvedValue({ + classId: "class-1", + availableBalance: 120000, + } as any); + mockPrisma.escrowAccount.update.mockResolvedValue({} as any); + mockPrisma.ledgerEntry.create.mockResolvedValue({ id: "led-refund" } as any); + mockPrisma.paymentIntent.updateMany.mockResolvedValue({} as any); + mockPrisma.notification.create.mockResolvedValue({ id: "noti-refund" } as any); + + const result = await refundEscrow(mockPrisma as any, "class-1"); + expect(result.refunded).toBe(true); + expect(mockPrisma.ledgerEntry.create).toHaveBeenCalled(); + expect(mockPrisma.notification.create).toHaveBeenCalled(); + }); +}); 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..d0264b5 --- /dev/null +++ b/mentorme_backend/tests/api/scheduling.test.ts @@ -0,0 +1,332 @@ +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 }], + }, + }; + const baseClassRecord = { ...baseClass }; + + test("schedule creation succeeds without conflicts", async () => { + mockPrisma.class.findUnique.mockResolvedValue({ ...baseClassRecord }); + mockPrisma.class.update.mockResolvedValue({ ...baseClassRecord, 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); + }); + + test("recurrence payload with string numbers is accepted", async () => { + mockPrisma.class.findUnique.mockResolvedValue({ ...baseClassRecord }); + mockPrisma.class.update.mockResolvedValue({ ...baseClassRecord } as any); + mockPrisma.tutorProfile.findUnique.mockResolvedValue({ + id: "tutor-1", + userId: "user-tutor", + verificationStatus: "VERIFIED", + }); + mockPrisma.booking.findMany.mockResolvedValue([]); + mockPrisma.session.findMany + .mockResolvedValueOnce([]) // tutor conflicts + .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) // next call (student conflicts or transaction) + .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", + }, + ] 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({ + timezone: "UTC", + recurrence: { + startDate: "2025-01-06T00:00:00.000Z", + weeks: "2", + slots: [{ dayOfWeek: "1", startMinute: "600", endMinute: "660" }], + }, + }); + + expect(res.status).toBe(200); + expect(res.body.sessions.length).toBeGreaterThan(0); + }); + + test("recurrence respects timezone (Asia/Ho_Chi_Minh 09:00-10:00 => 02:00-03:00 UTC)", async () => { + mockPrisma.class.findUnique.mockResolvedValue({ ...baseClassRecord }); + mockPrisma.class.update.mockResolvedValue({ ...baseClassRecord } as any); + mockPrisma.tutorProfile.findUnique.mockResolvedValue({ + id: "tutor-1", + userId: "user-tutor", + verificationStatus: "VERIFIED", + }); + mockPrisma.booking.findMany.mockResolvedValue([]); + mockPrisma.session.findMany + .mockResolvedValueOnce([]) // tutor conflicts + .mockResolvedValueOnce([]) // student conflicts + .mockResolvedValueOnce([ + { + id: "sess-1", + classId: "class-1", + scheduledStartAt: new Date("2025-01-06T02:00:00.000Z"), + scheduledEndAt: new Date("2025-01-06T03:00:00.000Z"), + status: "SCHEDULED", + }, + ] as any); + mockPrisma.tutorUnavailability.findMany.mockResolvedValue([]); + mockPrisma.classSchedule.upsert.mockResolvedValue({ + id: "sched-1", + classId: "class-1", + totalSessions: 1, + timezone: "Asia/Ho_Chi_Minh", + } 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({ + timezone: "Asia/Ho_Chi_Minh", + recurrence: { + startDate: "2025-01-06", + weeks: 1, + slots: [{ dayOfWeek: 1, startMinute: 540, endMinute: 600 }], + }, + }); + + expect(res.status).toBe(200); + const created = (mockPrisma.session.createMany.mock.calls[0]?.[0]?.data?.[0] || {}) as any; + expect(new Date(created.scheduledStartAt).toISOString()).toBe("2025-01-06T02:00:00.000Z"); + expect(new Date(created.scheduledEndAt).toISOString()).toBe("2025-01-06T03:00:00.000Z"); + }); + + test("invalid recurrence returns clear message", async () => { + mockPrisma.class.findUnique.mockResolvedValue({ ...baseClassRecord }); + mockPrisma.tutorProfile.findUnique.mockResolvedValue({ + id: "tutor-1", + userId: "user-tutor", + verificationStatus: "VERIFIED", + }); + mockPrisma.booking.findMany.mockResolvedValue([]); + mockPrisma.session.findMany.mockResolvedValue([]); + mockPrisma.tutorUnavailability.findMany.mockResolvedValue([]); + + const res = await request(app) + .post("/api/classes/class-1/schedule") + .set("Authorization", `Bearer ${tutorToken}`) + .send({ + timezone: "", + recurrence: { + startDate: "invalid-date", + weeks: 0, + slots: [{ dayOfWeek: 9, startMinute: 600, endMinute: 500 }], + }, + }); + + expect(res.status).toBe(400); + expect(res.body.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: expect.stringContaining("recurrence") }), + ]) + ); + }); +}); 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..16ac36f 100644 --- a/mentorme_backend/tests/utils/mockPrisma.ts +++ b/mentorme_backend/tests/utils/mockPrisma.ts @@ -10,6 +10,15 @@ type PrismaMock = { booking: ModelMock; review: ModelMock; subject: ModelMock; + tutorAvailability: ModelMock; + tutorUnavailability: ModelMock; + classSchedule: ModelMock; + session: ModelMock; + notification: ModelMock; + reminderLog: ModelMock; + paymentIntent: ModelMock; + escrowAccount: ModelMock; + ledgerEntry: ModelMock; $transaction: jest.Mock; prisma?: never; }; @@ -19,9 +28,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 +45,15 @@ export const mockPrisma: PrismaMock = { booking: createModelMock(), review: createModelMock(), subject: createModelMock(), + tutorAvailability: createModelMock(), + tutorUnavailability: createModelMock(), + classSchedule: createModelMock(), + session: createModelMock(), + notification: createModelMock(), + reminderLog: createModelMock(), + paymentIntent: createModelMock(), + escrowAccount: createModelMock(), + ledgerEntry: createModelMock(), $transaction: jest.fn(), }; @@ -48,5 +68,14 @@ 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); + resetModel(mockPrisma.paymentIntent); + resetModel(mockPrisma.escrowAccount); + resetModel(mockPrisma.ledgerEntry); mockPrisma.$transaction.mockReset(); }; diff --git a/mentorme_frontend/app/classes/[id]/book/page.tsx b/mentorme_frontend/app/classes/[id]/book/page.tsx index 2cf08cc..c5e2ab2 100644 --- a/mentorme_frontend/app/classes/[id]/book/page.tsx +++ b/mentorme_frontend/app/classes/[id]/book/page.tsx @@ -61,12 +61,13 @@ const themeStyles: Record> = { select: 'w-full px-4 py-2 bg-slate-900 border border-slate-700 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-slate-600 transition-all', heading: 'text-3xl font-bold text-white mb-6', - error: 'bg-red-500/10 border border-red-500/40 text-red-200', - btnPrimary: + error: 'bg-red-500/10 border border-red-500/40 text-red-200', + btnPrimary: 'flex-1 bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 disabled:opacity-50 text-white font-semibold py-2 px-4 rounded-lg transition shadow-lg shadow-purple-500/30', - btnGhost: + btnGhost: 'flex-1 bg-white/10 hover:bg-white/20 border border-white/20 text-white font-semibold py-2 px-4 rounded-lg transition', - text: 'text-white', + text: 'text-white', + muted: 'text-white/70', }, light: { page: 'bg-slate-50', @@ -78,12 +79,13 @@ const themeStyles: Record> = { select: 'w-full px-4 py-2 bg-white border border-slate-300 rounded-lg text-slate-900 focus:outline-none focus:ring-2 focus:ring-purple-400 focus:border-purple-200 transition-all', heading: 'text-3xl font-bold text-slate-900 mb-6', - error: 'bg-red-50 border border-red-200 text-red-700', - btnPrimary: + error: 'bg-red-50 border border-red-200 text-red-700', + btnPrimary: 'flex-1 bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600 disabled:opacity-50 text-white font-semibold py-2 px-4 rounded-lg transition shadow-sm', - btnGhost: + btnGhost: 'flex-1 bg-slate-100 hover:bg-slate-200 border border-slate-200 text-slate-800 font-semibold py-2 px-4 rounded-lg transition', - text: 'text-slate-900', + text: 'text-slate-900', + muted: 'text-slate-600', }, }; @@ -97,10 +99,12 @@ export default function BookClassPage() { const classId = params.id as string; const { data: classData, isLoading: classLoading } = useSWR(`/api/classes/${classId}`, apiClient.get); + const { data: bookingCheck } = useSWR(user ? `/api/bookings/me?classId=${classId}` : null, apiClient.get); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(''); const [issues, setIssues] = useState([]); + const [formData, setFormData] = useState({ isTrial: false, requestedHoursPerWeek: 2, @@ -170,11 +174,11 @@ export default function BookClassPage() { let slotNote = ''; if (parsedSlots.length > 0) { const label = language === 'vi' ? 'Khung giờ mong muốn' : 'Preferred slots'; - const lines = parsedSlots.map((slot) => { - const dayLabel = (language === 'vi' ? dayOptions.find((d) => d.value === slot.dayOfWeek)?.vi : dayOptions.find((d) => d.value === slot.dayOfWeek)?.en) || `Day ${slot.dayOfWeek}`; - return `- ${dayLabel}: ${slot.startTime} - ${slot.endTime}`; - }); - slotNote = `\n${label}:\n${lines.join('\n')}`; + const lines = parsedSlots.map((slot) => { + const dayLabel = (language === 'vi' ? dayOptions.find((d) => d.value === slot.dayOfWeek)?.vi : dayOptions.find((d) => d.value === slot.dayOfWeek)?.en) || `Day ${slot.dayOfWeek}`; + return `- ${dayLabel}: ${slot.startTime} - ${slot.endTime}`; + }); + slotNote = `\n${label}:\n${lines.join('\n')}`; } await apiClient.post('/api/bookings', { @@ -184,7 +188,7 @@ export default function BookClassPage() { startDateExpected: formData.startDateExpected, noteFromStudent: `${formData.noteFromStudent || ''}${slotNote}`, }); - router.push('/dashboard/student/bookings'); + router.push('/dashboard/student/my-classes'); } catch (err: any) { if (err instanceof ApiError) { const issueMessages = @@ -193,7 +197,11 @@ export default function BookClassPage() { return path ? `${path}: ${i.message}` : i.message; }) || []; setIssues(issueMessages); - setError(err.message || 'Failed to create booking'); + if ((err as any).status === 409 || err.message?.includes('đặt lớp này')) { + setError(language === 'vi' ? 'Bạn đã đặt lớp này, không thể đặt lại.' : 'You already booked this class.'); + } else { + setError(err.message || 'Failed to create booking'); + } } else { setError(err.message || 'Failed to create booking'); } @@ -212,6 +220,27 @@ export default function BookClassPage() {

{t.title}

+ {bookingCheck?.bookingExists && ( +
+

+ {language === 'vi' ? 'Bạn đã đặt lớp này.' : 'You already booked this class.'} +

+
+ + {language === 'vi' ? 'Lớp của tôi' : 'My classes'} + + + {language === 'vi' ? 'Thanh toán' : 'Payment'} + +
+
+ )} {classLoading ? (
@@ -384,8 +413,8 @@ export default function BookClassPage() { />
-
- + ))} +
+ ) : ( +

{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} + + )} +
+ ))} +
+ ) : ( +

+ )} +
+ + {selected.verificationStatus === 'PENDING' && ( + <> +
+ +