Skip to content

Commit 9d8ba42

Browse files
PttCodingManclaude
andcommitted
feat: optimistic-lock the revert endpoint
POST /api/pages/{slug}/revert/{num} now accepts an optional base_version in the request body and returns 409 when it doesn't match the page's current version. Mirrors the contract of PUT /api/pages/{slug}, so a concurrent edit during the window between opening the versions page and clicking revert no longer silently clobbers someone else's change. GET /api/pages/{slug}/versions also returns page_version so the UI can pin without an extra round-trip; the versions page now sends it back. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4e43342 commit 9d8ba42

2 files changed

Lines changed: 37 additions & 2 deletions

File tree

backend/app/routers/versions.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import difflib
22
from fastapi import APIRouter, HTTPException, Depends, Query
3+
from pydantic import BaseModel
34
from app.auth import get_current_user
45
from app.database import get_db, write_transaction
56
from app.services.acl import require_page_read
@@ -8,6 +9,10 @@
89
from app.services.media_ref import parse_and_update_media_refs
910
from app.routers.activity import log_activity
1011

12+
13+
class RevertRequest(BaseModel):
14+
base_version: int | None = None # for optimistic locking, mirrors PUT /pages/{slug}
15+
1116
router = APIRouter(prefix="/api/pages", tags=["versions"])
1217

1318

@@ -57,8 +62,14 @@ async def list_versions(
5762
LIMIT ? OFFSET ?""",
5863
(page_id, per_page, offset),
5964
)
65+
# Carry the page's current version number so the revert UI can pass
66+
# base_version back without an extra round-trip.
67+
page_version_row = await db.execute_fetchall(
68+
"SELECT version FROM pages WHERE id = ?", (page_id,)
69+
)
6070
return {
6171
"versions": [dict(v) for v in versions],
72+
"page_version": page_version_row[0]["version"] if page_version_row else None,
6273
"total": total,
6374
"page": page,
6475
"per_page": per_page,
@@ -126,7 +137,12 @@ async def diff_versions(
126137

127138

128139
@router.post("/{slug}/revert/{num}")
129-
async def revert_to_version(slug: str, num: int, user=Depends(get_current_user)):
140+
async def revert_to_version(
141+
slug: str,
142+
num: int,
143+
body: RevertRequest = RevertRequest(),
144+
user=Depends(get_current_user),
145+
):
130146
if user.get("role") == "viewer":
131147
raise HTTPException(status_code=403, detail="Viewers cannot revert pages")
132148

@@ -145,6 +161,21 @@ async def revert_to_version(slug: str, num: int, user=Depends(get_current_user))
145161
detail="You do not have write permission on this page",
146162
)
147163

164+
# Optimistic lock — same contract as PUT /pages/{slug}. Reverting always
165+
# changes content, so the client must pin to a known base_version. If
166+
# someone else edited the page between the user opening the versions
167+
# page and clicking revert, return 409 instead of silently clobbering.
168+
if body.base_version is not None and body.base_version != current["version"]:
169+
raise HTTPException(
170+
status_code=409,
171+
detail={
172+
"error": "conflict",
173+
"message": "This page was modified by someone else. Reload the versions list and try again.",
174+
"current_version": current["version"],
175+
"your_version": body.base_version,
176+
},
177+
)
178+
148179
version = await db.execute_fetchall(
149180
"SELECT title, content_md FROM page_versions WHERE page_id = ? AND version_num = ?",
150181
(current["id"], num),

frontend/src/pages/PageVersions.jsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,10 +107,12 @@ export default function PageVersions() {
107107
const [selectedV2, setSelectedV2] = useState(null)
108108
const [reverting, setReverting] = useState(false)
109109
const [confirmRevert, setConfirmRevert] = useState(null)
110+
const [pageVersion, setPageVersion] = useState(null)
110111

111112
useEffect(() => {
112113
api.get(`/pages/${slug}/versions`).then((res) => {
113114
setVersions(res.data.versions)
115+
setPageVersion(res.data.page_version)
114116
setLoading(false)
115117
// Auto-select latest two for diff
116118
if (res.data.versions.length >= 2) {
@@ -138,7 +140,9 @@ export default function PageVersions() {
138140
const handleRevert = async (versionNum) => {
139141
setReverting(true)
140142
try {
141-
await api.post(`/pages/${slug}/revert/${versionNum}`)
143+
// Pin to the page version we loaded so a concurrent edit returns 409
144+
// instead of silently clobbering the other user's change.
145+
await api.post(`/pages/${slug}/revert/${versionNum}`, { base_version: pageVersion })
142146
setConfirmRevert(null)
143147
navigate(`/page/${slug}`)
144148
} catch (err) {

0 commit comments

Comments
 (0)