From 0ad0e88853ac284c8df5d55e65426157d6c991d0 Mon Sep 17 00:00:00 2001 From: BOHA Date: Tue, 24 Mar 2026 11:08:41 +0100 Subject: [PATCH] feat: pessimistic locking for offer editing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../20260324_add_offer_lock/migration.sql | 3 + prisma/schema.prisma | 2 + src/admin/pages/OfferDetail.tsx | 78 +++++++++++++---- src/routes/admin/quotations.ts | 84 ++++++++++++++++++- 4 files changed, 150 insertions(+), 17 deletions(-) create mode 100644 prisma/migrations/20260324_add_offer_lock/migration.sql diff --git a/prisma/migrations/20260324_add_offer_lock/migration.sql b/prisma/migrations/20260324_add_offer_lock/migration.sql new file mode 100644 index 0000000..be4a313 --- /dev/null +++ b/prisma/migrations/20260324_add_offer_lock/migration.sql @@ -0,0 +1,3 @@ +-- Add lock fields to quotations table for pessimistic locking +ALTER TABLE `quotations` ADD COLUMN `locked_by` INT NULL AFTER `scope_description`; +ALTER TABLE `quotations` ADD COLUMN `locked_at` DATETIME NULL AFTER `locked_by`; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e1f9950..37f5ee3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -380,6 +380,8 @@ model quotations { status String @default("active") @db.VarChar(20) scope_title String? @db.VarChar(500) scope_description String? @db.Text + locked_by Int? + locked_at DateTime? @db.DateTime(0) uuid String? @db.VarChar(36) modified_at DateTime? @db.DateTime(0) sync_version Int? @default(0) diff --git a/src/admin/pages/OfferDetail.tsx b/src/admin/pages/OfferDetail.tsx index cefd857..37eb33e 100644 --- a/src/admin/pages/OfferDetail.tsx +++ b/src/admin/pages/OfferDetail.tsx @@ -202,10 +202,13 @@ export default function OfferDetail() { const [customerOrderNumber, setCustomerOrderNumber] = useState('') const [orderAttachment, setOrderAttachment] = useState(null) const [pdfLoading, setPdfLoading] = useState(false) + const [lockedBy, setLockedBy] = useState<{ user_id: number; username: string; full_name: string } | null>(null) + const heartbeatRef = useRef | 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 )} - {!isInvalidated && ( + {!isInvalidated && !isLockedByOther && ( @@ -854,7 +900,7 @@ export default function OfferDetail() { - {!isInvalidated && @@ -862,7 +908,7 @@ export default function OfferDetail() { - {!isInvalidated && @@ -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() {

Rozsah projektu

- {!isInvalidated && ( + {!isInvalidated && !isLockedByOther && (
{scopeTemplates.length > 0 && (
} + {!isInvalidated && !isLockedByOther && } # Popis MnožstvíCena/ks V ceně Celkem} + {!isInvalidated && !isLockedByOther && }