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

@@ -202,10 +202,13 @@ export default function OfferDetail() {
const [customerOrderNumber, setCustomerOrderNumber] = useState('')
const [orderAttachment, setOrderAttachment] = useState<File | null>(null)
const [pdfLoading, setPdfLoading] = useState(false)
const [lockedBy, setLockedBy] = useState<{ user_id: number; username: string; full_name: string } | null>(null)
const heartbeatRef = useRef<ReturnType<typeof setInterval> | null>(null)
useModalLock(showOrderModal)
const isInvalidated = offerStatus === 'invalidated'
const isLockedByOther = !!lockedBy
const isExpiredNotInvalidated = isEdit && !isInvalidated && !orderInfo && form.valid_until && new Date(form.valid_until) < new Date(new Date().toDateString())
// Load data
@@ -240,6 +243,12 @@ export default function OfferDetail() {
})) : [])
setOfferStatus(d.status || '')
setOrderInfo(d.order || null)
setLockedBy(d.locked_by || null)
// Try to acquire lock if not locked by someone else and not invalidated
if (!d.locked_by && d.status !== 'invalidated' && hasPermission('offers.edit')) {
apiFetch(`${API_BASE}/offers/${id}/lock`, { method: 'POST' }).catch(() => {})
}
} else {
alert.error(result.error || 'Nepodařilo se načíst nabídku')
navigate('/offers')
@@ -250,7 +259,22 @@ export default function OfferDetail() {
} finally {
setLoading(false)
}
}, [id, alert, navigate])
}, [id, alert, navigate, hasPermission])
// Heartbeat to keep lock alive + cleanup on unmount
useEffect(() => {
if (!isEdit || !id || isLockedByOther || isInvalidated) return
heartbeatRef.current = setInterval(() => {
apiFetch(`${API_BASE}/offers/${id}/heartbeat`, { method: 'POST' }).catch(() => {})
}, 2 * 60 * 1000) // every 2 minutes
return () => {
if (heartbeatRef.current) clearInterval(heartbeatRef.current)
// Release lock on unmount
apiFetch(`${API_BASE}/offers/${id}/unlock`, { method: 'POST' }).catch(() => {})
}
}, [isEdit, id, isLockedByOther, isInvalidated])
useEffect(() => {
if (isEdit) fetchDetail()
@@ -633,7 +657,7 @@ export default function OfferDetail() {
Zneplatnit
</button>
)}
{!isInvalidated && (
{!isInvalidated && !isLockedByOther && (
<button onClick={handleSave} className="admin-btn admin-btn-primary" disabled={saving}>
{saving ? (
<>
@@ -651,9 +675,31 @@ export default function OfferDetail() {
</div>
</motion.div>
{/* Lock banner */}
{isLockedByOther && (
<div style={{
background: 'color-mix(in srgb, var(--warning) 15%, transparent)',
border: '1px solid var(--warning)',
borderRadius: 'var(--border-radius-sm)',
padding: '0.75rem 1rem',
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
marginBottom: '1rem',
fontSize: '0.875rem',
color: 'var(--warning)',
}}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
<span>Nabídku právě upravuje <strong>{lockedBy!.full_name}</strong>. Můžete ji pouze prohlížet.</span>
</div>
)}
{/* Quotation Form */}
<motion.div
className={`offers-editor-section${isInvalidated ? ' offers-readonly' : ''}`}
className={`offers-editor-section${(isInvalidated || isLockedByOther) ? ' offers-readonly' : ''}`}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
@@ -676,14 +722,14 @@ export default function OfferDetail() {
onChange={(e) => updateForm('project_code', e.target.value)}
className="admin-form-input"
placeholder="Volitelný kód projektu"
readOnly={isInvalidated}
readOnly={isInvalidated || isLockedByOther}
/>
</FormField>
<FormField label="Zákazník" error={errors.customer_id}>
{form.customer_id ? (
<div className="offers-customer-selected">
<span>{form.customer_name}</span>
{!isInvalidated && (
{!isInvalidated && !isLockedByOther && (
<button type="button" onClick={clearCustomer} className="admin-btn-icon" title="Odebrat zákazníka">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
@@ -700,7 +746,7 @@ export default function OfferDetail() {
onFocus={() => setShowCustomerDropdown(true)}
className="admin-form-input"
placeholder="Hledat zákazníka..."
readOnly={isInvalidated}
readOnly={isInvalidated || isLockedByOther}
/>
{showCustomerDropdown && !isInvalidated && (
<div className="offers-customer-dropdown">
@@ -788,7 +834,7 @@ export default function OfferDetail() {
onChange={(e) => updateForm('vat_rate', parseFloat(e.target.value) || 0)}
className="admin-form-input flex-1"
step="0.1"
readOnly={isInvalidated}
readOnly={isInvalidated || isLockedByOther}
/>
<label className="admin-form-checkbox" style={{ whiteSpace: 'nowrap' }}>
<input
@@ -809,7 +855,7 @@ export default function OfferDetail() {
className="admin-form-input"
placeholder="Volitelný"
step="0.0001"
readOnly={isInvalidated}
readOnly={isInvalidated || isLockedByOther}
/>
</FormField>
</div>
@@ -826,7 +872,7 @@ export default function OfferDetail() {
<div className="admin-card-body">
<div className="admin-card-header flex-between">
<h3 className="admin-card-title">Položky</h3>
{!isInvalidated && (
{!isInvalidated && !isLockedByOther && (
<button onClick={addItem} className="admin-btn admin-btn-secondary admin-btn-sm">
+ Přidat položku
</button>
@@ -854,7 +900,7 @@ export default function OfferDetail() {
<table className="admin-table">
<thead>
<tr>
{!isInvalidated && <th style={{ width: '2rem' }} />}
{!isInvalidated && !isLockedByOther && <th style={{ width: '2rem' }} />}
<th style={{ width: '2.5rem', textAlign: 'center' }}>#</th>
<th>Popis</th>
<th style={{ width: '5rem' }}>Množství</th>
@@ -862,7 +908,7 @@ export default function OfferDetail() {
<th style={{ width: '7rem' }}>Cena/ks</th>
<th style={{ width: '4rem', textAlign: 'center' }}>V ceně</th>
<th style={{ width: '7rem', textAlign: 'right' }}>Celkem</th>
{!isInvalidated && <th style={{ width: '3rem' }} />}
{!isInvalidated && !isLockedByOther && <th style={{ width: '3rem' }} />}
</tr>
</thead>
<tbody>
@@ -872,7 +918,7 @@ export default function OfferDetail() {
item={item}
index={index}
currency={form.currency}
readOnly={isInvalidated}
readOnly={isInvalidated || isLockedByOther}
canDelete={items.length > 1}
onUpdate={(field, value) => updateItem(index, field, value)}
onRemove={() => removeItem(index)}
@@ -914,7 +960,7 @@ export default function OfferDetail() {
<div className="admin-card-body">
<div className="admin-card-header flex-between">
<h3 className="admin-card-title">Rozsah projektu</h3>
{!isInvalidated && (
{!isInvalidated && !isLockedByOther && (
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
{scopeTemplates.length > 0 && (
<select
@@ -981,7 +1027,7 @@ export default function OfferDetail() {
</span>
)}
</span>
{!isInvalidated && (
{!isInvalidated && !isLockedByOther && (
<div style={{ display: 'flex', gap: '0.25rem' }}>
{idx > 0 && (
<button
@@ -1034,7 +1080,7 @@ export default function OfferDetail() {
onChange={(e) => setSections(prev => prev.map((s, i) => i === idx ? { ...s, title: e.target.value } : s))}
className="admin-form-input"
placeholder="Název sekce (anglicky)"
readOnly={isInvalidated}
readOnly={isInvalidated || isLockedByOther}
/>
</FormField>
<FormField label={<><span className="offers-lang-badge offers-lang-badge-cz">CZ</span>Název sekce</>}>
@@ -1044,7 +1090,7 @@ export default function OfferDetail() {
onChange={(e) => setSections(prev => prev.map((s, i) => i === idx ? { ...s, title_cz: e.target.value } : s))}
className="admin-form-input"
placeholder="Název sekce (česky)"
readOnly={isInvalidated}
readOnly={isInvalidated || isLockedByOther}
/>
</FormField>
</div>

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