feat(qualification-criteria): dnd reordering with whole-list PATCH

The chevron up/down buttons rewrote a single row's display_order,
which didn't actually swap positions since the neighbouring rows kept
their original orders. Replaced with a proper drag-handle (dnd-kit
sortable, matching the waiting-list-manager pattern) backed by a new
POST /admin/qualification-criteria/reorder endpoint that rewrites
display_order = index for every row in a transaction. The service
rejects partial / extraneous id lists so a stale UI can't silently
drop a criterion. Optimistic local-cache update keeps the row in
position during the round-trip; rollback on error.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-14 03:49:17 +02:00
parent 905852b8a5
commit 233129f91a
4 changed files with 228 additions and 59 deletions

View File

@@ -14,10 +14,11 @@ import { and, asc, eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { interestQualifications, interests, qualificationCriteria } from '@/lib/db/schema';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { ConflictError, NotFoundError } from '@/lib/errors';
import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors';
import { emitToRoom } from '@/lib/socket/server';
import type {
CreateQualificationCriterionInput,
ReorderQualificationCriteriaInput,
SetInterestQualificationInput,
UpdateQualificationCriterionInput,
} from '@/lib/validators/qualification';
@@ -137,6 +138,70 @@ export async function deleteCriterion(id: string, portId: string, meta: AuditMet
return { ok: true };
}
/**
* Whole-list reorder. Rewrites display_order to match the array index of
* each id, inside a single transaction so partial failure can't leave the
* port with a scrambled order. The ids array must cover exactly the port's
* current criteria — any mismatch (extra or missing id) is rejected so the
* UI can't silently drop a criterion by sending a stale list.
*/
export async function reorderCriteria(
portId: string,
data: ReorderQualificationCriteriaInput,
meta: AuditMeta,
) {
return db
.transaction(async (tx) => {
const current = await tx
.select({ id: qualificationCriteria.id })
.from(qualificationCriteria)
.where(eq(qualificationCriteria.portId, portId));
const currentIds = new Set(current.map((r) => r.id));
const incomingIds = new Set(data.ids);
if (
currentIds.size !== incomingIds.size ||
[...currentIds].some((id) => !incomingIds.has(id))
) {
throw new ValidationError(
'Reorder payload must reference exactly the ports current criteria',
);
}
for (let i = 0; i < data.ids.length; i++) {
await tx
.update(qualificationCriteria)
.set({ displayOrder: i, updatedAt: new Date() })
.where(
and(
eq(qualificationCriteria.id, data.ids[i]!),
eq(qualificationCriteria.portId, portId),
),
);
}
return tx
.select()
.from(qualificationCriteria)
.where(eq(qualificationCriteria.portId, portId))
.orderBy(asc(qualificationCriteria.displayOrder), asc(qualificationCriteria.createdAt));
})
.then((rows) => {
void createAuditLog({
userId: meta.userId,
portId,
action: 'update',
entityType: 'qualification_criterion',
entityId: 'reorder',
newValue: { orderedIds: data.ids },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
return rows;
});
}
// ─── Per-interest state ─────────────────────────────────────────────────────
export interface QualificationRow {