xtablo-source/go-backend/internal/web/handlers/tablos.go
2026-05-10 10:37:47 +02:00

557 lines
16 KiB
Go

package handlers
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"github.com/google/uuid"
tablomodel "xtablo-backend/internal/tablos"
"xtablo-backend/internal/web/views"
)
var ErrTabloNotFound = tablomodel.ErrNotFound
var tabloColorPattern = regexp.MustCompile(`^#[0-9A-Fa-f]{6}$`)
const defaultTabloColor = "#3B82F6"
const tabloColorValidationMessage = "La couleur du projet doit être un code hexadécimal au format #RRGGBB"
type TabloStatus = tablomodel.Status
const (
TabloStatusTodo = tablomodel.StatusTodo
TabloStatusInProgress = tablomodel.StatusInProgress
TabloStatusDone = tablomodel.StatusDone
)
type TabloRecord = tablomodel.Record
type CreateTabloInput = tablomodel.CreateInput
type UpdateTabloInput = tablomodel.UpdateInput
type ListTablosInput = tablomodel.ListInput
type TablosPageState struct {
View string
Query string
Status string
ModalKind string
EditingTabloID string
}
func normalizeTabloQuery(query string) string {
return strings.ToLower(strings.TrimSpace(query))
}
func parseTablosPageState(values interface {
Get(string) string
}) TablosPageState {
view := strings.TrimSpace(values.Get("view"))
if view != "list" {
view = "grid"
}
status := strings.TrimSpace(values.Get("status"))
switch status {
case "todo", "in_progress", "done":
default:
status = "all"
}
return TablosPageState{
View: view,
Query: strings.TrimSpace(values.Get("q")),
Status: status,
ModalKind: normalizedModalKind(strings.TrimSpace(values.Get("modal"))),
}
}
func (s TablosPageState) statusFilter() *TabloStatus {
switch s.Status {
case string(TabloStatusTodo):
status := TabloStatusTodo
return &status
case string(TabloStatusInProgress):
status := TabloStatusInProgress
return &status
case string(TabloStatusDone):
status := TabloStatusDone
return &status
default:
return nil
}
}
func normalizedModalKind(kind string) string {
switch kind {
case "create", "edit":
return kind
default:
return ""
}
}
func normalizeTabloColor(raw string) (string, bool) {
color := strings.TrimSpace(raw)
if !tabloColorPattern.MatchString(color) {
return "", false
}
return strings.ToUpper(color), true
}
func storedTabloColor(raw string) string {
if color, ok := normalizeTabloColor(raw); ok {
return color
}
return defaultTabloColor
}
func (h *AuthHandler) PostTablos() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, ok := h.authenticatedUser(r.Context(), r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "invalid form payload", http.StatusBadRequest)
return
}
state := parseTablosPageState(r.Form)
state.ModalKind = "create"
name := strings.TrimSpace(r.FormValue("name"))
if name == "" {
tablos, err := listTablosForState(r.Context(), h.repo, user.ID, state)
if err != nil {
http.Error(w, "failed to list tablos", http.StatusInternalServerError)
return
}
renderTablosResponse(w, r, "/tablos", tablosPageViewModel(user, state, tablos, name, r.FormValue("color"), "Le nom du projet est requis"), http.StatusUnprocessableEntity)
return
}
color, ok := normalizeTabloColor(r.FormValue("color"))
if !ok {
tablos, err := listTablosForState(r.Context(), h.repo, user.ID, state)
if err != nil {
http.Error(w, "failed to list tablos", http.StatusInternalServerError)
return
}
renderTablosResponse(w, r, "/tablos", tablosPageViewModel(user, state, tablos, name, r.FormValue("color"), tabloColorValidationMessage), http.StatusUnprocessableEntity)
return
}
if _, err := h.repo.CreateTablo(r.Context(), CreateTabloInput{
OwnerID: user.ID,
Name: name,
Color: color,
Status: TabloStatusTodo,
}); err != nil {
http.Error(w, "failed to create tablo", http.StatusInternalServerError)
return
}
state.ModalKind = ""
tablos, err := listTablosForState(r.Context(), h.repo, user.ID, state)
if err != nil {
http.Error(w, "failed to list tablos", http.StatusInternalServerError)
return
}
renderTablosResponse(w, r, "/tablos", tablosPageViewModel(user, state, tablos, "", "", ""), http.StatusOK)
}
}
func (h *AuthHandler) GetEditTabloModal() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, ok := h.authenticatedUser(r.Context(), r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
tabloID, err := uuid.Parse(r.PathValue("tabloID"))
if err != nil {
http.Error(w, "invalid tablo id", http.StatusBadRequest)
return
}
state := parseTablosPageState(r.URL.Query())
state.ModalKind = "edit"
state.EditingTabloID = tabloID.String()
tablos, err := listTablosForState(r.Context(), h.repo, user.ID, state)
if err != nil {
http.Error(w, "failed to list tablos", http.StatusInternalServerError)
return
}
tablo, ok := findTabloByID(tablos, tabloID)
if !ok {
http.Error(w, "tablo not found", http.StatusNotFound)
return
}
renderTablosResponse(w, r, "/tablos", tablosPageViewModel(user, state, tablos, tablo.Name, tablo.Color, ""), http.StatusOK)
}
}
func (h *AuthHandler) PostTabloUpdate() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, ok := h.authenticatedUser(r.Context(), r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
tabloID, err := uuid.Parse(r.PathValue("tabloID"))
if err != nil {
http.Error(w, "invalid tablo id", http.StatusBadRequest)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "invalid form payload", http.StatusBadRequest)
return
}
state := parseTablosPageState(r.Form)
state.ModalKind = "edit"
state.EditingTabloID = tabloID.String()
name := strings.TrimSpace(r.FormValue("name"))
if name == "" {
tablos, err := listTablosForState(r.Context(), h.repo, user.ID, state)
if err != nil {
http.Error(w, "failed to list tablos", http.StatusInternalServerError)
return
}
renderTablosResponse(w, r, "/tablos", tablosPageViewModel(user, state, tablos, name, r.FormValue("color"), "Le nom du projet est requis"), http.StatusUnprocessableEntity)
return
}
color, colorOK := normalizeTabloColor(r.FormValue("color"))
if !colorOK {
tablos, err := listTablosForState(r.Context(), h.repo, user.ID, state)
if err != nil {
http.Error(w, "failed to list tablos", http.StatusInternalServerError)
return
}
renderTablosResponse(w, r, "/tablos", tablosPageViewModel(user, state, tablos, name, r.FormValue("color"), tabloColorValidationMessage), http.StatusUnprocessableEntity)
return
}
if err := h.repo.UpdateTablo(r.Context(), UpdateTabloInput{
ID: tabloID,
OwnerID: user.ID,
Name: name,
Color: color,
}); err != nil {
if errors.Is(err, ErrTabloNotFound) {
http.Error(w, "tablo not found", http.StatusNotFound)
return
}
http.Error(w, "failed to update tablo", http.StatusInternalServerError)
return
}
state.ModalKind = ""
state.EditingTabloID = ""
tablos, err := listTablosForState(r.Context(), h.repo, user.ID, state)
if err != nil {
http.Error(w, "failed to list tablos", http.StatusInternalServerError)
return
}
renderTablosResponse(w, r, "/tablos", tablosPageViewModel(user, state, tablos, "", "", ""), http.StatusOK)
}
}
func (h *AuthHandler) DeleteTablo() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, ok := h.authenticatedUser(r.Context(), r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
tabloID, err := uuid.Parse(r.PathValue("tabloID"))
if err != nil {
http.Error(w, "invalid tablo id", http.StatusBadRequest)
return
}
if err := h.repo.SoftDeleteTablo(r.Context(), tabloID, user.ID); err != nil {
if errors.Is(err, ErrTabloNotFound) {
http.Error(w, "tablo not found", http.StatusNotFound)
return
}
http.Error(w, "failed to delete tablo", http.StatusInternalServerError)
return
}
state := parseTablosPageState(r.URL.Query())
tablos, err := listTablosForState(r.Context(), h.repo, user.ID, state)
if err != nil {
http.Error(w, "failed to list tablos", http.StatusInternalServerError)
return
}
renderTablosResponse(w, r, "/tablos", tablosPageViewModel(user, state, tablos, "", "", ""), http.StatusOK)
}
}
func (h *AuthHandler) renderTablosPage(w http.ResponseWriter, r *http.Request) {
user, ok := h.authenticatedUser(r.Context(), r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
state := parseTablosPageState(r.URL.Query())
tablos, err := listTablosForState(r.Context(), h.repo, user.ID, state)
if err != nil {
http.Error(w, "failed to list tablos", http.StatusInternalServerError)
return
}
renderTablosResponse(w, r, "/tablos", tablosPageViewModel(user, state, tablos, "", "", ""), http.StatusOK)
}
func tablosPageViewModel(user PublicUser, state TablosPageState, tablos []TabloRecord, formName string, formColor string, errorMessage string) views.TablosPageViewModel {
return views.NewTablosPageViewModel(
user.DisplayName,
state.View,
state.Query,
state.Status,
state.ModalKind,
state.EditingTabloID,
formName,
formColor,
errorMessage,
buildTabloCardViews(tablos, state),
)
}
func listTablosForState(ctx context.Context, repo AuthRepository, ownerID uuid.UUID, state TablosPageState) ([]TabloRecord, error) {
return repo.ListTablos(ctx, ListTablosInput{
OwnerID: ownerID,
Query: state.Query,
Status: state.statusFilter(),
})
}
func findTabloByID(tablos []TabloRecord, targetID uuid.UUID) (TabloRecord, bool) {
for _, tablo := range tablos {
if tablo.ID == targetID {
return tablo, true
}
}
return TabloRecord{}, false
}
func renderTablosResponse(w http.ResponseWriter, r *http.Request, activePath string, vm views.TablosPageViewModel, statusCode int) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(statusCode)
var err error
content := views.TablosPageContent(vm)
if isHXRequest(r) {
err = views.DashboardContentSwapWithMainClass(activePath, "flex-1 overflow-auto", content).Render(r.Context(), w)
} else {
err = views.DashboardPageWithMainClass(activePath, "flex-1 overflow-auto", content).Render(r.Context(), w)
}
if err != nil {
http.Error(w, "failed to render tablos page", http.StatusInternalServerError)
}
}
func (r *InMemoryAuthRepository) CreateTablo(_ context.Context, input CreateTabloInput) (TabloRecord, error) {
r.mu.Lock()
defer r.mu.Unlock()
now := time.Now().UTC()
tablo := TabloRecord{
ID: uuid.New(),
OwnerID: input.OwnerID,
Name: strings.TrimSpace(input.Name),
Color: storedTabloColor(input.Color),
Status: input.Status,
CreatedAt: now,
UpdatedAt: now,
}
r.tablos[tablo.ID] = tablo
return tablo, nil
}
func (r *InMemoryAuthRepository) ListTablos(_ context.Context, input ListTablosInput) ([]TabloRecord, error) {
r.mu.RLock()
defer r.mu.RUnlock()
query := normalizeTabloQuery(input.Query)
var tablos []TabloRecord
for _, tablo := range r.tablos {
if tablo.OwnerID != input.OwnerID {
continue
}
if tablo.DeletedAt != nil {
continue
}
if input.Status != nil && tablo.Status != *input.Status {
continue
}
if query != "" && !strings.Contains(strings.ToLower(tablo.Name), query) {
continue
}
tablos = append(tablos, tablo)
}
sortTablosByCreatedAtDesc(tablos)
return tablos, nil
}
func (r *InMemoryAuthRepository) UpdateTablo(_ context.Context, input UpdateTabloInput) error {
r.mu.Lock()
defer r.mu.Unlock()
tablo, ok := r.tablos[input.ID]
if !ok || tablo.OwnerID != input.OwnerID || tablo.DeletedAt != nil {
return ErrTabloNotFound
}
tablo.Name = strings.TrimSpace(input.Name)
tablo.Color = storedTabloColor(input.Color)
tablo.UpdatedAt = time.Now().UTC()
r.tablos[input.ID] = tablo
return nil
}
func (r *InMemoryAuthRepository) SoftDeleteTablo(_ context.Context, tabloID uuid.UUID, ownerID uuid.UUID) error {
r.mu.Lock()
defer r.mu.Unlock()
tablo, ok := r.tablos[tabloID]
if !ok || tablo.OwnerID != ownerID || tablo.DeletedAt != nil {
return ErrTabloNotFound
}
now := time.Now().UTC()
tablo.DeletedAt = &now
tablo.UpdatedAt = now
r.tablos[tabloID] = tablo
return nil
}
func sortTablosByCreatedAtDesc(tablos []TabloRecord) {
for i := 0; i < len(tablos); i++ {
for j := i + 1; j < len(tablos); j++ {
if tablos[j].CreatedAt.After(tablos[i].CreatedAt) {
tablos[i], tablos[j] = tablos[j], tablos[i]
}
}
}
}
func buildTabloCardViews(tablos []TabloRecord, state TablosPageState) []views.TabloCardView {
items := make([]views.TabloCardView, 0, len(tablos))
for _, tablo := range tablos {
statusLabel, statusClass, progress, statusTone := tabloStatusPresentation(tablo.Status)
iconKind, bgClass, fgClass, accent := tabloIconPresentation(tablo.Name)
items = append(items, views.TabloCardView{
ID: tablo.ID.String(),
Name: tablo.Name,
Color: storedTabloColor(tablo.Color),
Status: string(tablo.Status),
StatusLabel: statusLabel,
StatusClass: statusClass,
StatusTone: statusTone,
Progress: progress,
CreatedAtLabel: formatFrenchDate(tablo.CreatedAt),
CardDateLabel: formatCardDate(tablo.CreatedAt),
ProgressLabel: fmt.Sprintf("%d%%", progress),
DeleteURL: "/tablos/" + tablo.ID.String(),
DeleteRequestURL: buildDeleteRequestURL("/tablos/"+tablo.ID.String(), state),
EditRequestURL: buildEditRequestURL("/tablos/"+tablo.ID.String()+"/edit", state),
IconKind: iconKind,
IconBgClass: bgClass,
IconFgClass: fgClass,
Accent: accent,
Initial: projectInitial(tablo.Name),
})
}
return items
}
func buildDeleteRequestURL(path string, state TablosPageState) string {
return buildStatefulRequestURL(path, state)
}
func buildEditRequestURL(path string, state TablosPageState) string {
return buildStatefulRequestURL(path, state)
}
func buildStatefulRequestURL(path string, state TablosPageState) string {
values := url.Values{}
values.Set("view", state.View)
values.Set("status", state.Status)
if strings.TrimSpace(state.Query) != "" {
values.Set("q", strings.TrimSpace(state.Query))
}
encoded := values.Encode()
if encoded == "" {
return path
}
return path + "?" + encoded
}
func tabloStatusPresentation(status TabloStatus) (string, string, int, string) {
switch status {
case TabloStatusInProgress:
return "En cours", "bg-[#FFF4E2] text-[#DB9729] border border-[#DB9729]", 50, "warning"
case TabloStatusDone:
return "Terminé", "bg-green-50 text-green-600 border border-green-200 dark:bg-green-950/30 dark:text-green-400 dark:border-green-800", 100, "success"
default:
return "À faire", "bg-blue-50 text-blue-600 border border-blue-200 dark:bg-blue-950/30 dark:text-blue-400 dark:border-blue-800", 0, "info"
}
}
func tabloIconPresentation(name string) (string, string, string, string) {
switch len(strings.TrimSpace(name)) % 3 {
case 1:
return "gem", "bg-purple-500", "text-white", "purple"
case 2:
return "sparkles", "bg-cyan-500", "text-gray-700", "red"
default:
return "bolt", "bg-blue-500", "text-white", "blue"
}
}
func formatFrenchDate(value time.Time) string {
months := []string{"janv.", "fevr.", "mars", "avr.", "mai", "juin", "juil.", "aout", "sept.", "oct.", "nov.", "dec."}
month := months[int(value.Month())-1]
return fmt.Sprintf("%02d %s %d", value.Day(), month, value.Year())
}
func formatCardDate(value time.Time) string {
return value.Format("Jan 02, 2006")
}
func projectInitial(name string) string {
trimmed := strings.TrimSpace(name)
if trimmed == "" {
return "P"
}
runes := []rune(trimmed)
return strings.ToUpper(string(runes[0]))
}