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:
@@ -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 port’s 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 {
|
||||
|
||||
@@ -22,6 +22,15 @@ export const updateQualificationCriterionSchema = createQualificationCriterionSc
|
||||
.omit({ key: true })
|
||||
.partial();
|
||||
|
||||
/**
|
||||
* Whole-list reorder. The IDs array must cover exactly the port's current
|
||||
* criteria — the service rejects partial / extraneous IDs to keep the
|
||||
* resulting display_order contiguous.
|
||||
*/
|
||||
export const reorderQualificationCriteriaSchema = z.object({
|
||||
ids: z.array(z.string().min(1)).min(1),
|
||||
});
|
||||
|
||||
/**
|
||||
* Per-interest qualification state. Only `confirmed` + optional `notes` are
|
||||
* writable — `confirmedAt` / `confirmedBy` are stamped server-side from
|
||||
@@ -36,3 +45,4 @@ export const setInterestQualificationSchema = z.object({
|
||||
export type CreateQualificationCriterionInput = z.infer<typeof createQualificationCriterionSchema>;
|
||||
export type UpdateQualificationCriterionInput = z.infer<typeof updateQualificationCriterionSchema>;
|
||||
export type SetInterestQualificationInput = z.infer<typeof setInterestQualificationSchema>;
|
||||
export type ReorderQualificationCriteriaInput = z.infer<typeof reorderQualificationCriteriaSchema>;
|
||||
|
||||
Reference in New Issue
Block a user