DocumentTemplateDetailController.java

package com.taxonomy.templates;

import com.taxonomy.templates.DocumentTemplateGitRepository.TemplateConflictException;
import com.taxonomy.templates.DocumentTemplateGitRepository.TemplateDescriptor;
import com.taxonomy.templates.DocumentTemplateGitRepository.TemplateDiff;
import com.taxonomy.templates.DocumentTemplateGitRepository.TemplateNotFoundException;
import com.taxonomy.templates.DocumentTemplateService.TemplateFile;
import com.taxonomy.templates.DocumentTemplateService.TemplatePartView;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.CacheControl;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;

import java.io.IOException;
import java.security.Principal;

/** Accessible server-rendered history, comparison, inspection and restore workspace. */
@Controller
public final class DocumentTemplateDetailController {

    private final DocumentTemplateService templates;
    private final DocumentTemplateReportPreview preview;

    public DocumentTemplateDetailController(
            DocumentTemplateService templates,
            DocumentTemplateReportPreview preview) {
        this.templates = templates;
        this.preview = preview;
    }

    /** The URL, download and upload all retain the same immutable starting revision. */
    @GetMapping("/admin/document-templates/{templateId}/local-edit")
    public String localEdit(
            @PathVariable String templateId,
            @RequestParam String revision,
            Model model) throws IOException {
        if (revision == null || !revision.matches("[0-9a-f]{40}")) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
                    "A full immutable template revision is required");
        }
        TemplateFile original;
        try {
            original = templates.download(templateId, revision);
        } catch (TemplateNotFoundException exception) {
            throw new ResponseStatusException(HttpStatus.NOT_FOUND,
                    "The requested template starting revision does not exist", exception);
        }
        model.addAttribute("template", descriptor(original));
        model.addAttribute("maxArchiveBytes", OoxmlTemplatePackageCodec.MAX_ARCHIVE_BYTES);
        model.addAttribute("decisionReportTemplate",
                DecisionRationaleTemplateContract.TEMPLATE_ID.equals(templateId));
        return "document-template-local-edit";
    }

    @GetMapping("/admin/document-templates/{templateId}")
    public String detail(
            @PathVariable String templateId,
            @RequestParam(required = false) String from,
            @RequestParam(required = false) String to,
            @RequestParam(required = false) String partRevision,
            @RequestParam(required = false) String partPath,
            Model model) throws IOException {
        TemplateFile current = templates.downloadCurrent(templateId);
        TemplateDescriptor descriptor = descriptor(current);
        model.addAttribute("template", descriptor);
        model.addAttribute("history", templates.history(templateId));
        model.addAttribute("decisionReportTemplate",
                DecisionRationaleTemplateContract.TEMPLATE_ID.equals(templateId));

        if (from != null && !from.isBlank()) {
            String right = to == null || to.isBlank() ? current.commitId() : to;
            TemplateDiff diff = templates.diff(templateId, from, right);
            model.addAttribute("diff", diff);
            model.addAttribute("fromRevision", from);
            model.addAttribute("toRevision", right);
        }
        if (partRevision != null && !partRevision.isBlank()
                && partPath != null && !partPath.isBlank()) {
            TemplatePartView part = templates.readPart(
                    templateId, partRevision, partPath);
            model.addAttribute("part", part);
            model.addAttribute("partRevision", partRevision);
        }
        return "document-template-detail";
    }

    /** A read-only, bookmarkable confirmation; reloading never replaces its precondition. */
    @GetMapping("/admin/document-templates/{templateId}/restore")
    public String confirmRestore(
            @PathVariable String templateId,
            @RequestParam String revision,
            @RequestParam String expectedHead,
            Model model) throws IOException {
        requireImmutableRevision(revision);
        requireImmutableRevision(expectedHead);
        try {
            TemplateDescriptor target = templates.describe(templateId, revision);
            TemplateDescriptor current = templates.describeCurrent(templateId);
            model.addAttribute("template", current);
            model.addAttribute("restoreTarget", target);
            model.addAttribute("restoreRevision", revision);
            model.addAttribute("restoreExpectedHead", expectedHead);
            model.addAttribute("restoreConflict", !expectedHead.equals(current.headCommit()));
            return "document-template-restore";
        } catch (TemplateNotFoundException exception) {
            throw missingRestoreVersion(exception);
        }
    }

    @PostMapping("/admin/document-templates/{templateId}/restore")
    public String restore(
            @PathVariable String templateId,
            @RequestParam String revision,
            @RequestParam String expectedHead,
            @RequestParam(defaultValue = "false") boolean confirmed,
            Principal principal,
            Model model,
            HttpServletResponse response,
            RedirectAttributes redirect) throws IOException {
        requireImmutableRevision(revision);
        requireImmutableRevision(expectedHead);
        if (!confirmed) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
                    "Explicit confirmation is required to restore a template");
        }
        try {
            TemplateDescriptor restored = templates.restore(
                    templateId, revision, expectedHead, principal.getName());
            redirect.addFlashAttribute("restoredRevision", restored.headCommit());
            return "redirect:/admin/document-templates/" + templateId;
        } catch (TemplateConflictException exception) {
            // Do not substitute the new head or retry the mutation. Preserve both original choices.
            String view = confirmRestore(templateId, revision, expectedHead, model);
            model.addAttribute("restoreConflict", true);
            response.setStatus(HttpStatus.PRECONDITION_FAILED.value());
            return view;
        } catch (TemplateNotFoundException exception) {
            throw missingRestoreVersion(exception);
        }
    }

    private static void requireImmutableRevision(String revision) {
        if (revision == null || !revision.matches("[0-9a-f]{40}")) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
                    "A full immutable template revision is required");
        }
    }

    private static ResponseStatusException missingRestoreVersion(TemplateNotFoundException cause) {
        return new ResponseStatusException(HttpStatus.NOT_FOUND,
                "The requested template version does not exist", cause);
    }

    @GetMapping("/admin/document-templates/{templateId}/test.docx")
    public ResponseEntity<byte[]> testReport(@PathVariable String templateId) {
        if (!DecisionRationaleTemplateContract.TEMPLATE_ID.equals(templateId)) {
            throw new IllegalArgumentException(
                    "A generated test report is available only for the decision report template");
        }
        byte[] docx = preview.renderPreview();
        return ResponseEntity.ok()
                .cacheControl(CacheControl.noStore())
                .contentType(MediaType.parseMediaType(
                        "application/vnd.openxmlformats-officedocument.wordprocessingml.document"))
                .contentLength(docx.length)
                .header(HttpHeaders.CONTENT_DISPOSITION,
                        ContentDisposition.attachment()
                                .filename("decision-rationale-template-test.docx")
                                .build().toString())
                .body(docx);
    }

    private static TemplateDescriptor descriptor(TemplateFile file) {
        var manifest = file.manifest();
        return new TemplateDescriptor(
                manifest.templateId(), manifest.displayName(), manifest.fileName(),
                file.commitId(), manifest.updatedAt(), manifest.updatedBy(),
                manifest.uncompressedSize(), manifest.partCount(), manifest.packageSha256());
    }
}