diff --git a/backend/cmd/web/main.go b/backend/cmd/web/main.go index cfe04fc..4192493 100644 --- a/backend/cmd/web/main.go +++ b/backend/cmd/web/main.go @@ -149,10 +149,11 @@ func main() { fileDeps := web.FilesDeps{Queries: q, Files: filesStore, MaxUploadMB: maxUploadMB} etapeDeps := web.EtapesDeps{Queries: q} eventDeps := web.EventsDeps{Queries: q} + discussionDeps := web.DiscussionDeps{Queries: q} planningDeps := web.PlanningDeps{Queries: q} // D-09: pass the embedded static FS — binary has zero runtime file dependencies. - router, err := web.NewRouter(pool, assets.Static, deps, tabloDeps, taskDeps, etapeDeps, eventDeps, planningDeps, fileDeps, csrfKey, env) + router, err := web.NewRouter(pool, assets.Static, deps, tabloDeps, taskDeps, etapeDeps, eventDeps, discussionDeps, planningDeps, fileDeps, csrfKey, env) if err != nil { slog.Error("router init failed", "err", err) os.Exit(1) diff --git a/backend/internal/web/csrf_test.go b/backend/internal/web/csrf_test.go index fc54a51..94d4452 100644 --- a/backend/internal/web/csrf_test.go +++ b/backend/internal/web/csrf_test.go @@ -22,7 +22,7 @@ func newTestRouterWithCSRF(q *sqlc.Queries, store *auth.Store) http.Handler { csrfKey[i] = byte(i + 1) } deps := AuthDeps{Queries: q, Store: store, Secure: false} - router, err := NewRouter(stubPinger{}, os.DirFS("./static"), deps, TablosDeps{Queries: q}, TasksDeps{Queries: q}, EtapesDeps{Queries: q}, EventsDeps{Queries: q}, PlanningDeps{Queries: q}, FilesDeps{Queries: q}, csrfKey, "dev", "localhost") + router, err := NewRouter(stubPinger{}, os.DirFS("./static"), deps, TablosDeps{Queries: q}, TasksDeps{Queries: q}, EtapesDeps{Queries: q}, EventsDeps{Queries: q}, DiscussionDeps{Queries: q}, PlanningDeps{Queries: q}, FilesDeps{Queries: q}, csrfKey, "dev", "localhost") if err != nil { panic("newTestRouterWithCSRF: " + err.Error()) } diff --git a/backend/internal/web/handlers_auth_test.go b/backend/internal/web/handlers_auth_test.go index 3367114..7e94997 100644 --- a/backend/internal/web/handlers_auth_test.go +++ b/backend/internal/web/handlers_auth_test.go @@ -36,7 +36,7 @@ var testCSRFKey = func() []byte { // Referer header are accepted. func newTestRouter(q *sqlc.Queries, store *auth.Store) http.Handler { deps := AuthDeps{Queries: q, Store: store, Secure: false} - router, err := NewRouter(stubPinger{}, os.DirFS("./static"), deps, TablosDeps{Queries: q}, TasksDeps{Queries: q}, EtapesDeps{Queries: q}, EventsDeps{Queries: q}, PlanningDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost") + router, err := NewRouter(stubPinger{}, os.DirFS("./static"), deps, TablosDeps{Queries: q}, TasksDeps{Queries: q}, EtapesDeps{Queries: q}, EventsDeps{Queries: q}, DiscussionDeps{Queries: q}, PlanningDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost") if err != nil { panic("newTestRouter: " + err.Error()) } @@ -47,7 +47,7 @@ func newTestRouter(q *sqlc.Queries, store *auth.Store) http.Handler { // enabling rate-limit tests to use a fake clock. func newTestRouterWithLimiter(q *sqlc.Queries, store *auth.Store, rl *auth.LimiterStore) http.Handler { deps := AuthDeps{Queries: q, Store: store, Secure: false, Limiter: rl} - router, err := NewRouter(stubPinger{}, os.DirFS("./static"), deps, TablosDeps{Queries: q}, TasksDeps{Queries: q}, EtapesDeps{Queries: q}, EventsDeps{Queries: q}, PlanningDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost") + router, err := NewRouter(stubPinger{}, os.DirFS("./static"), deps, TablosDeps{Queries: q}, TasksDeps{Queries: q}, EtapesDeps{Queries: q}, EventsDeps{Queries: q}, DiscussionDeps{Queries: q}, PlanningDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost") if err != nil { panic("newTestRouterWithLimiter: " + err.Error()) } @@ -56,7 +56,7 @@ func newTestRouterWithLimiter(q *sqlc.Queries, store *auth.Store, rl *auth.Limit func newAuthPageRouter(t *testing.T, deps AuthDeps) http.Handler { t.Helper() - router, err := NewRouter(stubPinger{}, os.DirFS("./static"), deps, TablosDeps{}, TasksDeps{}, EtapesDeps{}, EventsDeps{}, PlanningDeps{}, FilesDeps{}, testCSRFKey, "dev", "localhost") + router, err := NewRouter(stubPinger{}, os.DirFS("./static"), deps, TablosDeps{}, TasksDeps{}, EtapesDeps{}, EventsDeps{}, DiscussionDeps{}, PlanningDeps{}, FilesDeps{}, testCSRFKey, "dev", "localhost") if err != nil { t.Fatalf("NewRouter: %v", err) } diff --git a/backend/internal/web/handlers_discussion.go b/backend/internal/web/handlers_discussion.go new file mode 100644 index 0000000..65248c8 --- /dev/null +++ b/backend/internal/web/handlers_discussion.go @@ -0,0 +1,118 @@ +package web + +import ( + "log/slog" + "net/http" + "strings" + + "backend/internal/db/sqlc" + "backend/templates" + + "github.com/google/uuid" + "github.com/gorilla/csrf" + "github.com/jackc/pgx/v5/pgtype" +) + +type DiscussionDeps struct { + Queries *sqlc.Queries +} + +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 + } + _ = templates.TabloDetailPage(user, csrf.Token(r), tablo, nil, nil, templates.EtapeTaskCounts{}, templates.EtapeFilter{}, nil, templates.EventsCalendar{}, data, "discussion").Render(r.Context(), w) + } +} + +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) + + if r.Header.Get("HX-Request") == "true" { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _ = templates.DiscussionMessageRow(templates.DiscussionMessageFromRow(row)).Render(r.Context(), w) + return + } + http.Redirect(w, r, templates.DiscussionURL(tablo.ID), http.StatusSeeOther) + } +} diff --git a/backend/internal/web/handlers_discussion_test.go b/backend/internal/web/handlers_discussion_test.go index 799d242..680b16c 100644 --- a/backend/internal/web/handlers_discussion_test.go +++ b/backend/internal/web/handlers_discussion_test.go @@ -21,7 +21,7 @@ import ( func newDiscussionTestRouter(q *sqlc.Queries, store *auth.Store) http.Handler { authDeps := AuthDeps{Queries: q, Store: store, Secure: false} tabloDeps := TablosDeps{Queries: q} - router, err := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, TasksDeps{Queries: q}, EtapesDeps{Queries: q}, EventsDeps{Queries: q}, PlanningDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost") + router, err := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, TasksDeps{Queries: q}, EtapesDeps{Queries: q}, EventsDeps{Queries: q}, DiscussionDeps{Queries: q}, PlanningDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost") if err != nil { panic("newDiscussionTestRouter: " + err.Error()) } diff --git a/backend/internal/web/handlers_etapes_test.go b/backend/internal/web/handlers_etapes_test.go index b226e71..1280c4c 100644 --- a/backend/internal/web/handlers_etapes_test.go +++ b/backend/internal/web/handlers_etapes_test.go @@ -20,7 +20,7 @@ func newEtapeTestRouter(q *sqlc.Queries, store *auth.Store) http.Handler { tabloDeps := TablosDeps{Queries: q} taskDeps := TasksDeps{Queries: q} etapeDeps := EtapesDeps{Queries: q} - router, err := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, taskDeps, etapeDeps, EventsDeps{Queries: q}, PlanningDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost") + router, err := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, taskDeps, etapeDeps, EventsDeps{Queries: q}, DiscussionDeps{Queries: q}, PlanningDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost") if err != nil { panic("newEtapeTestRouter: " + err.Error()) } diff --git a/backend/internal/web/handlers_events.go b/backend/internal/web/handlers_events.go index 4551856..04f8d0e 100644 --- a/backend/internal/web/handlers_events.go +++ b/backend/internal/web/handlers_events.go @@ -166,7 +166,7 @@ func TabloEventsTabHandler(deps EventsDeps) http.HandlerFunc { _ = templates.EventsTabFragment(tablo, calendar, csrf.Token(r)).Render(r.Context(), w) return } - _ = templates.TabloDetailPage(user, csrf.Token(r), tablo, nil, nil, templates.EtapeTaskCounts{}, templates.EtapeFilter{}, nil, calendar, "events").Render(r.Context(), w) + _ = templates.TabloDetailPage(user, csrf.Token(r), tablo, nil, nil, templates.EtapeTaskCounts{}, templates.EtapeFilter{}, nil, calendar, templates.DiscussionTabData{}, "events").Render(r.Context(), w) } } diff --git a/backend/internal/web/handlers_events_test.go b/backend/internal/web/handlers_events_test.go index ad97be9..43e76e1 100644 --- a/backend/internal/web/handlers_events_test.go +++ b/backend/internal/web/handlers_events_test.go @@ -26,7 +26,7 @@ func newEventTestRouter(q *sqlc.Queries, store *auth.Store) http.Handler { taskDeps := TasksDeps{Queries: q} etapeDeps := EtapesDeps{Queries: q} eventDeps := EventsDeps{Queries: q} - router, err := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, taskDeps, etapeDeps, eventDeps, PlanningDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost") + router, err := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, taskDeps, etapeDeps, eventDeps, DiscussionDeps{Queries: q}, PlanningDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost") if err != nil { panic("newEventTestRouter: " + err.Error()) } diff --git a/backend/internal/web/handlers_files.go b/backend/internal/web/handlers_files.go index b70ffe1..08f2c78 100644 --- a/backend/internal/web/handlers_files.go +++ b/backend/internal/web/handlers_files.go @@ -95,7 +95,7 @@ func TabloFilesTabHandler(deps FilesDeps) http.HandlerFunc { _ = templates.FilesTabFragment(tablo, fileList, csrf.Token(r)).Render(r.Context(), w) return } - _ = templates.TabloDetailPage(user, csrf.Token(r), tablo, nil, nil, templates.EtapeTaskCounts{}, templates.EtapeFilter{}, fileList, templates.EventsCalendar{}, "files").Render(r.Context(), w) + _ = templates.TabloDetailPage(user, csrf.Token(r), tablo, nil, nil, templates.EtapeTaskCounts{}, templates.EtapeFilter{}, fileList, templates.EventsCalendar{}, templates.DiscussionTabData{}, "files").Render(r.Context(), w) } } @@ -119,7 +119,7 @@ func TabloTasksTabHandler(deps FilesDeps) http.HandlerFunc { _ = templates.TasksTabFragment(tablo, tasks, etapes, counts, filter, csrf.Token(r)).Render(r.Context(), w) return } - _ = templates.TabloDetailPage(user, csrf.Token(r), tablo, tasks, etapes, counts, filter, nil, templates.EventsCalendar{}, "tasks").Render(r.Context(), w) + _ = templates.TabloDetailPage(user, csrf.Token(r), tablo, tasks, etapes, counts, filter, nil, templates.EventsCalendar{}, templates.DiscussionTabData{}, "tasks").Render(r.Context(), w) } } diff --git a/backend/internal/web/handlers_files_test.go b/backend/internal/web/handlers_files_test.go index 53a029c..79d0a23 100644 --- a/backend/internal/web/handlers_files_test.go +++ b/backend/internal/web/handlers_files_test.go @@ -61,7 +61,7 @@ func newFileTestRouter(q *sqlc.Queries, store *auth.Store, fileStore files.FileS tabloDeps := TablosDeps{Queries: q} taskDeps := TasksDeps{Queries: q} fileDeps := FilesDeps{Queries: q, Files: fileStore, MaxUploadMB: 25} - router, err := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, taskDeps, EtapesDeps{Queries: q}, EventsDeps{Queries: q}, PlanningDeps{Queries: q}, fileDeps, testCSRFKey, "dev", "localhost") + router, err := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, taskDeps, EtapesDeps{Queries: q}, EventsDeps{Queries: q}, DiscussionDeps{Queries: q}, PlanningDeps{Queries: q}, fileDeps, testCSRFKey, "dev", "localhost") if err != nil { panic("newFileTestRouter: " + err.Error()) } @@ -170,7 +170,7 @@ func TestFileUploadTooLarge(t *testing.T) { tabloDeps := TablosDeps{Queries: q} taskDeps := TasksDeps{Queries: q} fileDeps := FilesDeps{Queries: q, Files: stub, MaxUploadMB: 1} - router, err := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, taskDeps, EtapesDeps{Queries: q}, EventsDeps{Queries: q}, PlanningDeps{Queries: q}, fileDeps, testCSRFKey, "dev", "localhost") + router, err := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, taskDeps, EtapesDeps{Queries: q}, EventsDeps{Queries: q}, DiscussionDeps{Queries: q}, PlanningDeps{Queries: q}, fileDeps, testCSRFKey, "dev", "localhost") if err != nil { t.Fatalf("NewRouter: %v", err) } diff --git a/backend/internal/web/handlers_planning_test.go b/backend/internal/web/handlers_planning_test.go index fa43856..af23846 100644 --- a/backend/internal/web/handlers_planning_test.go +++ b/backend/internal/web/handlers_planning_test.go @@ -21,8 +21,9 @@ func newPlanningTestRouter(q *sqlc.Queries, store *auth.Store, now func() time.T taskDeps := TasksDeps{Queries: q} etapeDeps := EtapesDeps{Queries: q} eventDeps := EventsDeps{Queries: q} + discussionDeps := DiscussionDeps{Queries: q} planningDeps := PlanningDeps{Queries: q, Now: now} - router, err := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, taskDeps, etapeDeps, eventDeps, planningDeps, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost") + router, err := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, taskDeps, etapeDeps, eventDeps, discussionDeps, planningDeps, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost") if err != nil { panic("newPlanningTestRouter: " + err.Error()) } diff --git a/backend/internal/web/handlers_social_test.go b/backend/internal/web/handlers_social_test.go index 6fd33dc..0c386db 100644 --- a/backend/internal/web/handlers_social_test.go +++ b/backend/internal/web/handlers_social_test.go @@ -72,7 +72,7 @@ func withGoogleClaims(deps AuthDeps, claims auth.ProviderClaims) AuthDeps { func newSocialRouter(t *testing.T, deps AuthDeps) http.Handler { t.Helper() - router, err := NewRouter(stubPinger{}, os.DirFS("./static"), deps, TablosDeps{}, TasksDeps{}, EtapesDeps{}, EventsDeps{}, PlanningDeps{}, FilesDeps{}, testCSRFKey, "dev", "localhost") + router, err := NewRouter(stubPinger{}, os.DirFS("./static"), deps, TablosDeps{}, TasksDeps{}, EtapesDeps{}, EventsDeps{}, DiscussionDeps{}, PlanningDeps{}, FilesDeps{}, testCSRFKey, "dev", "localhost") if err != nil { t.Fatalf("NewRouter: %v", err) } diff --git a/backend/internal/web/handlers_tablos.go b/backend/internal/web/handlers_tablos.go index acb6425..3076f2f 100644 --- a/backend/internal/web/handlers_tablos.go +++ b/backend/internal/web/handlers_tablos.go @@ -202,7 +202,7 @@ func TabloDetailHandler(deps TablosDeps) http.HandlerFunc { tasks = []sqlc.Task{} } w.Header().Set("Content-Type", "text/html; charset=utf-8") - _ = templates.TabloDetailPage(user, csrf.Token(r), tablo, tasks, nil, templates.EtapeTaskCounts{}, templates.EtapeFilter{}, nil, templates.EventsCalendar{}, "overview").Render(r.Context(), w) + _ = templates.TabloDetailPage(user, csrf.Token(r), tablo, tasks, nil, templates.EtapeTaskCounts{}, templates.EtapeFilter{}, nil, templates.EventsCalendar{}, templates.DiscussionTabData{}, "overview").Render(r.Context(), w) } } @@ -308,7 +308,7 @@ func TabloUpdateHandler(deps TablosDeps) http.HandlerFunc { if tasks == nil { tasks = []sqlc.Task{} } - _ = templates.TabloDetailPage(user, csrf.Token(r), tablo, tasks, nil, templates.EtapeTaskCounts{}, templates.EtapeFilter{}, nil, templates.EventsCalendar{}, "overview").Render(ctx, w) + _ = templates.TabloDetailPage(user, csrf.Token(r), tablo, tasks, nil, templates.EtapeTaskCounts{}, templates.EtapeFilter{}, nil, templates.EventsCalendar{}, templates.DiscussionTabData{}, "overview").Render(ctx, w) return } diff --git a/backend/internal/web/handlers_tablos_test.go b/backend/internal/web/handlers_tablos_test.go index bb7a909..cb6eaf1 100644 --- a/backend/internal/web/handlers_tablos_test.go +++ b/backend/internal/web/handlers_tablos_test.go @@ -27,7 +27,7 @@ import ( func newTabloTestRouter(q *sqlc.Queries, store *auth.Store) http.Handler { authDeps := AuthDeps{Queries: q, Store: store, Secure: false} tabloDeps := TablosDeps{Queries: q} - router, err := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, TasksDeps{Queries: q}, EtapesDeps{Queries: q}, EventsDeps{Queries: q}, PlanningDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost") + router, err := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, TasksDeps{Queries: q}, EtapesDeps{Queries: q}, EventsDeps{Queries: q}, DiscussionDeps{Queries: q}, PlanningDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost") if err != nil { panic("newTabloTestRouter: " + err.Error()) } diff --git a/backend/internal/web/handlers_tasks_test.go b/backend/internal/web/handlers_tasks_test.go index 8fbdcc6..f50a50d 100644 --- a/backend/internal/web/handlers_tasks_test.go +++ b/backend/internal/web/handlers_tasks_test.go @@ -30,7 +30,7 @@ func newTaskTestRouter(q *sqlc.Queries, store *auth.Store) http.Handler { authDeps := AuthDeps{Queries: q, Store: store, Secure: false} tabloDeps := TablosDeps{Queries: q} taskDeps := TasksDeps{Queries: q} - router, err := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, taskDeps, EtapesDeps{Queries: q}, EventsDeps{Queries: q}, PlanningDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost") + router, err := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, taskDeps, EtapesDeps{Queries: q}, EventsDeps{Queries: q}, DiscussionDeps{Queries: q}, PlanningDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost") if err != nil { panic("newTaskTestRouter: " + err.Error()) } diff --git a/backend/internal/web/handlers_test.go b/backend/internal/web/handlers_test.go index 0354120..cfc77f4 100644 --- a/backend/internal/web/handlers_test.go +++ b/backend/internal/web/handlers_test.go @@ -92,7 +92,7 @@ func TestReadyz_Down(t *testing.T) { // was public. The HTMX demo content is tested by // TestProtected_HomeAuthRendersUserEmail in handlers_auth_test.go. func TestIndex_UnauthRedirects(t *testing.T) { - router, err := NewRouter(stubPinger{}, os.DirFS("./static"), AuthDeps{}, TablosDeps{}, TasksDeps{}, EtapesDeps{}, EventsDeps{}, PlanningDeps{}, FilesDeps{}, testCSRFKey, "dev", "localhost") + router, err := NewRouter(stubPinger{}, os.DirFS("./static"), AuthDeps{}, TablosDeps{}, TasksDeps{}, EtapesDeps{}, EventsDeps{}, DiscussionDeps{}, PlanningDeps{}, FilesDeps{}, testCSRFKey, "dev", "localhost") if err != nil { t.Fatalf("NewRouter: %v", err) } @@ -110,7 +110,7 @@ func TestIndex_UnauthRedirects(t *testing.T) { } func TestDemoTime_Fragment(t *testing.T) { - router, err := NewRouter(stubPinger{}, os.DirFS("./static"), AuthDeps{}, TablosDeps{}, TasksDeps{}, EtapesDeps{}, EventsDeps{}, PlanningDeps{}, FilesDeps{}, testCSRFKey, "dev", "localhost") + router, err := NewRouter(stubPinger{}, os.DirFS("./static"), AuthDeps{}, TablosDeps{}, TasksDeps{}, EtapesDeps{}, EventsDeps{}, DiscussionDeps{}, PlanningDeps{}, FilesDeps{}, testCSRFKey, "dev", "localhost") if err != nil { t.Fatalf("NewRouter: %v", err) } @@ -136,7 +136,7 @@ func TestDemoTime_Fragment(t *testing.T) { } func TestRequestID_HeaderSet(t *testing.T) { - router, err := NewRouter(stubPinger{}, os.DirFS("./static"), AuthDeps{}, TablosDeps{}, TasksDeps{}, EtapesDeps{}, EventsDeps{}, PlanningDeps{}, FilesDeps{}, testCSRFKey, "dev", "localhost") + router, err := NewRouter(stubPinger{}, os.DirFS("./static"), AuthDeps{}, TablosDeps{}, TasksDeps{}, EtapesDeps{}, EventsDeps{}, DiscussionDeps{}, PlanningDeps{}, FilesDeps{}, testCSRFKey, "dev", "localhost") if err != nil { t.Fatalf("NewRouter: %v", err) } diff --git a/backend/internal/web/router.go b/backend/internal/web/router.go index 01b8605..3bcf778 100644 --- a/backend/internal/web/router.go +++ b/backend/internal/web/router.go @@ -48,7 +48,7 @@ type Pinger interface { // trustedOrigins is an optional list of additional origins for the CSRF // referer check (used in integration tests to allow localhost requests without // a Referer header). In production, pass no extra args — leave empty. -func NewRouter(pinger Pinger, staticFS fs.FS, deps AuthDeps, tabloDeps TablosDeps, taskDeps TasksDeps, etapeDeps EtapesDeps, eventDeps EventsDeps, planningDeps PlanningDeps, fileDeps FilesDeps, csrfKey []byte, env string, trustedOrigins ...string) (http.Handler, error) { +func NewRouter(pinger Pinger, staticFS fs.FS, deps AuthDeps, tabloDeps TablosDeps, taskDeps TasksDeps, etapeDeps EtapesDeps, eventDeps EventsDeps, discussionDeps DiscussionDeps, planningDeps PlanningDeps, fileDeps FilesDeps, csrfKey []byte, env string, trustedOrigins ...string) (http.Handler, error) { r := chi.NewRouter() r.Use(RequestIDMiddleware) r.Use(chimw.RealIP) @@ -124,6 +124,9 @@ func NewRouter(pinger Pinger, staticFS fs.FS, deps AuthDeps, tabloDeps TablosDep r.Post("/tablos/{id}/events/{event_id}", EventUpdateHandler(eventDeps)) r.Get("/tablos/{id}/events/{event_id}/delete-confirm", EventDeleteConfirmHandler(eventDeps)) r.Post("/tablos/{id}/events/{event_id}/delete", EventDeleteHandler(eventDeps)) + // Discussion tab and message routes — static discussion segment before later parametric child routes. + r.Get("/tablos/{id}/discussion", TabloDiscussionTabHandler(discussionDeps)) + r.Post("/tablos/{id}/discussion/messages", DiscussionMessageCreateHandler(discussionDeps)) // Parametric task routes — must come after static task segments. r.Get("/tablos/{id}/tasks/{task_id}/show", TaskShowHandler(taskDeps)) r.Get("/tablos/{id}/tasks/{task_id}/edit", TaskEditHandler(taskDeps)) diff --git a/backend/templates/discussion.templ b/backend/templates/discussion.templ new file mode 100644 index 0000000..c5a1518 --- /dev/null +++ b/backend/templates/discussion.templ @@ -0,0 +1,92 @@ +package templates + +import ( + "time" + + "backend/internal/db/sqlc" + "backend/internal/web/ui" +) + +templ DiscussionTabFragment(tablo sqlc.Tablo, data DiscussionTabData, form DiscussionForm, errs DiscussionErrors, csrfToken string) { +
+
+
+

Discussion

+

1 participant

+
+
+
+ if len(data.Messages) == 0 { + @DiscussionEmptyState() + } else { +
+ for i, message := range data.Messages { + if DiscussionShowDaySeparator(data.Messages, i) { + @DiscussionDaySeparator(message.CreatedAt) + } + @DiscussionMessageRow(message) + } +
+ } +
+ @DiscussionComposer(tablo, form, errs, csrfToken) +
+} + +templ DiscussionEmptyState() { +
+

No messages yet

+

Start the discussion for this tablo.

+
+} + +templ DiscussionDaySeparator(createdAt time.Time) { +
+ { DiscussionDateLabel(createdAt) } +
+} + +templ DiscussionMessageRow(message DiscussionMessageView) { +
+
+ { message.AuthorEmail } + +
+

{ message.Body }

+
+} + +templ DiscussionComposer(tablo sqlc.Tablo, form DiscussionForm, errs DiscussionErrors, csrfToken string) { +
+ @ui.CSRFField(csrfToken) + @GeneralError(errs.General) +
+ + + @FieldError(errs.Body) +
+
+ @ui.Button(ui.ButtonProps{ + Label: "Send message", + Variant: ui.ButtonVariantDefault, + Tone: ui.ButtonToneSolid, + Size: ui.SizeMD, + Type: "submit", + }) +
+
+} diff --git a/backend/templates/discussion_forms.go b/backend/templates/discussion_forms.go new file mode 100644 index 0000000..8ed9837 --- /dev/null +++ b/backend/templates/discussion_forms.go @@ -0,0 +1,83 @@ +package templates + +import ( + "strconv" + "time" + + "backend/internal/db/sqlc" + + "github.com/google/uuid" +) + +const DiscussionMaxBodyLength = 10000 + +type DiscussionForm struct { + Body string +} + +type DiscussionErrors struct { + Body string + General string +} + +type DiscussionMessageView struct { + ID uuid.UUID + AuthorEmail string + Body string + CreatedAt time.Time +} + +type DiscussionTabData struct { + Messages []DiscussionMessageView +} + +func DiscussionPostURL(tabloID uuid.UUID) string { + return "/tablos/" + tabloID.String() + "/discussion/messages" +} + +func DiscussionMaxBodyLengthString() string { + return strconv.Itoa(DiscussionMaxBodyLength) +} + +func DiscussionURL(tabloID uuid.UUID) string { + return "/tablos/" + tabloID.String() + "/discussion" +} + +func DiscussionMessagesFromRows(rows []sqlc.ListDiscussionMessagesByTabloRow) []DiscussionMessageView { + messages := make([]DiscussionMessageView, 0, len(rows)) + for _, row := range rows { + messages = append(messages, DiscussionMessageView{ + ID: row.ID, + AuthorEmail: row.AuthorEmail, + Body: row.Body, + CreatedAt: row.CreatedAt.Time, + }) + } + return messages +} + +func DiscussionMessageFromRow(row sqlc.GetDiscussionMessageWithAuthorRow) DiscussionMessageView { + return DiscussionMessageView{ + ID: row.ID, + AuthorEmail: row.AuthorEmail, + Body: row.Body, + CreatedAt: row.CreatedAt.Time, + } +} + +func DiscussionDateLabel(t time.Time) string { + return t.Local().Format("January 2, 2006") +} + +func DiscussionTimestampLabel(t time.Time) string { + return t.Local().Format("January 2, 2006 15:04") +} + +func DiscussionShowDaySeparator(messages []DiscussionMessageView, index int) bool { + if index == 0 { + return true + } + current := messages[index].CreatedAt.Local() + previous := messages[index-1].CreatedAt.Local() + return current.Year() != previous.Year() || current.YearDay() != previous.YearDay() +} diff --git a/backend/templates/tablos.templ b/backend/templates/tablos.templ index f61068d..e6145c6 100644 --- a/backend/templates/tablos.templ +++ b/backend/templates/tablos.templ @@ -171,7 +171,7 @@ templ TabloCardWithOOBFormClear(tablo sqlc.Tablo, csrfToken string) { // files and tasks are pre-fetched slices for the active tab (may be nil for inactive tabs). // UI-SPEC §3 Interaction Contract — GET /tablos/{id}. // D-07: signature includes activeTab string param; D-08: tab bar links carry hx-push-url. -templ TabloDetailPage(user *auth.User, csrfToken string, tablo sqlc.Tablo, tasks []sqlc.Task, etapes []sqlc.Etape, counts EtapeTaskCounts, filter EtapeFilter, files []sqlc.TabloFile, events EventsCalendar, activeTab string) { +templ TabloDetailPage(user *auth.User, csrfToken string, tablo sqlc.Tablo, tasks []sqlc.Task, etapes []sqlc.Etape, counts EtapeTaskCounts, filter EtapeFilter, files []sqlc.TabloFile, events EventsCalendar, discussion DiscussionTabData, activeTab string) { @Layout("Tablos — Xtablo", user, csrfToken) {
← Back to tablos @@ -235,6 +235,18 @@ templ TabloDetailPage(user *auth.User, csrfToken string, tablo sqlc.Tablo, tasks class="px-4 py-2 text-sm text-slate-600 hover:text-slate-800 hover:border-b-2 hover:border-slate-400 -mb-px" } >Events + Discussion
@@ -244,6 +256,8 @@ templ TabloDetailPage(user *auth.User, csrfToken string, tablo sqlc.Tablo, tasks @FilesTabFragment(tablo, files, csrfToken) } else if activeTab == "events" { @EventsTabFragment(tablo, events, csrfToken) + } else if activeTab == "discussion" { + @DiscussionTabFragment(tablo, discussion, DiscussionForm{}, DiscussionErrors{}, csrfToken) } else { @TabloOverviewTabFragment(tablo, csrfToken) }