DocumentService.kt

package de.werkbaum.service

import de.werkbaum.domain.ChangeAuthor
import de.werkbaum.domain.ChangeType
import de.werkbaum.domain.Document
import de.werkbaum.domain.DocumentHistoryEntry
import de.werkbaum.repository.DocumentHistoryRepository
import de.werkbaum.repository.DocumentRepository
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import org.springframework.transaction.support.TransactionSynchronization
import org.springframework.transaction.support.TransactionSynchronizationManager
import java.time.Clock
import java.time.Duration
import java.time.OffsetDateTime
import java.util.UUID

@Service
@Transactional
class DocumentService(
    private val repository: DocumentRepository,
    private val historyRepository: DocumentHistoryRepository,
    private val clock: Clock,
    private val properties: LiveEditingProperties,
    private val notifier: ChangeNotifier,
) {

    fun findAll(): List<Document> = repository.findAll()

    fun findById(id: UUID): Document =
        findByIdOrNull(id) ?: throw DocumentNotFoundException(id)

    /** Wie [findById], nur ohne Ausnahme – wenn der Aufrufer selbst unterscheiden will. */
    fun findByIdOrNull(id: UUID): Document? = repository.findById(id)

    fun create(title: String, content: String): Document {
        val now = OffsetDateTime.now(clock)
        val document = Document(
            id = UUID.randomUUID(),
            title = title,
            content = content,
            version = 1,
            createdAt = now,
            updatedAt = now,
        )
        repository.save(document)
        recordHistory(document, ChangeType.CREATED)
        return document
    }

    /**
     * Ersetzt Titel und Inhalt vollständig.
     *
     * [milestone] `false` schreibt eine **Sync-Version** – gedacht für den
     * getakteten Strom des Live-Editings (D76), der sonst hunderte
     * nutzersichtbare Stände je Sitzung erzeugte. Der Vollersatz über die API
     * ist dagegen eine bewusste Handlung (Import, Reparatur) und bleibt
     * Meilenstein.
     */
    fun update(
        id: UUID,
        title: String,
        content: String,
        milestone: Boolean = true,
        author: ChangeAuthor? = null,
    ): Document {
        val existing = findById(id)
        val updated = existing.copy(
            title = title,
            content = content,
            version = existing.version + 1,
            updatedAt = OffsetDateTime.now(clock),
        )
        repository.save(updated)
        recordHistory(updated, ChangeType.UPDATED, milestone, author)
        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)
        // Tombstone-Eintrag: konserviert den letzten Stand und überlebt das DELETE.
        recordHistory(
            existing.copy(
                version = existing.version + 1,
                updatedAt = OffsetDateTime.now(clock),
            ),
            ChangeType.DELETED,
        )
    }

    /**
     * Die **nutzersichtbare** Historie: alle Meilensteine, älteste zuerst,
     * dazu immer der jüngste Stand. Sync-Versionen bleiben draußen – sie
     * tragen das Protokoll, nicht die Erzählung (D76).
     *
     * Funktioniert auch für bereits gelöschte Dokumente; 404 nur, wenn die
     * UUID gänzlich unbekannt ist.
     */
    fun history(id: UUID): List<DocumentHistoryEntry> {
        if (!historyRepository.exists(id)) throw DocumentNotFoundException(id)
        val milestones = historyRepository.findMilestones(id)
        // Die letzte Version einer noch laufenden Schreibphase ist noch kein
        // Meilenstein – sichtbar sein muss sie trotzdem.
        val latest = historyRepository.findLatest(id)
        return if (latest != null && milestones.none { it.version == latest.version }) {
            milestones + latest
        } else {
            milestones
        }
    }

    /**
     * Stellt ein Dokument unter derselben UUID wieder her.
     *
     * - Ohne [targetVersion]: letzter Stand vor dem Löschen ([ChangeType.RESTORED]).
     *   Existiert das Dokument noch, gibt es einen Konflikt (409).
     * - Mit [targetVersion]: Inhalt dieser Version wird als neue Version
     *   übernommen. Bei einem lebenden Dokument ist das ein Rückfall
     *   ([ChangeType.ROLLED_BACK]), kein Wiederherstellen – der Client hatte
     *   nie eine Sperre.
     *
     * Eine verdichtete Sync-Version ist nicht mehr anzusteuern (404). Das ist
     * die Zwei-Ebenen-Regel im Betrieb: Angeboten werden Meilensteine, und die
     * bleiben.
     */
    fun restore(id: UUID, targetVersion: Long? = null): Document {
        if (!historyRepository.exists(id)) throw DocumentNotFoundException(id)

        val existing = repository.findById(id)
        if (existing != null && targetVersion == null) {
            throw DocumentConflictException(
                "Dokument $id existiert noch; zum Rollback bitte eine Zielversion angeben"
            )
        }

        val snapshot = if (targetVersion != null) {
            historyRepository.findVersion(id, targetVersion)
                ?.takeIf { it.changeType != ChangeType.DELETED }
                ?: throw DocumentNotFoundException(id)
        } else {
            // Der Tombstone trägt den letzten Stand – er ist die verlässliche
            // Quelle, auch wenn die Version davor längst verdichtet wurde.
            historyRepository.findLatest(id) ?: throw DocumentNotFoundException(id)
        }

        val now = OffsetDateTime.now(clock)
        val lastVersion = maxOf(historyRepository.maxVersion(id) ?: 0, existing?.version ?: 0)
        val restored = Document(
            id = id,
            title = snapshot.title,
            content = snapshot.content,
            version = lastVersion + 1,
            createdAt = existing?.createdAt
                ?: historyRepository.findOldest(id)?.timestamp
                ?: now,
            updatedAt = now,
        )
        repository.save(restored)
        recordHistory(
            restored,
            if (existing != null) ChangeType.ROLLED_BACK else ChangeType.RESTORED,
        )
        return restored
    }

    /**
     * Schreibt einen Historieneintrag und hält dabei die zwei Ebenen instand:
     *
     * 1. War die vorige Version eine Sync-Version und liegt sie länger als
     *    [LiveEditingProperties.milestonePause] zurück, war sie die **letzte
     *    vor einer Schreibpause** und wird nachträglich Meilenstein. So
     *    braucht es keinen Zeitgeber – die nächste Änderung stellt fest, dass
     *    eine Pause war.
     * 2. Strukturelle Änderungen sind immer Meilensteine.
     * 3. Danach wird verdichtet: Sync-Versionen jenseits der
     *    Aufbewahrungsfrist entfallen.
     */
    private fun recordHistory(
        document: Document,
        changeType: ChangeType,
        milestone: Boolean = true,
        author: ChangeAuthor? = null,
    ) {
        val previous = historyRepository.findLatest(document.id)
        if (previous != null && !previous.milestone &&
            Duration.between(previous.timestamp, document.updatedAt) >= properties.milestonePause
        ) {
            historyRepository.promoteToMilestone(document.id, previous.version)
        }

        historyRepository.append(
            DocumentHistoryEntry(
                documentId = document.id,
                version = document.version,
                title = document.title,
                content = document.content,
                changeType = changeType,
                timestamp = document.updatedAt,
                milestone = milestone || changeType.isStructural,
                author = author,
            )
        )

        historyRepository.compact(
            document.id,
            document.updatedAt.minus(properties.syncRetention),
        )

        publishAfterCommit(document.id)
    }

    /**
     * Weckt die Beobachter am Änderungsfeed – **nach** dem Commit. Vorher
     * geweckt läse ein Beobachter einen Stand, der noch nicht steht, und
     * bekäme das Ereignis nie wieder. Ohne laufende Transaktion (Tests) wird
     * sofort gemeldet.
     */
    private fun publishAfterCommit(documentId: UUID) {
        if (!TransactionSynchronizationManager.isSynchronizationActive()) {
            notifier.published(documentId)
            return
        }
        TransactionSynchronizationManager.registerSynchronization(
            object : TransactionSynchronization {
                override fun afterCommit() = notifier.published(documentId)
            }
        )
    }

    /** Anlegen, Löschen, Wiederherstellen und Rückfall sind nie bloß Sync-Versionen. */
    private val ChangeType.isStructural: Boolean
        get() = this != ChangeType.UPDATED
}