feat(csv): import expected signature from CSV

This commit is contained in:
Benjamin
2025-11-26 22:40:21 +01:00
parent 1608aad6a8
commit 533e62fcfe
17 changed files with 1734 additions and 63 deletions

View File

@@ -0,0 +1,251 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
package services
import (
"encoding/csv"
"errors"
"io"
"regexp"
"strings"
)
var emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)
// CSVSignerEntry represents a valid signer entry parsed from CSV
type CSVSignerEntry struct {
LineNumber int `json:"lineNumber"`
Email string `json:"email"`
Name string `json:"name"`
}
// CSVParseError represents an error for a specific line in the CSV
type CSVParseError struct {
LineNumber int `json:"lineNumber"`
Content string `json:"content"`
Error string `json:"error"`
}
// CSVParseResult contains the complete result of parsing a CSV file
type CSVParseResult struct {
Signers []CSVSignerEntry `json:"signers"`
Errors []CSVParseError `json:"errors"`
TotalLines int `json:"totalLines"`
ValidCount int `json:"validCount"`
InvalidCount int `json:"invalidCount"`
HasHeader bool `json:"hasHeader"`
}
// CSVParserConfig holds configuration for CSV parsing
type CSVParserConfig struct {
MaxSigners int
}
// CSVParser handles CSV file parsing for expected signers import
type CSVParser struct {
config CSVParserConfig
}
// NewCSVParser creates a new CSV parser with the given configuration
func NewCSVParser(maxSigners int) *CSVParser {
return &CSVParser{
config: CSVParserConfig{
MaxSigners: maxSigners,
},
}
}
// Parse reads and parses a CSV file from the given reader
func (p *CSVParser) Parse(reader io.Reader) (*CSVParseResult, error) {
result := &CSVParseResult{
Signers: []CSVSignerEntry{},
Errors: []CSVParseError{},
}
// Try to detect the separator by reading the first line
content, err := io.ReadAll(reader)
if err != nil {
return nil, err
}
if len(content) == 0 {
return result, nil
}
separator := detectSeparator(string(content))
csvReader := csv.NewReader(strings.NewReader(string(content)))
csvReader.Comma = separator
csvReader.FieldsPerRecord = -1 // Allow variable number of fields
csvReader.TrimLeadingSpace = true
csvReader.LazyQuotes = true
records, err := csvReader.ReadAll()
if err != nil {
return nil, err
}
if len(records) == 0 {
return result, nil
}
// Detect header and column positions
emailCol, nameCol, hasHeader := detectColumns(records[0])
result.HasHeader = hasHeader
startRow := 0
if hasHeader {
startRow = 1
}
entryNumber := 0 // Counter for entry numbering (starts at 1 for first entry)
for i := startRow; i < len(records); i++ {
row := records[i]
// Skip empty rows
if isEmptyRow(row) {
continue
}
entryNumber++ // Increment for each non-empty data row
result.TotalLines++
// Check max signers limit
if p.config.MaxSigners > 0 && len(result.Signers) >= p.config.MaxSigners {
result.Errors = append(result.Errors, CSVParseError{
LineNumber: entryNumber,
Content: strings.Join(row, string(separator)),
Error: "max_signers_exceeded",
})
result.InvalidCount++
continue
}
entry, parseErr := parseRow(row, emailCol, nameCol, entryNumber)
if parseErr != nil {
result.Errors = append(result.Errors, CSVParseError{
LineNumber: entryNumber,
Content: strings.Join(row, string(separator)),
Error: parseErr.Error(),
})
result.InvalidCount++
continue
}
result.Signers = append(result.Signers, *entry)
result.ValidCount++
}
return result, nil
}
// detectSeparator determines if the CSV uses comma or semicolon
func detectSeparator(content string) rune {
firstLine := strings.Split(content, "\n")[0]
semicolonCount := strings.Count(firstLine, ";")
commaCount := strings.Count(firstLine, ",")
if semicolonCount > commaCount {
return ';'
}
return ','
}
// detectColumns analyzes the first row to detect column positions and if it's a header
func detectColumns(firstRow []string) (emailCol, nameCol int, hasHeader bool) {
emailCol = -1
nameCol = -1
hasHeader = false
for i, field := range firstRow {
normalized := strings.ToLower(strings.TrimSpace(field))
switch normalized {
case "email", "e-mail", "mail", "courriel":
emailCol = i
hasHeader = true
case "name", "nom", "prenom", "prénom", "firstname", "lastname", "fullname", "full_name":
nameCol = i
hasHeader = true
}
}
// If header detected, return found positions
if hasHeader {
return emailCol, nameCol, true
}
// No header detected - determine column positions from data
// Try to identify email column by checking for @ symbol
for i, field := range firstRow {
trimmed := strings.TrimSpace(field)
if isValidEmail(trimmed) {
emailCol = i
// If there's another column, assume it's the name
if len(firstRow) > 1 {
if i == 0 {
nameCol = 1
} else {
nameCol = 0
}
}
break
}
}
// If we couldn't find an email column, assume first column is email
if emailCol == -1 {
emailCol = 0
if len(firstRow) > 1 {
nameCol = 1
}
}
return emailCol, nameCol, false
}
// parseRow extracts email and name from a row
func parseRow(row []string, emailCol, nameCol, lineNumber int) (*CSVSignerEntry, error) {
email := ""
name := ""
if emailCol >= 0 && emailCol < len(row) {
email = strings.TrimSpace(row[emailCol])
}
if nameCol >= 0 && nameCol < len(row) {
name = strings.TrimSpace(row[nameCol])
}
// Validate email
if email == "" {
return nil, errors.New("email_required")
}
email = strings.ToLower(email)
if !isValidEmail(email) {
return nil, errors.New("invalid_email_format")
}
return &CSVSignerEntry{
LineNumber: lineNumber,
Email: email,
Name: name,
}, nil
}
// isValidEmail checks if the email format is valid
func isValidEmail(email string) bool {
return emailRegex.MatchString(email)
}
// isEmptyRow checks if all fields in a row are empty
func isEmptyRow(row []string) bool {
for _, field := range row {
if strings.TrimSpace(field) != "" {
return false
}
}
return true
}

View File

@@ -0,0 +1,570 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
package services
import (
"strings"
"testing"
)
func TestCSVParser_Parse_WithHeader(t *testing.T) {
parser := NewCSVParser(500)
csvContent := `email,name
jane@example.com,Jane Doe
john@example.com,John Smith`
result, err := parser.Parse(strings.NewReader(csvContent))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !result.HasHeader {
t.Error("expected HasHeader=true")
}
if result.ValidCount != 2 {
t.Errorf("expected ValidCount=2, got %d", result.ValidCount)
}
if len(result.Signers) != 2 {
t.Errorf("expected 2 signers, got %d", len(result.Signers))
}
if result.Signers[0].Email != "jane@example.com" {
t.Errorf("expected email jane@example.com, got %s", result.Signers[0].Email)
}
if result.Signers[0].Name != "Jane Doe" {
t.Errorf("expected name Jane Doe, got %s", result.Signers[0].Name)
}
}
func TestCSVParser_Parse_WithHeaderReversedColumns(t *testing.T) {
parser := NewCSVParser(500)
csvContent := `name,email
Jane Doe,jane@example.com
John Smith,john@example.com`
result, err := parser.Parse(strings.NewReader(csvContent))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !result.HasHeader {
t.Error("expected HasHeader=true")
}
if result.ValidCount != 2 {
t.Errorf("expected ValidCount=2, got %d", result.ValidCount)
}
if result.Signers[0].Email != "jane@example.com" {
t.Errorf("expected email jane@example.com, got %s", result.Signers[0].Email)
}
if result.Signers[0].Name != "Jane Doe" {
t.Errorf("expected name Jane Doe, got %s", result.Signers[0].Name)
}
}
func TestCSVParser_Parse_WithoutHeader(t *testing.T) {
parser := NewCSVParser(500)
csvContent := `jane@example.com,Jane Doe
john@example.com,John Smith`
result, err := parser.Parse(strings.NewReader(csvContent))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.HasHeader {
t.Error("expected HasHeader=false")
}
if result.ValidCount != 2 {
t.Errorf("expected ValidCount=2, got %d", result.ValidCount)
}
if result.Signers[0].Email != "jane@example.com" {
t.Errorf("expected email jane@example.com, got %s", result.Signers[0].Email)
}
}
func TestCSVParser_Parse_SingleColumn(t *testing.T) {
parser := NewCSVParser(500)
csvContent := `email
jane@example.com
john@example.com`
result, err := parser.Parse(strings.NewReader(csvContent))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !result.HasHeader {
t.Error("expected HasHeader=true")
}
if result.ValidCount != 2 {
t.Errorf("expected ValidCount=2, got %d", result.ValidCount)
}
if result.Signers[0].Name != "" {
t.Errorf("expected empty name, got %s", result.Signers[0].Name)
}
}
func TestCSVParser_Parse_SingleColumnNoHeader(t *testing.T) {
parser := NewCSVParser(500)
csvContent := `jane@example.com
john@example.com`
result, err := parser.Parse(strings.NewReader(csvContent))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.HasHeader {
t.Error("expected HasHeader=false")
}
if result.ValidCount != 2 {
t.Errorf("expected ValidCount=2, got %d", result.ValidCount)
}
}
func TestCSVParser_Parse_SemicolonSeparator(t *testing.T) {
parser := NewCSVParser(500)
csvContent := `email;name
jane@example.com;Jane Doe
john@example.com;John Smith`
result, err := parser.Parse(strings.NewReader(csvContent))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.ValidCount != 2 {
t.Errorf("expected ValidCount=2, got %d", result.ValidCount)
}
if result.Signers[0].Email != "jane@example.com" {
t.Errorf("expected email jane@example.com, got %s", result.Signers[0].Email)
}
}
func TestCSVParser_Parse_InvalidEmails(t *testing.T) {
parser := NewCSVParser(500)
csvContent := `email,name
jane@example.com,Jane Doe
not-an-email,Invalid User
john@example.com,John Smith
@missing.local,Bad Email`
result, err := parser.Parse(strings.NewReader(csvContent))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.ValidCount != 2 {
t.Errorf("expected ValidCount=2, got %d", result.ValidCount)
}
if result.InvalidCount != 2 {
t.Errorf("expected InvalidCount=2, got %d", result.InvalidCount)
}
if len(result.Errors) != 2 {
t.Errorf("expected 2 errors, got %d", len(result.Errors))
}
for _, parseErr := range result.Errors {
if parseErr.Error != "invalid_email_format" {
t.Errorf("expected error 'invalid_email_format', got '%s'", parseErr.Error)
}
}
}
func TestCSVParser_Parse_EmptyLines(t *testing.T) {
parser := NewCSVParser(500)
csvContent := `email,name
jane@example.com,Jane Doe
john@example.com,John Smith
,`
result, err := parser.Parse(strings.NewReader(csvContent))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.ValidCount != 2 {
t.Errorf("expected ValidCount=2, got %d", result.ValidCount)
}
// Empty lines (including ",") are skipped, so InvalidCount should be 0
if result.InvalidCount != 0 {
t.Errorf("expected InvalidCount=0, got %d", result.InvalidCount)
}
// TotalLines should only count non-empty data rows
if result.TotalLines != 2 {
t.Errorf("expected TotalLines=2, got %d", result.TotalLines)
}
}
func TestCSVParser_Parse_MaxLimit(t *testing.T) {
parser := NewCSVParser(2)
csvContent := `email,name
jane@example.com,Jane Doe
john@example.com,John Smith
alice@example.com,Alice Brown
bob@example.com,Bob Wilson`
result, err := parser.Parse(strings.NewReader(csvContent))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.ValidCount != 2 {
t.Errorf("expected ValidCount=2 (max limit), got %d", result.ValidCount)
}
if result.InvalidCount != 2 {
t.Errorf("expected InvalidCount=2 (exceeds limit), got %d", result.InvalidCount)
}
// Check that the exceeded entries have the right error
for _, parseErr := range result.Errors {
if parseErr.Error != "max_signers_exceeded" {
t.Errorf("expected error 'max_signers_exceeded', got '%s'", parseErr.Error)
}
}
}
func TestCSVParser_Parse_TrimWhitespace(t *testing.T) {
parser := NewCSVParser(500)
csvContent := `email,name
jane@example.com , Jane Doe
john@example.com , John Smith `
result, err := parser.Parse(strings.NewReader(csvContent))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.ValidCount != 2 {
t.Errorf("expected ValidCount=2, got %d", result.ValidCount)
}
if result.Signers[0].Email != "jane@example.com" {
t.Errorf("expected trimmed email jane@example.com, got '%s'", result.Signers[0].Email)
}
if result.Signers[0].Name != "Jane Doe" {
t.Errorf("expected trimmed name 'Jane Doe', got '%s'", result.Signers[0].Name)
}
}
func TestCSVParser_Parse_EmptyFile(t *testing.T) {
parser := NewCSVParser(500)
result, err := parser.Parse(strings.NewReader(""))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.TotalLines != 0 {
t.Errorf("expected TotalLines=0, got %d", result.TotalLines)
}
if result.ValidCount != 0 {
t.Errorf("expected ValidCount=0, got %d", result.ValidCount)
}
}
func TestCSVParser_Parse_HeaderOnly(t *testing.T) {
parser := NewCSVParser(500)
csvContent := `email,name`
result, err := parser.Parse(strings.NewReader(csvContent))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !result.HasHeader {
t.Error("expected HasHeader=true")
}
if result.TotalLines != 0 {
t.Errorf("expected TotalLines=0, got %d", result.TotalLines)
}
if result.ValidCount != 0 {
t.Errorf("expected ValidCount=0, got %d", result.ValidCount)
}
}
func TestCSVParser_Parse_EmailNormalization(t *testing.T) {
parser := NewCSVParser(500)
csvContent := `email,name
JANE@EXAMPLE.COM,Jane Doe
John@Example.COM,John Smith`
result, err := parser.Parse(strings.NewReader(csvContent))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Signers[0].Email != "jane@example.com" {
t.Errorf("expected lowercase email jane@example.com, got %s", result.Signers[0].Email)
}
if result.Signers[1].Email != "john@example.com" {
t.Errorf("expected lowercase email john@example.com, got %s", result.Signers[1].Email)
}
}
func TestCSVParser_Parse_FrenchHeaders(t *testing.T) {
parser := NewCSVParser(500)
csvContent := `courriel,nom
jane@example.com,Jane Dupont`
result, err := parser.Parse(strings.NewReader(csvContent))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !result.HasHeader {
t.Error("expected HasHeader=true for French headers")
}
if result.ValidCount != 1 {
t.Errorf("expected ValidCount=1, got %d", result.ValidCount)
}
if result.Signers[0].Email != "jane@example.com" {
t.Errorf("expected email jane@example.com, got %s", result.Signers[0].Email)
}
}
func TestCSVParser_Parse_CRLFLineEndings(t *testing.T) {
parser := NewCSVParser(500)
csvContent := "email,name\r\njane@example.com,Jane Doe\r\njohn@example.com,John Smith"
result, err := parser.Parse(strings.NewReader(csvContent))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.ValidCount != 2 {
t.Errorf("expected ValidCount=2, got %d", result.ValidCount)
}
}
func TestCSVParser_Parse_SpecialCharactersInName(t *testing.T) {
parser := NewCSVParser(500)
csvContent := `email,name
jane@example.com,"Jean-François Müller"
john@example.com,José García`
result, err := parser.Parse(strings.NewReader(csvContent))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.ValidCount != 2 {
t.Errorf("expected ValidCount=2, got %d", result.ValidCount)
}
if result.Signers[0].Name != "Jean-François Müller" {
t.Errorf("expected name 'Jean-François Müller', got '%s'", result.Signers[0].Name)
}
if result.Signers[1].Name != "José García" {
t.Errorf("expected name 'José García', got '%s'", result.Signers[1].Name)
}
}
func TestCSVParser_Parse_QuotedFields(t *testing.T) {
parser := NewCSVParser(500)
csvContent := `email,name
"jane@example.com","Doe, Jane"
"john@example.com","Smith, John"`
result, err := parser.Parse(strings.NewReader(csvContent))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.ValidCount != 2 {
t.Errorf("expected ValidCount=2, got %d", result.ValidCount)
}
if result.Signers[0].Name != "Doe, Jane" {
t.Errorf("expected name 'Doe, Jane', got '%s'", result.Signers[0].Name)
}
}
func TestCSVParser_Parse_MissingEmailColumn(t *testing.T) {
parser := NewCSVParser(500)
csvContent := `name
Jane Doe
John Smith`
result, err := parser.Parse(strings.NewReader(csvContent))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Should fail because "name" header doesn't contain emails
// Since only 'name' is detected as header, emailCol will be -1
// All lines will fail email validation
if result.ValidCount != 0 {
t.Errorf("expected ValidCount=0, got %d", result.ValidCount)
}
if result.InvalidCount != 2 {
t.Errorf("expected InvalidCount=2, got %d", result.InvalidCount)
}
}
func TestCSVParser_Parse_EntryNumbering(t *testing.T) {
parser := NewCSVParser(500)
// CSV with header - entry numbers should start at 1
csvContent := `email,name
jane@example.com,Jane Doe
john@example.com,John Smith
alice@example.com,Alice Brown`
result, err := parser.Parse(strings.NewReader(csvContent))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// First entry should be number 1, not 2 (even though it's file line 2)
if result.Signers[0].LineNumber != 1 {
t.Errorf("expected first entry LineNumber=1, got %d", result.Signers[0].LineNumber)
}
if result.Signers[1].LineNumber != 2 {
t.Errorf("expected second entry LineNumber=2, got %d", result.Signers[1].LineNumber)
}
if result.Signers[2].LineNumber != 3 {
t.Errorf("expected third entry LineNumber=3, got %d", result.Signers[2].LineNumber)
}
}
func TestCSVParser_Parse_EntryNumberingWithErrors(t *testing.T) {
parser := NewCSVParser(500)
// CSV with header and some invalid entries
csvContent := `email,name
jane@example.com,Jane Doe
invalid-email,Bad User
john@example.com,John Smith`
result, err := parser.Parse(strings.NewReader(csvContent))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Valid entries should be numbered 1 and 3
if result.Signers[0].LineNumber != 1 {
t.Errorf("expected first valid entry LineNumber=1, got %d", result.Signers[0].LineNumber)
}
if result.Signers[1].LineNumber != 3 {
t.Errorf("expected second valid entry LineNumber=3, got %d", result.Signers[1].LineNumber)
}
// Error should be at entry 2
if result.Errors[0].LineNumber != 2 {
t.Errorf("expected error at entry LineNumber=2, got %d", result.Errors[0].LineNumber)
}
}
func TestDetectSeparator(t *testing.T) {
tests := []struct {
name string
content string
expected rune
}{
{
name: "comma separator",
content: "email,name\njane@example.com,Jane",
expected: ',',
},
{
name: "semicolon separator",
content: "email;name\njane@example.com;Jane",
expected: ';',
},
{
name: "more semicolons",
content: "email;name;extra\njane@example.com,Jane",
expected: ';',
},
{
name: "equal count defaults to comma",
content: "email,name;extra",
expected: ',',
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := detectSeparator(tt.content)
if got != tt.expected {
t.Errorf("expected separator '%c', got '%c'", tt.expected, got)
}
})
}
}
func TestIsValidEmail(t *testing.T) {
tests := []struct {
email string
expected bool
}{
{"test@example.com", true},
{"test.name@example.com", true},
{"test+tag@example.com", true},
{"test@sub.example.com", true},
{"TEST@EXAMPLE.COM", true},
{"invalid", false},
{"invalid@", false},
{"@example.com", false},
{"test@.com", false},
{"test@example", false},
{"", false},
{"test@@example.com", false},
}
for _, tt := range tests {
t.Run(tt.email, func(t *testing.T) {
got := isValidEmail(tt.email)
if got != tt.expected {
t.Errorf("isValidEmail(%s) = %v, expected %v", tt.email, got, tt.expected)
}
})
}
}

View File

@@ -39,6 +39,7 @@ type AppConfig struct {
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
}
type DatabaseConfig struct {
@@ -252,6 +253,9 @@ func Load() (*Config, error) {
config.App.DocumentRateLimit = getEnvInt("ACKIFY_DOCUMENT_RATE_LIMIT", 10)
config.App.GeneralRateLimit = getEnvInt("ACKIFY_GENERAL_RATE_LIMIT", 100)
// CSV import configuration
config.App.ImportMaxSigners = getEnvInt("ACKIFY_IMPORT_MAX_SIGNERS", 500)
// Validation: At least one authentication method must be enabled
if !config.Auth.OAuthEnabled && !config.Auth.MagicLinkEnabled {
return nil, fmt.Errorf("at least one authentication method must be enabled: set ACKIFY_OAUTH_CLIENT_ID/CLIENT_SECRET for OAuth or ACKIFY_MAIL_HOST for MagicLink")

View File

@@ -4,9 +4,13 @@ package admin
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"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/infrastructure/i18n"
"github.com/btouchard/ackify-ce/backend/internal/presentation/api/shared"
@@ -52,16 +56,18 @@ type Handler struct {
reminderService reminderService
signatureService signatureService
baseURL string
importMaxSigners int
}
// NewHandler creates a new admin handler
func NewHandler(documentRepo documentRepository, expectedSignerRepo expectedSignerRepository, reminderService reminderService, signatureService signatureService, baseURL string) *Handler {
func NewHandler(documentRepo documentRepository, expectedSignerRepo expectedSignerRepository, reminderService reminderService, signatureService signatureService, baseURL string, importMaxSigners int) *Handler {
return &Handler{
documentRepo: documentRepo,
expectedSignerRepo: expectedSignerRepo,
reminderService: reminderService,
signatureService: signatureService,
baseURL: baseURL,
importMaxSigners: importMaxSigners,
}
}
@@ -684,3 +690,193 @@ func (h *Handler) HandleDeleteDocument(w http.ResponseWriter, r *http.Request) {
"message": "Document deleted successfully",
})
}
// CSVPreviewResponse represents the response for CSV preview
type CSVPreviewResponse struct {
Signers []services.CSVSignerEntry `json:"signers"`
Errors []services.CSVParseError `json:"errors"`
TotalLines int `json:"totalLines"`
ValidCount int `json:"validCount"`
InvalidCount int `json:"invalidCount"`
HasHeader bool `json:"hasHeader"`
ExistingEmails []string `json:"existingEmails"`
MaxSigners int `json:"maxSigners"`
}
// HandlePreviewCSV handles POST /api/v1/admin/documents/{docId}/signers/preview-csv
func (h *Handler) HandlePreviewCSV(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
docID := chi.URLParam(r, "docId")
if docID == "" {
shared.WriteError(w, http.StatusBadRequest, shared.ErrCodeBadRequest, "Document ID is required", nil)
return
}
// Limit file size to 1MB
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1MB
// Parse multipart form
if err := r.ParseMultipartForm(1 << 20); err != nil {
shared.WriteError(w, http.StatusBadRequest, shared.ErrCodeBadRequest, "File too large or invalid form data", nil)
return
}
file, _, err := r.FormFile("file")
if err != nil {
shared.WriteError(w, http.StatusBadRequest, shared.ErrCodeBadRequest, "CSV file is required", nil)
return
}
defer file.Close()
// Read file content
content, err := io.ReadAll(file)
if err != nil {
shared.WriteError(w, http.StatusBadRequest, shared.ErrCodeBadRequest, "Failed to read file", nil)
return
}
// Parse CSV
parser := services.NewCSVParser(h.importMaxSigners)
result, err := parser.Parse(strings.NewReader(string(content)))
if err != nil {
logger.Logger.Error("Failed to parse CSV", "error", err.Error(), "doc_id", docID)
shared.WriteError(w, http.StatusBadRequest, shared.ErrCodeBadRequest, fmt.Sprintf("Failed to parse CSV: %s", err.Error()), nil)
return
}
// Get existing signers for this document to identify duplicates
existingEmails := []string{}
existingSigners, err := h.expectedSignerRepo.ListByDocID(ctx, docID)
if err == nil {
existingEmailsMap := make(map[string]bool)
for _, signer := range existingSigners {
existingEmailsMap[strings.ToLower(signer.Email)] = true
}
// Check which emails from the CSV already exist
for _, entry := range result.Signers {
if existingEmailsMap[strings.ToLower(entry.Email)] {
existingEmails = append(existingEmails, entry.Email)
}
}
}
response := CSVPreviewResponse{
Signers: result.Signers,
Errors: result.Errors,
TotalLines: result.TotalLines,
ValidCount: result.ValidCount,
InvalidCount: result.InvalidCount,
HasHeader: result.HasHeader,
ExistingEmails: existingEmails,
MaxSigners: h.importMaxSigners,
}
shared.WriteJSON(w, http.StatusOK, response)
}
// ImportSignersRequest represents the request body for importing signers
type ImportSignersRequest struct {
Signers []ImportSignerEntry `json:"signers"`
}
// ImportSignerEntry represents a single signer to import
type ImportSignerEntry struct {
Email string `json:"email"`
Name string `json:"name"`
}
// ImportSignersResponse represents the response for signer import
type ImportSignersResponse struct {
Message string `json:"message"`
Imported int `json:"imported"`
Skipped int `json:"skipped"`
Total int `json:"total"`
}
// HandleImportSigners handles POST /api/v1/admin/documents/{docId}/signers/import
func (h *Handler) HandleImportSigners(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
docID := chi.URLParam(r, "docId")
if docID == "" {
shared.WriteError(w, http.StatusBadRequest, shared.ErrCodeBadRequest, "Document ID is required", nil)
return
}
// Get user from context
user, ok := shared.GetUserFromContext(ctx)
if !ok {
shared.WriteUnauthorized(w, "")
return
}
// Parse request body
var req ImportSignersRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
shared.WriteError(w, http.StatusBadRequest, shared.ErrCodeBadRequest, "Invalid request body", nil)
return
}
// Validate signers count
if len(req.Signers) == 0 {
shared.WriteError(w, http.StatusBadRequest, shared.ErrCodeBadRequest, "At least one signer is required", nil)
return
}
if len(req.Signers) > h.importMaxSigners {
shared.WriteError(w, http.StatusBadRequest, shared.ErrCodeBadRequest,
fmt.Sprintf("Maximum %d signers per import (received %d)", h.importMaxSigners, len(req.Signers)), nil)
return
}
// Get existing signers to calculate skipped count
existingEmailsMap := make(map[string]bool)
existingSigners, err := h.expectedSignerRepo.ListByDocID(ctx, docID)
if err == nil {
for _, signer := range existingSigners {
existingEmailsMap[strings.ToLower(signer.Email)] = true
}
}
// Count how many will be skipped (already exist)
skippedCount := 0
for _, signer := range req.Signers {
if existingEmailsMap[strings.ToLower(signer.Email)] {
skippedCount++
}
}
// Convert to ContactInfo slice
contacts := make([]models.ContactInfo, 0, len(req.Signers))
for _, signer := range req.Signers {
contacts = append(contacts, models.ContactInfo{
Email: strings.ToLower(strings.TrimSpace(signer.Email)),
Name: strings.TrimSpace(signer.Name),
})
}
// Add all signers (repository handles duplicates with ON CONFLICT DO NOTHING)
if err := h.expectedSignerRepo.AddExpected(ctx, docID, contacts, user.Email); err != nil {
logger.Logger.Error("Failed to import signers", "error", err.Error(), "doc_id", docID, "count", len(contacts))
shared.WriteError(w, http.StatusInternalServerError, shared.ErrCodeInternal, "Failed to import signers", nil)
return
}
importedCount := len(req.Signers) - skippedCount
logger.Logger.Info("Signers imported successfully",
"doc_id", docID,
"imported", importedCount,
"skipped", skippedCount,
"total", len(req.Signers),
"imported_by", user.Email)
shared.WriteJSON(w, http.StatusOK, ImportSignersResponse{
Message: "Import completed",
Imported: importedCount,
Skipped: skippedCount,
Total: len(req.Signers),
})
}

View File

@@ -44,6 +44,7 @@ type RouterConfig struct {
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
}
// NewRouter creates and configures the API v1 router
@@ -186,8 +187,14 @@ 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
}
// Initialize admin handler
adminHandler := apiAdmin.NewHandler(cfg.DocumentRepository, cfg.ExpectedSignerRepository, cfg.ReminderService, cfg.SignatureService, cfg.BaseURL)
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) {
@@ -208,6 +215,10 @@ func NewRouter(cfg RouterConfig) *chi.Mux {
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)

View File

@@ -183,6 +183,7 @@ func NewServer(ctx context.Context, cfg *config.Config, frontend embed.FS, versi
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)

View File

@@ -113,6 +113,9 @@ ACKIFY_AUTH_MAGICLINK_RATE_LIMIT_IP=10 # Max requests per IP (default: 10)
ACKIFY_AUTH_RATE_LIMIT=5 # Authentication endpoints (default: 5/min)
ACKIFY_DOCUMENT_RATE_LIMIT=10 # Document creation (default: 10/min)
ACKIFY_GENERAL_RATE_LIMIT=100 # General API requests (default: 100/min)
# CSV Import
ACKIFY_IMPORT_MAX_SIGNERS=500 # Max signers per CSV import (default: 500)
```
**When to adjust**:

View File

@@ -45,17 +45,108 @@ X-CSRF-Token: abc123
}
```
### Batch Adding
### CSV Import (Recommended)
The most efficient method to add many signers is the native CSV import.
**Via Admin Dashboard:**
1. Go to `/admin` and select a document
2. In the "Expected Readers" section, click **Import CSV**
3. Select a CSV file
4. Preview entries (valid, existing, invalid)
5. Confirm the import
**Supported CSV format:**
```csv
email,name
alice@company.com,Alice Smith
bob@company.com,Bob Jones
charlie@company.com,Charlie Brown
```
**Auto-detected features:**
- **Separator**: comma (`,`) or semicolon (`;`)
- **Header**: automatic detection of `email` and `name` columns
- **Column order**: flexible (email/name or name/email)
- **Name column**: optional
**Examples of valid formats:**
```csv
# With header, comma separator
email,name
alice@company.com,Alice Smith
# Without header (email only)
bob@company.com
charlie@company.com
# French headers, semicolon separator
courriel;nom
alice@company.com;Alice Smith
# Reversed columns
name,email
Bob Jones,bob@company.com
```
**Configurable limit:**
```bash
# Email list in a file
cat emails.txt | while read email; do
curl -X POST http://localhost:8080/api/v1/admin/documents/policy_2025/signers \
-b cookies.txt \
-H "X-CSRF-Token: $CSRF_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"email\": \"$email\"}"
done
# Default: 500 signers max per import
ACKIFY_IMPORT_MAX_SIGNERS=1000
```
**Via API:**
1. **Preview** (CSV analysis):
```http
POST /api/v1/admin/documents/{docId}/signers/preview-csv
Content-Type: multipart/form-data
X-CSRF-Token: abc123
file: [CSV file]
```
Response:
```json
{
"signers": [
{"lineNumber": 2, "email": "alice@company.com", "name": "Alice Smith"},
{"lineNumber": 3, "email": "bob@company.com", "name": "Bob Jones"}
],
"errors": [
{"lineNumber": 5, "content": "invalid-email", "error": "invalid_email_format"}
],
"totalLines": 4,
"validCount": 2,
"invalidCount": 1,
"hasHeader": true,
"existingEmails": ["charlie@company.com"],
"maxSigners": 500
}
```
2. **Import** (after validation):
```http
POST /api/v1/admin/documents/{docId}/signers/import
Content-Type: application/json
X-CSRF-Token: abc123
{
"signers": [
{"email": "alice@company.com", "name": "Alice Smith"},
{"email": "bob@company.com", "name": "Bob Jones"}
]
}
```
Response:
```json
{
"message": "Import completed",
"imported": 2,
"skipped": 0,
"total": 2
}
```
## Completion Tracking
@@ -286,22 +377,11 @@ See [Email Setup](../configuration/email-setup.md) for more details.
### CSV Import
For bulk import:
```python
import csv
import requests
with open('employees.csv') as f:
reader = csv.DictReader(f)
for row in reader:
requests.post(
'http://localhost:8080/api/v1/admin/documents/policy_2025/signers',
json={'email': row['email'], 'name': row['name']},
headers={'X-CSRF-Token': csrf_token},
cookies=cookies
)
```
Use the native CSV import (see "CSV Import" section above) for bulk imports. Benefits:
- Preview before import with error detection
- Automatic duplicate detection
- Configurable limit (`ACKIFY_IMPORT_MAX_SIGNERS`)
- Multi-format support (comma/semicolon, with/without header)
### Customization

View File

@@ -113,6 +113,9 @@ ACKIFY_AUTH_MAGICLINK_RATE_LIMIT_IP=10 # Max requêtes par IP (défaut: 10)
ACKIFY_AUTH_RATE_LIMIT=5 # Endpoints d'authentification (défaut: 5/min)
ACKIFY_DOCUMENT_RATE_LIMIT=10 # Création de documents (défaut: 10/min)
ACKIFY_GENERAL_RATE_LIMIT=100 # Requêtes API générales (défaut: 100/min)
# Import CSV
ACKIFY_IMPORT_MAX_SIGNERS=500 # Max signataires par import CSV (défaut: 500)
```
**Quand ajuster** :

View File

@@ -45,17 +45,108 @@ X-CSRF-Token: abc123
}
```
### Ajout en Batch
### Import CSV (Recommandé)
La méthode la plus efficace pour ajouter de nombreux signataires est l'import CSV natif.
**Via le Dashboard Admin :**
1. Aller sur `/admin` et sélectionner un document
2. Dans la section "Lecteurs attendus", cliquer sur **Import CSV**
3. Sélectionner un fichier CSV
4. Prévisualiser les entrées (valides, existantes, invalides)
5. Confirmer l'import
**Format CSV supporté :**
```csv
email,name
alice@company.com,Alice Smith
bob@company.com,Bob Jones
charlie@company.com,Charlie Brown
```
**Fonctionnalités auto-détectées :**
- **Séparateur** : virgule (`,`) ou point-virgule (`;`)
- **En-tête** : détection automatique des colonnes `email` et `name`
- **Ordre des colonnes** : flexible (email/name ou name/email)
- **Colonne name** : optionnelle
**Exemples de formats valides :**
```csv
# Avec en-tête, séparateur virgule
email,name
alice@company.com,Alice Smith
# Sans en-tête (email seul)
bob@company.com
charlie@company.com
# En-têtes français, séparateur point-virgule
courriel;nom
alice@company.com;Alice Smith
# Colonnes inversées
name,email
Bob Jones,bob@company.com
```
**Limite configurable :**
```bash
# Liste d'emails dans un fichier
cat emails.txt | while read email; do
curl -X POST http://localhost:8080/api/v1/admin/documents/policy_2025/signers \
-b cookies.txt \
-H "X-CSRF-Token: $CSRF_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"email\": \"$email\"}"
done
# Par défaut : 500 signataires max par import
ACKIFY_IMPORT_MAX_SIGNERS=1000
```
**Via l'API :**
1. **Preview** (analyse du CSV) :
```http
POST /api/v1/admin/documents/{docId}/signers/preview-csv
Content-Type: multipart/form-data
X-CSRF-Token: abc123
file: [fichier CSV]
```
Response :
```json
{
"signers": [
{"lineNumber": 2, "email": "alice@company.com", "name": "Alice Smith"},
{"lineNumber": 3, "email": "bob@company.com", "name": "Bob Jones"}
],
"errors": [
{"lineNumber": 5, "content": "invalid-email", "error": "invalid_email_format"}
],
"totalLines": 4,
"validCount": 2,
"invalidCount": 1,
"hasHeader": true,
"existingEmails": ["charlie@company.com"],
"maxSigners": 500
}
```
2. **Import** (après validation) :
```http
POST /api/v1/admin/documents/{docId}/signers/import
Content-Type: application/json
X-CSRF-Token: abc123
{
"signers": [
{"email": "alice@company.com", "name": "Alice Smith"},
{"email": "bob@company.com", "name": "Bob Jones"}
]
}
```
Response :
```json
{
"message": "Import completed",
"imported": 2,
"skipped": 0,
"total": 2
}
```
## Tracking de Complétion
@@ -286,22 +377,11 @@ Voir [Email Setup](../configuration/email-setup.md) pour plus de détails.
### Import CSV
Pour importer massivement :
```python
import csv
import requests
with open('employees.csv') as f:
reader = csv.DictReader(f)
for row in reader:
requests.post(
'http://localhost:8080/api/v1/admin/documents/policy_2025/signers',
json={'email': row['email'], 'name': row['name']},
headers={'X-CSRF-Token': csrf_token},
cookies=cookies
)
```
Utilisez l'import CSV natif (voir section "Import CSV" ci-dessus) pour les imports en masse. Avantages :
- Preview avant import avec détection d'erreurs
- Détection automatique des doublons
- Limite configurable (`ACKIFY_IMPORT_MAX_SIGNERS`)
- Support multi-formats (virgule/point-virgule, avec/sans en-tête)
### Personnalisation

View File

@@ -408,7 +408,34 @@
"deleteItem4": "Der Erinnerungsverlauf",
"deleteConfirmTitle": "⚠️ Löschen bestätigen",
"deleteConfirmButton": "Endgültig löschen",
"documentId": "Dokument-ID:"
"documentId": "Dokument-ID:",
"importCSV": "CSV importieren",
"importCSVTitle": "Leser aus einer CSV-Datei importieren",
"selectFile": "Eine CSV-Datei auswählen",
"csvFormatHelp": "Akzeptiertes Format: 'email' und 'name' Spalten (beliebige Reihenfolge), Komma- oder Semikolon-Trennzeichen",
"analyze": "Analysieren",
"analyzing": "Wird analysiert...",
"validEntries": "Gültig",
"existingEntries": "Bereits vorhanden",
"invalidEntries": "Ungültig",
"lineNumber": "Zeile",
"email": "E-Mail",
"name": "Name",
"statusValid": "Gültig",
"statusExists": "Vorhanden",
"parseErrors": "Analysefehler",
"content": "Inhalt",
"errorReason": "Fehler",
"backToFileSelection": "Zurück",
"importing": "Importieren...",
"importButton": "{count} Leser importieren",
"csvImportSuccess": "{imported} Leser importiert, {skipped} übersprungen",
"filterPlaceholder": "Nach Name oder E-Mail filtern...",
"csvError": {
"email_required": "E-Mail erforderlich",
"invalid_email_format": "Ungültiges E-Mail-Format",
"max_signers_exceeded": "Limit überschritten"
}
},
"documentForm": {
"title": "Dokumentreferenz",

View File

@@ -408,7 +408,34 @@
"deleteItem4": "The reminder history",
"deleteConfirmTitle": "⚠️ Confirm deletion",
"deleteConfirmButton": "Delete permanently",
"documentId": "Document ID:"
"documentId": "Document ID:",
"importCSV": "Import CSV",
"importCSVTitle": "Import readers from a CSV file",
"selectFile": "Select a CSV file",
"csvFormatHelp": "Accepted format: 'email' and 'name' columns (any order), comma or semicolon separator",
"analyze": "Analyze",
"analyzing": "Analyzing...",
"validEntries": "Valid",
"existingEntries": "Already exist",
"invalidEntries": "Invalid",
"lineNumber": "Line",
"email": "Email",
"name": "Name",
"statusValid": "Valid",
"statusExists": "Existing",
"parseErrors": "Parse errors",
"content": "Content",
"errorReason": "Error",
"backToFileSelection": "Back",
"importing": "Importing...",
"importButton": "Import {count} reader(s)",
"csvImportSuccess": "{imported} reader(s) imported, {skipped} skipped",
"filterPlaceholder": "Filter by name or email...",
"csvError": {
"email_required": "Email required",
"invalid_email_format": "Invalid email format",
"max_signers_exceeded": "Limit exceeded"
}
},
"documentForm": {
"title": "Document reference",

View File

@@ -408,7 +408,34 @@
"deleteItem4": "El historial de recordatorios",
"deleteConfirmTitle": "⚠️ Confirmar la eliminación",
"deleteConfirmButton": "Eliminar definitivamente",
"documentId": "ID del documento:"
"documentId": "ID del documento:",
"importCSV": "Importar CSV",
"importCSVTitle": "Importar lectores desde un archivo CSV",
"selectFile": "Seleccionar un archivo CSV",
"csvFormatHelp": "Formato aceptado: columnas 'email' y 'name' (orden libre), separador coma o punto y coma",
"analyze": "Analizar",
"analyzing": "Analizando...",
"validEntries": "Válidos",
"existingEntries": "Ya existen",
"invalidEntries": "Inválidos",
"lineNumber": "Línea",
"email": "Email",
"name": "Nombre",
"statusValid": "Válido",
"statusExists": "Existente",
"parseErrors": "Errores de análisis",
"content": "Contenido",
"errorReason": "Error",
"backToFileSelection": "Volver",
"importing": "Importando...",
"importButton": "Importar {count} lector(es)",
"csvImportSuccess": "{imported} lector(es) importado(s), {skipped} ignorado(s)",
"filterPlaceholder": "Filtrar por nombre o email...",
"csvError": {
"email_required": "Email requerido",
"invalid_email_format": "Formato de email inválido",
"max_signers_exceeded": "Límite excedido"
}
},
"documentForm": {
"title": "Referencia del documento",

View File

@@ -405,7 +405,34 @@
"deleteItem4": "L'historique des relances",
"deleteConfirmTitle": "⚠️ Confirmer la suppression",
"deleteConfirmButton": "Supprimer définitivement",
"documentId": "Document ID:"
"documentId": "Document ID:",
"importCSV": "Import CSV",
"importCSVTitle": "Importer des lecteurs depuis un fichier CSV",
"selectFile": "Sélectionner un fichier CSV",
"csvFormatHelp": "Format accepté : colonnes 'email' et 'name' (ordre libre), séparateur virgule ou point-virgule",
"analyze": "Analyser",
"analyzing": "Analyse...",
"validEntries": "Valides",
"existingEntries": "Déjà présents",
"invalidEntries": "Invalides",
"lineNumber": "Ligne",
"email": "Email",
"name": "Nom",
"statusValid": "Valide",
"statusExists": "Existant",
"parseErrors": "Erreurs de parsing",
"content": "Contenu",
"errorReason": "Erreur",
"backToFileSelection": "Retour",
"importing": "Import...",
"importButton": "Importer {count} lecteur(s)",
"csvImportSuccess": "{imported} lecteur(s) importé(s), {skipped} ignoré(s)",
"filterPlaceholder": "Filtrer par nom ou email...",
"csvError": {
"email_required": "Email requis",
"invalid_email_format": "Format email invalide",
"max_signers_exceeded": "Limite dépassée"
}
},
"documentForm": {
"title": "Référence du document",

View File

@@ -408,7 +408,34 @@
"deleteItem4": "La cronologia dei promemoria",
"deleteConfirmTitle": "⚠️ Conferma eliminazione",
"deleteConfirmButton": "Elimina definitivamente",
"documentId": "ID Documento:"
"documentId": "ID Documento:",
"importCSV": "Importa CSV",
"importCSVTitle": "Importa lettori da un file CSV",
"selectFile": "Seleziona un file CSV",
"csvFormatHelp": "Formato accettato: colonne 'email' e 'name' (ordine libero), separatore virgola o punto e virgola",
"analyze": "Analizza",
"analyzing": "Analisi in corso...",
"validEntries": "Validi",
"existingEntries": "Già presenti",
"invalidEntries": "Non validi",
"lineNumber": "Riga",
"email": "Email",
"name": "Nome",
"statusValid": "Valido",
"statusExists": "Esistente",
"parseErrors": "Errori di analisi",
"content": "Contenuto",
"errorReason": "Errore",
"backToFileSelection": "Indietro",
"importing": "Importazione...",
"importButton": "Importa {count} lettore/i",
"csvImportSuccess": "{imported} lettore/i importato/i, {skipped} saltato/i",
"filterPlaceholder": "Filtra per nome o email...",
"csvError": {
"email_required": "Email richiesta",
"invalid_email_format": "Formato email non valido",
"max_signers_exceeded": "Limite superato"
}
},
"documentForm": {
"title": "Riferimento del documento",

View File

@@ -11,7 +11,11 @@ import {
removeExpectedSigner,
sendReminders,
deleteDocument,
previewCSVSigners,
importSigners,
type DocumentStatus,
type CSVPreviewResult,
type CSVSignerEntry,
} from '@/services/admin'
import { extractError } from '@/services/http'
import {
@@ -26,6 +30,11 @@ import {
Clock,
X,
Trash2,
Upload,
AlertTriangle,
FileCheck,
FileX,
Search,
} from 'lucide-vue-next'
import Card from '@/components/ui/Card.vue'
import CardHeader from '@/components/ui/CardHeader.vue'
@@ -64,9 +73,17 @@ const showDeleteConfirmModal = ref(false)
const showMetadataWarningModal = ref(false)
const showRemoveSignerModal = ref(false)
const showSendRemindersModal = ref(false)
const showImportCSVModal = ref(false)
const signerToRemove = ref('')
const remindersMessage = ref('')
// CSV Import
const csvFile = ref<File | null>(null)
const csvPreview = ref<CSVPreviewResult | null>(null)
const analyzingCSV = ref(false)
const importingCSV = ref(false)
const csvError = ref('')
// Metadata form
const metadataForm = ref<Partial<{
title: string
@@ -93,6 +110,7 @@ const savingMetadata = ref(false)
// Expected signers form
const signersEmails = ref('')
const addingSigners = ref(false)
const signerFilter = ref('')
// Reminders
const sendMode = ref<'all' | 'selected'>('all')
@@ -112,6 +130,15 @@ const stats = computed(() => documentStatus.value?.stats)
const reminderStats = computed(() => documentStatus.value?.reminderStats)
const smtpEnabled = computed(() => (window as any).ACKIFY_SMTP_ENABLED || false)
const expectedSigners = computed(() => documentStatus.value?.expectedSigners || [])
const filteredSigners = computed(() => {
const filter = signerFilter.value.toLowerCase().trim()
if (!filter) return expectedSigners.value
return expectedSigners.value.filter(signer =>
signer.email.toLowerCase().includes(filter) ||
(signer.name && signer.name.toLowerCase().includes(filter)) ||
(signer.userName && signer.userName.toLowerCase().includes(filter))
)
})
const unexpectedSignatures = computed(() => documentStatus.value?.unexpectedSignatures || [])
const documentMetadata = computed(() => documentStatus.value?.document)
@@ -355,6 +382,90 @@ async function handleDeleteDocument() {
}
}
// CSV Import functions
function openImportCSVModal() {
csvFile.value = null
csvPreview.value = null
csvError.value = ''
showImportCSVModal.value = true
}
function handleCSVFileChange(event: Event) {
const target = event.target as HTMLInputElement
if (target.files && target.files[0]) {
csvFile.value = target.files[0]
csvPreview.value = null
csvError.value = ''
}
}
async function analyzeCSV() {
if (!csvFile.value) return
try {
analyzingCSV.value = true
csvError.value = ''
const response = await previewCSVSigners(docId.value, csvFile.value)
csvPreview.value = response.data
} catch (err) {
csvError.value = extractError(err)
console.error('Failed to analyze CSV:', err)
} finally {
analyzingCSV.value = false
}
}
function getSignerStatus(signer: CSVSignerEntry): 'valid' | 'exists' {
if (!csvPreview.value) return 'valid'
return csvPreview.value.existingEmails.includes(signer.email) ? 'exists' : 'valid'
}
const signersToImport = computed(() => {
if (!csvPreview.value) return []
return csvPreview.value.signers.filter(
s => !csvPreview.value!.existingEmails.includes(s.email)
)
})
async function confirmImportCSV() {
if (!csvPreview.value || signersToImport.value.length === 0) return
try {
importingCSV.value = true
csvError.value = ''
const signersData = signersToImport.value.map(s => ({
email: s.email,
name: s.name
}))
const response = await importSigners(docId.value, signersData)
showImportCSVModal.value = false
csvFile.value = null
csvPreview.value = null
success.value = t('admin.documentDetail.csvImportSuccess', {
imported: response.data.imported,
skipped: response.data.skipped
})
await loadDocumentStatus()
setTimeout(() => (success.value = ''), 3000)
} catch (err) {
csvError.value = extractError(err)
console.error('Failed to import signers:', err)
} finally {
importingCSV.value = false
}
}
function closeImportCSVModal() {
showImportCSVModal.value = false
csvFile.value = null
csvPreview.value = null
csvError.value = ''
}
onMounted(() => {
loadDocumentStatus()
})
@@ -524,15 +635,34 @@ onMounted(() => {
<CardTitle>{{ t('admin.documentDetail.readers') }}</CardTitle>
<CardDescription v-if="stats">{{ stats.signedCount }} / {{ stats.expectedCount }} {{ t('admin.dashboard.stats.signed').toLowerCase() }}</CardDescription>
</div>
<Button @click="showAddSignersModal = true" size="sm">
<Plus :size="16" class="mr-2" />
{{ t('admin.documentDetail.addButton') }}
</Button>
<div class="flex gap-2">
<Button @click="openImportCSVModal" size="sm" variant="outline">
<Upload :size="16" class="mr-2" />
{{ t('admin.documentDetail.importCSV') }}
</Button>
<Button @click="showAddSignersModal = true" size="sm">
<Plus :size="16" class="mr-2" />
{{ t('admin.documentDetail.addButton') }}
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<!-- Expected Signers Table -->
<!-- Filter + Expected Signers Table -->
<div v-if="expectedSigners.length > 0">
<!-- Filter -->
<div class="relative mb-4">
<Search :size="16" class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground z-10 pointer-events-none" />
<Input
v-model="signerFilter"
:placeholder="t('admin.documentDetail.filterPlaceholder')"
class="pl-9"
name="ackify-signer-filter"
autocomplete="off"
data-1p-ignore
data-lpignore="true"
/>
</div>
<Table>
<TableHeader>
<TableRow>
@@ -547,7 +677,7 @@ onMounted(() => {
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="signer in expectedSigners" :key="signer.email">
<TableRow v-for="signer in filteredSigners" :key="signer.email">
<TableCell>
<input v-if="!signer.hasSigned" type="checkbox" class="rounded"
:checked="selectedEmails.includes(signer.email)"
@@ -731,6 +861,154 @@ onMounted(() => {
</Card>
</div>
<!-- Import CSV Modal -->
<div v-if="showImportCSVModal" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4" @click.self="closeImportCSVModal">
<Card class="max-w-3xl w-full max-h-[90vh] overflow-hidden flex flex-col">
<CardHeader>
<div class="flex items-center justify-between">
<CardTitle>{{ t('admin.documentDetail.importCSVTitle') }}</CardTitle>
<Button variant="ghost" size="icon" @click="closeImportCSVModal">
<X :size="20" />
</Button>
</div>
</CardHeader>
<CardContent class="flex-1 overflow-auto">
<!-- Error Alert -->
<Alert v-if="csvError" variant="destructive" class="mb-4">
<AlertDescription>{{ csvError }}</AlertDescription>
</Alert>
<!-- Step 1: File Selection -->
<div v-if="!csvPreview" class="space-y-4">
<div>
<label class="block text-sm font-medium mb-2">{{ t('admin.documentDetail.selectFile') }}</label>
<input
type="file"
accept=".csv"
@change="handleCSVFileChange"
class="block w-full text-sm text-muted-foreground
file:mr-4 file:py-2 file:px-4
file:rounded-md file:border-0
file:text-sm file:font-medium
file:bg-primary file:text-primary-foreground
hover:file:bg-primary/90
cursor-pointer"
/>
<p class="text-xs text-muted-foreground mt-2">
{{ t('admin.documentDetail.csvFormatHelp') }}
</p>
</div>
<div class="flex justify-end space-x-3">
<Button type="button" variant="outline" @click="closeImportCSVModal">
{{ t('common.cancel') }}
</Button>
<Button @click="analyzeCSV" :disabled="!csvFile || analyzingCSV">
<Loader2 v-if="analyzingCSV" :size="16" class="mr-2 animate-spin" />
{{ analyzingCSV ? t('admin.documentDetail.analyzing') : t('admin.documentDetail.analyze') }}
</Button>
</div>
</div>
<!-- Step 2: Preview -->
<div v-else class="space-y-4">
<!-- Summary -->
<div class="grid gap-3 sm:grid-cols-3">
<div class="bg-green-50 dark:bg-green-900/20 rounded-lg p-3 flex items-center gap-3">
<FileCheck :size="24" class="text-green-600" />
<div>
<p class="text-sm text-muted-foreground">{{ t('admin.documentDetail.validEntries') }}</p>
<p class="text-xl font-bold text-green-600">{{ signersToImport.length }}</p>
</div>
</div>
<div v-if="csvPreview.existingEmails.length > 0" class="bg-orange-50 dark:bg-orange-900/20 rounded-lg p-3 flex items-center gap-3">
<AlertTriangle :size="24" class="text-orange-600" />
<div>
<p class="text-sm text-muted-foreground">{{ t('admin.documentDetail.existingEntries') }}</p>
<p class="text-xl font-bold text-orange-600">{{ csvPreview.existingEmails.length }}</p>
</div>
</div>
<div v-if="csvPreview.invalidCount > 0" class="bg-red-50 dark:bg-red-900/20 rounded-lg p-3 flex items-center gap-3">
<FileX :size="24" class="text-red-600" />
<div>
<p class="text-sm text-muted-foreground">{{ t('admin.documentDetail.invalidEntries') }}</p>
<p class="text-xl font-bold text-red-600">{{ csvPreview.invalidCount }}</p>
</div>
</div>
</div>
<!-- Preview Table -->
<div class="border rounded-lg overflow-hidden">
<div class="max-h-64 overflow-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead class="w-16">{{ t('admin.documentDetail.lineNumber') }}</TableHead>
<TableHead>{{ t('admin.documentDetail.email') }}</TableHead>
<TableHead>{{ t('admin.documentDetail.name') }}</TableHead>
<TableHead class="w-32">{{ t('admin.documentDetail.status') }}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="signer in csvPreview.signers" :key="signer.lineNumber" :class="getSignerStatus(signer) === 'exists' ? 'bg-orange-50/50 dark:bg-orange-900/10' : ''">
<TableCell class="text-muted-foreground">{{ signer.lineNumber }}</TableCell>
<TableCell>{{ signer.email }}</TableCell>
<TableCell>{{ signer.name || '-' }}</TableCell>
<TableCell>
<Badge :variant="getSignerStatus(signer) === 'exists' ? 'secondary' : 'default'">
{{ getSignerStatus(signer) === 'exists' ? t('admin.documentDetail.statusExists') : t('admin.documentDetail.statusValid') }}
</Badge>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div>
<!-- Errors Table -->
<div v-if="csvPreview.errors.length > 0" class="border border-destructive rounded-lg overflow-hidden">
<div class="bg-destructive/10 px-4 py-2 font-medium text-destructive">
{{ t('admin.documentDetail.parseErrors') }}
</div>
<div class="max-h-32 overflow-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead class="w-16">{{ t('admin.documentDetail.lineNumber') }}</TableHead>
<TableHead>{{ t('admin.documentDetail.content') }}</TableHead>
<TableHead>{{ t('admin.documentDetail.errorReason') }}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="err in csvPreview.errors" :key="err.lineNumber" class="bg-red-50/50 dark:bg-red-900/10">
<TableCell class="text-muted-foreground">{{ err.lineNumber }}</TableCell>
<TableCell class="font-mono text-xs truncate max-w-48">{{ err.content }}</TableCell>
<TableCell class="text-destructive text-sm">{{ t('admin.documentDetail.csvError.' + err.error, err.error) }}</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div>
<!-- Actions -->
<div class="flex justify-between items-center pt-4">
<Button type="button" variant="ghost" @click="csvPreview = null; csvFile = null">
{{ t('admin.documentDetail.backToFileSelection') }}
</Button>
<div class="flex gap-3">
<Button type="button" variant="outline" @click="closeImportCSVModal">
{{ t('common.cancel') }}
</Button>
<Button @click="confirmImportCSV" :disabled="importingCSV || signersToImport.length === 0">
<Loader2 v-if="importingCSV" :size="16" class="mr-2 animate-spin" />
{{ importingCSV ? t('admin.documentDetail.importing') : t('admin.documentDetail.importButton', { count: signersToImport.length }) }}
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
<!-- Delete Confirmation Modal -->
<div v-if="showDeleteConfirmModal" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4" @click.self="showDeleteConfirmModal = false">
<Card class="max-w-md w-full border-destructive">

View File

@@ -140,6 +140,65 @@ export async function removeExpectedSigner(
return response.data
}
// ============================================================================
// CSV IMPORT
// ============================================================================
export interface CSVSignerEntry {
lineNumber: number
email: string
name: string
}
export interface CSVParseError {
lineNumber: number
content: string
error: string
}
export interface CSVPreviewResult {
signers: CSVSignerEntry[]
errors: CSVParseError[]
totalLines: number
validCount: number
invalidCount: number
hasHeader: boolean
existingEmails: string[]
maxSigners: number
}
export interface ImportSignersResult {
message: string
imported: number
skipped: number
total: number
}
// Preview CSV file before import
export async function previewCSVSigners(
docId: string,
file: File
): Promise<ApiResponse<CSVPreviewResult>> {
const formData = new FormData()
formData.append('file', file)
const response = await http.post(`/admin/documents/${docId}/signers/preview-csv`, formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
return response.data
}
// Import signers after preview confirmation
export async function importSigners(
docId: string,
signers: { email: string; name: string }[]
): Promise<ApiResponse<ImportSignersResult>> {
const response = await http.post(`/admin/documents/${docId}/signers/import`, { signers })
return response.data
}
// ============================================================================
// REMINDERS
// ============================================================================