Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
5e91e82
feat: init tutor notes and mentorship
b0ink Jan 7, 2026
a74742a
feat: tutor moderation route
b0ink Jan 8, 2026
569c829
feat: add moderator actions
b0ink Jan 12, 2026
9f15954
refactor: add confirmation modals
b0ink Jan 12, 2026
c380be7
refactor: move moderation buttons to footer
b0ink Jan 13, 2026
9843bbb
feat: filter tutor notes by task
b0ink Jan 13, 2026
1e92d8a
refactor: mark tutor notes as read
b0ink Jan 13, 2026
1d60f44
refactor: view tutor notes via modal
b0ink Jan 13, 2026
4b0239e
fix: remove task definition filters for moderation
b0ink Feb 2, 2026
3ab7cdc
chore: add dividers for moderation buttons
b0ink Feb 2, 2026
b167de9
refactor: add tooltip
b0ink Feb 3, 2026
2da6c29
refactor: clean up tasks tutor getter
b0ink Feb 3, 2026
335378a
refactor: rename vars to tutor notes
b0ink Feb 3, 2026
bbe8537
refactor: add typing
b0ink Feb 3, 2026
c34a12c
refactor: clean up unused code
b0ink Feb 3, 2026
bbdd745
fix: check for valid tutor
b0ink Feb 3, 2026
eebfb36
feat: init escalation request
b0ink Feb 9, 2026
cd2614d
chore: add moderation type field
b0ink Feb 9, 2026
6623ee1
refactor: add alternate ui for escalated tasks
b0ink Feb 9, 2026
d75e029
Merge branch '10.0.x' into feat/tutor-notes
b0ink Feb 15, 2026
8f7ae52
chore: add moderation action type
b0ink Feb 17, 2026
bffcfe3
feat: feedback appeal modal
b0ink Feb 17, 2026
3f819d1
feat: add feedback review requested comment
b0ink Feb 17, 2026
c98be20
chore: revert task-submission-card
b0ink Feb 17, 2026
9221572
feat: add moderation confirmation modal and allow dismiss all
b0ink Feb 17, 2026
ec45c3c
chore: dismiss modal
b0ink Feb 17, 2026
f1f6aed
feat: filter tasks by tutor
b0ink Feb 17, 2026
e646f83
refactor: style action buttons
b0ink Feb 17, 2026
edc55ae
chore: rename staff to tutor
b0ink Feb 17, 2026
5bc3e92
refactor: improve wording
b0ink Feb 17, 2026
a0fe6ca
refactor: improve styling
b0ink Feb 17, 2026
e5c5a8d
chore: add relevant icon
b0ink Feb 17, 2026
4948cc0
refactor: update success alert
b0ink Feb 17, 2026
704da11
chore: increase alert duration
b0ink Feb 17, 2026
f2a5eb0
chore: display tasks tutor
b0ink Feb 17, 2026
a0b89f4
refactor: sort tutors alphabetically
b0ink Feb 17, 2026
e153791
Merge branch '10.0.x' into feat/tutor-notes
b0ink Feb 18, 2026
5c4f7cd
chore: remove divider on the right
b0ink Feb 18, 2026
31c1db7
Merge branch '10.0.x' into feat/tutor-notes
b0ink Feb 18, 2026
c7f3603
refactor: require a comment for review requests
b0ink Feb 18, 2026
8cdc482
chore: increase modal max width
b0ink Feb 18, 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 src/app/api/models/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ export class Project extends Entity {

public hasPortfolio: boolean;
public portfolioStatus: number;
public portfolioFiles: { kind: string; name: string; idx: number }[];
public portfolioFiles: {kind: string; name: string; idx: number}[];
public escalationAttemptsRemaining: number;

public taskStats: {
key: TaskStatusEnum;
Expand Down
61 changes: 61 additions & 0 deletions src/app/api/models/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import {
TestAttempt,
TestAttemptService,
ScormComment,
UnitRoleService,
UnitRole,
} from './doubtfire-model';
import {Grade} from './grade';
import {LOCALE_ID} from '@angular/core';
Expand All @@ -27,6 +29,18 @@ import {gradeTaskModal, uploadSubmissionModal} from 'src/app/ajs-upgraded-provid
import {AlertService} from 'src/app/common/services/alert.service';
import {MappingFunctions} from '../services/mapping-fn';

export const FeedbackModerationAction = {
ShowMore: 'show_more',
ShowLess: 'show_less',
DismissOk: 'dismiss_ok',
DismissGood: 'dismiss_good',
Upheld: 'upheld',
Overturn: 'overturn',
} as const;

export type FeedbackModerationActionType =
(typeof FeedbackModerationAction)[keyof typeof FeedbackModerationAction];

export class Task extends Entity {
id: number;

Expand All @@ -44,6 +58,8 @@ export class Task extends Entity {
numNewComments: number = 0;
hasExtensions: boolean;

moderationType: 'random_sample' | 'escalation' | 'first_feedback';

project: Project;
definition: TaskDefinition;

Expand Down Expand Up @@ -93,6 +109,16 @@ export class Task extends Entity {
return this.commentCache.currentValues;
}

public get tutor(): UnitRole {
const enrolments = this.project.tutorialEnrolmentsCache.currentValues.filter(
(t) => t.tutorialStream.name === this.definition.tutorialStream.name,
);
if (enrolments.length === 1) {
const user = enrolments[0].tutor;
return this.unit.staff.find((ur) => ur.user.id === user.id);
}
}

public addComment(textString): void {
AppInjector.get(TaskCommentService)
.addComment(this, textString, 'text')
Expand Down Expand Up @@ -982,4 +1008,39 @@ export class Task extends Entity {

return false;
}

public moderateFeedback(
action: FeedbackModerationActionType,
applyToAll: boolean = false,
): Observable<boolean> {
const unitRoleService: UnitRoleService = AppInjector.get(UnitRoleService);

const tutor = this.tutor;
if (!tutor) {
return;
}

return unitRoleService.post(
{
id: tutor.id,
taskId: this.id,
},
{
endpointFormat: '/unit_roles/:id:/moderation/:taskId:',
body: {
action,
apply_to_all: applyToAll,
},
},
);
}

public requestFeedbackReview(): Observable<boolean> {
const httpClient: HttpClient = AppInjector.get(HttpClient);
const url = `${AppInjector.get(DoubtfireConstants).API_URL}/projects/${
this.project.id
}/task_def_id/${this.definition.id}/feedback_review`;

return httpClient.post<boolean>(url, {});
}
}
55 changes: 55 additions & 0 deletions src/app/api/models/tutor-note.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import {Entity} from 'ngx-entity-service';
import {AppInjector} from 'src/app/app-injector';
import {AlertService} from 'src/app/common/services/alert.service';
import {TutorNoteService} from '../services/tutor-note.service';
import {Project, Task, TaskDefinition, UnitRole, User, UserService} from './doubtfire-model';

export class TutorNote extends Entity {
id: number;

unitRole: UnitRole;
task?: Task;
taskDefinition?: TaskDefinition;
project?: Project;
user: User;
note: string;
replyTo?: TutorNote;
replyToId: number;
readByUnitRole: boolean;

createdAt: Date;
updatedAt: Date;

constructor(data?: UnitRole) {
super();
if (data) {
this.unitRole = data;
} else {
console.error('Failed to get unit role');
}
}

public get noteIsForMe(): boolean {
const userService: UserService = AppInjector.get(UserService);
return this.unitRole.user.id === userService.currentUser.id;
}

public get authorIsMe(): boolean {
const userService: UserService = AppInjector.get(UserService);
return this.user.id === userService.currentUser.id;
}

public delete() {
const tutorNoteService: TutorNoteService = AppInjector.get(TutorNoteService);
tutorNoteService
.delete({unitRoleId: this.unitRole.id, id: this.id}, {cache: this.unitRole.tutorNotesCache})
.subscribe({
next: () => {
AppInjector.get(AlertService).error('Successfully deleted tutor note', 4000);
},
error: (error) => {
AppInjector.get(AlertService).error(error?.message || error || 'Unknown error', 2000);
},
});
}
}
22 changes: 16 additions & 6 deletions src/app/api/models/unit-role.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,25 @@
import { Entity, EntityMapping } from 'ngx-entity-service';
import { User, Unit } from './doubtfire-model';
import {Entity, EntityCache, EntityMapping} from 'ngx-entity-service';
import {User, Unit} from './doubtfire-model';
import {TutorNote} from './tutor-note';

/**
* A unit role represents a academic teaching role within a unit. Linking a user
* to their role within the unit.
*/
export class UnitRole extends Entity {

id: number;
role: string;
user: User;
unit: Unit;
observerOnly: boolean;
mentorId: number;
tutorNoteCount: number;

public get mentor(): UnitRole {
return this.unit?.staff.find((ur) => ur.id === this.mentorId);
}

public readonly tutorNotesCache: EntityCache<TutorNote> = new EntityCache<TutorNote>();

/**
* The id for updated roles - but we need to move away from this to the role string...
Expand All @@ -29,10 +37,12 @@ export class UnitRole extends Entity {
return this.user.matches(text) || this.unit.matches(text);
}

public override toJson<T extends Entity>(mappingData: EntityMapping<T>, ignoreKeys?: string[]): object {
public override toJson<T extends Entity>(
mappingData: EntityMapping<T>,
ignoreKeys?: string[],
): object {
return {
unit_role: super.toJson(mappingData, ignoreKeys),
}
};
}

}
1 change: 1 addition & 0 deletions src/app/api/services/project.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ export class ProjectService extends CachedEntityService<Project> {
});
},
},
'escalationAttemptsRemaining',
);

this.mapping.addJsonKey(
Expand Down
20 changes: 20 additions & 0 deletions src/app/api/services/task.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export class TaskService extends CachedEntityService<Task> {

private readonly taskInboxEndpoint = '/units/:id:/tasks/inbox';
private readonly taskExplorerEndpoint = '/units/:id:/task_definitions/:task_def_id:/tasks';
private readonly taskModerationEndpoint = '/units/:id:/tasks/moderation';
private readonly refreshTaskEndpoint = 'projects/:projectId:/refresh_tasks/:taskDefinitionId:';

constructor(httpClient: HttpClient) {
Expand Down Expand Up @@ -97,6 +98,7 @@ export class TaskService extends CachedEntityService<Task> {
});
},
},
'moderationType',
);

this.mapping.addJsonKey('qualityPts', 'grade', 'includeInPortfolio', 'trigger');
Expand Down Expand Up @@ -155,6 +157,24 @@ export class TaskService extends CachedEntityService<Task> {
);
}

public queryTasksForMentorModeration(unit: Unit): Observable<Task[]> {
const cache: EntityCache<Task> = new EntityCache<Task>();
return this.query(
{
id: unit.id,
},
{
endpointFormat: this.taskModerationEndpoint,
cache: cache,
constructorParams: unit,
},
).pipe(
tap((tasks: Task[]) => {
unit.incorporateTasks(tasks);
}),
);
}

public refreshExtensionDetails(task: Task): void {
const pathIds = {
projectId: task.project.id,
Expand Down
Loading