first commit
This commit is contained in:
28
backend/.env.example
Normal file
28
backend/.env.example
Normal file
@@ -0,0 +1,28 @@
|
||||
# Backend Environment Example
|
||||
|
||||
# MongoDB
|
||||
MONGODB_URI=mongodb://admin:password@localhost:27017/noteapp?authSource=admin
|
||||
|
||||
# JWT Configuration
|
||||
JWT_SECRET=your-super-secret-jwt-key-minimum-32-characters-change-in-production
|
||||
JWT_ISSUER=noteapp
|
||||
|
||||
# Encryption Key (32 bytes/characters for AES-256)
|
||||
ENCRYPTION_KEY=00000000000000000000000000000000
|
||||
|
||||
# Server
|
||||
PORT=8080
|
||||
ENV=development
|
||||
LOG_LEVEL=info
|
||||
|
||||
# Default Admin
|
||||
DEFAULT_ADMIN_EMAIL=admin@notely.local
|
||||
DEFAULT_ADMIN_USERNAME=admin
|
||||
DEFAULT_ADMIN_PASSWORD=ChangeThisAdminPassword123!
|
||||
|
||||
# CORS (comma-separated origins)
|
||||
CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000
|
||||
|
||||
# Rate Limiting
|
||||
RATE_LIMIT_REQUESTS=50
|
||||
RATE_LIMIT_WINDOW=1s
|
||||
433
backend/cmd/server/main.go
Normal file
433
backend/cmd/server/main.go
Normal file
@@ -0,0 +1,433 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/noteapp/backend/internal/application/services"
|
||||
"github.com/noteapp/backend/internal/domain/entities"
|
||||
"github.com/noteapp/backend/internal/domain/repositories"
|
||||
"github.com/noteapp/backend/internal/infrastructure/auth"
|
||||
"github.com/noteapp/backend/internal/infrastructure/database"
|
||||
"github.com/noteapp/backend/internal/infrastructure/security"
|
||||
"github.com/noteapp/backend/internal/interfaces/handlers"
|
||||
"github.com/noteapp/backend/internal/interfaces/middleware"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Load environment variables
|
||||
_ = godotenv.Load()
|
||||
|
||||
// Configuration
|
||||
mongoURL := os.Getenv("MONGODB_URI")
|
||||
if mongoURL == "" {
|
||||
mongoURL = "mongodb://localhost:27017"
|
||||
}
|
||||
|
||||
jwtSecret := os.Getenv("JWT_SECRET")
|
||||
if jwtSecret == "" {
|
||||
jwtSecret = "your-secret-key-change-in-production"
|
||||
}
|
||||
|
||||
encryptionKey := os.Getenv("ENCRYPTION_KEY")
|
||||
if encryptionKey == "" {
|
||||
encryptionKey = "00000000000000000000000000000000" // 32 bytes
|
||||
}
|
||||
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
port = "8080"
|
||||
}
|
||||
|
||||
// Connect to database
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
db, err := database.NewDatabase(ctx, mongoURL)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to connect to database: %v", err)
|
||||
}
|
||||
defer db.Close(context.Background())
|
||||
|
||||
// Initialize security components
|
||||
passwordHasher := security.NewPasswordHasher()
|
||||
encryptor, err := security.NewEncryptor(encryptionKey)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to initialize encryptor: %v", err)
|
||||
}
|
||||
|
||||
// Initialize JWT manager
|
||||
jwtManager := auth.NewJWTManager(jwtSecret, "noteapp", 1*time.Hour)
|
||||
|
||||
// Initialize services
|
||||
permissionService := services.NewPermissionService(
|
||||
db.UserRepo,
|
||||
db.GroupRepo,
|
||||
db.MembershipRepo,
|
||||
db.SpaceRepo,
|
||||
)
|
||||
|
||||
authService := services.NewAuthService(
|
||||
db.UserRepo,
|
||||
db.GroupRepo,
|
||||
db.ProviderRepo,
|
||||
db.LinkRepo,
|
||||
db.RecoveryRepo,
|
||||
db.FeatureFlagRepo,
|
||||
permissionService,
|
||||
jwtManager,
|
||||
passwordHasher,
|
||||
encryptor,
|
||||
)
|
||||
|
||||
spaceService := services.NewSpaceService(
|
||||
db.SpaceRepo,
|
||||
db.MembershipRepo,
|
||||
db.NoteRepo,
|
||||
db.CategoryRepo,
|
||||
db.UserRepo,
|
||||
permissionService,
|
||||
)
|
||||
|
||||
noteService := services.NewNoteService(
|
||||
db.NoteRepo,
|
||||
db.CategoryRepo,
|
||||
db.MembershipRepo,
|
||||
nil, // NoteRevisionRepository
|
||||
db.SpaceRepo,
|
||||
permissionService,
|
||||
passwordHasher,
|
||||
)
|
||||
|
||||
categoryService := services.NewCategoryService(
|
||||
db.CategoryRepo,
|
||||
db.MembershipRepo,
|
||||
db.NoteRepo,
|
||||
permissionService,
|
||||
)
|
||||
|
||||
adminService := services.NewAdminService(
|
||||
db.UserRepo,
|
||||
db.GroupRepo,
|
||||
db.SpaceRepo,
|
||||
db.MembershipRepo,
|
||||
db.NoteRepo,
|
||||
db.CategoryRepo,
|
||||
db.FeatureFlagRepo,
|
||||
permissionService,
|
||||
)
|
||||
|
||||
if err := permissionService.EnsureAdminGroup(context.Background()); err != nil {
|
||||
log.Fatalf("failed to initialize admin group: %v", err)
|
||||
}
|
||||
|
||||
if err := ensureDefaultAdminUser(
|
||||
context.Background(),
|
||||
db.UserRepo,
|
||||
db.GroupRepo,
|
||||
permissionService,
|
||||
passwordHasher,
|
||||
); err != nil {
|
||||
log.Fatalf("failed to initialize default admin user: %v", err)
|
||||
}
|
||||
|
||||
// Initialize handlers
|
||||
authHandler := handlers.NewAuthHandler(authService)
|
||||
spaceHandler := handlers.NewSpaceHandler(spaceService)
|
||||
noteHandler := handlers.NewNoteHandler(noteService)
|
||||
categoryHandler := handlers.NewCategoryHandler(categoryService)
|
||||
adminHandler := handlers.NewAdminHandler(adminService)
|
||||
publicHandler := handlers.NewPublicHandler(spaceService, noteService)
|
||||
settingsHandler := handlers.NewSettingsHandler(authService)
|
||||
|
||||
// Create router
|
||||
router := mux.NewRouter()
|
||||
router.PathPrefix("/").Methods(http.MethodOptions).HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
|
||||
// Middleware
|
||||
authMiddleware := middleware.NewAuthMiddleware(jwtManager)
|
||||
router.Use(middleware.LoggingMiddleware)
|
||||
router.Use(middleware.CORSMiddleware)
|
||||
router.Use(middleware.SecurityHeaders)
|
||||
|
||||
// Public endpoints
|
||||
router.HandleFunc("/health", authHandler.Health).Methods("GET")
|
||||
router.HandleFunc("/api/v1/auth/register", authHandler.Register).Methods("POST")
|
||||
router.HandleFunc("/api/v1/auth/login", authHandler.Login).Methods("POST")
|
||||
router.HandleFunc("/api/v1/auth/refresh", authHandler.RefreshToken).Methods("POST")
|
||||
router.HandleFunc("/api/v1/auth/logout", authHandler.Logout).Methods("POST")
|
||||
router.HandleFunc("/api/v1/auth/providers", authHandler.ListProviders).Methods("GET")
|
||||
router.HandleFunc("/api/v1/auth/providers/{providerId}/start", authHandler.StartProviderLogin).Methods("GET")
|
||||
router.HandleFunc("/api/v1/auth/providers/{providerId}/callback", authHandler.CompleteProviderLogin).Methods("GET")
|
||||
router.HandleFunc("/api/v1/settings/feature-flags", settingsHandler.GetFeatureFlags).Methods("GET")
|
||||
|
||||
// Public read-only endpoints (no auth required)
|
||||
public := router.PathPrefix("/api/v1/public").Subrouter()
|
||||
public.HandleFunc("/spaces", publicHandler.ListPublicSpaces).Methods("GET")
|
||||
public.HandleFunc("/spaces/{spaceId}", publicHandler.GetPublicSpace).Methods("GET")
|
||||
public.HandleFunc("/spaces/{spaceId}/notes", publicHandler.GetPublicNotes).Methods("GET")
|
||||
public.HandleFunc("/spaces/{spaceId}/notes/{noteId}", publicHandler.GetPublicNote).Methods("GET")
|
||||
public.HandleFunc("/spaces/{spaceId}/notes/{noteId}/unlock", publicHandler.UnlockPublicNote).Methods("POST")
|
||||
|
||||
// Protected endpoints
|
||||
api := router.PathPrefix("/api/v1").Subrouter()
|
||||
api.Use(authMiddleware.Middleware)
|
||||
|
||||
// Space endpoints
|
||||
api.HandleFunc("/spaces", spaceHandler.GetUserSpaces).Methods("GET")
|
||||
api.HandleFunc("/spaces", spaceHandler.CreateSpace).Methods("POST")
|
||||
api.HandleFunc("/spaces/{spaceId}", spaceHandler.GetSpace).Methods("GET")
|
||||
api.HandleFunc("/spaces/{spaceId}", spaceHandler.UpdateSpace).Methods("PUT")
|
||||
api.HandleFunc("/spaces/{spaceId}", spaceHandler.DeleteSpace).Methods("DELETE")
|
||||
api.HandleFunc("/spaces/{spaceId}/members", spaceHandler.AddMember).Methods("POST")
|
||||
api.HandleFunc("/spaces/{spaceId}/members", spaceHandler.GetSpaceMembers).Methods("GET")
|
||||
api.HandleFunc("/spaces/{spaceId}/members/{userId}", spaceHandler.RemoveMember).Methods("DELETE")
|
||||
api.HandleFunc("/spaces/{spaceId}/available-users", spaceHandler.GetAvailableUsers).Methods("GET")
|
||||
|
||||
// Note endpoints
|
||||
api.HandleFunc("/spaces/{spaceId}/notes", noteHandler.GetNotesBySpace).Methods("GET")
|
||||
api.HandleFunc("/spaces/{spaceId}/notes", noteHandler.CreateNote).Methods("POST")
|
||||
api.HandleFunc("/spaces/{spaceId}/notes/search", noteHandler.SearchNotes).Methods("GET")
|
||||
api.HandleFunc("/spaces/{spaceId}/notes/{noteId}", noteHandler.GetNote).Methods("GET")
|
||||
api.HandleFunc("/spaces/{spaceId}/notes/{noteId}/unlock", noteHandler.UnlockNote).Methods("POST")
|
||||
api.HandleFunc("/spaces/{spaceId}/notes/{noteId}", noteHandler.UpdateNote).Methods("PUT")
|
||||
api.HandleFunc("/spaces/{spaceId}/notes/{noteId}", noteHandler.DeleteNote).Methods("DELETE")
|
||||
|
||||
// Category endpoints
|
||||
api.HandleFunc("/spaces/{spaceId}/categories", categoryHandler.GetCategoryTree).Methods("GET")
|
||||
api.HandleFunc("/spaces/{spaceId}/categories", categoryHandler.CreateCategory).Methods("POST")
|
||||
api.HandleFunc("/spaces/{spaceId}/categories/{categoryId}", categoryHandler.UpdateCategory).Methods("PUT")
|
||||
api.HandleFunc("/spaces/{spaceId}/categories/{categoryId}", categoryHandler.DeleteCategory).Methods("DELETE")
|
||||
api.HandleFunc("/spaces/{spaceId}/categories/{categoryId}/move", categoryHandler.MoveCategory).Methods("PATCH")
|
||||
|
||||
// Admin endpoints
|
||||
admin := router.PathPrefix("/api/v1/admin").Subrouter()
|
||||
admin.Use(authMiddleware.Middleware)
|
||||
admin.Use(func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
userIDHex, err := middleware.GetUserIDFromContext(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
userID, err := bson.ObjectIDFromHex(userIDHex)
|
||||
if err != nil {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
allowed, err := permissionService.UserHasPermission(r.Context(), userID, "admin.access")
|
||||
if err != nil {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if !allowed {
|
||||
allowed, err = permissionService.UserHasPermission(r.Context(), userID, "*")
|
||||
if err != nil || !allowed {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
})
|
||||
admin.HandleFunc("/users", adminHandler.ListUsers).Methods("GET")
|
||||
admin.HandleFunc("/users/{userId}/groups", adminHandler.UpdateUserGroups).Methods("PUT")
|
||||
admin.HandleFunc("/groups", adminHandler.ListGroups).Methods("GET")
|
||||
admin.HandleFunc("/groups", adminHandler.CreateGroup).Methods("POST")
|
||||
admin.HandleFunc("/groups/{groupId}", adminHandler.UpdateGroup).Methods("PUT")
|
||||
admin.HandleFunc("/spaces", adminHandler.ListAllSpaces).Methods("GET")
|
||||
admin.HandleFunc("/spaces/{spaceId}", adminHandler.UpdateSpace).Methods("PUT")
|
||||
admin.HandleFunc("/spaces/{spaceId}", adminHandler.DeleteSpace).Methods("DELETE")
|
||||
admin.HandleFunc("/spaces/{spaceId}/members", adminHandler.AddSpaceMember).Methods("POST")
|
||||
admin.HandleFunc("/spaces/{spaceId}/members", adminHandler.ListSpaceMembers).Methods("GET")
|
||||
admin.HandleFunc("/spaces/{spaceId}/members/{userId}", adminHandler.RemoveSpaceMember).Methods("DELETE")
|
||||
admin.HandleFunc("/spaces/{spaceId}/visibility", adminHandler.SetSpaceVisibility).Methods("PUT")
|
||||
admin.HandleFunc("/feature-flags", adminHandler.GetFeatureFlags).Methods("GET")
|
||||
admin.HandleFunc("/feature-flags", adminHandler.UpdateFeatureFlags).Methods("PUT")
|
||||
// manage identity providers — admin-only
|
||||
admin.HandleFunc("/auth/providers", authHandler.CreateProvider).Methods("POST")
|
||||
|
||||
// Serve static files (frontend) for all other routes
|
||||
// This must be after all API route handlers to allow API routes to take precedence
|
||||
router.PathPrefix("/").HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// List of static file extensions to serve directly
|
||||
staticExts := map[string]bool{
|
||||
".js": true, ".css": true, ".svg": true, ".png": true,
|
||||
".jpg": true, ".jpeg": true, ".gif": true, ".ico": true,
|
||||
".woff": true, ".woff2": true, ".ttf": true, ".eot": true,
|
||||
}
|
||||
|
||||
filePath := "./public" + r.URL.Path
|
||||
if r.URL.Path == "/" {
|
||||
filePath = "./public/index.html"
|
||||
}
|
||||
|
||||
// Check if it's a static file (has an extension in staticExts)
|
||||
isStatic := false
|
||||
for ext := range staticExts {
|
||||
if len(r.URL.Path) > len(ext) {
|
||||
if r.URL.Path[len(r.URL.Path)-len(ext):] == ext {
|
||||
isStatic = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If it doesn't look like a static file, serve index.html (SPA routing)
|
||||
if !isStatic {
|
||||
filePath = "./public/index.html"
|
||||
}
|
||||
|
||||
http.ServeFile(w, r, filePath)
|
||||
})
|
||||
|
||||
// Start server
|
||||
server := &http.Server{
|
||||
Addr: ":" + port,
|
||||
Handler: router,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 15 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
log.Printf("Server starting on port %s\n", port)
|
||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("Server error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func ensureDefaultAdminUser(
|
||||
ctx context.Context,
|
||||
userRepo repositories.UserRepository,
|
||||
groupRepo repositories.GroupRepository,
|
||||
permissionService *services.PermissionService,
|
||||
passwordHasher *security.PasswordHasher,
|
||||
) error {
|
||||
adminEmail := strings.ToLower(strings.TrimSpace(os.Getenv("DEFAULT_ADMIN_EMAIL")))
|
||||
adminUsername := strings.TrimSpace(os.Getenv("DEFAULT_ADMIN_USERNAME"))
|
||||
adminPassword := strings.TrimSpace(os.Getenv("DEFAULT_ADMIN_PASSWORD"))
|
||||
adminFirstName := "System"
|
||||
adminLastName := "Admin"
|
||||
|
||||
if adminEmail == "" && adminUsername == "" && adminPassword == "" {
|
||||
log.Println("default admin bootstrap skipped (DEFAULT_ADMIN_* env vars not set)")
|
||||
return nil
|
||||
}
|
||||
|
||||
if adminEmail == "" || adminUsername == "" || adminPassword == "" {
|
||||
return errors.New("DEFAULT_ADMIN_EMAIL, DEFAULT_ADMIN_USERNAME and DEFAULT_ADMIN_PASSWORD must all be set")
|
||||
}
|
||||
|
||||
if len(adminPassword) < 8 {
|
||||
return errors.New("DEFAULT_ADMIN_PASSWORD must be at least 8 characters")
|
||||
}
|
||||
|
||||
adminGroup, err := groupRepo.GetGroupByName(ctx, "Admin")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user, err := userRepo.GetUserByEmail(ctx, adminEmail)
|
||||
if err != nil {
|
||||
if err.Error() != "user not found" {
|
||||
return err
|
||||
}
|
||||
|
||||
if existingUsernameUser, usernameErr := userRepo.GetUserByUsername(ctx, adminUsername); usernameErr == nil && existingUsernameUser != nil {
|
||||
return errors.New("DEFAULT_ADMIN_USERNAME already belongs to another user")
|
||||
} else if usernameErr != nil && usernameErr.Error() != "user not found" {
|
||||
return usernameErr
|
||||
}
|
||||
|
||||
hashedPassword, hashErr := passwordHasher.HashPassword(adminPassword)
|
||||
if hashErr != nil {
|
||||
return hashErr
|
||||
}
|
||||
|
||||
user = &entities.User{
|
||||
Email: adminEmail,
|
||||
Username: adminUsername,
|
||||
PasswordHash: hashedPassword,
|
||||
FirstName: adminFirstName,
|
||||
LastName: adminLastName,
|
||||
GroupIDs: []bson.ObjectID{adminGroup.ID},
|
||||
IsActive: true,
|
||||
EmailVerified: true,
|
||||
}
|
||||
|
||||
if createErr := userRepo.CreateUser(ctx, user); createErr != nil {
|
||||
return createErr
|
||||
}
|
||||
|
||||
if permissionService != nil {
|
||||
if permErr := permissionService.UpdateUserEffectivePermissions(ctx, user); permErr != nil {
|
||||
return permErr
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("default admin user created: %s", adminEmail)
|
||||
return nil
|
||||
}
|
||||
|
||||
modified := false
|
||||
if !user.IsActive {
|
||||
user.IsActive = true
|
||||
modified = true
|
||||
}
|
||||
|
||||
hasAdminGroup := false
|
||||
for _, groupID := range user.GroupIDs {
|
||||
if groupID == adminGroup.ID {
|
||||
hasAdminGroup = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasAdminGroup {
|
||||
user.GroupIDs = append(user.GroupIDs, adminGroup.ID)
|
||||
modified = true
|
||||
}
|
||||
|
||||
passwordMatches, verifyErr := passwordHasher.VerifyPassword(adminPassword, user.PasswordHash)
|
||||
if verifyErr != nil {
|
||||
return verifyErr
|
||||
}
|
||||
if !passwordMatches {
|
||||
hashedPassword, hashErr := passwordHasher.HashPassword(adminPassword)
|
||||
if hashErr != nil {
|
||||
return hashErr
|
||||
}
|
||||
user.PasswordHash = hashedPassword
|
||||
modified = true
|
||||
}
|
||||
|
||||
if !modified {
|
||||
log.Printf("default admin user already initialized: %s", adminEmail)
|
||||
return nil
|
||||
}
|
||||
|
||||
if permissionService != nil {
|
||||
if err := permissionService.UpdateUserEffectivePermissions(ctx, user); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := userRepo.UpdateUser(ctx, user); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("default admin user synchronized from environment: %s", adminEmail)
|
||||
return nil
|
||||
}
|
||||
23
backend/go.mod
Normal file
23
backend/go.mod
Normal file
@@ -0,0 +1,23 @@
|
||||
module github.com/noteapp/backend
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/golang-jwt/jwt/v5 v5.2.0
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/joho/godotenv v1.5.1
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0
|
||||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/oauth2 v0.30.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/klauspost/compress v1.17.6 // indirect
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||
github.com/xdg-go/scram v1.2.0 // indirect
|
||||
github.com/xdg-go/stringprep v1.0.4 // indirect
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
)
|
||||
56
backend/go.sum
Normal file
56
backend/go.sum
Normal file
@@ -0,0 +1,56 @@
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/klauspost/compress v1.17.6 h1:60eq2E/jlfwQXtvZEeBUYADs+BwKBWURIY+Gj2eRGjI=
|
||||
github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
|
||||
github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
|
||||
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
|
||||
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
440
backend/internal/application/dto/dto.go
Normal file
440
backend/internal/application/dto/dto.go
Normal file
@@ -0,0 +1,440 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"github.com/noteapp/backend/internal/domain/entities"
|
||||
)
|
||||
|
||||
// ========== AUTH DTOs ==========
|
||||
|
||||
// RegisterRequest represents a registration request
|
||||
type RegisterRequest struct {
|
||||
Email string `json:"email" validate:"required,email"`
|
||||
Username string `json:"username" validate:"required,min=3,max=20"`
|
||||
Password string `json:"password" validate:"required,min=8"`
|
||||
PasswordConfirm string `json:"password_confirm" validate:"required,eqfield=Password"`
|
||||
FirstName string `json:"first_name" validate:"max=50"`
|
||||
LastName string `json:"last_name" validate:"max=50"`
|
||||
}
|
||||
|
||||
// LoginRequest represents a login request
|
||||
type LoginRequest struct {
|
||||
Email string `json:"email" validate:"required,email"`
|
||||
Password string `json:"password" validate:"required"`
|
||||
}
|
||||
|
||||
// LoginResponse represents a login response
|
||||
type LoginResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
User *UserDTO `json:"user"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
|
||||
// AuthProviderDTO represents an OAuth/OIDC provider in API responses.
|
||||
type AuthProviderDTO struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
AuthorizationURL string `json:"authorization_url,omitempty"`
|
||||
TokenURL string `json:"token_url,omitempty"`
|
||||
UserInfoURL string `json:"userinfo_url,omitempty"`
|
||||
Scopes []string `json:"scopes"`
|
||||
IDTokenClaim string `json:"id_token_claim,omitempty"`
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
|
||||
// CreateAuthProviderRequest represents an OAuth/OIDC provider creation request.
|
||||
type CreateAuthProviderRequest struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
ClientID string `json:"client_id"`
|
||||
ClientSecret string `json:"client_secret"`
|
||||
AuthorizationURL string `json:"authorization_url"`
|
||||
TokenURL string `json:"token_url"`
|
||||
UserInfoURL string `json:"userinfo_url"`
|
||||
Scopes []string `json:"scopes"`
|
||||
IDTokenClaim string `json:"id_token_claim,omitempty"`
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
|
||||
// FeatureFlagsDTO represents app-wide feature flags in API responses.
|
||||
type FeatureFlagsDTO struct {
|
||||
RegistrationEnabled bool `json:"registration_enabled"`
|
||||
ProviderLoginEnabled bool `json:"provider_login_enabled"`
|
||||
PublicSharingEnabled bool `json:"public_sharing_enabled"`
|
||||
}
|
||||
|
||||
// UpdateFeatureFlagsRequest represents admin payload for feature flag updates.
|
||||
type UpdateFeatureFlagsRequest struct {
|
||||
RegistrationEnabled bool `json:"registration_enabled"`
|
||||
ProviderLoginEnabled bool `json:"provider_login_enabled"`
|
||||
PublicSharingEnabled bool `json:"public_sharing_enabled"`
|
||||
}
|
||||
|
||||
// UserDTO represents a user in API responses
|
||||
type UserDTO struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Username string `json:"username"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
Avatar string `json:"avatar,omitempty"`
|
||||
GroupIDs []string `json:"group_ids,omitempty"`
|
||||
Permissions []string `json:"permissions,omitempty"`
|
||||
EmailVerified bool `json:"email_verified"`
|
||||
}
|
||||
|
||||
// NewUserDTO creates a DTO from a user entity
|
||||
func NewUserDTO(user *entities.User) *UserDTO {
|
||||
groupIDs := make([]string, 0, len(user.GroupIDs))
|
||||
for _, groupID := range user.GroupIDs {
|
||||
groupIDs = append(groupIDs, groupID.Hex())
|
||||
}
|
||||
|
||||
return &UserDTO{
|
||||
ID: user.ID.Hex(),
|
||||
Email: user.Email,
|
||||
Username: user.Username,
|
||||
FirstName: user.FirstName,
|
||||
LastName: user.LastName,
|
||||
Avatar: user.Avatar,
|
||||
GroupIDs: groupIDs,
|
||||
Permissions: user.Permissions,
|
||||
EmailVerified: user.EmailVerified,
|
||||
}
|
||||
}
|
||||
|
||||
// AdminUserDTO extends UserDTO with admin-visible fields
|
||||
type AdminUserDTO struct {
|
||||
*UserDTO
|
||||
IsActive bool `json:"is_active"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// PermissionGroupDTO represents a permission group in API responses.
|
||||
type PermissionGroupDTO struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Permissions []string `json:"permissions"`
|
||||
IsSystem bool `json:"is_system"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// CreatePermissionGroupRequest represents group creation input.
|
||||
type CreatePermissionGroupRequest struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Permissions []string `json:"permissions"`
|
||||
}
|
||||
|
||||
// UpdatePermissionGroupRequest represents group update input.
|
||||
type UpdatePermissionGroupRequest struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Permissions []string `json:"permissions"`
|
||||
}
|
||||
|
||||
// UpdateUserGroupsRequest represents user group assignment input.
|
||||
type UpdateUserGroupsRequest struct {
|
||||
GroupIDs []string `json:"group_ids"`
|
||||
}
|
||||
|
||||
// NewAdminUserDTO creates an admin DTO from a user entity
|
||||
func NewAdminUserDTO(user *entities.User) *AdminUserDTO {
|
||||
return &AdminUserDTO{
|
||||
UserDTO: NewUserDTO(user),
|
||||
IsActive: user.IsActive,
|
||||
CreatedAt: user.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
}
|
||||
|
||||
// NewPermissionGroupDTO creates a DTO from a permission group entity.
|
||||
func NewPermissionGroupDTO(group *entities.PermissionGroup) *PermissionGroupDTO {
|
||||
return &PermissionGroupDTO{
|
||||
ID: group.ID.Hex(),
|
||||
Name: group.Name,
|
||||
Description: group.Description,
|
||||
Permissions: group.Permissions,
|
||||
IsSystem: group.IsSystem,
|
||||
CreatedAt: group.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
UpdatedAt: group.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
}
|
||||
|
||||
// AddSpaceMemberRequest represents a request to add a member to a space
|
||||
type AddSpaceMemberRequest struct {
|
||||
UserID string `json:"user_id"`
|
||||
}
|
||||
|
||||
// SpaceMemberDTO represents a member in a space
|
||||
type SpaceMemberDTO struct {
|
||||
UserID string `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
JoinedAt string `json:"joined_at"`
|
||||
}
|
||||
|
||||
// UserOptionDTO is a lightweight user object for dropdowns
|
||||
type UserOptionDTO struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
// NewAuthProviderDTO creates a DTO from an auth provider entity.
|
||||
func NewAuthProviderDTO(provider *entities.AuthProvider) *AuthProviderDTO {
|
||||
return &AuthProviderDTO{
|
||||
ID: provider.ID.Hex(),
|
||||
Name: provider.Name,
|
||||
Type: provider.Type,
|
||||
AuthorizationURL: provider.AuthorizationURL,
|
||||
TokenURL: provider.TokenURL,
|
||||
UserInfoURL: provider.UserInfoURL,
|
||||
Scopes: provider.Scopes,
|
||||
IDTokenClaim: provider.IDTokenClaim,
|
||||
IsActive: provider.IsActive,
|
||||
}
|
||||
}
|
||||
|
||||
// NewFeatureFlagsDTO creates a DTO from feature flags entity.
|
||||
func NewFeatureFlagsDTO(flags *entities.FeatureFlags) *FeatureFlagsDTO {
|
||||
if flags == nil {
|
||||
flags = entities.NewDefaultFeatureFlags()
|
||||
}
|
||||
|
||||
return &FeatureFlagsDTO{
|
||||
RegistrationEnabled: flags.RegistrationEnabled,
|
||||
ProviderLoginEnabled: flags.ProviderLoginEnabled,
|
||||
PublicSharingEnabled: flags.PublicSharingEnabled,
|
||||
}
|
||||
}
|
||||
|
||||
// ========== SPACE DTOs ==========
|
||||
|
||||
// CreateSpaceRequest represents a space creation request
|
||||
type CreateSpaceRequest struct {
|
||||
Name string `json:"name" validate:"required,min=1,max=100"`
|
||||
Description string `json:"description" validate:"max=500"`
|
||||
Icon string `json:"icon,omitempty" validate:"max=20"`
|
||||
IsPublic bool `json:"is_public"`
|
||||
}
|
||||
|
||||
// SpaceDTO represents a space in API responses
|
||||
type SpaceDTO struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
PermissionKey string `json:"permission_key"`
|
||||
Description string `json:"description"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
OwnerID string `json:"owner_id"`
|
||||
IsPublic bool `json:"is_public"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// NewSpaceDTO creates a DTO from a space entity
|
||||
func NewSpaceDTO(space *entities.Space) *SpaceDTO {
|
||||
dto := &SpaceDTO{
|
||||
ID: space.ID.Hex(),
|
||||
Name: space.Name,
|
||||
PermissionKey: entities.SpacePermissionToken(space.Name),
|
||||
Description: space.Description,
|
||||
Icon: space.Icon,
|
||||
OwnerID: space.OwnerID.Hex(),
|
||||
IsPublic: space.IsPublic,
|
||||
CreatedAt: space.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
UpdatedAt: space.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
return dto
|
||||
}
|
||||
|
||||
// ========== NOTE DTOs ==========
|
||||
|
||||
// CreateNoteRequest represents a note creation request
|
||||
type CreateNoteRequest struct {
|
||||
Title string `json:"title" validate:"required,min=1,max=255"`
|
||||
Description string `json:"description" validate:"max=500"`
|
||||
Content string `json:"content"`
|
||||
NotePassword string `json:"note_password,omitempty" validate:"omitempty,min=4,max=128"`
|
||||
Tags []string `json:"tags"`
|
||||
CategoryID *string `json:"category_id,omitempty"`
|
||||
IsPinned bool `json:"is_pinned"`
|
||||
IsFavorite bool `json:"is_favorite"`
|
||||
IsPublic bool `json:"is_public"`
|
||||
}
|
||||
|
||||
// UpdateNoteRequest represents a note update request
|
||||
type UpdateNoteRequest struct {
|
||||
Title string `json:"title" validate:"min=1,max=255"`
|
||||
Description *string `json:"description,omitempty" validate:"omitempty,max=500"`
|
||||
Content string `json:"content"`
|
||||
NotePassword *string `json:"note_password,omitempty" validate:"omitempty,max=128"`
|
||||
Tags []string `json:"tags"`
|
||||
CategoryID *string `json:"category_id,omitempty"`
|
||||
IsPinned *bool `json:"is_pinned"`
|
||||
IsFavorite *bool `json:"is_favorite"`
|
||||
IsPublic *bool `json:"is_public,omitempty"`
|
||||
}
|
||||
|
||||
// UnlockNoteRequest represents a password unlock request for protected notes
|
||||
type UnlockNoteRequest struct {
|
||||
Password string `json:"password" validate:"required,min=1,max=128"`
|
||||
}
|
||||
|
||||
// NoteDTO represents a note in API responses
|
||||
type NoteDTO struct {
|
||||
ID string `json:"id"`
|
||||
SpaceID string `json:"space_id"`
|
||||
CategoryID *string `json:"category_id,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Content string `json:"content"`
|
||||
Tags []string `json:"tags"`
|
||||
IsPinned bool `json:"is_pinned"`
|
||||
IsFavorite bool `json:"is_favorite"`
|
||||
IsPublic bool `json:"is_public"`
|
||||
IsPasswordProtected bool `json:"is_password_protected"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
UpdatedBy string `json:"updated_by"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// NoteListItemDTO represents a lightweight note payload for list/tree endpoints
|
||||
type NoteListItemDTO struct {
|
||||
ID string `json:"id"`
|
||||
SpaceID string `json:"space_id"`
|
||||
CategoryID *string `json:"category_id,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
IsPinned bool `json:"is_pinned"`
|
||||
IsFavorite bool `json:"is_favorite"`
|
||||
IsPublic bool `json:"is_public"`
|
||||
IsPasswordProtected bool `json:"is_password_protected"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// NewNoteDTO creates a DTO from a note entity
|
||||
func NewNoteDTO(note *entities.Note) *NoteDTO {
|
||||
var categoryID *string
|
||||
if note.CategoryID != nil {
|
||||
id := note.CategoryID.Hex()
|
||||
categoryID = &id
|
||||
}
|
||||
return &NoteDTO{
|
||||
ID: note.ID.Hex(),
|
||||
SpaceID: note.SpaceID.Hex(),
|
||||
CategoryID: categoryID,
|
||||
Title: note.Title,
|
||||
Description: note.Description,
|
||||
Content: note.Content,
|
||||
Tags: note.Tags,
|
||||
IsPinned: note.IsPinned,
|
||||
IsFavorite: note.IsFavorite,
|
||||
IsPublic: note.IsPublic,
|
||||
IsPasswordProtected: note.IsPasswordProtected,
|
||||
CreatedBy: note.CreatedBy.Hex(),
|
||||
UpdatedBy: note.UpdatedBy.Hex(),
|
||||
CreatedAt: note.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
UpdatedAt: note.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
}
|
||||
|
||||
// NewNoteListItemDTO creates a lightweight DTO from a note entity
|
||||
func NewNoteListItemDTO(note *entities.Note) *NoteListItemDTO {
|
||||
var categoryID *string
|
||||
if note.CategoryID != nil {
|
||||
id := note.CategoryID.Hex()
|
||||
categoryID = &id
|
||||
}
|
||||
|
||||
return &NoteListItemDTO{
|
||||
ID: note.ID.Hex(),
|
||||
SpaceID: note.SpaceID.Hex(),
|
||||
CategoryID: categoryID,
|
||||
Title: note.Title,
|
||||
Description: note.Description,
|
||||
IsPinned: note.IsPinned,
|
||||
IsFavorite: note.IsFavorite,
|
||||
IsPublic: note.IsPublic,
|
||||
IsPasswordProtected: note.IsPasswordProtected,
|
||||
UpdatedAt: note.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
}
|
||||
|
||||
// ========== CATEGORY DTOs ==========
|
||||
|
||||
// CreateCategoryRequest represents a category creation request
|
||||
type CreateCategoryRequest struct {
|
||||
Name string `json:"name" validate:"required,min=1,max=100"`
|
||||
Description string `json:"description" validate:"max=500"`
|
||||
ParentID *string `json:"parent_id,omitempty"`
|
||||
Icon string `json:"icon,omitempty" validate:"max=20"`
|
||||
}
|
||||
|
||||
// UpdateCategoryRequest represents a category update request
|
||||
type UpdateCategoryRequest struct {
|
||||
Name string `json:"name" validate:"min=1,max=100"`
|
||||
Description string `json:"description" validate:"max=500"`
|
||||
Icon string `json:"icon,omitempty" validate:"max=20"`
|
||||
}
|
||||
|
||||
// CategoryDTO represents a category in API responses
|
||||
type CategoryDTO struct {
|
||||
ID string `json:"id"`
|
||||
SpaceID string `json:"space_id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
ParentID *string `json:"parent_id,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Order int `json:"order"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// CategoryTreeDTO represents a category with its subcategories and notes
|
||||
type CategoryTreeDTO struct {
|
||||
*CategoryDTO
|
||||
Subcategories []*CategoryTreeDTO `json:"subcategories"`
|
||||
Notes []*NoteListItemDTO `json:"notes"`
|
||||
}
|
||||
|
||||
// NewCategoryDTO creates a DTO from a category entity
|
||||
func NewCategoryDTO(category *entities.Category) *CategoryDTO {
|
||||
var parentID *string
|
||||
if category.ParentID != nil {
|
||||
id := category.ParentID.Hex()
|
||||
parentID = &id
|
||||
}
|
||||
return &CategoryDTO{
|
||||
ID: category.ID.Hex(),
|
||||
SpaceID: category.SpaceID.Hex(),
|
||||
Name: category.Name,
|
||||
Description: category.Description,
|
||||
ParentID: parentID,
|
||||
Icon: category.Icon,
|
||||
Order: category.Order,
|
||||
CreatedAt: category.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
UpdatedAt: category.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
}
|
||||
|
||||
// ========== ERROR DTOs ==========
|
||||
|
||||
// ErrorResponse represents an error response
|
||||
type ErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
Message string `json:"message"`
|
||||
Code int `json:"code"`
|
||||
}
|
||||
|
||||
// ValidationError represents a validation error
|
||||
type ValidationError struct {
|
||||
Field string `json:"field"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// ValidationErrorResponse represents multiple validation errors
|
||||
type ValidationErrorResponse struct {
|
||||
Errors []ValidationError `json:"errors"`
|
||||
}
|
||||
313
backend/internal/application/services/admin_service.go
Normal file
313
backend/internal/application/services/admin_service.go
Normal file
@@ -0,0 +1,313 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
|
||||
"github.com/noteapp/backend/internal/application/dto"
|
||||
"github.com/noteapp/backend/internal/domain/entities"
|
||||
"github.com/noteapp/backend/internal/domain/repositories"
|
||||
)
|
||||
|
||||
// AdminService handles admin-level operations
|
||||
type AdminService struct {
|
||||
userRepo repositories.UserRepository
|
||||
groupRepo repositories.GroupRepository
|
||||
spaceRepo repositories.SpaceRepository
|
||||
membershipRepo repositories.MembershipRepository
|
||||
noteRepo repositories.NoteRepository
|
||||
categoryRepo repositories.CategoryRepository
|
||||
featureFlagRepo repositories.FeatureFlagRepository
|
||||
permissionService *PermissionService
|
||||
}
|
||||
|
||||
// NewAdminService creates a new AdminService
|
||||
func NewAdminService(
|
||||
userRepo repositories.UserRepository,
|
||||
groupRepo repositories.GroupRepository,
|
||||
spaceRepo repositories.SpaceRepository,
|
||||
membershipRepo repositories.MembershipRepository,
|
||||
noteRepo repositories.NoteRepository,
|
||||
categoryRepo repositories.CategoryRepository,
|
||||
featureFlagRepo repositories.FeatureFlagRepository,
|
||||
permissionService *PermissionService,
|
||||
) *AdminService {
|
||||
return &AdminService{
|
||||
userRepo: userRepo,
|
||||
groupRepo: groupRepo,
|
||||
spaceRepo: spaceRepo,
|
||||
membershipRepo: membershipRepo,
|
||||
noteRepo: noteRepo,
|
||||
categoryRepo: categoryRepo,
|
||||
featureFlagRepo: featureFlagRepo,
|
||||
permissionService: permissionService,
|
||||
}
|
||||
}
|
||||
|
||||
// ListUsers returns all users as admin DTOs
|
||||
func (s *AdminService) ListUsers(ctx context.Context) ([]*dto.AdminUserDTO, error) {
|
||||
users, err := s.userRepo.ListAllUsers(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]*dto.AdminUserDTO, len(users))
|
||||
for i, u := range users {
|
||||
if s.permissionService != nil {
|
||||
permissions, err := s.permissionService.GetUserEffectivePermissions(ctx, u)
|
||||
if err == nil {
|
||||
u.Permissions = permissions
|
||||
}
|
||||
}
|
||||
result[i] = dto.NewAdminUserDTO(u)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ListGroups returns all permission groups.
|
||||
func (s *AdminService) ListGroups(ctx context.Context) ([]*dto.PermissionGroupDTO, error) {
|
||||
groups, err := s.groupRepo.ListGroups(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]*dto.PermissionGroupDTO, len(groups))
|
||||
for i, group := range groups {
|
||||
result[i] = dto.NewPermissionGroupDTO(group)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// CreateGroup creates a new permission group.
|
||||
func (s *AdminService) CreateGroup(ctx context.Context, req *dto.CreatePermissionGroupRequest) (*dto.PermissionGroupDTO, error) {
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
return nil, errors.New("group name is required")
|
||||
}
|
||||
|
||||
group := &entities.PermissionGroup{
|
||||
Name: name,
|
||||
Description: strings.TrimSpace(req.Description),
|
||||
Permissions: normalizePermissions(req.Permissions),
|
||||
IsSystem: false,
|
||||
}
|
||||
|
||||
if err := s.groupRepo.CreateGroup(ctx, group); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return dto.NewPermissionGroupDTO(group), nil
|
||||
}
|
||||
|
||||
// UpdateGroup updates a permission group.
|
||||
func (s *AdminService) UpdateGroup(ctx context.Context, groupID bson.ObjectID, req *dto.UpdatePermissionGroupRequest) (*dto.PermissionGroupDTO, error) {
|
||||
group, err := s.groupRepo.GetGroupByID(ctx, groupID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if group.IsSystem {
|
||||
return nil, errors.New("system groups cannot be modified")
|
||||
}
|
||||
|
||||
if name := strings.TrimSpace(req.Name); name != "" {
|
||||
group.Name = name
|
||||
}
|
||||
group.Description = strings.TrimSpace(req.Description)
|
||||
group.Permissions = normalizePermissions(req.Permissions)
|
||||
|
||||
if err := s.groupRepo.UpdateGroup(ctx, group); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.refreshAllUserPermissions(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return dto.NewPermissionGroupDTO(group), nil
|
||||
}
|
||||
|
||||
// UpdateUserGroups assigns groups to a user.
|
||||
func (s *AdminService) UpdateUserGroups(ctx context.Context, userID bson.ObjectID, groupIDs []bson.ObjectID) (*dto.AdminUserDTO, error) {
|
||||
if s.permissionService == nil {
|
||||
return nil, errors.New("permission service unavailable")
|
||||
}
|
||||
|
||||
user, err := s.permissionService.SetUserGroups(ctx, userID, groupIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return dto.NewAdminUserDTO(user), nil
|
||||
}
|
||||
|
||||
func (s *AdminService) refreshAllUserPermissions(ctx context.Context) error {
|
||||
if s.permissionService == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
users, err := s.userRepo.ListAllUsers(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, user := range users {
|
||||
if err := s.permissionService.UpdateUserEffectivePermissions(ctx, user); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizePermissions(permissions []string) []string {
|
||||
unique := map[string]struct{}{}
|
||||
result := make([]string, 0, len(permissions))
|
||||
for _, permission := range permissions {
|
||||
normalized := entities.NormalizePermission(permission)
|
||||
if normalized == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := unique[normalized]; exists {
|
||||
continue
|
||||
}
|
||||
unique[normalized] = struct{}{}
|
||||
result = append(result, normalized)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ListAllSpaces returns all spaces
|
||||
func (s *AdminService) ListAllSpaces(ctx context.Context) ([]*dto.SpaceDTO, error) {
|
||||
spaces, err := s.spaceRepo.GetAllSpaces(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]*dto.SpaceDTO, len(spaces))
|
||||
for i, space := range spaces {
|
||||
result[i] = dto.NewSpaceDTO(space)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// UpdateSpace updates all editable space fields
|
||||
func (s *AdminService) UpdateSpace(ctx context.Context, spaceID bson.ObjectID, req *dto.CreateSpaceRequest) (*dto.SpaceDTO, error) {
|
||||
space, err := s.spaceRepo.GetSpaceByID(ctx, spaceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
space.Name = req.Name
|
||||
space.Description = req.Description
|
||||
space.Icon = req.Icon
|
||||
space.IsPublic = req.IsPublic
|
||||
|
||||
if err := s.spaceRepo.UpdateSpace(ctx, space); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return dto.NewSpaceDTO(space), nil
|
||||
}
|
||||
|
||||
// SetSpaceVisibility sets the is_public flag on a space
|
||||
func (s *AdminService) SetSpaceVisibility(ctx context.Context, spaceID bson.ObjectID, isPublic bool) error {
|
||||
space, err := s.spaceRepo.GetSpaceByID(ctx, spaceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
space.IsPublic = isPublic
|
||||
return s.spaceRepo.UpdateSpace(ctx, space)
|
||||
}
|
||||
|
||||
// AddSpaceMember adds a member in a space if not already present.
|
||||
func (s *AdminService) AddSpaceMember(ctx context.Context, spaceID, userID bson.ObjectID) error {
|
||||
existing, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID)
|
||||
if err == nil && existing != nil {
|
||||
return nil
|
||||
}
|
||||
return s.membershipRepo.CreateMembership(ctx, &entities.Membership{
|
||||
UserID: userID,
|
||||
SpaceID: spaceID,
|
||||
})
|
||||
}
|
||||
|
||||
// ListSpaceMembers returns all members for a space
|
||||
func (s *AdminService) ListSpaceMembers(ctx context.Context, spaceID bson.ObjectID) ([]*dto.SpaceMemberDTO, error) {
|
||||
memberships, err := s.membershipRepo.GetSpaceMembers(ctx, spaceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]*dto.SpaceMemberDTO, 0, len(memberships))
|
||||
for _, member := range memberships {
|
||||
username := member.UserID.Hex()
|
||||
if user, err := s.userRepo.GetUserByID(ctx, member.UserID); err == nil {
|
||||
username = user.Username
|
||||
}
|
||||
|
||||
result = append(result, &dto.SpaceMemberDTO{
|
||||
UserID: member.UserID.Hex(),
|
||||
Username: username,
|
||||
JoinedAt: member.JoinedAt.Format("2006-01-02T15:04:05Z"),
|
||||
})
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// RemoveSpaceMember removes a member from a space.
|
||||
func (s *AdminService) RemoveSpaceMember(ctx context.Context, spaceID, userID bson.ObjectID) error {
|
||||
membership, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.membershipRepo.DeleteMembership(ctx, membership.ID)
|
||||
}
|
||||
|
||||
// DeleteSpace deletes a space and all associated data (admin, no permission check).
|
||||
func (s *AdminService) DeleteSpace(ctx context.Context, spaceID bson.ObjectID) error {
|
||||
if err := s.noteRepo.DeleteNotesBySpaceID(ctx, spaceID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.categoryRepo.DeleteCategoriesBySpaceID(ctx, spaceID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.membershipRepo.DeleteMembershipsBySpaceID(ctx, spaceID); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.spaceRepo.DeleteSpace(ctx, spaceID)
|
||||
}
|
||||
|
||||
// GetFeatureFlags returns current app-wide feature flags.
|
||||
func (s *AdminService) GetFeatureFlags(ctx context.Context) (*dto.FeatureFlagsDTO, error) {
|
||||
if s.featureFlagRepo == nil {
|
||||
return dto.NewFeatureFlagsDTO(nil), nil
|
||||
}
|
||||
|
||||
flags, err := s.featureFlagRepo.GetFeatureFlags(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return dto.NewFeatureFlagsDTO(flags), nil
|
||||
}
|
||||
|
||||
// UpdateFeatureFlags updates app-wide feature flags.
|
||||
func (s *AdminService) UpdateFeatureFlags(ctx context.Context, req *dto.UpdateFeatureFlagsRequest) (*dto.FeatureFlagsDTO, error) {
|
||||
if s.featureFlagRepo == nil {
|
||||
return nil, errors.New("feature flags are unavailable")
|
||||
}
|
||||
|
||||
flags := &entities.FeatureFlags{
|
||||
RegistrationEnabled: req.RegistrationEnabled,
|
||||
ProviderLoginEnabled: req.ProviderLoginEnabled,
|
||||
PublicSharingEnabled: req.PublicSharingEnabled,
|
||||
}
|
||||
|
||||
if err := s.featureFlagRepo.UpdateFeatureFlags(ctx, flags); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return dto.NewFeatureFlagsDTO(flags), nil
|
||||
}
|
||||
592
backend/internal/application/services/auth_service.go
Normal file
592
backend/internal/application/services/auth_service.go
Normal file
@@ -0,0 +1,592 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/noteapp/backend/internal/application/dto"
|
||||
"github.com/noteapp/backend/internal/domain/entities"
|
||||
"github.com/noteapp/backend/internal/domain/repositories"
|
||||
"github.com/noteapp/backend/internal/infrastructure/auth"
|
||||
"github.com/noteapp/backend/internal/infrastructure/security"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// AuthService handles authentication operations
|
||||
type AuthService struct {
|
||||
userRepo repositories.UserRepository
|
||||
groupRepo repositories.GroupRepository
|
||||
providerRepo repositories.AuthProviderRepository
|
||||
linkRepo repositories.UserProviderLinkRepository
|
||||
recoveryRepo repositories.AccountRecoveryRepository
|
||||
featureFlagRepo repositories.FeatureFlagRepository
|
||||
permissionService *PermissionService
|
||||
jwtManager *auth.JWTManager
|
||||
passHasher *security.PasswordHasher
|
||||
encryptor *security.Encryptor
|
||||
}
|
||||
|
||||
// NewAuthService creates a new auth service
|
||||
func NewAuthService(
|
||||
userRepo repositories.UserRepository,
|
||||
groupRepo repositories.GroupRepository,
|
||||
providerRepo repositories.AuthProviderRepository,
|
||||
linkRepo repositories.UserProviderLinkRepository,
|
||||
recoveryRepo repositories.AccountRecoveryRepository,
|
||||
featureFlagRepo repositories.FeatureFlagRepository,
|
||||
permissionService *PermissionService,
|
||||
jwtManager *auth.JWTManager,
|
||||
passHasher *security.PasswordHasher,
|
||||
encryptor *security.Encryptor,
|
||||
) *AuthService {
|
||||
return &AuthService{
|
||||
userRepo: userRepo,
|
||||
groupRepo: groupRepo,
|
||||
providerRepo: providerRepo,
|
||||
linkRepo: linkRepo,
|
||||
recoveryRepo: recoveryRepo,
|
||||
featureFlagRepo: featureFlagRepo,
|
||||
permissionService: permissionService,
|
||||
jwtManager: jwtManager,
|
||||
passHasher: passHasher,
|
||||
encryptor: encryptor,
|
||||
}
|
||||
}
|
||||
|
||||
// Register registers a new user
|
||||
func (s *AuthService) Register(ctx context.Context, req *dto.RegisterRequest) (*dto.LoginResponse, error) {
|
||||
flags, err := s.GetFeatureFlags(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !flags.RegistrationEnabled {
|
||||
return nil, errors.New("registration is currently disabled")
|
||||
}
|
||||
|
||||
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
|
||||
req.Username = strings.TrimSpace(req.Username)
|
||||
|
||||
// Check if email already exists
|
||||
_, err = s.userRepo.GetUserByEmail(ctx, req.Email)
|
||||
if err == nil {
|
||||
return nil, errors.New("email already registered")
|
||||
}
|
||||
|
||||
// Check if username already exists
|
||||
_, err = s.userRepo.GetUserByUsername(ctx, req.Username)
|
||||
if err == nil {
|
||||
return nil, errors.New("username already taken")
|
||||
}
|
||||
|
||||
// Hash password
|
||||
hashedPassword, err := s.passHasher.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Create user
|
||||
user := &entities.User{
|
||||
Email: req.Email,
|
||||
Username: req.Username,
|
||||
PasswordHash: hashedPassword,
|
||||
FirstName: req.FirstName,
|
||||
LastName: req.LastName,
|
||||
IsActive: true,
|
||||
EmailVerified: false, // Should verify email in production
|
||||
}
|
||||
|
||||
if err := s.userRepo.CreateUser(ctx, user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if s.permissionService != nil {
|
||||
if err := s.permissionService.UpdateUserEffectivePermissions(ctx, user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Generate tokens
|
||||
accessToken, err := s.jwtManager.GenerateAccessToken(user.ID.Hex(), user.Email, user.Username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
refreshToken, err := s.jwtManager.GenerateRefreshToken(user.ID.Hex())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &dto.LoginResponse{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
User: dto.NewUserDTO(user),
|
||||
ExpiresIn: 3600, // 1 hour
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Login authenticates a user
|
||||
func (s *AuthService) Login(ctx context.Context, req *dto.LoginRequest) (*dto.LoginResponse, error) {
|
||||
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
|
||||
|
||||
// Get user by email
|
||||
user, err := s.userRepo.GetUserByEmail(ctx, req.Email)
|
||||
if err != nil {
|
||||
return nil, errors.New("invalid credentials")
|
||||
}
|
||||
|
||||
if !user.IsActive {
|
||||
return nil, errors.New("account is inactive")
|
||||
}
|
||||
|
||||
// Verify password
|
||||
match, err := s.passHasher.VerifyPassword(req.Password, user.PasswordHash)
|
||||
if err != nil || !match {
|
||||
return nil, errors.New("invalid credentials")
|
||||
}
|
||||
|
||||
// Update last login
|
||||
now := time.Now()
|
||||
user.LastLoginAt = &now
|
||||
if s.permissionService != nil {
|
||||
if err := s.permissionService.UpdateUserEffectivePermissions(ctx, user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := s.userRepo.UpdateUser(ctx, user); err != nil {
|
||||
// Log error but don't fail the login
|
||||
}
|
||||
|
||||
// Generate tokens
|
||||
accessToken, err := s.jwtManager.GenerateAccessToken(user.ID.Hex(), user.Email, user.Username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
refreshToken, err := s.jwtManager.GenerateRefreshToken(user.ID.Hex())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &dto.LoginResponse{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
User: dto.NewUserDTO(user),
|
||||
ExpiresIn: 3600,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RefreshAccessToken refreshes an access token
|
||||
func (s *AuthService) RefreshAccessToken(ctx context.Context, refreshToken string) (string, error) {
|
||||
claims, err := s.jwtManager.VerifyRefreshToken(refreshToken)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
user, err := s.userRepo.GetUserByID(ctx, mustParseObjectID(claims.UserID))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return s.jwtManager.GenerateAccessToken(user.ID.Hex(), user.Email, user.Username)
|
||||
}
|
||||
|
||||
// RequestPasswordReset initiates password reset flow
|
||||
func (s *AuthService) RequestPasswordReset(ctx context.Context, email string) error {
|
||||
user, err := s.userRepo.GetUserByEmail(ctx, email)
|
||||
if err != nil {
|
||||
// Don't reveal if email exists (security best practice)
|
||||
return nil
|
||||
}
|
||||
|
||||
token, err := auth.GenerateRandomToken(32)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
recovery := &entities.AccountRecovery{
|
||||
UserID: user.ID,
|
||||
Token: token,
|
||||
Type: "password_reset",
|
||||
ExpiresAt: time.Now().Add(1 * time.Hour),
|
||||
}
|
||||
|
||||
// Save recovery token
|
||||
// This would need AccountRecoveryRepository implementation
|
||||
_ = recovery
|
||||
|
||||
// In production: send email with reset link containing token
|
||||
return nil
|
||||
}
|
||||
|
||||
// mustParseObjectID parses a string to ObjectID, panics on error
|
||||
func mustParseObjectID(id string) bson.ObjectID {
|
||||
objID, _ := bson.ObjectIDFromHex(id)
|
||||
return objID
|
||||
}
|
||||
|
||||
// ListProviders returns all active OAuth/OIDC providers.
|
||||
func (s *AuthService) ListProviders(ctx context.Context) ([]*dto.AuthProviderDTO, error) {
|
||||
flags, err := s.GetFeatureFlags(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !flags.ProviderLoginEnabled {
|
||||
return []*dto.AuthProviderDTO{}, nil
|
||||
}
|
||||
|
||||
if s.providerRepo == nil {
|
||||
return []*dto.AuthProviderDTO{}, nil
|
||||
}
|
||||
|
||||
providers, err := s.providerRepo.GetAllProviders(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]*dto.AuthProviderDTO, 0, len(providers))
|
||||
for _, provider := range providers {
|
||||
result = append(result, dto.NewAuthProviderDTO(provider))
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetFeatureFlags returns current app-wide feature flags.
|
||||
func (s *AuthService) GetFeatureFlags(ctx context.Context) (*dto.FeatureFlagsDTO, error) {
|
||||
if s.featureFlagRepo == nil {
|
||||
return dto.NewFeatureFlagsDTO(nil), nil
|
||||
}
|
||||
|
||||
flags, err := s.featureFlagRepo.GetFeatureFlags(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return dto.NewFeatureFlagsDTO(flags), nil
|
||||
}
|
||||
|
||||
// CreateProvider stores a new OAuth/OIDC provider.
|
||||
func (s *AuthService) CreateProvider(ctx context.Context, req *dto.CreateAuthProviderRequest) (*dto.AuthProviderDTO, error) {
|
||||
if s.providerRepo == nil || s.encryptor == nil {
|
||||
return nil, errors.New("provider configuration unavailable")
|
||||
}
|
||||
|
||||
providerType := strings.ToLower(strings.TrimSpace(req.Type))
|
||||
if providerType != "oidc" && providerType != "oauth2" {
|
||||
return nil, errors.New("provider type must be oidc or oauth2")
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(req.Name)
|
||||
clientID := strings.TrimSpace(req.ClientID)
|
||||
clientSecret := strings.TrimSpace(req.ClientSecret)
|
||||
authorizationURL := strings.TrimSpace(req.AuthorizationURL)
|
||||
tokenURL := strings.TrimSpace(req.TokenURL)
|
||||
if name == "" || clientID == "" || clientSecret == "" || authorizationURL == "" || tokenURL == "" {
|
||||
return nil, errors.New("missing required provider fields")
|
||||
}
|
||||
|
||||
encryptedSecret, err := s.encryptor.Encrypt(clientSecret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
provider := &entities.AuthProvider{
|
||||
Name: name,
|
||||
Type: providerType,
|
||||
ClientID: clientID,
|
||||
ClientSecret: encryptedSecret,
|
||||
AuthorizationURL: authorizationURL,
|
||||
TokenURL: tokenURL,
|
||||
UserInfoURL: strings.TrimSpace(req.UserInfoURL),
|
||||
Scopes: normalizeScopes(req.Scopes, providerType),
|
||||
IDTokenClaim: strings.TrimSpace(req.IDTokenClaim),
|
||||
IsActive: req.IsActive,
|
||||
}
|
||||
|
||||
if err := s.providerRepo.CreateProvider(ctx, provider); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return dto.NewAuthProviderDTO(provider), nil
|
||||
}
|
||||
|
||||
// BuildProviderAuthorizationURL constructs a provider authorization URL.
|
||||
func (s *AuthService) BuildProviderAuthorizationURL(ctx context.Context, providerID bson.ObjectID, redirectURI, state string) (string, error) {
|
||||
flags, err := s.GetFeatureFlags(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !flags.ProviderLoginEnabled {
|
||||
return "", errors.New("provider login is currently disabled")
|
||||
}
|
||||
|
||||
provider, secret, err := s.getProviderConfig(ctx, providerID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
config := oauth2.Config{
|
||||
ClientID: provider.ClientID,
|
||||
ClientSecret: secret,
|
||||
RedirectURL: redirectURI,
|
||||
Scopes: normalizeScopes(provider.Scopes, provider.Type),
|
||||
Endpoint: oauth2.Endpoint{
|
||||
AuthURL: provider.AuthorizationURL,
|
||||
TokenURL: provider.TokenURL,
|
||||
},
|
||||
}
|
||||
|
||||
return config.AuthCodeURL(state, oauth2.AccessTypeOffline), nil
|
||||
}
|
||||
|
||||
// CompleteProviderLogin exchanges an auth code and creates a user session.
|
||||
func (s *AuthService) CompleteProviderLogin(ctx context.Context, providerID bson.ObjectID, code, redirectURI string) (*dto.LoginResponse, error) {
|
||||
if s.providerRepo == nil || s.linkRepo == nil {
|
||||
return nil, errors.New("provider login unavailable")
|
||||
}
|
||||
|
||||
flags, err := s.GetFeatureFlags(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !flags.ProviderLoginEnabled {
|
||||
return nil, errors.New("provider login is currently disabled")
|
||||
}
|
||||
|
||||
provider, secret, err := s.getProviderConfig(ctx, providerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
config := oauth2.Config{
|
||||
ClientID: provider.ClientID,
|
||||
ClientSecret: secret,
|
||||
RedirectURL: redirectURI,
|
||||
Scopes: normalizeScopes(provider.Scopes, provider.Type),
|
||||
Endpoint: oauth2.Endpoint{
|
||||
AuthURL: provider.AuthorizationURL,
|
||||
TokenURL: provider.TokenURL,
|
||||
},
|
||||
}
|
||||
|
||||
token, err := config.Exchange(ctx, code)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
profile, err := s.fetchProviderProfile(ctx, provider, token.AccessToken, token.Extra(provider.IDTokenClaim))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user, err := s.findOrCreateOAuthUser(ctx, provider, profile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
accessToken, err := s.jwtManager.GenerateAccessToken(user.ID.Hex(), user.Email, user.Username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
refreshToken, err := s.jwtManager.GenerateRefreshToken(user.ID.Hex())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &dto.LoginResponse{AccessToken: accessToken, RefreshToken: refreshToken, User: dto.NewUserDTO(user), ExpiresIn: 3600}, nil
|
||||
}
|
||||
|
||||
type providerProfile struct {
|
||||
ProviderUserID string
|
||||
Email string
|
||||
Username string
|
||||
FirstName string
|
||||
LastName string
|
||||
}
|
||||
|
||||
func (s *AuthService) getProviderConfig(ctx context.Context, providerID bson.ObjectID) (*entities.AuthProvider, string, error) {
|
||||
provider, err := s.providerRepo.GetProviderByID(ctx, providerID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if !provider.IsActive {
|
||||
return nil, "", errors.New("provider is inactive")
|
||||
}
|
||||
|
||||
secret, err := s.encryptor.Decrypt(provider.ClientSecret)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
return provider, secret, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) fetchProviderProfile(ctx context.Context, provider *entities.AuthProvider, accessToken string, rawIDToken any) (*providerProfile, error) {
|
||||
payload := map[string]any{}
|
||||
|
||||
if provider.UserInfoURL != "" {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, provider.UserInfoURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("provider userinfo request failed: %s", string(body))
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if idToken, ok := rawIDToken.(string); ok && idToken != "" {
|
||||
payload = decodeJWTWithoutVerify(idToken)
|
||||
} else {
|
||||
return nil, errors.New("provider must define userinfo_url or return id_token")
|
||||
}
|
||||
|
||||
profile := &providerProfile{
|
||||
ProviderUserID: firstNonEmpty(asString(payload["sub"]), asString(payload["id"]), asString(payload["user_id"])),
|
||||
Email: strings.ToLower(strings.TrimSpace(firstNonEmpty(asString(payload["email"]), asString(payload["upn"])))),
|
||||
Username: firstNonEmpty(asString(payload["preferred_username"]), asString(payload["login"]), asString(payload["name"])),
|
||||
FirstName: firstNonEmpty(asString(payload["given_name"]), asString(payload["first_name"])),
|
||||
LastName: firstNonEmpty(asString(payload["family_name"]), asString(payload["last_name"])),
|
||||
}
|
||||
|
||||
if profile.ProviderUserID == "" {
|
||||
return nil, errors.New("provider user info missing subject identifier")
|
||||
}
|
||||
if profile.Email == "" {
|
||||
profile.Email = fmt.Sprintf("%s@%s.oauth.local", sanitizeUsername(profile.ProviderUserID), sanitizeUsername(provider.Name))
|
||||
}
|
||||
if profile.Username == "" {
|
||||
profile.Username = strings.Split(profile.Email, "@")[0]
|
||||
}
|
||||
profile.Username = sanitizeUsername(profile.Username)
|
||||
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) findOrCreateOAuthUser(ctx context.Context, provider *entities.AuthProvider, profile *providerProfile) (*entities.User, error) {
|
||||
if link, err := s.linkRepo.GetLinkByProviderUserID(ctx, provider.ID, profile.ProviderUserID); err == nil {
|
||||
return s.userRepo.GetUserByID(ctx, link.UserID)
|
||||
}
|
||||
|
||||
user, err := s.userRepo.GetUserByEmail(ctx, profile.Email)
|
||||
if err != nil {
|
||||
username, err := s.generateUniqueUsername(ctx, profile.Username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user = &entities.User{Email: profile.Email, Username: username, PasswordHash: "", FirstName: profile.FirstName, LastName: profile.LastName, IsActive: true, EmailVerified: true}
|
||||
if err := s.userRepo.CreateUser(ctx, user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := s.linkRepo.GetLink(ctx, user.ID, provider.ID); err != nil {
|
||||
if err := s.linkRepo.CreateLink(ctx, &entities.UserProviderLink{UserID: user.ID, ProviderID: provider.ID, ProviderUserID: profile.ProviderUserID, Email: profile.Email}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) generateUniqueUsername(ctx context.Context, base string) (string, error) {
|
||||
base = sanitizeUsername(base)
|
||||
candidates := []string{base}
|
||||
for i := 0; i < 5; i++ {
|
||||
token, err := auth.GenerateRandomToken(2)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
candidates = append(candidates, fmt.Sprintf("%s-%s", base, token[:4]))
|
||||
}
|
||||
|
||||
for _, candidate := range candidates {
|
||||
if _, err := s.userRepo.GetUserByUsername(ctx, candidate); err != nil {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s-%d", base, time.Now().Unix()), nil
|
||||
}
|
||||
|
||||
func normalizeScopes(scopes []string, providerType string) []string {
|
||||
if len(scopes) == 0 {
|
||||
if providerType == "oidc" {
|
||||
return []string{"openid", "profile", "email"}
|
||||
}
|
||||
return []string{"profile", "email"}
|
||||
}
|
||||
|
||||
result := make([]string, 0, len(scopes))
|
||||
for _, scope := range scopes {
|
||||
scope = strings.TrimSpace(scope)
|
||||
if scope != "" {
|
||||
result = append(result, scope)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func decodeJWTWithoutVerify(token string) map[string]any {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) < 2 {
|
||||
return map[string]any{}
|
||||
}
|
||||
|
||||
decoded, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
|
||||
claims := map[string]any{}
|
||||
if err := json.Unmarshal(decoded, &claims); err != nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return claims
|
||||
}
|
||||
|
||||
func asString(value any) string {
|
||||
if str, ok := value.(string); ok {
|
||||
return strings.TrimSpace(str)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func sanitizeUsername(value string) string {
|
||||
cleaned := regexp.MustCompile(`[^a-zA-Z0-9_-]+`).ReplaceAllString(strings.ToLower(strings.TrimSpace(value)), "-")
|
||||
cleaned = strings.Trim(cleaned, "-")
|
||||
if cleaned == "" {
|
||||
return "user"
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
283
backend/internal/application/services/category_service.go
Normal file
283
backend/internal/application/services/category_service.go
Normal file
@@ -0,0 +1,283 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
|
||||
"github.com/noteapp/backend/internal/application/dto"
|
||||
"github.com/noteapp/backend/internal/domain/entities"
|
||||
"github.com/noteapp/backend/internal/domain/repositories"
|
||||
)
|
||||
|
||||
// CategoryService handles category operations
|
||||
type CategoryService struct {
|
||||
categoryRepo repositories.CategoryRepository
|
||||
membershipRepo repositories.MembershipRepository
|
||||
noteRepo repositories.NoteRepository
|
||||
permissionService *PermissionService
|
||||
}
|
||||
|
||||
// NewCategoryService creates a new category service
|
||||
func NewCategoryService(
|
||||
categoryRepo repositories.CategoryRepository,
|
||||
membershipRepo repositories.MembershipRepository,
|
||||
noteRepo repositories.NoteRepository,
|
||||
permissionService *PermissionService,
|
||||
) *CategoryService {
|
||||
return &CategoryService{
|
||||
categoryRepo: categoryRepo,
|
||||
membershipRepo: membershipRepo,
|
||||
noteRepo: noteRepo,
|
||||
permissionService: permissionService,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateCategory creates a new category
|
||||
func (s *CategoryService) CreateCategory(ctx context.Context, spaceID, userID bson.ObjectID, req *dto.CreateCategoryRequest) (*dto.CategoryDTO, error) {
|
||||
// Verify user has access to space
|
||||
membership, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID)
|
||||
if err != nil {
|
||||
return nil, errors.New("unauthorized")
|
||||
}
|
||||
_ = membership
|
||||
hasPermission, permErr := s.hasSpacePermission(ctx, userID, spaceID, "category.create")
|
||||
if permErr != nil {
|
||||
return nil, permErr
|
||||
}
|
||||
if !hasPermission {
|
||||
return nil, errors.New("insufficient permissions")
|
||||
}
|
||||
|
||||
var parentID *bson.ObjectID
|
||||
if req.ParentID != nil {
|
||||
id, _ := bson.ObjectIDFromHex(*req.ParentID)
|
||||
parentID = &id
|
||||
|
||||
// Verify parent category exists and belongs to same space
|
||||
parent, err := s.categoryRepo.GetCategoryByID(ctx, id)
|
||||
if err != nil || parent.SpaceID != spaceID {
|
||||
return nil, errors.New("invalid parent category")
|
||||
}
|
||||
}
|
||||
|
||||
// Get next order value
|
||||
categories, err := s.categoryRepo.GetCategoriesBySpaceID(ctx, spaceID)
|
||||
order := len(categories)
|
||||
|
||||
category := &entities.Category{
|
||||
SpaceID: spaceID,
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
ParentID: parentID,
|
||||
Icon: req.Icon,
|
||||
Order: order,
|
||||
CreatedBy: userID,
|
||||
UpdatedBy: userID,
|
||||
}
|
||||
|
||||
if err := s.categoryRepo.CreateCategory(ctx, category); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return dto.NewCategoryDTO(category), nil
|
||||
}
|
||||
|
||||
// GetCategoryTree retrieves the full tree structure for a space
|
||||
func (s *CategoryService) GetCategoryTree(ctx context.Context, spaceID, userID bson.ObjectID) ([]*dto.CategoryTreeDTO, error) {
|
||||
// Verify user has access to space
|
||||
if _, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID); err != nil {
|
||||
return nil, errors.New("unauthorized")
|
||||
}
|
||||
|
||||
// Get root categories
|
||||
categories, err := s.categoryRepo.GetRootCategories(ctx, spaceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var trees []*dto.CategoryTreeDTO
|
||||
for _, category := range categories {
|
||||
tree, err := s.buildCategoryTree(ctx, category, spaceID)
|
||||
if err == nil {
|
||||
trees = append(trees, tree)
|
||||
}
|
||||
}
|
||||
|
||||
return trees, nil
|
||||
}
|
||||
|
||||
// buildCategoryTree recursively builds a category tree
|
||||
func (s *CategoryService) buildCategoryTree(ctx context.Context, category *entities.Category, spaceID bson.ObjectID) (*dto.CategoryTreeDTO, error) {
|
||||
tree := &dto.CategoryTreeDTO{
|
||||
CategoryDTO: dto.NewCategoryDTO(category),
|
||||
}
|
||||
|
||||
// Get subcategories
|
||||
subcategories, err := s.categoryRepo.GetSubcategories(ctx, category.ID)
|
||||
if err == nil {
|
||||
for _, subcat := range subcategories {
|
||||
subtree, err := s.buildCategoryTree(ctx, subcat, spaceID)
|
||||
if err == nil {
|
||||
tree.Subcategories = append(tree.Subcategories, subtree)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get notes in this category
|
||||
notes, err := s.noteRepo.GetNotesByCategory(ctx, spaceID, category.ID)
|
||||
if err == nil {
|
||||
for _, note := range notes {
|
||||
tree.Notes = append(tree.Notes, dto.NewNoteListItemDTO(note))
|
||||
}
|
||||
}
|
||||
|
||||
return tree, nil
|
||||
}
|
||||
|
||||
// UpdateCategory updates a category
|
||||
func (s *CategoryService) UpdateCategory(ctx context.Context, categoryID, spaceID, userID bson.ObjectID, req *dto.UpdateCategoryRequest) (*dto.CategoryDTO, error) {
|
||||
// Verify user has access to space
|
||||
membership, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID)
|
||||
if err != nil {
|
||||
return nil, errors.New("unauthorized")
|
||||
}
|
||||
_ = membership
|
||||
hasPermission, permErr := s.hasSpacePermission(ctx, userID, spaceID, "category.edit")
|
||||
if permErr != nil {
|
||||
return nil, permErr
|
||||
}
|
||||
if !hasPermission {
|
||||
return nil, errors.New("insufficient permissions")
|
||||
}
|
||||
|
||||
category, err := s.categoryRepo.GetCategoryByID(ctx, categoryID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Verify category belongs to this space
|
||||
if category.SpaceID != spaceID {
|
||||
return nil, errors.New("category not found in this space")
|
||||
}
|
||||
|
||||
if req.Name != "" {
|
||||
category.Name = req.Name
|
||||
}
|
||||
if req.Description != "" {
|
||||
category.Description = req.Description
|
||||
}
|
||||
if req.Icon != "" {
|
||||
category.Icon = req.Icon
|
||||
}
|
||||
|
||||
category.UpdatedBy = userID
|
||||
|
||||
if err := s.categoryRepo.UpdateCategory(ctx, category); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return dto.NewCategoryDTO(category), nil
|
||||
}
|
||||
|
||||
// DeleteCategory deletes a category (and optionally move notes)
|
||||
func (s *CategoryService) DeleteCategory(ctx context.Context, categoryID, spaceID, userID bson.ObjectID, moveNotesTo *string) error {
|
||||
// Verify user has access to space
|
||||
membership, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID)
|
||||
if err != nil {
|
||||
return errors.New("unauthorized")
|
||||
}
|
||||
_ = membership
|
||||
hasPermission, permErr := s.hasSpacePermission(ctx, userID, spaceID, "category.delete")
|
||||
if permErr != nil {
|
||||
return permErr
|
||||
}
|
||||
if !hasPermission {
|
||||
return errors.New("insufficient permissions")
|
||||
}
|
||||
|
||||
category, err := s.categoryRepo.GetCategoryByID(ctx, categoryID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Verify category belongs to this space
|
||||
if category.SpaceID != spaceID {
|
||||
return errors.New("category not found in this space")
|
||||
}
|
||||
|
||||
// Handle notes in this category
|
||||
notes, err := s.noteRepo.GetNotesByCategory(ctx, spaceID, categoryID)
|
||||
if err == nil {
|
||||
for _, note := range notes {
|
||||
if moveNotesTo != nil {
|
||||
targetID, _ := bson.ObjectIDFromHex(*moveNotesTo)
|
||||
note.CategoryID = &targetID
|
||||
s.noteRepo.UpdateNote(ctx, note)
|
||||
} else {
|
||||
// Move to root (no category)
|
||||
note.CategoryID = nil
|
||||
s.noteRepo.UpdateNote(ctx, note)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return s.categoryRepo.DeleteCategory(ctx, categoryID)
|
||||
}
|
||||
|
||||
// MoveCategory moves a category to a new parent
|
||||
func (s *CategoryService) MoveCategory(ctx context.Context, categoryID, spaceID, userID bson.ObjectID, newParentID *string) (*dto.CategoryDTO, error) {
|
||||
// Verify user has access to space
|
||||
membership, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID)
|
||||
if err != nil {
|
||||
return nil, errors.New("unauthorized")
|
||||
}
|
||||
_ = membership
|
||||
hasPermission, permErr := s.hasSpacePermission(ctx, userID, spaceID, "category.edit")
|
||||
if permErr != nil {
|
||||
return nil, permErr
|
||||
}
|
||||
if !hasPermission {
|
||||
return nil, errors.New("insufficient permissions")
|
||||
}
|
||||
|
||||
category, err := s.categoryRepo.GetCategoryByID(ctx, categoryID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Verify category belongs to this space
|
||||
if category.SpaceID != spaceID {
|
||||
return nil, errors.New("category not found in this space")
|
||||
}
|
||||
|
||||
// Validate new parent
|
||||
if newParentID != nil {
|
||||
parentID, _ := bson.ObjectIDFromHex(*newParentID)
|
||||
parent, err := s.categoryRepo.GetCategoryByID(ctx, parentID)
|
||||
if err != nil || parent.SpaceID != spaceID {
|
||||
return nil, errors.New("invalid parent category")
|
||||
}
|
||||
category.ParentID = &parentID
|
||||
} else {
|
||||
category.ParentID = nil
|
||||
}
|
||||
|
||||
category.UpdatedBy = userID
|
||||
category.UpdatedAt = time.Now()
|
||||
|
||||
if err := s.categoryRepo.UpdateCategory(ctx, category); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return dto.NewCategoryDTO(category), nil
|
||||
}
|
||||
|
||||
func (s *CategoryService) hasSpacePermission(ctx context.Context, userID, spaceID bson.ObjectID, action string) (bool, error) {
|
||||
if s.permissionService == nil {
|
||||
return false, errors.New("permission service unavailable")
|
||||
}
|
||||
return s.permissionService.HasSpacePermission(ctx, userID, spaceID, action)
|
||||
}
|
||||
427
backend/internal/application/services/note_service.go
Normal file
427
backend/internal/application/services/note_service.go
Normal file
@@ -0,0 +1,427 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/noteapp/backend/internal/application/dto"
|
||||
"github.com/noteapp/backend/internal/domain/entities"
|
||||
"github.com/noteapp/backend/internal/domain/repositories"
|
||||
"github.com/noteapp/backend/internal/infrastructure/security"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// NoteService handles note operations
|
||||
type NoteService struct {
|
||||
noteRepo repositories.NoteRepository
|
||||
categoryRepo repositories.CategoryRepository
|
||||
membershipRepo repositories.MembershipRepository
|
||||
revisionRepo repositories.NoteRevisionRepository
|
||||
spaceRepo repositories.SpaceRepository
|
||||
permissionService *PermissionService
|
||||
passwordHasher *security.PasswordHasher
|
||||
}
|
||||
|
||||
// NewNoteService creates a new note service
|
||||
func NewNoteService(
|
||||
noteRepo repositories.NoteRepository,
|
||||
categoryRepo repositories.CategoryRepository,
|
||||
membershipRepo repositories.MembershipRepository,
|
||||
revisionRepo repositories.NoteRevisionRepository,
|
||||
spaceRepo repositories.SpaceRepository,
|
||||
permissionService *PermissionService,
|
||||
passwordHasher *security.PasswordHasher,
|
||||
) *NoteService {
|
||||
return &NoteService{
|
||||
noteRepo: noteRepo,
|
||||
categoryRepo: categoryRepo,
|
||||
membershipRepo: membershipRepo,
|
||||
revisionRepo: revisionRepo,
|
||||
spaceRepo: spaceRepo,
|
||||
permissionService: permissionService,
|
||||
passwordHasher: passwordHasher,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *NoteService) toDisplayNoteDTO(note *entities.Note) *dto.NoteDTO {
|
||||
noteDTO := dto.NewNoteDTO(note)
|
||||
if note.IsPasswordProtected {
|
||||
noteDTO.Content = ""
|
||||
}
|
||||
return noteDTO
|
||||
}
|
||||
|
||||
// CreateNote creates a new note
|
||||
func (s *NoteService) CreateNote(ctx context.Context, spaceID, userID bson.ObjectID, req *dto.CreateNoteRequest) (*dto.NoteDTO, error) {
|
||||
// Verify user has access to space
|
||||
membership, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID)
|
||||
if err != nil {
|
||||
return nil, errors.New("unauthorized")
|
||||
}
|
||||
_ = membership
|
||||
hasPermission, permErr := s.hasSpacePermission(ctx, userID, spaceID, "note.create")
|
||||
if permErr != nil {
|
||||
return nil, permErr
|
||||
}
|
||||
if !hasPermission {
|
||||
return nil, errors.New("insufficient permissions")
|
||||
}
|
||||
|
||||
var categoryID *bson.ObjectID
|
||||
if req.CategoryID != nil {
|
||||
id, _ := bson.ObjectIDFromHex(*req.CategoryID)
|
||||
categoryID = &id
|
||||
}
|
||||
|
||||
note := &entities.Note{
|
||||
SpaceID: spaceID,
|
||||
CategoryID: categoryID,
|
||||
Title: req.Title,
|
||||
Description: req.Description,
|
||||
Content: req.Content,
|
||||
Tags: req.Tags,
|
||||
IsPinned: req.IsPinned,
|
||||
IsFavorite: req.IsFavorite,
|
||||
IsPublic: req.IsPublic,
|
||||
CreatedBy: userID,
|
||||
UpdatedBy: userID,
|
||||
}
|
||||
|
||||
notePassword := strings.TrimSpace(req.NotePassword)
|
||||
if notePassword != "" {
|
||||
if len(notePassword) < 4 {
|
||||
return nil, errors.New("note password must be at least 4 characters")
|
||||
}
|
||||
if s.passwordHasher == nil {
|
||||
return nil, errors.New("password hasher unavailable")
|
||||
}
|
||||
hash, err := s.passwordHasher.HashPassword(notePassword)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
note.PasswordHash = hash
|
||||
note.IsPasswordProtected = true
|
||||
}
|
||||
|
||||
if err := s.noteRepo.CreateNote(ctx, note); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return dto.NewNoteDTO(note), nil
|
||||
}
|
||||
|
||||
// GetNote retrieves a note (with space authorization check)
|
||||
func (s *NoteService) GetNote(ctx context.Context, noteID, spaceID, userID bson.ObjectID) (*dto.NoteDTO, error) {
|
||||
// Verify user has access to space
|
||||
if _, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID); err != nil {
|
||||
return nil, errors.New("unauthorized")
|
||||
}
|
||||
|
||||
note, err := s.noteRepo.GetNoteByID(ctx, noteID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Verify note belongs to this space
|
||||
if note.SpaceID != spaceID {
|
||||
return nil, errors.New("note not found in this space")
|
||||
}
|
||||
|
||||
// Update viewed time
|
||||
now := time.Now()
|
||||
note.ViewedAt = &now
|
||||
_ = s.noteRepo.UpdateNote(ctx, note)
|
||||
|
||||
return s.toDisplayNoteDTO(note), nil
|
||||
}
|
||||
|
||||
// GetNotesBySpace retrieves notes in a space
|
||||
func (s *NoteService) GetNotesBySpace(ctx context.Context, spaceID, userID bson.ObjectID, skip, limit int) ([]*dto.NoteDTO, error) {
|
||||
// Verify user has access to space
|
||||
if _, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID); err != nil {
|
||||
return nil, errors.New("unauthorized")
|
||||
}
|
||||
|
||||
notes, err := s.noteRepo.GetNotesBySpaceID(ctx, spaceID, skip, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var noteDTOs []*dto.NoteDTO
|
||||
for _, note := range notes {
|
||||
noteDTOs = append(noteDTOs, s.toDisplayNoteDTO(note))
|
||||
}
|
||||
|
||||
return noteDTOs, nil
|
||||
}
|
||||
|
||||
// GetPublicNotesBySpace returns notes for a public space without requiring auth
|
||||
func (s *NoteService) GetPublicNotesBySpace(ctx context.Context, spaceID bson.ObjectID, skip, limit int) ([]*dto.NoteDTO, error) {
|
||||
space, err := s.spaceRepo.GetSpaceByID(ctx, spaceID)
|
||||
if err != nil {
|
||||
return nil, errors.New("space not found")
|
||||
}
|
||||
if !space.IsPublic {
|
||||
return nil, errors.New("space is not public")
|
||||
}
|
||||
|
||||
notes, err := s.noteRepo.GetPublicNotesBySpaceID(ctx, spaceID, skip, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var noteDTOs []*dto.NoteDTO
|
||||
for _, note := range notes {
|
||||
noteDTOs = append(noteDTOs, s.toDisplayNoteDTO(note))
|
||||
}
|
||||
return noteDTOs, nil
|
||||
}
|
||||
|
||||
// GetPublicNoteBySpaceAndID returns a single public note in a public space without requiring auth
|
||||
func (s *NoteService) GetPublicNoteBySpaceAndID(ctx context.Context, spaceID, noteID bson.ObjectID) (*dto.NoteDTO, error) {
|
||||
space, err := s.spaceRepo.GetSpaceByID(ctx, spaceID)
|
||||
if err != nil {
|
||||
return nil, errors.New("space not found")
|
||||
}
|
||||
if !space.IsPublic {
|
||||
return nil, errors.New("space is not public")
|
||||
}
|
||||
|
||||
note, err := s.noteRepo.GetNoteByID(ctx, noteID)
|
||||
if err != nil {
|
||||
return nil, errors.New("note not found")
|
||||
}
|
||||
if note.SpaceID != spaceID {
|
||||
return nil, errors.New("note not found")
|
||||
}
|
||||
if !note.IsPublic {
|
||||
return nil, errors.New("note is not public")
|
||||
}
|
||||
|
||||
return s.toDisplayNoteDTO(note), nil
|
||||
}
|
||||
|
||||
// SearchNotes performs full-text search on notes in a space
|
||||
func (s *NoteService) SearchNotes(ctx context.Context, spaceID, userID bson.ObjectID, query string) ([]*dto.NoteDTO, error) {
|
||||
// Verify user has access to space
|
||||
if _, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID); err != nil {
|
||||
return nil, errors.New("unauthorized")
|
||||
}
|
||||
|
||||
notes, err := s.noteRepo.SearchNotes(ctx, spaceID, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var noteDTOs []*dto.NoteDTO
|
||||
for _, note := range notes {
|
||||
noteDTOs = append(noteDTOs, s.toDisplayNoteDTO(note))
|
||||
}
|
||||
|
||||
return noteDTOs, nil
|
||||
}
|
||||
|
||||
// UpdateNote updates a note
|
||||
func (s *NoteService) UpdateNote(ctx context.Context, noteID, spaceID, userID bson.ObjectID, req *dto.UpdateNoteRequest) (*dto.NoteDTO, error) {
|
||||
// Verify user has access to space
|
||||
membership, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID)
|
||||
if err != nil {
|
||||
return nil, errors.New("unauthorized")
|
||||
}
|
||||
_ = membership
|
||||
hasPermission, permErr := s.hasSpacePermission(ctx, userID, spaceID, "note.edit")
|
||||
if permErr != nil {
|
||||
return nil, permErr
|
||||
}
|
||||
if !hasPermission {
|
||||
return nil, errors.New("insufficient permissions")
|
||||
}
|
||||
|
||||
note, err := s.noteRepo.GetNoteByID(ctx, noteID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Verify note belongs to this space
|
||||
if note.SpaceID != spaceID {
|
||||
return nil, errors.New("note not found in this space")
|
||||
}
|
||||
|
||||
// Create revision before updating
|
||||
if s.revisionRepo != nil {
|
||||
revision := &entities.NoteRevision{
|
||||
NoteID: note.ID,
|
||||
SpaceID: spaceID,
|
||||
Title: note.Title,
|
||||
Content: note.Content,
|
||||
ChangedBy: userID,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
_ = s.revisionRepo.CreateRevision(ctx, revision)
|
||||
}
|
||||
|
||||
// Update fields
|
||||
if req.Title != "" {
|
||||
note.Title = req.Title
|
||||
}
|
||||
if req.Content != "" {
|
||||
note.Content = req.Content
|
||||
}
|
||||
if req.Description != nil {
|
||||
note.Description = *req.Description
|
||||
}
|
||||
if req.Tags != nil {
|
||||
note.Tags = req.Tags
|
||||
}
|
||||
if req.CategoryID != nil {
|
||||
id, _ := bson.ObjectIDFromHex(*req.CategoryID)
|
||||
note.CategoryID = &id
|
||||
}
|
||||
if req.IsPinned != nil {
|
||||
note.IsPinned = *req.IsPinned
|
||||
}
|
||||
if req.IsFavorite != nil {
|
||||
note.IsFavorite = *req.IsFavorite
|
||||
}
|
||||
if req.IsPublic != nil {
|
||||
note.IsPublic = *req.IsPublic
|
||||
}
|
||||
if req.NotePassword != nil {
|
||||
if s.passwordHasher == nil {
|
||||
return nil, errors.New("password hasher unavailable")
|
||||
}
|
||||
notePassword := strings.TrimSpace(*req.NotePassword)
|
||||
if notePassword == "" {
|
||||
note.PasswordHash = ""
|
||||
note.IsPasswordProtected = false
|
||||
} else {
|
||||
if len(notePassword) < 4 {
|
||||
return nil, errors.New("note password must be at least 4 characters")
|
||||
}
|
||||
hash, hashErr := s.passwordHasher.HashPassword(notePassword)
|
||||
if hashErr != nil {
|
||||
return nil, hashErr
|
||||
}
|
||||
note.PasswordHash = hash
|
||||
note.IsPasswordProtected = true
|
||||
}
|
||||
}
|
||||
|
||||
note.UpdatedBy = userID
|
||||
|
||||
if err := s.noteRepo.UpdateNote(ctx, note); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return dto.NewNoteDTO(note), nil
|
||||
}
|
||||
|
||||
// UnlockNote verifies a protected note password and returns full note content for authenticated users.
|
||||
func (s *NoteService) UnlockNote(ctx context.Context, noteID, spaceID, userID bson.ObjectID, password string) (*dto.NoteDTO, error) {
|
||||
if _, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID); err != nil {
|
||||
return nil, errors.New("unauthorized")
|
||||
}
|
||||
|
||||
note, err := s.noteRepo.GetNoteByID(ctx, noteID)
|
||||
if err != nil {
|
||||
return nil, errors.New("note not found")
|
||||
}
|
||||
if note.SpaceID != spaceID {
|
||||
return nil, errors.New("note not found in this space")
|
||||
}
|
||||
if !note.IsPasswordProtected {
|
||||
return dto.NewNoteDTO(note), nil
|
||||
}
|
||||
if strings.TrimSpace(password) == "" {
|
||||
return nil, errors.New("password is required")
|
||||
}
|
||||
if s.passwordHasher == nil {
|
||||
return nil, errors.New("password hasher unavailable")
|
||||
}
|
||||
matched, verifyErr := s.passwordHasher.VerifyPassword(password, note.PasswordHash)
|
||||
if verifyErr != nil || !matched {
|
||||
return nil, errors.New("invalid note password")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
note.ViewedAt = &now
|
||||
_ = s.noteRepo.UpdateNote(ctx, note)
|
||||
|
||||
return dto.NewNoteDTO(note), nil
|
||||
}
|
||||
|
||||
// UnlockPublicNote verifies a protected public note password and returns full note content.
|
||||
func (s *NoteService) UnlockPublicNote(ctx context.Context, spaceID, noteID bson.ObjectID, password string) (*dto.NoteDTO, error) {
|
||||
space, err := s.spaceRepo.GetSpaceByID(ctx, spaceID)
|
||||
if err != nil {
|
||||
return nil, errors.New("space not found")
|
||||
}
|
||||
if !space.IsPublic {
|
||||
return nil, errors.New("space is not public")
|
||||
}
|
||||
|
||||
note, err := s.noteRepo.GetNoteByID(ctx, noteID)
|
||||
if err != nil {
|
||||
return nil, errors.New("note not found")
|
||||
}
|
||||
if note.SpaceID != spaceID || !note.IsPublic {
|
||||
return nil, errors.New("note not found")
|
||||
}
|
||||
if !note.IsPasswordProtected {
|
||||
return dto.NewNoteDTO(note), nil
|
||||
}
|
||||
if strings.TrimSpace(password) == "" {
|
||||
return nil, errors.New("password is required")
|
||||
}
|
||||
if s.passwordHasher == nil {
|
||||
return nil, errors.New("password hasher unavailable")
|
||||
}
|
||||
matched, verifyErr := s.passwordHasher.VerifyPassword(password, note.PasswordHash)
|
||||
if verifyErr != nil || !matched {
|
||||
return nil, errors.New("invalid note password")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
note.ViewedAt = &now
|
||||
_ = s.noteRepo.UpdateNote(ctx, note)
|
||||
|
||||
return dto.NewNoteDTO(note), nil
|
||||
}
|
||||
|
||||
// DeleteNote deletes a note
|
||||
func (s *NoteService) DeleteNote(ctx context.Context, noteID, spaceID, userID bson.ObjectID) error {
|
||||
// Verify user has access to space
|
||||
membership, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID)
|
||||
if err != nil {
|
||||
return errors.New("unauthorized")
|
||||
}
|
||||
_ = membership
|
||||
hasPermission, permErr := s.hasSpacePermission(ctx, userID, spaceID, "note.delete")
|
||||
if permErr != nil {
|
||||
return permErr
|
||||
}
|
||||
if !hasPermission {
|
||||
return errors.New("insufficient permissions")
|
||||
}
|
||||
|
||||
note, err := s.noteRepo.GetNoteByID(ctx, noteID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Verify note belongs to this space
|
||||
if note.SpaceID != spaceID {
|
||||
return errors.New("note not found in this space")
|
||||
}
|
||||
|
||||
return s.noteRepo.DeleteNote(ctx, noteID)
|
||||
}
|
||||
|
||||
func (s *NoteService) hasSpacePermission(ctx context.Context, userID, spaceID bson.ObjectID, action string) (bool, error) {
|
||||
if s.permissionService == nil {
|
||||
return false, errors.New("permission service unavailable")
|
||||
}
|
||||
return s.permissionService.HasSpacePermission(ctx, userID, spaceID, action)
|
||||
}
|
||||
174
backend/internal/application/services/permission_service.go
Normal file
174
backend/internal/application/services/permission_service.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/noteapp/backend/internal/domain/entities"
|
||||
"github.com/noteapp/backend/internal/domain/repositories"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
const adminGroupName = "Admin"
|
||||
|
||||
// PermissionService resolves and checks user permissions.
|
||||
type PermissionService struct {
|
||||
userRepo repositories.UserRepository
|
||||
groupRepo repositories.GroupRepository
|
||||
membershipRepo repositories.MembershipRepository
|
||||
spaceRepo repositories.SpaceRepository
|
||||
}
|
||||
|
||||
// NewPermissionService creates a permission service.
|
||||
func NewPermissionService(
|
||||
userRepo repositories.UserRepository,
|
||||
groupRepo repositories.GroupRepository,
|
||||
membershipRepo repositories.MembershipRepository,
|
||||
spaceRepo repositories.SpaceRepository,
|
||||
) *PermissionService {
|
||||
return &PermissionService{
|
||||
userRepo: userRepo,
|
||||
groupRepo: groupRepo,
|
||||
membershipRepo: membershipRepo,
|
||||
spaceRepo: spaceRepo,
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureAdminGroup ensures the built-in Admin group exists with full wildcard access.
|
||||
func (s *PermissionService) EnsureAdminGroup(ctx context.Context) error {
|
||||
adminGroup, err := s.groupRepo.GetGroupByName(ctx, adminGroupName)
|
||||
if err != nil {
|
||||
adminGroup = &entities.PermissionGroup{
|
||||
Name: adminGroupName,
|
||||
Description: "System group with full access",
|
||||
Permissions: []string{"*"},
|
||||
IsSystem: true,
|
||||
}
|
||||
if createErr := s.groupRepo.CreateGroup(ctx, adminGroup); createErr != nil {
|
||||
return createErr
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetUserEffectivePermissions returns a deduplicated list of permissions for a user.
|
||||
func (s *PermissionService) GetUserEffectivePermissions(ctx context.Context, user *entities.User) ([]string, error) {
|
||||
granted := make(map[string]struct{})
|
||||
|
||||
groups, err := s.groupRepo.GetGroupsByIDs(ctx, user.GroupIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, group := range groups {
|
||||
for _, permission := range group.Permissions {
|
||||
normalized := entities.NormalizePermission(permission)
|
||||
if normalized != "" {
|
||||
granted[normalized] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]string, 0, len(granted))
|
||||
for permission := range granted {
|
||||
result = append(result, permission)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// UpdateUserEffectivePermissions resolves and persists effective user permissions.
|
||||
func (s *PermissionService) UpdateUserEffectivePermissions(ctx context.Context, user *entities.User) error {
|
||||
permissions, err := s.GetUserEffectivePermissions(ctx, user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
user.Permissions = permissions
|
||||
return s.userRepo.UpdateUser(ctx, user)
|
||||
}
|
||||
|
||||
// SetUserGroups assigns groups to a user and refreshes permissions.
|
||||
func (s *PermissionService) SetUserGroups(ctx context.Context, userID bson.ObjectID, groupIDs []bson.ObjectID) (*entities.User, error) {
|
||||
user, err := s.userRepo.GetUserByID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(groupIDs) > 0 {
|
||||
groups, err := s.groupRepo.GetGroupsByIDs(ctx, groupIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(groups) != len(groupIDs) {
|
||||
return nil, errors.New("one or more groups not found")
|
||||
}
|
||||
}
|
||||
|
||||
user.GroupIDs = dedupeObjectIDs(groupIDs)
|
||||
if err := s.UpdateUserEffectivePermissions(ctx, user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// UserHasPermission checks if user has a concrete permission, supporting wildcards.
|
||||
func (s *PermissionService) UserHasPermission(ctx context.Context, userID bson.ObjectID, permission string) (bool, error) {
|
||||
user, err := s.userRepo.GetUserByID(ctx, userID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return s.UserEntityHasPermission(ctx, user, permission)
|
||||
}
|
||||
|
||||
// UserEntityHasPermission checks permission from a loaded user entity.
|
||||
func (s *PermissionService) UserEntityHasPermission(ctx context.Context, user *entities.User, permission string) (bool, error) {
|
||||
permission = entities.NormalizePermission(permission)
|
||||
if permission == "" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
granted, err := s.GetUserEffectivePermissions(ctx, user)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
for _, pattern := range granted {
|
||||
if entities.PermissionMatches(pattern, permission) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// HasSpacePermission checks a space-scoped permission action, like note.create.
|
||||
func (s *PermissionService) HasSpacePermission(ctx context.Context, userID, spaceID bson.ObjectID, action string) (bool, error) {
|
||||
space, err := s.spaceRepo.GetSpaceByID(ctx, spaceID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
action = strings.Trim(strings.ToLower(action), ". ")
|
||||
if action == "" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
permission := "space." + entities.SpacePermissionToken(space.Name) + "." + action
|
||||
return s.UserHasPermission(ctx, userID, permission)
|
||||
}
|
||||
|
||||
func dedupeObjectIDs(ids []bson.ObjectID) []bson.ObjectID {
|
||||
seen := map[bson.ObjectID]struct{}{}
|
||||
result := make([]bson.ObjectID, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if id.IsZero() {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[id]; exists {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
result = append(result, id)
|
||||
}
|
||||
return result
|
||||
}
|
||||
319
backend/internal/application/services/space_service.go
Normal file
319
backend/internal/application/services/space_service.go
Normal file
@@ -0,0 +1,319 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/noteapp/backend/internal/application/dto"
|
||||
"github.com/noteapp/backend/internal/domain/entities"
|
||||
"github.com/noteapp/backend/internal/domain/repositories"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// SpaceService handles space operations
|
||||
type SpaceService struct {
|
||||
spaceRepo repositories.SpaceRepository
|
||||
membershipRepo repositories.MembershipRepository
|
||||
noteRepo repositories.NoteRepository
|
||||
categoryRepo repositories.CategoryRepository
|
||||
userRepo repositories.UserRepository
|
||||
permissionService *PermissionService
|
||||
}
|
||||
|
||||
// NewSpaceService creates a new space service
|
||||
func NewSpaceService(
|
||||
spaceRepo repositories.SpaceRepository,
|
||||
membershipRepo repositories.MembershipRepository,
|
||||
noteRepo repositories.NoteRepository,
|
||||
categoryRepo repositories.CategoryRepository,
|
||||
userRepo repositories.UserRepository,
|
||||
permissionService *PermissionService,
|
||||
) *SpaceService {
|
||||
return &SpaceService{
|
||||
spaceRepo: spaceRepo,
|
||||
membershipRepo: membershipRepo,
|
||||
noteRepo: noteRepo,
|
||||
categoryRepo: categoryRepo,
|
||||
userRepo: userRepo,
|
||||
permissionService: permissionService,
|
||||
}
|
||||
}
|
||||
|
||||
// GetPublicSpace returns a single publicly accessible space
|
||||
func (s *SpaceService) GetPublicSpace(ctx context.Context, spaceID bson.ObjectID) (*dto.SpaceDTO, error) {
|
||||
space, err := s.spaceRepo.GetSpaceByID(ctx, spaceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !space.IsPublic {
|
||||
return nil, errors.New("space is not public")
|
||||
}
|
||||
return dto.NewSpaceDTO(space), nil
|
||||
}
|
||||
|
||||
// GetPublicSpaces returns all publicly accessible spaces
|
||||
func (s *SpaceService) GetPublicSpaces(ctx context.Context) ([]*dto.SpaceDTO, error) {
|
||||
spaces, err := s.spaceRepo.GetPublicSpaces(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]*dto.SpaceDTO, len(spaces))
|
||||
for i, space := range spaces {
|
||||
result[i] = dto.NewSpaceDTO(space)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// CreateSpace creates a new space owned by the user
|
||||
func (s *SpaceService) CreateSpace(ctx context.Context, userID bson.ObjectID, req *dto.CreateSpaceRequest) (*dto.SpaceDTO, error) {
|
||||
if allowed, err := s.canCreateSpace(ctx, userID); err != nil {
|
||||
return nil, err
|
||||
} else if !allowed {
|
||||
return nil, errors.New("insufficient permissions")
|
||||
}
|
||||
|
||||
space := &entities.Space{
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
Icon: req.Icon,
|
||||
OwnerID: userID,
|
||||
IsPublic: req.IsPublic,
|
||||
}
|
||||
|
||||
if err := s.spaceRepo.CreateSpace(ctx, space); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Add user as initial member
|
||||
membership := &entities.Membership{
|
||||
UserID: userID,
|
||||
SpaceID: space.ID,
|
||||
JoinedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := s.membershipRepo.CreateMembership(ctx, membership); err != nil {
|
||||
// Delete space if membership creation fails
|
||||
s.spaceRepo.DeleteSpace(ctx, space.ID)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return dto.NewSpaceDTO(space), nil
|
||||
}
|
||||
|
||||
// GetUserSpaces retrieves all spaces for a user
|
||||
func (s *SpaceService) GetUserSpaces(ctx context.Context, userID bson.ObjectID) ([]*dto.SpaceDTO, error) {
|
||||
// Get all memberships for the user
|
||||
memberships, err := s.membershipRepo.GetUserMemberships(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var spaceDTOs []*dto.SpaceDTO
|
||||
for _, membership := range memberships {
|
||||
space, err := s.spaceRepo.GetSpaceByID(ctx, membership.SpaceID)
|
||||
if err != nil {
|
||||
continue // Skip spaces that can't be loaded
|
||||
}
|
||||
|
||||
spaceDTOs = append(spaceDTOs, dto.NewSpaceDTO(space))
|
||||
}
|
||||
|
||||
return spaceDTOs, nil
|
||||
}
|
||||
|
||||
// GetSpaceByID gets a space by ID (with authorization check)
|
||||
func (s *SpaceService) GetSpaceByID(ctx context.Context, spaceID, userID bson.ObjectID) (*dto.SpaceDTO, error) {
|
||||
// Verify user has access to this space
|
||||
if _, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID); err != nil {
|
||||
return nil, errors.New("unauthorized")
|
||||
}
|
||||
|
||||
space, err := s.spaceRepo.GetSpaceByID(ctx, spaceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return dto.NewSpaceDTO(space), nil
|
||||
}
|
||||
|
||||
// UpdateSpace updates a space (owner only)
|
||||
func (s *SpaceService) UpdateSpace(ctx context.Context, spaceID, userID bson.ObjectID, updates *dto.CreateSpaceRequest) (*dto.SpaceDTO, error) {
|
||||
hasPermission, err := s.hasGlobalOrSpacePermission(ctx, userID, spaceID, "space.edit", "settings.edit")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !hasPermission {
|
||||
return nil, errors.New("unauthorized")
|
||||
}
|
||||
|
||||
space, err := s.spaceRepo.GetSpaceByID(ctx, spaceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
space.Name = updates.Name
|
||||
space.Description = updates.Description
|
||||
space.Icon = updates.Icon
|
||||
space.IsPublic = updates.IsPublic
|
||||
|
||||
if err := s.spaceRepo.UpdateSpace(ctx, space); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return dto.NewSpaceDTO(space), nil
|
||||
}
|
||||
|
||||
// DeleteSpace deletes a space (owner only)
|
||||
func (s *SpaceService) DeleteSpace(ctx context.Context, spaceID, userID bson.ObjectID) error {
|
||||
hasPermission, err := s.hasGlobalOrSpacePermission(ctx, userID, spaceID, "space.delete", "settings.delete")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !hasPermission {
|
||||
return errors.New("unauthorized")
|
||||
}
|
||||
|
||||
if err := s.noteRepo.DeleteNotesBySpaceID(ctx, spaceID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.categoryRepo.DeleteCategoriesBySpaceID(ctx, spaceID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.membershipRepo.DeleteMembershipsBySpaceID(ctx, spaceID); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.spaceRepo.DeleteSpace(ctx, spaceID)
|
||||
}
|
||||
|
||||
// AddMember adds a member to a space.
|
||||
func (s *SpaceService) AddMember(ctx context.Context, spaceID, userID, targetUserID bson.ObjectID) error {
|
||||
hasPermission, err := s.hasSpacePermission(ctx, userID, spaceID, "settings.member.manage")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !hasPermission {
|
||||
return errors.New("unauthorized")
|
||||
}
|
||||
|
||||
// Create membership for target user
|
||||
newMembership := &entities.Membership{
|
||||
UserID: targetUserID,
|
||||
SpaceID: spaceID,
|
||||
JoinedAt: time.Now(),
|
||||
InvitedBy: userID,
|
||||
}
|
||||
|
||||
return s.membershipRepo.CreateMembership(ctx, newMembership)
|
||||
}
|
||||
|
||||
// RemoveMember removes a member from a space (owner only)
|
||||
func (s *SpaceService) RemoveMember(ctx context.Context, spaceID, userID, targetUserID bson.ObjectID) error {
|
||||
hasPermission, err := s.hasSpacePermission(ctx, userID, spaceID, "settings.member.manage")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !hasPermission {
|
||||
return errors.New("unauthorized")
|
||||
}
|
||||
|
||||
// Get target membership
|
||||
targetMembership, err := s.membershipRepo.GetUserMembership(ctx, targetUserID, spaceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.membershipRepo.DeleteMembership(ctx, targetMembership.ID)
|
||||
}
|
||||
|
||||
// GetSpaceMembers returns all space members (owner only)
|
||||
func (s *SpaceService) GetSpaceMembers(ctx context.Context, spaceID, userID bson.ObjectID) ([]*dto.SpaceMemberDTO, error) {
|
||||
hasPermission, err := s.hasSpacePermission(ctx, userID, spaceID, "settings.member.view")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !hasPermission {
|
||||
return nil, errors.New("unauthorized")
|
||||
}
|
||||
|
||||
memberships, err := s.membershipRepo.GetSpaceMembers(ctx, spaceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]*dto.SpaceMemberDTO, 0, len(memberships))
|
||||
for _, member := range memberships {
|
||||
username := member.UserID.Hex()
|
||||
if user, err := s.userRepo.GetUserByID(ctx, member.UserID); err == nil {
|
||||
username = user.Username
|
||||
}
|
||||
result = append(result, &dto.SpaceMemberDTO{
|
||||
UserID: member.UserID.Hex(),
|
||||
Username: username,
|
||||
JoinedAt: member.JoinedAt.Format("2006-01-02T15:04:05Z"),
|
||||
})
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ListAvailableUsers returns all users for member selection (owner only)
|
||||
func (s *SpaceService) ListAvailableUsers(ctx context.Context, spaceID, userID bson.ObjectID) ([]*dto.UserOptionDTO, error) {
|
||||
hasPermission, err := s.hasSpacePermission(ctx, userID, spaceID, "settings.member.manage")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !hasPermission {
|
||||
return nil, errors.New("unauthorized")
|
||||
}
|
||||
|
||||
users, err := s.userRepo.ListAllUsers(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]*dto.UserOptionDTO, 0, len(users))
|
||||
for _, user := range users {
|
||||
result = append(result, &dto.UserOptionDTO{
|
||||
ID: user.ID.Hex(),
|
||||
Username: user.Username,
|
||||
})
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *SpaceService) canCreateSpace(ctx context.Context, userID bson.ObjectID) (bool, error) {
|
||||
if s.permissionService == nil {
|
||||
return false, errors.New("permission service unavailable")
|
||||
}
|
||||
|
||||
hasPermission, err := s.permissionService.UserHasPermission(ctx, userID, "space.create")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return hasPermission, nil
|
||||
}
|
||||
|
||||
func (s *SpaceService) hasSpacePermission(ctx context.Context, userID, spaceID bson.ObjectID, action string) (bool, error) {
|
||||
if s.permissionService == nil {
|
||||
return false, nil
|
||||
}
|
||||
return s.permissionService.HasSpacePermission(ctx, userID, spaceID, action)
|
||||
}
|
||||
|
||||
func (s *SpaceService) hasGlobalOrSpacePermission(ctx context.Context, userID, spaceID bson.ObjectID, globalPermission, spaceAction string) (bool, error) {
|
||||
if s.permissionService == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
hasGlobalPermission, err := s.permissionService.UserHasPermission(ctx, userID, globalPermission)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if hasGlobalPermission {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return s.permissionService.HasSpacePermission(ctx, userID, spaceID, spaceAction)
|
||||
}
|
||||
51
backend/internal/domain/entities/auth.go
Normal file
51
backend/internal/domain/entities/auth.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package entities
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// AuthProvider represents a configured OAuth/OIDC provider
|
||||
type AuthProvider struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty"`
|
||||
Name string `bson:"name"`
|
||||
Type string `bson:"type"` // "oidc", "oauth2"
|
||||
ClientID string `bson:"client_id"`
|
||||
ClientSecret string `bson:"client_secret"` // Encrypted in DB
|
||||
AuthorizationURL string `bson:"authorization_url"`
|
||||
TokenURL string `bson:"token_url"`
|
||||
UserInfoURL string `bson:"userinfo_url"`
|
||||
Scopes []string `bson:"scopes"`
|
||||
IDTokenClaim string `bson:"id_token_claim,omitempty"`
|
||||
IsActive bool `bson:"is_active"`
|
||||
CreatedAt time.Time `bson:"created_at"`
|
||||
UpdatedAt time.Time `bson:"updated_at"`
|
||||
}
|
||||
|
||||
// LoginAttempt tracks login attempts for brute-force protection
|
||||
type LoginAttempt struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty"`
|
||||
Email string `bson:"email"`
|
||||
IPAddress string `bson:"ip_address"`
|
||||
Success bool `bson:"success"`
|
||||
Reason string `bson:"reason,omitempty"`
|
||||
CreatedAt time.Time `bson:"created_at"`
|
||||
ExpiresAt time.Time `bson:"expires_at"`
|
||||
}
|
||||
|
||||
// FeatureFlags controls app-wide behavior toggles.
|
||||
type FeatureFlags struct {
|
||||
RegistrationEnabled bool `bson:"registration_enabled"`
|
||||
ProviderLoginEnabled bool `bson:"provider_login_enabled"`
|
||||
PublicSharingEnabled bool `bson:"public_sharing_enabled"`
|
||||
}
|
||||
|
||||
// NewDefaultFeatureFlags returns safe defaults for a new deployment.
|
||||
func NewDefaultFeatureFlags() *FeatureFlags {
|
||||
return &FeatureFlags{
|
||||
RegistrationEnabled: true,
|
||||
ProviderLoginEnabled: true,
|
||||
PublicSharingEnabled: true,
|
||||
}
|
||||
}
|
||||
55
backend/internal/domain/entities/note.go
Normal file
55
backend/internal/domain/entities/note.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package entities
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// Note represents a note within a space
|
||||
type Note struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty"`
|
||||
SpaceID bson.ObjectID `bson:"space_id"`
|
||||
CategoryID *bson.ObjectID `bson:"category_id,omitempty"`
|
||||
Title string `bson:"title"`
|
||||
Description string `bson:"description"`
|
||||
Content string `bson:"content"`
|
||||
PasswordHash string `bson:"password_hash,omitempty"`
|
||||
Tags []string `bson:"tags"`
|
||||
IsPinned bool `bson:"is_pinned"`
|
||||
IsFavorite bool `bson:"is_favorite"`
|
||||
IsPublic bool `bson:"is_public"`
|
||||
IsPasswordProtected bool `bson:"is_password_protected"`
|
||||
CreatedBy bson.ObjectID `bson:"created_by"`
|
||||
UpdatedBy bson.ObjectID `bson:"updated_by"`
|
||||
CreatedAt time.Time `bson:"created_at"`
|
||||
UpdatedAt time.Time `bson:"updated_at"`
|
||||
ViewedAt *time.Time `bson:"viewed_at,omitempty"`
|
||||
}
|
||||
|
||||
// Category represents a folder/category within a space
|
||||
type Category struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty"`
|
||||
SpaceID bson.ObjectID `bson:"space_id"`
|
||||
Name string `bson:"name"`
|
||||
Description string `bson:"description,omitempty"`
|
||||
ParentID *bson.ObjectID `bson:"parent_id,omitempty"`
|
||||
Icon string `bson:"icon,omitempty"`
|
||||
Order int `bson:"order"`
|
||||
CreatedBy bson.ObjectID `bson:"created_by"`
|
||||
UpdatedBy bson.ObjectID `bson:"updated_by"`
|
||||
CreatedAt time.Time `bson:"created_at"`
|
||||
UpdatedAt time.Time `bson:"updated_at"`
|
||||
}
|
||||
|
||||
// NoteRevision represents a historical version of a note
|
||||
type NoteRevision struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty"`
|
||||
NoteID bson.ObjectID `bson:"note_id"`
|
||||
SpaceID bson.ObjectID `bson:"space_id"`
|
||||
Title string `bson:"title"`
|
||||
Content string `bson:"content"`
|
||||
ChangedBy bson.ObjectID `bson:"changed_by"`
|
||||
CreatedAt time.Time `bson:"created_at"`
|
||||
ChangeRef string `bson:"change_ref,omitempty"`
|
||||
}
|
||||
83
backend/internal/domain/entities/permission_group.go
Normal file
83
backend/internal/domain/entities/permission_group.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package entities
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
var permissionTokenSanitizer = regexp.MustCompile(`[^a-z0-9_-]+`)
|
||||
|
||||
// PermissionGroup represents a named group of permissions.
|
||||
type PermissionGroup struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty"`
|
||||
Name string `bson:"name"`
|
||||
NameKey string `bson:"name_key"`
|
||||
Description string `bson:"description,omitempty"`
|
||||
Permissions []string `bson:"permissions"`
|
||||
IsSystem bool `bson:"is_system"`
|
||||
CreatedAt time.Time `bson:"created_at"`
|
||||
UpdatedAt time.Time `bson:"updated_at"`
|
||||
}
|
||||
|
||||
// NormalizePermission lowercases and trims a permission string.
|
||||
func NormalizePermission(permission string) string {
|
||||
return strings.ToLower(strings.TrimSpace(permission))
|
||||
}
|
||||
|
||||
// SpacePermissionToken converts a space name to a dot-safe permission token.
|
||||
func SpacePermissionToken(spaceName string) string {
|
||||
normalized := strings.ToLower(strings.TrimSpace(spaceName))
|
||||
normalized = strings.ReplaceAll(normalized, " ", "_")
|
||||
normalized = permissionTokenSanitizer.ReplaceAllString(normalized, "_")
|
||||
normalized = strings.Trim(normalized, "_")
|
||||
if normalized == "" {
|
||||
return "space"
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
// PermissionMatches reports whether a wildcard pattern matches a concrete permission.
|
||||
func PermissionMatches(pattern, permission string) bool {
|
||||
pattern = NormalizePermission(pattern)
|
||||
permission = NormalizePermission(permission)
|
||||
|
||||
if pattern == "" || permission == "" {
|
||||
return false
|
||||
}
|
||||
if pattern == "*" || pattern == permission {
|
||||
return true
|
||||
}
|
||||
if !strings.Contains(pattern, "*") {
|
||||
return false
|
||||
}
|
||||
|
||||
parts := strings.Split(pattern, "*")
|
||||
remaining := permission
|
||||
|
||||
if parts[0] != "" {
|
||||
if !strings.HasPrefix(remaining, parts[0]) {
|
||||
return false
|
||||
}
|
||||
remaining = remaining[len(parts[0]):]
|
||||
}
|
||||
|
||||
for i := 1; i < len(parts); i++ {
|
||||
part := parts[i]
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
idx := strings.Index(remaining, part)
|
||||
if idx < 0 {
|
||||
return false
|
||||
}
|
||||
remaining = remaining[idx+len(part):]
|
||||
}
|
||||
|
||||
if parts[len(parts)-1] != "" {
|
||||
return strings.HasSuffix(permission, parts[len(parts)-1])
|
||||
}
|
||||
return true
|
||||
}
|
||||
41
backend/internal/domain/entities/space.go
Normal file
41
backend/internal/domain/entities/space.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package entities
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// Space represents a top-level container for notes and categories
|
||||
type Space struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty"`
|
||||
Name string `bson:"name"`
|
||||
Description string `bson:"description,omitempty"`
|
||||
Icon string `bson:"icon,omitempty"`
|
||||
OwnerID bson.ObjectID `bson:"owner_id"`
|
||||
IsPublic bool `bson:"is_public"`
|
||||
CreatedAt time.Time `bson:"created_at"`
|
||||
UpdatedAt time.Time `bson:"updated_at"`
|
||||
}
|
||||
|
||||
// Membership represents a user's membership in a space
|
||||
type Membership struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty"`
|
||||
UserID bson.ObjectID `bson:"user_id"`
|
||||
SpaceID bson.ObjectID `bson:"space_id"`
|
||||
JoinedAt time.Time `bson:"joined_at"`
|
||||
InvitedBy bson.ObjectID `bson:"invited_by,omitempty"`
|
||||
InvitedAt *time.Time `bson:"invited_at,omitempty"`
|
||||
}
|
||||
|
||||
// SpaceInvitation represents an invitation to join a space
|
||||
type SpaceInvitation struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty"`
|
||||
SpaceID bson.ObjectID `bson:"space_id"`
|
||||
Email string `bson:"email"`
|
||||
Token string `bson:"token"`
|
||||
CreatedBy bson.ObjectID `bson:"created_by"`
|
||||
CreatedAt time.Time `bson:"created_at"`
|
||||
ExpiresAt time.Time `bson:"expires_at"`
|
||||
AcceptedAt *time.Time `bson:"accepted_at,omitempty"`
|
||||
}
|
||||
51
backend/internal/domain/entities/user.go
Normal file
51
backend/internal/domain/entities/user.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package entities
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// User represents a system user
|
||||
type User struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty"`
|
||||
Email string `bson:"email"`
|
||||
Username string `bson:"username"`
|
||||
PasswordHash string `bson:"password_hash"`
|
||||
FirstName string `bson:"first_name"`
|
||||
LastName string `bson:"last_name"`
|
||||
Avatar string `bson:"avatar,omitempty"`
|
||||
GroupIDs []bson.ObjectID `bson:"group_ids,omitempty"`
|
||||
Permissions []string `bson:"permissions,omitempty"`
|
||||
IsActive bool `bson:"is_active"`
|
||||
EmailVerified bool `bson:"email_verified"`
|
||||
CreatedAt time.Time `bson:"created_at"`
|
||||
UpdatedAt time.Time `bson:"updated_at"`
|
||||
LastLoginAt *time.Time `bson:"last_login_at,omitempty"`
|
||||
}
|
||||
|
||||
// UserProviderLink links external OAuth/OIDC providers to a user
|
||||
type UserProviderLink struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty"`
|
||||
UserID bson.ObjectID `bson:"user_id"`
|
||||
ProviderID bson.ObjectID `bson:"provider_id"`
|
||||
ProviderUserID string `bson:"provider_user_id"`
|
||||
Email string `bson:"email"`
|
||||
ProfileData map[string]any `bson:"profile_data,omitempty"`
|
||||
AccessToken string `bson:"access_token"` // Consider encrypting in production
|
||||
RefreshToken string `bson:"refresh_token,omitempty"`
|
||||
AccessTokenExp *time.Time `bson:"access_token_exp,omitempty"`
|
||||
LinkedAt time.Time `bson:"linked_at"`
|
||||
LastUsedAt *time.Time `bson:"last_used_at,omitempty"`
|
||||
}
|
||||
|
||||
// AccountRecovery represents account recovery tokens
|
||||
type AccountRecovery struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty"`
|
||||
UserID bson.ObjectID `bson:"user_id"`
|
||||
Token string `bson:"token"`
|
||||
Type string `bson:"type"` // "password_reset", "email_verification"
|
||||
ExpiresAt time.Time `bson:"expires_at"`
|
||||
UsedAt *time.Time `bson:"used_at,omitempty"`
|
||||
CreatedAt time.Time `bson:"created_at"`
|
||||
}
|
||||
40
backend/internal/domain/repositories/additional.go
Normal file
40
backend/internal/domain/repositories/additional.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/noteapp/backend/internal/domain/entities"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// AccountRecoveryRepository defines account recovery operations
|
||||
type AccountRecoveryRepository interface {
|
||||
CreateRecovery(ctx context.Context, recovery *entities.AccountRecovery) error
|
||||
GetRecoveryByToken(ctx context.Context, token string) (*entities.AccountRecovery, error)
|
||||
MarkRecoveryUsed(ctx context.Context, id bson.ObjectID) error
|
||||
}
|
||||
|
||||
// FeatureFlagRepository defines app feature-flag operations.
|
||||
type FeatureFlagRepository interface {
|
||||
GetFeatureFlags(ctx context.Context) (*entities.FeatureFlags, error)
|
||||
UpdateFeatureFlags(ctx context.Context, flags *entities.FeatureFlags) error
|
||||
}
|
||||
|
||||
// Additional repository extensions
|
||||
type (
|
||||
// SpaceRepository extensions
|
||||
SpaceRepositoryExt interface {
|
||||
SpaceRepository
|
||||
}
|
||||
|
||||
// MembershipRepository extensions
|
||||
MembershipRepositoryExt interface {
|
||||
MembershipRepository
|
||||
GetUserMemberships(ctx context.Context, userID bson.ObjectID) ([]*entities.Membership, error)
|
||||
}
|
||||
|
||||
// NoteRepository extensions
|
||||
NoteRepositoryExt interface {
|
||||
NoteRepository
|
||||
}
|
||||
)
|
||||
215
backend/internal/domain/repositories/interfaces.go
Normal file
215
backend/internal/domain/repositories/interfaces.go
Normal file
@@ -0,0 +1,215 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/noteapp/backend/internal/domain/entities"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// UserRepository defines user operations
|
||||
type UserRepository interface {
|
||||
// CreateUser creates a new user
|
||||
CreateUser(ctx context.Context, user *entities.User) error
|
||||
|
||||
// GetUserByID retrieves a user by ID
|
||||
GetUserByID(ctx context.Context, id bson.ObjectID) (*entities.User, error)
|
||||
|
||||
// GetUserByEmail retrieves a user by email
|
||||
GetUserByEmail(ctx context.Context, email string) (*entities.User, error)
|
||||
|
||||
// GetUserByUsername retrieves a user by username
|
||||
GetUserByUsername(ctx context.Context, username string) (*entities.User, error)
|
||||
|
||||
// UpdateUser updates a user
|
||||
UpdateUser(ctx context.Context, user *entities.User) error
|
||||
|
||||
// DeleteUser deletes a user
|
||||
DeleteUser(ctx context.Context, id bson.ObjectID) error
|
||||
|
||||
// ListAllUsers retrieves all users (admin use)
|
||||
ListAllUsers(ctx context.Context) ([]*entities.User, error)
|
||||
}
|
||||
|
||||
// GroupRepository defines permission group operations
|
||||
type GroupRepository interface {
|
||||
// CreateGroup creates a new permission group
|
||||
CreateGroup(ctx context.Context, group *entities.PermissionGroup) error
|
||||
|
||||
// GetGroupByID retrieves a group by ID
|
||||
GetGroupByID(ctx context.Context, id bson.ObjectID) (*entities.PermissionGroup, error)
|
||||
|
||||
// GetGroupByName retrieves a group by name
|
||||
GetGroupByName(ctx context.Context, name string) (*entities.PermissionGroup, error)
|
||||
|
||||
// GetGroupsByIDs retrieves groups by IDs
|
||||
GetGroupsByIDs(ctx context.Context, ids []bson.ObjectID) ([]*entities.PermissionGroup, error)
|
||||
|
||||
// ListGroups retrieves all groups
|
||||
ListGroups(ctx context.Context) ([]*entities.PermissionGroup, error)
|
||||
|
||||
// UpdateGroup updates an existing group
|
||||
UpdateGroup(ctx context.Context, group *entities.PermissionGroup) error
|
||||
|
||||
// DeleteGroup deletes a group
|
||||
DeleteGroup(ctx context.Context, id bson.ObjectID) error
|
||||
}
|
||||
|
||||
// SpaceRepository defines space operations
|
||||
type SpaceRepository interface {
|
||||
// CreateSpace creates a new space
|
||||
CreateSpace(ctx context.Context, space *entities.Space) error
|
||||
|
||||
// GetSpaceByID retrieves a space by ID
|
||||
GetSpaceByID(ctx context.Context, id bson.ObjectID) (*entities.Space, error)
|
||||
|
||||
// GetSpacesByUserID retrieves all spaces for a user
|
||||
GetSpacesByUserID(ctx context.Context, userID bson.ObjectID) ([]*entities.Space, error)
|
||||
|
||||
// GetAllSpaces retrieves all spaces (admin use)
|
||||
GetAllSpaces(ctx context.Context) ([]*entities.Space, error)
|
||||
|
||||
// GetPublicSpaces retrieves all spaces marked as public
|
||||
GetPublicSpaces(ctx context.Context) ([]*entities.Space, error)
|
||||
|
||||
// UpdateSpace updates a space
|
||||
UpdateSpace(ctx context.Context, space *entities.Space) error
|
||||
|
||||
// DeleteSpace deletes a space
|
||||
DeleteSpace(ctx context.Context, id bson.ObjectID) error
|
||||
}
|
||||
|
||||
// MembershipRepository defines membership operations
|
||||
type MembershipRepository interface {
|
||||
// CreateMembership creates a new membership
|
||||
CreateMembership(ctx context.Context, membership *entities.Membership) error
|
||||
|
||||
// GetMembershipByID retrieves a membership by ID
|
||||
GetMembershipByID(ctx context.Context, id bson.ObjectID) (*entities.Membership, error)
|
||||
|
||||
// GetUserMembership retrieves a membership for a user in a space
|
||||
GetUserMembership(ctx context.Context, userID, spaceID bson.ObjectID) (*entities.Membership, error)
|
||||
|
||||
// GetSpaceMembers retrieves all members in a space
|
||||
GetSpaceMembers(ctx context.Context, spaceID bson.ObjectID) ([]*entities.Membership, error)
|
||||
|
||||
// GetUserMemberships retrieves all memberships for a user
|
||||
GetUserMemberships(ctx context.Context, userID bson.ObjectID) ([]*entities.Membership, error)
|
||||
|
||||
// UpdateMembership updates a membership
|
||||
UpdateMembership(ctx context.Context, membership *entities.Membership) error
|
||||
|
||||
// DeleteMembership deletes a membership
|
||||
DeleteMembership(ctx context.Context, id bson.ObjectID) error
|
||||
|
||||
// DeleteMembershipsBySpaceID deletes all memberships for a space
|
||||
DeleteMembershipsBySpaceID(ctx context.Context, spaceID bson.ObjectID) error
|
||||
}
|
||||
|
||||
// NoteRepository defines note operations
|
||||
type NoteRepository interface {
|
||||
// CreateNote creates a new note
|
||||
CreateNote(ctx context.Context, note *entities.Note) error
|
||||
|
||||
// GetNoteByID retrieves a note by ID
|
||||
GetNoteByID(ctx context.Context, id bson.ObjectID) (*entities.Note, error)
|
||||
|
||||
// GetNotesBySpaceID retrieves all notes in a space
|
||||
GetNotesBySpaceID(ctx context.Context, spaceID bson.ObjectID, skip, limit int) ([]*entities.Note, error)
|
||||
|
||||
// GetPublicNotesBySpaceID retrieves public notes in a space
|
||||
GetPublicNotesBySpaceID(ctx context.Context, spaceID bson.ObjectID, skip, limit int) ([]*entities.Note, error)
|
||||
|
||||
// GetNotesByCategory retrieves notes in a category
|
||||
GetNotesByCategory(ctx context.Context, spaceID, categoryID bson.ObjectID) ([]*entities.Note, error)
|
||||
|
||||
// SearchNotes performs full-text search on notes
|
||||
SearchNotes(ctx context.Context, spaceID bson.ObjectID, query string) ([]*entities.Note, error)
|
||||
|
||||
// UpdateNote updates a note
|
||||
UpdateNote(ctx context.Context, note *entities.Note) error
|
||||
|
||||
// DeleteNote deletes a note
|
||||
DeleteNote(ctx context.Context, id bson.ObjectID) error
|
||||
|
||||
// DeleteNotesBySpaceID deletes all notes in a space
|
||||
DeleteNotesBySpaceID(ctx context.Context, spaceID bson.ObjectID) error
|
||||
}
|
||||
|
||||
// CategoryRepository defines category operations
|
||||
type CategoryRepository interface {
|
||||
// CreateCategory creates a new category
|
||||
CreateCategory(ctx context.Context, category *entities.Category) error
|
||||
|
||||
// GetCategoryByID retrieves a category by ID
|
||||
GetCategoryByID(ctx context.Context, id bson.ObjectID) (*entities.Category, error)
|
||||
|
||||
// GetCategoriesBySpaceID retrieves all categories in a space
|
||||
GetCategoriesBySpaceID(ctx context.Context, spaceID bson.ObjectID) ([]*entities.Category, error)
|
||||
|
||||
// GetRootCategories retrieves root level categories in a space
|
||||
GetRootCategories(ctx context.Context, spaceID bson.ObjectID) ([]*entities.Category, error)
|
||||
|
||||
// GetSubcategories retrieves subcategories of a category
|
||||
GetSubcategories(ctx context.Context, parentID bson.ObjectID) ([]*entities.Category, error)
|
||||
|
||||
// UpdateCategory updates a category
|
||||
UpdateCategory(ctx context.Context, category *entities.Category) error
|
||||
|
||||
// DeleteCategory deletes a category
|
||||
DeleteCategory(ctx context.Context, id bson.ObjectID) error
|
||||
|
||||
// DeleteCategoriesBySpaceID deletes all categories in a space
|
||||
DeleteCategoriesBySpaceID(ctx context.Context, spaceID bson.ObjectID) error
|
||||
}
|
||||
|
||||
// AuthProviderRepository defines auth provider operations
|
||||
type AuthProviderRepository interface {
|
||||
// CreateProvider creates a new auth provider
|
||||
CreateProvider(ctx context.Context, provider *entities.AuthProvider) error
|
||||
|
||||
// GetProviderByID retrieves a provider by ID
|
||||
GetProviderByID(ctx context.Context, id bson.ObjectID) (*entities.AuthProvider, error)
|
||||
|
||||
// GetAllProviders retrieves all active providers
|
||||
GetAllProviders(ctx context.Context) ([]*entities.AuthProvider, error)
|
||||
|
||||
// UpdateProvider updates a provider
|
||||
UpdateProvider(ctx context.Context, provider *entities.AuthProvider) error
|
||||
|
||||
// DeleteProvider deletes a provider
|
||||
DeleteProvider(ctx context.Context, id bson.ObjectID) error
|
||||
}
|
||||
|
||||
// UserProviderLinkRepository defines user provider link operations
|
||||
type UserProviderLinkRepository interface {
|
||||
// CreateLink creates a new user provider link
|
||||
CreateLink(ctx context.Context, link *entities.UserProviderLink) error
|
||||
|
||||
// GetLink retrieves a user provider link
|
||||
GetLink(ctx context.Context, userID, providerID bson.ObjectID) (*entities.UserProviderLink, error)
|
||||
|
||||
// GetLinkByProviderUserID retrieves a link by provider user ID
|
||||
GetLinkByProviderUserID(ctx context.Context, providerID bson.ObjectID, providerUserID string) (*entities.UserProviderLink, error)
|
||||
|
||||
// GetUserLinks retrieves all provider links for a user
|
||||
GetUserLinks(ctx context.Context, userID bson.ObjectID) ([]*entities.UserProviderLink, error)
|
||||
|
||||
// UpdateLink updates a provider link
|
||||
UpdateLink(ctx context.Context, link *entities.UserProviderLink) error
|
||||
|
||||
// DeleteLink deletes a provider link
|
||||
DeleteLink(ctx context.Context, id bson.ObjectID) error
|
||||
}
|
||||
|
||||
// NoteRevisionRepository defines note revision operations
|
||||
type NoteRevisionRepository interface {
|
||||
// CreateRevision creates a new note revision
|
||||
CreateRevision(ctx context.Context, revision *entities.NoteRevision) error
|
||||
|
||||
// GetRevisionsByNoteID retrieves all revisions for a note
|
||||
GetRevisionsByNoteID(ctx context.Context, noteID bson.ObjectID) ([]*entities.NoteRevision, error)
|
||||
|
||||
// GetRevisionByID retrieves a specific revision
|
||||
GetRevisionByID(ctx context.Context, id bson.ObjectID) (*entities.NoteRevision, error)
|
||||
}
|
||||
145
backend/internal/infrastructure/auth/jwt.go
Normal file
145
backend/internal/infrastructure/auth/jwt.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// JWTManager handles JWT token creation and verification
|
||||
type JWTManager struct {
|
||||
secretKey string
|
||||
issuer string
|
||||
duration time.Duration
|
||||
}
|
||||
|
||||
// JWTClaims represents custom JWT claims
|
||||
type JWTClaims struct {
|
||||
UserID string `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
Username string `json:"username"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// RefreshTokenClaims represents refresh token claims
|
||||
type RefreshTokenClaims struct {
|
||||
UserID string `json:"user_id"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// NewJWTManager creates a new JWT manager
|
||||
func NewJWTManager(secretKey, issuer string, duration time.Duration) *JWTManager {
|
||||
return &JWTManager{
|
||||
secretKey: secretKey,
|
||||
issuer: issuer,
|
||||
duration: duration,
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateAccessToken generates a new access token
|
||||
func (m *JWTManager) GenerateAccessToken(userID, email, username string) (string, error) {
|
||||
claims := JWTClaims{
|
||||
UserID: userID,
|
||||
Email: email,
|
||||
Username: username,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(m.duration)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
Issuer: m.issuer,
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(m.secretKey))
|
||||
}
|
||||
|
||||
// GenerateRefreshToken generates a new refresh token
|
||||
func (m *JWTManager) GenerateRefreshToken(userID string) (string, error) {
|
||||
claims := RefreshTokenClaims{
|
||||
UserID: userID,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour * 7)), // 7 days
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
Issuer: m.issuer,
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(m.secretKey))
|
||||
}
|
||||
|
||||
// VerifyAccessToken verifies and parses an access token
|
||||
func (m *JWTManager) VerifyAccessToken(tokenString string) (*JWTClaims, error) {
|
||||
claims := &JWTClaims{}
|
||||
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
return []byte(m.secretKey), nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !token.Valid {
|
||||
return nil, errors.New("invalid token")
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// VerifyRefreshToken verifies and parses a refresh token
|
||||
func (m *JWTManager) VerifyRefreshToken(tokenString string) (*RefreshTokenClaims, error) {
|
||||
claims := &RefreshTokenClaims{}
|
||||
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
return []byte(m.secretKey), nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !token.Valid {
|
||||
return nil, errors.New("invalid token")
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// GenerateRandomToken generates a random token for password reset, etc.
|
||||
func GenerateRandomToken(length int) (string, error) {
|
||||
token := make([]byte, length)
|
||||
if _, err := rand.Read(token); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(token), nil
|
||||
}
|
||||
|
||||
// GeneratePKCEChallenge generates a PKCE code challenge
|
||||
func GeneratePKCEChallenge() (codeVerifier, codeChallenge string, err error) {
|
||||
bytes := make([]byte, 32)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
codeVerifier = hex.EncodeToString(bytes)
|
||||
|
||||
// For code_challenge, we'd need base64url encoding of SHA256(verifier)
|
||||
// For simplicity in this example, using hex, but in production use base64url(sha256(verifier))
|
||||
codeChallenge = codeVerifier
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// GenerateStateToken generates a state token for OAuth/OIDC flows
|
||||
func GenerateStateToken() (string, error) {
|
||||
return GenerateRandomToken(16)
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
|
||||
"github.com/noteapp/backend/internal/domain/entities"
|
||||
)
|
||||
|
||||
// AccountRecoveryRepository implements account recovery operations
|
||||
type AccountRecoveryRepository struct {
|
||||
collection *mongo.Collection
|
||||
}
|
||||
|
||||
type featureFlagSettings struct {
|
||||
ID string `bson:"_id"`
|
||||
Flags entities.FeatureFlags `bson:"flags"`
|
||||
UpdatedAt time.Time `bson:"updated_at"`
|
||||
}
|
||||
|
||||
// FeatureFlagRepository implements app-wide feature flag operations.
|
||||
type FeatureFlagRepository struct {
|
||||
collection *mongo.Collection
|
||||
}
|
||||
|
||||
// NewFeatureFlagRepository creates a new feature flag repository.
|
||||
func NewFeatureFlagRepository(db *mongo.Database) *FeatureFlagRepository {
|
||||
return &FeatureFlagRepository{
|
||||
collection: db.Collection("app_settings"),
|
||||
}
|
||||
}
|
||||
|
||||
// GetFeatureFlags returns persisted feature flags or defaults when not set.
|
||||
func (r *FeatureFlagRepository) GetFeatureFlags(ctx context.Context) (*entities.FeatureFlags, error) {
|
||||
var settings featureFlagSettings
|
||||
err := r.collection.FindOne(ctx, bson.M{"_id": "feature_flags"}).Decode(&settings)
|
||||
if err != nil {
|
||||
if err == mongo.ErrNoDocuments {
|
||||
return entities.NewDefaultFeatureFlags(), nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
flags := settings.Flags
|
||||
return &flags, nil
|
||||
}
|
||||
|
||||
// UpdateFeatureFlags persists feature flags.
|
||||
func (r *FeatureFlagRepository) UpdateFeatureFlags(ctx context.Context, flags *entities.FeatureFlags) error {
|
||||
if flags == nil {
|
||||
flags = entities.NewDefaultFeatureFlags()
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
_, err := r.collection.UpdateOne(
|
||||
ctx,
|
||||
bson.M{"_id": "feature_flags"},
|
||||
bson.M{
|
||||
"$set": bson.M{
|
||||
"flags": flags,
|
||||
"updated_at": now,
|
||||
},
|
||||
},
|
||||
options.UpdateOne().SetUpsert(true),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// NewAccountRecoveryRepository creates a new recovery repository
|
||||
func NewAccountRecoveryRepository(db *mongo.Database) *AccountRecoveryRepository {
|
||||
return &AccountRecoveryRepository{
|
||||
collection: db.Collection("account_recovery"),
|
||||
}
|
||||
}
|
||||
|
||||
// CreateRecovery creates a new recovery token
|
||||
func (r *AccountRecoveryRepository) CreateRecovery(ctx context.Context, recovery *entities.AccountRecovery) error {
|
||||
recovery.ID = bson.NewObjectID()
|
||||
recovery.CreatedAt = time.Now()
|
||||
|
||||
_, err := r.collection.InsertOne(ctx, recovery)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetRecoveryByToken retrieves a recovery record by token
|
||||
func (r *AccountRecoveryRepository) GetRecoveryByToken(ctx context.Context, token string) (*entities.AccountRecovery, error) {
|
||||
var recovery entities.AccountRecovery
|
||||
err := r.collection.FindOne(ctx, bson.M{
|
||||
"token": token,
|
||||
"expires_at": bson.M{"$gt": time.Now()},
|
||||
"used_at": bson.M{"$exists": false},
|
||||
}).Decode(&recovery)
|
||||
|
||||
if err != nil {
|
||||
if err == mongo.ErrNoDocuments {
|
||||
return nil, errors.New("recovery token not found or expired")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &recovery, nil
|
||||
}
|
||||
|
||||
// MarkRecoveryUsed marks a recovery token as used
|
||||
func (r *AccountRecoveryRepository) MarkRecoveryUsed(ctx context.Context, id bson.ObjectID) error {
|
||||
now := time.Now()
|
||||
_, err := r.collection.UpdateOne(ctx, bson.M{"_id": id}, bson.M{
|
||||
"$set": bson.M{"used_at": now},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// NoteRevisionRepository implements note revision operations
|
||||
type NoteRevisionRepository struct {
|
||||
collection *mongo.Collection
|
||||
}
|
||||
|
||||
// NewNoteRevisionRepository creates a new revision repository
|
||||
func NewNoteRevisionRepository(db *mongo.Database) *NoteRevisionRepository {
|
||||
return &NoteRevisionRepository{
|
||||
collection: db.Collection("note_revisions"),
|
||||
}
|
||||
}
|
||||
|
||||
// CreateRevision creates a new note revision
|
||||
func (r *NoteRevisionRepository) CreateRevision(ctx context.Context, revision *entities.NoteRevision) error {
|
||||
revision.ID = bson.NewObjectID()
|
||||
revision.CreatedAt = time.Now()
|
||||
|
||||
_, err := r.collection.InsertOne(ctx, revision)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetRevisionsByNoteID retrieves all revisions for a note
|
||||
func (r *NoteRevisionRepository) GetRevisionsByNoteID(ctx context.Context, noteID bson.ObjectID) ([]*entities.NoteRevision, error) {
|
||||
var revisions []*entities.NoteRevision
|
||||
|
||||
cursor, err := r.collection.Find(ctx, bson.M{"note_id": noteID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cursor.Close(ctx)
|
||||
|
||||
if err = cursor.All(ctx, &revisions); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return revisions, nil
|
||||
}
|
||||
|
||||
// GetRevisionByID retrieves a specific revision
|
||||
func (r *NoteRevisionRepository) GetRevisionByID(ctx context.Context, id bson.ObjectID) (*entities.NoteRevision, error) {
|
||||
var revision entities.NoteRevision
|
||||
err := r.collection.FindOne(ctx, bson.M{"_id": id}).Decode(&revision)
|
||||
|
||||
if err != nil {
|
||||
if err == mongo.ErrNoDocuments {
|
||||
return nil, errors.New("revision not found")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &revision, nil
|
||||
}
|
||||
|
||||
// AuthProviderRepository implements auth provider operations
|
||||
type AuthProviderRepository struct {
|
||||
collection *mongo.Collection
|
||||
}
|
||||
|
||||
// NewAuthProviderRepository creates a new provider repository
|
||||
func NewAuthProviderRepository(db *mongo.Database) *AuthProviderRepository {
|
||||
return &AuthProviderRepository{
|
||||
collection: db.Collection("auth_providers"),
|
||||
}
|
||||
}
|
||||
|
||||
// CreateProvider creates a new provider
|
||||
func (r *AuthProviderRepository) CreateProvider(ctx context.Context, provider *entities.AuthProvider) error {
|
||||
provider.ID = bson.NewObjectID()
|
||||
provider.CreatedAt = time.Now()
|
||||
provider.UpdatedAt = time.Now()
|
||||
|
||||
_, err := r.collection.InsertOne(ctx, provider)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetProviderByID retrieves a provider by ID
|
||||
func (r *AuthProviderRepository) GetProviderByID(ctx context.Context, id bson.ObjectID) (*entities.AuthProvider, error) {
|
||||
var provider entities.AuthProvider
|
||||
err := r.collection.FindOne(ctx, bson.M{"_id": id}).Decode(&provider)
|
||||
|
||||
if err != nil {
|
||||
if err == mongo.ErrNoDocuments {
|
||||
return nil, errors.New("provider not found")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &provider, nil
|
||||
}
|
||||
|
||||
// GetAllProviders retrieves all active providers
|
||||
func (r *AuthProviderRepository) GetAllProviders(ctx context.Context) ([]*entities.AuthProvider, error) {
|
||||
var providers []*entities.AuthProvider
|
||||
|
||||
cursor, err := r.collection.Find(ctx, bson.M{"is_active": true})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cursor.Close(ctx)
|
||||
|
||||
if err = cursor.All(ctx, &providers); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return providers, nil
|
||||
}
|
||||
|
||||
// UpdateProvider updates a provider
|
||||
func (r *AuthProviderRepository) UpdateProvider(ctx context.Context, provider *entities.AuthProvider) error {
|
||||
provider.UpdatedAt = time.Now()
|
||||
_, err := r.collection.ReplaceOne(ctx, bson.M{"_id": provider.ID}, provider)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteProvider deletes a provider
|
||||
func (r *AuthProviderRepository) DeleteProvider(ctx context.Context, id bson.ObjectID) error {
|
||||
_, err := r.collection.DeleteOne(ctx, bson.M{"_id": id})
|
||||
return err
|
||||
}
|
||||
|
||||
// UserProviderLinkRepository implements user provider link operations
|
||||
type UserProviderLinkRepository struct {
|
||||
collection *mongo.Collection
|
||||
}
|
||||
|
||||
// NewUserProviderLinkRepository creates a new link repository
|
||||
func NewUserProviderLinkRepository(db *mongo.Database) *UserProviderLinkRepository {
|
||||
return &UserProviderLinkRepository{
|
||||
collection: db.Collection("user_provider_links"),
|
||||
}
|
||||
}
|
||||
|
||||
// CreateLink creates a new user provider link
|
||||
func (r *UserProviderLinkRepository) CreateLink(ctx context.Context, link *entities.UserProviderLink) error {
|
||||
link.ID = bson.NewObjectID()
|
||||
link.LinkedAt = time.Now()
|
||||
|
||||
_, err := r.collection.InsertOne(ctx, link)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetLink retrieves a user provider link
|
||||
func (r *UserProviderLinkRepository) GetLink(ctx context.Context, userID, providerID bson.ObjectID) (*entities.UserProviderLink, error) {
|
||||
var link entities.UserProviderLink
|
||||
err := r.collection.FindOne(ctx, bson.M{
|
||||
"user_id": userID,
|
||||
"provider_id": providerID,
|
||||
}).Decode(&link)
|
||||
|
||||
if err != nil {
|
||||
if err == mongo.ErrNoDocuments {
|
||||
return nil, errors.New("link not found")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &link, nil
|
||||
}
|
||||
|
||||
// GetLinkByProviderUserID retrieves a link by provider user ID
|
||||
func (r *UserProviderLinkRepository) GetLinkByProviderUserID(ctx context.Context, providerID bson.ObjectID, providerUserID string) (*entities.UserProviderLink, error) {
|
||||
var link entities.UserProviderLink
|
||||
err := r.collection.FindOne(ctx, bson.M{
|
||||
"provider_id": providerID,
|
||||
"provider_user_id": providerUserID,
|
||||
}).Decode(&link)
|
||||
|
||||
if err != nil {
|
||||
if err == mongo.ErrNoDocuments {
|
||||
return nil, errors.New("link not found")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &link, nil
|
||||
}
|
||||
|
||||
// GetUserLinks retrieves all provider links for a user
|
||||
func (r *UserProviderLinkRepository) GetUserLinks(ctx context.Context, userID bson.ObjectID) ([]*entities.UserProviderLink, error) {
|
||||
var links []*entities.UserProviderLink
|
||||
|
||||
cursor, err := r.collection.Find(ctx, bson.M{"user_id": userID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cursor.Close(ctx)
|
||||
|
||||
if err = cursor.All(ctx, &links); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return links, nil
|
||||
}
|
||||
|
||||
// UpdateLink updates a provider link
|
||||
func (r *UserProviderLinkRepository) UpdateLink(ctx context.Context, link *entities.UserProviderLink) error {
|
||||
_, err := r.collection.ReplaceOne(ctx, bson.M{"_id": link.ID}, link)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteLink deletes a provider link
|
||||
func (r *UserProviderLinkRepository) DeleteLink(ctx context.Context, id bson.ObjectID) error {
|
||||
_, err := r.collection.DeleteOne(ctx, bson.M{"_id": id})
|
||||
return err
|
||||
}
|
||||
92
backend/internal/infrastructure/database/database.go
Normal file
92
backend/internal/infrastructure/database/database.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
// Database holds all repository instances
|
||||
type Database struct {
|
||||
Client *mongo.Client
|
||||
DB *mongo.Database
|
||||
UserRepo *UserRepository
|
||||
SpaceRepo *SpaceRepository
|
||||
MembershipRepo *MembershipRepository
|
||||
NoteRepo *NoteRepository
|
||||
CategoryRepo *CategoryRepository
|
||||
RevisionRepo *NoteRevisionRepository
|
||||
GroupRepo *PermissionGroupRepository
|
||||
ProviderRepo *AuthProviderRepository
|
||||
LinkRepo *UserProviderLinkRepository
|
||||
RecoveryRepo *AccountRecoveryRepository
|
||||
FeatureFlagRepo *FeatureFlagRepository
|
||||
}
|
||||
|
||||
// NewDatabase initializes a new database connection and repositories
|
||||
func NewDatabase(ctx context.Context, mongoURL string) (*Database, error) {
|
||||
client, err := mongo.Connect(options.Client().ApplyURI(mongoURL))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Verify connection
|
||||
if err = client.Ping(ctx, nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
db := client.Database("noteapp")
|
||||
|
||||
// Create repositories
|
||||
database := &Database{
|
||||
Client: client,
|
||||
DB: db,
|
||||
UserRepo: NewUserRepository(db),
|
||||
SpaceRepo: NewSpaceRepository(db),
|
||||
MembershipRepo: NewMembershipRepository(db),
|
||||
NoteRepo: NewNoteRepository(db),
|
||||
CategoryRepo: NewCategoryRepository(db),
|
||||
RevisionRepo: NewNoteRevisionRepository(db),
|
||||
GroupRepo: NewPermissionGroupRepository(db),
|
||||
ProviderRepo: NewAuthProviderRepository(db),
|
||||
LinkRepo: NewUserProviderLinkRepository(db),
|
||||
RecoveryRepo: NewAccountRecoveryRepository(db),
|
||||
FeatureFlagRepo: NewFeatureFlagRepository(db),
|
||||
}
|
||||
|
||||
// Ensure all indexes are created
|
||||
if err := database.EnsureIndexes(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return database, nil
|
||||
}
|
||||
|
||||
// EnsureIndexes ensures all necessary indexes are created
|
||||
func (d *Database) EnsureIndexes(ctx context.Context) error {
|
||||
if err := d.UserRepo.EnsureIndexes(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := d.SpaceRepo.EnsureIndexes(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := d.MembershipRepo.EnsureIndexes(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := d.NoteRepo.EnsureIndexes(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := d.CategoryRepo.EnsureIndexes(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := d.GroupRepo.EnsureIndexes(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close closes the database connection
|
||||
func (d *Database) Close(ctx context.Context) error {
|
||||
return d.Client.Disconnect(ctx)
|
||||
}
|
||||
129
backend/internal/infrastructure/database/group_repository.go
Normal file
129
backend/internal/infrastructure/database/group_repository.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/noteapp/backend/internal/domain/entities"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
// PermissionGroupRepository implements permission group data access.
|
||||
type PermissionGroupRepository struct {
|
||||
collection *mongo.Collection
|
||||
}
|
||||
|
||||
// NewPermissionGroupRepository creates a new group repository.
|
||||
func NewPermissionGroupRepository(db *mongo.Database) *PermissionGroupRepository {
|
||||
return &PermissionGroupRepository{collection: db.Collection("permission_groups")}
|
||||
}
|
||||
|
||||
// CreateGroup creates a new permission group.
|
||||
func (r *PermissionGroupRepository) CreateGroup(ctx context.Context, group *entities.PermissionGroup) error {
|
||||
group.ID = bson.NewObjectID()
|
||||
group.Name = strings.TrimSpace(group.Name)
|
||||
group.NameKey = strings.ToLower(group.Name)
|
||||
group.CreatedAt = time.Now()
|
||||
group.UpdatedAt = time.Now()
|
||||
_, err := r.collection.InsertOne(ctx, group)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetGroupByID retrieves a group by ID.
|
||||
func (r *PermissionGroupRepository) GetGroupByID(ctx context.Context, id bson.ObjectID) (*entities.PermissionGroup, error) {
|
||||
var group entities.PermissionGroup
|
||||
err := r.collection.FindOne(ctx, bson.M{"_id": id}).Decode(&group)
|
||||
if err != nil {
|
||||
if err == mongo.ErrNoDocuments {
|
||||
return nil, errors.New("group not found")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &group, nil
|
||||
}
|
||||
|
||||
// GetGroupByName retrieves a group by case-insensitive name.
|
||||
func (r *PermissionGroupRepository) GetGroupByName(ctx context.Context, name string) (*entities.PermissionGroup, error) {
|
||||
normalized := strings.ToLower(strings.TrimSpace(name))
|
||||
var group entities.PermissionGroup
|
||||
err := r.collection.FindOne(ctx, bson.M{"name_key": normalized}).Decode(&group)
|
||||
if err != nil {
|
||||
if err == mongo.ErrNoDocuments {
|
||||
return nil, errors.New("group not found")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &group, nil
|
||||
}
|
||||
|
||||
// GetGroupsByIDs retrieves groups by IDs.
|
||||
func (r *PermissionGroupRepository) GetGroupsByIDs(ctx context.Context, ids []bson.ObjectID) ([]*entities.PermissionGroup, error) {
|
||||
if len(ids) == 0 {
|
||||
return []*entities.PermissionGroup{}, nil
|
||||
}
|
||||
|
||||
cursor, err := r.collection.Find(ctx, bson.M{"_id": bson.M{"$in": ids}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cursor.Close(ctx)
|
||||
|
||||
var groups []*entities.PermissionGroup
|
||||
if err := cursor.All(ctx, &groups); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
// ListGroups retrieves all groups sorted by name.
|
||||
func (r *PermissionGroupRepository) ListGroups(ctx context.Context) ([]*entities.PermissionGroup, error) {
|
||||
opts := options.Find().SetSort(bson.D{{Key: "name", Value: 1}})
|
||||
cursor, err := r.collection.Find(ctx, bson.M{}, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cursor.Close(ctx)
|
||||
|
||||
var groups []*entities.PermissionGroup
|
||||
if err := cursor.All(ctx, &groups); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
// UpdateGroup updates an existing group.
|
||||
func (r *PermissionGroupRepository) UpdateGroup(ctx context.Context, group *entities.PermissionGroup) error {
|
||||
group.UpdatedAt = time.Now()
|
||||
_, err := r.collection.UpdateOne(ctx, bson.M{"_id": group.ID}, bson.M{
|
||||
"$set": bson.M{
|
||||
"name": strings.TrimSpace(group.Name),
|
||||
"name_key": strings.ToLower(strings.TrimSpace(group.Name)),
|
||||
"description": group.Description,
|
||||
"permissions": group.Permissions,
|
||||
"is_system": group.IsSystem,
|
||||
"updated_at": group.UpdatedAt,
|
||||
},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteGroup deletes a group.
|
||||
func (r *PermissionGroupRepository) DeleteGroup(ctx context.Context, id bson.ObjectID) error {
|
||||
_, err := r.collection.DeleteOne(ctx, bson.M{"_id": id})
|
||||
return err
|
||||
}
|
||||
|
||||
// EnsureIndexes creates indexes for the permission groups collection.
|
||||
func (r *PermissionGroupRepository) EnsureIndexes(ctx context.Context) error {
|
||||
_, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{
|
||||
{
|
||||
Keys: bson.D{{Key: "name_key", Value: 1}},
|
||||
Options: options.Index().SetUnique(true),
|
||||
},
|
||||
})
|
||||
return err
|
||||
}
|
||||
338
backend/internal/infrastructure/database/note_repository.go
Normal file
338
backend/internal/infrastructure/database/note_repository.go
Normal file
@@ -0,0 +1,338 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
|
||||
"github.com/noteapp/backend/internal/domain/entities"
|
||||
)
|
||||
|
||||
// NoteRepository implements the note repository interface
|
||||
type NoteRepository struct {
|
||||
collection *mongo.Collection
|
||||
}
|
||||
|
||||
func notePrioritySortOptions(skip, limit int) *options.FindOptionsBuilder {
|
||||
return options.Find().
|
||||
SetSkip(int64(skip)).
|
||||
SetLimit(int64(limit)).
|
||||
SetSort(bson.D{
|
||||
{Key: "is_pinned", Value: -1},
|
||||
{Key: "is_favorite", Value: -1},
|
||||
{Key: "title", Value: 1},
|
||||
}).
|
||||
SetCollation(&options.Collation{Locale: "en", Strength: 2})
|
||||
}
|
||||
|
||||
// NewNoteRepository creates a new note repository
|
||||
func NewNoteRepository(db *mongo.Database) *NoteRepository {
|
||||
return &NoteRepository{
|
||||
collection: db.Collection("notes"),
|
||||
}
|
||||
}
|
||||
|
||||
// CreateNote creates a new note
|
||||
func (r *NoteRepository) CreateNote(ctx context.Context, note *entities.Note) error {
|
||||
note.ID = bson.NewObjectID()
|
||||
note.CreatedAt = time.Now()
|
||||
note.UpdatedAt = time.Now()
|
||||
|
||||
_, err := r.collection.InsertOne(ctx, note)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetNoteByID retrieves a note by ID
|
||||
func (r *NoteRepository) GetNoteByID(ctx context.Context, id bson.ObjectID) (*entities.Note, error) {
|
||||
var note entities.Note
|
||||
err := r.collection.FindOne(ctx, bson.M{"_id": id}).Decode(¬e)
|
||||
if err != nil {
|
||||
if err == mongo.ErrNoDocuments {
|
||||
return nil, errors.New("note not found")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return ¬e, nil
|
||||
}
|
||||
|
||||
// GetNotesBySpaceID retrieves all notes in a space with pagination
|
||||
func (r *NoteRepository) GetNotesBySpaceID(ctx context.Context, spaceID bson.ObjectID, skip, limit int) ([]*entities.Note, error) {
|
||||
var notes []*entities.Note
|
||||
|
||||
opts := notePrioritySortOptions(skip, limit)
|
||||
cursor, err := r.collection.Find(ctx, bson.M{"space_id": spaceID}, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cursor.Close(ctx)
|
||||
|
||||
if err = cursor.All(ctx, ¬es); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return notes, nil
|
||||
}
|
||||
|
||||
// GetPublicNotesBySpaceID retrieves public notes in a space with pagination.
|
||||
func (r *NoteRepository) GetPublicNotesBySpaceID(ctx context.Context, spaceID bson.ObjectID, skip, limit int) ([]*entities.Note, error) {
|
||||
var notes []*entities.Note
|
||||
|
||||
opts := notePrioritySortOptions(skip, limit)
|
||||
cursor, err := r.collection.Find(ctx, bson.M{"space_id": spaceID, "is_public": true}, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cursor.Close(ctx)
|
||||
|
||||
if err = cursor.All(ctx, ¬es); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return notes, nil
|
||||
}
|
||||
|
||||
// GetNotesByCategory retrieves notes in a category
|
||||
func (r *NoteRepository) GetNotesByCategory(ctx context.Context, spaceID, categoryID bson.ObjectID) ([]*entities.Note, error) {
|
||||
var notes []*entities.Note
|
||||
|
||||
opts := options.Find().
|
||||
SetSort(bson.D{
|
||||
{Key: "is_pinned", Value: -1},
|
||||
{Key: "is_favorite", Value: -1},
|
||||
{Key: "title", Value: 1},
|
||||
}).
|
||||
SetCollation(&options.Collation{Locale: "en", Strength: 2})
|
||||
|
||||
cursor, err := r.collection.Find(ctx, bson.M{
|
||||
"space_id": spaceID,
|
||||
"category_id": categoryID,
|
||||
}, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cursor.Close(ctx)
|
||||
|
||||
if err = cursor.All(ctx, ¬es); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return notes, nil
|
||||
}
|
||||
|
||||
// SearchNotes performs full-text search on notes
|
||||
func (r *NoteRepository) SearchNotes(ctx context.Context, spaceID bson.ObjectID, query string) ([]*entities.Note, error) {
|
||||
var notes []*entities.Note
|
||||
|
||||
cursor, err := r.collection.Find(ctx, bson.M{
|
||||
"space_id": spaceID,
|
||||
"$text": bson.M{
|
||||
"$search": query,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cursor.Close(ctx)
|
||||
|
||||
if err = cursor.All(ctx, ¬es); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return notes, nil
|
||||
}
|
||||
|
||||
// UpdateNote updates a note
|
||||
func (r *NoteRepository) UpdateNote(ctx context.Context, note *entities.Note) error {
|
||||
note.UpdatedAt = time.Now()
|
||||
_, err := r.collection.ReplaceOne(ctx, bson.M{"_id": note.ID}, note)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteNote deletes a note
|
||||
func (r *NoteRepository) DeleteNote(ctx context.Context, id bson.ObjectID) error {
|
||||
_, err := r.collection.DeleteOne(ctx, bson.M{"_id": id})
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteNotesBySpaceID deletes all notes in a space
|
||||
func (r *NoteRepository) DeleteNotesBySpaceID(ctx context.Context, spaceID bson.ObjectID) error {
|
||||
_, err := r.collection.DeleteMany(ctx, bson.M{"space_id": spaceID})
|
||||
return err
|
||||
}
|
||||
|
||||
// EnsureIndexes creates necessary indexes
|
||||
func (r *NoteRepository) EnsureIndexes(ctx context.Context) error {
|
||||
indexModel := []mongo.IndexModel{
|
||||
{
|
||||
Keys: bson.D{bson.E{Key: "space_id", Value: 1}},
|
||||
},
|
||||
{
|
||||
Keys: bson.D{bson.E{Key: "category_id", Value: 1}},
|
||||
},
|
||||
{
|
||||
Keys: bson.D{
|
||||
bson.E{Key: "title", Value: "text"},
|
||||
bson.E{Key: "content", Value: "text"},
|
||||
bson.E{Key: "tags", Value: "text"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Keys: bson.D{bson.E{Key: "updated_at", Value: -1}},
|
||||
},
|
||||
{
|
||||
Keys: bson.D{
|
||||
{Key: "space_id", Value: 1},
|
||||
{Key: "is_pinned", Value: -1},
|
||||
{Key: "is_favorite", Value: -1},
|
||||
{Key: "title", Value: 1},
|
||||
},
|
||||
},
|
||||
{
|
||||
Keys: bson.D{
|
||||
{Key: "space_id", Value: 1},
|
||||
{Key: "is_public", Value: 1},
|
||||
{Key: "is_pinned", Value: -1},
|
||||
{Key: "is_favorite", Value: -1},
|
||||
{Key: "title", Value: 1},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := r.collection.Indexes().CreateMany(ctx, indexModel)
|
||||
return err
|
||||
}
|
||||
|
||||
// ========== CATEGORY REPOSITORY ==========
|
||||
|
||||
// CategoryRepository implements the category repository interface
|
||||
type CategoryRepository struct {
|
||||
collection *mongo.Collection
|
||||
}
|
||||
|
||||
// NewCategoryRepository creates a new category repository
|
||||
func NewCategoryRepository(db *mongo.Database) *CategoryRepository {
|
||||
return &CategoryRepository{
|
||||
collection: db.Collection("categories"),
|
||||
}
|
||||
}
|
||||
|
||||
// CreateCategory creates a new category
|
||||
func (r *CategoryRepository) CreateCategory(ctx context.Context, category *entities.Category) error {
|
||||
category.ID = bson.NewObjectID()
|
||||
category.CreatedAt = time.Now()
|
||||
category.UpdatedAt = time.Now()
|
||||
|
||||
_, err := r.collection.InsertOne(ctx, category)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetCategoryByID retrieves a category by ID
|
||||
func (r *CategoryRepository) GetCategoryByID(ctx context.Context, id bson.ObjectID) (*entities.Category, error) {
|
||||
var category entities.Category
|
||||
err := r.collection.FindOne(ctx, bson.M{"_id": id}).Decode(&category)
|
||||
if err != nil {
|
||||
if err == mongo.ErrNoDocuments {
|
||||
return nil, errors.New("category not found")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &category, nil
|
||||
}
|
||||
|
||||
// GetCategoriesBySpaceID retrieves all categories in a space
|
||||
func (r *CategoryRepository) GetCategoriesBySpaceID(ctx context.Context, spaceID bson.ObjectID) ([]*entities.Category, error) {
|
||||
var categories []*entities.Category
|
||||
|
||||
opts := options.Find().SetSort(bson.M{"order": 1})
|
||||
cursor, err := r.collection.Find(ctx, bson.M{"space_id": spaceID}, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cursor.Close(ctx)
|
||||
|
||||
if err = cursor.All(ctx, &categories); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return categories, nil
|
||||
}
|
||||
|
||||
// GetRootCategories retrieves root level categories in a space
|
||||
func (r *CategoryRepository) GetRootCategories(ctx context.Context, spaceID bson.ObjectID) ([]*entities.Category, error) {
|
||||
var categories []*entities.Category
|
||||
|
||||
opts := options.Find().SetSort(bson.M{"order": 1})
|
||||
cursor, err := r.collection.Find(ctx, bson.M{
|
||||
"space_id": spaceID,
|
||||
"parent_id": nil,
|
||||
}, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cursor.Close(ctx)
|
||||
|
||||
if err = cursor.All(ctx, &categories); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return categories, nil
|
||||
}
|
||||
|
||||
// GetSubcategories retrieves subcategories of a category
|
||||
func (r *CategoryRepository) GetSubcategories(ctx context.Context, parentID bson.ObjectID) ([]*entities.Category, error) {
|
||||
var categories []*entities.Category
|
||||
|
||||
opts := options.Find().SetSort(bson.M{"order": 1})
|
||||
cursor, err := r.collection.Find(ctx, bson.M{"parent_id": parentID}, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cursor.Close(ctx)
|
||||
|
||||
if err = cursor.All(ctx, &categories); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return categories, nil
|
||||
}
|
||||
|
||||
// UpdateCategory updates a category
|
||||
func (r *CategoryRepository) UpdateCategory(ctx context.Context, category *entities.Category) error {
|
||||
category.UpdatedAt = time.Now()
|
||||
_, err := r.collection.ReplaceOne(ctx, bson.M{"_id": category.ID}, category)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteCategory deletes a category
|
||||
func (r *CategoryRepository) DeleteCategory(ctx context.Context, id bson.ObjectID) error {
|
||||
_, err := r.collection.DeleteOne(ctx, bson.M{"_id": id})
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteCategoriesBySpaceID deletes all categories in a space
|
||||
func (r *CategoryRepository) DeleteCategoriesBySpaceID(ctx context.Context, spaceID bson.ObjectID) error {
|
||||
_, err := r.collection.DeleteMany(ctx, bson.M{"space_id": spaceID})
|
||||
return err
|
||||
}
|
||||
|
||||
// EnsureIndexes creates necessary indexes
|
||||
func (r *CategoryRepository) EnsureIndexes(ctx context.Context) error {
|
||||
indexModel := []mongo.IndexModel{
|
||||
{
|
||||
Keys: bson.D{bson.E{Key: "space_id", Value: 1}},
|
||||
},
|
||||
{
|
||||
Keys: bson.D{bson.E{Key: "parent_id", Value: 1}},
|
||||
},
|
||||
{
|
||||
Keys: bson.D{bson.E{Key: "order", Value: 1}},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := r.collection.Indexes().CreateMany(ctx, indexModel)
|
||||
return err
|
||||
}
|
||||
249
backend/internal/infrastructure/database/space_repository.go
Normal file
249
backend/internal/infrastructure/database/space_repository.go
Normal file
@@ -0,0 +1,249 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
|
||||
"github.com/noteapp/backend/internal/domain/entities"
|
||||
)
|
||||
|
||||
// SpaceRepository implements the space repository interface
|
||||
type SpaceRepository struct {
|
||||
collection *mongo.Collection
|
||||
}
|
||||
|
||||
// NewSpaceRepository creates a new space repository
|
||||
func NewSpaceRepository(db *mongo.Database) *SpaceRepository {
|
||||
return &SpaceRepository{
|
||||
collection: db.Collection("spaces"),
|
||||
}
|
||||
}
|
||||
|
||||
// CreateSpace creates a new space
|
||||
func (r *SpaceRepository) CreateSpace(ctx context.Context, space *entities.Space) error {
|
||||
space.ID = bson.NewObjectID()
|
||||
space.CreatedAt = time.Now()
|
||||
space.UpdatedAt = time.Now()
|
||||
|
||||
_, err := r.collection.InsertOne(ctx, space)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetSpaceByID retrieves a space by ID
|
||||
func (r *SpaceRepository) GetSpaceByID(ctx context.Context, id bson.ObjectID) (*entities.Space, error) {
|
||||
var space entities.Space
|
||||
err := r.collection.FindOne(ctx, bson.M{"_id": id}).Decode(&space)
|
||||
if err != nil {
|
||||
if err == mongo.ErrNoDocuments {
|
||||
return nil, errors.New("space not found")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &space, nil
|
||||
}
|
||||
|
||||
// GetSpacesByUserID retrieves all spaces for a user (via memberships)
|
||||
func (r *SpaceRepository) GetSpacesByUserID(ctx context.Context, userID bson.ObjectID) ([]*entities.Space, error) {
|
||||
var spaces []*entities.Space
|
||||
|
||||
// Query spaces where user is a member
|
||||
opts := options.Find().SetSort(bson.M{"created_at": -1})
|
||||
cursor, err := r.collection.Find(ctx, bson.M{}, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cursor.Close(ctx)
|
||||
|
||||
// This would typically be joined with membership collection
|
||||
// For now, returning all spaces - in production, filter by membership
|
||||
if err = cursor.All(ctx, &spaces); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return spaces, nil
|
||||
}
|
||||
|
||||
// UpdateSpace updates a space
|
||||
func (r *SpaceRepository) UpdateSpace(ctx context.Context, space *entities.Space) error {
|
||||
space.UpdatedAt = time.Now()
|
||||
_, err := r.collection.ReplaceOne(ctx, bson.M{"_id": space.ID}, space)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteSpace deletes a space
|
||||
func (r *SpaceRepository) DeleteSpace(ctx context.Context, id bson.ObjectID) error {
|
||||
_, err := r.collection.DeleteOne(ctx, bson.M{"_id": id})
|
||||
return err
|
||||
}
|
||||
|
||||
// GetAllSpaces retrieves all spaces sorted by creation date descending
|
||||
func (r *SpaceRepository) GetAllSpaces(ctx context.Context) ([]*entities.Space, error) {
|
||||
opts := options.Find().SetSort(bson.D{bson.E{Key: "created_at", Value: -1}})
|
||||
cursor, err := r.collection.Find(ctx, bson.M{}, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cursor.Close(ctx)
|
||||
|
||||
var spaces []*entities.Space
|
||||
if err := cursor.All(ctx, &spaces); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return spaces, nil
|
||||
}
|
||||
|
||||
// GetPublicSpaces retrieves all spaces marked as public
|
||||
func (r *SpaceRepository) GetPublicSpaces(ctx context.Context) ([]*entities.Space, error) {
|
||||
opts := options.Find().SetSort(bson.D{bson.E{Key: "created_at", Value: -1}})
|
||||
cursor, err := r.collection.Find(ctx, bson.M{"is_public": true}, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cursor.Close(ctx)
|
||||
|
||||
var spaces []*entities.Space
|
||||
if err := cursor.All(ctx, &spaces); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return spaces, nil
|
||||
}
|
||||
|
||||
// EnsureIndexes creates necessary indexes
|
||||
func (r *SpaceRepository) EnsureIndexes(ctx context.Context) error {
|
||||
indexModel := []mongo.IndexModel{
|
||||
{
|
||||
Keys: bson.D{bson.E{Key: "owner_id", Value: 1}},
|
||||
},
|
||||
{
|
||||
Keys: bson.D{bson.E{Key: "created_at", Value: -1}},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := r.collection.Indexes().CreateMany(ctx, indexModel)
|
||||
return err
|
||||
}
|
||||
|
||||
// ========== MEMBERSHIP REPOSITORY ==========
|
||||
|
||||
// MembershipRepository implements the membership repository interface
|
||||
type MembershipRepository struct {
|
||||
collection *mongo.Collection
|
||||
}
|
||||
|
||||
// NewMembershipRepository creates a new membership repository
|
||||
func NewMembershipRepository(db *mongo.Database) *MembershipRepository {
|
||||
return &MembershipRepository{
|
||||
collection: db.Collection("memberships"),
|
||||
}
|
||||
}
|
||||
|
||||
// CreateMembership creates a new membership
|
||||
func (r *MembershipRepository) CreateMembership(ctx context.Context, membership *entities.Membership) error {
|
||||
membership.ID = bson.NewObjectID()
|
||||
membership.JoinedAt = time.Now()
|
||||
|
||||
_, err := r.collection.InsertOne(ctx, membership)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetMembershipByID retrieves a membership by ID
|
||||
func (r *MembershipRepository) GetMembershipByID(ctx context.Context, id bson.ObjectID) (*entities.Membership, error) {
|
||||
var membership entities.Membership
|
||||
err := r.collection.FindOne(ctx, bson.M{"_id": id}).Decode(&membership)
|
||||
if err != nil {
|
||||
if err == mongo.ErrNoDocuments {
|
||||
return nil, errors.New("membership not found")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &membership, nil
|
||||
}
|
||||
|
||||
// GetUserMembership retrieves a membership for a user in a space
|
||||
func (r *MembershipRepository) GetUserMembership(ctx context.Context, userID, spaceID bson.ObjectID) (*entities.Membership, error) {
|
||||
var membership entities.Membership
|
||||
err := r.collection.FindOne(ctx, bson.M{
|
||||
"user_id": userID,
|
||||
"space_id": spaceID,
|
||||
}).Decode(&membership)
|
||||
if err != nil {
|
||||
if err == mongo.ErrNoDocuments {
|
||||
return nil, errors.New("membership not found")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &membership, nil
|
||||
}
|
||||
|
||||
// GetSpaceMembers retrieves all members in a space
|
||||
func (r *MembershipRepository) GetSpaceMembers(ctx context.Context, spaceID bson.ObjectID) ([]*entities.Membership, error) {
|
||||
var memberships []*entities.Membership
|
||||
|
||||
cursor, err := r.collection.Find(ctx, bson.M{"space_id": spaceID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cursor.Close(ctx)
|
||||
|
||||
if err = cursor.All(ctx, &memberships); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return memberships, nil
|
||||
}
|
||||
|
||||
// GetUserMemberships retrieves all memberships for a user
|
||||
func (r *MembershipRepository) GetUserMemberships(ctx context.Context, userID bson.ObjectID) ([]*entities.Membership, error) {
|
||||
var memberships []*entities.Membership
|
||||
|
||||
cursor, err := r.collection.Find(ctx, bson.M{"user_id": userID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cursor.Close(ctx)
|
||||
|
||||
if err = cursor.All(ctx, &memberships); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return memberships, nil
|
||||
}
|
||||
|
||||
// UpdateMembership updates a membership
|
||||
func (r *MembershipRepository) UpdateMembership(ctx context.Context, membership *entities.Membership) error {
|
||||
_, err := r.collection.ReplaceOne(ctx, bson.M{"_id": membership.ID}, membership)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteMembership deletes a membership
|
||||
func (r *MembershipRepository) DeleteMembership(ctx context.Context, id bson.ObjectID) error {
|
||||
_, err := r.collection.DeleteOne(ctx, bson.M{"_id": id})
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteMembershipsBySpaceID deletes all memberships for a space
|
||||
func (r *MembershipRepository) DeleteMembershipsBySpaceID(ctx context.Context, spaceID bson.ObjectID) error {
|
||||
_, err := r.collection.DeleteMany(ctx, bson.M{"space_id": spaceID})
|
||||
return err
|
||||
}
|
||||
|
||||
// EnsureIndexes creates necessary indexes
|
||||
func (r *MembershipRepository) EnsureIndexes(ctx context.Context) error {
|
||||
indexModel := []mongo.IndexModel{
|
||||
{
|
||||
Keys: bson.D{bson.E{Key: "user_id", Value: 1}, bson.E{Key: "space_id", Value: 1}},
|
||||
Options: options.Index().SetUnique(true),
|
||||
},
|
||||
{
|
||||
Keys: bson.D{bson.E{Key: "space_id", Value: 1}},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := r.collection.Indexes().CreateMany(ctx, indexModel)
|
||||
return err
|
||||
}
|
||||
120
backend/internal/infrastructure/database/user_repository.go
Normal file
120
backend/internal/infrastructure/database/user_repository.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
|
||||
"github.com/noteapp/backend/internal/domain/entities"
|
||||
)
|
||||
|
||||
// UserRepository implements the user repository interface
|
||||
type UserRepository struct {
|
||||
collection *mongo.Collection
|
||||
}
|
||||
|
||||
// NewUserRepository creates a new user repository
|
||||
func NewUserRepository(db *mongo.Database) *UserRepository {
|
||||
return &UserRepository{
|
||||
collection: db.Collection("users"),
|
||||
}
|
||||
}
|
||||
|
||||
// CreateUser creates a new user
|
||||
func (r *UserRepository) CreateUser(ctx context.Context, user *entities.User) error {
|
||||
user.ID = bson.NewObjectID()
|
||||
user.CreatedAt = time.Now()
|
||||
user.UpdatedAt = time.Now()
|
||||
|
||||
_, err := r.collection.InsertOne(ctx, user)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetUserByID retrieves a user by ID
|
||||
func (r *UserRepository) GetUserByID(ctx context.Context, id bson.ObjectID) (*entities.User, error) {
|
||||
var user entities.User
|
||||
err := r.collection.FindOne(ctx, bson.M{"_id": id}).Decode(&user)
|
||||
if err != nil {
|
||||
if err == mongo.ErrNoDocuments {
|
||||
return nil, errors.New("user not found")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// GetUserByEmail retrieves a user by email
|
||||
func (r *UserRepository) GetUserByEmail(ctx context.Context, email string) (*entities.User, error) {
|
||||
var user entities.User
|
||||
err := r.collection.FindOne(ctx, bson.M{"email": email}).Decode(&user)
|
||||
if err != nil {
|
||||
if err == mongo.ErrNoDocuments {
|
||||
return nil, errors.New("user not found")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// GetUserByUsername retrieves a user by username
|
||||
func (r *UserRepository) GetUserByUsername(ctx context.Context, username string) (*entities.User, error) {
|
||||
var user entities.User
|
||||
err := r.collection.FindOne(ctx, bson.M{"username": username}).Decode(&user)
|
||||
if err != nil {
|
||||
if err == mongo.ErrNoDocuments {
|
||||
return nil, errors.New("user not found")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// UpdateUser updates a user
|
||||
func (r *UserRepository) UpdateUser(ctx context.Context, user *entities.User) error {
|
||||
user.UpdatedAt = time.Now()
|
||||
_, err := r.collection.ReplaceOne(ctx, bson.M{"_id": user.ID}, user)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteUser deletes a user
|
||||
func (r *UserRepository) DeleteUser(ctx context.Context, id bson.ObjectID) error {
|
||||
_, err := r.collection.DeleteOne(ctx, bson.M{"_id": id})
|
||||
return err
|
||||
}
|
||||
|
||||
// ListAllUsers retrieves all users sorted by creation date descending
|
||||
func (r *UserRepository) ListAllUsers(ctx context.Context) ([]*entities.User, error) {
|
||||
opts := options.Find().SetSort(bson.D{bson.E{Key: "created_at", Value: -1}})
|
||||
cursor, err := r.collection.Find(ctx, bson.M{}, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cursor.Close(ctx)
|
||||
|
||||
var users []*entities.User
|
||||
if err := cursor.All(ctx, &users); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
// EnsureIndexes creates necessary indexes for users collection
|
||||
func (r *UserRepository) EnsureIndexes(ctx context.Context) error {
|
||||
indexModel := []mongo.IndexModel{
|
||||
{
|
||||
Keys: bson.D{bson.E{Key: "email", Value: 1}},
|
||||
Options: options.Index().SetUnique(true),
|
||||
},
|
||||
{
|
||||
Keys: bson.D{bson.E{Key: "username", Value: 1}},
|
||||
Options: options.Index().SetUnique(true),
|
||||
},
|
||||
}
|
||||
|
||||
_, err := r.collection.Indexes().CreateMany(ctx, indexModel)
|
||||
return err
|
||||
}
|
||||
79
backend/internal/infrastructure/security/encryption.go
Normal file
79
backend/internal/infrastructure/security/encryption.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"io"
|
||||
)
|
||||
|
||||
// Encryptor provides encryption/decryption for sensitive data
|
||||
type Encryptor struct {
|
||||
key []byte
|
||||
}
|
||||
|
||||
// NewEncryptor creates a new encryptor with the given key
|
||||
// The key must be 32 bytes for AES-256
|
||||
func NewEncryptor(key string) (*Encryptor, error) {
|
||||
if len(key) != 32 {
|
||||
return nil, errors.New("encryption key must be 32 bytes (256 bits)")
|
||||
}
|
||||
return &Encryptor{
|
||||
key: []byte(key),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Encrypt encrypts data using AES-256-GCM
|
||||
func (e *Encryptor) Encrypt(plaintext string) (string, error) {
|
||||
block, err := aes.NewCipher(e.key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
|
||||
return base64.StdEncoding.EncodeToString(ciphertext), nil
|
||||
}
|
||||
|
||||
// Decrypt decrypts data encrypted with Encrypt
|
||||
func (e *Encryptor) Decrypt(ciphertext string) (string, error) {
|
||||
data, err := base64.StdEncoding.DecodeString(ciphertext)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(e.key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
nonceSize := gcm.NonceSize()
|
||||
if len(data) < nonceSize {
|
||||
return "", errors.New("ciphertext too short")
|
||||
}
|
||||
|
||||
nonce := data[:nonceSize]
|
||||
ciphertextBytes := data[nonceSize:]
|
||||
plaintext, err := gcm.Open(nil, nonce, ciphertextBytes, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(plaintext), nil
|
||||
}
|
||||
121
backend/internal/infrastructure/security/password.go
Normal file
121
backend/internal/infrastructure/security/password.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// PasswordHasher provides password hashing and verification
|
||||
type PasswordHasher struct {
|
||||
time uint32
|
||||
memory uint32
|
||||
threads uint8
|
||||
keyLen uint32
|
||||
saltLen int
|
||||
}
|
||||
|
||||
// NewPasswordHasher creates a new password hasher with sensible defaults
|
||||
func NewPasswordHasher() *PasswordHasher {
|
||||
return &PasswordHasher{
|
||||
time: 1,
|
||||
memory: 64 * 1024, // 64 MB
|
||||
threads: 4,
|
||||
keyLen: 32,
|
||||
saltLen: 16,
|
||||
}
|
||||
}
|
||||
|
||||
// HashPassword hashes a password using Argon2id
|
||||
// Returns hash in format "$argon2id$v=19$m=65536,t=1,p=4$salt$hash"
|
||||
func (ph *PasswordHasher) HashPassword(password string) (string, error) {
|
||||
salt := make([]byte, ph.saltLen)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
hash := argon2.IDKey(
|
||||
[]byte(password),
|
||||
salt,
|
||||
ph.time,
|
||||
ph.memory,
|
||||
ph.threads,
|
||||
ph.keyLen,
|
||||
)
|
||||
|
||||
hashStr := fmt.Sprintf(
|
||||
"$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
|
||||
19,
|
||||
ph.memory,
|
||||
ph.time,
|
||||
ph.threads,
|
||||
hex.EncodeToString(salt),
|
||||
hex.EncodeToString(hash),
|
||||
)
|
||||
|
||||
return hashStr, nil
|
||||
}
|
||||
|
||||
// VerifyPassword verifies a password against a hash
|
||||
func (ph *PasswordHasher) VerifyPassword(password, hash string) (bool, error) {
|
||||
// Backward compatibility: accept legacy bcrypt hashes.
|
||||
if strings.HasPrefix(hash, "$2a$") || strings.HasPrefix(hash, "$2b$") || strings.HasPrefix(hash, "$2y$") {
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil {
|
||||
return false, nil
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
parts := strings.Split(hash, "$")
|
||||
if len(parts) != 6 || parts[1] != "argon2id" {
|
||||
return false, errors.New("invalid password hash format")
|
||||
}
|
||||
|
||||
versionPart := strings.TrimPrefix(parts[2], "v=")
|
||||
version, err := strconv.Atoi(versionPart)
|
||||
if err != nil || version != 19 {
|
||||
return false, errors.New("invalid password hash version")
|
||||
}
|
||||
|
||||
var memory, timeCost uint32
|
||||
var threads uint8
|
||||
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &timeCost, &threads); err != nil {
|
||||
return false, errors.New("invalid password hash parameters")
|
||||
}
|
||||
|
||||
saltStr := parts[4]
|
||||
hashStr := parts[5]
|
||||
|
||||
salt, err := hex.DecodeString(saltStr)
|
||||
if err != nil {
|
||||
return false, errors.New("invalid salt in password hash")
|
||||
}
|
||||
|
||||
expectedHashBytes, err := hex.DecodeString(hashStr)
|
||||
if err != nil {
|
||||
return false, errors.New("invalid hash in password hash")
|
||||
}
|
||||
|
||||
// Hash the input password with the extracted parameters
|
||||
computedHash := argon2.IDKey(
|
||||
[]byte(password),
|
||||
salt,
|
||||
timeCost,
|
||||
memory,
|
||||
threads,
|
||||
uint32(len(expectedHashBytes)),
|
||||
)
|
||||
|
||||
if subtle.ConstantTimeCompare(computedHash, expectedHashBytes) == 1 {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
294
backend/internal/interfaces/handlers/admin_handler.go
Normal file
294
backend/internal/interfaces/handlers/admin_handler.go
Normal 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)
|
||||
}
|
||||
299
backend/internal/interfaces/handlers/auth_handler.go
Normal file
299
backend/internal/interfaces/handlers/auth_handler.go
Normal 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()
|
||||
}
|
||||
212
backend/internal/interfaces/handlers/category_handler.go
Normal file
212
backend/internal/interfaces/handlers/category_handler.go
Normal 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)
|
||||
}
|
||||
266
backend/internal/interfaces/handlers/note_handler.go
Normal file
266
backend/internal/interfaces/handlers/note_handler.go
Normal 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)
|
||||
}
|
||||
156
backend/internal/interfaces/handlers/public_handler.go
Normal file
156
backend/internal/interfaces/handlers/public_handler.go
Normal 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)
|
||||
}
|
||||
30
backend/internal/interfaces/handlers/settings_handler.go
Normal file
30
backend/internal/interfaces/handlers/settings_handler.go
Normal 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)
|
||||
}
|
||||
295
backend/internal/interfaces/handlers/space_handler.go
Normal file
295
backend/internal/interfaces/handlers/space_handler.go
Normal 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})
|
||||
}
|
||||
91
backend/internal/interfaces/middleware/auth.go
Normal file
91
backend/internal/interfaces/middleware/auth.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/noteapp/backend/internal/infrastructure/auth"
|
||||
)
|
||||
|
||||
// ContextKey is a custom type for context keys
|
||||
type ContextKey string
|
||||
|
||||
const (
|
||||
UserIDKey ContextKey = "user_id"
|
||||
EmailKey ContextKey = "email"
|
||||
UserKey ContextKey = "user"
|
||||
)
|
||||
|
||||
// AuthMiddleware verifies JWT tokens
|
||||
type AuthMiddleware struct {
|
||||
jwtManager *auth.JWTManager
|
||||
}
|
||||
|
||||
// NewAuthMiddleware creates a new auth middleware
|
||||
func NewAuthMiddleware(jwtManager *auth.JWTManager) *AuthMiddleware {
|
||||
return &AuthMiddleware{
|
||||
jwtManager: jwtManager,
|
||||
}
|
||||
}
|
||||
|
||||
// Middleware wraps an HTTP handler with authentication
|
||||
func (m *AuthMiddleware) Middleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Skip auth for login and register endpoints
|
||||
if strings.HasSuffix(r.URL.Path, "/auth/login") ||
|
||||
strings.HasSuffix(r.URL.Path, "/auth/register") ||
|
||||
strings.HasSuffix(r.URL.Path, "/health") {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract token from Authorization header
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
http.Error(w, "Missing authorization header", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
http.Error(w, "Invalid authorization header format", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
token := parts[1]
|
||||
|
||||
// Verify token
|
||||
claims, err := m.jwtManager.VerifyAccessToken(token)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid token: "+err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Add claims to context
|
||||
ctx := context.WithValue(r.Context(), UserIDKey, claims.UserID)
|
||||
ctx = context.WithValue(ctx, EmailKey, claims.Email)
|
||||
|
||||
r = r.WithContext(ctx)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// GetUserIDFromContext extracts user ID from context
|
||||
func GetUserIDFromContext(ctx context.Context) (string, error) {
|
||||
userID, ok := ctx.Value(UserIDKey).(string)
|
||||
if !ok {
|
||||
return "", errors.New("user ID not found in context")
|
||||
}
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
// GetEmailFromContext extracts email from context
|
||||
func GetEmailFromContext(ctx context.Context) (string, error) {
|
||||
email, ok := ctx.Value(EmailKey).(string)
|
||||
if !ok {
|
||||
return "", errors.New("email not found in context")
|
||||
}
|
||||
return email, nil
|
||||
}
|
||||
91
backend/internal/interfaces/middleware/security.go
Normal file
91
backend/internal/interfaces/middleware/security.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SecurityHeaders adds security headers to responses
|
||||
func SecurityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// HSTS - HTTP Strict Transport Security
|
||||
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
|
||||
|
||||
// CSRF Protection - same-site cookies
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
|
||||
// XSS Protection
|
||||
w.Header().Set("X-XSS-Protection", "1; mode=block")
|
||||
|
||||
// Clickjacking protection
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
|
||||
// CSP - Content Security Policy
|
||||
w.Header().Set("Content-Security-Policy",
|
||||
"default-src 'self'; "+
|
||||
"script-src 'self' 'unsafe-inline'; "+
|
||||
"style-src 'self' 'unsafe-inline'; "+
|
||||
"img-src 'self' data: https:; "+
|
||||
"font-src 'self'; "+
|
||||
"connect-src 'self'; "+
|
||||
"frame-ancestors 'none'")
|
||||
|
||||
// Referrer Policy
|
||||
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// RateLimitMiddleware implements basic rate limiting
|
||||
type RateLimitMiddleware struct {
|
||||
// In production, use a proper rate limiter like github.com/go-chi/chi/middleware
|
||||
// This is a placeholder for demonstration
|
||||
}
|
||||
|
||||
// NewRateLimitMiddleware creates a new rate limit middleware
|
||||
func NewRateLimitMiddleware() *RateLimitMiddleware {
|
||||
return &RateLimitMiddleware{}
|
||||
}
|
||||
|
||||
// Middleware returns the rate limit middleware handler
|
||||
func (m *RateLimitMiddleware) Middleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// TODO: Implement proper rate limiting using distributed cache
|
||||
// For now, this is a placeholder
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// LoggingMiddleware logs HTTP requests
|
||||
func LoggingMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Printf("[%s] %s %s\n", r.Method, r.RequestURI, r.RemoteAddr)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// CORSMiddleware enables CORS
|
||||
func CORSMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
origin := r.Header.Get("Origin")
|
||||
switch {
|
||||
case origin == "http://localhost", origin == "http://localhost:5173", strings.HasPrefix(origin, "http://127.0.0.1:"):
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Set("Vary", "Origin")
|
||||
default:
|
||||
w.Header().Set("Access-Control-Allow-Origin", "http://localhost")
|
||||
}
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS, PATCH")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With")
|
||||
w.Header().Set("Access-Control-Max-Age", "600")
|
||||
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
55
backend/tests/integration/integration_test.go
Normal file
55
backend/tests/integration/integration_test.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/noteapp/backend/internal/infrastructure/database"
|
||||
)
|
||||
|
||||
// TestDatabaseConnection tests MongoDB connection
|
||||
func TestDatabaseConnection(t *testing.T) {
|
||||
mongoURL := "mongodb://admin:password@localhost:27017/noteapp?authSource=admin"
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
db, err := database.NewDatabase(ctx, mongoURL)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to connect to database: %v", err)
|
||||
}
|
||||
defer db.Close(ctx)
|
||||
|
||||
t.Log("✓ Successfully connected to MongoDB")
|
||||
}
|
||||
|
||||
// TestAPIHealth tests the health check endpoint
|
||||
func TestAPIHealth(t *testing.T) {
|
||||
resp, err := http.Get("http://localhost:8080/health")
|
||||
if err != nil {
|
||||
t.Skipf("Skipping test - server not running: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("Expected status 200, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
t.Log("✓ Health check passed")
|
||||
}
|
||||
|
||||
// TestAuthenticationFlow does an integration test of auth flow
|
||||
func TestAuthenticationFlow(t *testing.T) {
|
||||
log.Println("Integration test: Authentication flow")
|
||||
log.Println("1. Register new user")
|
||||
log.Println("2. Login with credentials")
|
||||
log.Println("3. Use access token to access protected endpoint")
|
||||
log.Println("4. Refresh access token")
|
||||
log.Println("5. Logout")
|
||||
|
||||
fmt.Println("\nTo run: cd backend && go test ./tests/integration/...")
|
||||
}
|
||||
185
backend/tests/unit/auth_service_test.go
Normal file
185
backend/tests/unit/auth_service_test.go
Normal file
@@ -0,0 +1,185 @@
|
||||
package unit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/noteapp/backend/internal/application/dto"
|
||||
"github.com/noteapp/backend/internal/application/services"
|
||||
"github.com/noteapp/backend/internal/domain/entities"
|
||||
"github.com/noteapp/backend/internal/infrastructure/auth"
|
||||
"github.com/noteapp/backend/internal/infrastructure/security"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// MockUserRepository is a mock for testing
|
||||
type MockUserRepository struct {
|
||||
users map[string]*entities.User
|
||||
}
|
||||
|
||||
func NewMockUserRepository() *MockUserRepository {
|
||||
return &MockUserRepository{
|
||||
users: make(map[string]*entities.User),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MockUserRepository) CreateUser(ctx context.Context, user *entities.User) error {
|
||||
m.users[user.Email] = user
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockUserRepository) GetUserByID(ctx context.Context, id bson.ObjectID) (*entities.User, error) {
|
||||
for _, u := range m.users {
|
||||
if u.Email != "" {
|
||||
return u, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockUserRepository) GetUserByEmail(ctx context.Context, email string) (*entities.User, error) {
|
||||
if user, ok := m.users[email]; ok {
|
||||
return user, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockUserRepository) GetUserByUsername(ctx context.Context, username string) (*entities.User, error) {
|
||||
// Simplified mock
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockUserRepository) UpdateUser(ctx context.Context, user *entities.User) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockUserRepository) DeleteUser(ctx context.Context, id bson.ObjectID) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockUserRepository) ListAllUsers(ctx context.Context) ([]*entities.User, error) {
|
||||
users := make([]*entities.User, 0, len(m.users))
|
||||
for _, user := range m.users {
|
||||
users = append(users, user)
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
// TestRegisterUser tests user registration
|
||||
func TestRegisterUser(t *testing.T) {
|
||||
mockRepo := NewMockUserRepository()
|
||||
jwtManager := auth.NewJWTManager("test-secret-key", "noteapp", 0)
|
||||
passHasher := security.NewPasswordHasher()
|
||||
encryptor, _ := security.NewEncryptor("00000000000000000000000000000000")
|
||||
|
||||
authService := services.NewAuthService(
|
||||
mockRepo,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
jwtManager,
|
||||
passHasher,
|
||||
encryptor,
|
||||
)
|
||||
|
||||
req := &dto.RegisterRequest{
|
||||
Email: "test@example.com",
|
||||
Username: "testuser",
|
||||
Password: "SecurePassword123",
|
||||
PasswordConfirm: "SecurePassword123",
|
||||
FirstName: "Test",
|
||||
LastName: "User",
|
||||
}
|
||||
|
||||
response, err := authService.Register(context.Background(), req)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if response == nil {
|
||||
t.Fatal("Expected response, got nil")
|
||||
}
|
||||
|
||||
if response.AccessToken == "" {
|
||||
t.Fatal("Expected access token")
|
||||
}
|
||||
|
||||
if response.User.Email != req.Email {
|
||||
t.Fatalf("Expected email %s, got %s", req.Email, response.User.Email)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPasswordHashing tests password hashing and verification
|
||||
func TestPasswordHashing(t *testing.T) {
|
||||
hasher := security.NewPasswordHasher()
|
||||
password := "MySecurePassword123"
|
||||
|
||||
// Hash password
|
||||
hash, err := hasher.HashPassword(password)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to hash password: %v", err)
|
||||
}
|
||||
|
||||
// Verify correct password
|
||||
valid, err := hasher.VerifyPassword(password, hash)
|
||||
if err != nil || !valid {
|
||||
t.Fatal("Expected password verification to succeed")
|
||||
}
|
||||
|
||||
// Verify wrong password
|
||||
valid, err = hasher.VerifyPassword("WrongPassword", hash)
|
||||
if err == nil || valid {
|
||||
t.Fatal("Expected password verification to fail")
|
||||
}
|
||||
}
|
||||
|
||||
// TestJWTGeneration tests JWT token generation and verification
|
||||
func TestJWTGeneration(t *testing.T) {
|
||||
jwtManager := auth.NewJWTManager("test-secret-key", "noteapp", 0)
|
||||
|
||||
token, err := jwtManager.GenerateAccessToken("user123", "user@example.com", "testuser")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate token: %v", err)
|
||||
}
|
||||
|
||||
claims, err := jwtManager.VerifyAccessToken(token)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to verify token: %v", err)
|
||||
}
|
||||
|
||||
if claims.UserID != "user123" {
|
||||
t.Fatalf("Expected user_id user123, got %s", claims.UserID)
|
||||
}
|
||||
|
||||
if claims.Email != "user@example.com" {
|
||||
t.Fatalf("Expected email user@example.com, got %s", claims.Email)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEncryption tests encryption and decryption
|
||||
func TestEncryption(t *testing.T) {
|
||||
encryptor, err := security.NewEncryptor("00000000000000000000000000000000")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create encryptor: %v", err)
|
||||
}
|
||||
|
||||
plaintext := "sensitive-data-to-encrypt"
|
||||
|
||||
encrypted, err := encryptor.Encrypt(plaintext)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to encrypt: %v", err)
|
||||
}
|
||||
|
||||
decrypted, err := encryptor.Decrypt(encrypted)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to decrypt: %v", err)
|
||||
}
|
||||
|
||||
if decrypted != plaintext {
|
||||
t.Fatalf("Expected %s, got %s", plaintext, decrypted)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user