- TablosListHandler: derives sidebarTablos from cardViews, calls TablosDashboard with activePath="/" - TabloDetailHandler: fetches ListTablosByUser for sidebar, calls TabloDetailPage with activePath="" - TabloUpdateHandler: fetches ListTablosByUser for non-HTMX error path - renderTabloCreateError: derives errorSidebarTablos from errorCardViews - TabloDiscussionTabHandler, TabloEventsTabHandler, TabloFilesTabHandler, TabloTasksTabHandler: fetch ListTablosByUser for non-HTMX full-page renders - PlanningPageHandler: fetches ListTablosByUser, calls PlanningPage with activePath="/planning" - AccountProvidersHandler: fetches ListTablosByUser, calls AccountProvidersPage with activePath="/" - planning.templ: updated signature + switched to @AppLayout - account_providers.templ: updated signature + switched to @AppLayout
359 lines
13 KiB
Go
359 lines
13 KiB
Go
package web
|
|
|
|
import (
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"backend/internal/db/sqlc"
|
|
"backend/templates"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/gorilla/csrf"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
)
|
|
|
|
type EventsDeps struct {
|
|
Queries *sqlc.Queries
|
|
}
|
|
|
|
func parseCalendarMonth(raw string, now time.Time) time.Time {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw != "" {
|
|
if parsed, err := time.Parse("2006-01", raw); err == nil {
|
|
return time.Date(parsed.Year(), parsed.Month(), 1, 0, 0, 0, 0, time.Local)
|
|
}
|
|
}
|
|
return time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.Local)
|
|
}
|
|
|
|
func pgDateFromTime(t time.Time) pgtype.Date {
|
|
return pgtype.Date{Time: time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.Local), Valid: true}
|
|
}
|
|
|
|
func parseEventDate(raw string) (pgtype.Date, bool) {
|
|
parsed, err := time.Parse("2006-01-02", strings.TrimSpace(raw))
|
|
if err != nil {
|
|
return pgtype.Date{}, false
|
|
}
|
|
return pgDateFromTime(parsed), true
|
|
}
|
|
|
|
func parseEventTime(raw string) (pgtype.Time, bool) {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return pgtype.Time{}, false
|
|
}
|
|
parsed, err := time.Parse("15:04", raw)
|
|
if err != nil {
|
|
return pgtype.Time{}, false
|
|
}
|
|
micros := int64(parsed.Hour())*int64(time.Hour/time.Microsecond) + int64(parsed.Minute())*int64(time.Minute/time.Microsecond)
|
|
return pgtype.Time{Microseconds: micros, Valid: true}, true
|
|
}
|
|
|
|
func buildEventsCalendar(month time.Time, events []sqlc.Event) templates.EventsCalendar {
|
|
monthStart := time.Date(month.Year(), month.Month(), 1, 0, 0, 0, 0, time.Local)
|
|
monthEnd := monthStart.AddDate(0, 1, -1)
|
|
leading := (int(monthStart.Weekday()) + 6) % 7
|
|
gridStart := monthStart.AddDate(0, 0, -leading)
|
|
days := make([]templates.EventCalendarDay, 0, 42)
|
|
byDate := make(map[string][]sqlc.Event)
|
|
for _, event := range events {
|
|
key := templates.FormatEventDate(event.EventDate)
|
|
byDate[key] = append(byDate[key], event)
|
|
}
|
|
for i := 0; i < 42; i++ {
|
|
day := gridStart.AddDate(0, 0, i)
|
|
key := day.Format("2006-01-02")
|
|
dayEvents := byDate[key]
|
|
more := 0
|
|
if len(dayEvents) > 3 {
|
|
more = len(dayEvents) - 3
|
|
}
|
|
days = append(days, templates.EventCalendarDay{
|
|
Date: key,
|
|
DayNumber: day.Day(),
|
|
InMonth: !day.Before(monthStart) && !day.After(monthEnd),
|
|
Events: dayEvents,
|
|
MoreCount: more,
|
|
})
|
|
}
|
|
return templates.EventsCalendar{
|
|
Month: monthStart.Format("2006-01"),
|
|
MonthLabel: monthStart.Format("January 2006"),
|
|
PrevMonth: monthStart.AddDate(0, -1, 0).Format("2006-01"),
|
|
PrevMonthLabel: monthStart.AddDate(0, -1, 0).Format("January 2006"),
|
|
NextMonth: monthStart.AddDate(0, 1, 0).Format("2006-01"),
|
|
NextMonthLabel: monthStart.AddDate(0, 1, 0).Format("January 2006"),
|
|
Days: days,
|
|
WeekdayLabs: templates.DefaultWeekdayLabels(),
|
|
}
|
|
}
|
|
|
|
func loadEventsCalendar(w http.ResponseWriter, r *http.Request, q *sqlc.Queries, tablo sqlc.Tablo) (templates.EventsCalendar, bool) {
|
|
month := parseCalendarMonth(r.URL.Query().Get("month"), time.Now())
|
|
monthStart := time.Date(month.Year(), month.Month(), 1, 0, 0, 0, 0, time.Local)
|
|
monthEnd := monthStart.AddDate(0, 1, -1)
|
|
events, err := q.ListEventsByTabloRange(r.Context(), sqlc.ListEventsByTabloRangeParams{
|
|
TabloID: tablo.ID,
|
|
EventDate: pgDateFromTime(monthStart),
|
|
EventDate_2: pgDateFromTime(monthEnd),
|
|
})
|
|
if err != nil {
|
|
slog.Default().Error("events tab: ListEventsByTabloRange failed", "tablo_id", tablo.ID, "err", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return templates.EventsCalendar{}, false
|
|
}
|
|
return buildEventsCalendar(month, events), true
|
|
}
|
|
|
|
func loadOwnedEvent(w http.ResponseWriter, r *http.Request, deps EventsDeps) (sqlc.Tablo, sqlc.Event, bool) {
|
|
tablo, _, ok := loadOwnedTablo(w, r, TablosDeps{Queries: deps.Queries})
|
|
if !ok {
|
|
return sqlc.Tablo{}, sqlc.Event{}, false
|
|
}
|
|
eventID, err := uuid.Parse(chi.URLParam(r, "event_id"))
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
return sqlc.Tablo{}, sqlc.Event{}, false
|
|
}
|
|
event, err := deps.Queries.GetEventByID(r.Context(), sqlc.GetEventByIDParams{
|
|
ID: eventID,
|
|
TabloID: tablo.ID,
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
http.NotFound(w, r)
|
|
return sqlc.Tablo{}, sqlc.Event{}, false
|
|
}
|
|
slog.Default().Error("events: GetEventByID failed", "tablo_id", tablo.ID, "event_id", eventID, "err", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return sqlc.Tablo{}, sqlc.Event{}, false
|
|
}
|
|
return tablo, event, true
|
|
}
|
|
|
|
func renderEventsTab(w http.ResponseWriter, r *http.Request, q *sqlc.Queries, tablo sqlc.Tablo) {
|
|
calendar, ok := loadEventsCalendar(w, r, q, tablo)
|
|
if !ok {
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if r.Header.Get("HX-Request") == "true" {
|
|
w.Header().Set("HX-Retarget", "#events-tab")
|
|
w.Header().Set("HX-Reswap", "outerHTML")
|
|
}
|
|
_ = templates.EventsTabFragment(tablo, calendar, csrf.Token(r)).Render(r.Context(), w)
|
|
}
|
|
|
|
func TabloEventsTabHandler(deps EventsDeps) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
tablo, user, ok := loadOwnedTablo(w, r, TablosDeps{Queries: deps.Queries})
|
|
if !ok {
|
|
return
|
|
}
|
|
calendar, ok := loadEventsCalendar(w, r, deps.Queries, tablo)
|
|
if !ok {
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if r.Header.Get("HX-Request") == "true" {
|
|
_ = templates.EventsTabFragment(tablo, calendar, csrf.Token(r)).Render(r.Context(), w)
|
|
return
|
|
}
|
|
eventsSidebarTablos, sidebarErr := deps.Queries.ListTablosByUser(r.Context(), user.ID)
|
|
if sidebarErr != nil {
|
|
slog.Default().Error("events: ListTablosByUser failed", "user_id", user.ID, "err", sidebarErr)
|
|
eventsSidebarTablos = []sqlc.Tablo{}
|
|
}
|
|
if eventsSidebarTablos == nil {
|
|
eventsSidebarTablos = []sqlc.Tablo{}
|
|
}
|
|
_ = templates.TabloDetailPage(user, csrf.Token(r), "", eventsSidebarTablos, tablo, nil, nil, templates.EtapeTaskCounts{}, templates.EtapeFilter{}, nil, calendar, templates.DiscussionTabData{}, "events").Render(r.Context(), w)
|
|
}
|
|
}
|
|
|
|
func EventNewFormHandler(deps EventsDeps) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
tablo, _, ok := loadOwnedTablo(w, r, TablosDeps{Queries: deps.Queries})
|
|
if !ok {
|
|
return
|
|
}
|
|
month := parseCalendarMonth(r.URL.Query().Get("month"), time.Now()).Format("2006-01")
|
|
form := templates.EventCreateForm{EventDate: strings.TrimSpace(r.URL.Query().Get("date"))}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
_ = templates.EventCreateFormFragment(tablo.ID, form, templates.EventCreateErrors{}, csrf.Token(r), month).Render(r.Context(), w)
|
|
}
|
|
}
|
|
|
|
func EventCancelNewHandler(deps EventsDeps) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if _, _, ok := loadOwnedTablo(w, r, TablosDeps{Queries: deps.Queries}); !ok {
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
_, _ = w.Write([]byte(""))
|
|
}
|
|
}
|
|
|
|
func EventCreateHandler(deps EventsDeps) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
tablo, _, ok := loadOwnedTablo(w, r, TablosDeps{Queries: deps.Queries})
|
|
if !ok {
|
|
return
|
|
}
|
|
form, params, errs, valid := parseEventCreateForm(r, tablo.ID)
|
|
if !valid {
|
|
month := parseCalendarMonth(r.URL.Query().Get("month"), time.Now()).Format("2006-01")
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if r.Header.Get("HX-Request") == "true" {
|
|
w.Header().Set("HX-Retarget", "#event-form-slot")
|
|
w.Header().Set("HX-Reswap", "innerHTML")
|
|
}
|
|
w.WriteHeader(http.StatusUnprocessableEntity)
|
|
_ = templates.EventCreateFormFragment(tablo.ID, form, errs, csrf.Token(r), month).Render(r.Context(), w)
|
|
return
|
|
}
|
|
if _, err := deps.Queries.CreateEvent(r.Context(), params); err != nil {
|
|
slog.Default().Error("events create: CreateEvent failed", "tablo_id", tablo.ID, "err", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
renderEventsTab(w, r, deps.Queries, tablo)
|
|
}
|
|
}
|
|
|
|
func EventEditFormHandler(deps EventsDeps) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
tablo, event, ok := loadOwnedEvent(w, r, deps)
|
|
if !ok {
|
|
return
|
|
}
|
|
month := parseCalendarMonth(r.URL.Query().Get("month"), time.Now()).Format("2006-01")
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
_ = templates.EventEditFormFragment(tablo.ID, event, templates.EventFormFromEvent(event), templates.EventCreateErrors{}, csrf.Token(r), month).Render(r.Context(), w)
|
|
}
|
|
}
|
|
|
|
func EventUpdateHandler(deps EventsDeps) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
tablo, event, ok := loadOwnedEvent(w, r, deps)
|
|
if !ok {
|
|
return
|
|
}
|
|
form, createParams, errs, valid := parseEventCreateForm(r, tablo.ID)
|
|
month := parseCalendarMonth(r.URL.Query().Get("month"), time.Now()).Format("2006-01")
|
|
if !valid {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if r.Header.Get("HX-Request") == "true" {
|
|
w.Header().Set("HX-Retarget", "#event-form-slot")
|
|
w.Header().Set("HX-Reswap", "innerHTML")
|
|
}
|
|
w.WriteHeader(http.StatusUnprocessableEntity)
|
|
_ = templates.EventEditFormFragment(tablo.ID, event, form, errs, csrf.Token(r), month).Render(r.Context(), w)
|
|
return
|
|
}
|
|
if _, err := deps.Queries.UpdateEvent(r.Context(), sqlc.UpdateEventParams{
|
|
ID: event.ID,
|
|
TabloID: tablo.ID,
|
|
Title: createParams.Title,
|
|
EventDate: createParams.EventDate,
|
|
StartTime: createParams.StartTime,
|
|
EndTime: createParams.EndTime,
|
|
Description: createParams.Description,
|
|
Location: createParams.Location,
|
|
}); err != nil {
|
|
slog.Default().Error("events update: UpdateEvent failed", "tablo_id", tablo.ID, "event_id", event.ID, "err", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
renderEventsTab(w, r, deps.Queries, tablo)
|
|
}
|
|
}
|
|
|
|
func EventDeleteConfirmHandler(deps EventsDeps) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
tablo, event, ok := loadOwnedEvent(w, r, deps)
|
|
if !ok {
|
|
return
|
|
}
|
|
month := parseCalendarMonth(r.URL.Query().Get("month"), time.Now()).Format("2006-01")
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
_ = templates.EventDeleteConfirmFragment(tablo.ID, event, csrf.Token(r), month).Render(r.Context(), w)
|
|
}
|
|
}
|
|
|
|
func EventDeleteHandler(deps EventsDeps) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
tablo, event, ok := loadOwnedEvent(w, r, deps)
|
|
if !ok {
|
|
return
|
|
}
|
|
if err := deps.Queries.DeleteEvent(r.Context(), sqlc.DeleteEventParams{
|
|
ID: event.ID,
|
|
TabloID: tablo.ID,
|
|
}); err != nil {
|
|
slog.Default().Error("events delete: DeleteEvent failed", "tablo_id", tablo.ID, "event_id", event.ID, "err", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
renderEventsTab(w, r, deps.Queries, tablo)
|
|
}
|
|
}
|
|
|
|
func parseEventCreateForm(r *http.Request, tabloID uuid.UUID) (templates.EventCreateForm, sqlc.CreateEventParams, templates.EventCreateErrors, bool) {
|
|
title := strings.TrimSpace(r.PostFormValue("title"))
|
|
eventDate := strings.TrimSpace(r.PostFormValue("event_date"))
|
|
startTime := strings.TrimSpace(r.PostFormValue("start_time"))
|
|
endTime := strings.TrimSpace(r.PostFormValue("end_time"))
|
|
location := strings.TrimSpace(r.PostFormValue("location"))
|
|
description := strings.TrimSpace(r.PostFormValue("description"))
|
|
form := templates.EventCreateForm{
|
|
Title: title,
|
|
EventDate: eventDate,
|
|
StartTime: startTime,
|
|
EndTime: endTime,
|
|
Location: location,
|
|
Description: description,
|
|
}
|
|
var errs templates.EventCreateErrors
|
|
if title == "" {
|
|
errs.Title = "Title is required."
|
|
} else if len(title) > 255 {
|
|
errs.Title = "Title must be 255 characters or fewer."
|
|
}
|
|
dateValue, ok := parseEventDate(eventDate)
|
|
if !ok {
|
|
errs.EventDate = "Date is required."
|
|
}
|
|
startValue, ok := parseEventTime(startTime)
|
|
if !ok {
|
|
errs.StartTime = "Start time is required."
|
|
}
|
|
var endValue pgtype.Time
|
|
if endTime != "" {
|
|
var endOK bool
|
|
endValue, endOK = parseEventTime(endTime)
|
|
if !endOK {
|
|
errs.EndTime = "End time must use HH:MM."
|
|
} else if startValue.Valid && endValue.Microseconds <= startValue.Microseconds {
|
|
errs.EndTime = "End time must be after the start time."
|
|
}
|
|
}
|
|
valid := errs.Title == "" && errs.EventDate == "" && errs.StartTime == "" && errs.EndTime == ""
|
|
params := sqlc.CreateEventParams{
|
|
TabloID: tabloID,
|
|
Title: title,
|
|
EventDate: dateValue,
|
|
StartTime: startValue,
|
|
EndTime: endValue,
|
|
Description: pgtype.Text{String: description, Valid: description != ""},
|
|
Location: pgtype.Text{String: location, Valid: location != ""},
|
|
}
|
|
return form, params, errs, valid
|
|
}
|