Files
ackify/backend/internal/presentation/api/documents/handler.go
Benjamin 066374ca33 refactor(api): centralize pagination logic in shared package
Changes:
- Add PaginationParams struct for query parameter handling
- Add ParsePaginationParams() to parse and validate pagination from HTTP requests
- Add Validate() method with configurable min/max constraints
- Support both 'limit' and 'page_size' query parameters for flexibility
- Migrate documents handler (default: 20/page, max: 100)
- Migrate admin handler (default: 100/page, max: 200)
- Remove duplicated strconv imports and validation logic
2025-11-23 00:22:29 +01:00

580 lines
18 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-or-later
package documents
import (
"context"
"encoding/json"
"net/http"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
"github.com/btouchard/ackify-ce/backend/internal/application/services"
"github.com/btouchard/ackify-ce/backend/internal/domain/models"
"github.com/btouchard/ackify-ce/backend/internal/presentation/api/shared"
"github.com/btouchard/ackify-ce/backend/pkg/logger"
)
// documentService defines the interface for document operations
type documentService interface {
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)
}
// documentRepository defines the interface for document repository operations
type documentRepository interface {
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)
}
// expectedSignerRepository defines the interface for expected signer operations
type expectedSignerRepository interface {
ListByDocID(ctx context.Context, docID string) ([]*models.ExpectedSigner, error)
GetStats(ctx context.Context, docID string) (*models.DocCompletionStats, error)
}
// webhookPublisher defines minimal publish capability
type webhookPublisher interface {
Publish(ctx context.Context, eventType string, payload map[string]interface{}) error
}
// Handler handles document API requests
type Handler struct {
signatureService *services.SignatureService
documentService documentService
documentRepo documentRepository
expectedSignerRepo expectedSignerRepository
webhookPublisher webhookPublisher
adminEmails []string
onlyAdminCanCreate bool
}
// NewHandler creates a handler with all dependencies for full functionality
func NewHandler(
signatureService *services.SignatureService,
documentService documentService,
documentRepo documentRepository,
expectedSignerRepo expectedSignerRepository,
publisher webhookPublisher,
adminEmails []string,
onlyAdminCanCreate bool,
) *Handler {
return &Handler{
signatureService: signatureService,
documentService: documentService,
documentRepo: documentRepo,
expectedSignerRepo: expectedSignerRepo,
webhookPublisher: publisher,
adminEmails: adminEmails,
onlyAdminCanCreate: onlyAdminCanCreate,
}
}
// DocumentDTO represents a document data transfer object
type DocumentDTO struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
CreatedAt string `json:"createdAt,omitempty"`
UpdatedAt string `json:"updatedAt,omitempty"`
SignatureCount int `json:"signatureCount"`
ExpectedSignerCount int `json:"expectedSignerCount"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
// SignatureDTO represents a signature data transfer object
type SignatureDTO struct {
ID string `json:"id"`
DocID string `json:"docId"`
UserEmail string `json:"userEmail"`
UserName string `json:"userName,omitempty"`
SignedAt string `json:"signedAt"`
Signature string `json:"signature"`
PayloadHash string `json:"payloadHash"`
Nonce string `json:"nonce"`
PrevHash string `json:"prevHash,omitempty"`
}
// CreateDocumentRequest represents the request body for creating a document
type CreateDocumentRequest struct {
Reference string `json:"reference"`
Title string `json:"title,omitempty"`
}
// CreateDocumentResponse represents the response for creating a document
type CreateDocumentResponse struct {
DocID string `json:"docId"`
URL string `json:"url,omitempty"`
Title string `json:"title"`
CreatedAt string `json:"createdAt"`
}
// HandleCreateDocument handles POST /api/v1/documents
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)
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
}
}
// Parse request body
var req CreateDocumentRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
logger.Logger.Warn("Invalid document creation request body",
"error", err.Error(),
"remote_addr", r.RemoteAddr)
shared.WriteError(w, http.StatusBadRequest, shared.ErrCodeBadRequest, "Invalid request body", map[string]interface{}{"error": err.Error()})
return
}
// Validate reference field
if req.Reference == "" {
logger.Logger.Warn("Document creation request missing reference field",
"remote_addr", r.RemoteAddr)
shared.WriteError(w, http.StatusBadRequest, shared.ErrCodeBadRequest, "Reference is required", nil)
return
}
logger.Logger.Info("Document creation request received",
"reference", req.Reference,
"has_title", req.Title != "",
"remote_addr", r.RemoteAddr)
// Create document request
docRequest := services.CreateDocumentRequest{
Reference: req.Reference,
Title: req.Title,
}
// Create document
doc, err := h.documentService.CreateDocument(ctx, docRequest)
if err != nil {
logger.Logger.Error("Document creation failed in handler",
"reference", req.Reference,
"error", err.Error())
shared.WriteError(w, http.StatusInternalServerError, shared.ErrCodeInternal, "Failed to create document", map[string]interface{}{"error": err.Error()})
return
}
logger.Logger.Info("Document creation succeeded",
"doc_id", doc.DocID,
"title", doc.Title,
"has_url", doc.URL != "")
// Publish webhook event
if h.webhookPublisher != nil {
_ = h.webhookPublisher.Publish(ctx, "document.created", map[string]interface{}{
"doc_id": doc.DocID,
"title": doc.Title,
"url": doc.URL,
"checksum": doc.Checksum,
"checksum_algorithm": doc.ChecksumAlgorithm,
})
}
// Return the created document
response := CreateDocumentResponse{
DocID: doc.DocID,
URL: doc.URL,
Title: doc.Title,
CreatedAt: doc.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
}
shared.WriteJSON(w, http.StatusCreated, response)
}
// HandleListDocuments handles GET /api/v1/documents
func (h *Handler) HandleListDocuments(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Parse pagination and search parameters
pagination := shared.ParsePaginationParams(r, 20, 100)
searchQuery := r.URL.Query().Get("search")
// If no document repository is available, return empty list (backward compat)
if h.documentRepo == nil {
shared.WritePaginatedJSON(w, []DocumentDTO{}, pagination.Page, pagination.PageSize, 0)
return
}
// Fetch documents from repository
var docs []*models.Document
var err error
if searchQuery != "" {
// Use search if query is provided
docs, err = h.documentRepo.Search(ctx, searchQuery, pagination.PageSize, pagination.Offset)
logger.Logger.Debug("Public document search request",
"query", searchQuery,
"limit", pagination.PageSize,
"offset", pagination.Offset)
} else {
// Otherwise, list all documents
docs, err = h.documentRepo.List(ctx, pagination.PageSize, pagination.Offset)
logger.Logger.Debug("Public document list request",
"limit", pagination.PageSize,
"offset", pagination.Offset)
}
if err != nil {
logger.Logger.Error("Failed to fetch documents",
"search", searchQuery,
"error", err.Error())
shared.WriteError(w, http.StatusInternalServerError, shared.ErrCodeInternal, "Failed to fetch documents", nil)
return
}
// Get total count of documents (with or without search filter)
totalCount, err := h.documentRepo.Count(ctx, searchQuery)
if err != nil {
logger.Logger.Warn("Failed to count documents, using result count",
"error", err.Error(),
"search", searchQuery)
totalCount = len(docs)
}
// Convert to DTOs (enriched with counts)
documents := make([]DocumentDTO, 0, len(docs))
for _, doc := range docs {
dto := DocumentDTO{
ID: doc.DocID,
Title: doc.Title,
Description: doc.Description,
CreatedAt: doc.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
UpdatedAt: doc.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
}
// Get signature count
if sigs, err := h.signatureService.GetDocumentSignatures(ctx, doc.DocID); err == nil {
dto.SignatureCount = len(sigs)
}
// Get expected signer count
if h.expectedSignerRepo != nil {
if stats, err := h.expectedSignerRepo.GetStats(ctx, doc.DocID); err == nil {
dto.ExpectedSignerCount = stats.ExpectedCount
}
}
documents = append(documents, dto)
}
shared.WritePaginatedJSON(w, documents, pagination.Page, pagination.PageSize, totalCount)
}
// HandleGetDocument handles GET /api/v1/documents/{docId}
func (h *Handler) HandleGetDocument(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
docID := chi.URLParam(r, "docId")
if docID == "" {
shared.WriteValidationError(w, "Document ID is required", nil)
return
}
// Get document from DB if repository available
var doc *models.Document
if h.documentRepo != nil {
var err error
doc, err = h.documentRepo.GetByDocID(ctx, docID)
if err != nil {
logger.Logger.Error("Failed to get document", "doc_id", docID, "error", err.Error())
shared.WriteInternalError(w)
return
}
if doc == nil {
shared.WriteError(w, http.StatusNotFound, shared.ErrCodeNotFound, "Document not found", nil)
return
}
}
// Get signatures for the document
signatures, err := h.signatureService.GetDocumentSignatures(ctx, docID)
if err != nil {
logger.Logger.Error("Failed to get signatures", "doc_id", docID, "error", err.Error())
shared.WriteInternalError(w)
return
}
// Build response
response := DocumentDTO{
ID: docID,
SignatureCount: len(signatures),
}
// Fill with document data if available
if doc != nil {
response.Title = doc.Title
response.Description = doc.Description
response.CreatedAt = doc.CreatedAt.Format("2006-01-02T15:04:05Z07:00")
response.UpdatedAt = doc.UpdatedAt.Format("2006-01-02T15:04:05Z07:00")
} else {
// Fallback for backward compat (when no repo)
response.Title = "Document " + docID
}
// Get expected signer count
if h.expectedSignerRepo != nil {
if stats, err := h.expectedSignerRepo.GetStats(ctx, docID); err == nil {
response.ExpectedSignerCount = stats.ExpectedCount
}
}
shared.WriteJSON(w, http.StatusOK, response)
}
// HandleGetDocumentSignatures handles GET /api/v1/documents/{docId}/signatures
func (h *Handler) HandleGetDocumentSignatures(w http.ResponseWriter, r *http.Request) {
docID := chi.URLParam(r, "docId")
if docID == "" {
shared.WriteValidationError(w, "Document ID is required", nil)
return
}
ctx := r.Context()
signatures, err := h.signatureService.GetDocumentSignatures(ctx, docID)
if err != nil {
logger.Logger.Error("Failed to get signatures",
"doc_id", docID,
"error", err.Error())
shared.WriteInternalError(w)
return
}
// Convert to DTOs
dtos := make([]SignatureDTO, len(signatures))
for i := range signatures {
dtos[i] = signatureToDTO(signatures[i])
}
shared.WriteJSON(w, http.StatusOK, dtos)
}
// PublicExpectedSigner represents an expected signer in public API (minimal info)
type PublicExpectedSigner struct {
Email string `json:"email"`
Name string `json:"name"`
}
// HandleGetExpectedSigners handles GET /api/v1/documents/{docId}/expected-signers
func (h *Handler) HandleGetExpectedSigners(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
docID := chi.URLParam(r, "docId")
if docID == "" {
shared.WriteValidationError(w, "Document ID is required", nil)
return
}
// If no repository, return empty list (backward compat)
if h.expectedSignerRepo == nil {
shared.WriteJSON(w, http.StatusOK, []PublicExpectedSigner{})
return
}
// Get expected signers (public version - without internal notes/metadata)
signers, err := h.expectedSignerRepo.ListByDocID(ctx, docID)
if err != nil {
logger.Logger.Error("Failed to get expected signers", "doc_id", docID, "error", err.Error())
shared.WriteInternalError(w)
return
}
// Convert to public DTO (minimal info - no notes, no internal metadata)
response := make([]PublicExpectedSigner, 0, len(signers))
for _, signer := range signers {
response = append(response, PublicExpectedSigner{
Email: signer.Email,
Name: signer.Name,
})
}
shared.WriteJSON(w, http.StatusOK, response)
}
// Helper function to convert signature model to DTO
func signatureToDTO(sig *models.Signature) SignatureDTO {
dto := SignatureDTO{
ID: strconv.FormatInt(sig.ID, 10),
DocID: sig.DocID,
UserEmail: sig.UserEmail,
UserName: sig.UserName,
SignedAt: sig.SignedAtUTC.Format("2006-01-02T15:04:05Z07:00"),
Signature: sig.Signature,
PayloadHash: sig.PayloadHash,
Nonce: sig.Nonce,
}
if sig.PrevHash != nil && *sig.PrevHash != "" {
dto.PrevHash = *sig.PrevHash
}
return dto
}
// FindOrCreateDocumentResponse represents the response for finding or creating a document
type FindOrCreateDocumentResponse struct {
DocID string `json:"docId"`
URL string `json:"url,omitempty"`
Title string `json:"title"`
Checksum string `json:"checksum,omitempty"`
ChecksumAlgorithm string `json:"checksumAlgorithm,omitempty"`
Description string `json:"description,omitempty"`
CreatedAt string `json:"createdAt"`
IsNew bool `json:"isNew"`
}
// HandleFindOrCreateDocument handles GET /api/v1/documents/find-or-create?ref={reference}
func (h *Handler) HandleFindOrCreateDocument(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Get reference from query parameter
ref := r.URL.Query().Get("ref")
if ref == "" {
logger.Logger.Warn("Find or create request missing ref parameter",
"remote_addr", r.RemoteAddr)
shared.WriteError(w, http.StatusBadRequest, shared.ErrCodeBadRequest, "ref parameter is required", nil)
return
}
logger.Logger.Info("Find or create document request",
"reference", ref,
"remote_addr", r.RemoteAddr)
// Check if user is authenticated
user, isAuthenticated := shared.GetUserFromContext(ctx)
// First, try to find the document (without creating)
refType := detectReferenceType(ref)
existingDoc, err := h.documentService.FindByReference(ctx, ref, string(refType))
if err != nil {
logger.Logger.Error("Failed to search for document",
"reference", ref,
"error", err.Error())
shared.WriteError(w, http.StatusInternalServerError, shared.ErrCodeInternal, "Failed to search for document", map[string]interface{}{"error": err.Error()})
return
}
// If document exists, return it
if existingDoc != nil {
logger.Logger.Info("Document found",
"doc_id", existingDoc.DocID,
"reference", ref)
response := FindOrCreateDocumentResponse{
DocID: existingDoc.DocID,
URL: existingDoc.URL,
Title: existingDoc.Title,
Checksum: existingDoc.Checksum,
ChecksumAlgorithm: existingDoc.ChecksumAlgorithm,
Description: existingDoc.Description,
CreatedAt: existingDoc.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
IsNew: false,
}
shared.WriteJSON(w, http.StatusOK, response)
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)
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 {
logger.Logger.Error("Failed to create document",
"reference", ref,
"error", err.Error())
shared.WriteError(w, http.StatusInternalServerError, shared.ErrCodeInternal, "Failed to create document", map[string]interface{}{"error": err.Error()})
return
}
logger.Logger.Info("Document created",
"doc_id", doc.DocID,
"reference", ref,
"user_email", user.Email)
// Build response
response := FindOrCreateDocumentResponse{
DocID: doc.DocID,
URL: doc.URL,
Title: doc.Title,
Checksum: doc.Checksum,
ChecksumAlgorithm: doc.ChecksumAlgorithm,
Description: doc.Description,
CreatedAt: doc.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
IsNew: isNew,
}
shared.WriteJSON(w, http.StatusOK, response)
}
func detectReferenceType(ref string) ReferenceType {
if strings.HasPrefix(ref, "http://") || strings.HasPrefix(ref, "https://") {
return "url"
}
if strings.Contains(ref, "/") || strings.Contains(ref, "\\") {
return "path"
}
return "reference"
}
type ReferenceType string