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:
mhoennig
2026-08-27 08:13:25 +02:00
co-authored by Claude Fable 5
parent c438a88344
commit fbe9b1fd22
16 changed files with 380 additions and 12 deletions
@@ -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)]
+72 -2
View File
@@ -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