diff --git a/backend/internal/application/services/checksum_service_test.go b/backend/internal/application/services/checksum_service_test.go index 55b4567..33308fc 100644 --- a/backend/internal/application/services/checksum_service_test.go +++ b/backend/internal/application/services/checksum_service_test.go @@ -121,6 +121,27 @@ func (f *fakeDocumentRepository) FindByReference(_ context.Context, ref string, return nil, nil } +func (f *fakeDocumentRepository) List(_ context.Context, limit, offset int) ([]*models.Document, error) { + return nil, nil +} + +func (f *fakeDocumentRepository) Search(_ context.Context, query string, limit, offset int) ([]*models.Document, error) { + return nil, nil +} + +func (f *fakeDocumentRepository) Count(_ context.Context, searchQuery string) (int, error) { + return len(f.documents), nil +} + +func (f *fakeDocumentRepository) CreateOrUpdate(ctx context.Context, docID string, input models.DocumentInput, createdBy string) (*models.Document, error) { + return f.Create(ctx, docID, input, createdBy) +} + +func (f *fakeDocumentRepository) Delete(_ context.Context, docID string) error { + delete(f.documents, docID) + return nil +} + func TestChecksumService_ValidateChecksumFormat(t *testing.T) { service := NewChecksumService(newFakeVerificationRepository(), newFakeDocumentRepository()) diff --git a/backend/internal/application/services/document_service.go b/backend/internal/application/services/document_service.go index 05791bb..2757a86 100644 --- a/backend/internal/application/services/document_service.go +++ b/backend/internal/application/services/document_service.go @@ -20,6 +20,11 @@ type documentRepository interface { Create(ctx context.Context, docID string, input models.DocumentInput, createdBy string) (*models.Document, error) GetByDocID(ctx context.Context, docID string) (*models.Document, error) FindByReference(ctx context.Context, ref string, refType string) (*models.Document, error) + List(ctx context.Context, limit, offset int) ([]*models.Document, error) + Search(ctx context.Context, query string, limit, offset int) ([]*models.Document, error) + Count(ctx context.Context, searchQuery string) (int, error) + CreateOrUpdate(ctx context.Context, docID string, input models.DocumentInput, createdBy string) (*models.Document, error) + Delete(ctx context.Context, docID string) error } // DocumentService handles document metadata operations and unique ID generation @@ -442,3 +447,33 @@ func (s *DocumentService) FindOrCreateDocument(ctx context.Context, ref string) return doc, true, nil } + +// List returns documents with pagination +func (s *DocumentService) List(ctx context.Context, limit, offset int) ([]*models.Document, error) { + return s.repo.List(ctx, limit, offset) +} + +// Search searches documents by query with pagination +func (s *DocumentService) Search(ctx context.Context, query string, limit, offset int) ([]*models.Document, error) { + return s.repo.Search(ctx, query, limit, offset) +} + +// Count returns the total count of documents, optionally filtered by search query +func (s *DocumentService) Count(ctx context.Context, searchQuery string) (int, error) { + return s.repo.Count(ctx, searchQuery) +} + +// GetByDocID retrieves a document by its ID +func (s *DocumentService) GetByDocID(ctx context.Context, docID string) (*models.Document, error) { + return s.repo.GetByDocID(ctx, docID) +} + +// CreateOrUpdate creates a new document or updates an existing one +func (s *DocumentService) CreateOrUpdate(ctx context.Context, docID string, input models.DocumentInput, createdBy string) (*models.Document, error) { + return s.repo.CreateOrUpdate(ctx, docID, input, createdBy) +} + +// Delete removes a document by its ID +func (s *DocumentService) Delete(ctx context.Context, docID string) error { + return s.repo.Delete(ctx, docID) +} diff --git a/backend/internal/application/services/document_service_duplicate_test.go b/backend/internal/application/services/document_service_duplicate_test.go index 73e9d8d..21b0c52 100644 --- a/backend/internal/application/services/document_service_duplicate_test.go +++ b/backend/internal/application/services/document_service_duplicate_test.go @@ -67,6 +67,27 @@ func (m *mockDocRepo) FindByReference(ctx context.Context, ref string, refType s return nil, nil } +func (m *mockDocRepo) List(ctx context.Context, limit, offset int) ([]*models.Document, error) { + return nil, nil +} + +func (m *mockDocRepo) Search(ctx context.Context, query string, limit, offset int) ([]*models.Document, error) { + return nil, nil +} + +func (m *mockDocRepo) Count(ctx context.Context, searchQuery string) (int, error) { + return len(m.documents), nil +} + +func (m *mockDocRepo) CreateOrUpdate(ctx context.Context, docID string, input models.DocumentInput, createdBy string) (*models.Document, error) { + return m.Create(ctx, docID, input, createdBy) +} + +func (m *mockDocRepo) Delete(ctx context.Context, docID string) error { + delete(m.documents, docID) + return nil +} + // TestFindOrCreateDocument_SameReferenceTwice tests that calling FindOrCreateDocument // with the same reference twice does NOT create duplicate documents func TestFindOrCreateDocument_SameReferenceTwice(t *testing.T) { diff --git a/backend/internal/application/services/document_service_test.go b/backend/internal/application/services/document_service_test.go index 5b6a323..67421da 100644 --- a/backend/internal/application/services/document_service_test.go +++ b/backend/internal/application/services/document_service_test.go @@ -378,6 +378,26 @@ func (m *mockDocumentRepository) FindByReference(ctx context.Context, ref string return nil, nil // Not found by default } +func (m *mockDocumentRepository) List(ctx context.Context, limit, offset int) ([]*models.Document, error) { + return nil, nil +} + +func (m *mockDocumentRepository) Search(ctx context.Context, query string, limit, offset int) ([]*models.Document, error) { + return nil, nil +} + +func (m *mockDocumentRepository) Count(ctx context.Context, searchQuery string) (int, error) { + return 0, nil +} + +func (m *mockDocumentRepository) CreateOrUpdate(ctx context.Context, docID string, input models.DocumentInput, createdBy string) (*models.Document, error) { + return m.Create(ctx, docID, input, createdBy) +} + +func (m *mockDocumentRepository) Delete(ctx context.Context, docID string) error { + return nil +} + // Test CreateDocument with URL reference func TestDocumentService_CreateDocument_WithURL(t *testing.T) { mockRepo := &mockDocumentRepository{} diff --git a/backend/internal/application/services/expected_signer_service.go b/backend/internal/application/services/expected_signer_service.go new file mode 100644 index 0000000..ab6b840 --- /dev/null +++ b/backend/internal/application/services/expected_signer_service.go @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +package services + +import ( + "context" + + "github.com/btouchard/ackify-ce/backend/internal/domain/models" +) + +type expectedSignerRepo interface { + ListByDocID(ctx context.Context, docID string) ([]*models.ExpectedSigner, error) + ListWithStatusByDocID(ctx context.Context, docID string) ([]*models.ExpectedSignerWithStatus, error) + AddExpected(ctx context.Context, docID string, contacts []models.ContactInfo, addedBy string) error + Remove(ctx context.Context, docID, email string) error + GetStats(ctx context.Context, docID string) (*models.DocCompletionStats, error) +} + +// ExpectedSignerService handles expected signer operations +type ExpectedSignerService struct { + repo expectedSignerRepo +} + +// NewExpectedSignerService creates a new expected signer service +func NewExpectedSignerService(repo expectedSignerRepo) *ExpectedSignerService { + return &ExpectedSignerService{repo: repo} +} + +// ListByDocID returns all expected signers for a document +func (s *ExpectedSignerService) ListByDocID(ctx context.Context, docID string) ([]*models.ExpectedSigner, error) { + return s.repo.ListByDocID(ctx, docID) +} + +// ListWithStatusByDocID returns all expected signers with their signature status +func (s *ExpectedSignerService) ListWithStatusByDocID(ctx context.Context, docID string) ([]*models.ExpectedSignerWithStatus, error) { + return s.repo.ListWithStatusByDocID(ctx, docID) +} + +// AddExpected adds expected signers to a document +func (s *ExpectedSignerService) AddExpected(ctx context.Context, docID string, contacts []models.ContactInfo, addedBy string) error { + return s.repo.AddExpected(ctx, docID, contacts, addedBy) +} + +// Remove removes an expected signer from a document +func (s *ExpectedSignerService) Remove(ctx context.Context, docID, email string) error { + return s.repo.Remove(ctx, docID, email) +} + +// GetStats returns completion statistics for a document +func (s *ExpectedSignerService) GetStats(ctx context.Context, docID string) (*models.DocCompletionStats, error) { + return s.repo.GetStats(ctx, docID) +} diff --git a/backend/internal/infrastructure/authorization/document_authorizer.go b/backend/internal/infrastructure/authorization/document_authorizer.go new file mode 100644 index 0000000..dcce60d --- /dev/null +++ b/backend/internal/infrastructure/authorization/document_authorizer.go @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +package authorization + +import ( + "context" + "strings" + + "github.com/btouchard/ackify-ce/backend/internal/domain/models" +) + +type CEDocumentAuthorizer struct { + adminEmails []string + onlyAdminCanCreate bool +} + +func NewCEDocumentAuthorizer(adminEmails []string, onlyAdminCanCreate bool) *CEDocumentAuthorizer { + return &CEDocumentAuthorizer{ + adminEmails: adminEmails, + onlyAdminCanCreate: onlyAdminCanCreate, + } +} + +func (a *CEDocumentAuthorizer) CanCreateDocument(ctx context.Context, user *models.User) bool { + if !a.onlyAdminCanCreate { + return true + } + + if user == nil { + return false + } + + for _, adminEmail := range a.adminEmails { + if strings.EqualFold(user.Email, adminEmail) { + return true + } + } + + return false +} diff --git a/backend/internal/presentation/api/documents/handler.go b/backend/internal/presentation/api/documents/handler.go index 0ef799d..b059751 100644 --- a/backend/internal/presentation/api/documents/handler.go +++ b/backend/internal/presentation/api/documents/handler.go @@ -42,26 +42,34 @@ type webhookPublisher interface { Publish(ctx context.Context, eventType string, payload map[string]interface{}) error } +// signatureService defines the interface for signature operations (used for counts) +type signatureService interface { + GetDocumentSignatures(ctx context.Context, docID string) ([]*models.Signature, error) +} + +// documentAuthorizer defines the interface for document creation authorization +type documentAuthorizer interface { + CanCreateDocument(ctx context.Context, user *models.User) bool +} + // Handler handles document API requests type Handler struct { - signatureService *services.SignatureService + signatureService signatureService documentService documentService documentRepo documentRepository expectedSignerRepo expectedSignerRepository webhookPublisher webhookPublisher - adminEmails []string - onlyAdminCanCreate bool + authorizer documentAuthorizer } // NewHandler creates a handler with all dependencies for full functionality func NewHandler( - signatureService *services.SignatureService, + signatureService signatureService, documentService documentService, documentRepo documentRepository, expectedSignerRepo expectedSignerRepository, publisher webhookPublisher, - adminEmails []string, - onlyAdminCanCreate bool, + authorizer documentAuthorizer, ) *Handler { return &Handler{ signatureService: signatureService, @@ -69,8 +77,7 @@ func NewHandler( documentRepo: documentRepo, expectedSignerRepo: expectedSignerRepo, webhookPublisher: publisher, - adminEmails: adminEmails, - onlyAdminCanCreate: onlyAdminCanCreate, + authorizer: authorizer, } } @@ -117,32 +124,14 @@ type CreateDocumentResponse struct { func (h *Handler) HandleCreateDocument(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - // Check if only admins can create documents - if h.onlyAdminCanCreate { - user, authenticated := shared.GetUserFromContext(ctx) - if !authenticated { - logger.Logger.Warn("Unauthenticated user attempted to create document", - "remote_addr", r.RemoteAddr) + user, _ := shared.GetUserFromContext(ctx) + if !h.authorizer.CanCreateDocument(ctx, user) { + if user == nil { shared.WriteError(w, http.StatusUnauthorized, shared.ErrCodeUnauthorized, "Authentication required to create document", nil) - return - } - - // Check if user is admin - isAdmin := false - for _, adminEmail := range h.adminEmails { - if strings.ToLower(user.Email) == strings.ToLower(adminEmail) { - isAdmin = true - break - } - } - - if !isAdmin { - logger.Logger.Warn("Non-admin user attempted to create document", - "user_email", user.Email, - "remote_addr", r.RemoteAddr) - shared.WriteError(w, http.StatusForbidden, shared.ErrCodeForbidden, "Only administrators can create documents", nil) - return + } else { + shared.WriteError(w, http.StatusForbidden, shared.ErrCodeForbidden, "Not authorized to create documents", nil) } + return } // Parse request body @@ -470,8 +459,7 @@ func (h *Handler) HandleFindOrCreateDocument(w http.ResponseWriter, r *http.Requ "reference", ref, "remote_addr", r.RemoteAddr) - // Check if user is authenticated - user, isAuthenticated := shared.GetUserFromContext(ctx) + user, _ := shared.GetUserFromContext(ctx) // First, try to find the document (without creating) refType := detectReferenceType(ref) @@ -505,35 +493,16 @@ func (h *Handler) HandleFindOrCreateDocument(w http.ResponseWriter, r *http.Requ return } - // Document doesn't exist - check authentication before creating - if !isAuthenticated { - logger.Logger.Warn("Unauthenticated user attempted to create document", - "reference", ref, - "remote_addr", r.RemoteAddr) - shared.WriteError(w, http.StatusUnauthorized, shared.ErrCodeUnauthorized, "Authentication required to create document", nil) + // Document doesn't exist - check authorization before creating + if !h.authorizer.CanCreateDocument(ctx, user) { + if user == nil { + shared.WriteError(w, http.StatusUnauthorized, shared.ErrCodeUnauthorized, "Authentication required to create document", nil) + } else { + shared.WriteError(w, http.StatusForbidden, shared.ErrCodeForbidden, "Not authorized to create documents", nil) + } return } - // Check if only admins can create documents - if h.onlyAdminCanCreate { - isAdmin := false - for _, adminEmail := range h.adminEmails { - if strings.ToLower(user.Email) == strings.ToLower(adminEmail) { - isAdmin = true - break - } - } - - if !isAdmin { - logger.Logger.Warn("Non-admin user attempted to create document via find-or-create", - "user_email", user.Email, - "reference", ref, - "remote_addr", r.RemoteAddr) - shared.WriteError(w, http.StatusForbidden, shared.ErrCodeForbidden, "Only administrators can create documents", nil) - return - } - } - // User is authenticated, create the document doc, isNew, err := h.documentService.FindOrCreateDocument(ctx, ref) if err != nil { diff --git a/backend/internal/presentation/api/documents/handler_test.go b/backend/internal/presentation/api/documents/handler_test.go index 57fa0bb..2195bf5 100644 --- a/backend/internal/presentation/api/documents/handler_test.go +++ b/backend/internal/presentation/api/documents/handler_test.go @@ -20,6 +20,8 @@ import ( "github.com/btouchard/ackify-ce/backend/internal/presentation/api/shared" ) +// Note: services import is still needed for CreateDocumentRequest + // ============================================================================ // TEST FIXTURES & MOCKS // ============================================================================ @@ -103,10 +105,23 @@ func (m *mockSignatureService) GetDocumentSignatures(ctx context.Context, docID return []*models.Signature{testSignature}, nil } +// Mock document authorizer +type mockDocumentAuthorizer struct { + canCreateFunc func(ctx context.Context, user *models.User) bool +} + +func (m *mockDocumentAuthorizer) CanCreateDocument(ctx context.Context, user *models.User) bool { + if m.canCreateFunc != nil { + return m.canCreateFunc(ctx, user) + } + return true +} + func createTestHandler() *Handler { return &Handler{ - signatureService: &services.SignatureService{}, // Not used in these tests + signatureService: &mockSignatureService{}, documentService: &mockDocumentService{}, + authorizer: &mockDocumentAuthorizer{}, } } @@ -121,14 +136,16 @@ func addUserToContext(ctx context.Context, user *models.User) context.Context { func TestNewHandler(t *testing.T) { t.Parallel() - sigService := &services.SignatureService{} + sigService := &mockSignatureService{} docService := &mockDocumentService{} + authorizer := &mockDocumentAuthorizer{} - handler := NewHandler(sigService, docService, nil, nil, nil, nil, false) + handler := NewHandler(sigService, docService, nil, nil, nil, authorizer) assert.NotNil(t, handler) assert.Equal(t, sigService, handler.signatureService) assert.Equal(t, docService, handler.documentService) + assert.Equal(t, authorizer, handler.authorizer) } // ============================================================================ @@ -174,6 +191,7 @@ func TestHandler_HandleCreateDocument_Success(t *testing.T) { handler := &Handler{ documentService: mockDocService, + authorizer: &mockDocumentAuthorizer{}, } reqBody := CreateDocumentRequest{ @@ -272,6 +290,7 @@ func TestHandler_HandleCreateDocument_ServiceError(t *testing.T) { handler := &Handler{ documentService: mockDocService, + authorizer: &mockDocumentAuthorizer{}, } reqBody := CreateDocumentRequest{ @@ -422,6 +441,7 @@ func TestHandler_HandleFindOrCreateDocument_FindExisting(t *testing.T) { handler := &Handler{ documentService: mockDocService, + authorizer: &mockDocumentAuthorizer{}, } req := httptest.NewRequest(http.MethodGet, "/api/v1/documents/find-or-create?ref=https://example.com/doc.pdf", nil) @@ -446,7 +466,6 @@ func TestHandler_HandleFindOrCreateDocument_CreateNew(t *testing.T) { mockDocService := &mockDocumentService{ findByReferenceFunc: func(ctx context.Context, ref string, refType string) (*models.Document, error) { - // Document not found - return nil, nil (not an error) return nil, nil }, findOrCreateDocFunc: func(ctx context.Context, ref string) (*models.Document, bool, error) { @@ -457,11 +476,11 @@ func TestHandler_HandleFindOrCreateDocument_CreateNew(t *testing.T) { handler := &Handler{ documentService: mockDocService, + authorizer: &mockDocumentAuthorizer{}, } req := httptest.NewRequest(http.MethodGet, "/api/v1/documents/find-or-create?ref=https://example.com/new-doc.pdf", nil) - // Add authenticated user to context ctx := addUserToContext(req.Context(), testUser) req = req.WithContext(ctx) @@ -486,17 +505,16 @@ func TestHandler_HandleFindOrCreateDocument_UnauthenticatedCreate(t *testing.T) mockDocService := &mockDocumentService{ findByReferenceFunc: func(ctx context.Context, ref string, refType string) (*models.Document, error) { - // Document not found - return nil, nil (not an error) return nil, nil }, } handler := &Handler{ documentService: mockDocService, + authorizer: &mockDocumentAuthorizer{canCreateFunc: func(ctx context.Context, user *models.User) bool { return user != nil }}, } req := httptest.NewRequest(http.MethodGet, "/api/v1/documents/find-or-create?ref=https://example.com/new-doc.pdf", nil) - // No user in context rec := httptest.NewRecorder() handler.HandleFindOrCreateDocument(rec, req) @@ -698,14 +716,13 @@ func TestHandler_HandleCreateDocument_Concurrent(t *testing.T) { // ADMIN-ONLY DOCUMENT CREATION TESTS // ============================================================================ -func TestHandler_HandleCreateDocument_AdminOnlyEnabled_AdminUser(t *testing.T) { +func TestHandler_HandleCreateDocument_Authorized(t *testing.T) { t.Parallel() handler := &Handler{ - signatureService: &services.SignatureService{}, - documentService: &mockDocumentService{}, - adminEmails: []string{"admin@example.com"}, - onlyAdminCanCreate: true, + signatureService: &mockSignatureService{}, + documentService: &mockDocumentService{}, + authorizer: &mockDocumentAuthorizer{canCreateFunc: func(ctx context.Context, user *models.User) bool { return true }}, } reqBody := CreateDocumentRequest{ @@ -718,7 +735,6 @@ func TestHandler_HandleCreateDocument_AdminOnlyEnabled_AdminUser(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/api/v1/documents", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") - // Add admin user to context adminUser := &models.User{ Sub: "oauth2|admin", Email: "admin@example.com", @@ -734,14 +750,13 @@ func TestHandler_HandleCreateDocument_AdminOnlyEnabled_AdminUser(t *testing.T) { assert.Contains(t, rec.Body.String(), "test-doc-123") } -func TestHandler_HandleCreateDocument_AdminOnlyEnabled_NonAdminUser(t *testing.T) { +func TestHandler_HandleCreateDocument_NotAuthorized(t *testing.T) { t.Parallel() handler := &Handler{ - signatureService: &services.SignatureService{}, - documentService: &mockDocumentService{}, - adminEmails: []string{"admin@example.com"}, - onlyAdminCanCreate: true, + signatureService: &mockSignatureService{}, + documentService: &mockDocumentService{}, + authorizer: &mockDocumentAuthorizer{canCreateFunc: func(ctx context.Context, user *models.User) bool { return false }}, } reqBody := CreateDocumentRequest{ @@ -754,7 +769,6 @@ func TestHandler_HandleCreateDocument_AdminOnlyEnabled_NonAdminUser(t *testing.T req := httptest.NewRequest(http.MethodPost, "/api/v1/documents", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") - // Add non-admin user to context regularUser := &models.User{ Sub: "oauth2|user", Email: "user@example.com", @@ -767,17 +781,16 @@ func TestHandler_HandleCreateDocument_AdminOnlyEnabled_NonAdminUser(t *testing.T handler.HandleCreateDocument(rec, req) assert.Equal(t, http.StatusForbidden, rec.Code) - assert.Contains(t, rec.Body.String(), "Only administrators can create documents") + assert.Contains(t, rec.Body.String(), "Not authorized") } -func TestHandler_HandleCreateDocument_AdminOnlyEnabled_Unauthenticated(t *testing.T) { +func TestHandler_HandleCreateDocument_Unauthenticated(t *testing.T) { t.Parallel() handler := &Handler{ - signatureService: &services.SignatureService{}, - documentService: &mockDocumentService{}, - adminEmails: []string{"admin@example.com"}, - onlyAdminCanCreate: true, + signatureService: &mockSignatureService{}, + documentService: &mockDocumentService{}, + authorizer: &mockDocumentAuthorizer{canCreateFunc: func(ctx context.Context, user *models.User) bool { return user != nil }}, } reqBody := CreateDocumentRequest{ @@ -789,7 +802,6 @@ func TestHandler_HandleCreateDocument_AdminOnlyEnabled_Unauthenticated(t *testin req := httptest.NewRequest(http.MethodPost, "/api/v1/documents", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") - // No user in context (unauthenticated) rec := httptest.NewRecorder() handler.HandleCreateDocument(rec, req) @@ -798,14 +810,13 @@ func TestHandler_HandleCreateDocument_AdminOnlyEnabled_Unauthenticated(t *testin assert.Contains(t, rec.Body.String(), "Authentication required") } -func TestHandler_HandleCreateDocument_AdminOnlyDisabled_AnyUser(t *testing.T) { +func TestHandler_HandleCreateDocument_PublicCreation(t *testing.T) { t.Parallel() handler := &Handler{ - signatureService: &services.SignatureService{}, - documentService: &mockDocumentService{}, - adminEmails: []string{"admin@example.com"}, - onlyAdminCanCreate: false, // Disabled + signatureService: &mockSignatureService{}, + documentService: &mockDocumentService{}, + authorizer: &mockDocumentAuthorizer{canCreateFunc: func(ctx context.Context, user *models.User) bool { return true }}, } reqBody := CreateDocumentRequest{ diff --git a/backend/internal/presentation/api/router.go b/backend/internal/presentation/api/router.go index fe69732..6ebbc6e 100644 --- a/backend/internal/presentation/api/router.go +++ b/backend/internal/presentation/api/router.go @@ -16,35 +16,27 @@ import ( "github.com/btouchard/ackify-ce/backend/internal/infrastructure/database" apiAdmin "github.com/btouchard/ackify-ce/backend/internal/presentation/api/admin" apiAuth "github.com/btouchard/ackify-ce/backend/internal/presentation/api/auth" - "github.com/btouchard/ackify-ce/backend/internal/presentation/api/documents" "github.com/btouchard/ackify-ce/backend/internal/presentation/api/health" "github.com/btouchard/ackify-ce/backend/internal/presentation/api/shared" - "github.com/btouchard/ackify-ce/backend/internal/presentation/api/signatures" "github.com/btouchard/ackify-ce/backend/internal/presentation/api/users" + "github.com/btouchard/ackify-ce/backend/pkg/coreapp" ) // RouterConfig holds configuration for the API router type RouterConfig struct { AuthService *auth.OauthService MagicLinkService *services.MagicLinkService - SignatureService *services.SignatureService - DocumentService *services.DocumentService - DocumentRepository *database.DocumentRepository - ExpectedSignerRepository *database.ExpectedSignerRepository - ReminderService *services.ReminderAsyncService // Now using async service WebhookRepository *database.WebhookRepository WebhookDeliveryRepository *database.WebhookDeliveryRepository - WebhookPublisher *services.WebhookPublisher + CoreDeps coreapp.CoreDeps BaseURL string AdminEmails []string AutoLogin bool OAuthEnabled bool MagicLinkEnabled bool - OnlyAdminCanCreate bool - AuthRateLimit int // Global auth rate limit (requests per minute), default: 5 - DocumentRateLimit int // Document creation rate limit (requests per minute), default: 10 - GeneralRateLimit int // General API rate limit (requests per minute), default: 100 - ImportMaxSigners int // Maximum signers per CSV import, default: 500 + AuthRateLimit int + DocumentRateLimit int + GeneralRateLimit int } // NewRouter creates and configures the API v1 router @@ -82,20 +74,14 @@ func NewRouter(cfg RouterConfig) *chi.Mux { r.Use(apiMiddleware.CORS) r.Use(generalRateLimit.Middleware) - // Initialize handlers + // Initialize coreapp handler groups (documents/signatures) + coreGroups := coreapp.NewHandlerGroups(cfg.CoreDeps) + + // Initialize handlers for non-coreapp routes healthHandler := health.NewHandler() authHandler := apiAuth.NewHandler(cfg.AuthService, cfg.MagicLinkService, apiMiddleware, cfg.BaseURL, cfg.OAuthEnabled, cfg.MagicLinkEnabled) usersHandler := users.NewHandler(cfg.AdminEmails) - documentsHandler := documents.NewHandler( - cfg.SignatureService, - cfg.DocumentService, - cfg.DocumentRepository, - cfg.ExpectedSignerRepository, - cfg.WebhookPublisher, - cfg.AdminEmails, - cfg.OnlyAdminCanCreate, - ) - signaturesHandler := signatures.NewHandler(cfg.SignatureService, cfg.ExpectedSignerRepository, cfg.WebhookPublisher) + webhooksHandler := apiAdmin.NewWebhooksHandler(cfg.WebhookRepository, cfg.WebhookDeliveryRepository) // Public routes r.Group(func(r chi.Router) { @@ -139,27 +125,18 @@ func NewRouter(cfg RouterConfig) *chi.Mux { }) }) - // Public document endpoints - r.Route("/documents", func(r chi.Router) { - // Document creation (with CSRF and stricter rate limiting) - r.Group(func(r chi.Router) { - r.Use(apiMiddleware.CSRFProtect) - r.Use(documentRateLimit.Middleware) - r.Post("/", documentsHandler.HandleCreateDocument) - }) + // Public document endpoints (from coreapp) + coreGroups.RegisterPublic(r) + }) - // Read-only document endpoints - r.Get("/", documentsHandler.HandleListDocuments) - r.Get("/{docId}", documentsHandler.HandleGetDocument) - r.Get("/{docId}/signatures", documentsHandler.HandleGetDocumentSignatures) - r.Get("/{docId}/expected-signers", documentsHandler.HandleGetExpectedSigners) - - // Find or create document by reference (public for embed support, but with optional auth) - r.Group(func(r chi.Router) { - r.Use(apiMiddleware.OptionalAuth) - r.Get("/find-or-create", documentsHandler.HandleFindOrCreateDocument) - }) - }) + // User document routes with special handling + // - Document creation requires CSRF + rate limiting + // - find-or-create requires optional auth + r.Group(func(r chi.Router) { + r.Use(apiMiddleware.CSRFProtect) + r.Use(documentRateLimit.Middleware) + r.Use(apiMiddleware.OptionalAuth) + coreGroups.RegisterUser(r) }) // Authenticated routes @@ -167,19 +144,10 @@ func NewRouter(cfg RouterConfig) *chi.Mux { r.Use(apiMiddleware.RequireAuth) r.Use(apiMiddleware.CSRFProtect) - // User endpoints + // User endpoints (non-coreapp) r.Route("/users", func(r chi.Router) { r.Get("/me", usersHandler.HandleGetCurrentUser) }) - - // Signature endpoints - r.Route("/signatures", func(r chi.Router) { - r.Get("/", signaturesHandler.HandleGetUserSignatures) - r.Post("/", signaturesHandler.HandleCreateSignature) - }) - - // Document signature status (authenticated) - r.Get("/documents/{docId}/signatures/status", signaturesHandler.HandleGetSignatureStatus) }) // Admin routes @@ -187,53 +155,18 @@ func NewRouter(cfg RouterConfig) *chi.Mux { r.Use(apiMiddleware.RequireAdmin) r.Use(apiMiddleware.CSRFProtect) - // Configure import max signers with default - importMaxSigners := cfg.ImportMaxSigners - if importMaxSigners == 0 { - importMaxSigners = 500 // Default: 500 signers per import - } + // Admin document routes (from coreapp) + coreGroups.RegisterAdmin(r) - // Initialize admin handler - adminHandler := apiAdmin.NewHandler(cfg.DocumentRepository, cfg.ExpectedSignerRepository, cfg.ReminderService, cfg.SignatureService, cfg.BaseURL, importMaxSigners) - webhooksHandler := apiAdmin.NewWebhooksHandler(cfg.WebhookRepository, cfg.WebhookDeliveryRepository) - - r.Route("/admin", func(r chi.Router) { - // Document management - r.Route("/documents", func(r chi.Router) { - r.Get("/", adminHandler.HandleListDocuments) - r.Get("/{docId}", adminHandler.HandleGetDocument) - r.Get("/{docId}/signers", adminHandler.HandleGetDocumentWithSigners) - r.Get("/{docId}/status", adminHandler.HandleGetDocumentStatus) - - // Document metadata - r.Put("/{docId}/metadata", adminHandler.HandleUpdateDocumentMetadata) - - // Document deletion - r.Delete("/{docId}", adminHandler.HandleDeleteDocument) - - // Expected signers management - r.Post("/{docId}/signers", adminHandler.HandleAddExpectedSigner) - r.Delete("/{docId}/signers/{email}", adminHandler.HandleRemoveExpectedSigner) - - // CSV import for expected signers - r.Post("/{docId}/signers/preview-csv", adminHandler.HandlePreviewCSV) - r.Post("/{docId}/signers/import", adminHandler.HandleImportSigners) - - // Reminder management - r.Post("/{docId}/reminders", adminHandler.HandleSendReminders) - r.Get("/{docId}/reminders", adminHandler.HandleGetReminderHistory) - }) - - // Webhooks management - r.Route("/webhooks", func(r chi.Router) { - r.Get("/", webhooksHandler.HandleListWebhooks) - r.Post("/", webhooksHandler.HandleCreateWebhook) - r.Get("/{id}", webhooksHandler.HandleGetWebhook) - r.Put("/{id}", webhooksHandler.HandleUpdateWebhook) - r.Patch("/{id}/{action}", webhooksHandler.HandleToggleWebhook) // action: enable|disable - r.Delete("/{id}", webhooksHandler.HandleDeleteWebhook) - r.Get("/{id}/deliveries", webhooksHandler.HandleListDeliveries) - }) + // Webhooks management (non-coreapp) + r.Route("/admin/webhooks", func(r chi.Router) { + r.Get("/", webhooksHandler.HandleListWebhooks) + r.Post("/", webhooksHandler.HandleCreateWebhook) + r.Get("/{id}", webhooksHandler.HandleGetWebhook) + r.Put("/{id}", webhooksHandler.HandleUpdateWebhook) + r.Patch("/{id}/{action}", webhooksHandler.HandleToggleWebhook) // action: enable|disable + r.Delete("/{id}", webhooksHandler.HandleDeleteWebhook) + r.Get("/{id}/deliveries", webhooksHandler.HandleListDeliveries) }) }) diff --git a/backend/pkg/coreapp/deps.go b/backend/pkg/coreapp/deps.go new file mode 100644 index 0000000..69aa3af --- /dev/null +++ b/backend/pkg/coreapp/deps.go @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +package coreapp + +import ( + "context" + + "github.com/btouchard/ackify-ce/backend/internal/application/services" + "github.com/btouchard/ackify-ce/backend/internal/domain/models" +) + +type DocumentService interface { + // Creation + CreateDocument(ctx context.Context, req services.CreateDocumentRequest) (*models.Document, error) + FindOrCreateDocument(ctx context.Context, ref string) (*models.Document, bool, error) + FindByReference(ctx context.Context, ref string, refType string) (*models.Document, error) + // Read + List(ctx context.Context, limit, offset int) ([]*models.Document, error) + Search(ctx context.Context, query string, limit, offset int) ([]*models.Document, error) + Count(ctx context.Context, searchQuery string) (int, error) + GetByDocID(ctx context.Context, docID string) (*models.Document, error) + // Write + CreateOrUpdate(ctx context.Context, docID string, input models.DocumentInput, createdBy string) (*models.Document, error) + Delete(ctx context.Context, docID string) error +} + +type SignatureService interface { + CreateSignature(ctx context.Context, request *models.SignatureRequest) error + GetSignatureStatus(ctx context.Context, docID string, user *models.User) (*models.SignatureStatus, error) + GetSignatureByDocAndUser(ctx context.Context, docID string, user *models.User) (*models.Signature, error) + GetDocumentSignatures(ctx context.Context, docID string) ([]*models.Signature, error) + GetUserSignatures(ctx context.Context, user *models.User) ([]*models.Signature, error) +} + +type ExpectedSignerService interface { + ListByDocID(ctx context.Context, docID string) ([]*models.ExpectedSigner, error) + ListWithStatusByDocID(ctx context.Context, docID string) ([]*models.ExpectedSignerWithStatus, error) + AddExpected(ctx context.Context, docID string, contacts []models.ContactInfo, addedBy string) error + Remove(ctx context.Context, docID, email string) error + GetStats(ctx context.Context, docID string) (*models.DocCompletionStats, error) +} + +type ReminderService interface { + SendReminders(ctx context.Context, docID, sentBy string, specificEmails []string, docURL string, locale string) (*models.ReminderSendResult, error) + GetReminderHistory(ctx context.Context, docID string) ([]*models.ReminderLog, error) + GetReminderStats(ctx context.Context, docID string) (*models.ReminderStats, error) +} + +type WebhookPublisher interface { + Publish(ctx context.Context, eventType string, payload map[string]interface{}) error +} + +type DocumentAuthorizer interface { + CanCreateDocument(ctx context.Context, user *models.User) bool +} + +type CoreDeps struct { + Documents DocumentService + DocumentAuthorizer DocumentAuthorizer + Signatures SignatureService + ExpectedSigners ExpectedSignerService + Reminders ReminderService + WebhookPublisher WebhookPublisher + BaseURL string + ImportMaxSigners int +} diff --git a/backend/pkg/coreapp/handlers.go b/backend/pkg/coreapp/handlers.go new file mode 100644 index 0000000..6e26338 --- /dev/null +++ b/backend/pkg/coreapp/handlers.go @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +package coreapp + +import ( + "github.com/btouchard/ackify-ce/backend/internal/presentation/api/admin" + "github.com/btouchard/ackify-ce/backend/internal/presentation/api/documents" + "github.com/btouchard/ackify-ce/backend/internal/presentation/api/signatures" +) + +type CoreHandlers struct { + Documents *documents.Handler + Signatures *signatures.Handler + Admin *admin.Handler +} + +func NewCoreHandlers(deps CoreDeps) *CoreHandlers { + return &CoreHandlers{ + Documents: documents.NewHandler( + deps.Signatures, + deps.Documents, + deps.Documents, // DocumentService implements documentRepository interface + deps.ExpectedSigners, // ExpectedSignerService implements expectedSignerRepository interface + deps.WebhookPublisher, + deps.DocumentAuthorizer, + ), + Signatures: signatures.NewHandler( + deps.Signatures, + deps.ExpectedSigners, // ExpectedSignerService implements expectedSignerStatsRepo interface + deps.WebhookPublisher, + ), + Admin: admin.NewHandler( + deps.Documents, // DocumentService implements documentRepository interface + deps.ExpectedSigners, // ExpectedSignerService implements expectedSignerRepository interface + deps.Reminders, + deps.Signatures, + deps.BaseURL, + deps.ImportMaxSigners, + ), + } +} diff --git a/backend/pkg/coreapp/router.go b/backend/pkg/coreapp/router.go new file mode 100644 index 0000000..1dc5044 --- /dev/null +++ b/backend/pkg/coreapp/router.go @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +package coreapp + +import "github.com/go-chi/chi/v5" + +type RouteRegistrar func(r chi.Router) + +type HandlerGroups struct { + RegisterPublic RouteRegistrar + RegisterUser RouteRegistrar + RegisterAdmin RouteRegistrar +} + +func NewHandlerGroups(deps CoreDeps) HandlerGroups { + h := NewCoreHandlers(deps) + + return HandlerGroups{ + RegisterPublic: func(r chi.Router) { + r.Route("/documents", func(r chi.Router) { + r.Get("/", h.Documents.HandleListDocuments) + r.Get("/{docId}", h.Documents.HandleGetDocument) + r.Get("/{docId}/signatures", h.Documents.HandleGetDocumentSignatures) + r.Get("/{docId}/expected-signers", h.Documents.HandleGetExpectedSigners) + }) + }, + + RegisterUser: func(r chi.Router) { + r.Post("/documents", h.Documents.HandleCreateDocument) + r.Get("/documents/find-or-create", h.Documents.HandleFindOrCreateDocument) + r.Get("/signatures", h.Signatures.HandleGetUserSignatures) + r.Post("/signatures", h.Signatures.HandleCreateSignature) + r.Get("/documents/{docId}/signatures/status", h.Signatures.HandleGetSignatureStatus) + }, + + RegisterAdmin: func(r chi.Router) { + r.Route("/admin/documents", func(r chi.Router) { + r.Get("/", h.Admin.HandleListDocuments) + r.Get("/{docId}", h.Admin.HandleGetDocument) + r.Get("/{docId}/signers", h.Admin.HandleGetDocumentWithSigners) + r.Get("/{docId}/status", h.Admin.HandleGetDocumentStatus) + r.Put("/{docId}/metadata", h.Admin.HandleUpdateDocumentMetadata) + r.Delete("/{docId}", h.Admin.HandleDeleteDocument) + r.Post("/{docId}/signers", h.Admin.HandleAddExpectedSigner) + r.Delete("/{docId}/signers/{email}", h.Admin.HandleRemoveExpectedSigner) + r.Post("/{docId}/signers/preview-csv", h.Admin.HandlePreviewCSV) + r.Post("/{docId}/signers/import", h.Admin.HandleImportSigners) + r.Post("/{docId}/reminders", h.Admin.HandleSendReminders) + r.Get("/{docId}/reminders", h.Admin.HandleGetReminderHistory) + }) + }, + } +} diff --git a/backend/pkg/web/server.go b/backend/pkg/web/server.go index 012e01f..c470b1a 100644 --- a/backend/pkg/web/server.go +++ b/backend/pkg/web/server.go @@ -15,6 +15,7 @@ import ( "github.com/btouchard/ackify-ce/backend/internal/application/services" "github.com/btouchard/ackify-ce/backend/internal/infrastructure/auth" + "github.com/btouchard/ackify-ce/backend/internal/infrastructure/authorization" "github.com/btouchard/ackify-ce/backend/internal/infrastructure/config" "github.com/btouchard/ackify-ce/backend/internal/infrastructure/database" "github.com/btouchard/ackify-ce/backend/internal/infrastructure/email" @@ -24,6 +25,7 @@ import ( "github.com/btouchard/ackify-ce/backend/internal/infrastructure/workers" "github.com/btouchard/ackify-ce/backend/internal/presentation/api" "github.com/btouchard/ackify-ce/backend/internal/presentation/handlers" + "github.com/btouchard/ackify-ce/backend/pkg/coreapp" "github.com/btouchard/ackify-ce/backend/pkg/crypto" "github.com/btouchard/ackify-ce/backend/pkg/logger" ) @@ -104,6 +106,7 @@ func NewServer(ctx context.Context, cfg *config.Config, frontend embed.FS, versi signatureService := services.NewSignatureService(signatureRepo, documentRepo, signer) signatureService.SetChecksumConfig(&cfg.Checksum) documentService := services.NewDocumentService(documentRepo, &cfg.Checksum) + expectedSignerService := services.NewExpectedSignerService(expectedSignerRepo) // Initialize email worker for async processing var emailWorker *email.Worker @@ -177,27 +180,39 @@ func NewServer(ctx context.Context, cfg *config.Config, frontend embed.FS, versi webhookPublisher, )) + // Build CoreDeps for coreapp handlers (documents/signatures) + importMaxSigners := cfg.App.ImportMaxSigners + if importMaxSigners == 0 { + importMaxSigners = 500 // Default: 500 signers per import + } + + documentAuthorizer := authorization.NewCEDocumentAuthorizer(cfg.App.AdminEmails, cfg.App.OnlyAdminCanCreate) + + coreDeps := coreapp.CoreDeps{ + Documents: documentService, + Signatures: signatureService, + ExpectedSigners: expectedSignerService, + Reminders: reminderService, + WebhookPublisher: webhookPublisher, + DocumentAuthorizer: documentAuthorizer, + BaseURL: cfg.App.BaseURL, + ImportMaxSigners: importMaxSigners, + } + apiConfig := api.RouterConfig{ AuthService: authService, MagicLinkService: magicLinkService, - SignatureService: signatureService, - DocumentService: documentService, - DocumentRepository: documentRepo, - ExpectedSignerRepository: expectedSignerRepo, - ReminderService: reminderService, WebhookRepository: webhookRepo, WebhookDeliveryRepository: webhookDeliveryRepo, - WebhookPublisher: webhookPublisher, + CoreDeps: coreDeps, BaseURL: cfg.App.BaseURL, AdminEmails: cfg.App.AdminEmails, AutoLogin: cfg.OAuth.AutoLogin, OAuthEnabled: cfg.Auth.OAuthEnabled, MagicLinkEnabled: cfg.Auth.MagicLinkEnabled, - OnlyAdminCanCreate: cfg.App.OnlyAdminCanCreate, AuthRateLimit: cfg.App.AuthRateLimit, DocumentRateLimit: cfg.App.DocumentRateLimit, GeneralRateLimit: cfg.App.GeneralRateLimit, - ImportMaxSigners: cfg.App.ImportMaxSigners, } apiRouter := api.NewRouter(apiConfig) router.Mount("/api/v1", apiRouter)