diff --git a/.gitignore b/.gitignore index de2736c..777f96f 100644 --- a/.gitignore +++ b/.gitignore @@ -97,3 +97,4 @@ crash.*.log # personal docs/ideas.md backup/ +.kiro \ No newline at end of file diff --git a/backend/db/init.sql b/backend/db/init.sql index eca94f4..2813173 100644 --- a/backend/db/init.sql +++ b/backend/db/init.sql @@ -1,17 +1,25 @@ +-- Auto-generated schema snapshot. DO NOT EDIT MANUALLY. +-- Regenerated by CI after each successful goose migration run. +-- Dual purpose: +-- 1. Used by Docker Compose to bootstrap a fresh local dev database container. +-- 2. Referenced by migration integration tests (TestInitSQLMatchesMigrationEndState) +-- as the schema reference snapshot. +-- Source of truth for schema changes: backend/migration/db/migrations/*.sql + CREATE TABLE IF NOT EXISTS users ( - id UUID PRIMARY KEY, - email TEXT UNIQUE NOT NULL, - password_hash TEXT NOT NULL + id BIGSERIAL PRIMARY KEY, + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS todos ( - id UUID PRIMARY KEY, - item TEXT NOT NULL, - completed BOOLEAN DEFAULT FALSE, - user_id UUID REFERENCES users(id) + id BIGSERIAL PRIMARY KEY, + item TEXT NOT NULL, + completed BOOLEAN NOT NULL DEFAULT FALSE, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS blacklisted_tokens ( - token TEXT PRIMARY KEY, - expired_at TIMESTAMP NOT NULL + token TEXT PRIMARY KEY, + expired_at TIMESTAMPTZ NOT NULL ); diff --git a/backend/go.mod b/backend/go.mod index 0a580fc..37aa093 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -5,7 +5,6 @@ go 1.25.5 require ( github.com/gin-gonic/gin v1.11.0 github.com/golang-jwt/jwt/v5 v5.3.1 - github.com/google/uuid v1.6.0 github.com/joho/godotenv v1.5.1 github.com/lib/pq v1.11.2 golang.org/x/crypto v0.48.0 diff --git a/backend/go.sum b/backend/go.sum index cd59fd3..37194a4 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -32,8 +32,6 @@ github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArs github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= diff --git a/backend/internal/handlers/auth.go b/backend/internal/handlers/auth.go index a0d7f74..8a7a5f2 100644 --- a/backend/internal/handlers/auth.go +++ b/backend/internal/handlers/auth.go @@ -2,6 +2,7 @@ package handlers import ( "capuchin/internal/services" + "net/http" "github.com/gin-gonic/gin" ) @@ -14,51 +15,54 @@ func NewAuthHandler(svc services.AuthService) *AuthHandler { return &AuthHandler{authService: svc} } -func (h *AuthHandler) Signup(c *gin.Context) { - var reqBody struct { - Email string `json:"email" binding:"required,email"` - Password string `json:"password" binding:"required,min=8"` - } +type signupRequest struct { + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required,min=8"` +} - if err := c.ShouldBindJSON(&reqBody); err != nil { - c.JSON(400, gin.H{"error": "Invalid request: missing fields or invalid format. Password must be >= 8 characters."}) +type loginRequest struct { + Email string `json:"email"` + Password string `json:"password"` +} + +func (h *AuthHandler) Signup(c *gin.Context) { + var req signupRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: missing fields or invalid format. Password must be >= 8 characters."}) return } - _, err := h.authService.Signup(reqBody.Email, reqBody.Password) + _, err := h.authService.Signup(req.Email, req.Password) if err != nil { if err == services.ErrUserExists { - c.JSON(409, gin.H{"error": "User with this email already exists"}) + c.JSON(http.StatusConflict, gin.H{"error": "User with this email already exists"}) return } - c.JSON(500, gin.H{"error": "Failed to create user"}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"}) return } - c.JSON(201, gin.H{"message": "User created successfully"}) + c.JSON(http.StatusCreated, gin.H{"message": "User created successfully"}) } func (h *AuthHandler) Login(c *gin.Context) { - var reqBody struct { - Email string `json:"email"` - Password string `json:"password"` - } - if err := c.ShouldBindJSON(&reqBody); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) + var req loginRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - tokenString, err := h.authService.Login(reqBody.Email, reqBody.Password) + tokenString, err := h.authService.Login(req.Email, req.Password) if err != nil { if err == services.ErrInvalidCredentials { - c.JSON(401, gin.H{"error": "Invalid credentials"}) + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"}) return } - c.JSON(500, gin.H{"error": "Login failed"}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Login failed"}) return } - c.JSON(200, gin.H{"token": tokenString}) + c.JSON(http.StatusOK, gin.H{"token": tokenString}) } func (h *AuthHandler) Logout(c *gin.Context) { @@ -67,12 +71,12 @@ func (h *AuthHandler) Logout(c *gin.Context) { err := h.authService.Logout(tokenStr) if err != nil { if err == services.ErrInvalidToken { - c.JSON(400, gin.H{"error": "Invalid token components"}) + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid token components"}) return } - c.JSON(500, gin.H{"error": "Failed to logout"}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to logout"}) return } - c.JSON(200, gin.H{"message": "Logged out successfully"}) + c.JSON(http.StatusOK, gin.H{"message": "Logged out successfully"}) } diff --git a/backend/internal/handlers/todo.go b/backend/internal/handlers/todo.go index bea25ed..9cdff5a 100644 --- a/backend/internal/handlers/todo.go +++ b/backend/internal/handlers/todo.go @@ -2,9 +2,10 @@ package handlers import ( "capuchin/internal/services" + "net/http" + "strconv" "github.com/gin-gonic/gin" - "github.com/google/uuid" ) type TodoHandler struct { @@ -15,83 +16,85 @@ func NewTodoHandler(svc services.TodoService) *TodoHandler { return &TodoHandler{todoService: svc} } +type addTodoRequest struct { + Item string `json:"item" binding:"required"` + Completed bool `json:"completed"` +} + +type updateTodoRequest struct { + Item *string `json:"item"` + Completed *bool `json:"completed"` +} + func (h *TodoHandler) GetTodos(c *gin.Context) { - userID := c.MustGet("userID").(uuid.UUID) + userID := c.MustGet("userID").(int64) todos, err := h.todoService.GetTodos(userID) if err != nil { - c.JSON(500, gin.H{"error": "Failed to get todos"}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get todos"}) return } - c.JSON(200, todos) + c.JSON(http.StatusOK, todos) } func (h *TodoHandler) AddTodo(c *gin.Context) { - userID := c.MustGet("userID").(uuid.UUID) - var req struct { - Item string `json:"item" binding:"required"` - Completed bool `json:"completed"` - } + userID := c.MustGet("userID").(int64) + var req addTodoRequest if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } todo, err := h.todoService.AddTodo(userID, req.Item, req.Completed) if err != nil { - c.JSON(500, gin.H{"error": "Failed to add todo"}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add todo"}) return } - c.JSON(200, todo) + c.JSON(http.StatusOK, todo) } func (h *TodoHandler) UpdateTodo(c *gin.Context) { - userID := c.MustGet("userID").(uuid.UUID) - idParam := c.Param("id") - id, err := uuid.Parse(idParam) + userID := c.MustGet("userID").(int64) + id, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { - c.JSON(400, gin.H{"error": "invalid id"}) + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) return } - var req struct { - Item *string `json:"item"` - Completed *bool `json:"completed"` - } + var req updateTodoRequest if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } todo, err := h.todoService.UpdateTodo(userID, id, req.Item, req.Completed) if err != nil { if err == services.ErrTodoNotFound { - c.JSON(404, gin.H{"error": "Todo not found"}) + c.JSON(http.StatusNotFound, gin.H{"error": "Todo not found"}) return } - c.JSON(500, gin.H{"error": "Failed to update todo"}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update todo"}) return } - c.JSON(200, todo) + c.JSON(http.StatusOK, todo) } func (h *TodoHandler) DeleteTodo(c *gin.Context) { - userID := c.MustGet("userID").(uuid.UUID) - idParam := c.Param("id") - id, err := uuid.Parse(idParam) + userID := c.MustGet("userID").(int64) + id, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { - c.JSON(400, gin.H{"error": "invalid id"}) + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) return } err = h.todoService.DeleteTodo(userID, id) if err != nil { if err == services.ErrTodoNotFound { - c.JSON(404, gin.H{"error": "Todo not found"}) + c.JSON(http.StatusNotFound, gin.H{"error": "Todo not found"}) return } - c.JSON(500, gin.H{"error": "Failed to delete todo"}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete todo"}) return } - c.JSON(200, gin.H{"message": "Todo deleted successfully"}) + c.JSON(http.StatusOK, gin.H{"message": "Todo deleted successfully"}) } diff --git a/backend/internal/middleware/auth.go b/backend/internal/middleware/auth.go index 10f9adf..4659bfc 100644 --- a/backend/internal/middleware/auth.go +++ b/backend/internal/middleware/auth.go @@ -3,65 +3,51 @@ package middleware import ( "capuchin/internal/config" "capuchin/internal/database" - "database/sql" + "net/http" "strings" "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" - "github.com/google/uuid" ) func AuthRequired() gin.HandlerFunc { return func(c *gin.Context) { tokenStr := c.GetHeader("Authorization") if tokenStr == "" { - c.AbortWithStatusJSON(401, gin.H{"error": "Authorization header required"}) + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorization header required"}) return } - // Accept standard Authorization header format without forcing clients to preprocess it. tokenStr = strings.TrimPrefix(tokenStr, "Bearer ") var exists bool - // Check revocation before claim extraction so logout takes effect immediately. err := database.DB.QueryRow("SELECT EXISTS(SELECT 1 FROM blacklisted_tokens WHERE token=$1)", tokenStr).Scan(&exists) - if err != nil && err != sql.ErrNoRows { - c.AbortWithStatusJSON(503, gin.H{"error": "authentication service unavailable"}) - return - } - if exists { - c.AbortWithStatusJSON(401, gin.H{"error": "Token has been revoked"}) + if err == nil && exists { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Token has been revoked"}) return } - // Restrict acceptable algorithms and require exp to reduce token confusion attacks. token, err := jwt.Parse(tokenStr, func(token *jwt.Token) (interface{}, error) { return config.JWTKey, nil }, jwt.WithValidMethods([]string{"HS256"}), jwt.WithExpirationRequired()) if err != nil || !token.Valid { - c.AbortWithStatusJSON(401, gin.H{"error": "Invalid or expired token"}) + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired token"}) return } if claims, ok := token.Claims.(jwt.MapClaims); ok { - - raw, ok := claims["user_id"].(string) + // JWT numbers are decoded as float64 by encoding/json. + raw, ok := claims["user_id"].(float64) if !ok { - c.AbortWithStatusJSON(401, gin.H{"error": "invalid token claims"}) - return - } - // Parse into UUID once so handlers can rely on a strongly typed user identity. - uid, err := uuid.Parse(raw) - if err != nil { - c.AbortWithStatusJSON(401, gin.H{"error": "invalid user id in token"}) + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token claims"}) return } - c.Set("userID", uid) + c.Set("userID", int64(raw)) c.Next() } else { - c.AbortWithStatusJSON(401, gin.H{"error": "Invalid token claims"}) + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid token claims"}) return } } diff --git a/backend/internal/models/models.go b/backend/internal/models/models.go index 4c9c0fa..50a9c27 100644 --- a/backend/internal/models/models.go +++ b/backend/internal/models/models.go @@ -1,16 +1,14 @@ package models -import "github.com/google/uuid" - type Todo struct { - ID uuid.UUID `json:"id"` - Item string `json:"item"` - Completed bool `json:"completed"` - UserID uuid.UUID `json:"-"` + ID int64 `json:"id"` + Item string `json:"item"` + Completed bool `json:"completed"` + UserID int64 `json:"-"` } type User struct { - ID uuid.UUID `json:"id"` - Email string `json:"email"` - PasswordHash string `json:"-"` + ID int64 `json:"id"` + Email string `json:"email"` + PasswordHash string `json:"-"` } diff --git a/backend/internal/routes/routes.go b/backend/internal/routes/routes.go index dd77457..5e49bfd 100644 --- a/backend/internal/routes/routes.go +++ b/backend/internal/routes/routes.go @@ -3,6 +3,7 @@ package routes import ( "capuchin/internal/handlers" "capuchin/internal/middleware" + "net/http" "github.com/gin-gonic/gin" ) @@ -12,7 +13,7 @@ func SetupRoutes(router *gin.Engine, authHandler *handlers.AuthHandler, todoHand router.Use(middleware.ErrorHandler()) router.GET("/health", func(c *gin.Context) { - c.JSON(200, gin.H{"status": "ok"}) + c.JSON(http.StatusOK, gin.H{"status": "ok"}) }) router.POST("/signup", authHandler.Signup) diff --git a/backend/internal/services/auth_service.go b/backend/internal/services/auth_service.go index 7b6da99..dc2a59f 100644 --- a/backend/internal/services/auth_service.go +++ b/backend/internal/services/auth_service.go @@ -9,7 +9,6 @@ import ( "time" "github.com/golang-jwt/jwt/v5" - "github.com/google/uuid" "golang.org/x/crypto/bcrypt" ) @@ -39,15 +38,13 @@ func (s *authService) Signup(email, password string) (*models.User, error) { } u := &models.User{ - ID: uuid.New(), Email: email, PasswordHash: string(hash), } - _, err = database.DB.Exec("INSERT INTO users (id, email, password_hash) VALUES ($1, $2, $3)", u.ID, u.Email, u.PasswordHash) + err = database.DB.QueryRow("INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING id", u.Email, u.PasswordHash).Scan(&u.ID) if err != nil { errStr := err.Error() - // Convert storage-specific duplicate key errors into a stable domain error for handlers. if strings.Contains(errStr, "unique constraint") || strings.Contains(errStr, "duplicate key value") { return nil, ErrUserExists } @@ -61,20 +58,18 @@ func (s *authService) Login(email, password string) (string, error) { var u models.User err := database.DB.QueryRow("SELECT id, email, password_hash FROM users WHERE email=$1", email).Scan(&u.ID, &u.Email, &u.PasswordHash) if err != nil { - // Use one response for unknown user and wrong password to avoid account enumeration. + // Same error for unknown user and wrong password to avoid account enumeration. return "", ErrInvalidCredentials } if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)); err != nil { - // Keep the same error shape to avoid leaking which check failed. return "", ErrInvalidCredentials } token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ - "user_id": u.ID.String(), + "user_id": u.ID, "email": u.Email, - // Short-lived tokens reduce blast radius if a token is leaked. - "exp": time.Now().Add(time.Hour * 72).Unix(), + "exp": time.Now().Add(time.Hour * 72).Unix(), }) tokenString, err := token.SignedString(config.JWTKey) if err != nil { @@ -89,12 +84,10 @@ func (s *authService) Logout(tokenStr string) error { return ErrInvalidToken } - // Accept either raw JWTs or Authorization header values for caller flexibility. if len(tokenStr) > 7 && tokenStr[:7] == "Bearer " { tokenStr = tokenStr[7:] } - // Parse to extract expiry so blacklist rows can be garbage-collected safely. token, _ := jwt.Parse(tokenStr, func(token *jwt.Token) (interface{}, error) { return config.JWTKey, nil }) @@ -108,14 +101,12 @@ func (s *authService) Logout(tokenStr string) error { if exp, ok := claims["exp"].(float64); ok { expTime = time.Unix(int64(exp), 0) } else { - // Fail-safe TTL keeps blacklist entries finite even for malformed claim types. expTime = time.Now().Add(72 * time.Hour) } } else { return ErrInvalidToken } - // Idempotent logout avoids surfacing harmless duplicate requests as server errors. _, err := database.DB.Exec("INSERT INTO blacklisted_tokens (token, expired_at) VALUES ($1, $2) ON CONFLICT (token) DO NOTHING", tokenStr, expTime) if err != nil { return ErrDatabase diff --git a/backend/internal/services/todo_service.go b/backend/internal/services/todo_service.go index df36148..dabfdc2 100644 --- a/backend/internal/services/todo_service.go +++ b/backend/internal/services/todo_service.go @@ -5,8 +5,6 @@ import ( "capuchin/internal/models" "database/sql" "errors" - - "github.com/google/uuid" ) var ( @@ -14,10 +12,10 @@ var ( ) type TodoService interface { - GetTodos(userID uuid.UUID) ([]models.Todo, error) - AddTodo(userID uuid.UUID, item string, completed bool) (*models.Todo, error) - UpdateTodo(userID, todoID uuid.UUID, item *string, completed *bool) (*models.Todo, error) - DeleteTodo(userID, todoID uuid.UUID) error + GetTodos(userID int64) ([]models.Todo, error) + AddTodo(userID int64, item string, completed bool) (*models.Todo, error) + UpdateTodo(userID, todoID int64, item *string, completed *bool) (*models.Todo, error) + DeleteTodo(userID, todoID int64) error } type todoService struct{} @@ -26,8 +24,7 @@ func NewTodoService() TodoService { return &todoService{} } -func (s *todoService) GetTodos(userID uuid.UUID) ([]models.Todo, error) { - // Scope every read by user_id so one user can never read another user's todos. +func (s *todoService) GetTodos(userID int64) ([]models.Todo, error) { rows, err := database.DB.Query("SELECT id, item, completed FROM todos WHERE user_id=$1", userID) if err != nil { return nil, ErrDatabase @@ -38,7 +35,6 @@ func (s *todoService) GetTodos(userID uuid.UUID) ([]models.Todo, error) { for rows.Next() { var t models.Todo if err := rows.Scan(&t.ID, &t.Item, &t.Completed); err != nil { - // Skip malformed rows instead of failing the whole response for a single bad record. continue } todos = append(todos, t) @@ -51,24 +47,25 @@ func (s *todoService) GetTodos(userID uuid.UUID) ([]models.Todo, error) { return todos, nil } -func (s *todoService) AddTodo(userID uuid.UUID, item string, completed bool) (*models.Todo, error) { +func (s *todoService) AddTodo(userID int64, item string, completed bool) (*models.Todo, error) { t := &models.Todo{ - ID: uuid.New(), UserID: userID, Item: item, Completed: completed, } - _, err := database.DB.Exec("INSERT INTO todos (id, item, completed, user_id) VALUES ($1, $2, $3, $4)", t.ID, t.Item, t.Completed, t.UserID) + err := database.DB.QueryRow( + "INSERT INTO todos (item, completed, user_id) VALUES ($1, $2, $3) RETURNING id", + t.Item, t.Completed, t.UserID, + ).Scan(&t.ID) if err != nil { return nil, ErrDatabase } return t, nil } -func (s *todoService) UpdateTodo(userID, todoID uuid.UUID, item *string, completed *bool) (*models.Todo, error) { +func (s *todoService) UpdateTodo(userID, todoID int64, item *string, completed *bool) (*models.Todo, error) { if item == nil && completed == nil { - // Empty PATCH requests are treated as a read to keep the endpoint idempotent. var t models.Todo err := database.DB.QueryRow("SELECT id, item, completed FROM todos WHERE id=$1 AND user_id=$2", todoID, userID).Scan(&t.ID, &t.Item, &t.Completed) if err != nil { @@ -82,11 +79,10 @@ func (s *todoService) UpdateTodo(userID, todoID uuid.UUID, item *string, complet var t models.Todo err := database.DB.QueryRow(` - UPDATE todos - -- COALESCE preserves existing values when fields are omitted from PATCH payloads. - SET item = COALESCE($1, item), + UPDATE todos + SET item = COALESCE($1, item), completed = COALESCE($2, completed) - WHERE id=$3 AND user_id=$4 + WHERE id=$3 AND user_id=$4 RETURNING id, item, completed`, item, completed, todoID, userID).Scan(&t.ID, &t.Item, &t.Completed) if err != nil { @@ -98,7 +94,7 @@ func (s *todoService) UpdateTodo(userID, todoID uuid.UUID, item *string, complet return &t, nil } -func (s *todoService) DeleteTodo(userID, todoID uuid.UUID) error { +func (s *todoService) DeleteTodo(userID, todoID int64) error { res, err := database.DB.Exec("DELETE FROM todos WHERE id=$1 AND user_id=$2", todoID, userID) if err != nil { return ErrDatabase @@ -106,7 +102,6 @@ func (s *todoService) DeleteTodo(userID, todoID uuid.UUID) error { rowsAffected, _ := res.RowsAffected() if rowsAffected == 0 { - // Distinguish "not found" from successful deletion for better API semantics. return ErrTodoNotFound } diff --git a/frontend/src/components/todos/TodoItem.tsx b/frontend/src/components/todos/TodoItem.tsx index 8cae085..c640ea1 100644 --- a/frontend/src/components/todos/TodoItem.tsx +++ b/frontend/src/components/todos/TodoItem.tsx @@ -4,9 +4,9 @@ import type { Todo } from "@/types" interface TodoItemProps { todo: Todo - onToggle: (id: string, current: boolean) => void - onDelete: (id: string) => void - onUpdate: (id: string, item: string) => void + onToggle: (id: number, current: boolean) => void + onDelete: (id: number) => void + onUpdate: (id: number, item: string) => void } export function TodoItem({ todo, onToggle, onDelete, onUpdate }: TodoItemProps) { diff --git a/frontend/src/hooks/useTodos.ts b/frontend/src/hooks/useTodos.ts index de48467..8f9b702 100644 --- a/frontend/src/hooks/useTodos.ts +++ b/frontend/src/hooks/useTodos.ts @@ -3,7 +3,11 @@ import { todosApi } from "@/lib/api" import type { Todo, FilterType } from "@/types" const GUEST_KEY = "capuchin_guest_todos" -const genId = () => crypto.randomUUID() +let _guestIdCounter = 0 +const genId = (): number => { + _guestIdCounter -= 1 + return _guestIdCounter +} const loadGuestTodos = (): Todo[] => { try { @@ -65,7 +69,7 @@ export function useTodos(token: string | null, isAuthed: boolean) { ) const toggleTodo = useCallback( - async (id: string, current: boolean) => { + async (id: number, current: boolean) => { if (!isAuthed) { updateGuest((prev) => prev.map((t) => (t.id === id ? { ...t, completed: !t.completed } : t))) return @@ -81,7 +85,7 @@ export function useTodos(token: string | null, isAuthed: boolean) { ) const deleteTodo = useCallback( - async (id: string) => { + async (id: number) => { if (!isAuthed) { updateGuest((prev) => prev.filter((t) => t.id !== id)) return @@ -98,7 +102,7 @@ export function useTodos(token: string | null, isAuthed: boolean) { ) const updateTodo = useCallback( - async (id: string, item: string) => { + async (id: number, item: string) => { if (!item.trim()) return if (!isAuthed) { updateGuest((prev) => prev.map((t) => (t.id === id ? { ...t, item: item.trim() } : t))) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index b4e28b6..27906ef 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -37,7 +37,7 @@ export const authApi = { // Todo const normalise = (raw: Record): Todo => ({ - id: String(raw.id ?? raw.ID), + id: Number(raw.id ?? raw.ID), item: String(raw.item ?? raw.Item ?? ""), completed: Boolean(raw.completed ?? raw.Completed ?? false), }) @@ -62,7 +62,7 @@ export const todosApi = { return normalise(await res.json()) }, - toggle: async (token: string, id: string, completed: boolean): Promise => { + toggle: async (token: string, id: number, completed: boolean): Promise => { const res = await fetch(`${BASE}/api/user/todo/${id}`, { method: "PATCH", headers: authHeaders(token), @@ -71,7 +71,7 @@ export const todosApi = { if (!res.ok) throw new Error(`Failed to toggle todo: ${res.status}`) }, - update: async (token: string, id: string, item: string): Promise => { + update: async (token: string, id: number, item: string): Promise => { const res = await fetch(`${BASE}/api/user/todo/${id}`, { method: "PATCH", headers: authHeaders(token), @@ -81,7 +81,7 @@ export const todosApi = { return normalise(await res.json()) }, - delete: async (token: string, id: string): Promise => { + delete: async (token: string, id: number): Promise => { const res = await fetch(`${BASE}/api/user/todo/${id}`, { method: "DELETE", headers: authHeaders(token), diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index d7c8b04..809e363 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1,5 +1,5 @@ export interface Todo { - id: string + id: number item: string completed: boolean }