first commit
This commit is contained in:
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
|
||||
}
|
||||
Reference in New Issue
Block a user