Update api docs

This commit is contained in:
2024-12-03 21:50:16 +01:00
parent c400d81c87
commit e413e955c5
17 changed files with 331 additions and 261 deletions

View File

@@ -8,6 +8,7 @@ import (
"time"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/go-git/go-git/v5/plumbing/transport/http"
)
@@ -26,11 +27,19 @@ type Config struct {
type Client interface {
Clone() error
Pull() error
Commit(message string) error
Commit(message string) (CommitHash, error)
Push() error
EnsureRepo() error
}
// CommitHash represents a Git commit hash
type CommitHash plumbing.Hash
// String returns the string representation of the CommitHash
func (h CommitHash) String() string {
return plumbing.Hash(h).String()
}
// client implements the Client interface
type client struct {
Config
@@ -101,22 +110,22 @@ func (c *client) Pull() error {
}
// Commit commits the changes in the repository with the given message
func (c *client) Commit(message string) error {
func (c *client) Commit(message string) (CommitHash, error) {
if c.repo == nil {
return fmt.Errorf("repository not initialized")
return CommitHash(plumbing.ZeroHash), fmt.Errorf("repository not initialized")
}
w, err := c.repo.Worktree()
if err != nil {
return fmt.Errorf("failed to get worktree: %w", err)
return CommitHash(plumbing.ZeroHash), fmt.Errorf("failed to get worktree: %w", err)
}
_, err = w.Add(".")
if err != nil {
return fmt.Errorf("failed to add changes: %w", err)
return CommitHash(plumbing.ZeroHash), fmt.Errorf("failed to add changes: %w", err)
}
_, err = w.Commit(message, &git.CommitOptions{
hash, err := w.Commit(message, &git.CommitOptions{
Author: &object.Signature{
Name: c.CommitName,
Email: c.CommitEmail,
@@ -124,10 +133,10 @@ func (c *client) Commit(message string) error {
},
})
if err != nil {
return fmt.Errorf("failed to commit changes: %w", err)
return CommitHash(plumbing.ZeroHash), fmt.Errorf("failed to commit changes: %w", err)
}
return nil
return CommitHash(hash), nil
}
// Push pushes the changes to the remote repository

View File

@@ -55,13 +55,13 @@ type SystemStats struct {
// @ID adminListUsers
// @Produce json
// @Success 200 {array} models.User
// @Failure 500 {string} "Failed to list users"
// @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) {
users, err := h.DB.GetAllUsers()
if err != nil {
http.Error(w, "Failed to list users", http.StatusInternalServerError)
respondError(w, "Failed to list users", http.StatusInternalServerError)
return
}
@@ -79,45 +79,45 @@ func (h *Handler) AdminListUsers() http.HandlerFunc {
// @Produce json
// @Param user body CreateUserRequest true "User details"
// @Success 200 {object} models.User
// @Failure 400 {string} "Invalid request body"
// @Failure 400 {string} "Email, password, and role are required"
// @Failure 400 {string} "Password must be at least 8 characters"
// @Failure 409 {string} "Email already exists"
// @Failure 500 {string} "Failed to hash password"
// @Failure 500 {string} "Failed to create user"
// @Failure 500 {string} "Failed to initialize user workspace"
// @Failure 400 {object} ErrorResponse "Invalid request body"
// @Failure 400 {object} ErrorResponse "Email, password, and role are required"
// @Failure 400 {object} ErrorResponse "Password must be at least 8 characters"
// @Failure 409 {object} ErrorResponse "Email already exists"
// @Failure 500 {object} ErrorResponse "Failed to hash password"
// @Failure 500 {object} ErrorResponse "Failed to create user"
// @Failure 500 {object} ErrorResponse "Failed to initialize user workspace"
// @Router /admin/users [post]
func (h *Handler) AdminCreateUser() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req CreateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
respondError(w, "Invalid request body", http.StatusBadRequest)
return
}
// Validate request
if req.Email == "" || req.Password == "" || req.Role == "" {
http.Error(w, "Email, password, and role are required", http.StatusBadRequest)
respondError(w, "Email, password, and role are required", http.StatusBadRequest)
return
}
// Check if email already exists
existingUser, err := h.DB.GetUserByEmail(req.Email)
if err == nil && existingUser != nil {
http.Error(w, "Email already exists", http.StatusConflict)
respondError(w, "Email already exists", http.StatusConflict)
return
}
// Check if password is long enough
if len(req.Password) < 8 {
http.Error(w, "Password must be at least 8 characters", http.StatusBadRequest)
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 {
http.Error(w, "Failed to hash password", http.StatusInternalServerError)
respondError(w, "Failed to hash password", http.StatusInternalServerError)
return
}
@@ -131,13 +131,13 @@ func (h *Handler) AdminCreateUser() http.HandlerFunc {
insertedUser, err := h.DB.CreateUser(user)
if err != nil {
http.Error(w, "Failed to create user", http.StatusInternalServerError)
respondError(w, "Failed to create user", http.StatusInternalServerError)
return
}
// Initialize user workspace
if err := h.Storage.InitializeUserWorkspace(insertedUser.ID, insertedUser.LastWorkspaceID); err != nil {
http.Error(w, "Failed to initialize user workspace", http.StatusInternalServerError)
respondError(w, "Failed to initialize user workspace", http.StatusInternalServerError)
return
}
@@ -154,20 +154,20 @@ func (h *Handler) AdminCreateUser() http.HandlerFunc {
// @Produce json
// @Param userId path int true "User ID"
// @Success 200 {object} models.User
// @Failure 400 {string} "Invalid user ID"
// @Failure 404 {string} "User not found"
// @Failure 400 {object} ErrorResponse "Invalid user ID"
// @Failure 404 {object} ErrorResponse "User not found"
// @Router /admin/users/{userId} [get]
func (h *Handler) AdminGetUser() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, err := strconv.Atoi(chi.URLParam(r, "userId"))
if err != nil {
http.Error(w, "Invalid user ID", http.StatusBadRequest)
respondError(w, "Invalid user ID", http.StatusBadRequest)
return
}
user, err := h.DB.GetUserByID(userID)
if err != nil {
http.Error(w, "User not found", http.StatusNotFound)
respondError(w, "User not found", http.StatusNotFound)
return
}
@@ -186,30 +186,30 @@ func (h *Handler) AdminGetUser() http.HandlerFunc {
// @Param userId path int true "User ID"
// @Param user body UpdateUserRequest true "User details"
// @Success 200 {object} models.User
// @Failure 400 {string} "Invalid user ID"
// @Failure 400 {string} "Invalid request body"
// @Failure 404 {string} "User not found"
// @Failure 500 {string} "Failed to hash password"
// @Failure 500 {string} "Failed to update user"
// @Failure 400 {object} ErrorResponse "Invalid user ID"
// @Failure 400 {object} ErrorResponse "Invalid request body"
// @Failure 404 {object} ErrorResponse "User not found"
// @Failure 500 {object} ErrorResponse "Failed to hash password"
// @Failure 500 {object} ErrorResponse "Failed to update user"
// @Router /admin/users/{userId} [put]
func (h *Handler) AdminUpdateUser() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, err := strconv.Atoi(chi.URLParam(r, "userId"))
if err != nil {
http.Error(w, "Invalid user ID", http.StatusBadRequest)
respondError(w, "Invalid user ID", http.StatusBadRequest)
return
}
// Get existing user
user, err := h.DB.GetUserByID(userID)
if err != nil {
http.Error(w, "User not found", http.StatusNotFound)
respondError(w, "User not found", http.StatusNotFound)
return
}
var req UpdateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
respondError(w, "Invalid request body", http.StatusBadRequest)
return
}
@@ -226,14 +226,14 @@ func (h *Handler) AdminUpdateUser() http.HandlerFunc {
if req.Password != "" {
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
http.Error(w, "Failed to hash password", http.StatusInternalServerError)
respondError(w, "Failed to hash password", http.StatusInternalServerError)
return
}
user.PasswordHash = string(hashedPassword)
}
if err := h.DB.UpdateUser(user); err != nil {
http.Error(w, "Failed to update user", http.StatusInternalServerError)
respondError(w, "Failed to update user", http.StatusInternalServerError)
return
}
@@ -249,11 +249,11 @@ func (h *Handler) AdminUpdateUser() http.HandlerFunc {
// @ID adminDeleteUser
// @Param userId path int true "User ID"
// @Success 204 "No Content"
// @Failure 400 {string} "Invalid user ID"
// @Failure 400 {string} "Cannot delete your own account"
// @Failure 403 {string} "Cannot delete other admin users"
// @Failure 404 {string} "User not found"
// @Failure 500 {string} "Failed to delete user"
// @Failure 400 {object} ErrorResponse "Invalid user ID"
// @Failure 400 {object} ErrorResponse "Cannot delete your own account"
// @Failure 403 {object} ErrorResponse "Cannot delete other admin users"
// @Failure 404 {object} ErrorResponse "User not found"
// @Failure 500 {object} ErrorResponse "Failed to delete user"
// @Router /admin/users/{userId} [delete]
func (h *Handler) AdminDeleteUser() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
@@ -264,31 +264,31 @@ func (h *Handler) AdminDeleteUser() http.HandlerFunc {
userID, err := strconv.Atoi(chi.URLParam(r, "userId"))
if err != nil {
http.Error(w, "Invalid user ID", http.StatusBadRequest)
respondError(w, "Invalid user ID", http.StatusBadRequest)
return
}
// Prevent admin from deleting themselves
if userID == ctx.UserID {
http.Error(w, "Cannot delete your own account", http.StatusBadRequest)
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 {
http.Error(w, "User not found", http.StatusNotFound)
respondError(w, "User not found", http.StatusNotFound)
return
}
// Prevent deletion of other admin users
if user.Role == models.RoleAdmin && ctx.UserID != userID {
http.Error(w, "Cannot delete other admin users", http.StatusForbidden)
respondError(w, "Cannot delete other admin users", http.StatusForbidden)
return
}
if err := h.DB.DeleteUser(userID); err != nil {
http.Error(w, "Failed to delete user", http.StatusInternalServerError)
respondError(w, "Failed to delete user", http.StatusInternalServerError)
return
}
@@ -304,15 +304,15 @@ func (h *Handler) AdminDeleteUser() http.HandlerFunc {
// @ID adminListWorkspaces
// @Produce json
// @Success 200 {array} WorkspaceStats
// @Failure 500 {string} "Failed to list workspaces"
// @Failure 500 {string} "Failed to get user"
// @Failure 500 {string} "Failed to get file stats"
// @Failure 500 {object} ErrorResponse "Failed to list workspaces"
// @Failure 500 {object} ErrorResponse "Failed to get user"
// @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) {
workspaces, err := h.DB.GetAllWorkspaces()
if err != nil {
http.Error(w, "Failed to list workspaces", http.StatusInternalServerError)
respondError(w, "Failed to list workspaces", http.StatusInternalServerError)
return
}
@@ -324,7 +324,7 @@ func (h *Handler) AdminListWorkspaces() http.HandlerFunc {
user, err := h.DB.GetUserByID(ws.UserID)
if err != nil {
http.Error(w, "Failed to get user", http.StatusInternalServerError)
respondError(w, "Failed to get user", http.StatusInternalServerError)
return
}
@@ -336,7 +336,7 @@ func (h *Handler) AdminListWorkspaces() http.HandlerFunc {
fileStats, err := h.Storage.GetFileStats(ws.UserID, ws.ID)
if err != nil {
http.Error(w, "Failed to get file stats", http.StatusInternalServerError)
respondError(w, "Failed to get file stats", http.StatusInternalServerError)
return
}
@@ -357,20 +357,20 @@ func (h *Handler) AdminListWorkspaces() http.HandlerFunc {
// @ID adminGetSystemStats
// @Produce json
// @Success 200 {object} SystemStats
// @Failure 500 {string} "Failed to get user stats"
// @Failure 500 {string} "Failed to get file stats"
// @Failure 500 {object} ErrorResponse "Failed to get user stats"
// @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) {
userStats, err := h.DB.GetSystemStats()
if err != nil {
http.Error(w, "Failed to get user stats", http.StatusInternalServerError)
respondError(w, "Failed to get user stats", http.StatusInternalServerError)
return
}
fileStats, err := h.Storage.GetTotalFileStats()
if err != nil {
http.Error(w, "Failed to get file stats", http.StatusInternalServerError)
respondError(w, "Failed to get file stats", http.StatusInternalServerError)
return
}

View File

@@ -43,43 +43,43 @@ type RefreshResponse struct {
// @Produce json
// @Param body body LoginRequest true "Login request"
// @Success 200 {object} LoginResponse
// @Failure 400 {string} string "Invalid request body"
// @Failure 400 {string} string "Email and password are required"
// @Failure 401 {string} string "Invalid credentials"
// @Failure 500 {string} string "Failed to create session"
// @Failure 400 {object} ErrorResponse "Invalid request body"
// @Failure 400 {object} ErrorResponse "Email and password are required"
// @Failure 401 {object} ErrorResponse "Invalid credentials"
// @Failure 500 {object} ErrorResponse "Failed to create session"
// @Router /auth/login [post]
func (h *Handler) Login(authService *auth.SessionService) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req LoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
respondError(w, "Invalid request body", http.StatusBadRequest)
return
}
// Validate request
if req.Email == "" || req.Password == "" {
http.Error(w, "Email and password are required", http.StatusBadRequest)
respondError(w, "Email and password are required", http.StatusBadRequest)
return
}
// Get user from database
user, err := h.DB.GetUserByEmail(req.Email)
if err != nil {
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
respondError(w, "Invalid credentials", http.StatusUnauthorized)
return
}
// Verify password
err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password))
if err != nil {
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
respondError(w, "Invalid credentials", http.StatusUnauthorized)
return
}
// Create session and generate tokens
session, accessToken, err := authService.CreateSession(user.ID, string(user.Role))
if err != nil {
http.Error(w, "Failed to create session", http.StatusInternalServerError)
respondError(w, "Failed to create session", http.StatusInternalServerError)
return
}
@@ -101,25 +101,25 @@ func (h *Handler) Login(authService *auth.SessionService) http.HandlerFunc {
// @Tags auth
// @ID logout
// @Security BearerAuth
// @Success 200 {string} string "OK"
// @Failure 400 {string} string "Session ID required"
// @Failure 500 {string} string "Failed to logout"
// @Success 204 "No Content"
// @Failure 400 {object} ErrorResponse "Session ID required"
// @Failure 500 {object} ErrorResponse "Failed to logout"
// @Router /auth/logout [post]
func (h *Handler) Logout(authService *auth.SessionService) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
sessionID := r.Header.Get("X-Session-ID")
if sessionID == "" {
http.Error(w, "Session ID required", http.StatusBadRequest)
respondError(w, "Session ID required", http.StatusBadRequest)
return
}
err := authService.InvalidateSession(sessionID)
if err != nil {
http.Error(w, "Failed to logout", http.StatusInternalServerError)
respondError(w, "Failed to logout", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
w.WriteHeader(http.StatusNoContent)
}
}
@@ -132,27 +132,27 @@ func (h *Handler) Logout(authService *auth.SessionService) http.HandlerFunc {
// @Produce json
// @Param body body RefreshRequest true "Refresh request"
// @Success 200 {object} RefreshResponse
// @Failure 400 {string} string "Invalid request body"
// @Failure 400 {string} string "Refresh token required"
// @Failure 401 {string} string "Invalid refresh token"
// @Failure 400 {object} ErrorResponse "Invalid request body"
// @Failure 400 {object} ErrorResponse "Refresh token required"
// @Failure 401 {object} ErrorResponse "Invalid refresh token"
// @Router /auth/refresh [post]
func (h *Handler) RefreshToken(authService *auth.SessionService) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req RefreshRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
respondError(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.RefreshToken == "" {
http.Error(w, "Refresh token required", http.StatusBadRequest)
respondError(w, "Refresh token required", http.StatusBadRequest)
return
}
// Generate new access token
accessToken, err := authService.RefreshSession(req.RefreshToken)
if err != nil {
http.Error(w, "Invalid refresh token", http.StatusUnauthorized)
respondError(w, "Invalid refresh token", http.StatusUnauthorized)
return
}
@@ -172,7 +172,7 @@ func (h *Handler) RefreshToken(authService *auth.SessionService) http.HandlerFun
// @Security BearerAuth
// @Produce json
// @Success 200 {object} models.User
// @Failure 404 {string} string "User not found"
// @Failure 404 {object} ErrorResponse "User not found"
// @Router /auth/me [get]
func (h *Handler) GetCurrentUser() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
@@ -184,7 +184,7 @@ func (h *Handler) GetCurrentUser() http.HandlerFunc {
// Get user from database
user, err := h.DB.GetUserByID(ctx.UserID)
if err != nil {
http.Error(w, "User not found", http.StatusNotFound)
respondError(w, "User not found", http.StatusNotFound)
return
}

View File

@@ -188,7 +188,7 @@ func TestAuthHandlers_Integration(t *testing.T) {
"X-Session-ID": loginResp.Session.ID,
}
rr = h.makeRequest(t, http.MethodPost, "/api/v1/auth/logout", nil, loginResp.AccessToken, headers)
require.Equal(t, http.StatusOK, rr.Code)
require.Equal(t, http.StatusNoContent, rr.Code)
// Try to use the refresh token - should fail
refreshReq := handlers.RefreshRequest{

View File

@@ -5,6 +5,7 @@ import (
"io"
"net/http"
"os"
"time"
"novamd/internal/context"
"novamd/internal/storage"
@@ -12,6 +13,28 @@ import (
"github.com/go-chi/chi/v5"
)
// LookupResponse represents a response to a file lookup request
type LookupResponse struct {
Paths []string `json:"paths"`
}
// SaveFileResponse represents a response to a save file request
type SaveFileResponse struct {
FilePath string `json:"filePath"`
Size int64 `json:"size"`
UpdatedAt time.Time `json:"updatedAt"`
}
// LastOpenedFileResponse represents a response to a last opened file request
type LastOpenedFileResponse struct {
LastOpenedFilePath string `json:"lastOpenedFilePath"`
}
// UpdateLastOpenedFileRequest represents a request to update the last opened file
type UpdateLastOpenedFileRequest struct {
FilePath string `json:"filePath"`
}
// ListFiles godoc
// @Summary List files
// @Description Lists all files in the user's workspace
@@ -20,8 +43,8 @@ import (
// @Security BearerAuth
// @Produce json
// @Param workspace_name path string true "Workspace name"
// @Success 200 {array} string
// @Failure 500 {string} string "Failed to list files"
// @Success 200 {array} storage.FileNode
// @Failure 500 {object} ErrorResponse "Failed to list files"
// @Router /workspaces/{workspace_name}/files [get]
func (h *Handler) ListFiles() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
@@ -32,7 +55,7 @@ func (h *Handler) ListFiles() http.HandlerFunc {
files, err := h.Storage.ListFilesRecursively(ctx.UserID, ctx.Workspace.ID)
if err != nil {
http.Error(w, "Failed to list files", http.StatusInternalServerError)
respondError(w, "Failed to list files", http.StatusInternalServerError)
return
}
@@ -49,9 +72,9 @@ func (h *Handler) ListFiles() http.HandlerFunc {
// @Produce json
// @Param workspace_name path string true "Workspace name"
// @Param filename query string true "File name"
// @Success 200 {object} map[string][]string
// @Failure 400 {string} string "Filename is required"
// @Failure 404 {string} string "File not found"
// @Success 200 {object} LookupResponse
// @Failure 400 {object} ErrorResponse "Filename is required"
// @Failure 404 {object} ErrorResponse "File not found"
// @Router /workspaces/{workspace_name}/files/lookup [get]
func (h *Handler) LookupFileByName() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
@@ -62,17 +85,17 @@ func (h *Handler) LookupFileByName() http.HandlerFunc {
filename := r.URL.Query().Get("filename")
if filename == "" {
http.Error(w, "Filename is required", http.StatusBadRequest)
respondError(w, "Filename is required", http.StatusBadRequest)
return
}
filePaths, err := h.Storage.FindFileByName(ctx.UserID, ctx.Workspace.ID, filename)
if err != nil {
http.Error(w, "File not found", http.StatusNotFound)
respondError(w, "File not found", http.StatusNotFound)
return
}
respondJSON(w, map[string][]string{"paths": filePaths})
respondJSON(w, &LookupResponse{Paths: filePaths})
}
}
@@ -86,10 +109,10 @@ func (h *Handler) LookupFileByName() http.HandlerFunc {
// @Param workspace_name path string true "Workspace name"
// @Param file_path path string true "File path"
// @Success 200 {string} "File content"
// @Failure 400 {string} string "Invalid file path"
// @Failure 404 {string} string "File not found"
// @Failure 500 {string} string "Failed to read file"
// @Failure 500 {string} string "Failed to write response"
// @Failure 400 {object} ErrorResponse "Invalid file path"
// @Failure 404 {object} ErrorResponse "File not found"
// @Failure 500 {object} ErrorResponse "Failed to read file"
// @Failure 500 {object} ErrorResponse "Failed to write response"
// @Router /workspaces/{workspace_name}/files/* [get]
func (h *Handler) GetFileContent() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
@@ -103,23 +126,23 @@ func (h *Handler) GetFileContent() http.HandlerFunc {
if err != nil {
if storage.IsPathValidationError(err) {
http.Error(w, "Invalid file path", http.StatusBadRequest)
respondError(w, "Invalid file path", http.StatusBadRequest)
return
}
if os.IsNotExist(err) {
http.Error(w, "File not found", http.StatusNotFound)
respondError(w, "File not found", http.StatusNotFound)
return
}
http.Error(w, "Failed to read file", http.StatusInternalServerError)
respondError(w, "Failed to read file", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/plain")
_, err = w.Write(content)
if err != nil {
http.Error(w, "Failed to write response", http.StatusInternalServerError)
respondError(w, "Failed to write response", http.StatusInternalServerError)
return
}
}
@@ -135,10 +158,10 @@ func (h *Handler) GetFileContent() http.HandlerFunc {
// @Produce json
// @Param workspace_name path string true "Workspace name"
// @Param file_path path string true "File path"
// @Success 200 {string} "File saved successfully"
// @Failure 400 {string} string "Failed to read request body"
// @Failure 400 {string} string "Invalid file path"
// @Failure 500 {string} string "Failed to save file"
// @Success 200 {object} SaveFileResponse
// @Failure 400 {object} ErrorResponse "Failed to read request body"
// @Failure 400 {object} ErrorResponse "Invalid file path"
// @Failure 500 {object} ErrorResponse "Failed to save file"
// @Router /workspaces/{workspace_name}/files/* [post]
func (h *Handler) SaveFile() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
@@ -150,22 +173,29 @@ func (h *Handler) SaveFile() http.HandlerFunc {
filePath := chi.URLParam(r, "*")
content, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read request body", http.StatusBadRequest)
respondError(w, "Failed to read request body", http.StatusBadRequest)
return
}
err = h.Storage.SaveFile(ctx.UserID, ctx.Workspace.ID, filePath, content)
if err != nil {
if storage.IsPathValidationError(err) {
http.Error(w, "Invalid file path", http.StatusBadRequest)
respondError(w, "Invalid file path", http.StatusBadRequest)
return
}
http.Error(w, "Failed to save file", http.StatusInternalServerError)
respondError(w, "Failed to save file", http.StatusInternalServerError)
return
}
respondJSON(w, map[string]string{"message": "File saved successfully"})
response := SaveFileResponse{
FilePath: filePath,
Size: int64(len(content)),
UpdatedAt: time.Now().UTC(),
}
w.WriteHeader(http.StatusOK)
respondJSON(w, response)
}
}
@@ -178,11 +208,11 @@ func (h *Handler) SaveFile() http.HandlerFunc {
// @Produce string
// @Param workspace_name path string true "Workspace name"
// @Param file_path path string true "File path"
// @Success 200 {string} "File deleted successfully"
// @Failure 400 {string} string "Invalid file path"
// @Failure 404 {string} string "File not found"
// @Failure 500 {string} string "Failed to delete file"
// @Failure 500 {string} string "Failed to write response"
// @Success 204 "No Content - File deleted successfully"
// @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/* [delete]
func (h *Handler) DeleteFile() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
@@ -195,25 +225,20 @@ func (h *Handler) DeleteFile() http.HandlerFunc {
err := h.Storage.DeleteFile(ctx.UserID, ctx.Workspace.ID, filePath)
if err != nil {
if storage.IsPathValidationError(err) {
http.Error(w, "Invalid file path", http.StatusBadRequest)
respondError(w, "Invalid file path", http.StatusBadRequest)
return
}
if os.IsNotExist(err) {
http.Error(w, "File not found", http.StatusNotFound)
respondError(w, "File not found", http.StatusNotFound)
return
}
http.Error(w, "Failed to delete file", http.StatusInternalServerError)
respondError(w, "Failed to delete file", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
_, err = w.Write([]byte("File deleted successfully"))
if err != nil {
http.Error(w, "Failed to write response", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
@@ -225,9 +250,9 @@ func (h *Handler) DeleteFile() http.HandlerFunc {
// @Security BearerAuth
// @Produce json
// @Param workspace_name path string true "Workspace name"
// @Success 200 {object} map[string]string
// @Failure 400 {string} string "Invalid file path"
// @Failure 500 {string} string "Failed to get last opened file"
// @Success 200 {object} LastOpenedFileResponse
// @Failure 400 {object} ErrorResponse "Invalid file path"
// @Failure 500 {object} ErrorResponse "Failed to get last opened file"
// @Router /workspaces/{workspace_name}/files/last [get]
func (h *Handler) GetLastOpenedFile() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
@@ -238,16 +263,16 @@ func (h *Handler) GetLastOpenedFile() http.HandlerFunc {
filePath, err := h.DB.GetLastOpenedFile(ctx.Workspace.ID)
if err != nil {
http.Error(w, "Failed to get last opened file", http.StatusInternalServerError)
respondError(w, "Failed to get last opened file", http.StatusInternalServerError)
return
}
if _, err := h.Storage.ValidatePath(ctx.UserID, ctx.Workspace.ID, filePath); err != nil {
http.Error(w, "Invalid file path", http.StatusBadRequest)
respondError(w, "Invalid file path", http.StatusBadRequest)
return
}
respondJSON(w, map[string]string{"lastOpenedFilePath": filePath})
respondJSON(w, &LastOpenedFileResponse{LastOpenedFilePath: filePath})
}
}
@@ -260,11 +285,12 @@ func (h *Handler) GetLastOpenedFile() http.HandlerFunc {
// @Accept json
// @Produce json
// @Param workspace_name path string true "Workspace name"
// @Success 200 {object} map[string]string
// @Failure 400 {string} string "Invalid request body"
// @Failure 400 {string} string "Invalid file path"
// @Failure 404 {string} string "File not found"
// @Failure 500 {string} string "Failed to update file"
// @Param body body UpdateLastOpenedFileRequest true "Update last opened file request"
// @Success 204 "No Content - Last opened file updated successfully"
// @Failure 400 {object} ErrorResponse "Invalid request body"
// @Failure 400 {object} ErrorResponse "Invalid file path"
// @Failure 404 {object} ErrorResponse "File not found"
// @Failure 500 {object} ErrorResponse "Failed to update file"
// @Router /workspaces/{workspace_name}/files/last [put]
func (h *Handler) UpdateLastOpenedFile() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
@@ -273,12 +299,10 @@ func (h *Handler) UpdateLastOpenedFile() http.HandlerFunc {
return
}
var requestBody struct {
FilePath string `json:"filePath"`
}
var requestBody UpdateLastOpenedFileRequest
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
respondError(w, "Invalid request body", http.StatusBadRequest)
return
}
@@ -287,25 +311,25 @@ func (h *Handler) UpdateLastOpenedFile() http.HandlerFunc {
_, err := h.Storage.GetFileContent(ctx.UserID, ctx.Workspace.ID, requestBody.FilePath)
if err != nil {
if storage.IsPathValidationError(err) {
http.Error(w, "Invalid file path", http.StatusBadRequest)
respondError(w, "Invalid file path", http.StatusBadRequest)
return
}
if os.IsNotExist(err) {
http.Error(w, "File not found", http.StatusNotFound)
respondError(w, "File not found", http.StatusNotFound)
return
}
http.Error(w, "Failed to update last opened file", http.StatusInternalServerError)
respondError(w, "Failed to update last opened file", http.StatusInternalServerError)
return
}
}
if err := h.DB.UpdateLastOpenedFile(ctx.Workspace.ID, requestBody.FilePath); err != nil {
http.Error(w, "Failed to update last opened file", http.StatusInternalServerError)
respondError(w, "Failed to update last opened file", http.StatusInternalServerError)
return
}
respondJSON(w, map[string]string{"message": "Last opened file updated successfully"})
w.WriteHeader(http.StatusNoContent)
}
}

View File

@@ -145,7 +145,7 @@ func TestFileHandlers_Integration(t *testing.T) {
// Delete file
rr = h.makeRequest(t, http.MethodDelete, baseURL+"/"+filePath, nil, h.RegularToken, nil)
require.Equal(t, http.StatusOK, rr.Code)
require.Equal(t, http.StatusNoContent, rr.Code)
// Verify file is gone
rr = h.makeRequest(t, http.MethodGet, baseURL+"/"+filePath, nil, h.RegularToken, nil)
@@ -171,7 +171,7 @@ func TestFileHandlers_Integration(t *testing.T) {
FilePath: "docs/readme.md",
}
rr = h.makeRequest(t, http.MethodPut, baseURL+"/last", updateReq, h.RegularToken, nil)
require.Equal(t, http.StatusOK, rr.Code)
require.Equal(t, http.StatusNoContent, rr.Code)
// Verify update
rr = h.makeRequest(t, http.MethodGet, baseURL+"/last", nil, h.RegularToken, nil)

View File

@@ -7,6 +7,21 @@ import (
"novamd/internal/context"
)
// CommitRequest represents a request to commit changes
type CommitRequest struct {
Message string `json:"message" example:"Initial commit"`
}
// CommitResponse represents a response to a commit request
type CommitResponse struct {
CommitHash string `json:"commitHash" example:"a1b2c3d4"`
}
// PullResponse represents a response to a pull http request
type PullResponse struct {
Message string `json:"message" example:"Pulled changes from remote"`
}
// StageCommitAndPush godoc
// @Summary Stage, commit, and push changes
// @Description Stages, commits, and pushes changes to the remote repository
@@ -15,11 +30,11 @@ import (
// @Security BearerAuth
// @Produce json
// @Param workspace_name path string true "Workspace name"
// @Param body body string true "Commit message"
// @Success 200 {object} map[string]string
// @Failure 400 {string} string "Invalid request body"
// @Failure 400 {string} string "Commit message is required"
// @Failure 500 {string} string "Failed to stage, commit, and push changes"
// @Param body body CommitRequest true "Commit request"
// @Success 200 {object} CommitResponse
// @Failure 400 {object} ErrorResponse "Invalid request body"
// @Failure 400 {object} ErrorResponse "Commit message is required"
// @Failure 500 {object} ErrorResponse "Failed to stage, commit, and push changes"
// @Router /workspaces/{workspace_name}/git/commit [post]
func (h *Handler) StageCommitAndPush() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
@@ -28,27 +43,25 @@ func (h *Handler) StageCommitAndPush() http.HandlerFunc {
return
}
var requestBody struct {
Message string `json:"message"`
}
var requestBody CommitRequest
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
respondError(w, "Invalid request body", http.StatusBadRequest)
return
}
if requestBody.Message == "" {
http.Error(w, "Commit message is required", http.StatusBadRequest)
respondError(w, "Commit message is required", http.StatusBadRequest)
return
}
err := h.Storage.StageCommitAndPush(ctx.UserID, ctx.Workspace.ID, requestBody.Message)
hash, err := h.Storage.StageCommitAndPush(ctx.UserID, ctx.Workspace.ID, requestBody.Message)
if err != nil {
http.Error(w, "Failed to stage, commit, and push changes: "+err.Error(), http.StatusInternalServerError)
respondError(w, "Failed to stage, commit, and push changes: "+err.Error(), http.StatusInternalServerError)
return
}
respondJSON(w, map[string]string{"message": "Changes staged, committed, and pushed successfully"})
respondJSON(w, CommitResponse{CommitHash: hash.String()})
}
}
@@ -60,8 +73,8 @@ func (h *Handler) StageCommitAndPush() http.HandlerFunc {
// @Security BearerAuth
// @Produce json
// @Param workspace_name path string true "Workspace name"
// @Success 200 {object} map[string]string
// @Failure 500 {string} string "Failed to pull changes"
// @Success 200 {object} PullResponse
// @Failure 500 {object} ErrorResponse "Failed to pull changes"
// @Router /workspaces/{workspace_name}/git/pull [post]
func (h *Handler) PullChanges() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
@@ -72,10 +85,10 @@ func (h *Handler) PullChanges() http.HandlerFunc {
err := h.Storage.Pull(ctx.UserID, ctx.Workspace.ID)
if err != nil {
http.Error(w, "Failed to pull changes: "+err.Error(), http.StatusInternalServerError)
respondError(w, "Failed to pull changes: "+err.Error(), http.StatusInternalServerError)
return
}
respondJSON(w, map[string]string{"message": "Pulled changes from remote"})
respondJSON(w, PullResponse{Message: "Successfully pulled changes from remote"})
}
}

View File

@@ -56,7 +56,7 @@ func TestGitHandlers_Integration(t *testing.T) {
var response map[string]string
err := json.NewDecoder(rr.Body).Decode(&response)
require.NoError(t, err)
assert.Contains(t, response["message"], "successfully")
require.Contains(t, response, "commitHash")
// Verify mock was called correctly
assert.Equal(t, 1, h.MockGit.GetCommitCount(), "Commit should be called once")
@@ -100,7 +100,7 @@ func TestGitHandlers_Integration(t *testing.T) {
var response map[string]string
err := json.NewDecoder(rr.Body).Decode(&response)
require.NoError(t, err)
assert.Contains(t, response["message"], "Pulled changes")
assert.Contains(t, response["message"], "Successfully pulled changes")
assert.Equal(t, 1, h.MockGit.GetPullCount(), "Pull should be called once")
})

View File

@@ -7,6 +7,11 @@ import (
"novamd/internal/storage"
)
// ErrorResponse is a generic error response
type ErrorResponse struct {
Message string `json:"message"`
}
// Handler provides common functionality for all handlers
type Handler struct {
DB db.Database
@@ -25,6 +30,12 @@ func NewHandler(db db.Database, s storage.Manager) *Handler {
func respondJSON(w http.ResponseWriter, data interface{}) {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(data); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
respondError(w, "Failed to encode response", http.StatusInternalServerError)
}
}
// respondError is a helper to send error responses
func respondError(w http.ResponseWriter, message string, code int) {
w.WriteHeader(code)
respondJSON(w, ErrorResponse{Message: message})
}

View File

@@ -4,6 +4,7 @@ package handlers_test
import (
"fmt"
"novamd/internal/git"
)
// MockGitClient implements the git.Client interface for testing
@@ -51,13 +52,13 @@ func (m *MockGitClient) Pull() error {
}
// Commit implements git.Client
func (m *MockGitClient) Commit(message string) error {
func (m *MockGitClient) Commit(message string) (git.CommitHash, error) {
if m.error != nil {
return m.error
return git.CommitHash{}, m.error
}
m.commitCount++
m.lastCommitMsg = message
return nil
return git.CommitHash{}, nil
}
// Push implements git.Client

View File

@@ -28,7 +28,7 @@ func (h *StaticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Security check to prevent directory traversal
if !strings.HasPrefix(cleanPath, h.staticPath) {
http.Error(w, "Invalid path", http.StatusBadRequest)
respondError(w, "Invalid path", http.StatusBadRequest)
return
}

View File

@@ -32,15 +32,15 @@ type DeleteAccountRequest struct {
// @Produce json
// @Param body body UpdateProfileRequest true "Profile update request"
// @Success 200 {object} models.User
// @Failure 400 {string} string "Invalid request body"
// @Failure 400 {string} string "Current password is required to change password"
// @Failure 400 {string} string "New password must be at least 8 characters long"
// @Failure 400 {string} string "Current password is required to change email"
// @Failure 401 {string} string "Current password is incorrect"
// @Failure 404 {string} string "User not found"
// @Failure 409 {string} string "Email already in use"
// @Failure 500 {string} string "Failed to process new password"
// @Failure 500 {string} string "Failed to update profile"
// @Failure 400 {object} ErrorResponse "Invalid request body"
// @Failure 400 {object} ErrorResponse "Current password is required to change password"
// @Failure 400 {object} ErrorResponse "New password must be at least 8 characters long"
// @Failure 400 {object} ErrorResponse "Current password is required to change email"
// @Failure 401 {object} ErrorResponse "Current password is incorrect"
// @Failure 404 {object} ErrorResponse "User not found"
// @Failure 409 {object} ErrorResponse "Email already in use"
// @Failure 500 {object} ErrorResponse "Failed to process new password"
// @Failure 500 {object} ErrorResponse "Failed to update profile"
// @Router /profile [put]
func (h *Handler) UpdateProfile() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
@@ -51,14 +51,14 @@ func (h *Handler) UpdateProfile() http.HandlerFunc {
var req UpdateProfileRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
respondError(w, "Invalid request body", http.StatusBadRequest)
return
}
// Get current user
user, err := h.DB.GetUserByID(ctx.UserID)
if err != nil {
http.Error(w, "User not found", http.StatusNotFound)
respondError(w, "User not found", http.StatusNotFound)
return
}
@@ -66,26 +66,26 @@ func (h *Handler) UpdateProfile() http.HandlerFunc {
if req.NewPassword != "" {
// Current password must be provided to change password
if req.CurrentPassword == "" {
http.Error(w, "Current password is required to change password", http.StatusBadRequest)
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 {
http.Error(w, "Current password is incorrect", http.StatusUnauthorized)
respondError(w, "Current password is incorrect", http.StatusUnauthorized)
return
}
// Validate new password
if len(req.NewPassword) < 8 {
http.Error(w, "New password must be at least 8 characters long", http.StatusBadRequest)
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 {
http.Error(w, "Failed to process new password", http.StatusInternalServerError)
respondError(w, "Failed to process new password", http.StatusInternalServerError)
return
}
user.PasswordHash = string(hashedPassword)
@@ -95,14 +95,14 @@ func (h *Handler) UpdateProfile() http.HandlerFunc {
if req.Email != "" && req.Email != user.Email {
// Check if email change requires password verification
if req.CurrentPassword == "" {
http.Error(w, "Current password is required to change email", http.StatusBadRequest)
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 {
http.Error(w, "Current password is incorrect", http.StatusUnauthorized)
respondError(w, "Current password is incorrect", http.StatusUnauthorized)
return
}
}
@@ -110,7 +110,7 @@ func (h *Handler) UpdateProfile() http.HandlerFunc {
// Check if new email is already in use
existingUser, err := h.DB.GetUserByEmail(req.Email)
if err == nil && existingUser.ID != user.ID {
http.Error(w, "Email already in use", http.StatusConflict)
respondError(w, "Email already in use", http.StatusConflict)
return
}
user.Email = req.Email
@@ -123,7 +123,7 @@ func (h *Handler) UpdateProfile() http.HandlerFunc {
// Update user in database
if err := h.DB.UpdateUser(user); err != nil {
http.Error(w, "Failed to update profile", http.StatusInternalServerError)
respondError(w, "Failed to update profile", http.StatusInternalServerError)
return
}
@@ -141,13 +141,13 @@ func (h *Handler) UpdateProfile() http.HandlerFunc {
// @Accept json
// @Produce json
// @Param body body DeleteAccountRequest true "Account deletion request"
// @Success 200 {object} map[string]string
// @Failure 400 {string} string "Invalid request body"
// @Failure 401 {string} string "Password is incorrect"
// @Failure 403 {string} string "Cannot delete the last admin account"
// @Failure 404 {string} string "User not found"
// @Failure 500 {string} string "Failed to verify admin status"
// @Failure 500 {string} string "Failed to delete account"
// @Success 204 "No Content - Account deleted successfully"
// @Failure 400 {object} ErrorResponse "Invalid request body"
// @Failure 401 {object} ErrorResponse "Password is incorrect"
// @Failure 403 {object} ErrorResponse "Cannot delete the last admin account"
// @Failure 404 {object} ErrorResponse "User not found"
// @Failure 500 {object} ErrorResponse "Failed to verify admin status"
// @Failure 500 {object} ErrorResponse "Failed to delete account"
// @Router /profile [delete]
func (h *Handler) DeleteAccount() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
@@ -158,20 +158,20 @@ func (h *Handler) DeleteAccount() http.HandlerFunc {
var req DeleteAccountRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
respondError(w, "Invalid request body", http.StatusBadRequest)
return
}
// Get current user
user, err := h.DB.GetUserByID(ctx.UserID)
if err != nil {
http.Error(w, "User not found", http.StatusNotFound)
respondError(w, "User not found", http.StatusNotFound)
return
}
// Verify password
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
http.Error(w, "Password is incorrect", http.StatusUnauthorized)
respondError(w, "Password is incorrect", http.StatusUnauthorized)
return
}
@@ -180,11 +180,11 @@ func (h *Handler) DeleteAccount() http.HandlerFunc {
// Count number of admin users
adminCount, err := h.DB.CountAdminUsers()
if err != nil {
http.Error(w, "Failed to verify admin status", http.StatusInternalServerError)
respondError(w, "Failed to verify admin status", http.StatusInternalServerError)
return
}
if adminCount <= 1 {
http.Error(w, "Cannot delete the last admin account", http.StatusForbidden)
respondError(w, "Cannot delete the last admin account", http.StatusForbidden)
return
}
}
@@ -192,24 +192,24 @@ func (h *Handler) DeleteAccount() http.HandlerFunc {
// Get user's workspaces for cleanup
workspaces, err := h.DB.GetWorkspacesByUserID(ctx.UserID)
if err != nil {
http.Error(w, "Failed to get user workspaces", http.StatusInternalServerError)
respondError(w, "Failed to get user workspaces", http.StatusInternalServerError)
return
}
// Delete workspace directories
for _, workspace := range workspaces {
if err := h.Storage.DeleteUserWorkspace(ctx.UserID, workspace.ID); err != nil {
http.Error(w, "Failed to delete workspace files", http.StatusInternalServerError)
respondError(w, "Failed to delete workspace files", http.StatusInternalServerError)
return
}
}
// Delete user from database (this will cascade delete workspaces and sessions)
if err := h.DB.DeleteUser(ctx.UserID); err != nil {
http.Error(w, "Failed to delete account", http.StatusInternalServerError)
respondError(w, "Failed to delete account", http.StatusInternalServerError)
return
}
respondJSON(w, map[string]string{"message": "Account deleted successfully"})
w.WriteHeader(http.StatusNoContent)
}
}

View File

@@ -191,7 +191,7 @@ func TestUserHandlers_Integration(t *testing.T) {
}
rr := h.makeRequest(t, http.MethodDelete, "/api/v1/profile", deleteReq, userToken, nil)
require.Equal(t, http.StatusOK, rr.Code)
require.Equal(t, http.StatusNoContent, rr.Code)
// Verify user is deleted
rr = h.makeRequest(t, http.MethodPost, "/api/v1/auth/login", loginReq, "", nil)

View File

@@ -9,6 +9,16 @@ import (
"novamd/internal/models"
)
// DeleteWorkspaceResponse contains the name of the next workspace after deleting the current one
type DeleteWorkspaceResponse struct {
NextWorkspaceName string `json:"nextWorkspaceName"`
}
// GetLastWorkspaceNameResponse contains the name of the last opened workspace
type GetLastWorkspaceNameResponse struct {
LastWorkspaceName string `json:"lastWorkspaceName"`
}
// ListWorkspaces godoc
// @Summary List workspaces
// @Description Lists all workspaces for the current user
@@ -17,7 +27,7 @@ import (
// @Security BearerAuth
// @Produce json
// @Success 200 {array} models.Workspace
// @Failure 500 {string} string "Failed to list workspaces"
// @Failure 500 {object} ErrorResponse "Failed to list workspaces"
// @Router /workspaces [get]
func (h *Handler) ListWorkspaces() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
@@ -28,7 +38,7 @@ func (h *Handler) ListWorkspaces() http.HandlerFunc {
workspaces, err := h.DB.GetWorkspacesByUserID(ctx.UserID)
if err != nil {
http.Error(w, "Failed to list workspaces", http.StatusInternalServerError)
respondError(w, "Failed to list workspaces", http.StatusInternalServerError)
return
}
@@ -46,11 +56,11 @@ func (h *Handler) ListWorkspaces() http.HandlerFunc {
// @Produce json
// @Param body body models.Workspace true "Workspace"
// @Success 200 {object} models.Workspace
// @Failure 400 {string} string "Invalid request body"
// @Failure 400 {string} string "Invalid workspace"
// @Failure 500 {string} string "Failed to create workspace"
// @Failure 500 {string} string "Failed to initialize workspace directory"
// @Failure 500 {string} string "Failed to setup git repo"
// @Failure 400 {object} ErrorResponse "Invalid request body"
// @Failure 400 {object} ErrorResponse "Invalid workspace"
// @Failure 500 {object} ErrorResponse "Failed to create workspace"
// @Failure 500 {object} ErrorResponse "Failed to initialize workspace directory"
// @Failure 500 {object} ErrorResponse "Failed to setup git repo"
// @Router /workspaces [post]
func (h *Handler) CreateWorkspace() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
@@ -61,23 +71,23 @@ func (h *Handler) CreateWorkspace() http.HandlerFunc {
var workspace models.Workspace
if err := json.NewDecoder(r.Body).Decode(&workspace); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
respondError(w, "Invalid request body", http.StatusBadRequest)
return
}
if err := workspace.ValidateGitSettings(); err != nil {
http.Error(w, "Invalid workspace", http.StatusBadRequest)
respondError(w, "Invalid workspace", http.StatusBadRequest)
return
}
workspace.UserID = ctx.UserID
if err := h.DB.CreateWorkspace(&workspace); err != nil {
http.Error(w, "Failed to create workspace", http.StatusInternalServerError)
respondError(w, "Failed to create workspace", http.StatusInternalServerError)
return
}
if err := h.Storage.InitializeUserWorkspace(workspace.UserID, workspace.ID); err != nil {
http.Error(w, "Failed to initialize workspace directory", http.StatusInternalServerError)
respondError(w, "Failed to initialize workspace directory", http.StatusInternalServerError)
return
}
@@ -91,7 +101,7 @@ func (h *Handler) CreateWorkspace() http.HandlerFunc {
workspace.GitCommitName,
workspace.GitCommitEmail,
); err != nil {
http.Error(w, "Failed to setup git repo: "+err.Error(), http.StatusInternalServerError)
respondError(w, "Failed to setup git repo: "+err.Error(), http.StatusInternalServerError)
return
}
}
@@ -109,7 +119,7 @@ func (h *Handler) CreateWorkspace() http.HandlerFunc {
// @Produce json
// @Param workspace_name path string true "Workspace name"
// @Success 200 {object} models.Workspace
// @Failure 500 {string} string "Failed to get workspace"
// @Failure 500 {object} ErrorResponse "Internal server error"
// @Router /workspaces/{workspace_name} [get]
func (h *Handler) GetWorkspace() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
@@ -151,9 +161,9 @@ func gitSettingsChanged(new, old *models.Workspace) bool {
// @Param workspace_name path string true "Workspace name"
// @Param body body models.Workspace true "Workspace"
// @Success 200 {object} models.Workspace
// @Failure 400 {string} string "Invalid request body"
// @Failure 500 {string} string "Failed to update workspace"
// @Failure 500 {string} string "Failed to setup git repo"
// @Failure 400 {object} ErrorResponse "Invalid request body"
// @Failure 500 {object} ErrorResponse "Failed to update workspace"
// @Failure 500 {object} ErrorResponse "Failed to setup git repo"
// @Router /workspaces/{workspace_name} [put]
func (h *Handler) UpdateWorkspace() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
@@ -164,7 +174,7 @@ func (h *Handler) UpdateWorkspace() http.HandlerFunc {
var workspace models.Workspace
if err := json.NewDecoder(r.Body).Decode(&workspace); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
respondError(w, "Invalid request body", http.StatusBadRequest)
return
}
@@ -174,7 +184,7 @@ func (h *Handler) UpdateWorkspace() http.HandlerFunc {
// Validate the workspace
if err := workspace.Validate(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
respondError(w, err.Error(), http.StatusBadRequest)
return
}
@@ -190,7 +200,7 @@ func (h *Handler) UpdateWorkspace() http.HandlerFunc {
workspace.GitCommitName,
workspace.GitCommitEmail,
); err != nil {
http.Error(w, "Failed to setup git repo: "+err.Error(), http.StatusInternalServerError)
respondError(w, "Failed to setup git repo: "+err.Error(), http.StatusInternalServerError)
return
}
@@ -200,7 +210,7 @@ func (h *Handler) UpdateWorkspace() http.HandlerFunc {
}
if err := h.DB.UpdateWorkspace(&workspace); err != nil {
http.Error(w, "Failed to update workspace", http.StatusInternalServerError)
respondError(w, "Failed to update workspace", http.StatusInternalServerError)
return
}
@@ -216,14 +226,14 @@ func (h *Handler) UpdateWorkspace() http.HandlerFunc {
// @Security BearerAuth
// @Produce json
// @Param workspace_name path string true "Workspace name"
// @Success 200 {object} map[string]string
// @Failure 400 {string} string "Cannot delete the last workspace"
// @Failure 500 {string} string "Failed to get workspaces"
// @Failure 500 {string} string "Failed to start transaction"
// @Failure 500 {string} string "Failed to update last workspace"
// @Failure 500 {string} string "Failed to delete workspace"
// @Failure 500 {string} string "Failed to rollback transaction"
// @Failure 500 {string} string "Failed to commit transaction"
// @Success 200 {object} DeleteWorkspaceResponse
// @Failure 400 {object} ErrorResponse "Cannot delete the last workspace"
// @Failure 500 {object} ErrorResponse "Failed to get workspaces"
// @Failure 500 {object} ErrorResponse "Failed to start transaction"
// @Failure 500 {object} ErrorResponse "Failed to update last workspace"
// @Failure 500 {object} ErrorResponse "Failed to delete workspace"
// @Failure 500 {object} ErrorResponse "Failed to rollback transaction"
// @Failure 500 {object} ErrorResponse "Failed to commit transaction"
// @Router /workspaces/{workspace_name} [delete]
func (h *Handler) DeleteWorkspace() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
@@ -235,12 +245,12 @@ func (h *Handler) DeleteWorkspace() http.HandlerFunc {
// Check if this is the user's last workspace
workspaces, err := h.DB.GetWorkspacesByUserID(ctx.UserID)
if err != nil {
http.Error(w, "Failed to get workspaces", http.StatusInternalServerError)
respondError(w, "Failed to get workspaces", http.StatusInternalServerError)
return
}
if len(workspaces) <= 1 {
http.Error(w, "Cannot delete the last workspace", http.StatusBadRequest)
respondError(w, "Cannot delete the last workspace", http.StatusBadRequest)
return
}
@@ -258,37 +268,37 @@ func (h *Handler) DeleteWorkspace() http.HandlerFunc {
// Start transaction
tx, err := h.DB.Begin()
if err != nil {
http.Error(w, "Failed to start transaction", http.StatusInternalServerError)
respondError(w, "Failed to start transaction", http.StatusInternalServerError)
return
}
defer func() {
if err := tx.Rollback(); err != nil && err != sql.ErrTxDone {
http.Error(w, "Failed to rollback transaction", http.StatusInternalServerError)
respondError(w, "Failed to rollback transaction", http.StatusInternalServerError)
}
}()
// Update last workspace ID first
err = h.DB.UpdateLastWorkspaceTx(tx, ctx.UserID, nextWorkspaceID)
if err != nil {
http.Error(w, "Failed to update last workspace", http.StatusInternalServerError)
respondError(w, "Failed to update last workspace", http.StatusInternalServerError)
return
}
// Delete the workspace
err = h.DB.DeleteWorkspaceTx(tx, ctx.Workspace.ID)
if err != nil {
http.Error(w, "Failed to delete workspace", http.StatusInternalServerError)
respondError(w, "Failed to delete workspace", http.StatusInternalServerError)
return
}
// Commit transaction
if err = tx.Commit(); err != nil {
http.Error(w, "Failed to commit transaction", http.StatusInternalServerError)
respondError(w, "Failed to commit transaction", http.StatusInternalServerError)
return
}
// Return the next workspace ID in the response so frontend knows where to redirect
respondJSON(w, map[string]string{"nextWorkspaceName": nextWorkspaceName})
respondJSON(w, &DeleteWorkspaceResponse{NextWorkspaceName: nextWorkspaceName})
}
}
@@ -299,8 +309,8 @@ func (h *Handler) DeleteWorkspace() http.HandlerFunc {
// @ID getLastWorkspaceName
// @Security BearerAuth
// @Produce json
// @Success 200 {object} map[string]string
// @Failure 500 {string} string "Failed to get last workspace"
// @Success 200 {object} LastWorkspaceNameResponse
// @Failure 500 {object} ErrorResponse "Failed to get last workspace"
// @Router /workspaces/last [get]
func (h *Handler) GetLastWorkspaceName() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
@@ -311,11 +321,11 @@ func (h *Handler) GetLastWorkspaceName() http.HandlerFunc {
workspaceName, err := h.DB.GetLastWorkspaceName(ctx.UserID)
if err != nil {
http.Error(w, "Failed to get last workspace", http.StatusInternalServerError)
respondError(w, "Failed to get last workspace", http.StatusInternalServerError)
return
}
respondJSON(w, map[string]string{"lastWorkspaceName": workspaceName})
respondJSON(w, &GetLastWorkspaceNameResponse{LastWorkspaceName: workspaceName})
}
}
@@ -327,9 +337,9 @@ func (h *Handler) GetLastWorkspaceName() http.HandlerFunc {
// @Security BearerAuth
// @Accept json
// @Produce json
// @Success 200 {object} map[string]string
// @Failure 400 {string} string "Invalid request body"
// @Failure 500 {string} string "Failed to update last workspace"
// @Success 204 "No Content - Last workspace updated successfully"
// @Failure 400 {object} ErrorResponse "Invalid request body"
// @Failure 500 {object} ErrorResponse "Failed to update last workspace"
// @Router /workspaces/last [put]
func (h *Handler) UpdateLastWorkspaceName() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
@@ -343,15 +353,15 @@ func (h *Handler) UpdateLastWorkspaceName() http.HandlerFunc {
}
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
respondError(w, "Invalid request body", http.StatusBadRequest)
return
}
if err := h.DB.UpdateLastWorkspace(ctx.UserID, requestBody.WorkspaceName); err != nil {
http.Error(w, "Failed to update last workspace", http.StatusInternalServerError)
respondError(w, "Failed to update last workspace", http.StatusInternalServerError)
return
}
respondJSON(w, map[string]string{"message": "Last workspace updated successfully"})
w.WriteHeader(http.StatusNoContent)
}
}

View File

@@ -226,7 +226,7 @@ func TestWorkspaceHandlers_Integration(t *testing.T) {
}
rr := h.makeRequest(t, http.MethodPut, "/api/v1/workspaces/last", req, h.RegularToken, nil)
require.Equal(t, http.StatusOK, rr.Code)
require.Equal(t, http.StatusNoContent, rr.Code)
// Verify the update
rr = h.makeRequest(t, http.MethodGet, "/api/v1/workspaces/last", nil, h.RegularToken, nil)

View File

@@ -9,7 +9,7 @@ import (
type RepositoryManager interface {
SetupGitRepo(userID, workspaceID int, gitURL, gitUser, gitToken, commitName, commitEmail string) error
DisableGitRepo(userID, workspaceID int)
StageCommitAndPush(userID, workspaceID int, message string) error
StageCommitAndPush(userID, workspaceID int, message string) (git.CommitHash, error)
Pull(userID, workspaceID int) error
}
@@ -36,17 +36,19 @@ func (s *Service) DisableGitRepo(userID, workspaceID int) {
// StageCommitAndPush stages, commit with the message, and pushes the changes to the Git repository.
// The git repository belongs to the given userID and is associated with the given workspaceID.
func (s *Service) StageCommitAndPush(userID, workspaceID int, message string) error {
func (s *Service) StageCommitAndPush(userID, workspaceID int, message string) (git.CommitHash, error) {
repo, ok := s.getGitRepo(userID, workspaceID)
if !ok {
return fmt.Errorf("git settings not configured for this workspace")
return git.CommitHash{}, fmt.Errorf("git settings not configured for this workspace")
}
if err := repo.Commit(message); err != nil {
return err
hash, err := repo.Commit(message)
if err != nil {
return git.CommitHash{}, err
}
return repo.Push()
err = repo.Push()
return hash, err
}
// Pull pulls the changes from the remote Git repository.

View File

@@ -29,10 +29,10 @@ func (m *MockGitClient) Pull() error {
return m.ReturnError
}
func (m *MockGitClient) Commit(message string) error {
func (m *MockGitClient) Commit(message string) (git.CommitHash, error) {
m.CommitCalled = true
m.CommitMessage = message
return m.ReturnError
return git.CommitHash{}, m.ReturnError
}
func (m *MockGitClient) Push() error {
@@ -138,7 +138,7 @@ func TestGitOperations(t *testing.T) {
})
t.Run("operations on non-configured workspace", func(t *testing.T) {
err := s.StageCommitAndPush(1, 1, "test commit")
_, err := s.StageCommitAndPush(1, 1, "test commit")
if err == nil {
t.Error("expected error for non-configured workspace, got nil")
}
@@ -157,7 +157,7 @@ func TestGitOperations(t *testing.T) {
s.GitRepos[1][1] = mockClient
// Test commit and push
err := s.StageCommitAndPush(1, 1, "test commit")
_, err := s.StageCommitAndPush(1, 1, "test commit")
if err != nil {
t.Errorf("unexpected error: %v", err)
}
@@ -189,7 +189,7 @@ func TestGitOperations(t *testing.T) {
s.GitRepos[1][1] = mockClient
// Test commit error
err := s.StageCommitAndPush(1, 1, "test commit")
_, err := s.StageCommitAndPush(1, 1, "test commit")
if err == nil {
t.Error("expected error for commit, got nil")
}