TaigaController.kt

package de.werkbaum.api

import de.werkbaum.generated.api.TaigaApi
import de.werkbaum.generated.model.TaigaAuthRequest
import de.werkbaum.generated.model.TaigaProject
import de.werkbaum.generated.model.TaigaSession
import de.werkbaum.generated.model.TaigaStoryCreateRequest
import de.werkbaum.generated.model.TaigaTaskCreateRequest
import de.werkbaum.generated.model.TaigaStatus
import de.werkbaum.generated.model.TaigaStatusPatch
import de.werkbaum.generated.model.TaigaTicket
import de.werkbaum.generated.model.TaigaTicketDetail
import de.werkbaum.integration.taiga.TaigaBadRequestException
import de.werkbaum.integration.taiga.TaigaBulkRefData
import de.werkbaum.integration.taiga.TaigaClient
import de.werkbaum.integration.taiga.TaigaTicketDetailData
import de.werkbaum.integration.taiga.TaigaTicketData
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController

/**
 * Der Taiga-Proxy (D91) — implementiert das generierte Interface, wie der
 * DocumentsController seines: Ändert sich die Spezifikation, schlägt hier
 * der Compile fehl (API First).
 *
 * Hier gibt es nur die Abbildung API ↔ Client; alles Inhaltliche —
 * Ziel-URL aus der Server-Konfiguration, Fehlerklassen, das schmale
 * Antwortformat — liegt im [TaigaClient]. Kein Logging von Request-Bodies:
 * Der Auth-Endpunkt sieht das Passwort nur im Durchflug.
 */
@RestController
@RequestMapping("/api/v1")
class TaigaController(private val client: TaigaClient) : TaigaApi {

    override fun taigaLogin(taigaAuthRequest: TaigaAuthRequest): ResponseEntity<TaigaSession> {
        val session = client.login(taigaAuthRequest.username, taigaAuthRequest.password)
        return ResponseEntity.ok(
            TaigaSession(
                authToken = session.authToken,
                userId = session.userId,
                username = session.username,
                fullName = session.fullName,
            )
        )
    }

    override fun taigaProjects(xTaigaToken: String, member: Long): ResponseEntity<List<TaigaProject>> =
        ResponseEntity.ok(
            client.projects(xTaigaToken, member).map {
                TaigaProject(id = it.id, name = it.name, slug = it.slug)
            }
        )

    override fun taigaCreateStory(
        xTaigaToken: String,
        taigaStoryCreateRequest: TaigaStoryCreateRequest,
    ): ResponseEntity<TaigaTicket> {
        val ticket = client.createStory(
            token = xTaigaToken,
            project = taigaStoryCreateRequest.project,
            subject = taigaStoryCreateRequest.subject,
        )
        return created(ticket)
    }

    override fun taigaCreateTask(
        xTaigaToken: String,
        taigaTaskCreateRequest: TaigaTaskCreateRequest,
    ): ResponseEntity<TaigaTicket> {
        val ticket = client.createTask(
            token = xTaigaToken,
            project = taigaTaskCreateRequest.project,
            subject = taigaTaskCreateRequest.subject,
            userStory = taigaTaskCreateRequest.userStory,
        )
        return created(ticket)
    }

    /* Bulk-Lesen (D91-Nachtrag 10): eine Anfrage vom Editor, der Fächer läuft
       im Client. Die Refs kommen MIT Werkbaum-Präfix (`US-`/`T-`) — es trägt
       den Typ; geparst wird streng, Ungültiges ist ein 400 statt still
       übersprungen (D59-Linie). Doppelte Refs werden vor dem Fächer
       zusammengelegt. */
    override fun taigaTickets(
        xTaigaToken: String,
        slug: String,
        refs: String,
    ): ResponseEntity<Map<String, TaigaTicketDetail>> =
        ResponseEntity.ok(
            client.tickets(xTaigaToken, slug, parseBulkRefs(refs))
                .mapValues { it.value.toApi() }
        )

    private fun parseBulkRefs(refs: String): List<TaigaBulkRefData> {
        val teile = refs.split(',').distinct()
        if (teile.size > MAX_BULK_REFS) {
            throw TaigaBadRequestException("Höchstens $MAX_BULK_REFS Refs je Anfrage")
        }
        return teile.map { teil ->
            val m = BULK_REF.matchEntire(teil)
                ?: throw TaigaBadRequestException("Ungültige Ref: '$teil'")
            TaigaBulkRefData(key = teil, task = m.groupValues[1] == "T", nr = m.groupValues[2].toLong())
        }
    }

    /* Lesen (D91-Nachtrag 6): zwei Endpunkte statt eines mit Typ-Parameter —
       das Präfix der Ref trägt den Typ, und Taiga hat getrennte
       `by_ref`-Endpunkte. Die Abbildung des Status auf die Notation macht der
       Editor: Das Backend parst die Notation nicht (D14). */
    override fun taigaStoryByRef(xTaigaToken: String, ref: Long, slug: String) =
        ticket(xTaigaToken, slug, ref, task = false)

    override fun taigaTaskByRef(xTaigaToken: String, ref: Long, slug: String) =
        ticket(xTaigaToken, slug, ref, task = true)

    private fun ticket(token: String, slug: String, ref: Long, task: Boolean):
        ResponseEntity<TaigaTicketDetail> =
        ResponseEntity.ok(client.ticket(token, slug, ref, task).toApi())

    /* Schreiben (D91-Nachtrag 7/8): angestoßen wird es im Knoten-Fenster, von
       selbst geschieht nichts. Die Zielspalte kommt als Id herein — welche es
       ist, entscheidet der Editor über die Statusbox-Abbildung (D14). */
    override fun taigaStoryStatuses(xTaigaToken: String, slug: String) =
        statuses(xTaigaToken, slug, task = false)

    override fun taigaTaskStatuses(xTaigaToken: String, slug: String) =
        statuses(xTaigaToken, slug, task = true)

    private fun statuses(token: String, slug: String, task: Boolean):
        ResponseEntity<List<TaigaStatus>> =
        ResponseEntity.ok(
            client.statuses(token, slug, task).map {
                TaigaStatus(id = it.id, name = it.name, closed = it.closed)
            }
        )

    override fun taigaSetStoryStatus(
        xTaigaToken: String,
        ref: Long,
        slug: String,
        taigaStatusPatch: TaigaStatusPatch,
    ) = setStatus(xTaigaToken, slug, ref, task = false, patch = taigaStatusPatch)

    override fun taigaSetTaskStatus(
        xTaigaToken: String,
        ref: Long,
        slug: String,
        taigaStatusPatch: TaigaStatusPatch,
    ) = setStatus(xTaigaToken, slug, ref, task = true, patch = taigaStatusPatch)

    private fun setStatus(
        token: String,
        slug: String,
        ref: Long,
        task: Boolean,
        patch: TaigaStatusPatch,
    ): ResponseEntity<TaigaTicketDetail> =
        ResponseEntity.ok(
            client.setStatus(token, slug, ref, task, patch.status, patch.version).toApi()
        )

    private fun TaigaTicketDetailData.toApi() = TaigaTicketDetail(
        id = id,
        ref = ref,
        subject = subject,
        status = status,
        statusClosed = statusClosed,
        assignee = assignee,
        version = version,
    )

    private fun created(ticket: TaigaTicketData): ResponseEntity<TaigaTicket> =
        ResponseEntity.status(HttpStatus.CREATED).body(
            TaigaTicket(id = ticket.id, ref = ticket.ref, subject = ticket.subject)
        )

    companion object {
        /** Höchstens so viele Refs je Bulk-Anfrage — mehr referenziert kein Plan. */
        private const val MAX_BULK_REFS = 200
        private val BULK_REF = Regex("(US|T)-(\\d{1,10})")
    }
}