feat(paket b): Umbenennen erreicht alle — PATCH /title und RENAMED im Feed (D85)
Backend: PATCH /documents/{id}/title mit expectedVersion (409 bei
veralteter Version, 400 bei leerem/zu langem Titel), neuer ChangeType
RENAMED (strukturell, immer Meilenstein), der Feed stellt den neuen Titel
im Klartext zu (ChangeEvent.title). Unter derselben Stripe-Sperre wie die
Inhalts-Patches; Owner-Vormerkung in der API-Beschreibung. Vier neue
Cucumber-Szenarien, zwei Unit-Tests.
Frontend: Der Zeilen-Stift eines Server-Dokuments benennt über den Server
um (optimistisch, 409-Retry, Rücknahme + Warnung bei Fehlschlag); fremde
Umbenennungen kommen als RENAMED über den Feed in Chip und Menü.
URL-Dokumente verlieren den Stift — ihr Name ist die URL.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
c438a88344
commit
fbe9b1fd22
@@ -11,6 +11,7 @@ import de.werkbaum.generated.model.DocumentHistoryEntry as ApiHistoryEntry
|
||||
import de.werkbaum.generated.model.DocumentUpdateRequest
|
||||
import de.werkbaum.generated.model.RestoreRequest
|
||||
import de.werkbaum.generated.model.ServiceInfo
|
||||
import de.werkbaum.generated.model.TitlePatchRequest
|
||||
import de.werkbaum.domain.ChangeAuthor
|
||||
import de.werkbaum.domain.ChangeEvent
|
||||
import de.werkbaum.domain.ChangeFeed
|
||||
@@ -137,6 +138,18 @@ class DocumentsController(
|
||||
.body(feed?.toApi())
|
||||
}
|
||||
|
||||
override fun patchDocumentTitle(
|
||||
documentId: UUID,
|
||||
titlePatchRequest: TitlePatchRequest,
|
||||
): ResponseEntity<ApiDocument> {
|
||||
val renamed = liveEditing.renameDocument(
|
||||
documentId,
|
||||
titlePatchRequest.title,
|
||||
titlePatchRequest.expectedVersion,
|
||||
)
|
||||
return ResponseEntity.ok(renamed.toApi())
|
||||
}
|
||||
|
||||
override fun getDocumentHistory(documentId: UUID): ResponseEntity<List<ApiHistoryEntry>> =
|
||||
ResponseEntity.ok(service.history(documentId).map { it.toApi() })
|
||||
|
||||
@@ -170,6 +183,7 @@ class DocumentsController(
|
||||
changeType = ApiChangeEvent.ChangeType.valueOf(changeType.name),
|
||||
clientId = author?.clientId,
|
||||
displayName = author?.displayName,
|
||||
title = title,
|
||||
)
|
||||
|
||||
private fun DocumentHistoryEntry.toApi(): ApiHistoryEntry = ApiHistoryEntry(
|
||||
|
||||
@@ -2,11 +2,16 @@ package de.werkbaum.domain
|
||||
|
||||
import de.werkbaum.diff.LineOp
|
||||
|
||||
/** Was an einer Version geschehen ist und wer sie eingereicht hat. */
|
||||
/**
|
||||
* Was an einer Version geschehen ist und wer sie eingereicht hat.
|
||||
* [title] nur bei [ChangeType.RENAMED]: der neue Titel im Klartext, damit der
|
||||
* Client ihn ohne weiteren Abruf übernehmen kann (D76/D85).
|
||||
*/
|
||||
data class ChangeEvent(
|
||||
val version: Long,
|
||||
val changeType: ChangeType,
|
||||
val author: ChangeAuthor? = null,
|
||||
val title: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,8 +11,11 @@ import java.util.UUID
|
||||
* eines **lebenden** Dokuments auf eine alte Version; für den Client ein
|
||||
* gewöhnlicher Inhaltswechsel. Beide trugen früher denselben Typ; ein Typ, der
|
||||
* zwei Dinge bedeutet, ist die Unschärfe, aus der später Fehler werden (D76).
|
||||
*
|
||||
* [RENAMED] ändert nur den Titel — ein Metadatum, kein Zeileninhalt (D76/D85);
|
||||
* der Feed stellt den neuen Titel im Klartext zu, der Inhalt bleibt.
|
||||
*/
|
||||
enum class ChangeType { CREATED, UPDATED, DELETED, RESTORED, ROLLED_BACK }
|
||||
enum class ChangeType { CREATED, UPDATED, RENAMED, DELETED, RESTORED, ROLLED_BACK }
|
||||
|
||||
/**
|
||||
* Ein Eintrag der Dokumenthistorie. Die Historie wird getrennt vom Dokument
|
||||
|
||||
@@ -76,6 +76,30 @@ class DocumentService(
|
||||
return updated
|
||||
}
|
||||
|
||||
/**
|
||||
* Ändert nur den Titel (D76/D85): ein Metadatum mit eigener
|
||||
* Versionsprüfung — weicht [expectedVersion] von der aktuellen Version
|
||||
* ab, gibt es 409, und der Client setzt frisch auf. Die Umbenennung ist
|
||||
* strukturell (Meilenstein) und erreicht alle über den Feed als
|
||||
* [ChangeType.RENAMED] samt neuem Titel.
|
||||
*/
|
||||
fun rename(id: UUID, title: String, expectedVersion: Long): Document {
|
||||
val existing = findById(id)
|
||||
if (existing.version != expectedVersion) {
|
||||
throw DocumentConflictException(
|
||||
"Version $expectedVersion ist nicht mehr aktuell (jetzt: ${existing.version})"
|
||||
)
|
||||
}
|
||||
val renamed = existing.copy(
|
||||
title = title,
|
||||
version = existing.version + 1,
|
||||
updatedAt = OffsetDateTime.now(clock),
|
||||
)
|
||||
repository.save(renamed)
|
||||
recordHistory(renamed, ChangeType.RENAMED)
|
||||
return renamed
|
||||
}
|
||||
|
||||
fun delete(id: UUID) {
|
||||
val existing = findById(id)
|
||||
repository.deleteById(id)
|
||||
|
||||
@@ -53,6 +53,21 @@ class LiveEditingService(
|
||||
fun patchContent(documentId: UUID, patch: ContentPatch): ContentPatchOutcome =
|
||||
lockFor(documentId).withLock { applyPatch(documentId, patch) }
|
||||
|
||||
/**
|
||||
* Umbenennen unter derselben Sperre wie die Inhalts-Patches (D85): Auch
|
||||
* der Titel bumpt die Version, und prüfen und schreiben gehören zusammen.
|
||||
* Titel-Regeln liegen hier (400), die Versionsprüfung im [DocumentService].
|
||||
*/
|
||||
fun renameDocument(documentId: UUID, title: String, expectedVersion: Long) =
|
||||
lockFor(documentId).withLock {
|
||||
val bereinigt = title.trim()
|
||||
if (bereinigt.isEmpty()) throw InvalidPatchException("Titel darf nicht leer sein")
|
||||
if (bereinigt.length > 255) {
|
||||
throw InvalidPatchException("Titel zu lang: ${bereinigt.length} (erlaubt: 255)")
|
||||
}
|
||||
documents.rename(documentId, bereinigt, expectedVersion)
|
||||
}
|
||||
|
||||
private fun applyPatch(documentId: UUID, patch: ContentPatch): ContentPatchOutcome {
|
||||
if (patch.ops.size > properties.maxOps) {
|
||||
throw InvalidPatchException(
|
||||
@@ -185,7 +200,10 @@ class LiveEditingService(
|
||||
}
|
||||
}
|
||||
|
||||
private fun DocumentHistoryEntry.toEvent() = ChangeEvent(version, changeType, author)
|
||||
private fun DocumentHistoryEntry.toEvent() = ChangeEvent(
|
||||
version, changeType, author,
|
||||
title = title.takeIf { changeType == de.werkbaum.domain.ChangeType.RENAMED },
|
||||
)
|
||||
|
||||
private fun lockFor(documentId: UUID): ReentrantLock =
|
||||
stripes[Math.floorMod(documentId.hashCode(), stripes.size)]
|
||||
|
||||
@@ -255,6 +255,56 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ProblemDetail"
|
||||
|
||||
/documents/{documentId}/title:
|
||||
parameters:
|
||||
- name: documentId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
patch:
|
||||
tags: [Documents]
|
||||
operationId: patchDocumentTitle
|
||||
summary: Dokument umbenennen (Live-Editing)
|
||||
description: >
|
||||
Aendert nur den Titel - er ist ein Metadatum, kein Zeileninhalt, und
|
||||
bekommt deshalb seinen eigenen Weg mit Versionspruefung (D76). Die
|
||||
Umbenennung erzeugt eine neue Version vom Typ RENAMED, die der
|
||||
Aenderungsfeed mit dem neuen Titel im Klartext zustellt - alle sehen
|
||||
denselben Namen.
|
||||
|
||||
|
||||
Verwaltungs-Aktion: Sie wird kuenftig an das geplante Owner-Passwort
|
||||
gebunden (die Bindung kommt als Berechtigungspruefung dazu, die
|
||||
Signatur bleibt).
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/TitlePatchRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Umbenanntes Dokument
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Document"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"409":
|
||||
description: >
|
||||
Versionskonflikt - `expectedVersion` ist nicht mehr die aktuelle
|
||||
Version. Der Client holt das Dokument frisch und versucht es mit
|
||||
dessen Version erneut.
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ProblemDetail"
|
||||
|
||||
/documents/{documentId}/changes:
|
||||
parameters:
|
||||
- name: documentId
|
||||
@@ -427,11 +477,26 @@ components:
|
||||
RESTORED heisst: ein geloeschtes Dokument ist wieder da.
|
||||
ROLLED_BACK ist der Rueckfall eines lebenden Dokuments auf eine
|
||||
aeltere Version.
|
||||
enum: [CREATED, UPDATED, DELETED, RESTORED, ROLLED_BACK]
|
||||
enum: [CREATED, UPDATED, RENAMED, DELETED, RESTORED, ROLLED_BACK]
|
||||
timestamp:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
TitlePatchRequest:
|
||||
type: object
|
||||
required: [title, expectedVersion]
|
||||
properties:
|
||||
title:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 255
|
||||
expectedVersion:
|
||||
type: integer
|
||||
format: int64
|
||||
description: >
|
||||
Die Version, auf der die Umbenennung aufsetzt - weicht sie von der
|
||||
aktuellen ab, antwortet der Server mit 409.
|
||||
|
||||
RestoreRequest:
|
||||
type: object
|
||||
properties:
|
||||
@@ -546,7 +611,7 @@ components:
|
||||
format: int64
|
||||
changeType:
|
||||
type: string
|
||||
enum: [CREATED, UPDATED, DELETED, RESTORED, ROLLED_BACK]
|
||||
enum: [CREATED, UPDATED, RENAMED, DELETED, RESTORED, ROLLED_BACK]
|
||||
clientId:
|
||||
type: string
|
||||
description: Fehlt bei Aenderungen ohne Absender (etwa ueber PUT).
|
||||
@@ -555,6 +620,11 @@ components:
|
||||
description: >
|
||||
Selbstgewaehlt und ohne Anmeldung eine Behauptung - in der
|
||||
Oberflaeche nicht wie ein Nachweis darstellen.
|
||||
title:
|
||||
type: string
|
||||
description: >
|
||||
Nur bei RENAMED - der neue Titel im Klartext, damit der Client
|
||||
ihn ohne weiteren Abruf uebernehmen kann (D76).
|
||||
|
||||
ChangeFeed:
|
||||
type: object
|
||||
|
||||
@@ -205,6 +205,45 @@ class LiveEditingStepDefinitions {
|
||||
feed.content shouldBe erwartet
|
||||
}
|
||||
|
||||
@Wenn("das Dokument auf {string} umbenannt wird")
|
||||
fun `das Dokument wird umbenannt`(titel: String) =
|
||||
sendRename(titel, currentDocument().version)
|
||||
|
||||
@Wenn("das Dokument mit veralteter Version auf {string} umbenannt wird")
|
||||
fun `veraltete Umbenennung`(titel: String) =
|
||||
sendRename(titel, currentDocument().version - 1)
|
||||
|
||||
private fun sendRename(titel: String, version: Long) {
|
||||
lastResponse = client.patch()
|
||||
.uri("/api/v1/documents/$documentId/title")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body("""{"title":${json(titel)},"expectedVersion":$version}""")
|
||||
.exchange()
|
||||
.returnResult(String::class.java)
|
||||
}
|
||||
|
||||
@Dann("erhalte ich für das Umbenennen den Status {int}")
|
||||
fun `status des Umbenennens`(erwartet: Int) {
|
||||
status() shouldBe erwartet
|
||||
}
|
||||
|
||||
@Und("das Dokument trägt den Titel {string}")
|
||||
fun `dokument traegt den Titel`(titel: String) {
|
||||
currentDocument().title shouldBe titel
|
||||
}
|
||||
|
||||
@Und("das Dokument steht auf Version {long}")
|
||||
fun `dokument steht auf Version`(version: Long) {
|
||||
currentDocument().version shouldBe version
|
||||
}
|
||||
|
||||
@Und("das RENAMED-Ereignis nennt den Titel {string}")
|
||||
fun `renamed nennt den Titel`(titel: String) {
|
||||
lastBody<ChangeFeed>().events
|
||||
.first { it.changeType.value == "RENAMED" }
|
||||
.title shouldBe titel
|
||||
}
|
||||
|
||||
@Und("der Feed meldet das Ereignis {string}")
|
||||
fun `der Feed meldet das Ereignis`(typ: String) {
|
||||
lastBody<ChangeFeed>().events.map { it.changeType.value } shouldContain typ
|
||||
|
||||
@@ -112,6 +112,35 @@ class DocumentServiceTest {
|
||||
entry.captured.version shouldBe 4
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rename inkrementiert Version und schreibt RENAMED-Meilenstein`() {
|
||||
val doc = sampleDocument(version = 3)
|
||||
every { repository.findById(doc.id) } returns doc
|
||||
val saved = slot<Document>()
|
||||
every { repository.save(capture(saved)) } answers { saved.captured }
|
||||
val entry = captureAppended()
|
||||
|
||||
val result = service.rename(doc.id, title = "Team-Plan", expectedVersion = 3)
|
||||
|
||||
result.version shouldBe 4
|
||||
result.title shouldBe "Team-Plan"
|
||||
result.content shouldBe doc.content /* der Inhalt bleibt */
|
||||
entry.captured.changeType shouldBe ChangeType.RENAMED
|
||||
entry.captured.milestone shouldBe true /* strukturell, nie bloss Sync */
|
||||
entry.captured.title shouldBe "Team-Plan"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rename mit veralteter Version wirft Konflikt und schreibt nichts`() {
|
||||
val doc = sampleDocument(version = 3)
|
||||
every { repository.findById(doc.id) } returns doc
|
||||
|
||||
shouldThrow<DocumentConflictException> {
|
||||
service.rename(doc.id, title = "Zu spaet", expectedVersion = 2)
|
||||
}
|
||||
verify(exactly = 0) { repository.save(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `delete entfernt Dokument und schreibt DELETED-Tombstone`() {
|
||||
val doc = sampleDocument(version = 2)
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# language: de
|
||||
Funktionalität: Ein geteiltes Dokument umbenennen
|
||||
Als Bearbeiter eines geteilten Plans
|
||||
möchte ich den Titel für alle ändern,
|
||||
damit jeder im Wähler denselben Namen sieht.
|
||||
|
||||
Grundlage: Der Titel ist ein Metadatum und bekommt seinen eigenen Weg mit
|
||||
Versionsprüfung (D76/D85); der Änderungsfeed stellt die Umbenennung samt
|
||||
neuem Titel zu.
|
||||
|
||||
Szenario: Umbenennen mit aktueller Version gilt und lässt den Inhalt stehen
|
||||
Angenommen es existiert ein Dokument "Plan" mit den Zeilen:
|
||||
"""
|
||||
- [ ] Eins
|
||||
"""
|
||||
Wenn das Dokument auf "Team-Plan" umbenannt wird
|
||||
Dann erhalte ich für das Umbenennen den Status 200
|
||||
Und das Dokument trägt den Titel "Team-Plan"
|
||||
Und das Dokument steht auf Version 2
|
||||
Und das Dokument hat die Zeilen:
|
||||
"""
|
||||
- [ ] Eins
|
||||
"""
|
||||
|
||||
Szenario: Der Feed stellt die Umbenennung samt neuem Titel zu
|
||||
Angenommen es existiert ein Dokument "Plan" mit den Zeilen:
|
||||
"""
|
||||
- [ ] Eins
|
||||
"""
|
||||
Wenn das Dokument auf "Team-Plan" umbenannt wird
|
||||
Und ich die Änderungen seit Version 1 abrufe
|
||||
Dann der Feed meldet das Ereignis "RENAMED"
|
||||
Und das RENAMED-Ereignis nennt den Titel "Team-Plan"
|
||||
|
||||
Szenario: Eine veraltete Version wird abgelehnt
|
||||
Angenommen es existiert ein Dokument "Plan" mit den Zeilen:
|
||||
"""
|
||||
- [ ] Eins
|
||||
"""
|
||||
Wenn das Dokument mit veralteter Version auf "Zu spät" umbenannt wird
|
||||
Dann erhalte ich für das Umbenennen den Status 409
|
||||
Und das Dokument trägt den Titel "Plan"
|
||||
|
||||
Szenario: Ein leerer Titel wird abgelehnt
|
||||
Angenommen es existiert ein Dokument "Plan" mit den Zeilen:
|
||||
"""
|
||||
- [ ] Eins
|
||||
"""
|
||||
Wenn das Dokument auf " " umbenannt wird
|
||||
Dann erhalte ich für das Umbenennen den Status 400
|
||||
Und das Dokument trägt den Titel "Plan"
|
||||
Reference in New Issue
Block a user