557 lines
16 KiB
Go
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]))
|
|
}
|