mirror of
https://github.com/btouchard/ackify-ce.git
synced 2026-01-19 12:29:59 -06:00
feat(csv): import expected signature from CSV
This commit is contained in:
251
backend/internal/application/services/csv_parser.go
Normal file
251
backend/internal/application/services/csv_parser.go
Normal 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
|
||||
}
|
||||
570
backend/internal/application/services/csv_parser_test.go
Normal file
570
backend/internal/application/services/csv_parser_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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**:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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** :
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
// ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user