feat: pessimistic locking for offer editing
When user A opens an offer, a lock is acquired (locked_by + locked_at). User B opening the same offer sees a warning banner and the form is read-only. Lock expires after 5 minutes without heartbeat. Backend: - POST /:id/lock — acquire lock (returns 423 if locked by another) - POST /:id/heartbeat — refresh lock timestamp (every 2 min) - POST /:id/unlock — release lock - GET /:id — includes locked_by info - PUT /:id — auto-releases lock on save Frontend: - Acquires lock on page load (edit mode only) - Sends heartbeat every 2 minutes - Releases lock on page unmount (navigate away) - Shows warning banner with locker's name - All inputs read-only + action buttons hidden when locked Migration: adds locked_by (INT) and locked_at (DATETIME) to quotations. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ import { success, error, parseId } from '../../utils/response';
|
||||
import { parsePagination, buildPaginationMeta } from '../../utils/pagination';
|
||||
import { parseBody } from '../../schemas/common';
|
||||
import { CreateQuotationSchema, UpdateQuotationSchema } from '../../schemas/offers.schema';
|
||||
import prisma from '../../config/database';
|
||||
import {
|
||||
listOffers,
|
||||
getOffer,
|
||||
@@ -16,6 +17,8 @@ import {
|
||||
getNextOfferNumber,
|
||||
} from '../../services/offers.service';
|
||||
|
||||
const LOCK_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes — lock expires if no heartbeat
|
||||
|
||||
|
||||
export default async function quotationsRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
fastify.get('/', { preHandler: requirePermission('offers.view') }, async (request, reply) => {
|
||||
@@ -68,7 +71,83 @@ export default async function quotationsRoutes(fastify: FastifyInstance): Promis
|
||||
const data = await getOffer(id);
|
||||
if (!data) return error(reply, 'Nabídka nenalezena', 404);
|
||||
|
||||
return success(reply, data);
|
||||
// Include lock info
|
||||
const quotation = await prisma.quotations.findUnique({
|
||||
where: { id },
|
||||
select: { locked_by: true, locked_at: true },
|
||||
});
|
||||
let lockedBy: { user_id: number; username: string; full_name: string } | null = null;
|
||||
if (quotation?.locked_by && quotation?.locked_at) {
|
||||
const lockAge = Date.now() - new Date(quotation.locked_at).getTime();
|
||||
if (lockAge < LOCK_TIMEOUT_MS && quotation.locked_by !== request.authData!.userId) {
|
||||
const lockUser = await prisma.users.findUnique({
|
||||
where: { id: quotation.locked_by },
|
||||
select: { id: true, username: true, first_name: true, last_name: true },
|
||||
});
|
||||
if (lockUser) {
|
||||
lockedBy = { user_id: lockUser.id, username: lockUser.username, full_name: `${lockUser.first_name} ${lockUser.last_name}`.trim() };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return success(reply, { ...data, locked_by: lockedBy });
|
||||
});
|
||||
|
||||
// POST /api/admin/offers/:id/lock — acquire lock
|
||||
fastify.post<{ Params: { id: string } }>('/:id/lock', { preHandler: requirePermission('offers.edit') }, async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
|
||||
const quotation = await prisma.quotations.findUnique({
|
||||
where: { id },
|
||||
select: { locked_by: true, locked_at: true },
|
||||
});
|
||||
if (!quotation) return error(reply, 'Nabídka nenalezena', 404);
|
||||
|
||||
// Check if locked by someone else and lock is fresh
|
||||
if (quotation.locked_by && quotation.locked_at) {
|
||||
const lockAge = Date.now() - new Date(quotation.locked_at).getTime();
|
||||
if (lockAge < LOCK_TIMEOUT_MS && quotation.locked_by !== request.authData!.userId) {
|
||||
const lockUser = await prisma.users.findUnique({
|
||||
where: { id: quotation.locked_by },
|
||||
select: { first_name: true, last_name: true },
|
||||
});
|
||||
return error(reply, `Nabídku právě upravuje ${lockUser ? `${lockUser.first_name} ${lockUser.last_name}`.trim() : 'jiný uživatel'}`, 423);
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.quotations.update({
|
||||
where: { id },
|
||||
data: { locked_by: request.authData!.userId, locked_at: new Date() },
|
||||
});
|
||||
|
||||
return success(reply, null, 200, 'Zámek nastaven');
|
||||
});
|
||||
|
||||
// POST /api/admin/offers/:id/heartbeat — keep lock alive
|
||||
fastify.post<{ Params: { id: string } }>('/:id/heartbeat', { preHandler: requirePermission('offers.edit') }, async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
|
||||
await prisma.quotations.updateMany({
|
||||
where: { id, locked_by: request.authData!.userId },
|
||||
data: { locked_at: new Date() },
|
||||
});
|
||||
|
||||
return success(reply, null);
|
||||
});
|
||||
|
||||
// POST /api/admin/offers/:id/unlock — release lock
|
||||
fastify.post<{ Params: { id: string } }>('/:id/unlock', { preHandler: requirePermission('offers.edit') }, async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
|
||||
await prisma.quotations.updateMany({
|
||||
where: { id, locked_by: request.authData!.userId },
|
||||
data: { locked_by: null, locked_at: null },
|
||||
});
|
||||
|
||||
return success(reply, null);
|
||||
});
|
||||
|
||||
fastify.post('/', { preHandler: requirePermission('offers.create') }, async (request, reply) => {
|
||||
@@ -93,6 +172,9 @@ export default async function quotationsRoutes(fastify: FastifyInstance): Promis
|
||||
if (result.error === 'invalidated') return error(reply, 'Nelze upravit zneplatněnou nabídku', 400);
|
||||
}
|
||||
|
||||
// Release lock after save
|
||||
await prisma.quotations.updateMany({ where: { id, locked_by: request.authData!.userId }, data: { locked_by: null, locked_at: null } });
|
||||
|
||||
await logAudit({ request, authData: request.authData, action: 'update', entityType: 'quotation', entityId: id, description: `Upravena nabídka ${(result as any).quotation_number}` });
|
||||
return success(reply, { id }, 200, 'Nabídka byla uložena');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user