7ce227ddea
- migration 009 + columna deadline TEXT en cards - backend: CardPatch.HasDeadline, eventos deadline_set/deadline_cleared - KanbanCard: menu derecho con DatePicker, badge countdown con colores por ratio (azul>=50%, amarillo<50%, rojo<10%, red.9 overdue) - App.tsx: filtro "Con deadline", handleSetCardDeadline optimista, jump-to-card + highlight - CalendarView: popover por dia con seq_num + titulo, click navega a card en tablero - HistoryModal: render eventos deadline_set/deadline_cleared - .gitignore: *.log Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
153 lines
3.9 KiB
Go
153 lines
3.9 KiB
Go
package main
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"time"
|
|
|
|
"fn-registry/functions/infra"
|
|
)
|
|
|
|
const (
|
|
cookieName = "kanban_session"
|
|
sessionTTL = 7 * 24 * time.Hour
|
|
)
|
|
|
|
type ctxKey string
|
|
|
|
const userCtxKey ctxKey = "kanban_user_id"
|
|
|
|
func setSessionCookie(w http.ResponseWriter, token string, expiresAt int64) {
|
|
infra.SessionCookieSet(w, cookieName, token, expiresAt)
|
|
}
|
|
|
|
func clearSessionCookie(w http.ResponseWriter) {
|
|
infra.SessionCookieClear(w, cookieName)
|
|
}
|
|
|
|
func tokenFromRequest(r *http.Request) string {
|
|
return infra.SessionTokenExtract(r, cookieName)
|
|
}
|
|
|
|
// POST /api/auth/register {username, password, display_name?}
|
|
func handleRegister(db *DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
var body struct {
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
DisplayName string `json:"display_name"`
|
|
}
|
|
if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil {
|
|
badRequest(w, err.Error())
|
|
return
|
|
}
|
|
u, err := db.CreateUser(body.Username, body.Password, body.DisplayName)
|
|
if err != nil {
|
|
if errors.Is(err, errUserAlreadyExists) {
|
|
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusConflict, Code: "user_exists", Message: err.Error()})
|
|
return
|
|
}
|
|
badRequest(w, err.Error())
|
|
return
|
|
}
|
|
infra.HTTPJSONResponse(w, http.StatusCreated, u)
|
|
}
|
|
}
|
|
|
|
// POST /api/auth/login {username, password}
|
|
func handleLogin(db *DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
var body struct {
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
}
|
|
if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil {
|
|
badRequest(w, err.Error())
|
|
return
|
|
}
|
|
u, err := db.Authenticate(body.Username, body.Password)
|
|
if err != nil {
|
|
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusUnauthorized, Code: "invalid_credentials", Message: "invalid username or password"})
|
|
return
|
|
}
|
|
sess, err := infra.SessionCreate(db.conn, u.ID, sessionTTL, map[string]any{"username": u.Username})
|
|
if err != nil {
|
|
serverError(w, err)
|
|
return
|
|
}
|
|
setSessionCookie(w, sess.Token, sess.ExpiresAt)
|
|
infra.HTTPJSONResponse(w, http.StatusOK, u)
|
|
}
|
|
}
|
|
|
|
// POST /api/auth/logout
|
|
func handleLogout(db *DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
token := tokenFromRequest(r)
|
|
if token != "" {
|
|
_ = db.DeleteSessionByToken(token)
|
|
}
|
|
clearSessionCookie(w)
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
}
|
|
|
|
// GET /api/me
|
|
func handleMe(db *DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
uid, ok := infra.UserIDFromContext(r.Context(), userCtxKey)
|
|
if !ok {
|
|
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusUnauthorized, Code: "unauthorized", Message: "no session"})
|
|
return
|
|
}
|
|
u, err := db.GetUserByID(uid)
|
|
if err != nil {
|
|
serverError(w, err)
|
|
return
|
|
}
|
|
infra.HTTPJSONResponse(w, http.StatusOK, u)
|
|
}
|
|
}
|
|
|
|
// PATCH /api/me { color? }
|
|
func handlePatchMe(db *DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
uid, ok := infra.UserIDFromContext(r.Context(), userCtxKey)
|
|
if !ok {
|
|
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusUnauthorized, Code: "unauthorized", Message: "no session"})
|
|
return
|
|
}
|
|
var body struct {
|
|
Color *string `json:"color"`
|
|
}
|
|
if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil {
|
|
badRequest(w, err.Error())
|
|
return
|
|
}
|
|
if body.Color != nil {
|
|
if err := db.UpdateUserColor(uid, *body.Color); err != nil {
|
|
serverError(w, err)
|
|
return
|
|
}
|
|
}
|
|
u, err := db.GetUserByID(uid)
|
|
if err != nil {
|
|
serverError(w, err)
|
|
return
|
|
}
|
|
infra.HTTPJSONResponse(w, http.StatusOK, u)
|
|
}
|
|
}
|
|
|
|
// GET /api/users
|
|
func handleListUsers(db *DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
users, err := db.ListUsers()
|
|
if err != nil {
|
|
serverError(w, err)
|
|
return
|
|
}
|
|
infra.HTTPJSONResponse(w, http.StatusOK, users)
|
|
}
|
|
}
|