feat(05-03): implement FileDownloadHandler, FileDeleteConfirmHandler, FileDeleteHandler

- FileDownloadHandler: nil guard → loadOwnedTabloForFile → PresignDownload → 302 redirect (FILE-04)
- FileDeleteConfirmHandler: nil guard → loadOwnedTabloForFile → render FileDeleteConfirmFragment
- FileDeleteHandler: nil guard → loadOwnedTabloForFile → S3 Delete (log+continue) → DeleteTabloFile → FileRowGone HTMX / 303 redirect (FILE-05, FILE-06)
- Add FileDeleteConfirmFragment templ component mirroring TaskDeleteConfirmFragment pattern (T-05-03-05)
This commit is contained in:
Arthur Belleville 2026-05-15 12:34:07 +02:00
parent 98a5a02b93
commit 9d4dd4f3e2
No known key found for this signature in database
2 changed files with 119 additions and 6 deletions

View file

@ -242,25 +242,91 @@ func itoa(n int) string {
}
// FileDownloadHandler handles GET /tablos/{id}/files/{file_id}/download.
// Stub — returns 501 until Plan 03.
// Generates a 5-minute presigned URL and returns a 302 redirect to it (FILE-04).
//
// Security invariants:
// - deps.Files nil guard is the FIRST statement (T-05-03-06)
// - Ownership enforced by loadOwnedTabloForFile (T-05-03-02, FILE-06)
// - Presigned URL has 5-minute TTL set inside files.Store.PresignDownload (T-05-03-01)
func FileDownloadHandler(deps FilesDeps) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "not implemented", http.StatusNotImplemented)
if deps.Files == nil {
http.Error(w, "storage not configured", http.StatusServiceUnavailable)
return
}
tablo, file, _, ok := loadOwnedTabloForFile(w, r, deps)
if !ok {
return
}
_ = tablo
url, err := deps.Files.PresignDownload(r.Context(), file.S3Key)
if err != nil {
slog.Default().Error("files download: PresignDownload failed", "file_id", file.ID, "err", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, url, http.StatusFound)
}
}
// FileDeleteConfirmHandler handles GET /tablos/{id}/files/{file_id}/delete-confirm.
// Stub — returns 501 until Plan 03.
// Renders an inline deletion confirmation fragment (same HTMX pattern as task delete).
//
// Security invariants:
// - deps.Files nil guard is the FIRST statement (T-05-03-06)
// - Ownership enforced by loadOwnedTabloForFile (T-05-03-02, FILE-06)
func FileDeleteConfirmHandler(deps FilesDeps) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "not implemented", http.StatusNotImplemented)
if deps.Files == nil {
http.Error(w, "storage not configured", http.StatusServiceUnavailable)
return
}
tablo, file, _, ok := loadOwnedTabloForFile(w, r, deps)
if !ok {
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = templates.FileDeleteConfirmFragment(tablo.ID, file, csrf.Token(r)).Render(r.Context(), w)
}
}
// FileDeleteHandler handles POST /tablos/{id}/files/{file_id}/delete.
// Stub — returns 501 until Plan 03.
// Deletes S3 object first (logs failure but continues), then removes DB row (FILE-05).
//
// Security invariants:
// - deps.Files nil guard is the FIRST statement (T-05-03-06)
// - Ownership enforced by loadOwnedTabloForFile (T-05-03-03, FILE-06)
// - CSRF enforced by gorilla/csrf middleware on the router (T-05-03-05)
// - S3 failure is logged but does NOT abort DB delete — orphan objects are Phase 6 worker concern (T-05-03-04)
func FileDeleteHandler(deps FilesDeps) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "not implemented", http.StatusNotImplemented)
if deps.Files == nil {
http.Error(w, "storage not configured", http.StatusServiceUnavailable)
return
}
tablo, file, _, ok := loadOwnedTabloForFile(w, r, deps)
if !ok {
return
}
// Step 1: Delete from S3. Log failure but always continue to DB delete.
if err := deps.Files.Delete(r.Context(), file.S3Key); err != nil {
slog.Default().Error("files delete: S3 Delete failed", "key", file.S3Key, "err", err)
// Do NOT return — orphan S3 objects are reconciled by Phase 6 worker.
}
// Step 2: Delete DB row. This is the authoritative state.
if err := deps.Queries.DeleteTabloFile(r.Context(), sqlc.DeleteTabloFileParams{
ID: file.ID,
TabloID: tablo.ID,
}); err != nil {
slog.Default().Error("files delete: DeleteTabloFile failed", "id", file.ID, "err", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = templates.FileRowGone(file.ID).Render(r.Context(), w)
return
}
http.Redirect(w, r, "/tablos/"+tablo.ID.String()+"/files", http.StatusSeeOther)
}
}

View file

@ -98,6 +98,53 @@ templ FileListRow(tabloID uuid.UUID, file sqlc.TabloFile) {
</li>
}
// FileDeleteConfirmFragment renders the inline delete confirmation for a single file row.
// Mirrors TaskDeleteConfirmFragment exactly: outer wrapper reuses the .file-row-zone id
// so HTMX outerHTML swap replaces the row in place (T-05-03-05, FILE-05).
templ FileDeleteConfirmFragment(tabloID uuid.UUID, file sqlc.TabloFile, csrfToken string) {
<div class="file-row-zone" id={ "file-" + file.ID.String() }>
<div class="bg-white rounded border border-slate-200 p-3 shadow-sm space-y-2">
<p class="text-sm font-semibold text-slate-800">Delete file?</p>
<p class="text-xs text-slate-600 truncate">{ file.Filename }</p>
<p class="text-xs text-slate-500">This cannot be undone.</p>
<div class="flex items-center gap-2">
<form
method="POST"
action={ templ.SafeURL("/tablos/" + tabloID.String() + "/files/" + file.ID.String() + "/delete") }
hx-post={ "/tablos/" + tabloID.String() + "/files/" + file.ID.String() + "/delete" }
hx-target="closest .file-row-zone"
hx-swap="outerHTML"
>
@ui.CSRFField(csrfToken)
@ui.Button(ui.ButtonProps{
Label: "Yes, delete",
Variant: ui.ButtonVariantDanger,
Tone: ui.ButtonToneSolid,
Size: ui.SizeMD,
Type: "submit",
Attrs: templ.Attributes{
"aria-label": "Confirm delete file",
},
})
</form>
@ui.Button(ui.ButtonProps{
Label: "Keep file",
Variant: ui.ButtonVariantNeutral,
Tone: ui.ButtonToneSoft,
Size: ui.SizeMD,
Type: "button",
Attrs: templ.Attributes{
"hx-get": "/tablos/" + tabloID.String() + "/files",
"hx-target": "#tab-content",
"hx-swap": "innerHTML",
"aria-label": "Keep file",
},
})
</div>
</div>
</div>
}
// FileListEmpty renders the empty-state message when no files are attached.
templ FileListEmpty() {
<p class="text-sm text-slate-400 italic py-4">No files attached yet.</p>