2026-05-16 08:11:14 +00:00
|
|
|
package web
|
|
|
|
|
|
|
|
|
|
import (
|
2026-05-16 08:18:33 +00:00
|
|
|
"bytes"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"fmt"
|
2026-05-16 08:11:14 +00:00
|
|
|
"log/slog"
|
|
|
|
|
"net/http"
|
|
|
|
|
"strings"
|
2026-05-16 08:18:33 +00:00
|
|
|
"time"
|
2026-05-16 08:11:14 +00:00
|
|
|
|
|
|
|
|
"backend/internal/db/sqlc"
|
|
|
|
|
"backend/templates"
|
|
|
|
|
|
|
|
|
|
"github.com/google/uuid"
|
|
|
|
|
"github.com/gorilla/csrf"
|
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type DiscussionDeps struct {
|
2026-05-16 08:18:33 +00:00
|
|
|
Queries *sqlc.Queries
|
|
|
|
|
Realtime DiscussionRealtime
|
2026-05-16 08:11:14 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func loadDiscussionTabData(w http.ResponseWriter, r *http.Request, q *sqlc.Queries, tablo sqlc.Tablo) (templates.DiscussionTabData, bool) {
|
|
|
|
|
rows, err := q.ListDiscussionMessagesByTablo(r.Context(), tablo.ID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
slog.Default().Error("discussion: ListDiscussionMessagesByTablo failed", "tablo_id", tablo.ID, "err", err)
|
|
|
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
|
|
|
return templates.DiscussionTabData{}, false
|
|
|
|
|
}
|
|
|
|
|
data := templates.DiscussionTabData{Messages: templates.DiscussionMessagesFromRows(rows)}
|
|
|
|
|
return data, true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func markDiscussionRead(r *http.Request, q *sqlc.Queries, tablo sqlc.Tablo, userID uuid.UUID, data templates.DiscussionTabData) {
|
|
|
|
|
if len(data.Messages) == 0 {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
last := data.Messages[len(data.Messages)-1]
|
|
|
|
|
if _, err := q.UpsertDiscussionReadState(r.Context(), sqlc.UpsertDiscussionReadStateParams{
|
|
|
|
|
TabloID: tablo.ID,
|
|
|
|
|
UserID: userID,
|
|
|
|
|
LastReadMessageID: pgtype.UUID{Bytes: last.ID, Valid: true},
|
|
|
|
|
}); err != nil {
|
|
|
|
|
slog.Default().Warn("discussion: UpsertDiscussionReadState failed", "tablo_id", tablo.ID, "err", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TabloDiscussionTabHandler(deps DiscussionDeps) http.HandlerFunc {
|
|
|
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
tablo, user, ok := loadOwnedTablo(w, r, TablosDeps{Queries: deps.Queries})
|
|
|
|
|
if !ok {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
data, ok := loadDiscussionTabData(w, r, deps.Queries, tablo)
|
|
|
|
|
if !ok {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
markDiscussionRead(r, deps.Queries, tablo, user.ID, data)
|
|
|
|
|
|
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
|
|
|
if r.Header.Get("HX-Request") == "true" {
|
|
|
|
|
_ = templates.DiscussionTabFragment(tablo, data, templates.DiscussionForm{}, templates.DiscussionErrors{}, csrf.Token(r)).Render(r.Context(), w)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-05-16 19:49:23 +00:00
|
|
|
discussionSidebarTablos, sidebarErr := deps.Queries.ListTablosByUser(r.Context(), user.ID)
|
|
|
|
|
if sidebarErr != nil {
|
|
|
|
|
slog.Default().Error("discussion: ListTablosByUser failed", "user_id", user.ID, "err", sidebarErr)
|
|
|
|
|
discussionSidebarTablos = []sqlc.Tablo{}
|
|
|
|
|
}
|
|
|
|
|
if discussionSidebarTablos == nil {
|
|
|
|
|
discussionSidebarTablos = []sqlc.Tablo{}
|
|
|
|
|
}
|
|
|
|
|
_ = templates.TabloDetailPage(user, csrf.Token(r), "", discussionSidebarTablos, tablo, nil, nil, templates.EtapeTaskCounts{}, templates.EtapeFilter{}, nil, templates.EventsCalendar{}, data, "discussion").Render(r.Context(), w)
|
2026-05-16 08:11:14 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func DiscussionMessageCreateHandler(deps DiscussionDeps) http.HandlerFunc {
|
|
|
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
tablo, user, ok := loadOwnedTablo(w, r, TablosDeps{Queries: deps.Queries})
|
|
|
|
|
if !ok {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
body := strings.TrimSpace(r.PostFormValue("body"))
|
|
|
|
|
form := templates.DiscussionForm{Body: r.PostFormValue("body")}
|
|
|
|
|
var errs templates.DiscussionErrors
|
|
|
|
|
if body == "" {
|
|
|
|
|
errs.Body = "Message is required."
|
|
|
|
|
} else if len([]rune(body)) > templates.DiscussionMaxBodyLength {
|
|
|
|
|
errs.Body = "Message is too long."
|
|
|
|
|
}
|
|
|
|
|
if errs.Body != "" {
|
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
|
|
|
w.WriteHeader(http.StatusUnprocessableEntity)
|
|
|
|
|
_ = templates.DiscussionComposer(tablo, form, errs, csrf.Token(r)).Render(r.Context(), w)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
msg, err := deps.Queries.CreateDiscussionMessage(r.Context(), sqlc.CreateDiscussionMessageParams{
|
|
|
|
|
TabloID: tablo.ID,
|
|
|
|
|
AuthorUserID: user.ID,
|
|
|
|
|
Body: body,
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
slog.Default().Error("discussion create: CreateDiscussionMessage failed", "tablo_id", tablo.ID, "err", err)
|
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
|
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
|
|
|
errs.General = "Message could not be sent. Please try again."
|
|
|
|
|
_ = templates.DiscussionComposer(tablo, form, errs, csrf.Token(r)).Render(r.Context(), w)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
row, err := deps.Queries.GetDiscussionMessageWithAuthor(r.Context(), sqlc.GetDiscussionMessageWithAuthorParams{
|
|
|
|
|
ID: msg.ID,
|
|
|
|
|
TabloID: tablo.ID,
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
slog.Default().Error("discussion create: GetDiscussionMessageWithAuthor failed", "tablo_id", tablo.ID, "message_id", msg.ID, "err", err)
|
|
|
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
data := templates.DiscussionTabData{Messages: []templates.DiscussionMessageView{templates.DiscussionMessageFromRow(row)}}
|
|
|
|
|
markDiscussionRead(r, deps.Queries, tablo, user.ID, data)
|
2026-05-16 08:18:33 +00:00
|
|
|
message := templates.DiscussionMessageFromRow(row)
|
|
|
|
|
if deps.Realtime != nil {
|
|
|
|
|
html, err := renderDiscussionMessageHTML(r, message)
|
|
|
|
|
if err != nil {
|
|
|
|
|
slog.Default().Warn("discussion create: render realtime message failed", "tablo_id", tablo.ID, "message_id", msg.ID, "err", err)
|
|
|
|
|
} else {
|
|
|
|
|
deps.Realtime.Publish(DiscussionEvent{
|
|
|
|
|
TabloID: tablo.ID,
|
|
|
|
|
MessageID: msg.ID,
|
|
|
|
|
AuthorUserID: user.ID,
|
|
|
|
|
MessageHTML: html,
|
|
|
|
|
RefreshUnread: true,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-16 08:11:14 +00:00
|
|
|
|
|
|
|
|
if r.Header.Get("HX-Request") == "true" {
|
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
2026-05-16 08:18:33 +00:00
|
|
|
_ = templates.DiscussionMessageRow(message).Render(r.Context(), w)
|
2026-05-16 08:11:14 +00:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
http.Redirect(w, r, templates.DiscussionURL(tablo.ID), http.StatusSeeOther)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-16 08:18:33 +00:00
|
|
|
|
|
|
|
|
func DiscussionStreamHandler(deps DiscussionDeps) http.HandlerFunc {
|
|
|
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
tablo, _, ok := loadOwnedTablo(w, r, TablosDeps{Queries: deps.Queries})
|
|
|
|
|
if !ok {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
flusher, ok := w.(http.Flusher)
|
|
|
|
|
if !ok {
|
|
|
|
|
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if deps.Realtime == nil {
|
|
|
|
|
http.Error(w, "streaming unavailable", http.StatusServiceUnavailable)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
events, unsubscribe := deps.Realtime.Subscribe(r.Context(), tablo.ID)
|
|
|
|
|
defer unsubscribe()
|
|
|
|
|
|
|
|
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
|
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
|
|
|
w.Header().Set("Connection", "keep-alive")
|
|
|
|
|
w.Header().Set("X-Accel-Buffering", "no")
|
|
|
|
|
_, _ = fmt.Fprint(w, ": connected\n\n")
|
|
|
|
|
flusher.Flush()
|
|
|
|
|
|
|
|
|
|
keepalive := time.NewTicker(25 * time.Second)
|
|
|
|
|
defer keepalive.Stop()
|
|
|
|
|
for {
|
|
|
|
|
select {
|
|
|
|
|
case <-r.Context().Done():
|
|
|
|
|
return
|
|
|
|
|
case <-keepalive.C:
|
|
|
|
|
_, _ = fmt.Fprint(w, ": keepalive\n\n")
|
|
|
|
|
flusher.Flush()
|
|
|
|
|
case event, ok := <-events:
|
|
|
|
|
if !ok {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if err := writeDiscussionEvent(w, event); err != nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
flusher.Flush()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func renderDiscussionMessageHTML(r *http.Request, message templates.DiscussionMessageView) (string, error) {
|
|
|
|
|
var buf bytes.Buffer
|
|
|
|
|
if err := templates.DiscussionMessageRow(message).Render(r.Context(), &buf); err != nil {
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
|
|
|
|
return buf.String(), nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func writeDiscussionEvent(w http.ResponseWriter, event DiscussionEvent) error {
|
|
|
|
|
payload, err := json.Marshal(event)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
_, err = fmt.Fprintf(w, "event: discussion-message\ndata: %s\n\n", payload)
|
|
|
|
|
return err
|
|
|
|
|
}
|