From f095a4cb8755979074cfa6fba105a72784f2e941 Mon Sep 17 00:00:00 2001 From: Jayant Rana Date: Thu, 30 Jul 2026 17:11:11 +0530 Subject: [PATCH] Complete platform hardening and learning workflows --- Backend/config.js | 2 + Backend/controllers/index.js | 53 ++ Backend/jobs/maintenance.js | 12 +- Backend/jobs/queue.js | 37 +- Backend/models/index.js | 94 +++- Backend/package.json | 1 + Backend/routes/courses.js | 6 + Backend/routes/enrollments.js | 10 +- Backend/routes/orders.js | 4 + Backend/scripts/verifyLemonSqueezyConfig.js | 39 ++ Backend/services/index.js | 499 +++++++++++++++++- Backend/services/upload.js | 25 + Backend/utils/email.js | 13 +- Backend/utils/emailTemplates.js | 14 +- Backend/utils/lemonSqueezy.js | 22 + Backend/utils/localVideoUpload.js | 18 +- Backend/utils/metrics.js | 11 +- Backend/validators/index.js | 20 +- Frontend/src/hooks/useOrders.js | 35 +- Frontend/src/pages/admin/OrderManagement.jsx | 52 +- .../src/pages/instructor/CourseEditor.jsx | 87 ++- Frontend/src/pages/instructor/MyCourses.jsx | 21 + Frontend/src/pages/public/PaymentResult.jsx | 19 +- Frontend/src/pages/student/Dashboard.jsx | 4 +- Frontend/src/pages/student/LessonPlayer.jsx | 77 ++- Frontend/src/pages/student/OrderHistory.jsx | 7 +- Frontend/src/services/api.js | 69 +++ 27 files changed, 1186 insertions(+), 65 deletions(-) create mode 100644 Backend/scripts/verifyLemonSqueezyConfig.js diff --git a/Backend/config.js b/Backend/config.js index 316fcf4..a8becc3 100644 --- a/Backend/config.js +++ b/Backend/config.js @@ -39,6 +39,7 @@ const config = { lemonSqueezyWebhookSecret: process.env.LEMONSQUEEZY_WEBHOOK_SECRET || "", lemonSqueezyAmountToleranceMinor: Number(process.env.LEMONSQUEEZY_AMOUNT_TOLERANCE_MINOR || 100), lemonSqueezyCheckoutTtlMinutes: Math.max(1, Number(process.env.LEMONSQUEEZY_CHECKOUT_TTL_MINUTES || 5)), + courseApprovalRequired: process.env.COURSE_APPROVAL_REQUIRED === "true", couponCleanupIntervalMs: Number(process.env.COUPON_CLEANUP_INTERVAL_MS || 60000), redisUrl: process.env.REDIS_URL || "", cacheTtlSeconds: Number(process.env.CACHE_TTL_SECONDS || 30), @@ -52,6 +53,7 @@ const config = { s3MultipartPartBytes: Math.max(5, Number(process.env.S3_MULTIPART_PART_MB || 8)) * 1024 * 1024, s3PresignExpiresSeconds: Math.min(3600, Math.max(60, Number(process.env.S3_PRESIGN_EXPIRES_SECONDS || 900))), s3MultipartSessionTtlHours: Math.max(1, Number(process.env.S3_MULTIPART_SESSION_TTL_HOURS || 24)), + s3MaxUploadBytes: Math.max(1, Number(process.env.S3_MAX_UPLOAD_MB || 5000)) * 1024 * 1024, }; if (!["s3", "local"].includes(config.mediaStorageProvider)) { diff --git a/Backend/controllers/index.js b/Backend/controllers/index.js index e33fa06..e77b86d 100644 --- a/Backend/controllers/index.js +++ b/Backend/controllers/index.js @@ -93,6 +93,16 @@ const courseController = { await recordAudit({ actor: req.user, action: "course.deleted", resourceType: "course", resourceId: req.params.id, request: req }); send(res, data); }), + restore: asyncHandler(async (req, res) => { + const data = await courseService.restore(req.user, req.params.id); + await recordAudit({ actor: req.user, action: "course.restored", resourceType: "course", resourceId: req.params.id, request: req }); + send(res, data); + }), + duplicate: asyncHandler(async (req, res) => { + const data = await courseService.duplicate(req.user, req.params.id); + await recordAudit({ actor: req.user, action: "course.duplicated", resourceType: "course", resourceId: data?._id, metadata: { sourceCourseId: req.params.id }, request: req }); + send(res, data, 201); + }), publish: asyncHandler(async (req, res) => { const data = await courseService.publish(req.user, req.params.id, req.body.isPublished); await recordAudit({ actor: req.user, action: "course.publish_changed", resourceType: "course", resourceId: req.params.id, metadata: { isPublished: req.body.isPublished }, request: req }); @@ -106,6 +116,18 @@ const courseController = { const data = await courseService.instructorCourses(req.user.id, req.query); send(res, data); }), + requestReview: asyncHandler(async (req, res) => { + const data = await courseService.requestReview(req.user, req.params.id); + send(res, data); + }), + review: asyncHandler(async (req, res) => { + const data = await courseService.review(req.user, req.params.id, req.body.status, req.body.reason); + await recordAudit({ actor: req.user, action: "course.reviewed", resourceType: "course", resourceId: req.params.id, metadata: { status: req.body.status }, request: req }); + send(res, data); + }), + versions: asyncHandler(async (req, res) => { + send(res, await courseService.versions(req.user, req.params.id)); + }), instructorDetails: asyncHandler(async (req, res) => { const data = await courseService.instructorCourseDetails(req.user, req.params.id); send(res, data); @@ -159,6 +181,14 @@ const enrollmentController = { const data = await enrollmentService.updateProgress(req.user, req.body); send(res, data); }), + bookmark: asyncHandler(async (req, res) => send(res, await enrollmentService.toggleBookmark(req.user, req.body))), + bookmarks: asyncHandler(async (req, res) => send(res, await enrollmentService.listBookmarks(req.user, req.query.courseId))), + saveNote: asyncHandler(async (req, res) => send(res, await enrollmentService.saveLessonNote(req.user, req.body))), + notes: asyncHandler(async (req, res) => send(res, await enrollmentService.listLessonNotes(req.user, req.query.courseId))), + quiz: asyncHandler(async (req, res) => send(res, await enrollmentService.submitQuiz(req.user, req.body))), + assignment: asyncHandler(async (req, res) => send(res, await enrollmentService.submitAssignment(req.user, req.body), 201)), + certificate: asyncHandler(async (req, res) => send(res, await enrollmentService.issueCertificate(req.user, req.params.courseId), 201)), + certificates: asyncHandler(async (req, res) => send(res, await enrollmentService.myCertificates(req.user.id))), courseProgress: asyncHandler(async (req, res) => { const data = await enrollmentService.getCourseProgress(req.user, req.params.id); send(res, data); @@ -186,6 +216,10 @@ const orderController = { const data = await orderService.getMyOrder(req.user.id, req.params.id); send(res, data); }), + cancel: asyncHandler(async (req, res) => { + const data = await orderService.cancel(req.user.id, req.params.id); + send(res, data); + }), list: asyncHandler(async (req, res) => { const data = await orderService.listOrders(req.query); send(res, data); @@ -206,6 +240,25 @@ const orderController = { const data = await orderService.replayWebhookDelivery(req.user, req.params.id, req); send(res, data); }), + reconciliation: asyncHandler(async (req, res) => { + const data = await orderService.listReconciliationCases(req.query.limit); + send(res, data); + }), + retryReconciliation: asyncHandler(async (req, res) => { + const data = await orderService.retryReconciliation(req.user, req.params.id, req); + send(res, data); + }), + resolveDispute: asyncHandler(async (req, res) => { + const data = await orderService.resolveDispute(req.user, req.params.id, req); + send(res, data); + }), + bookmark: asyncHandler(async (req, res) => send(res, await enrollmentService.toggleBookmark(req.user, req.body))), + bookmarks: asyncHandler(async (req, res) => send(res, await enrollmentService.listBookmarks(req.user, req.query.courseId))), + saveNote: asyncHandler(async (req, res) => send(res, await enrollmentService.saveLessonNote(req.user, req.body))), + notes: asyncHandler(async (req, res) => send(res, await enrollmentService.listLessonNotes(req.user, req.query.courseId))), + quiz: asyncHandler(async (req, res) => send(res, await enrollmentService.submitQuiz(req.user, req.body))), + certificate: asyncHandler(async (req, res) => send(res, await enrollmentService.issueCertificate(req.user, req.params.courseId), 201)), + certificates: asyncHandler(async (req, res) => send(res, await enrollmentService.myCertificates(req.user.id))), }; const reviewController = { diff --git a/Backend/jobs/maintenance.js b/Backend/jobs/maintenance.js index cde2099..ebcdf2d 100644 --- a/Backend/jobs/maintenance.js +++ b/Backend/jobs/maintenance.js @@ -1,6 +1,7 @@ const config = require("../config"); const { log } = require("../utils/logger"); -const { expirePendingOrders, releaseExpiredCouponReservations } = require("../services"); +const { expirePendingOrders, releaseExpiredCouponReservations, reconcilePaidOrders } = require("../services"); +const uploadService = require("../services/upload"); let timer = null; let running = false; @@ -9,12 +10,15 @@ const runCouponCleanup = async () => { if (running) return; running = true; try { - const [released, expired] = await Promise.all([ + const [released, expired, reconciled, mediaCleaned, staleLocalUploads] = await Promise.all([ releaseExpiredCouponReservations(), expirePendingOrders(), + reconcilePaidOrders(), + config.mediaStorageProvider === "s3" ? uploadService.cleanupExpiredS3MultipartUploads() : Promise.resolve(0), + uploadService.cleanupStaleLocalUploads(), ]); - if (released > 0 || expired > 0) { - log("info", "payment.cleanup_completed", { releasedCouponReservations: released, expiredOrders: expired }); + if (released > 0 || expired > 0 || reconciled.failed > 0 || mediaCleaned > 0 || staleLocalUploads > 0) { + log("info", "maintenance.cleanup_completed", { releasedCouponReservations: released, expiredOrders: expired, reconciliation: reconciled, mediaCleaned, staleLocalUploads }); } } catch (error) { log("error", "coupon.cleanup_failed", { diff --git a/Backend/jobs/queue.js b/Backend/jobs/queue.js index cacddda..39a59f7 100644 --- a/Backend/jobs/queue.js +++ b/Backend/jobs/queue.js @@ -2,7 +2,7 @@ const { Queue, Worker } = require("bullmq"); const Redis = require("ioredis"); const config = require("../config"); const { log } = require("../utils/logger"); -const { releaseExpiredCouponReservations } = require("../services"); +const { releaseExpiredCouponReservations, reconcilePaidOrders, orderService } = require("../services"); const QUEUE_NAME = "coursify-maintenance"; let queue; @@ -17,9 +17,18 @@ const startQueueWorkers = async () => { worker = new Worker( QUEUE_NAME, async (job) => { - if (job.name !== "coupon-cleanup") return; - const released = await releaseExpiredCouponReservations(); - if (released > 0) log("info", "coupon.cleanup_completed", { released, source: "queue" }); + if (job.name === "coupon-cleanup") { + const released = await releaseExpiredCouponReservations(); + if (released > 0) log("info", "coupon.cleanup_completed", { released, source: "queue" }); + return; + } + if (job.name === "payment-reconciliation") { + await reconcilePaidOrders(); + return; + } + if (job.name === "payment-retry") { + await orderService.retryReconciliation(null, job.data.reconciliationId, {}); + } }, { connection } ); @@ -38,10 +47,28 @@ const startQueueWorkers = async () => { removeOnComplete: 100, removeOnFail: 100, }); + await queue.add("payment-reconciliation", {}, { + jobId: "payment-reconciliation", + repeat: { every: Math.max(Number(config.couponCleanupIntervalMs) || 60000, 10000) }, + removeOnComplete: 100, + removeOnFail: 100, + }); log("info", "queue.started", { queue: QUEUE_NAME }); return true; }; +const enqueuePaymentRetry = async ({ reconciliationId, delayMs = 30000 }) => { + if (!queue || !reconciliationId) return false; + await queue.add("payment-retry", { reconciliationId: String(reconciliationId) }, { + jobId: `payment-retry:${String(reconciliationId)}`, + attempts: 5, + backoff: { type: "exponential", delay: Math.max(1000, Number(delayMs) || 30000) }, + removeOnComplete: 100, + removeOnFail: 100, + }); + return true; +}; + const stopQueueWorkers = async () => { await worker?.close(); await queue?.close(); @@ -51,4 +78,4 @@ const stopQueueWorkers = async () => { connection = null; }; -module.exports = { startQueueWorkers, stopQueueWorkers }; +module.exports = { startQueueWorkers, stopQueueWorkers, enqueuePaymentRetry }; diff --git a/Backend/models/index.js b/Backend/models/index.js index f7d76de..d0414d1 100644 --- a/Backend/models/index.js +++ b/Backend/models/index.js @@ -59,6 +59,13 @@ const courseSchema = new Schema( isPublished: { type: Boolean, default: false }, isFeatured: { type: Boolean, default: false }, isArchived: { type: Boolean, default: false, index: true }, + workflowStatus: { type: String, enum: ["draft", "pending_review", "approved", "rejected"], default: "draft", index: true }, + rejectionReason: { type: String, default: "" }, + seoTitle: { type: String, default: "", maxlength: 180 }, + seoDescription: { type: String, default: "", maxlength: 320 }, + archivedAt: { type: Date, default: null }, + archivedBy: { type: ObjectId, default: null }, + currentVersion: { type: Number, default: 1, min: 1 }, ratingAvg: { type: Number, default: 0 }, ratingCount: { type: Number, default: 0 }, enrollmentCount: { type: Number, default: 0 }, @@ -80,6 +87,19 @@ const courseSectionSchema = new Schema( courseSectionSchema.index({ courseId: 1, order: 1 }, { unique: true }); +const courseVersionSchema = new Schema( + { + courseId: { type: ObjectId, required: true, index: true }, + version: { type: Number, required: true, min: 1 }, + createdBy: { type: ObjectId, required: true }, + changeNote: { type: String, default: "" }, + snapshot: { type: Schema.Types.Mixed, required: true }, + }, + { timestamps: true } +); + +courseVersionSchema.index({ courseId: 1, version: 1 }, { unique: true }); + const lessonSchema = new Schema( { courseId: { type: ObjectId, required: true, index: true }, @@ -87,13 +107,14 @@ const lessonSchema = new Schema( title: { type: String, required: true, trim: true }, type: { type: String, - enum: ["video", "text", "pdf", "quiz"], + enum: ["video", "text", "pdf", "quiz", "assignment"], required: true, }, content: { type: String, default: "" }, videoUrl: { type: String, default: "" }, fileUrl: { type: String, default: "" }, fileKey: { type: String, default: "" }, + thumbnailUrl: { type: String, default: "" }, duration: { type: Number, default: 0 }, isPreview: { type: Boolean, default: false }, order: { type: Number, required: true }, @@ -110,7 +131,7 @@ const enrollmentSchema = new Schema( courseId: { type: ObjectId, required: true, index: true }, status: { type: String, - enum: ["active", "completed", "refunded"], + enum: ["active", "completed", "refunded", "suspended"], default: "active", }, progressPercent: { type: Number, default: 0 }, @@ -129,7 +150,7 @@ const orderSchema = new Schema( currency: { type: String, default: "INR" }, status: { type: String, - enum: ["pending", "paid", "failed", "refunded"], + enum: ["pending", "paid", "failed", "refunded", "cancelled", "refund_pending", "disputed"], default: "pending", index: true, }, @@ -142,6 +163,13 @@ const orderSchema = new Schema( couponReservationExpiresAt: { type: Date, default: null }, couponReservationReleased: { type: Boolean, default: false }, couponRedeemedAt: { type: Date, default: null }, + failureReason: { type: String, default: "" }, + providerEventName: { type: String, default: "" }, + providerRefundId: { type: String, default: "" }, + refundStatus: { type: String, enum: ["none", "pending", "completed", "failed"], default: "none" }, + refundedAt: { type: Date, default: null }, + disputeStatus: { type: String, enum: ["none", "open", "resolved"], default: "none" }, + reconciliationStatus: { type: String, enum: ["not_required", "pending", "resolved", "failed"], default: "not_required" }, }, { timestamps: true } ); @@ -232,6 +260,29 @@ const courseProgressSchema = new Schema( courseProgressSchema.index({ userId: 1, courseId: 1, lessonId: 1 }, { unique: true }); +const learningBookmarkSchema = new Schema({ userId: { type: ObjectId, required: true, index: true }, courseId: { type: ObjectId, required: true, index: true }, lessonId: { type: ObjectId, required: true, index: true } }, { timestamps: true }); +learningBookmarkSchema.index({ userId: 1, courseId: 1, lessonId: 1 }, { unique: true }); + +const lessonNoteSchema = new Schema({ userId: { type: ObjectId, required: true, index: true }, courseId: { type: ObjectId, required: true, index: true }, lessonId: { type: ObjectId, required: true, index: true }, content: { type: String, required: true, maxlength: 10000 } }, { timestamps: true }); +lessonNoteSchema.index({ userId: 1, courseId: 1, lessonId: 1 }, { unique: true }); + +const quizAttemptSchema = new Schema({ userId: { type: ObjectId, required: true, index: true }, courseId: { type: ObjectId, required: true, index: true }, lessonId: { type: ObjectId, required: true, index: true }, score: { type: Number, min: 0, max: 100 }, passed: { type: Boolean, default: false }, answers: { type: Schema.Types.Mixed, default: {} } }, { timestamps: true }); +quizAttemptSchema.index({ userId: 1, lessonId: 1, createdAt: -1 }); + +const certificateSchema = new Schema({ userId: { type: ObjectId, required: true, index: true }, courseId: { type: ObjectId, required: true, index: true }, certificateNumber: { type: String, required: true, unique: true, index: true }, issuedAt: { type: Date, default: Date.now } }, { timestamps: true }); +certificateSchema.index({ userId: 1, courseId: 1 }, { unique: true }); + +const assignmentSubmissionSchema = new Schema({ + userId: { type: ObjectId, required: true, index: true }, + courseId: { type: ObjectId, required: true, index: true }, + lessonId: { type: ObjectId, required: true, index: true }, + content: { type: String, required: true, maxlength: 20000 }, + status: { type: String, enum: ["submitted", "reviewed", "returned"], default: "submitted", index: true }, + grade: { type: Number, min: 0, max: 100, default: null }, + feedback: { type: String, default: "", maxlength: 5000 }, +}, { timestamps: true }); +assignmentSubmissionSchema.index({ userId: 1, lessonId: 1 }, { unique: true }); + const importedPlaylistSchema = new Schema( { userId: { type: ObjectId, required: true, index: true }, @@ -298,6 +349,9 @@ const notificationSchema = new Schema( title: { type: String, required: true, trim: true }, message: { type: String, required: true }, read: { type: Boolean, default: false }, + dedupeKey: { type: String, default: "" }, + relatedOrderId: { type: ObjectId, default: null, index: true }, + metadata: { type: Schema.Types.Mixed, default: {} }, }, { timestamps: true } ); @@ -351,6 +405,22 @@ const webhookDeliverySchema = new Schema( webhookDeliverySchema.index({ createdAt: 1 }, { expireAfterSeconds: 90 * 24 * 60 * 60 }); +const paymentReconciliationSchema = new Schema( + { + orderId: { type: ObjectId, required: true, unique: true, index: true }, + userId: { type: ObjectId, required: true, index: true }, + issue: { type: String, enum: ["paid_not_enrolled", "refund_failed", "dispute_review"], required: true }, + status: { type: String, enum: ["open", "resolved"], default: "open", index: true }, + attempts: { type: Number, default: 0, min: 0 }, + lastError: { type: String, default: "" }, + details: { type: Schema.Types.Mixed, default: {} }, + resolvedAt: { type: Date, default: null }, + }, + { timestamps: true } +); + +paymentReconciliationSchema.index({ status: 1, createdAt: -1 }); + const noteSchema = new Schema( { sellerId: { type: ObjectId, required: true, index: true }, @@ -414,6 +484,10 @@ const mediaUploadSchema = new Schema( key: { type: String, required: true }, folder: { type: String, required: true }, contentType: { type: String, required: true }, + fileName: { type: String, default: "" }, + size: { type: Number, default: 0, min: 0 }, + thumbnailUrl: { type: String, default: "" }, + durationSeconds: { type: Number, default: 0, min: 0 }, status: { type: String, enum: ["initiated", "completed", "aborted"], default: "initiated", index: true }, expiresAt: { type: Date, required: true }, }, @@ -429,6 +503,7 @@ const User = mongoose.models.User || mongoose.model("User", userSchema); const Category = mongoose.models.Category || mongoose.model("Category", categorySchema); const Course = mongoose.models.Course || mongoose.model("Course", courseSchema); const CourseSection = mongoose.models.CourseSection || mongoose.model("CourseSection", courseSectionSchema); +const CourseVersion = mongoose.models.CourseVersion || mongoose.model("CourseVersion", courseVersionSchema); const Lesson = mongoose.models.Lesson || mongoose.model("Lesson", lessonSchema); const Enrollment = mongoose.models.Enrollment || mongoose.model("Enrollment", enrollmentSchema); const Order = mongoose.models.Order || mongoose.model("Order", orderSchema); @@ -438,6 +513,11 @@ const CourseSeatReservation = const Review = mongoose.models.Review || mongoose.model("Review", reviewSchema); const Wishlist = mongoose.models.Wishlist || mongoose.model("Wishlist", wishlistSchema); const CourseProgress = mongoose.models.CourseProgress || mongoose.model("CourseProgress", courseProgressSchema); +const LearningBookmark = mongoose.models.LearningBookmark || mongoose.model("LearningBookmark", learningBookmarkSchema); +const LessonNote = mongoose.models.LessonNote || mongoose.model("LessonNote", lessonNoteSchema); +const QuizAttempt = mongoose.models.QuizAttempt || mongoose.model("QuizAttempt", quizAttemptSchema); +const Certificate = mongoose.models.Certificate || mongoose.model("Certificate", certificateSchema); +const AssignmentSubmission = mongoose.models.AssignmentSubmission || mongoose.model("AssignmentSubmission", assignmentSubmissionSchema); const ImportedPlaylist = mongoose.models.ImportedPlaylist || mongoose.model("ImportedPlaylist", importedPlaylistSchema); const ImportedPlaylistVideo = @@ -452,12 +532,14 @@ const Note = mongoose.models.Note || mongoose.model("Note", noteSchema); const NotePurchase = mongoose.models.NotePurchase || mongoose.model("NotePurchase", notePurchaseSchema); const AuditLog = mongoose.models.AuditLog || mongoose.model("AuditLog", auditLogSchema); const MediaUpload = mongoose.models.MediaUpload || mongoose.model("MediaUpload", mediaUploadSchema); +const PaymentReconciliation = mongoose.models.PaymentReconciliation || mongoose.model("PaymentReconciliation", paymentReconciliationSchema); module.exports = { User, Category, Course, CourseSection, + CourseVersion, Lesson, Enrollment, Order, @@ -466,6 +548,11 @@ module.exports = { Review, Wishlist, CourseProgress, + LearningBookmark, + LessonNote, + QuizAttempt, + Certificate, + AssignmentSubmission, ImportedPlaylist, ImportedPlaylistVideo, Coupon, @@ -477,4 +564,5 @@ module.exports = { NotePurchase, AuditLog, MediaUpload, + PaymentReconciliation, }; diff --git a/Backend/package.json b/Backend/package.json index 95c34b6..0aff07d 100644 --- a/Backend/package.json +++ b/Backend/package.json @@ -9,6 +9,7 @@ "seed": "node seed.js", "db:reset": "node scripts/resetDatabase.js", "migrate:refunded-enrollments": "node scripts/migrateRefundedEnrollments.js", + "payment:verify": "node scripts/verifyLemonSqueezyConfig.js", "test": "node --test --test-concurrency=1 test/integration/*.test.js test/unit/*.test.js", "lint": "eslint . --ext .js --ignore-pattern node_modules", "load": "node test/load/health.load.js" diff --git a/Backend/routes/courses.js b/Backend/routes/courses.js index bea23b9..5575ecd 100644 --- a/Backend/routes/courses.js +++ b/Backend/routes/courses.js @@ -10,6 +10,7 @@ const { lessonSchema, reviewSchema, lessonAccessSchema, + courseReviewSchema, } = require("../validators"); const router = Router(); @@ -17,8 +18,13 @@ const router = Router(); router.get("/", courseController.listPublic); router.post("/", requireAuth, requireRole("instructor", "admin"), validate(courseSchema), courseController.create); router.get("/admin/all", requireAuth, requireRole("admin"), courseController.adminList); +router.post("/admin/:id/review", requireAuth, requireRole("admin"), validate(courseReviewSchema), courseController.review); +router.post("/admin/:id/restore", requireAuth, requireRole("admin"), courseController.restore); router.get("/instructor/me", requireAuth, requireRole("instructor", "admin"), courseController.instructorCourses); router.get("/instructor/:id", requireAuth, requireRole("instructor", "admin"), courseController.instructorDetails); +router.get("/:id/versions", requireAuth, requireRole("instructor", "admin"), courseController.versions); +router.post("/:id/duplicate", requireAuth, requireRole("instructor", "admin"), courseController.duplicate); +router.post("/:id/request-review", requireAuth, requireRole("instructor", "admin"), courseController.requestReview); router.post("/:id/sections", requireAuth, requireRole("instructor", "admin"), validate(sectionSchema), sectionController.create); router.put("/sections/:id", requireAuth, requireRole("instructor", "admin"), validate(sectionSchema), sectionController.update); diff --git a/Backend/routes/enrollments.js b/Backend/routes/enrollments.js index 270be43..1831dcc 100644 --- a/Backend/routes/enrollments.js +++ b/Backend/routes/enrollments.js @@ -2,12 +2,20 @@ const { Router } = require("express"); const { enrollmentController } = require("../controllers"); const validate = require("../middlewares/validate"); const { requireAuth, requireRole } = require("../middlewares/auth"); -const { enrollmentSchema, progressSchema } = require("../validators"); +const { enrollmentSchema, progressSchema, lessonNoteSchema, bookmarkSchema, quizSubmissionSchema, assignmentSubmissionSchema } = require("../validators"); const router = Router(); router.post("/", requireAuth, requireRole("student"), validate(enrollmentSchema), enrollmentController.create); router.get("/me", requireAuth, requireRole("student"), enrollmentController.me); router.patch("/progress", requireAuth, requireRole("student"), validate(progressSchema), enrollmentController.progress); +router.post("/bookmarks", requireAuth, requireRole("student"), validate(bookmarkSchema), enrollmentController.bookmark); +router.get("/bookmarks", requireAuth, requireRole("student"), enrollmentController.bookmarks); +router.put("/notes", requireAuth, requireRole("student"), validate(lessonNoteSchema), enrollmentController.saveNote); +router.get("/notes", requireAuth, requireRole("student"), enrollmentController.notes); +router.post("/quiz", requireAuth, requireRole("student"), validate(quizSubmissionSchema), enrollmentController.quiz); +router.post("/assignments", requireAuth, requireRole("student"), validate(assignmentSubmissionSchema), enrollmentController.assignment); +router.post("/certificates/:courseId", requireAuth, requireRole("student"), enrollmentController.certificate); +router.get("/certificates", requireAuth, requireRole("student"), enrollmentController.certificates); module.exports = router; diff --git a/Backend/routes/orders.js b/Backend/routes/orders.js index 2e825cc..b5a8f1a 100644 --- a/Backend/routes/orders.js +++ b/Backend/routes/orders.js @@ -10,8 +10,12 @@ router.post("/webhook/lemon-squeezy", orderController.lemonSqueezyWebhook); router.post("/", requireAuth, requireRole("student"), validate(orderSchema), orderController.create); router.get("/me", requireAuth, requireRole("student"), orderController.me); router.get("/:id/status", requireAuth, requireRole("student"), orderController.status); +router.post("/:id/cancel", requireAuth, requireRole("student"), orderController.cancel); router.get("/webhook-monitoring", requireAuth, requireRole("admin"), orderController.webhookMonitoring); router.post("/webhook-monitoring/:id/replay", requireAuth, requireRole("admin"), orderController.replayWebhook); +router.get("/reconciliation", requireAuth, requireRole("admin"), orderController.reconciliation); +router.post("/reconciliation/:id/retry", requireAuth, requireRole("admin"), orderController.retryReconciliation); +router.post("/reconciliation/:id/resolve-dispute", requireAuth, requireRole("admin"), orderController.resolveDispute); router.get("/", requireAuth, requireRole("admin"), orderController.list); router.get("/:id", requireAuth, requireRole("admin"), orderController.adminDetails); router.post("/:id/refund", requireAuth, requireRole("admin"), orderController.adminRefund); diff --git a/Backend/scripts/verifyLemonSqueezyConfig.js b/Backend/scripts/verifyLemonSqueezyConfig.js new file mode 100644 index 0000000..681fc1e --- /dev/null +++ b/Backend/scripts/verifyLemonSqueezyConfig.js @@ -0,0 +1,39 @@ +require("dotenv").config(); + +const required = [ + "LEMONSQUEEZY_API_KEY", + "LEMONSQUEEZY_STORE_ID", + "LEMONSQUEEZY_VARIANT_ID", + "LEMONSQUEEZY_WEBHOOK_SECRET", +]; + +const missing = required.filter((key) => !process.env[key]); +if (missing.length) { + console.error(`Missing Lemon Squeezy variables: ${missing.join(", ")}`); + process.exitCode = 1; +} else { + const headers = { + Accept: "application/vnd.api+json", + "Content-Type": "application/vnd.api+json", + Authorization: `Bearer ${process.env.LEMONSQUEEZY_API_KEY}`, + }; + + const check = async (label, url) => { + const response = await fetch(url, { headers }); + const body = await response.json().catch(() => null); + if (!response.ok) throw new Error(`${label} check failed (${response.status}): ${body?.errors?.[0]?.detail || "unknown error"}`); + console.log(`${label}: OK`); + }; + + (async () => { + try { + await check("Store", `https://api.lemonsqueezy.com/v1/stores/${encodeURIComponent(process.env.LEMONSQUEEZY_STORE_ID)}`); + await check("Variant", `https://api.lemonsqueezy.com/v1/variants/${encodeURIComponent(process.env.LEMONSQUEEZY_VARIANT_ID)}`); + console.log("Webhook secret: configured"); + console.log("No payment was created by this check."); + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } + })(); +} diff --git a/Backend/services/index.js b/Backend/services/index.js index 8db1298..6d94961 100644 --- a/Backend/services/index.js +++ b/Backend/services/index.js @@ -22,18 +22,21 @@ const { deleteResetTokensByUserId, } = require("../utils/passwordResetStore"); const { createPresignedGetUrl } = require("../utils/s3"); -const { isEmailConfigured, sendPasswordResetEmail } = require("../utils/email"); +const { isEmailConfigured, sendPasswordResetEmail, sendPaymentNotificationEmail } = require("../utils/email"); const { getOrSetJson } = require("../utils/cache"); +const { paymentEvent } = require("../utils/metrics"); const { createCheckout: createLemonSqueezyCheckout, parseWebhookBody: parseLemonSqueezyWebhookBody, verifyWebhookSignature: verifyLemonSqueezyWebhookSignature, + issueRefund: issueLemonSqueezyRefund, } = require("../utils/lemonSqueezy"); const { User, Category, Course, CourseSection, + CourseVersion, Lesson, Enrollment, Order, @@ -42,12 +45,18 @@ const { Review, Wishlist, CourseProgress, + LearningBookmark, + LessonNote, + QuizAttempt, + Certificate, + AssignmentSubmission, Coupon, Notification, WebhookDelivery, Note, NotePurchase, AuditLog, + PaymentReconciliation, } = require("../models"); const isTransientTransactionError = (error) => @@ -128,8 +137,11 @@ const ensureRoleAllowed = (role) => { const isOwnerOrAdmin = (actor, ownerId) => actor?.role === "admin" || String(actor?.id) === String(ownerId); -const upsertNotification = async ({ userId, type, title, message }) => { - return Notification.create({ userId, type, title, message }); +const upsertNotification = async ({ userId, type, title, message, dedupeKey = "", relatedOrderId = null, metadata = {} }) => { + if (!dedupeKey) return { notification: await Notification.create({ userId, type, title, message, relatedOrderId, metadata }), created: true }; + const existing = await Notification.findOne({ userId, dedupeKey }); + if (existing) return { notification: existing, created: false }; + return { notification: await Notification.create({ userId, type, title, message, dedupeKey, relatedOrderId, metadata }), created: true }; }; const recalcCourseRatings = async (courseId) => { @@ -307,6 +319,8 @@ const pickCourseUpdates = (payload) => { "isPublished", "isFeatured", "maxSeats", + "seoTitle", + "seoDescription", ]; fields.forEach((field) => { @@ -327,7 +341,7 @@ const pickSectionUpdates = (payload) => { const pickLessonUpdates = (payload) => { const updates = {}; - const fields = ["title", "type", "content", "videoUrl", "fileKey", "fileUrl", "duration", "isPreview", "order"]; + const fields = ["title", "type", "content", "videoUrl", "fileKey", "fileUrl", "thumbnailUrl", "duration", "isPreview", "order"]; fields.forEach((field) => { if (payload[field] !== undefined) { @@ -621,12 +635,80 @@ const markOrderFailed = async (orderId) => { await releaseOrderSeatReservations(order, session); await Order.updateOne( { _id: order._id, status: "pending" }, - { $set: { status: "failed" } }, + { $set: { status: "failed", failureReason: "payment_failed", providerEventName: "payment_failed" } }, { session } ); }); }; +const notifyOrder = async (order, type, title, message, suffix = type, metadata = {}) => { + try { + paymentEvent(type); + const notificationResult = await upsertNotification({ + userId: order.userId, + type, + title, + message, + dedupeKey: `order:${order._id}:${suffix}`, + relatedOrderId: order._id, + metadata, + }); + await recordAudit({ + action: `payment.${type}`, + resourceType: "order", + resourceId: order._id, + metadata: { status: order.status, ...metadata }, + }); + const user = notificationResult.created ? await User.findById(order.userId).select("email").lean() : null; + if (notificationResult.created && user?.email) { + await sendPaymentNotificationEmail({ + email: user.email, + title, + message, + orderId: String(order._id), + actionUrl: config.frontendUrl ? `${config.frontendUrl.replace(/\/$/, "")}/student/orders` : "", + }); + } + } catch (error) { + log("warn", "payment.notification_failed", { orderId: String(order._id), type, error: { name: error?.name, message: error?.message } }); + } +}; + +const upsertReconciliation = async (order, issue, error = "", details = {}) => { + const reconciliation = await PaymentReconciliation.findOneAndUpdate( + { orderId: order._id }, + { $set: { userId: order.userId, issue, status: "open", lastError: String(error || ""), details }, $inc: { attempts: 1 } }, + { new: true, upsert: true } + ); + const admins = await User.find({ role: "admin", status: "active" }).select("_id").lean(); + await Promise.all(admins.map((admin) => upsertNotification({ + userId: admin._id, + type: "payment_reconciliation", + title: "Payment reconciliation required", + message: `Order ${String(order._id).slice(-6)} needs attention: ${issue.replaceAll("_", " ")}.`, + dedupeKey: `reconciliation:${reconciliation._id}`, + relatedOrderId: order._id, + metadata: { issue, error: String(error || "") }, + }))); + return reconciliation; +}; + +const schedulePaymentRetry = async (reconciliationId) => { + try { + const { enqueuePaymentRetry } = require("../jobs/queue"); + await enqueuePaymentRetry({ reconciliationId }); + } catch (error) { + log("warn", "payment.retry_enqueue_failed", { reconciliationId: String(reconciliationId || ""), error: { name: error?.name, message: error?.message } }); + } +}; + +const resolveReconciliation = async (orderId) => { + await PaymentReconciliation.updateOne( + { orderId, status: "open" }, + { $set: { status: "resolved", resolvedAt: new Date(), lastError: "" } } + ); +}; + const getProviderPaymentTime = (payload) => { const value = payload?.attributes?.paid_at || payload?.attributes?.created_at || payload?.attributes?.updated_at; const timestamp = value ? new Date(value) : new Date(); @@ -748,6 +830,30 @@ const expirePendingOrders = async () => { return expiredOrders.length; }; +const reconcilePaidOrders = async (limit = 100) => { + const orders = await Order.find({ status: "paid" }).sort({ updatedAt: 1 }).limit(Math.min(Math.max(Number(limit) || 100, 1), 500)); + let resolved = 0; + let failed = 0; + for (const order of orders) { + try { + await runDatabaseTransaction(async (session) => { + await ensureOrderEnrollments(order, session); + await ensureOrderNotePurchases(order, session, order.paymentIntentId); + }); + if (order.reconciliationStatus !== "resolved") { + await Order.updateOne({ _id: order._id }, { $set: { reconciliationStatus: "resolved" } }); + } + await resolveReconciliation(order._id); + resolved += 1; + } catch (error) { + failed += 1; + await Order.updateOne({ _id: order._id }, { $set: { reconciliationStatus: "failed" } }); + await upsertReconciliation(order, "paid_not_enrolled", error.message, { source: "maintenance" }); + } + } + return { scanned: orders.length, resolved, failed }; +}; + const courseSearchFilter = (query) => { const filter = { isPublished: true, isArchived: { $ne: true } }; @@ -996,6 +1102,48 @@ const userService = { }, }; +const buildCourseSnapshot = async (courseId) => { + const [course, sections, lessons] = await Promise.all([ + Course.findById(courseId).lean(), + CourseSection.find({ courseId }).sort({ order: 1 }).lean(), + Lesson.find({ courseId }).sort({ sectionId: 1, order: 1 }).lean(), + ]); + return { course, sections, lessons }; +}; + +const createCourseVersion = async (course, actor, changeNote = "") => { + const snapshot = await buildCourseSnapshot(course._id); + return CourseVersion.create({ + courseId: course._id, + version: Number(course.currentVersion || 1), + createdBy: actor.id || actor._id, + changeNote, + snapshot, + }); +}; + +const validateCourseForPublishing = async (courseId) => { + const [course, sections, lessons] = await Promise.all([ + Course.findById(courseId).lean(), + CourseSection.find({ courseId }).sort({ order: 1 }).lean(), + Lesson.find({ courseId }).lean(), + ]); + const errors = []; + if (!course?.title?.trim()) errors.push("A course title is required"); + if (!course?.description?.trim() || course.description.trim().length < 10) errors.push("A meaningful course description is required"); + if (!course?.thumbnailUrl) errors.push("A course thumbnail is required"); + if (!sections.length) errors.push("Add at least one section"); + if (!lessons.length) errors.push("Add at least one lesson"); + const sectionIds = new Set(sections.map((section) => String(section._id))); + lessons.forEach((lesson) => { + if (!sectionIds.has(String(lesson.sectionId))) errors.push(`Lesson ${lesson.title} is not attached to a valid section`); + if (lesson.type === "video" && !(lesson.videoUrl || lesson.fileKey || lesson.fileUrl)) errors.push(`Video lesson "${lesson.title}" needs uploaded media`); + if (lesson.type === "pdf" && !(lesson.fileKey || lesson.fileUrl)) errors.push(`PDF lesson "${lesson.title}" needs an uploaded file`); + if (lesson.type === "text" && !lesson.content?.trim()) errors.push(`Text lesson "${lesson.title}" needs content`); + }); + return { valid: errors.length === 0, errors }; +}; + const courseService = { async listPublic(query) { const cacheKey = `catalog:courses:v2:${JSON.stringify({ @@ -1056,8 +1204,11 @@ const courseService = { isPublished: Boolean(payload.isPublished), isFeatured: Boolean(payload.isFeatured), maxSeats: payload.maxSeats === undefined ? null : payload.maxSeats, + seoTitle: payload.seoTitle || "", + seoDescription: payload.seoDescription || "", + workflowStatus: payload.isPublished ? (config.courseApprovalRequired ? "pending_review" : "approved") : "draft", }); - + await createCourseVersion(course, actor, "Initial course version"); return course; }, @@ -1086,7 +1237,25 @@ const courseService = { updates.slug = slug; } + if (course.isPublished && Object.prototype.hasOwnProperty.call(updates, "isPublished") === false) { + updates.isPublished = false; + updates.workflowStatus = "draft"; + } + if (updates.isPublished === true) { + const validation = await validateCourseForPublishing(courseId); + if (!validation.valid) throw new ApiError(400, `Course cannot be published: ${validation.errors.join("; ")}`); + if (config.courseApprovalRequired && actor.role !== "admin" && course.workflowStatus !== "approved") { + updates.isPublished = false; + updates.workflowStatus = "pending_review"; + } else { + updates.workflowStatus = "approved"; + } + } else if (updates.isPublished === false) { + updates.workflowStatus = "draft"; + } + updates.currentVersion = Number(course.currentVersion || 1) + 1; const updated = await Course.findByIdAndUpdate(courseId, updates, { new: true }); + await createCourseVersion(updated, actor, "Course details updated"); return updated; }, @@ -1096,7 +1265,35 @@ const courseService = { } const course = await Course.findById(courseId); if (!course) throw new ApiError(404, "Course not found"); - return Course.findByIdAndUpdate(courseId, { isArchived: true, isPublished: false }, { new: true }); + return Course.findByIdAndUpdate(courseId, { isArchived: true, isPublished: false, archivedAt: new Date(), archivedBy: actor.id }, { new: true }); + }, + + async restore(actor, courseId) { + if (actor.role !== "admin") throw new ApiError(403, "Only admins can restore courses"); + const course = await Course.findByIdAndUpdate(courseId, { isArchived: false, archivedAt: null, archivedBy: null, workflowStatus: "draft" }, { new: true }); + if (!course) throw new ApiError(404, "Course not found"); + return course; + }, + + async duplicate(actor, courseId) { + const source = await Course.findById(courseId).lean(); + if (!source) throw new ApiError(404, "Course not found"); + if (!isOwnerOrAdmin(actor, source.instructorId)) throw new ApiError(403, "You cannot duplicate this course"); + const sourceSections = await CourseSection.find({ courseId }).sort({ order: 1 }).lean(); + const sourceLessons = await Lesson.find({ courseId }).sort({ order: 1 }).lean(); + const baseSlug = slugify(`${source.title} copy`); + let slug = baseSlug; + let counter = 1; + while (await Course.exists({ slug })) slug = `${baseSlug}-${counter++}`; + const [copy] = await Course.create([{ ...source, _id: undefined, title: `${source.title} Copy`, slug, instructorId: actor.role === "admin" ? source.instructorId : actor.id, isPublished: false, isArchived: false, workflowStatus: "draft", currentVersion: 1, enrollmentCount: 0, reservedSeats: 0, createdAt: undefined, updatedAt: undefined }]); + const sectionMap = new Map(); + for (const section of sourceSections) { + const [created] = await CourseSection.create([{ courseId: copy._id, title: section.title, order: section.order }]); + sectionMap.set(String(section._id), created._id); + } + if (sourceLessons.length) await Lesson.insertMany(sourceLessons.map((lesson) => ({ courseId: copy._id, sectionId: sectionMap.get(String(lesson.sectionId)), title: lesson.title, type: lesson.type, content: lesson.content, videoUrl: lesson.videoUrl, fileUrl: lesson.fileUrl, fileKey: lesson.fileKey, duration: lesson.duration, isPreview: lesson.isPreview, order: lesson.order }))); + await createCourseVersion(copy, actor, `Duplicated from ${source._id}`); + return copy; }, async publish(actor, courseId, isPublished) { @@ -1105,7 +1302,39 @@ const courseService = { if (!isOwnerOrAdmin(actor, course.instructorId)) { throw new ApiError(403, "You cannot update this course"); } - return Course.findByIdAndUpdate(courseId, { isPublished }, { new: true }); + if (isPublished) { + const validation = await validateCourseForPublishing(courseId); + if (!validation.valid) throw new ApiError(400, `Course cannot be published: ${validation.errors.join("; ")}`); + if (config.courseApprovalRequired && actor.role !== "admin" && course.workflowStatus !== "approved") { + return Course.findByIdAndUpdate(courseId, { isPublished: false, workflowStatus: "pending_review" }, { new: true }); + } + } + const updated = await Course.findByIdAndUpdate(courseId, { isPublished, workflowStatus: isPublished ? "approved" : "draft", rejectionReason: "" }, { new: true }); + await recordAudit({ actor, action: isPublished ? "course.published" : "course.unpublished", resourceType: "course", resourceId: courseId }); + return updated; + }, + + async requestReview(actor, courseId) { + const course = await Course.findById(courseId); + if (!course) throw new ApiError(404, "Course not found"); + if (!isOwnerOrAdmin(actor, course.instructorId)) throw new ApiError(403, "You cannot submit this course"); + const validation = await validateCourseForPublishing(courseId); + if (!validation.valid) throw new ApiError(400, `Course cannot be submitted: ${validation.errors.join("; ")}`); + return Course.findByIdAndUpdate(courseId, { workflowStatus: "pending_review", isPublished: false, rejectionReason: "" }, { new: true }); + }, + + async review(actor, courseId, status, reason = "") { + if (actor.role !== "admin") throw new ApiError(403, "Only admins can review courses"); + if (!["approved", "rejected"].includes(status)) throw new ApiError(400, "Invalid review status"); + const course = await Course.findByIdAndUpdate(courseId, { workflowStatus: status, rejectionReason: status === "rejected" ? reason : "" }, { new: true }); + if (!course) throw new ApiError(404, "Course not found"); + return course; + }, + + async versions(actor, courseId) { + const course = await Course.findById(courseId); + if (!course || !isOwnerOrAdmin(actor, course.instructorId)) throw new ApiError(403, "You cannot view course versions"); + return CourseVersion.find({ courseId }).sort({ version: -1 }).select("version createdBy changeNote createdAt").lean(); }, async adminList(query) { @@ -1248,6 +1477,7 @@ const lessonService = { videoUrl: payload.videoUrl || "", fileUrl: payload.fileUrl || "", fileKey: payload.fileKey || "", + thumbnailUrl: payload.thumbnailUrl || "", duration: payload.duration || 0, isPreview: Boolean(payload.isPreview), order: payload.order || lessonCount + 1, @@ -1369,6 +1599,71 @@ const enrollmentService = { return records; }, + async toggleBookmark(actor, payload) { + const enrollment = await Enrollment.exists({ userId: actor.id, courseId: payload.courseId, status: { $in: ["active", "completed"] } }); + if (!enrollment) throw new ApiError(403, "You must be enrolled to bookmark lessons"); + const existing = await LearningBookmark.findOne(payload); + if (existing) { + await existing.deleteOne(); + return { bookmarked: false }; + } + await LearningBookmark.create({ ...payload, userId: actor.id }); + return { bookmarked: true }; + }, + + async listBookmarks(actor, courseId) { + return LearningBookmark.find({ userId: actor.id, ...(courseId ? { courseId } : {}) }).sort({ createdAt: -1 }).lean(); + }, + + async saveLessonNote(actor, payload) { + const enrollment = await Enrollment.exists({ userId: actor.id, courseId: payload.courseId, status: { $in: ["active", "completed"] } }); + if (!enrollment) throw new ApiError(403, "You must be enrolled to save lesson notes"); + return LessonNote.findOneAndUpdate({ userId: actor.id, courseId: payload.courseId, lessonId: payload.lessonId }, { $set: { content: payload.content } }, { new: true, upsert: true }); + }, + + async listLessonNotes(actor, courseId) { + return LessonNote.find({ userId: actor.id, ...(courseId ? { courseId } : {}) }).sort({ updatedAt: -1 }).lean(); + }, + + async submitQuiz(actor, payload) { + const enrollment = await Enrollment.exists({ userId: actor.id, courseId: payload.courseId, status: { $in: ["active", "completed"] } }); + if (!enrollment) throw new ApiError(403, "You must be enrolled to submit a quiz"); + const lesson = await Lesson.findOne({ _id: payload.lessonId, courseId: payload.courseId, type: "quiz" }).lean(); + if (!lesson) throw new ApiError(404, "Quiz lesson not found"); + let quiz; + try { quiz = JSON.parse(lesson.content || "{}"); } catch { throw new ApiError(400, "Quiz content is invalid"); } + const questions = Array.isArray(quiz.questions) ? quiz.questions : []; + if (!questions.length) throw new ApiError(400, "This quiz has no questions"); + const correct = questions.filter((question) => String(payload.answers?.[question.id]) === String(question.correctAnswer)).length; + const score = Math.round((correct / questions.length) * 100); + const attempt = await QuizAttempt.create({ userId: actor.id, courseId: payload.courseId, lessonId: payload.lessonId, score, passed: score >= Number(quiz.passPercent || 70), answers: payload.answers }); + return { attempt, score, passed: attempt.passed }; + }, + + async submitAssignment(actor, payload) { + const enrollment = await Enrollment.exists({ userId: actor.id, courseId: payload.courseId, status: { $in: ["active", "completed"] } }); + if (!enrollment) throw new ApiError(403, "You must be enrolled to submit assignments"); + const lesson = await Lesson.findOne({ _id: payload.lessonId, courseId: payload.courseId, type: "assignment" }).lean(); + if (!lesson) throw new ApiError(404, "Assignment lesson not found"); + return AssignmentSubmission.findOneAndUpdate( + { userId: actor.id, courseId: payload.courseId, lessonId: payload.lessonId }, + { $set: { content: payload.content, status: "submitted", grade: null, feedback: "" } }, + { new: true, upsert: true, runValidators: true } + ).lean(); + }, + + async issueCertificate(actor, courseId) { + const enrollment = await Enrollment.findOne({ userId: actor.id, courseId, status: "completed" }); + if (!enrollment) throw new ApiError(409, "Complete the course before requesting a certificate"); + const existing = await Certificate.findOne({ userId: actor.id, courseId }); + if (existing) return existing; + return Certificate.create({ userId: actor.id, courseId, certificateNumber: `SK-${Date.now()}-${crypto.randomBytes(4).toString("hex").toUpperCase()}` }); + }, + + async myCertificates(userId) { + return Certificate.find({ userId }).sort({ issuedAt: -1 }).lean(); + }, + async getLessonAccessUrl(actor, courseId, lessonId) { const course = await Course.findById(courseId); if (!course) throw new ApiError(404, "Course not found"); @@ -1651,6 +1946,25 @@ const orderService = { return Order.find({ userId }).sort({ createdAt: -1 }); }, + async cancel(userId, orderId) { + if (!mongoose.isValidObjectId(orderId)) throw new ApiError(400, "Invalid order ID"); + const order = await runDatabaseTransaction(async (session) => { + const current = await Order.findOne({ _id: orderId, userId }).session(session); + if (!current) throw new ApiError(404, "Order not found"); + if (current.status === "cancelled") return current; + if (current.status !== "pending") throw new ApiError(409, "Only pending checkouts can be cancelled"); + await releaseOrderCouponReservation(current, session); + await releaseOrderSeatReservations(current, session, "cancelled"); + current.status = "cancelled"; + current.failureReason = "checkout_cancelled"; + current.providerEventName = "checkout_cancelled"; + await current.save({ session }); + return current; + }); + await notifyOrder(order, "payment_cancelled", "Checkout canceled", "Your checkout was canceled. No course access was granted.", "cancelled"); + return order; + }, + async getMyOrder(userId, orderId) { await expirePendingOrders(); if (!mongoose.isValidObjectId(orderId)) throw new ApiError(400, "Invalid order ID"); @@ -1686,6 +2000,25 @@ const orderService = { }, async recordAdminRefund(actor, id, request) { + const existingOrder = await Order.findById(id); + if (!existingOrder) throw new ApiError(404, "Order not found"); + if (existingOrder.status === "refunded") return existingOrder; + if (existingOrder.status !== "paid") throw new ApiError(400, "Only paid orders can be refunded"); + + let providerRefundId = ""; + if (existingOrder.paymentProvider === "lemon_squeezy" && existingOrder.paymentIntentId) { + try { + const refund = await issueLemonSqueezyRefund({ providerOrderId: existingOrder.paymentIntentId }); + providerRefundId = refund.providerRefundId; + } catch (error) { + existingOrder.status = "refund_pending"; + existingOrder.refundStatus = "failed"; + await existingOrder.save(); + const reconciliation = await upsertReconciliation(existingOrder, "refund_failed", error.message, { source: "admin_refund" }); + await schedulePaymentRetry(reconciliation._id); + throw error; + } + } const refundedOrder = await runDatabaseTransaction(async (session) => { const order = await Order.findById(id).session(session); if (!order) throw new ApiError(404, "Order not found"); @@ -1698,9 +2031,13 @@ const orderService = { await revokeOrderEnrollments(order, session); await revokeOrderNotePurchases(order, session); order.status = "refunded"; + order.refundStatus = "completed"; + order.providerRefundId = providerRefundId; + order.refundedAt = new Date(); await order.save({ session }); return order; }); + await notifyOrder(refundedOrder, "payment_refunded", "Payment refunded", "Your payment was refunded and course access was removed.", "refunded"); await recordAudit({ actor, action: "order.refunded", @@ -1717,6 +2054,71 @@ const orderService = { return WebhookDelivery.find({}).sort({ createdAt: -1 }).limit(safeLimit).lean(); }, + async listReconciliationCases(limit = 50) { + const safeLimit = Math.min(Math.max(Number(limit) || 50, 1), 200); + return PaymentReconciliation.find({ status: "open" }).sort({ createdAt: -1 }).limit(safeLimit).lean(); + }, + + async retryReconciliation(actor, id, request) { + if (!mongoose.isValidObjectId(id)) throw new ApiError(400, "Invalid reconciliation ID"); + const issue = await PaymentReconciliation.findById(id); + if (!issue) throw new ApiError(404, "Reconciliation case not found"); + const order = await Order.findById(issue.orderId); + if (!order) throw new ApiError(404, "Order not found"); + try { + if (issue.issue === "paid_not_enrolled") { + await runDatabaseTransaction(async (session) => { + await ensureOrderEnrollments(order, session); + await ensureOrderNotePurchases(order, session, order.paymentIntentId); + }); + order.reconciliationStatus = "resolved"; + await order.save(); + await resolveReconciliation(order._id); + } else if (issue.issue === "refund_failed") { + if (!order.paymentIntentId) throw new ApiError(409, "Provider order ID is missing"); + const refund = await issueLemonSqueezyRefund({ providerOrderId: order.paymentIntentId }); + order.status = "refunded"; + order.refundStatus = "completed"; + order.providerRefundId = refund.providerRefundId; + order.refundedAt = new Date(); + await order.save(); + await runDatabaseTransaction(async (session) => { + await revokeOrderEnrollments(order, session); + await revokeOrderNotePurchases(order, session); + }); + await resolveReconciliation(order._id); + } else { + throw new ApiError(409, "Dispute cases require provider confirmation before access can be restored"); + } + await recordAudit({ actor, action: "payment.reconciliation_retried", resourceType: "order", resourceId: order._id, metadata: { issue: issue.issue }, request }); + return { success: true, status: "resolved" }; + } catch (error) { + const reconciliation = await upsertReconciliation(order, issue.issue, error.message, issue.details); + if (issue.issue === "refund_failed") await schedulePaymentRetry(reconciliation._id); + throw error; + } + }, + + async resolveDispute(actor, id, request) { + if (!mongoose.isValidObjectId(id)) throw new ApiError(400, "Invalid reconciliation ID"); + const issue = await PaymentReconciliation.findOne({ _id: id, issue: "dispute_review", status: "open" }); + if (!issue) throw new ApiError(404, "Open dispute case not found"); + const order = await Order.findById(issue.orderId); + if (!order || order.status !== "disputed") throw new ApiError(409, "Order is not awaiting dispute resolution"); + await runDatabaseTransaction(async (session) => { + await Enrollment.updateMany({ userId: order.userId, status: "suspended", courseId: { $in: await OrderItem.distinct("courseId", { orderId: order._id }).session(session) } }, { $set: { status: "active" } }, { session }); + order.status = "paid"; + order.disputeStatus = "resolved"; + order.providerEventName = "dispute_resolved_admin"; + order.reconciliationStatus = "resolved"; + await order.save({ session }); + }); + await resolveReconciliation(order._id); + await notifyOrder(order, "payment_dispute_resolved", "Payment review resolved", "Your payment review is complete and course access has been restored.", "dispute_resolved"); + await recordAudit({ actor, action: "payment.dispute_resolved", resourceType: "order", resourceId: order._id, request }); + return { success: true, status: order.status }; + }, + async replayWebhookDelivery(actor, deliveryId, request) { if (!mongoose.isValidObjectId(deliveryId)) throw new ApiError(400, "Invalid webhook delivery ID"); const delivery = await WebhookDelivery.findById(deliveryId).lean(); @@ -1759,7 +2161,7 @@ const orderService = { } if (eventName === "order_refunded") { - return runDatabaseTransaction(async (session) => { + const result = await runDatabaseTransaction(async (session) => { const currentOrder = await Order.findById(order._id).session(session); if (!currentOrder) throw new ApiError(404, "Order not found"); @@ -1767,34 +2169,46 @@ const orderService = { await revokeOrderEnrollments(currentOrder, session); await revokeOrderNotePurchases(currentOrder, session); currentOrder.status = "refunded"; + currentOrder.refundStatus = "completed"; + currentOrder.refundedAt = getProviderPaymentTime(payload); + currentOrder.providerEventName = eventName; + currentOrder.disputeStatus = "none"; await currentOrder.save({ session }); return { ok: true, eventName, orderId: String(currentOrder._id), status: currentOrder.status }; }); + await resolveReconciliation(order._id); + await notifyOrder(order, "payment_refunded", "Payment refunded", "Your payment was refunded and course access was removed.", "refunded"); + return result; } if (eventName !== "order_created") { return { ok: true, skipped: true, eventName, orderId: String(order._id) }; } - // Do not grant access from a delayed success webhook after the local checkout - // window has already expired. The provider checkout also has an expiry, but - // this protects the application if a stale or replayed event arrives later. - if (order.expiresAt && order.expiresAt <= new Date() && order.status !== "paid") { - return { - ok: true, - skipped: true, - eventName, - orderId: String(order._id), - status: order.status, - reason: "checkout_expired", - }; + const providerStatus = payload?.attributes?.status || ""; + const normalizedProviderStatus = String(providerStatus).toLowerCase(); + if (["fraudulent", "disputed", "chargeback"].includes(normalizedProviderStatus)) { + const result = await runDatabaseTransaction(async (session) => { + const currentOrder = await Order.findById(order._id).session(session); + if (!currentOrder) throw new ApiError(404, "Order not found"); + const courseIds = await OrderItem.distinct("courseId", { orderId: currentOrder._id }).session(session); + await Enrollment.updateMany({ userId: currentOrder.userId, courseId: { $in: courseIds }, status: { $in: ["active", "completed"] } }, { $set: { status: "suspended" } }, { session }); + currentOrder.status = "disputed"; + currentOrder.disputeStatus = "open"; + currentOrder.providerEventName = eventName; + await currentOrder.save({ session }); + return { ok: true, eventName, orderId: String(currentOrder._id), status: currentOrder.status }; + }); + await upsertReconciliation(order, "dispute_review", "Provider marked the order as disputed or fraudulent", { providerStatus }); + await notifyOrder(order, "payment_disputed", "Payment under review", "Your payment is under provider review. Course access is temporarily suspended.", "disputed", { providerStatus }); + return result; } - const providerStatus = payload?.attributes?.status || ""; if (providerStatus && providerStatus !== "paid") { - if (["failed", "cancelled", "canceled", "expired"].includes(String(providerStatus).toLowerCase())) { + if (["failed", "cancelled", "canceled", "expired"].includes(normalizedProviderStatus)) { await markOrderFailed(order._id); + await notifyOrder(order, "payment_failed", "Payment failed", "The payment was not completed. You can try the checkout again.", "failed", { providerStatus }); return { ok: true, eventName, orderId: String(order._id), status: "failed", providerStatus }; } return { ok: true, skipped: true, eventName, orderId: String(order._id), status: providerStatus }; @@ -1803,9 +2217,38 @@ const orderService = { const { providerOrderId } = reconcileLemonSqueezyOrder(order, payload); const paymentTime = getProviderPaymentTime(payload); + // A provider can deliver a successful order after our five-minute checkout + // reservation expired. Refund it instead of granting an unreserved seat. + if ((order.expiresAt && order.expiresAt <= new Date() || order.status === "cancelled") && order.status !== "paid") { + try { + const refund = await issueLemonSqueezyRefund({ providerOrderId }); + const lateOrder = await Order.findById(order._id); + lateOrder.status = "refunded"; + lateOrder.refundStatus = "completed"; + lateOrder.providerRefundId = refund.providerRefundId; + lateOrder.refundedAt = new Date(); + lateOrder.failureReason = "late_payment_refunded"; + lateOrder.providerEventName = eventName; + await lateOrder.save(); + await notifyOrder(lateOrder, "payment_refunded", "Payment refunded", "This payment arrived after checkout expired, so it was refunded automatically.", "late_refunded"); + return { ok: true, eventName, orderId: String(order._id), status: "refunded", reason: "checkout_expired" }; + } catch (error) { + const lateOrder = await Order.findById(order._id); + lateOrder.status = "refund_pending"; + lateOrder.refundStatus = "failed"; + lateOrder.failureReason = "late_payment_refund_failed"; + lateOrder.providerEventName = eventName; + await lateOrder.save(); + const reconciliation = await upsertReconciliation(lateOrder, "refund_failed", error.message, { providerOrderId, reason: "checkout_expired" }); + await schedulePaymentRetry(reconciliation._id); + await notifyOrder(lateOrder, "payment_review", "Payment needs review", "Your payment arrived after checkout expired. Our team is reviewing the refund.", "late_refund_pending"); + return { ok: true, eventName, orderId: String(order._id), status: "refund_pending", reason: "refund_failed" }; + } + } + await releaseExpiredCouponReservations(); - return runDatabaseTransaction(async (session) => { + const result = await runDatabaseTransaction(async (session) => { const currentOrder = await Order.findById(order._id).session(session); if (!currentOrder) throw new ApiError(404, "Order not found"); @@ -1825,6 +2268,10 @@ const orderService = { currentOrder.status = "paid"; currentOrder.paymentProvider = "lemon_squeezy"; currentOrder.paymentIntentId = providerOrderId; + currentOrder.providerEventName = eventName; + currentOrder.failureReason = ""; + currentOrder.refundStatus = "none"; + currentOrder.reconciliationStatus = "resolved"; currentOrder.couponRedeemedAt = currentOrder.couponCode ? new Date() : null; currentOrder.couponReservationReleased = true; currentOrder.couponReservationExpiresAt = null; @@ -1840,6 +2287,9 @@ const orderService = { status: currentOrder.status, }; }); + await resolveReconciliation(order._id); + await notifyOrder(order, "payment_success", "Payment successful", "Your payment was confirmed and your course access is ready.", "paid"); + return result; }, async handleLemonSqueezyWebhook(args) { @@ -2230,4 +2680,5 @@ module.exports = { canDownloadNote, releaseExpiredCouponReservations, expirePendingOrders, + reconcilePaidOrders, }; diff --git a/Backend/services/upload.js b/Backend/services/upload.js index 6add57a..3a6aa67 100644 --- a/Backend/services/upload.js +++ b/Backend/services/upload.js @@ -150,6 +150,9 @@ const uploadService = { }, async initiateS3Multipart(actor, payload, request) { + if (!Number.isInteger(Number(payload.size)) || Number(payload.size) <= 0 || Number(payload.size) > config.s3MaxUploadBytes) { + throw new ApiError(400, `S3 uploads must be between 1 byte and ${Math.round(config.s3MaxUploadBytes / 1024 / 1024)} MB`); + } const folder = validateFileUpload(payload); assertFolderPermission(actor, folder); const upload = await initiateMultipartUpload({ ...payload, folder }); @@ -160,6 +163,8 @@ const uploadService = { key: upload.key, folder, contentType: payload.contentType, + fileName: payload.fileName, + size: Number(payload.size), expiresAt: new Date(Date.now() + config.s3MultipartSessionTtlHours * 60 * 60 * 1000), }); await recordAudit({ actor, action: "upload.s3_multipart_started", resourceType: "upload", resourceId: upload.key, metadata: { uploadId: upload.uploadId, folder }, request }); @@ -199,6 +204,26 @@ const uploadService = { return result; }, + async cleanupExpiredS3MultipartUploads() { + const uploads = await MediaUpload.find({ provider: "s3", status: "initiated", expiresAt: { $lte: new Date() } }).limit(100); + let cleaned = 0; + for (const upload of uploads) { + try { + await abortMultipartUpload({ key: upload.key, uploadId: upload.uploadId }); + } catch (error) { + if (!String(error?.name || "").includes("NoSuchUpload")) continue; + } + upload.status = "aborted"; + await upload.save(); + cleaned += 1; + } + return cleaned; + }, + + async cleanupStaleLocalUploads() { + return localVideoUpload.cleanupStaleUploads(); + }, + async startLocalVideoUpload(actor, payload, request) { const upload = await localVideoUpload.startUpload(actor, payload); await recordAudit({ actor, action: "upload.local_video_started", resourceType: "upload", resourceId: upload.uploadId, metadata: { fileName: upload.fileName, contentType: upload.contentType, size: upload.size }, request }); diff --git a/Backend/utils/email.js b/Backend/utils/email.js index 930c0c8..96b8a6d 100644 --- a/Backend/utils/email.js +++ b/Backend/utils/email.js @@ -2,7 +2,7 @@ const { Resend } = require("resend"); const { render } = require("@react-email/render"); const config = require("../config"); -const { PasswordResetEmail } = require("./emailTemplates"); +const { PasswordResetEmail, PaymentNotificationEmail } = require("./emailTemplates"); let resendClient; @@ -54,4 +54,13 @@ async function sendPasswordResetEmail({ email, resetUrl }) { }); } -module.exports = { isEmailConfigured, sendEmail, sendPasswordResetEmail }; +async function sendPaymentNotificationEmail({ email, title, message, orderId, actionUrl }) { + return sendEmail({ + from: config.emailFromNotifications, + to: email, + subject: title, + react: PaymentNotificationEmail({ title, message, orderId, actionUrl, logoUrl: config.emailLogoUrl }), + }); +} + +module.exports = { isEmailConfigured, sendEmail, sendPasswordResetEmail, sendPaymentNotificationEmail }; diff --git a/Backend/utils/emailTemplates.js b/Backend/utils/emailTemplates.js index 189c391..b9d26a1 100644 --- a/Backend/utils/emailTemplates.js +++ b/Backend/utils/emailTemplates.js @@ -57,4 +57,16 @@ function PasswordResetEmail({ resetUrl, logoUrl }) { ); } -module.exports = { PasswordResetEmail }; +function PaymentNotificationEmail({ title, message, orderId, actionUrl = "", actionLabel = "View dashboard", logoUrl }) { + return h( + EmailShell, + { preview: title, logoUrl }, + h(Text, { style: baseStyles.eyebrow }, "Skillnest payments"), + h(Heading, { style: { color: "#172019", fontSize: "30px", lineHeight: "1.15", margin: "12px 0 16px" } }, title), + h(Text, { style: baseStyles.text }, message), + orderId ? h(Text, { style: baseStyles.text }, `Order: ${orderId}`) : null, + actionUrl ? h(Section, { style: { margin: "28px 0" } }, h(Button, { href: actionUrl, style: baseStyles.button }, actionLabel)) : null, + ); +} + +module.exports = { PasswordResetEmail, PaymentNotificationEmail }; diff --git a/Backend/utils/lemonSqueezy.js b/Backend/utils/lemonSqueezy.js index 668954a..85d12eb 100644 --- a/Backend/utils/lemonSqueezy.js +++ b/Backend/utils/lemonSqueezy.js @@ -108,6 +108,27 @@ const createCheckout = async ({ }; }; +const issueRefund = async ({ providerOrderId, amount = null }) => { + const { apiKey } = getLemonSqueezyConfig(); + if (!providerOrderId) throw new ApiError(400, "Provider order ID is required to issue a refund"); + + const attributes = amount === null ? {} : { amount: normalizeMoneyAmount(amount) }; + const response = await fetch(`https://api.lemonsqueezy.com/v1/orders/${encodeURIComponent(providerOrderId)}/refund`, { + method: "POST", + headers: { + Accept: "application/vnd.api+json", + "Content-Type": "application/vnd.api+json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ data: { type: "orders", id: String(providerOrderId), attributes } }), + }); + const payload = await response.json().catch(() => null); + if (!response.ok) { + throw new ApiError(response.status >= 500 ? 502 : 400, payload?.errors?.[0]?.detail || "Lemon Squeezy refund failed"); + } + return { providerRefundId: String(payload?.data?.id || providerOrderId), raw: payload }; +}; + const verifyWebhookSignature = ({ rawBody, signature }) => { const { webhookSecret } = getLemonSqueezyConfig(); if (!webhookSecret) { @@ -149,6 +170,7 @@ const parseWebhookBody = (body) => { module.exports = { createCheckout, + issueRefund, parseWebhookBody, verifyWebhookSignature, }; diff --git a/Backend/utils/localVideoUpload.js b/Backend/utils/localVideoUpload.js index 4745da6..a6f058c 100644 --- a/Backend/utils/localVideoUpload.js +++ b/Backend/utils/localVideoUpload.js @@ -85,4 +85,20 @@ const cancelUpload = async (actor, uploadId) => { return { uploadId, cancelled: true }; }; -module.exports = { startUpload, getStatus, writeChunk, completeUpload, cancelUpload }; +const cleanupStaleUploads = async (maxAgeMs = 24 * 60 * 60 * 1000) => { + await ensureDirectories(); + const entries = await fs.promises.readdir(tempRoot, { withFileTypes: true }); + let cleaned = 0; + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith(".json")) continue; + const metadataFile = path.join(tempRoot, entry.name); + const stats = await fs.promises.stat(metadataFile); + if (Date.now() - stats.mtimeMs <= maxAgeMs) continue; + const uploadId = entry.name.slice(0, -5); + await Promise.all([fs.promises.rm(metadataPath(uploadId), { force: true }), fs.promises.rm(partPath(uploadId), { force: true })]); + cleaned += 1; + } + return cleaned; +}; + +module.exports = { startUpload, getStatus, writeChunk, completeUpload, cancelUpload, cleanupStaleUploads }; diff --git a/Backend/utils/metrics.js b/Backend/utils/metrics.js index 9c5023a..ed4b6d2 100644 --- a/Backend/utils/metrics.js +++ b/Backend/utils/metrics.js @@ -19,6 +19,15 @@ const requestDuration = new client.Histogram({ registers: [registry], }); +const paymentEvents = new client.Counter({ + name: "coursify_payment_events_total", + help: "Payment state transitions and notifications processed", + labelNames: ["event"], + registers: [registry], +}); + +const paymentEvent = (event) => paymentEvents.inc({ event: String(event || "unknown").slice(0, 60) }); + const requestMetrics = (req, res, next) => { if (!config.metricsEnabled) return next(); const startedAt = process.hrtime.bigint(); @@ -40,4 +49,4 @@ const metricsHandler = async (req, res) => { return res.end(await registry.metrics()); }; -module.exports = { requestMetrics, metricsHandler, registry }; +module.exports = { requestMetrics, metricsHandler, registry, paymentEvent }; diff --git a/Backend/validators/index.js b/Backend/validators/index.js index 44d4a2b..daa3187 100644 --- a/Backend/validators/index.js +++ b/Backend/validators/index.js @@ -64,6 +64,8 @@ const courseSchema = z.object({ isPublished: z.boolean().optional(), isFeatured: z.boolean().optional(), maxSeats: z.coerce.number().int().min(1).nullable().optional(), + seoTitle: z.string().max(180).optional(), + seoDescription: z.string().max(320).optional(), }); const courseUpdateSchema = courseSchema.partial(); @@ -72,6 +74,11 @@ const publishSchema = z.object({ isPublished: z.boolean(), }); +const courseReviewSchema = z.object({ + status: z.enum(["approved", "rejected"]), + reason: z.string().max(500).optional(), +}); + const sectionSchema = z.object({ title: z.string().min(2).max(180), order: z.coerce.number().int().min(1).optional(), @@ -79,11 +86,12 @@ const sectionSchema = z.object({ const lessonSchema = z.object({ title: z.string().min(2).max(180), - type: z.enum(["video", "text", "pdf", "quiz"]), + type: z.enum(["video", "text", "pdf", "quiz", "assignment"]), content: z.string().optional(), videoUrl: z.string().url().optional().or(z.literal("")), fileKey: z.string().min(1).optional().or(z.literal("")), fileUrl: z.string().url().optional().or(z.literal("")), + thumbnailUrl: z.string().url().optional().or(z.literal("")), duration: z.coerce.number().min(0).optional(), isPreview: z.boolean().optional(), order: z.coerce.number().int().min(1).optional(), @@ -100,6 +108,11 @@ const progressSchema = z.object({ isCompleted: z.boolean().optional(), }); +const lessonNoteSchema = z.object({ courseId: objectId, lessonId: objectId, content: z.string().min(1).max(10000) }); +const bookmarkSchema = z.object({ courseId: objectId, lessonId: objectId }); +const quizSubmissionSchema = z.object({ courseId: objectId, lessonId: objectId, answers: z.record(z.any()) }); +const assignmentSubmissionSchema = z.object({ courseId: objectId, lessonId: objectId, content: z.string().min(1).max(20000) }); + const orderSchema = z.object({ courseIds: z.array(objectId).min(1).optional(), noteIds: z.array(objectId).min(1).optional(), @@ -222,10 +235,15 @@ module.exports = { courseSchema, courseUpdateSchema, publishSchema, + courseReviewSchema, sectionSchema, lessonSchema, enrollmentSchema, progressSchema, + lessonNoteSchema, + bookmarkSchema, + quizSubmissionSchema, + assignmentSubmissionSchema, orderSchema, reviewSchema, categorySchema, diff --git a/Frontend/src/hooks/useOrders.js b/Frontend/src/hooks/useOrders.js index 2eefcbe..c63a864 100644 --- a/Frontend/src/hooks/useOrders.js +++ b/Frontend/src/hooks/useOrders.js @@ -18,7 +18,7 @@ export function useOrders({ pollOrderId = "", pollDurationMs = 120000 } = {}) { const orders = Array.isArray(query.state.data) ? query.state.data : []; const order = orders.find((item) => String(item?._id) === String(pollOrderId)); - if (order && ["paid", "failed", "refunded"].includes(order.status)) return false; + if (order && ["paid", "failed", "refunded", "cancelled", "refund_pending", "disputed"].includes(order.status)) return false; return 3000; } @@ -40,7 +40,7 @@ export function useOrderStatus(orderId, pollDurationMs = 120000) { enabled: Boolean(orderId), refetchInterval: (query) => { if (Date.now() - pollStartedAtRef.current >= pollDurationMs) return false; - if (["paid", "failed", "refunded"].includes(query.state.data?.status)) return false; + if (["paid", "failed", "refunded", "cancelled", "refund_pending", "disputed"].includes(query.state.data?.status)) return false; return 3000; }, refetchIntervalInBackground: Boolean(orderId), @@ -89,6 +89,37 @@ export function useReplayWebhook() { }); } +export function useCancelOrder() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id) => orderApi.cancel(id), + onSuccess: (_, id) => { + queryClient.invalidateQueries({ queryKey: ["orders"] }); + queryClient.invalidateQueries({ queryKey: ["order-status", id] }); + }, + }); +} + +export function useReconciliationCases(limit = 50) { + return useQuery({ queryKey: ["payment-reconciliation", limit], queryFn: () => orderApi.reconciliation(limit) }); +} + +export function useRetryReconciliation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id) => orderApi.retryReconciliation(id), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["payment-reconciliation"] }), + }); +} + +export function useResolveDispute() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id) => orderApi.resolveDispute(id), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["payment-reconciliation"] }), + }); +} + export function useCreateOrder() { const queryClient = useQueryClient(); return useMutation({ diff --git a/Frontend/src/pages/admin/OrderManagement.jsx b/Frontend/src/pages/admin/OrderManagement.jsx index e998cf8..c0e3e1a 100644 --- a/Frontend/src/pages/admin/OrderManagement.jsx +++ b/Frontend/src/pages/admin/OrderManagement.jsx @@ -1,4 +1,4 @@ -import { useAdminOrders, useAdminOrderDetails, useAdminRefundOrder, useReplayWebhook, useWebhookMonitoring } from "@/hooks/useOrders"; +import { useAdminOrders, useAdminOrderDetails, useAdminRefundOrder, useReplayWebhook, useWebhookMonitoring, useReconciliationCases, useRetryReconciliation, useResolveDispute } from "@/hooks/useOrders"; import { useState } from "react"; import { toast } from "sonner"; import { Card, Badge, Button } from "@/components/ui"; @@ -17,6 +17,9 @@ export function OrderManagement() { const refundOrder = useAdminRefundOrder(); const webhookQuery = useWebhookMonitoring(20); const replayWebhook = useReplayWebhook(); + const reconciliationQuery = useReconciliationCases(20); + const retryReconciliation = useRetryReconciliation(); + const resolveDispute = useResolveDispute(); if (ordersQuery.isLoading) return ; if (ordersQuery.isError) return ordersQuery.refetch()} />; @@ -24,10 +27,10 @@ export function OrderManagement() { const orders = ordersQuery.data?.data || ordersQuery.data || []; const recordRefund = async (order) => { - if (!window.confirm("Record this paid order as refunded and revoke course access?")) return; + if (!window.confirm("Issue a provider refund for this paid order and revoke course access?")) return; try { await refundOrder.mutateAsync(order._id); - toast.success("Refund recorded and course access revoked"); + toast.success("Refund issued and course access revoked"); } catch (error) { toast.error(getApiErrorMessage(error)); } @@ -44,6 +47,27 @@ export function OrderManagement() { } }; + const retryCase = async (item) => { + try { + await retryReconciliation.mutateAsync(item._id); + toast.success("Payment reconciliation completed"); + await Promise.all([reconciliationQuery.refetch(), ordersQuery.refetch()]); + } catch (error) { + toast.error(getApiErrorMessage(error)); + } + }; + + const restoreDisputeAccess = async (item) => { + if (!window.confirm("Confirm the provider resolved this dispute and restore course access?")) return; + try { + await resolveDispute.mutateAsync(item._id); + toast.success("Dispute resolved and access restored"); + await Promise.all([reconciliationQuery.refetch(), ordersQuery.refetch()]); + } catch (error) { + toast.error(getApiErrorMessage(error)); + } + }; + return (
@@ -96,6 +120,26 @@ export function OrderManagement() { )} + +
+
+

Payment reconciliation

+

Cases where payment, enrollment, or refund processing needs a safe retry.

+
+ +
+ {reconciliationQuery.isLoading ?

Loading reconciliation cases...

: (reconciliationQuery.data || []).length === 0 ?

No open reconciliation cases.

: ( +
+ {(reconciliationQuery.data || []).map((item) => ( +
+

{item.issue.replaceAll("_", " ")}

Order {String(item.orderId).slice(-6)} · Attempts {item.attempts || 0} · {item.lastError || "Needs review"}

+ {item.issue === "dispute_review" ? : } +
+ ))} +
+ )} +
+ {orders.length === 0 ? ( ) : ( @@ -110,7 +154,7 @@ export function OrderManagement() {
{order.status} - {order.status === "paid" ? : null} + {order.status === "paid" ? : null} ₹{order.amount}
diff --git a/Frontend/src/pages/instructor/CourseEditor.jsx b/Frontend/src/pages/instructor/CourseEditor.jsx index 67047f3..aba29b0 100644 --- a/Frontend/src/pages/instructor/CourseEditor.jsx +++ b/Frontend/src/pages/instructor/CourseEditor.jsx @@ -19,11 +19,12 @@ import { fileToDataUrl } from "@/utils/fileToDataUrl"; const lessonSchema = z.object({ _id: z.string().optional(), title: z.string().min(2), - type: z.enum(["video", "text", "pdf", "quiz"]), + type: z.enum(["video", "text", "pdf", "quiz", "assignment"]), content: z.string().optional(), videoUrl: z.string().optional(), fileUrl: z.string().optional(), fileKey: z.string().optional(), + thumbnailUrl: z.string().optional(), duration: z.coerce.number().optional(), isPreview: z.boolean().optional(), order: z.coerce.number().optional(), @@ -48,6 +49,8 @@ const schema = z.object({ language: z.string().optional(), categoryId: z.string().optional(), tagsText: z.string().optional(), + seoTitle: z.string().max(180).optional(), + seoDescription: z.string().max(320).optional(), isPublished: z.boolean().optional(), isFeatured: z.boolean().optional(), maxSeats: z.preprocess((value) => (value === "" || value === undefined || value === null ? null : value), z.coerce.number().int().min(1).nullable()), @@ -81,6 +84,8 @@ export function CourseEditor() { isPublished: false, isFeatured: false, maxSeats: null, + seoTitle: "", + seoDescription: "", sections: [], ...(savedDraft || {}), }, @@ -120,6 +125,8 @@ export function CourseEditor() { isPublished: false, isFeatured: false, maxSeats: null, + seoTitle: "", + seoDescription: "", sections: [], ...(readCourseDraft(draftKey) || {}), }); @@ -184,6 +191,8 @@ export function CourseEditor() { isPublished: Boolean(values.isPublished), isFeatured: Boolean(values.isFeatured), maxSeats: values.maxSeats === null ? null : Number(values.maxSeats), + seoTitle: values.seoTitle || "", + seoDescription: values.seoDescription || "", }; const savedCourse = isEdit ? await courseApi.update(id, payload) : await courseApi.create(payload); @@ -208,6 +217,7 @@ export function CourseEditor() { videoUrl: lesson.videoUrl || "", fileUrl: lesson.fileUrl || "", fileKey: lesson.fileKey || "", + thumbnailUrl: lesson.thumbnailUrl || "", duration: Number(lesson.duration || 0), isPreview: Boolean(lesson.isPreview), order: lesson.order || lessonIndex + 1, @@ -297,6 +307,12 @@ export function CourseEditor() { + + + + +