{ message.Body }
+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) { +
1 participant
+Start the discussion for this tablo.
+{ message.Body }
+