first commit

This commit is contained in:
domrichardson
2026-03-24 16:03:04 +00:00
commit df40cc57e1
80 changed files with 16766 additions and 0 deletions

View File

@@ -0,0 +1,294 @@
package handlers
import (
"encoding/json"
"net/http"
"github.com/gorilla/mux"
"go.mongodb.org/mongo-driver/v2/bson"
"github.com/noteapp/backend/internal/application/dto"
"github.com/noteapp/backend/internal/application/services"
)
// AdminHandler handles admin-level HTTP requests
type AdminHandler struct {
adminService *services.AdminService
}
// NewAdminHandler creates a new AdminHandler
func NewAdminHandler(adminService *services.AdminService) *AdminHandler {
return &AdminHandler{adminService: adminService}
}
// ListUsers handles GET /admin/users
func (h *AdminHandler) ListUsers(w http.ResponseWriter, r *http.Request) {
users, err := h.adminService.ListUsers(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"users": users})
}
// UpdateUserGroups handles PUT /admin/users/{userId}/groups
func (h *AdminHandler) UpdateUserGroups(w http.ResponseWriter, r *http.Request) {
userID, err := bson.ObjectIDFromHex(mux.Vars(r)["userId"])
if err != nil {
http.Error(w, "invalid user id", http.StatusBadRequest)
return
}
var req dto.UpdateUserGroupsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
groupIDs := make([]bson.ObjectID, 0, len(req.GroupIDs))
for _, groupID := range req.GroupIDs {
parsed, err := bson.ObjectIDFromHex(groupID)
if err != nil {
http.Error(w, "invalid group id", http.StatusBadRequest)
return
}
groupIDs = append(groupIDs, parsed)
}
user, err := h.adminService.UpdateUserGroups(r.Context(), userID, groupIDs)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(user)
}
// ListGroups handles GET /admin/groups
func (h *AdminHandler) ListGroups(w http.ResponseWriter, r *http.Request) {
groups, err := h.adminService.ListGroups(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"groups": groups})
}
// CreateGroup handles POST /admin/groups
func (h *AdminHandler) CreateGroup(w http.ResponseWriter, r *http.Request) {
var req dto.CreatePermissionGroupRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
group, err := h.adminService.CreateGroup(r.Context(), &req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(group)
}
// UpdateGroup handles PUT /admin/groups/{groupId}
func (h *AdminHandler) UpdateGroup(w http.ResponseWriter, r *http.Request) {
groupID, err := bson.ObjectIDFromHex(mux.Vars(r)["groupId"])
if err != nil {
http.Error(w, "invalid group id", http.StatusBadRequest)
return
}
var req dto.UpdatePermissionGroupRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
group, err := h.adminService.UpdateGroup(r.Context(), groupID, &req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(group)
}
// ListAllSpaces handles GET /admin/spaces
func (h *AdminHandler) ListAllSpaces(w http.ResponseWriter, r *http.Request) {
spaces, err := h.adminService.ListAllSpaces(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"spaces": spaces})
}
// UpdateSpace handles PUT /admin/spaces/{spaceId}
func (h *AdminHandler) UpdateSpace(w http.ResponseWriter, r *http.Request) {
spaceID, err := bson.ObjectIDFromHex(mux.Vars(r)["spaceId"])
if err != nil {
http.Error(w, "invalid space id", http.StatusBadRequest)
return
}
var req dto.CreateSpaceRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
space, err := h.adminService.UpdateSpace(r.Context(), spaceID, &req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(space)
}
// SetSpaceVisibility handles PUT /admin/spaces/{spaceId}/visibility
func (h *AdminHandler) SetSpaceVisibility(w http.ResponseWriter, r *http.Request) {
spaceID, err := bson.ObjectIDFromHex(mux.Vars(r)["spaceId"])
if err != nil {
http.Error(w, "invalid space id", http.StatusBadRequest)
return
}
var req struct {
IsPublic bool `json:"is_public"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
if err := h.adminService.SetSpaceVisibility(r.Context(), spaceID, req.IsPublic); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"message": "visibility updated"})
}
// AddSpaceMember handles POST /admin/spaces/{spaceId}/members
func (h *AdminHandler) AddSpaceMember(w http.ResponseWriter, r *http.Request) {
spaceID, err := bson.ObjectIDFromHex(mux.Vars(r)["spaceId"])
if err != nil {
http.Error(w, "invalid space id", http.StatusBadRequest)
return
}
var req dto.AddSpaceMemberRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
userID, err := bson.ObjectIDFromHex(req.UserID)
if err != nil {
http.Error(w, "invalid user id", http.StatusBadRequest)
return
}
if err := h.adminService.AddSpaceMember(r.Context(), spaceID, userID); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]string{"message": "member added"})
}
// ListSpaceMembers handles GET /admin/spaces/{spaceId}/members
func (h *AdminHandler) ListSpaceMembers(w http.ResponseWriter, r *http.Request) {
spaceID, err := bson.ObjectIDFromHex(mux.Vars(r)["spaceId"])
if err != nil {
http.Error(w, "invalid space id", http.StatusBadRequest)
return
}
members, err := h.adminService.ListSpaceMembers(r.Context(), spaceID)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"members": members})
}
// RemoveSpaceMember handles DELETE /admin/spaces/{spaceId}/members/{userId}
func (h *AdminHandler) RemoveSpaceMember(w http.ResponseWriter, r *http.Request) {
spaceID, err := bson.ObjectIDFromHex(mux.Vars(r)["spaceId"])
if err != nil {
http.Error(w, "invalid space id", http.StatusBadRequest)
return
}
userID, err := bson.ObjectIDFromHex(mux.Vars(r)["userId"])
if err != nil {
http.Error(w, "invalid user id", http.StatusBadRequest)
return
}
if err := h.adminService.RemoveSpaceMember(r.Context(), spaceID, userID); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusNoContent)
}
// DeleteSpace handles DELETE /admin/spaces/{spaceId}
func (h *AdminHandler) DeleteSpace(w http.ResponseWriter, r *http.Request) {
spaceID, err := bson.ObjectIDFromHex(mux.Vars(r)["spaceId"])
if err != nil {
http.Error(w, "invalid space id", http.StatusBadRequest)
return
}
if err := h.adminService.DeleteSpace(r.Context(), spaceID); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusNoContent)
}
// GetFeatureFlags handles GET /admin/feature-flags
func (h *AdminHandler) GetFeatureFlags(w http.ResponseWriter, r *http.Request) {
flags, err := h.adminService.GetFeatureFlags(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(flags)
}
// UpdateFeatureFlags handles PUT /admin/feature-flags
func (h *AdminHandler) UpdateFeatureFlags(w http.ResponseWriter, r *http.Request) {
var req dto.UpdateFeatureFlagsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
flags, err := h.adminService.UpdateFeatureFlags(r.Context(), &req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(flags)
}

View File

@@ -0,0 +1,299 @@
package handlers
import (
"encoding/base64"
"encoding/json"
"net/http"
"net/url"
"os"
"strings"
"github.com/gorilla/mux"
"github.com/noteapp/backend/internal/application/dto"
"github.com/noteapp/backend/internal/application/services"
"github.com/noteapp/backend/internal/infrastructure/auth"
"go.mongodb.org/mongo-driver/v2/bson"
)
// AuthHandler handles authentication endpoints
type AuthHandler struct {
authService *services.AuthService
}
// NewAuthHandler creates a new auth handler
func NewAuthHandler(authService *services.AuthService) *AuthHandler {
return &AuthHandler{
authService: authService,
}
}
// Register handles user registration
func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req dto.RegisterRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Basic validation
if req.Email == "" || req.Password == "" || req.Username == "" {
http.Error(w, "Missing required fields", http.StatusBadRequest)
return
}
response, err := h.authService.Register(r.Context(), &req)
if err != nil {
if strings.Contains(strings.ToLower(err.Error()), "registration is currently disabled") {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// Login handles user login
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req dto.LoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
response, err := h.authService.Login(r.Context(), &req)
if err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)
return
}
// Set secure HTTP-only cookie for refresh token
http.SetCookie(w, &http.Cookie{
Name: "refresh_token",
Value: response.RefreshToken,
Path: "/",
MaxAge: 7 * 24 * 60 * 60, // 7 days
HttpOnly: true,
Secure: isSecureRequest(r),
SameSite: http.SameSiteLaxMode,
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// Logout handles user logout
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
// Clear refresh token cookie
http.SetCookie(w, &http.Cookie{
Name: "refresh_token",
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: isSecureRequest(r),
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"message": "Logged out successfully"})
}
// ListProviders returns all active OAuth/OIDC providers.
func (h *AuthHandler) ListProviders(w http.ResponseWriter, r *http.Request) {
providers, err := h.authService.ListProviders(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"providers": providers})
}
// CreateProvider stores a new OAuth/OIDC provider configuration.
func (h *AuthHandler) CreateProvider(w http.ResponseWriter, r *http.Request) {
var req dto.CreateAuthProviderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
provider, err := h.authService.CreateProvider(r.Context(), &req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(provider)
}
// StartProviderLogin redirects the browser to the selected provider.
func (h *AuthHandler) StartProviderLogin(w http.ResponseWriter, r *http.Request) {
providerID, err := bson.ObjectIDFromHex(mux.Vars(r)["providerId"])
if err != nil {
http.Error(w, "Invalid provider ID", http.StatusBadRequest)
return
}
state, err := auth.GenerateStateToken()
if err != nil {
http.Error(w, "Failed to create OAuth state", http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: "oauth_state",
Value: state,
Path: "/",
MaxAge: 10 * 60,
HttpOnly: true,
Secure: isSecureRequest(r),
SameSite: http.SameSiteLaxMode,
})
redirectURI := buildBackendURL(r, "/api/v1/auth/providers/"+providerID.Hex()+"/callback")
authorizationURL, err := h.authService.BuildProviderAuthorizationURL(r.Context(), providerID, redirectURI, state)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
http.Redirect(w, r, authorizationURL, http.StatusFound)
}
// CompleteProviderLogin exchanges the authorization code and redirects back to the frontend.
func (h *AuthHandler) CompleteProviderLogin(w http.ResponseWriter, r *http.Request) {
providerID, err := bson.ObjectIDFromHex(mux.Vars(r)["providerId"])
if err != nil {
http.Error(w, "Invalid provider ID", http.StatusBadRequest)
return
}
stateCookie, err := r.Cookie("oauth_state")
if err != nil || stateCookie.Value == "" || stateCookie.Value != r.URL.Query().Get("state") {
http.Error(w, "Invalid OAuth state", http.StatusBadRequest)
return
}
response, err := h.authService.CompleteProviderLogin(r.Context(), providerID, r.URL.Query().Get("code"), buildBackendURL(r, "/api/v1/auth/providers/"+providerID.Hex()+"/callback"))
if err != nil {
http.Redirect(w, r, buildFrontendLoginURL("oauth_error", err.Error(), "", nil), http.StatusFound)
return
}
http.SetCookie(w, &http.Cookie{
Name: "oauth_state",
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: isSecureRequest(r),
SameSite: http.SameSiteLaxMode,
})
http.SetCookie(w, &http.Cookie{
Name: "refresh_token",
Value: response.RefreshToken,
Path: "/",
MaxAge: 7 * 24 * 60 * 60,
HttpOnly: true,
Secure: isSecureRequest(r),
SameSite: http.SameSiteLaxMode,
})
http.Redirect(w, r, buildFrontendLoginURL("oauth_success", "", response.AccessToken, response.User), http.StatusFound)
}
// RefreshToken handles token refresh
func (h *AuthHandler) RefreshToken(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Get refresh token from cookie
cookie, err := r.Cookie("refresh_token")
if err != nil {
http.Error(w, "Refresh token not found", http.StatusUnauthorized)
return
}
accessToken, err := h.authService.RefreshAccessToken(r.Context(), cookie.Value)
if err != nil {
http.Error(w, "Invalid refresh token", http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"access_token": accessToken,
"expires_in": 3600,
})
}
// Health check endpoint
func (h *AuthHandler) Health(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"status": "healthy",
})
}
func isSecureRequest(r *http.Request) bool {
if r.TLS != nil {
return true
}
return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
}
func buildBackendURL(r *http.Request, path string) string {
scheme := "http"
if isSecureRequest(r) {
scheme = "https"
}
return scheme + "://" + r.Host + path
}
func buildFrontendLoginURL(status, message, accessToken string, user *dto.UserDTO) string {
frontendURL := os.Getenv("FRONTEND_URL")
if frontendURL == "" {
frontendURL = "http://localhost:5173"
}
parsed, err := url.Parse(strings.TrimRight(frontendURL, "/") + "/login")
if err != nil {
return frontendURL + "/login"
}
query := parsed.Query()
if status != "" {
query.Set("status", status)
}
if message != "" {
query.Set("message", message)
}
if accessToken != "" {
query.Set("access_token", accessToken)
}
if user != nil {
payload, _ := json.Marshal(user)
query.Set("user_json", string(payload))
query.Set("user", base64.RawURLEncoding.EncodeToString(payload))
}
parsed.RawQuery = query.Encode()
return parsed.String()
}

View File

@@ -0,0 +1,212 @@
package handlers
import (
"encoding/json"
"net/http"
"github.com/gorilla/mux"
"go.mongodb.org/mongo-driver/v2/bson"
"github.com/noteapp/backend/internal/application/dto"
"github.com/noteapp/backend/internal/application/services"
"github.com/noteapp/backend/internal/interfaces/middleware"
)
// CategoryHandler handles category endpoints
type CategoryHandler struct {
categoryService *services.CategoryService
}
// NewCategoryHandler creates a new category handler
func NewCategoryHandler(categoryService *services.CategoryService) *CategoryHandler {
return &CategoryHandler{categoryService: categoryService}
}
// GetCategoryTree returns the full category tree for a space
func (h *CategoryHandler) GetCategoryTree(w http.ResponseWriter, r *http.Request) {
userID, err := getUserObjectID(r)
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
vars := mux.Vars(r)
spaceID, err := bson.ObjectIDFromHex(vars["spaceId"])
if err != nil {
http.Error(w, "invalid space id", http.StatusBadRequest)
return
}
tree, err := h.categoryService.GetCategoryTree(r.Context(), spaceID, userID)
if err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(tree)
}
// CreateCategory creates a new category in a space
func (h *CategoryHandler) CreateCategory(w http.ResponseWriter, r *http.Request) {
userID, err := getUserObjectID(r)
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
vars := mux.Vars(r)
spaceID, err := bson.ObjectIDFromHex(vars["spaceId"])
if err != nil {
http.Error(w, "invalid space id", http.StatusBadRequest)
return
}
var req dto.CreateCategoryRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
category, err := h.categoryService.CreateCategory(r.Context(), spaceID, userID, &req)
if err != nil {
if err.Error() == "unauthorized" {
http.Error(w, err.Error(), http.StatusForbidden)
} else {
http.Error(w, err.Error(), http.StatusBadRequest)
}
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(category)
}
// UpdateCategory updates a category
func (h *CategoryHandler) UpdateCategory(w http.ResponseWriter, r *http.Request) {
userID, err := getUserObjectID(r)
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
vars := mux.Vars(r)
spaceID, err := bson.ObjectIDFromHex(vars["spaceId"])
if err != nil {
http.Error(w, "invalid space id", http.StatusBadRequest)
return
}
categoryID, err := bson.ObjectIDFromHex(vars["categoryId"])
if err != nil {
http.Error(w, "invalid category id", http.StatusBadRequest)
return
}
var req dto.UpdateCategoryRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
category, err := h.categoryService.UpdateCategory(r.Context(), categoryID, spaceID, userID, &req)
if err != nil {
if err.Error() == "unauthorized" {
http.Error(w, err.Error(), http.StatusForbidden)
} else {
http.Error(w, err.Error(), http.StatusNotFound)
}
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(category)
}
// DeleteCategory deletes a category
func (h *CategoryHandler) DeleteCategory(w http.ResponseWriter, r *http.Request) {
userID, err := getUserObjectID(r)
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
vars := mux.Vars(r)
spaceID, err := bson.ObjectIDFromHex(vars["spaceId"])
if err != nil {
http.Error(w, "invalid space id", http.StatusBadRequest)
return
}
categoryID, err := bson.ObjectIDFromHex(vars["categoryId"])
if err != nil {
http.Error(w, "invalid category id", http.StatusBadRequest)
return
}
var moveNotesTo *string
if v := r.URL.Query().Get("moveNotesTo"); v != "" {
moveNotesTo = &v
}
if err := h.categoryService.DeleteCategory(r.Context(), categoryID, spaceID, userID, moveNotesTo); err != nil {
if err.Error() == "unauthorized" {
http.Error(w, err.Error(), http.StatusForbidden)
} else {
http.Error(w, err.Error(), http.StatusNotFound)
}
return
}
w.WriteHeader(http.StatusNoContent)
}
// MoveCategory moves a category to a new parent
func (h *CategoryHandler) MoveCategory(w http.ResponseWriter, r *http.Request) {
userID, err := getUserObjectID(r)
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
vars := mux.Vars(r)
spaceID, err := bson.ObjectIDFromHex(vars["spaceId"])
if err != nil {
http.Error(w, "invalid space id", http.StatusBadRequest)
return
}
categoryID, err := bson.ObjectIDFromHex(vars["categoryId"])
if err != nil {
http.Error(w, "invalid category id", http.StatusBadRequest)
return
}
var body struct {
ParentID *string `json:"parent_id"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
category, err := h.categoryService.MoveCategory(r.Context(), categoryID, spaceID, userID, body.ParentID)
if err != nil {
if err.Error() == "unauthorized" {
http.Error(w, err.Error(), http.StatusForbidden)
} else {
http.Error(w, err.Error(), http.StatusBadRequest)
}
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(category)
}
// getUserObjectID extracts the user ObjectID from the request context
func getUserObjectID(r *http.Request) (bson.ObjectID, error) {
userIDStr, ok := r.Context().Value(middleware.UserIDKey).(string)
if !ok || userIDStr == "" {
return bson.NilObjectID, http.ErrNoCookie
}
return bson.ObjectIDFromHex(userIDStr)
}

View File

@@ -0,0 +1,266 @@
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"github.com/gorilla/mux"
"go.mongodb.org/mongo-driver/v2/bson"
"github.com/noteapp/backend/internal/application/dto"
"github.com/noteapp/backend/internal/application/services"
"github.com/noteapp/backend/internal/interfaces/middleware"
)
// NoteHandler handles note endpoints
type NoteHandler struct {
noteService *services.NoteService
}
// NewNoteHandler creates a new note handler
func NewNoteHandler(noteService *services.NoteService) *NoteHandler {
return &NoteHandler{
noteService: noteService,
}
}
// CreateNote creates a new note
func (h *NoteHandler) CreateNote(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
spaceID := mux.Vars(r)["spaceId"]
userID, err := middleware.GetUserIDFromContext(r.Context())
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
spaceObjID, _ := bson.ObjectIDFromHex(spaceID)
userObjID, _ := bson.ObjectIDFromHex(userID)
var req dto.CreateNoteRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
note, err := h.noteService.CreateNote(r.Context(), spaceObjID, userObjID, &req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(note)
}
// GetNote retrieves a note
func (h *NoteHandler) GetNote(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
vars := mux.Vars(r)
spaceID := vars["spaceId"]
noteID := vars["noteId"]
userID, err := middleware.GetUserIDFromContext(r.Context())
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
spaceObjID, _ := bson.ObjectIDFromHex(spaceID)
noteObjID, _ := bson.ObjectIDFromHex(noteID)
userObjID, _ := bson.ObjectIDFromHex(userID)
note, err := h.noteService.GetNote(r.Context(), noteObjID, spaceObjID, userObjID)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(note)
}
// GetNotesBySpace retrieves notes in a space
func (h *NoteHandler) GetNotesBySpace(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
spaceID := mux.Vars(r)["spaceId"]
userID, err := middleware.GetUserIDFromContext(r.Context())
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Pagination
skip, _ := strconv.Atoi(r.URL.Query().Get("skip"))
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit == 0 || limit > 100 {
limit = 20
}
spaceObjID, _ := bson.ObjectIDFromHex(spaceID)
userObjID, _ := bson.ObjectIDFromHex(userID)
notes, err := h.noteService.GetNotesBySpace(r.Context(), spaceObjID, userObjID, skip, limit)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(notes)
}
// SearchNotes performs full-text search
func (h *NoteHandler) SearchNotes(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
spaceID := mux.Vars(r)["spaceId"]
query := r.URL.Query().Get("q")
if query == "" {
http.Error(w, "Missing search query", http.StatusBadRequest)
return
}
userID, err := middleware.GetUserIDFromContext(r.Context())
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
spaceObjID, _ := bson.ObjectIDFromHex(spaceID)
userObjID, _ := bson.ObjectIDFromHex(userID)
notes, err := h.noteService.SearchNotes(r.Context(), spaceObjID, userObjID, query)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(notes)
}
// UpdateNote updates a note
func (h *NoteHandler) UpdateNote(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
vars := mux.Vars(r)
spaceID := vars["spaceId"]
noteID := vars["noteId"]
userID, err := middleware.GetUserIDFromContext(r.Context())
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
var req dto.UpdateNoteRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
spaceObjID, _ := bson.ObjectIDFromHex(spaceID)
noteObjID, _ := bson.ObjectIDFromHex(noteID)
userObjID, _ := bson.ObjectIDFromHex(userID)
note, err := h.noteService.UpdateNote(r.Context(), noteObjID, spaceObjID, userObjID, &req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(note)
}
// DeleteNote deletes a note
func (h *NoteHandler) DeleteNote(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
vars := mux.Vars(r)
spaceID := vars["spaceId"]
noteID := vars["noteId"]
userID, err := middleware.GetUserIDFromContext(r.Context())
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
spaceObjID, _ := bson.ObjectIDFromHex(spaceID)
noteObjID, _ := bson.ObjectIDFromHex(noteID)
userObjID, _ := bson.ObjectIDFromHex(userID)
if err := h.noteService.DeleteNote(r.Context(), noteObjID, spaceObjID, userObjID); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusNoContent)
}
// UnlockNote verifies a note password and returns full note content
func (h *NoteHandler) UnlockNote(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
vars := mux.Vars(r)
spaceID := vars["spaceId"]
noteID := vars["noteId"]
userID, err := middleware.GetUserIDFromContext(r.Context())
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
var req dto.UnlockNoteRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
spaceObjID, _ := bson.ObjectIDFromHex(spaceID)
noteObjID, _ := bson.ObjectIDFromHex(noteID)
userObjID, _ := bson.ObjectIDFromHex(userID)
note, err := h.noteService.UnlockNote(r.Context(), noteObjID, spaceObjID, userObjID, req.Password)
if err != nil {
if err.Error() == "invalid note password" {
http.Error(w, err.Error(), http.StatusUnauthorized)
return
}
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(note)
}

View File

@@ -0,0 +1,156 @@
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"github.com/gorilla/mux"
"go.mongodb.org/mongo-driver/v2/bson"
"github.com/noteapp/backend/internal/application/dto"
"github.com/noteapp/backend/internal/application/services"
)
// PublicHandler handles unauthenticated public read-only requests
type PublicHandler struct {
spaceService *services.SpaceService
noteService *services.NoteService
}
// NewPublicHandler creates a new PublicHandler
func NewPublicHandler(spaceService *services.SpaceService, noteService *services.NoteService) *PublicHandler {
return &PublicHandler{spaceService: spaceService, noteService: noteService}
}
// GetPublicSpace handles GET /public/spaces/{spaceId}
func (h *PublicHandler) GetPublicSpace(w http.ResponseWriter, r *http.Request) {
spaceID, err := bson.ObjectIDFromHex(mux.Vars(r)["spaceId"])
if err != nil {
http.Error(w, "invalid space id", http.StatusBadRequest)
return
}
space, err := h.spaceService.GetPublicSpace(r.Context(), spaceID)
if err != nil {
if err.Error() == "space is not public" {
http.Error(w, "not found", http.StatusNotFound)
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(space)
}
// ListPublicSpaces handles GET /public/spaces
func (h *PublicHandler) ListPublicSpaces(w http.ResponseWriter, r *http.Request) {
spaces, err := h.spaceService.GetPublicSpaces(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"spaces": spaces})
}
// GetPublicNotes handles GET /public/spaces/{spaceId}/notes
func (h *PublicHandler) GetPublicNotes(w http.ResponseWriter, r *http.Request) {
spaceID, err := bson.ObjectIDFromHex(mux.Vars(r)["spaceId"])
if err != nil {
http.Error(w, "invalid space id", http.StatusBadRequest)
return
}
skip := 0
limit := 50
if v := r.URL.Query().Get("skip"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
skip = n
}
}
if v := r.URL.Query().Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 100 {
limit = n
}
}
notes, err := h.noteService.GetPublicNotesBySpace(r.Context(), spaceID, skip, limit)
if err != nil {
if err.Error() == "space is not public" {
http.Error(w, "not found", http.StatusNotFound)
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"notes": notes})
}
// GetPublicNote handles GET /public/spaces/{spaceId}/notes/{noteId}
func (h *PublicHandler) GetPublicNote(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
spaceID, err := bson.ObjectIDFromHex(vars["spaceId"])
if err != nil {
http.Error(w, "invalid space id", http.StatusBadRequest)
return
}
noteID, err := bson.ObjectIDFromHex(vars["noteId"])
if err != nil {
http.Error(w, "invalid note id", http.StatusBadRequest)
return
}
note, err := h.noteService.GetPublicNoteBySpaceAndID(r.Context(), spaceID, noteID)
if err != nil {
if err.Error() == "space is not public" || err.Error() == "note is not public" || err.Error() == "space not found" || err.Error() == "note not found" {
http.Error(w, "not found", http.StatusNotFound)
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(note)
}
// UnlockPublicNote verifies a public note password and returns full note content
func (h *PublicHandler) UnlockPublicNote(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
spaceID, err := bson.ObjectIDFromHex(vars["spaceId"])
if err != nil {
http.Error(w, "invalid space id", http.StatusBadRequest)
return
}
noteID, err := bson.ObjectIDFromHex(vars["noteId"])
if err != nil {
http.Error(w, "invalid note id", http.StatusBadRequest)
return
}
var req dto.UnlockNoteRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
note, err := h.noteService.UnlockPublicNote(r.Context(), spaceID, noteID, req.Password)
if err != nil {
if err.Error() == "invalid note password" {
http.Error(w, err.Error(), http.StatusUnauthorized)
return
}
if err.Error() == "space is not public" || err.Error() == "space not found" || err.Error() == "note not found" {
http.Error(w, "not found", http.StatusNotFound)
return
}
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(note)
}

View File

@@ -0,0 +1,30 @@
package handlers
import (
"encoding/json"
"net/http"
"github.com/noteapp/backend/internal/application/services"
)
// SettingsHandler handles public app settings endpoints.
type SettingsHandler struct {
authService *services.AuthService
}
// NewSettingsHandler creates a new settings handler.
func NewSettingsHandler(authService *services.AuthService) *SettingsHandler {
return &SettingsHandler{authService: authService}
}
// GetFeatureFlags handles GET /api/v1/settings/feature-flags.
func (h *SettingsHandler) GetFeatureFlags(w http.ResponseWriter, r *http.Request) {
flags, err := h.authService.GetFeatureFlags(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(flags)
}

View File

@@ -0,0 +1,295 @@
package handlers
import (
"encoding/json"
"net/http"
"github.com/gorilla/mux"
"github.com/noteapp/backend/internal/application/dto"
"github.com/noteapp/backend/internal/application/services"
"github.com/noteapp/backend/internal/interfaces/middleware"
"go.mongodb.org/mongo-driver/v2/bson"
)
// SpaceHandler handles space endpoints
type SpaceHandler struct {
spaceService *services.SpaceService
}
// NewSpaceHandler creates a new space handler
func NewSpaceHandler(spaceService *services.SpaceService) *SpaceHandler {
return &SpaceHandler{
spaceService: spaceService,
}
}
// CreateSpace creates a new space
func (h *SpaceHandler) CreateSpace(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
userID, err := middleware.GetUserIDFromContext(r.Context())
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
var req dto.CreateSpaceRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
userObjID, _ := bson.ObjectIDFromHex(userID)
space, err := h.spaceService.CreateSpace(r.Context(), userObjID, &req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(space)
}
// GetUserSpaces retrieves all spaces for the user
func (h *SpaceHandler) GetUserSpaces(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
userID, err := middleware.GetUserIDFromContext(r.Context())
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
userObjID, _ := bson.ObjectIDFromHex(userID)
spaces, err := h.spaceService.GetUserSpaces(r.Context(), userObjID)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(spaces)
}
// GetSpace retrieves a space
func (h *SpaceHandler) GetSpace(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
spaceID := mux.Vars(r)["spaceId"]
userID, err := middleware.GetUserIDFromContext(r.Context())
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
spaceObjID, _ := bson.ObjectIDFromHex(spaceID)
userObjID, _ := bson.ObjectIDFromHex(userID)
space, err := h.spaceService.GetSpaceByID(r.Context(), spaceObjID, userObjID)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(space)
}
// UpdateSpace updates a space
func (h *SpaceHandler) UpdateSpace(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
spaceID := mux.Vars(r)["spaceId"]
userID, err := middleware.GetUserIDFromContext(r.Context())
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
var req dto.CreateSpaceRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
spaceObjID, _ := bson.ObjectIDFromHex(spaceID)
userObjID, _ := bson.ObjectIDFromHex(userID)
space, err := h.spaceService.UpdateSpace(r.Context(), spaceObjID, userObjID, &req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(space)
}
// DeleteSpace deletes a space
func (h *SpaceHandler) DeleteSpace(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
spaceID := mux.Vars(r)["spaceId"]
userID, err := middleware.GetUserIDFromContext(r.Context())
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
spaceObjID, _ := bson.ObjectIDFromHex(spaceID)
userObjID, _ := bson.ObjectIDFromHex(userID)
if err := h.spaceService.DeleteSpace(r.Context(), spaceObjID, userObjID); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusNoContent)
}
// GetSpaceMembers retrieves all members in a space (owner only)
func (h *SpaceHandler) GetSpaceMembers(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
spaceID := mux.Vars(r)["spaceId"]
userID, err := middleware.GetUserIDFromContext(r.Context())
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
spaceObjID, _ := bson.ObjectIDFromHex(spaceID)
userObjID, _ := bson.ObjectIDFromHex(userID)
members, err := h.spaceService.GetSpaceMembers(r.Context(), spaceObjID, userObjID)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"members": members})
}
// AddMember adds a member to a space (owner/editor)
func (h *SpaceHandler) AddMember(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
spaceID := mux.Vars(r)["spaceId"]
userID, err := middleware.GetUserIDFromContext(r.Context())
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
var req dto.AddSpaceMemberRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
spaceObjID, _ := bson.ObjectIDFromHex(spaceID)
userObjID, _ := bson.ObjectIDFromHex(userID)
targetUserObjID, err := bson.ObjectIDFromHex(req.UserID)
if err != nil {
http.Error(w, "Invalid user id", http.StatusBadRequest)
return
}
if err := h.spaceService.AddMember(r.Context(), spaceObjID, userObjID, targetUserObjID); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]string{"message": "member added"})
}
// RemoveMember removes a member from a space (owner/editor)
func (h *SpaceHandler) RemoveMember(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
spaceID := mux.Vars(r)["spaceId"]
targetUserID := mux.Vars(r)["userId"]
userID, err := middleware.GetUserIDFromContext(r.Context())
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
spaceObjID, err := bson.ObjectIDFromHex(spaceID)
if err != nil {
http.Error(w, "Invalid space id", http.StatusBadRequest)
return
}
userObjID, err := bson.ObjectIDFromHex(userID)
if err != nil {
http.Error(w, "Invalid user id", http.StatusBadRequest)
return
}
targetUserObjID, err := bson.ObjectIDFromHex(targetUserID)
if err != nil {
http.Error(w, "Invalid target user id", http.StatusBadRequest)
return
}
if err := h.spaceService.RemoveMember(r.Context(), spaceObjID, userObjID, targetUserObjID); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusNoContent)
}
// GetAvailableUsers returns user options for member selection (owner only)
func (h *SpaceHandler) GetAvailableUsers(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
spaceID := mux.Vars(r)["spaceId"]
userID, err := middleware.GetUserIDFromContext(r.Context())
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
spaceObjID, _ := bson.ObjectIDFromHex(spaceID)
userObjID, _ := bson.ObjectIDFromHex(userID)
users, err := h.spaceService.ListAvailableUsers(r.Context(), spaceObjID, userObjID)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"users": users})
}