Skip to content

Commit f07afd2

Browse files
authored
Merge pull request #714 from devwums/fix/assigned-issues
Add moderation transition guard, review queue query bounds, reviewer claim lock, and rubric validator helpers
2 parents 2724c78 + 4cc4cda commit f07afd2

4 files changed

Lines changed: 131 additions & 0 deletions

File tree

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
export type ModerationState = 'NONE' | 'FLAGGED' | 'ESCALATED' | 'RESOLVED' | 'DISMISSED';
2+
export type ModerationAction = 'flag' | 'dismiss' | 'escalate' | 'resolve';
3+
4+
const LEGAL_TRANSITIONS: Record<ModerationAction, ModerationState[]> = {
5+
flag: ['NONE'],
6+
dismiss: ['FLAGGED'],
7+
escalate: ['FLAGGED'],
8+
resolve: ['FLAGGED', 'ESCALATED'],
9+
};
10+
11+
const NEXT_STATE: Record<ModerationAction, ModerationState> = {
12+
flag: 'FLAGGED',
13+
dismiss: 'DISMISSED',
14+
escalate: 'ESCALATED',
15+
resolve: 'RESOLVED',
16+
};
17+
18+
export interface ModerationAuditRecord {
19+
action: ModerationAction;
20+
actorId: string;
21+
fromState: ModerationState;
22+
toState: ModerationState;
23+
reason: string;
24+
at: Date;
25+
}
26+
27+
/** Validates a moderation transition and returns its immutable audit record. */
28+
export function applyModerationTransition(
29+
current: ModerationState,
30+
action: ModerationAction,
31+
actorId: string,
32+
reason: string,
33+
): ModerationAuditRecord {
34+
if (!LEGAL_TRANSITIONS[action].includes(current)) {
35+
throw new Error(`Illegal moderation transition: ${action} from ${current}`);
36+
}
37+
return {
38+
action,
39+
actorId,
40+
fromState: current,
41+
toState: NEXT_STATE[action],
42+
reason,
43+
at: new Date(),
44+
};
45+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
export interface ReviewQueueFilters {
2+
status?: string;
3+
maxAgeHours?: number;
4+
slaBreachedOnly?: boolean;
5+
}
6+
7+
export interface ReviewQueueQuery {
8+
filters: ReviewQueueFilters;
9+
page: number;
10+
pageSize: number;
11+
sortBy: 'createdAt' | 'slaDeadline';
12+
}
13+
14+
export const MAX_PAGE_SIZE = 100;
15+
16+
/** Builds a bounded, deterministic review-queue query from raw params. */
17+
export function buildReviewQueueQuery(
18+
filters: ReviewQueueFilters,
19+
page = 1,
20+
pageSize = 25,
21+
sortBy: ReviewQueueQuery['sortBy'] = 'slaDeadline',
22+
): ReviewQueueQuery {
23+
return {
24+
filters,
25+
page: Math.max(1, page),
26+
pageSize: Math.min(Math.max(1, pageSize), MAX_PAGE_SIZE),
27+
sortBy,
28+
};
29+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
export interface ClaimableSubmission {
2+
submissionId: string;
3+
claimedBy?: string;
4+
claimedAt?: Date;
5+
}
6+
7+
export class AlreadyClaimedError extends Error {
8+
constructor(submissionId: string) {
9+
super(`Submission ${submissionId} is already claimed`);
10+
}
11+
}
12+
13+
/**
14+
* Atomically claims a submission for review. Callers must pass the
15+
* current record read within the same transaction/lock to avoid races.
16+
*/
17+
export function claimForReview(
18+
current: ClaimableSubmission,
19+
reviewerId: string,
20+
): ClaimableSubmission {
21+
if (current.claimedBy && current.claimedBy !== reviewerId) {
22+
throw new AlreadyClaimedError(current.submissionId);
23+
}
24+
return { ...current, claimedBy: reviewerId, claimedAt: new Date() };
25+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
export interface RubricCriterion {
2+
weight: number;
3+
score: number;
4+
maxScore: number;
5+
}
6+
7+
export class InvalidRubricError extends Error {}
8+
9+
const EPSILON = 0.001;
10+
11+
/** Validates rubric weights sum to 1 and each score is within bounds. */
12+
export function validateRubric(criteria: RubricCriterion[]): void {
13+
const totalWeight = criteria.reduce((sum, c) => sum + c.weight, 0);
14+
if (Math.abs(totalWeight - 1) > EPSILON) {
15+
throw new InvalidRubricError(`Rubric weights must sum to 1, got ${totalWeight}`);
16+
}
17+
for (const c of criteria) {
18+
if (c.score < 0 || c.score > c.maxScore) {
19+
throw new InvalidRubricError(`Score ${c.score} out of bounds [0, ${c.maxScore}]`);
20+
}
21+
}
22+
}
23+
24+
/** Computes the weighted total score, rounded to two decimal places. */
25+
export function computeTotalScore(criteria: RubricCriterion[]): number {
26+
validateRubric(criteria);
27+
const total = criteria.reduce(
28+
(sum, c) => sum + (c.score / c.maxScore) * c.weight * 100,
29+
0,
30+
);
31+
return Math.round(total * 100) / 100;
32+
}

0 commit comments

Comments
 (0)