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:
BOHA
2026-03-24 11:08:41 +01:00
parent 17da3b17c3
commit 0ad0e88853
4 changed files with 150 additions and 17 deletions

View File

@@ -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');
});