feat(web): chat with an uploaded transcript, reusing the ADR-027 path (ADR-033)

GET/POST /uploads/{id}/chat, mounted only when both an Uploader and a Chatter
are wired. The row comes from the user-scoped private store, so another user's
upload is a 404 before any model is involved. The owner's transcript goes to
the SAME Chatter with App.UploadChatMaxChars instead of the 18,000-char caption
default. The chat panel is shared, not forked: chatView gains PostURL/Subject
(empty = a YouTube video, so existing callers are unchanged). The done card
links to chat only when chat is wired.

Mutation-checked: dropping the upload budget, ignoring the done-status guard,
or scoping the lookup to the wrong user each turn tests red.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 16:58:25 +02:00
co-authored by Claude Sonnet 5.5
parent a39f58b730
commit 4fdb212cef
7 changed files with 609 additions and 341 deletions
+24
View File
@@ -6,6 +6,8 @@ import (
"net/http"
"strings"
"github.com/a-h/templ"
"git.d-ma.be/mathias/tapir/internal/adapters/chat"
"git.d-ma.be/mathias/tapir/internal/adapters/store"
)
@@ -43,6 +45,28 @@ type chatView struct {
History []chat.Turn
Truncated bool
Error string
// PostURL and Subject let the same chat panel serve an UPLOADED recording
// (ADR-033): where the form posts, and what the transcript is called. Empty
// means a YouTube video (/v/{VideoID}/chat, "video"), so existing callers are
// unchanged.
PostURL templ.SafeURL
Subject string
}
// postURL is where the chat form posts.
func (v chatView) postURL() templ.SafeURL {
if v.PostURL != "" {
return v.PostURL
}
return chatURL(v.VideoID)
}
// subject is what the transcript is called in the panel's wording.
func (v chatView) subject() string {
if v.Subject != "" {
return v.Subject
}
return "video"
}
// handleChat renders the chat page for a summarized video (GET). Entry is scoped