refacto(backend): extract coreapp packages for DI and authorization

- Add pkg/coreapp/ with service interfaces and dependency injection
- Add DocumentAuthorizer for document access control
- Add ExpectedSignerService for expected signers management
- Simplify router and handlers by using coreapp dependencies
This commit is contained in:
Benjamin
2025-12-04 15:19:01 +01:00
parent 796d327442
commit 1b108ed874
13 changed files with 471 additions and 199 deletions
@@ -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())
@@ -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)
}
@@ -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) {
@@ -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{}
@@ -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)
}
@@ -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
}
@@ -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 {
@@ -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{
+33 -100
View File
@@ -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)
})
})
+65
View File
@@ -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
}
+40
View File
@@ -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,
),
}
}
+52
View File
@@ -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)
})
},
}
}
+23 -8
View File
@@ -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)