diff --git a/server/internal/handlers/admin_handlers.go b/server/internal/handlers/admin_handlers.go index 8c01590..1db95f0 100644 --- a/server/internal/handlers/admin_handlers.go +++ b/server/internal/handlers/admin_handlers.go @@ -6,6 +6,7 @@ import ( "net/http" "novamd/internal/context" "novamd/internal/db" + "novamd/internal/logging" "novamd/internal/models" "novamd/internal/storage" "strconv" @@ -47,6 +48,10 @@ type SystemStats struct { *storage.FileCountStats } +func getAdminLogger() logging.Logger { + return getHandlersLogger().WithGroup("admin") +} + // AdminListUsers godoc // @Summary List all users // @Description Returns the list of all users @@ -58,9 +63,22 @@ type SystemStats struct { // @Failure 500 {object} ErrorResponse "Failed to list users" // @Router /admin/users [get] func (h *Handler) AdminListUsers() http.HandlerFunc { - return func(w http.ResponseWriter, _ *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + ctx, ok := context.GetRequestContext(w, r) + if !ok { + return + } + log := getAdminLogger().With( + "handler", "AdminListUsers", + "adminID", ctx.UserID, + "clientIP", r.RemoteAddr, + ) + users, err := h.DB.GetAllUsers() if err != nil { + log.Error("failed to fetch users from database", + "error", err.Error(), + ) respondError(w, "Failed to list users", http.StatusInternalServerError) return } @@ -89,39 +107,63 @@ func (h *Handler) AdminListUsers() http.HandlerFunc { // @Router /admin/users [post] func (h *Handler) AdminCreateUser() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { + ctx, ok := context.GetRequestContext(w, r) + if !ok { + return + } + log := getAdminLogger().With( + "handler", "AdminCreateUser", + "adminID", ctx.UserID, + "clientIP", r.RemoteAddr, + ) + var req CreateUserRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + log.Debug("failed to decode request body", + "error", err.Error(), + ) respondError(w, "Invalid request body", http.StatusBadRequest) return } - // Validate request + // Validation logging if req.Email == "" || req.Password == "" || req.Role == "" { + log.Debug("missing required fields", + "hasEmail", req.Email != "", + "hasPassword", req.Password != "", + "hasRole", req.Role != "", + ) respondError(w, "Email, password, and role are required", http.StatusBadRequest) return } - // Check if email already exists + // Email existence check existingUser, err := h.DB.GetUserByEmail(req.Email) if err == nil && existingUser != nil { + log.Warn("attempted to create user with existing email", + "email", req.Email, + ) respondError(w, "Email already exists", http.StatusConflict) return } - // Check if password is long enough if len(req.Password) < 8 { + log.Debug("password too short", + "passwordLength", len(req.Password), + ) respondError(w, "Password must be at least 8 characters", http.StatusBadRequest) return } - // Hash password hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) if err != nil { + log.Error("failed to hash password", + "error", err.Error(), + ) respondError(w, "Failed to hash password", http.StatusInternalServerError) return } - // Create user user := &models.User{ Email: req.Email, DisplayName: req.DisplayName, @@ -131,16 +173,30 @@ func (h *Handler) AdminCreateUser() http.HandlerFunc { insertedUser, err := h.DB.CreateUser(user) if err != nil { + log.Error("failed to create user in database", + "error", err.Error(), + "email", req.Email, + "role", req.Role, + ) respondError(w, "Failed to create user", http.StatusInternalServerError) return } - // Initialize user workspace if err := h.Storage.InitializeUserWorkspace(insertedUser.ID, insertedUser.LastWorkspaceID); err != nil { + log.Error("failed to initialize user workspace", + "error", err.Error(), + "userID", insertedUser.ID, + "workspaceID", insertedUser.LastWorkspaceID, + ) respondError(w, "Failed to initialize user workspace", http.StatusInternalServerError) return } + log.Info("user created", + "newUserID", insertedUser.ID, + "email", insertedUser.Email, + "role", insertedUser.Role, + ) respondJSON(w, insertedUser) } } @@ -159,14 +215,32 @@ func (h *Handler) AdminCreateUser() http.HandlerFunc { // @Router /admin/users/{userId} [get] func (h *Handler) AdminGetUser() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { + ctx, ok := context.GetRequestContext(w, r) + if !ok { + return + } + log := getAdminLogger().With( + "handler", "AdminGetUser", + "adminID", ctx.UserID, + "clientIP", r.RemoteAddr, + ) + userID, err := strconv.Atoi(chi.URLParam(r, "userId")) if err != nil { + log.Debug("invalid user ID format", + "userIDParam", chi.URLParam(r, "userId"), + "error", err.Error(), + ) respondError(w, "Invalid user ID", http.StatusBadRequest) return } user, err := h.DB.GetUserByID(userID) if err != nil { + log.Debug("user not found", + "targetUserID", userID, + "error", err.Error(), + ) respondError(w, "User not found", http.StatusNotFound) return } @@ -194,49 +268,86 @@ func (h *Handler) AdminGetUser() http.HandlerFunc { // @Router /admin/users/{userId} [put] func (h *Handler) AdminUpdateUser() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { + ctx, ok := context.GetRequestContext(w, r) + if !ok { + return + } + log := getAdminLogger().With( + "handler", "AdminUpdateUser", + "adminID", ctx.UserID, + "clientIP", r.RemoteAddr, + ) + userID, err := strconv.Atoi(chi.URLParam(r, "userId")) if err != nil { + log.Debug("invalid user ID format", + "userIDParam", chi.URLParam(r, "userId"), + "error", err.Error(), + ) respondError(w, "Invalid user ID", http.StatusBadRequest) return } - // Get existing user user, err := h.DB.GetUserByID(userID) if err != nil { + log.Debug("user not found", + "targetUserID", userID, + "error", err.Error(), + ) respondError(w, "User not found", http.StatusNotFound) return } var req UpdateUserRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + log.Debug("failed to decode request body", + "error", err.Error(), + ) respondError(w, "Invalid request body", http.StatusBadRequest) return } - // Update fields if provided + // Track what's being updated for logging + updates := make(map[string]interface{}) + if req.Email != "" { user.Email = req.Email + updates["email"] = req.Email } if req.DisplayName != "" { user.DisplayName = req.DisplayName + updates["displayName"] = req.DisplayName } if req.Role != "" { user.Role = req.Role + updates["role"] = req.Role } if req.Password != "" { hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) if err != nil { + log.Error("failed to hash password", + "error", err.Error(), + ) respondError(w, "Failed to hash password", http.StatusInternalServerError) return } user.PasswordHash = string(hashedPassword) + updates["passwordUpdated"] = true } if err := h.DB.UpdateUser(user); err != nil { + log.Error("failed to update user in database", + "error", err.Error(), + "targetUserID", userID, + ) respondError(w, "Failed to update user", http.StatusInternalServerError) return } + log.Info("user updated", + "targetUserID", userID, + "updates", updates, + ) respondJSON(w, user) } } @@ -261,37 +372,61 @@ func (h *Handler) AdminDeleteUser() http.HandlerFunc { if !ok { return } + log := getAdminLogger().With( + "handler", "AdminDeleteUser", + "adminID", ctx.UserID, + "clientIP", r.RemoteAddr, + ) userID, err := strconv.Atoi(chi.URLParam(r, "userId")) if err != nil { + log.Debug("invalid user ID format", + "userIDParam", chi.URLParam(r, "userId"), + "error", err.Error(), + ) respondError(w, "Invalid user ID", http.StatusBadRequest) return } - // Prevent admin from deleting themselves if userID == ctx.UserID { + log.Warn("admin attempted to delete own account") respondError(w, "Cannot delete your own account", http.StatusBadRequest) return } - // Get user before deletion to check role user, err := h.DB.GetUserByID(userID) if err != nil { + log.Debug("user not found", + "targetUserID", userID, + "error", err.Error(), + ) respondError(w, "User not found", http.StatusNotFound) return } - // Prevent deletion of other admin users if user.Role == models.RoleAdmin && ctx.UserID != userID { + log.Warn("attempted to delete another admin user", + "targetUserID", userID, + "targetUserEmail", user.Email, + ) respondError(w, "Cannot delete other admin users", http.StatusForbidden) return } if err := h.DB.DeleteUser(userID); err != nil { + log.Error("failed to delete user from database", + "error", err.Error(), + "targetUserID", userID, + ) respondError(w, "Failed to delete user", http.StatusInternalServerError) return } + log.Info("user deleted", + "targetUserID", userID, + "targetUserEmail", user.Email, + "targetUserRole", user.Role, + ) w.WriteHeader(http.StatusNoContent) } } @@ -309,9 +444,22 @@ func (h *Handler) AdminDeleteUser() http.HandlerFunc { // @Failure 500 {object} ErrorResponse "Failed to get file stats" // @Router /admin/workspaces [get] func (h *Handler) AdminListWorkspaces() http.HandlerFunc { - return func(w http.ResponseWriter, _ *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + ctx, ok := context.GetRequestContext(w, r) + if !ok { + return + } + log := getAdminLogger().With( + "handler", "AdminListWorkspaces", + "adminID", ctx.UserID, + "clientIP", r.RemoteAddr, + ) + workspaces, err := h.DB.GetAllWorkspaces() if err != nil { + log.Error("failed to fetch workspaces from database", + "error", err.Error(), + ) respondError(w, "Failed to list workspaces", http.StatusInternalServerError) return } @@ -319,11 +467,15 @@ func (h *Handler) AdminListWorkspaces() http.HandlerFunc { workspacesStats := make([]*WorkspaceStats, 0, len(workspaces)) for _, ws := range workspaces { - workspaceData := &WorkspaceStats{} user, err := h.DB.GetUserByID(ws.UserID) if err != nil { + log.Error("failed to fetch user for workspace", + "error", err.Error(), + "workspaceID", ws.ID, + "userID", ws.UserID, + ) respondError(w, "Failed to get user", http.StatusInternalServerError) return } @@ -336,12 +488,16 @@ func (h *Handler) AdminListWorkspaces() http.HandlerFunc { fileStats, err := h.Storage.GetFileStats(ws.UserID, ws.ID) if err != nil { + log.Error("failed to fetch file stats for workspace", + "error", err.Error(), + "workspaceID", ws.ID, + "userID", ws.UserID, + ) respondError(w, "Failed to get file stats", http.StatusInternalServerError) return } workspaceData.FileCountStats = fileStats - workspacesStats = append(workspacesStats, workspaceData) } @@ -361,15 +517,31 @@ func (h *Handler) AdminListWorkspaces() http.HandlerFunc { // @Failure 500 {object} ErrorResponse "Failed to get file stats" // @Router /admin/stats [get] func (h *Handler) AdminGetSystemStats() http.HandlerFunc { - return func(w http.ResponseWriter, _ *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + ctx, ok := context.GetRequestContext(w, r) + if !ok { + return + } + log := getAdminLogger().With( + "handler", "AdminGetSystemStats", + "adminID", ctx.UserID, + "clientIP", r.RemoteAddr, + ) + userStats, err := h.DB.GetSystemStats() if err != nil { + log.Error("failed to fetch user statistics", + "error", err.Error(), + ) respondError(w, "Failed to get user stats", http.StatusInternalServerError) return } fileStats, err := h.Storage.GetTotalFileStats() if err != nil { + log.Error("failed to fetch file statistics", + "error", err.Error(), + ) respondError(w, "Failed to get file stats", http.StatusInternalServerError) return } diff --git a/server/internal/handlers/auth_handlers.go b/server/internal/handlers/auth_handlers.go index 2f1cbb2..3392d0c 100644 --- a/server/internal/handlers/auth_handlers.go +++ b/server/internal/handlers/auth_handlers.go @@ -7,6 +7,7 @@ import ( "net/http" "novamd/internal/auth" "novamd/internal/context" + "novamd/internal/logging" "novamd/internal/models" "time" @@ -26,6 +27,10 @@ type LoginResponse struct { ExpiresAt time.Time `json:"expiresAt,omitempty"` } +func getAuthLogger() logging.Logger { + return getHandlersLogger().WithGroup("auth") +} + // Login godoc // @Summary Login // @Description Logs in a user and returns a session with access and refresh tokens @@ -43,62 +48,88 @@ type LoginResponse struct { // @Router /auth/login [post] func (h *Handler) Login(authManager auth.SessionManager, cookieService auth.CookieManager) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { + log := getAuthLogger().With( + "handler", "Login", + "clientIP", r.RemoteAddr, + ) + var req LoginRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + log.Debug("failed to decode request body", + "error", err.Error(), + ) respondError(w, "Invalid request body", http.StatusBadRequest) return } - // Validate request if req.Email == "" || req.Password == "" { + log.Debug("missing required fields", + "hasEmail", req.Email != "", + "hasPassword", req.Password != "", + ) respondError(w, "Email and password are required", http.StatusBadRequest) return } - // Get user from database user, err := h.DB.GetUserByEmail(req.Email) if err != nil { + log.Debug("user not found", + "email", req.Email, + "error", err.Error(), + ) respondError(w, "Invalid credentials", http.StatusUnauthorized) return } - // Verify password err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)) if err != nil { + log.Warn("invalid password attempt", + "userID", user.ID, + "email", user.Email, + ) respondError(w, "Invalid credentials", http.StatusUnauthorized) return } - // Create session and generate tokens session, accessToken, err := authManager.CreateSession(user.ID, string(user.Role)) if err != nil { + log.Error("failed to create session", + "error", err.Error(), + "userID", user.ID, + ) respondError(w, "Failed to create session", http.StatusInternalServerError) return } - // Generate CSRF token csrfToken := make([]byte, 32) if _, err := rand.Read(csrfToken); err != nil { + log.Error("failed to generate CSRF token", + "error", err.Error(), + "userID", user.ID, + ) respondError(w, "Failed to generate CSRF token", http.StatusInternalServerError) return } csrfTokenString := hex.EncodeToString(csrfToken) - // Set cookies http.SetCookie(w, cookieService.GenerateAccessTokenCookie(accessToken)) http.SetCookie(w, cookieService.GenerateRefreshTokenCookie(session.RefreshToken)) http.SetCookie(w, cookieService.GenerateCSRFCookie(csrfTokenString)) - // Send CSRF token in header for initial setup w.Header().Set("X-CSRF-Token", csrfTokenString) - // Only send user info in response, not tokens response := LoginResponse{ User: user, SessionID: session.ID, ExpiresAt: session.ExpiresAt, } + log.Info("user logged in successfully", + "userID", user.ID, + "email", user.Email, + "role", user.Role, + "sessionID", session.ID, + ) respondJSON(w, response) } } @@ -114,24 +145,41 @@ func (h *Handler) Login(authManager auth.SessionManager, cookieService auth.Cook // @Router /auth/logout [post] func (h *Handler) Logout(authManager auth.SessionManager, cookieService auth.CookieManager) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - // Get session ID from cookie + ctx, ok := context.GetRequestContext(w, r) + if !ok { + return + } + log := getAuthLogger().With( + "handler", "Logout", + "userID", ctx.UserID, + "clientIP", r.RemoteAddr, + ) + sessionCookie, err := r.Cookie("access_token") if err != nil { - http.Error(w, "Unauthorized", http.StatusUnauthorized) + log.Debug("missing access token cookie", + "error", err.Error(), + ) + respondError(w, "Access token required", http.StatusBadRequest) return } - // Invalidate the session in the database if err := authManager.InvalidateSession(sessionCookie.Value); err != nil { + log.Error("failed to invalidate session", + "error", err.Error(), + "sessionID", sessionCookie.Value, + ) respondError(w, "Failed to invalidate session", http.StatusInternalServerError) return } - // Clear cookies http.SetCookie(w, cookieService.InvalidateCookie("access_token")) http.SetCookie(w, cookieService.InvalidateCookie("refresh_token")) http.SetCookie(w, cookieService.InvalidateCookie("csrf_token")) + log.Info("user logged out successfully", + "sessionID", sessionCookie.Value, + ) w.WriteHeader(http.StatusNoContent) } } @@ -151,22 +199,39 @@ func (h *Handler) Logout(authManager auth.SessionManager, cookieService auth.Coo // @Router /auth/refresh [post] func (h *Handler) RefreshToken(authManager auth.SessionManager, cookieService auth.CookieManager) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { + ctx, ok := context.GetRequestContext(w, r) + if !ok { + return + } + log := getAuthLogger().With( + "handler", "RefreshToken", + "userID", ctx.UserID, + "clientIP", r.RemoteAddr, + ) + refreshCookie, err := r.Cookie("refresh_token") if err != nil { + log.Debug("missing refresh token cookie", + "error", err.Error(), + ) respondError(w, "Refresh token required", http.StatusBadRequest) return } - // Generate new access token accessToken, err := authManager.RefreshSession(refreshCookie.Value) if err != nil { + log.Error("failed to refresh session", + "error", err.Error(), + ) respondError(w, "Invalid refresh token", http.StatusUnauthorized) return } - // Generate new CSRF token csrfToken := make([]byte, 32) if _, err := rand.Read(csrfToken); err != nil { + log.Error("failed to generate CSRF token", + "error", err.Error(), + ) respondError(w, "Failed to generate CSRF token", http.StatusInternalServerError) return } @@ -196,10 +261,17 @@ func (h *Handler) GetCurrentUser() http.HandlerFunc { if !ok { return } + log := getAuthLogger().With( + "handler", "GetCurrentUser", + "userID", ctx.UserID, + "clientIP", r.RemoteAddr, + ) - // Get user from database user, err := h.DB.GetUserByID(ctx.UserID) if err != nil { + log.Error("failed to fetch user", + "error", err.Error(), + ) respondError(w, "User not found", http.StatusNotFound) return } diff --git a/server/internal/handlers/file_handlers.go b/server/internal/handlers/file_handlers.go index a19597c..950d642 100644 --- a/server/internal/handlers/file_handlers.go +++ b/server/internal/handlers/file_handlers.go @@ -8,6 +8,7 @@ import ( "time" "novamd/internal/context" + "novamd/internal/logging" "novamd/internal/storage" "github.com/go-chi/chi/v5" @@ -35,6 +36,10 @@ type UpdateLastOpenedFileRequest struct { FilePath string `json:"filePath"` } +func getFilesLogger() logging.Logger { + return getHandlersLogger().WithGroup("files") +} + // ListFiles godoc // @Summary List files // @Description Lists all files in the user's workspace @@ -52,13 +57,25 @@ func (h *Handler) ListFiles() http.HandlerFunc { if !ok { return } + log := getFilesLogger().With( + "handler", "ListFiles", + "userID", ctx.UserID, + "workspaceID", ctx.Workspace.ID, + "clientIP", r.RemoteAddr, + ) files, err := h.Storage.ListFilesRecursively(ctx.UserID, ctx.Workspace.ID) if err != nil { + log.Error("failed to list files in workspace", + "error", err.Error(), + ) respondError(w, "Failed to list files", http.StatusInternalServerError) return } + log.Debug("files listed successfully", + "fileCount", len(files), + ) respondJSON(w, files) } } @@ -82,19 +99,40 @@ func (h *Handler) LookupFileByName() http.HandlerFunc { if !ok { return } + log := getFilesLogger().With( + "handler", "LookupFileByName", + "userID", ctx.UserID, + "workspaceID", ctx.Workspace.ID, + "clientIP", r.RemoteAddr, + ) filename := r.URL.Query().Get("filename") if filename == "" { + log.Debug("missing filename parameter") respondError(w, "Filename is required", http.StatusBadRequest) return } filePaths, err := h.Storage.FindFileByName(ctx.UserID, ctx.Workspace.ID, filename) if err != nil { + if !os.IsNotExist(err) { + log.Error("failed to lookup file", + "filename", filename, + "error", err.Error(), + ) + } else { + log.Debug("file not found", + "filename", filename, + ) + } respondError(w, "File not found", http.StatusNotFound) return } + log.Debug("file lookup successful", + "filename", filename, + "matchCount", len(filePaths), + ) respondJSON(w, &LookupResponse{Paths: filePaths}) } } @@ -120,21 +158,37 @@ func (h *Handler) GetFileContent() http.HandlerFunc { if !ok { return } + log := getFilesLogger().With( + "handler", "GetFileContent", + "userID", ctx.UserID, + "workspaceID", ctx.Workspace.ID, + "clientIP", r.RemoteAddr, + ) filePath := chi.URLParam(r, "*") content, err := h.Storage.GetFileContent(ctx.UserID, ctx.Workspace.ID, filePath) if err != nil { - if storage.IsPathValidationError(err) { + log.Error("invalid file path attempted", + "filePath", filePath, + "error", err.Error(), + ) respondError(w, "Invalid file path", http.StatusBadRequest) return } if os.IsNotExist(err) { + log.Debug("file not found", + "filePath", filePath, + ) respondError(w, "File not found", http.StatusNotFound) return } + log.Error("failed to read file content", + "filePath", filePath, + "error", err.Error(), + ) respondError(w, "Failed to read file", http.StatusInternalServerError) return } @@ -142,9 +196,18 @@ func (h *Handler) GetFileContent() http.HandlerFunc { w.Header().Set("Content-Type", "text/plain") _, err = w.Write(content) if err != nil { + log.Error("failed to write response", + "filePath", filePath, + "error", err.Error(), + ) respondError(w, "Failed to write response", http.StatusInternalServerError) return } + + log.Debug("file content retrieved", + "filePath", filePath, + "contentSize", len(content), + ) } } @@ -169,10 +232,20 @@ func (h *Handler) SaveFile() http.HandlerFunc { if !ok { return } + log := getFilesLogger().With( + "handler", "SaveFile", + "userID", ctx.UserID, + "workspaceID", ctx.Workspace.ID, + "clientIP", r.RemoteAddr, + ) filePath := chi.URLParam(r, "*") content, err := io.ReadAll(r.Body) if err != nil { + log.Error("failed to read request body", + "filePath", filePath, + "error", err.Error(), + ) respondError(w, "Failed to read request body", http.StatusBadRequest) return } @@ -180,10 +253,19 @@ func (h *Handler) SaveFile() http.HandlerFunc { err = h.Storage.SaveFile(ctx.UserID, ctx.Workspace.ID, filePath, content) if err != nil { if storage.IsPathValidationError(err) { + log.Error("invalid file path attempted", + "filePath", filePath, + "error", err.Error(), + ) respondError(w, "Invalid file path", http.StatusBadRequest) return } + log.Error("failed to save file", + "filePath", filePath, + "contentSize", len(content), + "error", err.Error(), + ) respondError(w, "Failed to save file", http.StatusInternalServerError) return } @@ -194,7 +276,11 @@ func (h *Handler) SaveFile() http.HandlerFunc { UpdatedAt: time.Now().UTC(), } - w.WriteHeader(http.StatusOK) + log.Debug("file saved", + "filePath", filePath, + "size", response.Size, + "updatedAt", response.UpdatedAt, + ) respondJSON(w, response) } } @@ -211,7 +297,6 @@ func (h *Handler) SaveFile() http.HandlerFunc { // @Failure 400 {object} ErrorResponse "Invalid file path" // @Failure 404 {object} ErrorResponse "File not found" // @Failure 500 {object} ErrorResponse "Failed to delete file" -// @Failure 500 {object} ErrorResponse "Failed to write response" // @Router /workspaces/{workspace_name}/files/{file_path} [delete] func (h *Handler) DeleteFile() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -219,24 +304,44 @@ func (h *Handler) DeleteFile() http.HandlerFunc { if !ok { return } + log := getFilesLogger().With( + "handler", "DeleteFile", + "userID", ctx.UserID, + "workspaceID", ctx.Workspace.ID, + "clientIP", r.RemoteAddr, + ) filePath := chi.URLParam(r, "*") err := h.Storage.DeleteFile(ctx.UserID, ctx.Workspace.ID, filePath) if err != nil { if storage.IsPathValidationError(err) { + log.Error("invalid file path attempted", + "filePath", filePath, + "error", err.Error(), + ) respondError(w, "Invalid file path", http.StatusBadRequest) return } if os.IsNotExist(err) { + log.Debug("file not found", + "filePath", filePath, + ) respondError(w, "File not found", http.StatusNotFound) return } + log.Error("failed to delete file", + "filePath", filePath, + "error", err.Error(), + ) respondError(w, "Failed to delete file", http.StatusInternalServerError) return } + log.Debug("file deleted", + "filePath", filePath, + ) w.WriteHeader(http.StatusNoContent) } } @@ -259,18 +364,34 @@ func (h *Handler) GetLastOpenedFile() http.HandlerFunc { if !ok { return } + log := getFilesLogger().With( + "handler", "GetLastOpenedFile", + "userID", ctx.UserID, + "workspaceID", ctx.Workspace.ID, + "clientIP", r.RemoteAddr, + ) filePath, err := h.DB.GetLastOpenedFile(ctx.Workspace.ID) if err != nil { + log.Error("failed to get last opened file from database", + "error", err.Error(), + ) respondError(w, "Failed to get last opened file", http.StatusInternalServerError) return } if _, err := h.Storage.ValidatePath(ctx.UserID, ctx.Workspace.ID, filePath); err != nil { + log.Error("invalid file path stored", + "filePath", filePath, + "error", err.Error(), + ) respondError(w, "Invalid file path", http.StatusBadRequest) return } + log.Debug("last opened file retrieved successfully", + "filePath", filePath, + ) respondJSON(w, &LastOpenedFileResponse{LastOpenedFilePath: filePath}) } } @@ -297,10 +418,18 @@ func (h *Handler) UpdateLastOpenedFile() http.HandlerFunc { if !ok { return } + log := getFilesLogger().With( + "handler", "UpdateLastOpenedFile", + "userID", ctx.UserID, + "workspaceID", ctx.Workspace.ID, + "clientIP", r.RemoteAddr, + ) var requestBody UpdateLastOpenedFileRequest - if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + log.Error("failed to decode request body", + "error", err.Error(), + ) respondError(w, "Invalid request body", http.StatusBadRequest) return } @@ -310,25 +439,43 @@ func (h *Handler) UpdateLastOpenedFile() http.HandlerFunc { _, err := h.Storage.GetFileContent(ctx.UserID, ctx.Workspace.ID, requestBody.FilePath) if err != nil { if storage.IsPathValidationError(err) { + log.Error("invalid file path attempted", + "filePath", requestBody.FilePath, + "error", err.Error(), + ) respondError(w, "Invalid file path", http.StatusBadRequest) return } if os.IsNotExist(err) { + log.Debug("file not found", + "filePath", requestBody.FilePath, + ) respondError(w, "File not found", http.StatusNotFound) return } + log.Error("failed to validate file path", + "filePath", requestBody.FilePath, + "error", err.Error(), + ) respondError(w, "Failed to update last opened file", http.StatusInternalServerError) return } } if err := h.DB.UpdateLastOpenedFile(ctx.Workspace.ID, requestBody.FilePath); err != nil { + log.Error("failed to update last opened file in database", + "filePath", requestBody.FilePath, + "error", err.Error(), + ) respondError(w, "Failed to update last opened file", http.StatusInternalServerError) return } + log.Debug("last opened file updated successfully", + "filePath", requestBody.FilePath, + ) w.WriteHeader(http.StatusNoContent) } } diff --git a/server/internal/handlers/git_handlers.go b/server/internal/handlers/git_handlers.go index 3135b6e..b57493f 100644 --- a/server/internal/handlers/git_handlers.go +++ b/server/internal/handlers/git_handlers.go @@ -3,8 +3,8 @@ package handlers import ( "encoding/json" "net/http" - "novamd/internal/context" + "novamd/internal/logging" ) // CommitRequest represents a request to commit changes @@ -22,6 +22,10 @@ type PullResponse struct { Message string `json:"message" example:"Pulled changes from remote"` } +func getGitLogger() logging.Logger { + return getHandlersLogger().WithGroup("git") +} + // StageCommitAndPush godoc // @Summary Stage, commit, and push changes // @Description Stages, commits, and pushes changes to the remote repository @@ -42,25 +46,43 @@ func (h *Handler) StageCommitAndPush() http.HandlerFunc { if !ok { return } + log := getGitLogger().With( + "handler", "StageCommitAndPush", + "userID", ctx.UserID, + "workspaceID", ctx.Workspace.ID, + "clientIP", r.RemoteAddr, + ) var requestBody CommitRequest - if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + log.Error("failed to decode request body", + "error", err.Error(), + ) respondError(w, "Invalid request body", http.StatusBadRequest) return } if requestBody.Message == "" { + log.Debug("empty commit message provided") respondError(w, "Commit message is required", http.StatusBadRequest) return } hash, err := h.Storage.StageCommitAndPush(ctx.UserID, ctx.Workspace.ID, requestBody.Message) if err != nil { + log.Error("failed to perform git operations", + "error", err.Error(), + "commitMessage", requestBody.Message, + ) respondError(w, "Failed to stage, commit, and push changes: "+err.Error(), http.StatusInternalServerError) return } + log.Debug("git operations completed successfully", + "commitHash", hash.String(), + "commitMessage", requestBody.Message, + ) + respondJSON(w, CommitResponse{CommitHash: hash.String()}) } } @@ -82,13 +104,23 @@ func (h *Handler) PullChanges() http.HandlerFunc { if !ok { return } + log := getGitLogger().With( + "handler", "PullChanges", + "userID", ctx.UserID, + "workspaceID", ctx.Workspace.ID, + "clientIP", r.RemoteAddr, + ) err := h.Storage.Pull(ctx.UserID, ctx.Workspace.ID) if err != nil { + log.Error("failed to pull changes from remote", + "error", err.Error(), + ) respondError(w, "Failed to pull changes: "+err.Error(), http.StatusInternalServerError) return } + log.Debug("successfully pulled changes from remote") respondJSON(w, PullResponse{Message: "Successfully pulled changes from remote"}) } } diff --git a/server/internal/handlers/handlers.go b/server/internal/handlers/handlers.go index da20999..b3dc4fb 100644 --- a/server/internal/handlers/handlers.go +++ b/server/internal/handlers/handlers.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "novamd/internal/db" + "novamd/internal/logging" "novamd/internal/storage" ) @@ -18,6 +19,15 @@ type Handler struct { Storage storage.Manager } +var logger logging.Logger + +func getHandlersLogger() logging.Logger { + if logger == nil { + logger = logging.WithGroup("handlers") + } + return logger +} + // NewHandler creates a new handler with the given dependencies func NewHandler(db db.Database, s storage.Manager) *Handler { return &Handler{ diff --git a/server/internal/handlers/static_handler.go b/server/internal/handlers/static_handler.go index 752e7b9..b70d219 100644 --- a/server/internal/handlers/static_handler.go +++ b/server/internal/handlers/static_handler.go @@ -2,6 +2,7 @@ package handlers import ( "net/http" + "novamd/internal/logging" "os" "path/filepath" "strings" @@ -19,8 +20,19 @@ func NewStaticHandler(staticPath string) *StaticHandler { } } +func getStaticLogger() logging.Logger { + return logging.WithGroup("static") +} + // ServeHTTP serves the static files func (h *StaticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + log := getStaticLogger().With( + "handler", "ServeHTTP", + "clientIP", r.RemoteAddr, + "method", r.Method, + "url", r.URL.Path, + ) + // Get the requested path requestedPath := r.URL.Path fullPath := filepath.Join(h.staticPath, requestedPath) @@ -28,6 +40,10 @@ func (h *StaticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Security check to prevent directory traversal if !strings.HasPrefix(cleanPath, h.staticPath) { + log.Warn("directory traversal attempt detected", + "requestedPath", requestedPath, + "cleanPath", cleanPath, + ) respondError(w, "Invalid path", http.StatusBadRequest) return } @@ -35,11 +51,29 @@ func (h *StaticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Set cache headers for assets if strings.HasPrefix(requestedPath, "/assets/") { w.Header().Set("Cache-Control", "public, max-age=31536000") // 1 year + log.Debug("cache headers set for asset", + "path", requestedPath, + ) } // Check if file exists (not counting .gz files) stat, err := os.Stat(cleanPath) if err != nil || stat.IsDir() { + if os.IsNotExist(err) { + log.Debug("file not found, serving index.html", + "requestedPath", requestedPath, + ) + } else if stat != nil && stat.IsDir() { + log.Debug("directory requested, serving index.html", + "requestedPath", requestedPath, + ) + } else { + log.Error("error checking file status", + "requestedPath", requestedPath, + "error", err.Error(), + ) + } + // Serve index.html for SPA routing indexPath := filepath.Join(h.staticPath, "index.html") http.ServeFile(w, r, indexPath) @@ -53,20 +87,32 @@ func (h *StaticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Encoding", "gzip") // Set proper content type based on original file + contentType := "application/octet-stream" switch filepath.Ext(cleanPath) { case ".js": - w.Header().Set("Content-Type", "application/javascript") + contentType = "application/javascript" case ".css": - w.Header().Set("Content-Type", "text/css") + contentType = "text/css" case ".html": - w.Header().Set("Content-Type", "text/html") + contentType = "text/html" } + w.Header().Set("Content-Type", contentType) + log.Debug("serving gzipped file", + "path", requestedPath, + "gzPath", gzPath, + "contentType", contentType, + ) http.ServeFile(w, r, gzPath) return } } // Serve original file + log.Debug("serving original file", + "path", requestedPath, + "size", stat.Size(), + "modTime", stat.ModTime(), + ) http.ServeFile(w, r, cleanPath) } diff --git a/server/internal/handlers/user_handlers.go b/server/internal/handlers/user_handlers.go index 7249b63..e0cf99b 100644 --- a/server/internal/handlers/user_handlers.go +++ b/server/internal/handlers/user_handlers.go @@ -5,6 +5,7 @@ import ( "net/http" "novamd/internal/context" + "novamd/internal/logging" "golang.org/x/crypto/bcrypt" ) @@ -22,6 +23,10 @@ type DeleteAccountRequest struct { Password string `json:"password"` } +func getProfileLogger() logging.Logger { + return getHandlersLogger().WithGroup("profile") +} + // UpdateProfile godoc // @Summary Update profile // @Description Updates the user's profile @@ -48,9 +53,17 @@ func (h *Handler) UpdateProfile() http.HandlerFunc { if !ok { return } + log := getProfileLogger().With( + "handler", "UpdateProfile", + "userID", ctx.UserID, + "clientIP", r.RemoteAddr, + ) var req UpdateProfileRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + log.Debug("failed to decode request body", + "error", err.Error(), + ) respondError(w, "Invalid request body", http.StatusBadRequest) return } @@ -58,76 +71,97 @@ func (h *Handler) UpdateProfile() http.HandlerFunc { // Get current user user, err := h.DB.GetUserByID(ctx.UserID) if err != nil { + log.Error("failed to fetch user from database", + "error", err.Error(), + ) respondError(w, "User not found", http.StatusNotFound) return } + // Track what's being updated for logging + updates := make(map[string]bool) + // Handle password update if requested if req.NewPassword != "" { - // Current password must be provided to change password if req.CurrentPassword == "" { + log.Debug("password change attempted without current password") respondError(w, "Current password is required to change password", http.StatusBadRequest) return } - // Verify current password if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.CurrentPassword)); err != nil { + log.Warn("incorrect password provided for password change") respondError(w, "Current password is incorrect", http.StatusUnauthorized) return } - // Validate new password if len(req.NewPassword) < 8 { + log.Debug("password change rejected - too short", + "passwordLength", len(req.NewPassword), + ) respondError(w, "New password must be at least 8 characters long", http.StatusBadRequest) return } - // Hash new password hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost) if err != nil { + log.Error("failed to hash new password", + "error", err.Error(), + ) respondError(w, "Failed to process new password", http.StatusInternalServerError) return } user.PasswordHash = string(hashedPassword) + updates["passwordChanged"] = true } // Handle email update if requested if req.Email != "" && req.Email != user.Email { - // Check if email change requires password verification if req.CurrentPassword == "" { + log.Debug("email change attempted without current password") respondError(w, "Current password is required to change email", http.StatusBadRequest) return } - // Verify current password if not already verified for password change if req.NewPassword == "" { if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.CurrentPassword)); err != nil { + log.Warn("incorrect password provided for email change") respondError(w, "Current password is incorrect", http.StatusUnauthorized) return } } - // Check if new email is already in use existingUser, err := h.DB.GetUserByEmail(req.Email) if err == nil && existingUser.ID != user.ID { + log.Debug("email change rejected - already in use", + "requestedEmail", req.Email, + ) respondError(w, "Email already in use", http.StatusConflict) return } user.Email = req.Email + updates["emailChanged"] = true } - // Update display name if provided (no password required) + // Update display name if provided if req.DisplayName != "" { user.DisplayName = req.DisplayName + updates["displayNameChanged"] = true } // Update user in database if err := h.DB.UpdateUser(user); err != nil { + log.Error("failed to update user in database", + "error", err.Error(), + "updates", updates, + ) respondError(w, "Failed to update profile", http.StatusInternalServerError) return } - // Return updated user data + log.Debug("profile updated successfully", + "updates", updates, + ) respondJSON(w, user) } } @@ -155,9 +189,17 @@ func (h *Handler) DeleteAccount() http.HandlerFunc { if !ok { return } + log := getProfileLogger().With( + "handler", "DeleteAccount", + "userID", ctx.UserID, + "clientIP", r.RemoteAddr, + ) var req DeleteAccountRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + log.Debug("failed to decode request body", + "error", err.Error(), + ) respondError(w, "Invalid request body", http.StatusBadRequest) return } @@ -165,25 +207,32 @@ func (h *Handler) DeleteAccount() http.HandlerFunc { // Get current user user, err := h.DB.GetUserByID(ctx.UserID) if err != nil { + log.Error("failed to fetch user from database", + "error", err.Error(), + ) respondError(w, "User not found", http.StatusNotFound) return } // Verify password if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil { - respondError(w, "Password is incorrect", http.StatusUnauthorized) + log.Warn("incorrect password provided for account deletion") + respondError(w, "Incorrect password", http.StatusUnauthorized) return } // Prevent admin from deleting their own account if they're the last admin if user.Role == "admin" { - // Count number of admin users adminCount, err := h.DB.CountAdminUsers() if err != nil { - respondError(w, "Failed to verify admin status", http.StatusInternalServerError) + log.Error("failed to count admin users", + "error", err.Error(), + ) + respondError(w, "Failed to get admin count", http.StatusInternalServerError) return } if adminCount <= 1 { + log.Warn("attempted to delete last admin account") respondError(w, "Cannot delete the last admin account", http.StatusForbidden) return } @@ -192,6 +241,9 @@ func (h *Handler) DeleteAccount() http.HandlerFunc { // Get user's workspaces for cleanup workspaces, err := h.DB.GetWorkspacesByUserID(ctx.UserID) if err != nil { + log.Error("failed to fetch user workspaces", + "error", err.Error(), + ) respondError(w, "Failed to get user workspaces", http.StatusInternalServerError) return } @@ -199,17 +251,31 @@ func (h *Handler) DeleteAccount() http.HandlerFunc { // Delete workspace directories for _, workspace := range workspaces { if err := h.Storage.DeleteUserWorkspace(ctx.UserID, workspace.ID); err != nil { + log.Error("failed to delete workspace directory", + "error", err.Error(), + "workspaceID", workspace.ID, + ) respondError(w, "Failed to delete workspace files", http.StatusInternalServerError) return } + log.Debug("workspace deleted", + "workspaceID", workspace.ID, + ) } - // Delete user from database (this will cascade delete workspaces and sessions) + // Delete user from database if err := h.DB.DeleteUser(ctx.UserID); err != nil { + log.Error("failed to delete user from database", + "error", err.Error(), + ) respondError(w, "Failed to delete account", http.StatusInternalServerError) return } + log.Info("user account deleted", + "email", user.Email, + "role", user.Role, + ) w.WriteHeader(http.StatusNoContent) } } diff --git a/server/internal/handlers/workspace_handlers.go b/server/internal/handlers/workspace_handlers.go index e04e543..de32f1a 100644 --- a/server/internal/handlers/workspace_handlers.go +++ b/server/internal/handlers/workspace_handlers.go @@ -6,6 +6,7 @@ import ( "net/http" "novamd/internal/context" + "novamd/internal/logging" "novamd/internal/models" ) @@ -19,6 +20,10 @@ type LastWorkspaceNameResponse struct { LastWorkspaceName string `json:"lastWorkspaceName"` } +func getWorkspaceLogger() logging.Logger { + return getHandlersLogger().WithGroup("workspace") +} + // ListWorkspaces godoc // @Summary List workspaces // @Description Lists all workspaces for the current user @@ -35,13 +40,24 @@ func (h *Handler) ListWorkspaces() http.HandlerFunc { if !ok { return } + log := getWorkspaceLogger().With( + "handler", "ListWorkspaces", + "userID", ctx.UserID, + "clientIP", r.RemoteAddr, + ) workspaces, err := h.DB.GetWorkspacesByUserID(ctx.UserID) if err != nil { + log.Error("failed to fetch workspaces from database", + "error", err.Error(), + ) respondError(w, "Failed to list workspaces", http.StatusInternalServerError) return } + log.Debug("workspaces retrieved successfully", + "count", len(workspaces), + ) respondJSON(w, workspaces) } } @@ -68,30 +84,54 @@ func (h *Handler) CreateWorkspace() http.HandlerFunc { if !ok { return } + log := getWorkspaceLogger().With( + "handler", "CreateWorkspace", + "userID", ctx.UserID, + "clientIP", r.RemoteAddr, + ) var workspace models.Workspace if err := json.NewDecoder(r.Body).Decode(&workspace); err != nil { + log.Debug("invalid request body received", + "error", err.Error(), + ) respondError(w, "Invalid request body", http.StatusBadRequest) return } if err := workspace.ValidateGitSettings(); err != nil { + log.Debug("invalid git settings provided", + "error", err.Error(), + ) respondError(w, "Invalid workspace", http.StatusBadRequest) return } workspace.UserID = ctx.UserID if err := h.DB.CreateWorkspace(&workspace); err != nil { + log.Error("failed to create workspace in database", + "error", err.Error(), + "workspaceName", workspace.Name, + ) respondError(w, "Failed to create workspace", http.StatusInternalServerError) return } if err := h.Storage.InitializeUserWorkspace(workspace.UserID, workspace.ID); err != nil { + log.Error("failed to initialize workspace directory", + "error", err.Error(), + "workspaceID", workspace.ID, + ) respondError(w, "Failed to initialize workspace directory", http.StatusInternalServerError) return } if workspace.GitEnabled { + log.Debug("setting up git repository", + "workspaceID", workspace.ID, + "gitURL", workspace.GitURL, + ) + if err := h.Storage.SetupGitRepo( ctx.UserID, workspace.ID, @@ -101,11 +141,20 @@ func (h *Handler) CreateWorkspace() http.HandlerFunc { workspace.GitCommitName, workspace.GitCommitEmail, ); err != nil { + log.Error("failed to setup git repository", + "error", err.Error(), + "workspaceID", workspace.ID, + ) respondError(w, "Failed to setup git repo: "+err.Error(), http.StatusInternalServerError) return } } + log.Info("workspace created", + "workspaceID", workspace.ID, + "workspaceName", workspace.Name, + "gitEnabled", workspace.GitEnabled, + ) respondJSON(w, workspace) } } @@ -171,9 +220,18 @@ func (h *Handler) UpdateWorkspace() http.HandlerFunc { if !ok { return } + log := getWorkspaceLogger().With( + "handler", "UpdateWorkspace", + "userID", ctx.UserID, + "workspaceID", ctx.Workspace.ID, + "clientIP", r.RemoteAddr, + ) var workspace models.Workspace if err := json.NewDecoder(r.Body).Decode(&workspace); err != nil { + log.Debug("invalid request body received", + "error", err.Error(), + ) respondError(w, "Invalid request body", http.StatusBadRequest) return } @@ -184,13 +242,28 @@ func (h *Handler) UpdateWorkspace() http.HandlerFunc { // Validate the workspace if err := workspace.Validate(); err != nil { + log.Debug("invalid workspace configuration", + "error", err.Error(), + ) respondError(w, err.Error(), http.StatusBadRequest) return } + // Track what's changed for logging + changes := map[string]bool{ + "gitSettings": gitSettingsChanged(&workspace, ctx.Workspace), + "name": workspace.Name != ctx.Workspace.Name, + "theme": workspace.Theme != ctx.Workspace.Theme, + "autoSave": workspace.AutoSave != ctx.Workspace.AutoSave, + } + // Handle Git repository setup/teardown if Git settings changed - if gitSettingsChanged(&workspace, ctx.Workspace) { + if changes["gitSettings"] { if workspace.GitEnabled { + log.Debug("updating git repository configuration", + "gitURL", workspace.GitURL, + ) + if err := h.Storage.SetupGitRepo( ctx.UserID, ctx.Workspace.ID, @@ -200,20 +273,29 @@ func (h *Handler) UpdateWorkspace() http.HandlerFunc { workspace.GitCommitName, workspace.GitCommitEmail, ); err != nil { + log.Error("failed to setup git repository", + "error", err.Error(), + ) respondError(w, "Failed to setup git repo: "+err.Error(), http.StatusInternalServerError) return } - } else { + log.Debug("disabling git repository") h.Storage.DisableGitRepo(ctx.UserID, ctx.Workspace.ID) } } if err := h.DB.UpdateWorkspace(&workspace); err != nil { + log.Error("failed to update workspace in database", + "error", err.Error(), + ) respondError(w, "Failed to update workspace", http.StatusInternalServerError) return } + log.Debug("workspace updated", + "changes", changes, + ) respondJSON(w, workspace) } } @@ -241,15 +323,25 @@ func (h *Handler) DeleteWorkspace() http.HandlerFunc { if !ok { return } + log := getWorkspaceLogger().With( + "handler", "DeleteWorkspace", + "userID", ctx.UserID, + "workspaceID", ctx.Workspace.ID, + "clientIP", r.RemoteAddr, + ) // Check if this is the user's last workspace workspaces, err := h.DB.GetWorkspacesByUserID(ctx.UserID) if err != nil { + log.Error("failed to fetch workspaces from database", + "error", err.Error(), + ) respondError(w, "Failed to get workspaces", http.StatusInternalServerError) return } if len(workspaces) <= 1 { + log.Debug("attempted to delete last workspace") respondError(w, "Cannot delete the last workspace", http.StatusBadRequest) return } @@ -265,14 +357,19 @@ func (h *Handler) DeleteWorkspace() http.HandlerFunc { } } - // Start transaction tx, err := h.DB.Begin() if err != nil { + log.Error("failed to start database transaction", + "error", err.Error(), + ) respondError(w, "Failed to start transaction", http.StatusInternalServerError) return } defer func() { if err := tx.Rollback(); err != nil && err != sql.ErrTxDone { + log.Error("failed to rollback transaction", + "error", err.Error(), + ) respondError(w, "Failed to rollback transaction", http.StatusInternalServerError) } }() @@ -280,6 +377,10 @@ func (h *Handler) DeleteWorkspace() http.HandlerFunc { // Update last workspace ID first err = h.DB.UpdateLastWorkspaceTx(tx, ctx.UserID, nextWorkspaceID) if err != nil { + log.Error("failed to update last workspace reference", + "error", err.Error(), + "nextWorkspaceID", nextWorkspaceID, + ) respondError(w, "Failed to update last workspace", http.StatusInternalServerError) return } @@ -287,16 +388,27 @@ func (h *Handler) DeleteWorkspace() http.HandlerFunc { // Delete the workspace err = h.DB.DeleteWorkspaceTx(tx, ctx.Workspace.ID) if err != nil { + log.Error("failed to delete workspace from database", + "error", err.Error(), + ) respondError(w, "Failed to delete workspace", http.StatusInternalServerError) return } // Commit transaction if err = tx.Commit(); err != nil { + log.Error("failed to commit transaction", + "error", err.Error(), + ) respondError(w, "Failed to commit transaction", http.StatusInternalServerError) return } + log.Info("workspace deleted", + "workspaceName", ctx.Workspace.Name, + "nextWorkspaceName", nextWorkspaceName, + ) + // Return the next workspace ID in the response so frontend knows where to redirect respondJSON(w, &DeleteWorkspaceResponse{NextWorkspaceName: nextWorkspaceName}) } @@ -318,13 +430,24 @@ func (h *Handler) GetLastWorkspaceName() http.HandlerFunc { if !ok { return } + log := getWorkspaceLogger().With( + "handler", "GetLastWorkspaceName", + "userID", ctx.UserID, + "clientIP", r.RemoteAddr, + ) workspaceName, err := h.DB.GetLastWorkspaceName(ctx.UserID) if err != nil { + log.Error("failed to fetch last workspace name", + "error", err.Error(), + ) respondError(w, "Failed to get last workspace", http.StatusInternalServerError) return } + log.Debug("last workspace name retrieved", + "workspaceName", workspaceName, + ) respondJSON(w, &LastWorkspaceNameResponse{LastWorkspaceName: workspaceName}) } } @@ -347,21 +470,36 @@ func (h *Handler) UpdateLastWorkspaceName() http.HandlerFunc { if !ok { return } + log := getWorkspaceLogger().With( + "handler", "UpdateLastWorkspaceName", + "userID", ctx.UserID, + "clientIP", r.RemoteAddr, + ) var requestBody struct { WorkspaceName string `json:"workspaceName"` } if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + log.Debug("invalid request body received", + "error", err.Error(), + ) respondError(w, "Invalid request body", http.StatusBadRequest) return } if err := h.DB.UpdateLastWorkspace(ctx.UserID, requestBody.WorkspaceName); err != nil { + log.Error("failed to update last workspace", + "error", err.Error(), + "workspaceName", requestBody.WorkspaceName, + ) respondError(w, "Failed to update last workspace", http.StatusInternalServerError) return } + log.Debug("last workspace name updated", + "workspaceName", requestBody.WorkspaceName, + ) w.WriteHeader(http.StatusNoContent) } }