chore: auto-commit (23 archivos)
- app.md - backend/auth.go - backend/db.go - backend/dist/assets/index-CPqSy0gZ.js - backend/dist/index.html - backend/handlers.go - backend/main.go - frontend/src/App.tsx - frontend/src/api.ts - frontend/src/components/KanbanCard.tsx - ... Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+5
-1
@@ -30,8 +30,12 @@ func tokenFromRequest(r *http.Request) string {
|
||||
}
|
||||
|
||||
// POST /api/auth/register {username, password, display_name?}
|
||||
func handleRegister(db *DB) http.HandlerFunc {
|
||||
func handleRegister(db *DB, flags *FeatureFlags) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !flags.Enabled("registration-enabled") {
|
||||
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusForbidden, Code: "registration_disabled", Message: "user registration is disabled on this instance"})
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
|
||||
+168
@@ -1031,3 +1031,171 @@ func (db *DB) CardHistory(cardID string) (*CardHistoryResponse, error) {
|
||||
CurrentlyLock: currently,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type CardMessage struct {
|
||||
ID string `json:"id"`
|
||||
CardID string `json:"card_id"`
|
||||
AuthorID *string `json:"author_id"`
|
||||
Body string `json:"body"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
func (db *DB) ListCardMessages(cardID string) ([]CardMessage, error) {
|
||||
rows, err := db.conn.Query(
|
||||
`SELECT id, card_id, author_id, body, created_at FROM card_messages WHERE card_id=? ORDER BY created_at`,
|
||||
cardID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []CardMessage{}
|
||||
for rows.Next() {
|
||||
var m CardMessage
|
||||
var author sql.NullString
|
||||
if err := rows.Scan(&m.ID, &m.CardID, &author, &m.Body, &m.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if author.Valid && author.String != "" {
|
||||
s := author.String
|
||||
m.AuthorID = &s
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (db *DB) CreateCardMessage(cardID, authorID, body string) (*CardMessage, error) {
|
||||
body = strings.TrimSpace(body)
|
||||
if body == "" {
|
||||
return nil, fmt.Errorf("body required")
|
||||
}
|
||||
if authorID == "" {
|
||||
return nil, fmt.Errorf("author required")
|
||||
}
|
||||
var exists int
|
||||
if err := db.conn.QueryRow(`SELECT 1 FROM cards WHERE id=?`, cardID).Scan(&exists); err != nil {
|
||||
return nil, fmt.Errorf("card not found: %w", err)
|
||||
}
|
||||
s := authorID
|
||||
m := &CardMessage{ID: newID(), CardID: cardID, AuthorID: &s, Body: body, CreatedAt: nowRFC3339()}
|
||||
if _, err := db.conn.Exec(
|
||||
`INSERT INTO card_messages (id, card_id, author_id, body, created_at) VALUES (?, ?, ?, ?, ?)`,
|
||||
m.ID, m.CardID, authorID, m.Body, m.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (db *DB) DeleteCardMessage(id, requesterID string) error {
|
||||
if requesterID == "" {
|
||||
return fmt.Errorf("session required")
|
||||
}
|
||||
res, err := db.conn.Exec(`DELETE FROM card_messages WHERE id=? AND author_id=?`, id, requesterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("not found or not author")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DuplicateCard clones a card into the same column at the end of the list.
|
||||
// Copies title, description, color, requester, assignee, tags, deadline, stickers.
|
||||
// Does NOT copy card_column_history, card_lock_history, card_events, card_messages.
|
||||
// Title gets " (copia)" suffix.
|
||||
func (db *DB) DuplicateCard(srcID, actorID string) (*Card, error) {
|
||||
tx, err := db.conn.Begin()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var src Card
|
||||
var assignee sql.NullString
|
||||
var deadline sql.NullString
|
||||
var tagsJSON, stickersJSON string
|
||||
if err := tx.QueryRow(
|
||||
`SELECT requester, title, description, color, column_id, assignee_id, tags, stickers, deadline
|
||||
FROM cards WHERE id=? AND deleted_at IS NULL`, srcID,
|
||||
).Scan(&src.Requester, &src.Title, &src.Description, &src.Color, &src.ColumnID, &assignee, &tagsJSON, &stickersJSON, &deadline); err != nil {
|
||||
return nil, fmt.Errorf("card not found: %w", err)
|
||||
}
|
||||
if assignee.Valid && assignee.String != "" {
|
||||
s := assignee.String
|
||||
src.AssigneeID = &s
|
||||
}
|
||||
if deadline.Valid && deadline.String != "" {
|
||||
s := deadline.String
|
||||
src.Deadline = &s
|
||||
}
|
||||
src.Tags = parseTags(tagsJSON)
|
||||
src.Stickers = parseStickers(stickersJSON)
|
||||
|
||||
var maxPos sql.NullInt64
|
||||
if err := tx.QueryRow(`SELECT MAX(position) FROM cards WHERE column_id=?`, src.ColumnID).Scan(&maxPos); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pos := 0
|
||||
if maxPos.Valid {
|
||||
pos = int(maxPos.Int64) + 1
|
||||
}
|
||||
var maxSeq sql.NullInt64
|
||||
if err := tx.QueryRow(`SELECT MAX(seq_num) FROM cards`).Scan(&maxSeq); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
seqNum := 1
|
||||
if maxSeq.Valid {
|
||||
seqNum = int(maxSeq.Int64) + 1
|
||||
}
|
||||
now := nowRFC3339()
|
||||
newTitle := src.Title + " (copia)"
|
||||
c := Card{
|
||||
ID: newID(), SeqNum: seqNum, Requester: src.Requester, Title: newTitle,
|
||||
Description: src.Description, Color: src.Color, ColumnID: src.ColumnID, Position: pos,
|
||||
AssigneeID: src.AssigneeID, Tags: src.Tags, Stickers: src.Stickers, Deadline: src.Deadline,
|
||||
CreatedAt: now, UpdatedAt: now, EnteredAt: now,
|
||||
}
|
||||
var assigneeVal any
|
||||
if c.AssigneeID != nil && *c.AssigneeID != "" {
|
||||
assigneeVal = *c.AssigneeID
|
||||
}
|
||||
var deadlineVal any
|
||||
if c.Deadline != nil && *c.Deadline != "" {
|
||||
deadlineVal = *c.Deadline
|
||||
}
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO cards (id, seq_num, requester, title, description, color, column_id, position, assignee_id, tags, stickers, deadline, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
c.ID, c.SeqNum, c.Requester, c.Title, c.Description, c.Color, c.ColumnID, c.Position,
|
||||
assigneeVal, encodeTags(c.Tags), encodeStickers(c.Stickers), deadlineVal, c.CreatedAt, c.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO card_column_history (id, card_id, column_id, entered_at, actor_id) VALUES (?, ?, ?, ?, ?)`,
|
||||
newID(), c.ID, c.ColumnID, now, nullableActor(actorID),
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var destDone int
|
||||
if err := tx.QueryRow(`SELECT is_done FROM columns WHERE id=?`, c.ColumnID).Scan(&destDone); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if destDone == 1 {
|
||||
if _, err := tx.Exec(`UPDATE cards SET completed_at=? WHERE id=?`, now, c.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.CompletedAt = &now
|
||||
}
|
||||
if err := insertCardEvent(tx, c.ID, "created", actorID, map[string]any{"title": newTitle, "column_id": c.ColumnID, "duplicated_from": srcID}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
+1176
File diff suppressed because one or more lines are too long
-1151
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Kanban</title>
|
||||
<script type="module" crossorigin src="/assets/index-CPqSy0gZ.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-BETde3Km.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-nR9uJgze.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"fn-registry/functions/infra"
|
||||
)
|
||||
|
||||
type FeatureFlag struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Issue string `json:"issue,omitempty"`
|
||||
Description string `json:"description"`
|
||||
Added string `json:"added,omitempty"`
|
||||
EnabledAt string `json:"enabled_at,omitempty"`
|
||||
}
|
||||
|
||||
type FeatureFlags struct {
|
||||
Flags map[string]FeatureFlag `json:"flags"`
|
||||
}
|
||||
|
||||
func (f FeatureFlags) Enabled(name string) bool {
|
||||
flag, ok := f.Flags[name]
|
||||
return ok && flag.Enabled
|
||||
}
|
||||
|
||||
func loadFeatureFlags(path string) (FeatureFlags, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return FeatureFlags{Flags: map[string]FeatureFlag{}}, nil
|
||||
}
|
||||
return FeatureFlags{}, err
|
||||
}
|
||||
var f FeatureFlags
|
||||
if err := json.Unmarshal(b, &f); err != nil {
|
||||
return FeatureFlags{}, err
|
||||
}
|
||||
if f.Flags == nil {
|
||||
f.Flags = map[string]FeatureFlag{}
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// GET /api/flags → { "<name>": true/false, ... }
|
||||
func handleListFlags(flags *FeatureFlags) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
out := make(map[string]bool, len(flags.Flags))
|
||||
for name, fl := range flags.Flags {
|
||||
out[name] = fl.Enabled
|
||||
}
|
||||
infra.HTTPJSONResponse(w, http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
+92
-2
@@ -280,6 +280,91 @@ func handleMoveCard(db *DB) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/cards/{id}/messages → [CardMessage, ...]
|
||||
func handleListCardMessages(db *DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
msgs, err := db.ListCardMessages(id)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
infra.HTTPJSONResponse(w, http.StatusOK, msgs)
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/cards/{id}/messages { body }
|
||||
func handleCreateCardMessage(db *DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
var body struct {
|
||||
Body string `json:"body"`
|
||||
}
|
||||
if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil {
|
||||
badRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(body.Body) == "" {
|
||||
badRequest(w, "body required")
|
||||
return
|
||||
}
|
||||
actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
|
||||
if actor == "" {
|
||||
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusUnauthorized, Code: "unauthorized", Message: "session required"})
|
||||
return
|
||||
}
|
||||
m, err := db.CreateCardMessage(id, actor, body.Body)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
notFound(w, err.Error())
|
||||
return
|
||||
}
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
infra.HTTPJSONResponse(w, http.StatusCreated, m)
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/cards/{cid}/messages/{mid}
|
||||
func handleDeleteCardMessage(db *DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
mid := r.PathValue("mid")
|
||||
actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
|
||||
if actor == "" {
|
||||
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusUnauthorized, Code: "unauthorized", Message: "session required"})
|
||||
return
|
||||
}
|
||||
if err := db.DeleteCardMessage(mid, actor); err != nil {
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
notFound(w, err.Error())
|
||||
return
|
||||
}
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/cards/{id}/duplicate
|
||||
func handleDuplicateCard(db *DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
|
||||
c, err := db.DuplicateCard(id, actor)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
notFound(w, "card not found")
|
||||
return
|
||||
}
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
infra.HTTPJSONResponse(w, http.StatusCreated, c)
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/cards/{id}/history → [HistoryEntry, ...]
|
||||
func handleCardHistory(db *DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -330,9 +415,10 @@ func handlePurgeCard(db *DB) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func apiRoutes(db *DB, chatWorkdir string, logger *ChatLogger, internalToken string) []infra.Route {
|
||||
func apiRoutes(db *DB, chatWorkdir string, logger *ChatLogger, internalToken string, flags *FeatureFlags) []infra.Route {
|
||||
return []infra.Route{
|
||||
{Method: "POST", Path: "/api/auth/register", Handler: handleRegister(db)},
|
||||
{Method: "GET", Path: "/api/flags", Handler: handleListFlags(flags)},
|
||||
{Method: "POST", Path: "/api/auth/register", Handler: handleRegister(db, flags)},
|
||||
{Method: "POST", Path: "/api/auth/login", Handler: handleLogin(db)},
|
||||
{Method: "POST", Path: "/api/auth/logout", Handler: handleLogout(db)},
|
||||
{Method: "GET", Path: "/api/me", Handler: handleMe(db)},
|
||||
@@ -348,6 +434,10 @@ func apiRoutes(db *DB, chatWorkdir string, logger *ChatLogger, internalToken str
|
||||
{Method: "PUT", Path: "/api/cards/{id}/stickers", Handler: handleUpdateCardStickers(db)},
|
||||
{Method: "DELETE", Path: "/api/cards/{id}", Handler: handleDeleteCard(db)},
|
||||
{Method: "POST", Path: "/api/cards/{id}/move", Handler: handleMoveCard(db)},
|
||||
{Method: "POST", Path: "/api/cards/{id}/duplicate", Handler: handleDuplicateCard(db)},
|
||||
{Method: "GET", Path: "/api/cards/{id}/messages", Handler: handleListCardMessages(db)},
|
||||
{Method: "POST", Path: "/api/cards/{id}/messages", Handler: handleCreateCardMessage(db)},
|
||||
{Method: "DELETE", Path: "/api/cards/{id}/messages/{mid}", Handler: handleDeleteCardMessage(db)},
|
||||
{Method: "GET", Path: "/api/cards/{id}/history", Handler: handleCardHistory(db)},
|
||||
{Method: "GET", Path: "/api/trash", Handler: handleListTrash(db)},
|
||||
{Method: "POST", Path: "/api/cards/{id}/restore", Handler: handleRestoreCard(db)},
|
||||
|
||||
+11
-2
@@ -35,8 +35,17 @@ func main() {
|
||||
port := flags.Int("port", 8095, "HTTP port")
|
||||
dbPath := flags.String("db", "operations.db", "SQLite database path")
|
||||
initialAdmin := flags.String("initial-admin", os.Getenv("KANBAN_INITIAL_ADMIN"), "Bootstrap admin in user:pass form (only if no users yet)")
|
||||
flagsPath := flags.String("flags", "dev/feature_flags.json", "Feature flags JSON path (missing file → all disabled)")
|
||||
flags.Parse(os.Args[1:])
|
||||
|
||||
featureFlags, err := loadFeatureFlags(*flagsPath)
|
||||
if err != nil {
|
||||
log.Fatalf("load feature flags: %v", err)
|
||||
}
|
||||
for name, fl := range featureFlags.Flags {
|
||||
log.Printf("feature flag %q enabled=%v", name, fl.Enabled)
|
||||
}
|
||||
|
||||
db, err := openDB(*dbPath)
|
||||
if err != nil {
|
||||
log.Fatalf("open db: %v", err)
|
||||
@@ -54,7 +63,7 @@ func main() {
|
||||
wd := chatWorkdir(*dbPath)
|
||||
logger := newChatLogger(filepath.Join(wd, "chat.log"))
|
||||
log.Printf("chat tool log: %s", logger.path)
|
||||
mux := infra.HTTPRouter(apiRoutes(db, wd, logger, internalToken))
|
||||
mux := infra.HTTPRouter(apiRoutes(db, wd, logger, internalToken, &featureFlags))
|
||||
|
||||
feHandler := frontendHandler()
|
||||
if feHandler != nil {
|
||||
@@ -67,7 +76,7 @@ func main() {
|
||||
authMW := infra.HTTPSessionCookieMiddleware(infra.SessionCookieConfig{
|
||||
DB: db.conn,
|
||||
CookieName: cookieName,
|
||||
SkipPaths: []string{"/api/auth/", "/api/tool/", "/health", "/assets/", "/index.html"},
|
||||
SkipPaths: []string{"/api/auth/", "/api/tool/", "/api/flags", "/health", "/assets/", "/index.html"},
|
||||
UserCtxKey: userCtxKey,
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
-- Per-card chat messages (human-to-human comments).
|
||||
-- Distinct from card_events (which records system events like title_changed)
|
||||
-- and from /api/chat (which is the board-level LLM chat).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS card_messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
card_id TEXT NOT NULL,
|
||||
author_id TEXT,
|
||||
body TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (card_id) REFERENCES cards(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_card_messages_card ON card_messages(card_id, created_at);
|
||||
Reference in New Issue
Block a user