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:
3
prisma/migrations/20260324_add_offer_lock/migration.sql
Normal file
3
prisma/migrations/20260324_add_offer_lock/migration.sql
Normal file
@@ -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`;
|
||||||
@@ -380,6 +380,8 @@ model quotations {
|
|||||||
status String @default("active") @db.VarChar(20)
|
status String @default("active") @db.VarChar(20)
|
||||||
scope_title String? @db.VarChar(500)
|
scope_title String? @db.VarChar(500)
|
||||||
scope_description String? @db.Text
|
scope_description String? @db.Text
|
||||||
|
locked_by Int?
|
||||||
|
locked_at DateTime? @db.DateTime(0)
|
||||||
uuid String? @db.VarChar(36)
|
uuid String? @db.VarChar(36)
|
||||||
modified_at DateTime? @db.DateTime(0)
|
modified_at DateTime? @db.DateTime(0)
|
||||||
sync_version Int? @default(0)
|
sync_version Int? @default(0)
|
||||||
|
|||||||
@@ -202,10 +202,13 @@ export default function OfferDetail() {
|
|||||||
const [customerOrderNumber, setCustomerOrderNumber] = useState('')
|
const [customerOrderNumber, setCustomerOrderNumber] = useState('')
|
||||||
const [orderAttachment, setOrderAttachment] = useState<File | null>(null)
|
const [orderAttachment, setOrderAttachment] = useState<File | null>(null)
|
||||||
const [pdfLoading, setPdfLoading] = useState(false)
|
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)
|
useModalLock(showOrderModal)
|
||||||
|
|
||||||
const isInvalidated = offerStatus === 'invalidated'
|
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())
|
const isExpiredNotInvalidated = isEdit && !isInvalidated && !orderInfo && form.valid_until && new Date(form.valid_until) < new Date(new Date().toDateString())
|
||||||
|
|
||||||
// Load data
|
// Load data
|
||||||
@@ -240,6 +243,12 @@ export default function OfferDetail() {
|
|||||||
})) : [])
|
})) : [])
|
||||||
setOfferStatus(d.status || '')
|
setOfferStatus(d.status || '')
|
||||||
setOrderInfo(d.order || null)
|
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 {
|
} else {
|
||||||
alert.error(result.error || 'Nepodařilo se načíst nabídku')
|
alert.error(result.error || 'Nepodařilo se načíst nabídku')
|
||||||
navigate('/offers')
|
navigate('/offers')
|
||||||
@@ -250,7 +259,22 @@ export default function OfferDetail() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
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(() => {
|
useEffect(() => {
|
||||||
if (isEdit) fetchDetail()
|
if (isEdit) fetchDetail()
|
||||||
@@ -633,7 +657,7 @@ export default function OfferDetail() {
|
|||||||
Zneplatnit
|
Zneplatnit
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{!isInvalidated && (
|
{!isInvalidated && !isLockedByOther && (
|
||||||
<button onClick={handleSave} className="admin-btn admin-btn-primary" disabled={saving}>
|
<button onClick={handleSave} className="admin-btn admin-btn-primary" disabled={saving}>
|
||||||
{saving ? (
|
{saving ? (
|
||||||
<>
|
<>
|
||||||
@@ -651,9 +675,31 @@ export default function OfferDetail() {
|
|||||||
</div>
|
</div>
|
||||||
</motion.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 */}
|
{/* Quotation Form */}
|
||||||
<motion.div
|
<motion.div
|
||||||
className={`offers-editor-section${isInvalidated ? ' offers-readonly' : ''}`}
|
className={`offers-editor-section${(isInvalidated || isLockedByOther) ? ' offers-readonly' : ''}`}
|
||||||
initial={{ opacity: 0, y: 12 }}
|
initial={{ opacity: 0, y: 12 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.25, delay: 0.06 }}
|
transition={{ duration: 0.25, delay: 0.06 }}
|
||||||
@@ -676,14 +722,14 @@ export default function OfferDetail() {
|
|||||||
onChange={(e) => updateForm('project_code', e.target.value)}
|
onChange={(e) => updateForm('project_code', e.target.value)}
|
||||||
className="admin-form-input"
|
className="admin-form-input"
|
||||||
placeholder="Volitelný kód projektu"
|
placeholder="Volitelný kód projektu"
|
||||||
readOnly={isInvalidated}
|
readOnly={isInvalidated || isLockedByOther}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Zákazník" error={errors.customer_id}>
|
<FormField label="Zákazník" error={errors.customer_id}>
|
||||||
{form.customer_id ? (
|
{form.customer_id ? (
|
||||||
<div className="offers-customer-selected">
|
<div className="offers-customer-selected">
|
||||||
<span>{form.customer_name}</span>
|
<span>{form.customer_name}</span>
|
||||||
{!isInvalidated && (
|
{!isInvalidated && !isLockedByOther && (
|
||||||
<button type="button" onClick={clearCustomer} className="admin-btn-icon" title="Odebrat zákazníka">
|
<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">
|
<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" />
|
<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)}
|
onFocus={() => setShowCustomerDropdown(true)}
|
||||||
className="admin-form-input"
|
className="admin-form-input"
|
||||||
placeholder="Hledat zákazníka..."
|
placeholder="Hledat zákazníka..."
|
||||||
readOnly={isInvalidated}
|
readOnly={isInvalidated || isLockedByOther}
|
||||||
/>
|
/>
|
||||||
{showCustomerDropdown && !isInvalidated && (
|
{showCustomerDropdown && !isInvalidated && (
|
||||||
<div className="offers-customer-dropdown">
|
<div className="offers-customer-dropdown">
|
||||||
@@ -788,7 +834,7 @@ export default function OfferDetail() {
|
|||||||
onChange={(e) => updateForm('vat_rate', parseFloat(e.target.value) || 0)}
|
onChange={(e) => updateForm('vat_rate', parseFloat(e.target.value) || 0)}
|
||||||
className="admin-form-input flex-1"
|
className="admin-form-input flex-1"
|
||||||
step="0.1"
|
step="0.1"
|
||||||
readOnly={isInvalidated}
|
readOnly={isInvalidated || isLockedByOther}
|
||||||
/>
|
/>
|
||||||
<label className="admin-form-checkbox" style={{ whiteSpace: 'nowrap' }}>
|
<label className="admin-form-checkbox" style={{ whiteSpace: 'nowrap' }}>
|
||||||
<input
|
<input
|
||||||
@@ -809,7 +855,7 @@ export default function OfferDetail() {
|
|||||||
className="admin-form-input"
|
className="admin-form-input"
|
||||||
placeholder="Volitelný"
|
placeholder="Volitelný"
|
||||||
step="0.0001"
|
step="0.0001"
|
||||||
readOnly={isInvalidated}
|
readOnly={isInvalidated || isLockedByOther}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
@@ -826,7 +872,7 @@ export default function OfferDetail() {
|
|||||||
<div className="admin-card-body">
|
<div className="admin-card-body">
|
||||||
<div className="admin-card-header flex-between">
|
<div className="admin-card-header flex-between">
|
||||||
<h3 className="admin-card-title">Položky</h3>
|
<h3 className="admin-card-title">Položky</h3>
|
||||||
{!isInvalidated && (
|
{!isInvalidated && !isLockedByOther && (
|
||||||
<button onClick={addItem} className="admin-btn admin-btn-secondary admin-btn-sm">
|
<button onClick={addItem} className="admin-btn admin-btn-secondary admin-btn-sm">
|
||||||
+ Přidat položku
|
+ Přidat položku
|
||||||
</button>
|
</button>
|
||||||
@@ -854,7 +900,7 @@ export default function OfferDetail() {
|
|||||||
<table className="admin-table">
|
<table className="admin-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
{!isInvalidated && <th style={{ width: '2rem' }} />}
|
{!isInvalidated && !isLockedByOther && <th style={{ width: '2rem' }} />}
|
||||||
<th style={{ width: '2.5rem', textAlign: 'center' }}>#</th>
|
<th style={{ width: '2.5rem', textAlign: 'center' }}>#</th>
|
||||||
<th>Popis</th>
|
<th>Popis</th>
|
||||||
<th style={{ width: '5rem' }}>Množství</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: '7rem' }}>Cena/ks</th>
|
||||||
<th style={{ width: '4rem', textAlign: 'center' }}>V ceně</th>
|
<th style={{ width: '4rem', textAlign: 'center' }}>V ceně</th>
|
||||||
<th style={{ width: '7rem', textAlign: 'right' }}>Celkem</th>
|
<th style={{ width: '7rem', textAlign: 'right' }}>Celkem</th>
|
||||||
{!isInvalidated && <th style={{ width: '3rem' }} />}
|
{!isInvalidated && !isLockedByOther && <th style={{ width: '3rem' }} />}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -872,7 +918,7 @@ export default function OfferDetail() {
|
|||||||
item={item}
|
item={item}
|
||||||
index={index}
|
index={index}
|
||||||
currency={form.currency}
|
currency={form.currency}
|
||||||
readOnly={isInvalidated}
|
readOnly={isInvalidated || isLockedByOther}
|
||||||
canDelete={items.length > 1}
|
canDelete={items.length > 1}
|
||||||
onUpdate={(field, value) => updateItem(index, field, value)}
|
onUpdate={(field, value) => updateItem(index, field, value)}
|
||||||
onRemove={() => removeItem(index)}
|
onRemove={() => removeItem(index)}
|
||||||
@@ -914,7 +960,7 @@ export default function OfferDetail() {
|
|||||||
<div className="admin-card-body">
|
<div className="admin-card-body">
|
||||||
<div className="admin-card-header flex-between">
|
<div className="admin-card-header flex-between">
|
||||||
<h3 className="admin-card-title">Rozsah projektu</h3>
|
<h3 className="admin-card-title">Rozsah projektu</h3>
|
||||||
{!isInvalidated && (
|
{!isInvalidated && !isLockedByOther && (
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||||
{scopeTemplates.length > 0 && (
|
{scopeTemplates.length > 0 && (
|
||||||
<select
|
<select
|
||||||
@@ -981,7 +1027,7 @@ export default function OfferDetail() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
{!isInvalidated && (
|
{!isInvalidated && !isLockedByOther && (
|
||||||
<div style={{ display: 'flex', gap: '0.25rem' }}>
|
<div style={{ display: 'flex', gap: '0.25rem' }}>
|
||||||
{idx > 0 && (
|
{idx > 0 && (
|
||||||
<button
|
<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))}
|
onChange={(e) => setSections(prev => prev.map((s, i) => i === idx ? { ...s, title: e.target.value } : s))}
|
||||||
className="admin-form-input"
|
className="admin-form-input"
|
||||||
placeholder="Název sekce (anglicky)"
|
placeholder="Název sekce (anglicky)"
|
||||||
readOnly={isInvalidated}
|
readOnly={isInvalidated || isLockedByOther}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label={<><span className="offers-lang-badge offers-lang-badge-cz">CZ</span>Název sekce</>}>
|
<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))}
|
onChange={(e) => setSections(prev => prev.map((s, i) => i === idx ? { ...s, title_cz: e.target.value } : s))}
|
||||||
className="admin-form-input"
|
className="admin-form-input"
|
||||||
placeholder="Název sekce (česky)"
|
placeholder="Název sekce (česky)"
|
||||||
readOnly={isInvalidated}
|
readOnly={isInvalidated || isLockedByOther}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { success, error, parseId } from '../../utils/response';
|
|||||||
import { parsePagination, buildPaginationMeta } from '../../utils/pagination';
|
import { parsePagination, buildPaginationMeta } from '../../utils/pagination';
|
||||||
import { parseBody } from '../../schemas/common';
|
import { parseBody } from '../../schemas/common';
|
||||||
import { CreateQuotationSchema, UpdateQuotationSchema } from '../../schemas/offers.schema';
|
import { CreateQuotationSchema, UpdateQuotationSchema } from '../../schemas/offers.schema';
|
||||||
|
import prisma from '../../config/database';
|
||||||
import {
|
import {
|
||||||
listOffers,
|
listOffers,
|
||||||
getOffer,
|
getOffer,
|
||||||
@@ -16,6 +17,8 @@ import {
|
|||||||
getNextOfferNumber,
|
getNextOfferNumber,
|
||||||
} from '../../services/offers.service';
|
} 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> {
|
export default async function quotationsRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
fastify.get('/', { preHandler: requirePermission('offers.view') }, async (request, reply) => {
|
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);
|
const data = await getOffer(id);
|
||||||
if (!data) return error(reply, 'Nabídka nenalezena', 404);
|
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) => {
|
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);
|
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}` });
|
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');
|
return success(reply, { id }, 200, 'Nabídka byla uložena');
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user