Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
40416d8
feat(peer-review/frontend): implement peer-review student and teacher…
shreyasmene06 Jul 11, 2026
9add502
feat(peer-review/backend): implement backend service, repositories, c…
shreyasmene06 Jul 11, 2026
ddaf644
test/docs(peer-review): add E2E happy-path spec and contributing docu…
shreyasmene06 Jul 11, 2026
927adfd
fix(frontend): fix JSX syntax error in expandable-question-card.tsx t…
shreyasmene06 Jul 29, 2026
807cd56
test(e2e): add comprehensive Playwright E2E spec for full peer-review…
shreyasmene06 Jul 29, 2026
0daf2b6
fix(peer-review): include peer_review_assessments in readItemById
shreyasmene06 Jul 29, 2026
14dfe4d
test(e2e): add Playwright spec for full peer-review UI workflow
shreyasmene06 Jul 29, 2026
1091c3d
refactor(peer-review): use assignmentRepo.findByAssessment in runForA…
shreyasmene06 Jul 30, 2026
d38c7b0
refactor(peer-review): drop unused lastFallbackPairs and collapse fal…
shreyasmene06 Jul 30, 2026
24a80c4
fix(peer-review): fix dynamic review total, immediate score calculati…
shreyasmene06 Aug 1, 2026
78050cb
feat(peer-review): add interactive analytics dashboard & reciprocal c…
shreyasmene06 Aug 1, 2026
ca295cf
feat(peer-review): add interactive analytics dashboard & reciprocal c…
shreyasmene06 Aug 1, 2026
e11764c
fix(peer-review): restore score override handlers & fix teacher manua…
shreyasmene06 Aug 1, 2026
c4e9ca8
feat(peer-review): implement direct overall final score override for …
shreyasmene06 Aug 1, 2026
cc3c069
feat(peer-review): implement rubric-based evaluation override for stu…
shreyasmene06 Aug 1, 2026
eac4e62
fix(peer-review): fix imperative create modal styling for dark mode c…
shreyasmene06 Aug 1, 2026
443becd
feat(peerReview): add teacher manual reviewer disqualification and UI…
shreyasmene06 Aug 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@
"#genAI/*.js": "./build/modules/genAI/*.js",
"#settings/*.js": "./build/modules/settings/*.js",
"#setting/*.js": "./build/modules/setting/*.js",
"#anomalies/*.js": "./build/modules/anomalies/*.js"
"#anomalies/*.js": "./build/modules/anomalies/*.js",
"#peerReview/*.js": "./build/modules/peerReview/*.js"
},
"type": "module",
"keywords": [],
Expand Down
3 changes: 2 additions & 1 deletion backend/src/bootstrap/jobs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import './backupDb.js';
import './allocateHp.js'
import './backfillFollowUpInvites.js';
import './evaluateSlotFulfillment.js';
import './peerReviewCrons.js';

export const initJobs = () => {
console.log('[CRON] Jobs initialized.');
};
};
69 changes: 69 additions & 0 deletions backend/src/bootstrap/jobs/peerReviewCrons.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/**
* Peer-review cron registration.
*
* Phase 4.2.2 + audit-improvement. Matches the existing cron
* registration pattern (`evaluateSlotFulfillment.ts`): import for
* side effects, schedule at module load time using getContainer().
*
* Public API:
* - registerPeerReviewCrons(container): explicit registration,
* used by tests + manual bootstrap paths.
*
* The side-effect import below auto-registers when this module is
* imported. The `bootstrap/jobs/index.ts` aggregator already pulls
* us in via `import './peerReviewCrons.js'`.
*/
import { Container } from 'inversify';
import { AssignmentRunner } from '#peerReview/cron/AssignmentRunner.js';
import { ReassignmentRunner } from '#peerReview/cron/ReassignmentRunner.js';
import { FinalizationRunner } from '#peerReview/cron/FinalizationRunner.js';
import { DueDateReminderRunner } from '#peerReview/cron/DueDateReminderRunner.js';
import { PEERREVIEW_TYPES } from '#peerReview/types.js';
import { getContainer } from '#root/bootstrap/loadModules.js';

let registered = false;

export function registerPeerReviewCrons(container: Container): void {
if (registered) return;
registered = true;
const assignment = container.get<AssignmentRunner>(
PEERREVIEW_TYPES.AssignmentRunner,
);
const reassignment = container.get<ReassignmentRunner>(
PEERREVIEW_TYPES.ReassignmentRunner,
);
const finalization = container.get<FinalizationRunner>(
PEERREVIEW_TYPES.FinalizationRunner,
);
const dueReminders = container.get<DueDateReminderRunner>(
PEERREVIEW_TYPES.DueDateReminderRunner,
);
assignment.scheduleCron();
reassignment.scheduleCron();
finalization.scheduleCron();
dueReminders.scheduleCron();
console.log('[peerReview] crons registered');
}

// Side-effect self-registration at module load time. Safe because
// getContainer() returns the populated container by the time the
// `import './peerReviewCrons.js'` line in bootstrap/jobs/index.ts
// runs (loadModules completes before startCron).
//
// In practice the side-effect import fires BEFORE the container is
// populated (the import chain runs before `await loadAppModules()` in
// index.ts), so the catch below hits. Production uses the explicit
// registerPeerReviewCrons(getContainer()) call from startCron() AFTER
// loadAppModules completes — that path is the supported one. This
// try/catch remains so test paths that import peerReviewCrons.js for
// type access don't crash on `getContainer()`.
try {
registerPeerReviewCrons(getContainer());
} catch (e) {
if (process.env.NODE_ENV !== 'test') {
console.warn(
'[peerReview] crons not registered at load (will retry from startCron):',
e instanceof Error ? e.message : String(e),
);
}
}
18 changes: 18 additions & 0 deletions backend/src/modules/auditTrails/interfaces/IAuditTrails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export enum AuditCategory {
ANNOUNCEMENT = 'ANNOUNCEMENT',
COHORT = 'COHORT',
EJECTION_POLICY = 'EJECTION_POLICY',
PEER_REVIEW = 'PEER_REVIEW',
}

export enum AuditAction {
Expand Down Expand Up @@ -117,6 +118,18 @@ export enum AuditAction {
EJECTION_POLICY_TOGGLE = 'EJECTION_POLICY_TOGGLE',
EJECTION_POLICY_DELETE = 'EJECTION_POLICY_DELETE',
COHORT_MOVE = 'COHORT_MOVE',

// Peer-Review Assessment (Phase 1 enum only; emissions come in later phases)
PEER_REVIEW_ASSESSMENT_CREATE = 'PEER_REVIEW_ASSESSMENT_CREATE',
PEER_REVIEW_ASSESSMENT_UPDATE = 'PEER_REVIEW_ASSESSMENT_UPDATE',
PEER_REVIEW_SUBMISSION_CREATE = 'PEER_REVIEW_SUBMISSION_CREATE',
PEER_REVIEW_ASSIGNMENTS_CREATED = 'PEER_REVIEW_ASSIGNMENTS_CREATED',
PEER_REVIEW_REVIEW_SUBMITTED = 'PEER_REVIEW_REVIEW_SUBMITTED',
PEER_REVIEW_REASSIGNED = 'PEER_REVIEW_REASSIGNED',
PEER_REVIEW_TEACHER_OVERRIDE = 'PEER_REVIEW_TEACHER_OVERRIDE',
PEER_REVIEW_ASSESSMENT_CLOSED = 'PEER_REVIEW_ASSESSMENT_CLOSED',
PEER_REVIEW_ASSESSMENT_DELETED = 'PEER_REVIEW_ASSESSMENT_DELETED',
PEER_REVIEW_SCORE_COMPUTED = 'PEER_REVIEW_SCORE_COMPUTED',
}

export enum OutComeStatus {
Expand Down Expand Up @@ -154,6 +167,11 @@ export interface InstructorAuditTrail {
userId?: string | ObjectId;
cohortId?: string | ObjectId;
policyId?: string | ObjectId;
// Peer-review context (used in later phases; harmless to declare now)
peerReviewAssessmentId?: string | ObjectId;
peerReviewSubmissionId?: string | ObjectId;
peerReviewAssignmentId?: string | ObjectId;
peerReviewReviewId?: string | ObjectId;
};

changes?: {
Expand Down
72 changes: 71 additions & 1 deletion backend/src/modules/courses/classes/transformers/Item.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,15 @@ import {
IVideoDetails,
IBlogDetails,
IFeedBackFormDetails,
IPeerReviewAssessmentDetails,
} from '#root/shared/interfaces/models.js';

export type Item = QuizItem | VideoItem | BlogItem | ProjectItem;
export type Item =
| QuizItem
| VideoItem
| BlogItem
| ProjectItem
| PeerReviewAssessmentItem;

class QuizItem {
@Expose()
Expand Down Expand Up @@ -313,6 +319,57 @@ class ProjectItem {
}
}

class PeerReviewAssessmentItem {
@Expose()
@Transform(ObjectIdToString.transformer, { toPlainOnly: true })
@Transform(StringToObjectId.transformer, { toClassOnly: true })
_id?: ID;

@Expose()
name: string;

@Expose()
isOptional?: boolean = false;

@Expose()
description: string;

@Expose()
type: ItemType = ItemType.PEER_REVIEW_ASSESSMENT;

@Expose()
details?: IPeerReviewAssessmentDetails;

@Expose()
isDeleted?: boolean;

@Expose()
deletedAt?: Date;

@Expose()
isHidden?: boolean;

constructor(
name: string,
description: string,
_id: ID,
details?: IPeerReviewAssessmentDetails,
isOptional: boolean = false,
) {
this._id = _id;
this.type = ItemType.PEER_REVIEW_ASSESSMENT;
this.name = name;
this.description = description;
this.isOptional = isOptional;

if (details) {
this.details = details;
}
this.isDeleted = false;
this.deletedAt = undefined;
}
}

class ItemBase {
@Expose()
@Transform(ObjectIdToString.transformer, { toPlainOnly: true })
Expand Down Expand Up @@ -377,6 +434,18 @@ class ItemBase {
itemBody.details,
);
break;
case ItemType.PEER_REVIEW_ASSESSMENT:
// Peer-review assessment items carry a slim details blob (assessmentId,
// rubric summary, deadlines). The full assessment doc is created in
// the same controller call (Phase 2.2.2) and linked via assessmentId.
this.itemDetails = new PeerReviewAssessmentItem(
itemBody.name,
itemBody.description,
this.itemId,
itemBody.peerReviewAssessmentDetails,
itemBody.isOptional,
);
break;
case ItemType.FEEDBACK:
this.itemDetails = new FeedBackFormItem(
itemBody.name,
Expand Down Expand Up @@ -479,6 +548,7 @@ export {
VideoItem,
BlogItem,
ProjectItem,
PeerReviewAssessmentItem,
FeedBackFormItem,
FeedbackSubmissionItem,
};
51 changes: 46 additions & 5 deletions backend/src/modules/courses/services/ItemService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,8 +130,24 @@ export class ItemService extends BaseService {
throw new NotFoundError(
`Section ${sectionId} not found in module ${moduleId}.`,
);
// Guard against legacy/seeded sections that never got an itemsGroupId
// (e.g. the auto-seeded Demo Course). Without this, `itemsGroupId.toString()`
// crashes with "Cannot read properties of undefined" and the GET
// returns 500, which is what surfaces as "I added a peer-review
// assessment and the page reloaded showing nothing" — the create
// succeeded, but the sidebar items fetch crashed so the new item
// never appeared.
const itemsGroupId = section?.itemsGroupId;
if (!itemsGroupId) {
return {
version,
module,
section,
itemsGroup: { _id: undefined as any, items: [] } as ItemsGroup,
};
}
const itemsGroup = await this.itemRepo.readItemsGroup(
typeof section?.itemsGroupId === 'string' ? section.itemsGroupId : section.itemsGroupId.toString(),
typeof itemsGroupId === 'string' ? itemsGroupId : itemsGroupId.toString(),
session,
);
if (!itemsGroup) {
Expand Down Expand Up @@ -187,10 +203,17 @@ export class ItemService extends BaseService {
);
// Check if any previous "learning item" exists before making the feedback form in the db
if (body.type === ItemType.FEEDBACK) {
const dbItemsGroup = await this.itemRepo.readItemsGroup(
typeof section.itemsGroupId === 'string' ? section.itemsGroupId : section.itemsGroupId.toString(),
session,
);
// Same guard as in _getVersionModuleSectionAndItemsGroup — a section
// without an itemsGroupId has zero items, so the "feedback cannot be
// first" check below is the right answer without needing a DB read.
const dbItemsGroup = section.itemsGroupId
? await this.itemRepo.readItemsGroup(
typeof section.itemsGroupId === 'string'
? section.itemsGroupId
: section.itemsGroupId.toString(),
session,
)
: null;

const sectionItems = dbItemsGroup?.items || [];

Expand Down Expand Up @@ -916,6 +939,24 @@ export class ItemService extends BaseService {
// Check item type
if (item.type === 'FEEDBACK') {
await this.feedbackRepo.deleteSubmissionsByFormId(itemId, session);
} else if (item.type === 'PEER_REVIEW_ASSESSMENT') {
const subColl = await this.database.getCollection('peer_review_submissions');
const queryId = ObjectId.isValid(itemId) ? new ObjectId(itemId) : itemId;
const assessmentColl = await this.database.getCollection('peer_review_assessments');
const assessment = await assessmentColl.findOne({ itemId: queryId as any });
if (assessment) {
const subCount = await subColl.countDocuments({ assessmentId: assessment._id as any });
if (subCount > 0) {
throw new ForbiddenError(
'Cannot delete an assessment after a student has submitted. Submissions are part of the student audit trail.',
);
}
await assessmentColl.updateOne(
{ _id: assessment._id },
{ $set: { isDeleted: true, deletedAt: new Date() } },
{ session },
);
}
}
// Step 1: Delete item
const deleted = await this.itemRepo.deleteItem(
Expand Down
47 changes: 16 additions & 31 deletions backend/src/modules/courses/services/deleteCronService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,39 +60,24 @@ export class DeleteCronService extends BaseService {
console.log(
`⏰ Running parallel progress cron for ${/*courseVersionMap.length*/''} course versions`,
);
// [local-dev] Run inside the cron closure only — was previously called
// at function-scope (i.e. at server boot), which crashed an empty local DB.
try {
const response =
await this.enrollmentService.bulkUpdateCompletedItemsCountParallelPerCourseVersion();
console.log(
`🎉 Parallel progress cron completed \n Total count : ${response?.totalCount} \n Updated count : ${response?.updatedCount}`,
);
} catch (err) {
console.warn(
'[DeleteCronService] progress cron skipped:',
(err as Error)?.message || err,
);
}
});

// (Lines below are leftover commented-out historical code from upstream —
// left as-is so a future rebasemerge diff is easy to read.)
// const results = await Promise.allSettled(
// courseVersionMap.map(({ courseId, versionId }) =>
// this.enrollmentService.bulkUpdateCompletedItemsCountParallelPerCourseVersion(
// courseId,
// versionId,
// ),
// ),
// );
const response = await this.enrollmentService.bulkUpdateCompletedItemsCountParallelPerCourseVersion();
// results.forEach((result, index) => {
// const { courseId, versionId } = courseVersionMap[index];

// if (result.status === 'fulfilled') {
// console.log(
// `✅ Course ${courseId} | Version ${versionId} completed`,
// `Total count:${result.value.totalCount}`, `Updated count:${result.value.updatedCount}`,
// );
// } else {
// console.error(
// `❌ Course ${courseId} | Version ${versionId} failed`,
// result.reason?.message || result.reason,
// );
// }
// });

console.log(`🎉 Parallel progress cron completed \n
Total count : ${response.totalCount} \n
Updated count : ${response.updatedCount}`);
// });

// console.log('🗓️ Progress update cron scheduled (hourly, parallel)');
}


Expand Down
Loading
Loading