From 5e74921ee7a448eb41ee391392d8641dffe4267c Mon Sep 17 00:00:00 2001 From: Benjamin Date: Mon, 6 Oct 2025 16:58:21 +0200 Subject: [PATCH] feat: admin dashboard document request signatures - New, clearer dashboard showing the status of each document - The administrator can create a list of expected signatures for a given document. - The administrator can manage the list of users who must confirm that they have read the document --- .dockerignore | 4 +- .gitignore | 4 +- Makefile | 4 +- README.md | 29 +- README_FR.md | 31 +- docs/features/smtp-service.md | 312 ++++++++++++++++ .../google-doc}/GOOGLE_INTEGRATION.md | 0 .../google-doc/exemple}/appscript/Code.gs | 0 .../exemple}/appscript/appsscript.json | 0 install/install.sh | 4 +- internal/domain/models/expected_signer.go | 30 ++ .../database/expected_signer_repository.go | 219 ++++++++++++ .../expected_signer_repository_test.go | 329 +++++++++++++++++ .../admin/handlers_expected_signers.go | 306 ++++++++++++++++ .../admin/handlers_expected_signers_test.go | 150 ++++++++ internal/presentation/admin/routes_admin.go | 9 +- migrations/0002_expected_signers.down.sql | 2 + migrations/0002_expected_signers.up.sql | 18 + pkg/web/server.go | 2 +- scripts/docker_smoke.sh | 2 +- templates/admin_dashboard.html.tpl | 50 +++ .../admin_document_expected_signers.html.tpl | 337 ++++++++++++++++++ templates/base.html.tpl | 4 +- 23 files changed, 1828 insertions(+), 18 deletions(-) create mode 100644 docs/features/smtp-service.md rename {examples => docs/integrations/google-doc}/GOOGLE_INTEGRATION.md (100%) rename {examples/google => docs/integrations/google-doc/exemple}/appscript/Code.gs (100%) rename {examples/google => docs/integrations/google-doc/exemple}/appscript/appsscript.json (100%) create mode 100644 internal/domain/models/expected_signer.go create mode 100644 internal/infrastructure/database/expected_signer_repository.go create mode 100644 internal/infrastructure/database/expected_signer_repository_test.go create mode 100644 internal/presentation/admin/handlers_expected_signers.go create mode 100644 internal/presentation/admin/handlers_expected_signers_test.go create mode 100644 migrations/0002_expected_signers.down.sql create mode 100644 migrations/0002_expected_signers.up.sql create mode 100644 templates/admin_document_expected_signers.html.tpl diff --git a/.dockerignore b/.dockerignore index c06c889..390da8a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -17,8 +17,8 @@ LICENSE .env.example community migrate -docker-compose.cloud.yml -docker-compose.local.yml +compose.cloud.yml +compose.local.yml # IDE .vscode/ diff --git a/.gitignore b/.gitignore index f2077b6..ed64399 100644 --- a/.gitignore +++ b/.gitignore @@ -11,8 +11,8 @@ AGENTS.md .gocache/ -docker-compose.local.yml -docker-compose.cloud.yml +compose.local.yml +compose.cloud.yml client_secret*.json /static diff --git a/Makefile b/Makefile index ffbfe79..4ee84a7 100644 --- a/Makefile +++ b/Makefile @@ -111,10 +111,10 @@ docker-build: ## Build Docker image docker build -t ackify-ce:latest . docker-test: ## Run tests in Docker environment - docker compose -f docker-compose.local.yml up -d ackify-db + docker compose -f compose.local.yml up -d ackify-db @sleep 5 $(MAKE) test - docker compose -f docker-compose.local.yml down + docker compose -f compose.local.yml down # CI targets ci: deps lint test coverage ## Run all CI checks diff --git a/README.md b/README.md index c42ec41..845095c 100644 --- a/README.md +++ b/README.md @@ -233,6 +233,7 @@ pkg/ # Shared utilities ## 📊 Database ```sql +-- Main signatures table CREATE TABLE signatures ( id BIGSERIAL PRIMARY KEY, doc_id TEXT NOT NULL, -- Document ID @@ -247,6 +248,17 @@ CREATE TABLE signatures ( prev_hash TEXT, UNIQUE (doc_id, user_sub) -- One signature per user/doc ); + +-- Expected signers table (for tracking) +CREATE TABLE expected_signers ( + id BIGSERIAL PRIMARY KEY, + doc_id TEXT NOT NULL, + email TEXT NOT NULL, + added_at TIMESTAMPTZ NOT NULL DEFAULT now(), + added_by TEXT NOT NULL, -- Admin who added + notes TEXT, + UNIQUE (doc_id, email) -- One expectation per email/doc +); ``` **Guarantees**: @@ -254,6 +266,7 @@ CREATE TABLE signatures ( - ✅ **Immutability**: `created_at` protected by trigger - ✅ **Integrity**: SHA-256 hash to detect modifications - ✅ **Non-repudiation**: Ed25519 signature cryptographically provable +- ✅ **Tracking**: Expected signers for completion monitoring --- @@ -332,14 +345,26 @@ ACKIFY_MAIL_PASSWORD="${SMTP_PASSWORD}" ### Admin - `GET /admin` - Dashboard (restricted) -- `GET /admin/docs/{docID}` - Signatures for a document -- `GET /admin/api/chain-integrity/{docID}` - Chain integrity JSON +- `GET /admin/docs/{docID}` - Document details with expected signers management +- `POST /admin/docs/{docID}/expected` - Add expected signers +- `POST /admin/docs/{docID}/expected/remove` - Remove an expected signer +- `GET /admin/docs/{docID}/status.json` - Document status as JSON (AJAX) +- `GET /admin/api/chain-integrity/{docID}` - Chain integrity verification JSON Access control: set `ACKIFY_ADMIN_EMAILS` with a comma-separated list of admin emails (exact match, case-insensitive). Example: ```bash ACKIFY_ADMIN_EMAILS="alice@company.com,bob@company.com" ``` +#### Expected Signers Feature +Administrators can define and track expected signers for each document: +- **Add expected signers**: Paste emails separated by newlines, commas, or semicolons +- **Track completion**: Visual progress bar with completion percentage +- **Monitor status**: See who signed (✓) vs. who is pending (⏳) +- **Detect unexpected signatures**: Identify users who signed but weren't expected +- **Share easily**: One-click copy of document signature link +- **Bulk management**: Add/remove signers individually or in batch + --- ## 🔍 Development & Testing diff --git a/README_FR.md b/README_FR.md index 0a9350e..a4aed54 100644 --- a/README_FR.md +++ b/README_FR.md @@ -241,6 +241,7 @@ pkg/ # Utilitaires partagĂ©s ## 📊 Base de DonnĂ©es ```sql +-- Table principale des signatures CREATE TABLE signatures ( id BIGSERIAL PRIMARY KEY, doc_id TEXT NOT NULL, -- ID document @@ -255,6 +256,17 @@ CREATE TABLE signatures ( prev_hash TEXT, -- Prev Hash UNIQUE (doc_id, user_sub) -- Une signature par user/doc ); + +-- Table des signataires attendus (pour le suivi) +CREATE TABLE expected_signers ( + id BIGSERIAL PRIMARY KEY, + doc_id TEXT NOT NULL, + email TEXT NOT NULL, + added_at TIMESTAMPTZ NOT NULL DEFAULT now(), + added_by TEXT NOT NULL, -- Admin qui a ajoutĂ© + notes TEXT, + UNIQUE (doc_id, email) -- Une attente par email/doc +); ``` **Garanties** : @@ -262,6 +274,7 @@ CREATE TABLE signatures ( - ✅ **ImmutabilitĂ©** : `created_at` protĂ©gĂ© par trigger - ✅ **IntĂ©gritĂ©** : Hachage SHA-256 pour dĂ©tecter modifications - ✅ **Non-rĂ©pudiation** : Signature Ed25519 cryptographiquement prouvable +- ✅ **Suivi** : Signataires attendus pour monitoring de complĂ©tion --- @@ -340,14 +353,26 @@ ACKIFY_MAIL_PASSWORD="${SMTP_PASSWORD}" ### Administration - `GET /admin` - Tableau de bord (restreint) -- `GET /admin/docs/{docID}` - Signataires d’un document -- `GET /admin/api/chain-integrity/{docID}` - IntĂ©gritĂ© de chaĂźne (JSON) +- `GET /admin/docs/{docID}` - DĂ©tails du document avec gestion des signataires attendus +- `POST /admin/docs/{docID}/expected` - Ajouter des signataires attendus +- `POST /admin/docs/{docID}/expected/remove` - Retirer un signataire attendu +- `GET /admin/docs/{docID}/status.json` - Statut du document en JSON (AJAX) +- `GET /admin/api/chain-integrity/{docID}` - VĂ©rification d'intĂ©gritĂ© de chaĂźne (JSON) -ContrĂŽle d’accĂšs: dĂ©finir `ACKIFY_ADMIN_EMAILS` avec des emails admins, sĂ©parĂ©s par des virgules (correspondance exacte, insensible Ă  la casse). Exemple: +ContrĂŽle d'accĂšs: dĂ©finir `ACKIFY_ADMIN_EMAILS` avec des emails admins, sĂ©parĂ©s par des virgules (correspondance exacte, insensible Ă  la casse). Exemple: ```bash ACKIFY_ADMIN_EMAILS="alice@entreprise.com,bob@entreprise.com" ``` +#### FonctionnalitĂ© Signataires Attendus +Les administrateurs peuvent dĂ©finir et suivre les signataires attendus pour chaque document : +- **Ajouter des signataires** : Coller des emails sĂ©parĂ©s par des sauts de ligne, virgules ou point-virgules +- **Suivre la complĂ©tion** : Barre de progression visuelle avec pourcentage +- **Monitorer le statut** : Voir qui a signĂ© (✓) vs. qui est en attente (⏳) +- **DĂ©tecter les signatures inattendues** : Identifier les utilisateurs qui ont signĂ© sans ĂȘtre attendus +- **Partage facile** : Copie en un clic du lien de signature du document +- **Gestion en masse** : Ajouter/retirer des signataires individuellement ou en lot + --- ## 🔍 DĂ©veloppement & Tests diff --git a/docs/features/smtp-service.md b/docs/features/smtp-service.md new file mode 100644 index 0000000..f2562b9 --- /dev/null +++ b/docs/features/smtp-service.md @@ -0,0 +1,312 @@ +# Guide d'utilisation – Service SMTP + +## 📧 Vue d'ensemble + +Le service SMTP d'Ackify permet d'envoyer des emails de rappel de signature aux utilisateurs. Il supporte : +- Templates multilingues (HTML + texte) +- Configuration complĂšte via variables d'environnement +- DĂ©sactivation automatique si non configurĂ© (pas d'erreur) +- Support TLS/STARTTLS +- Templates personnalisables + +## ⚙ Configuration + +### Variables d'environnement + +| Variable | Type | DĂ©faut | Description | +|----------|------|--------|-------------| +| `ACKIFY_MAIL_HOST` | string | - | **Obligatoire** : HĂŽte SMTP (ex: smtp.gmail.com) | +| `ACKIFY_MAIL_PORT` | int | `587` | Port SMTP | +| `ACKIFY_MAIL_USERNAME` | string | - | Identifiant SMTP (optionnel si auth non requise) | +| `ACKIFY_MAIL_PASSWORD` | string | - | Mot de passe SMTP | +| `ACKIFY_MAIL_TLS` | bool | `true` | Activer TLS implicite (port 465) | +| `ACKIFY_MAIL_STARTTLS` | bool | `true` | Activer STARTTLS (port 587) | +| `ACKIFY_MAIL_TIMEOUT` | duration | `10s` | Timeout de connexion | +| `ACKIFY_MAIL_FROM` | string | - | **Obligatoire** : Adresse expĂ©diteur | +| `ACKIFY_MAIL_FROM_NAME` | string | `ACKIFY_ORGANISATION` | Nom expĂ©diteur | +| `ACKIFY_MAIL_SUBJECT_PREFIX` | string | `""` | PrĂ©fixe ajoutĂ© aux sujets | +| `ACKIFY_MAIL_TEMPLATE_DIR` | path | `templates/emails` | RĂ©pertoire des templates | +| `ACKIFY_MAIL_DEFAULT_LOCALE` | string | `en` | Locale par dĂ©faut (en/fr) | + +### Exemple de configuration + +**.env (dĂ©veloppement avec MailHog)** : +```bash +ACKIFY_MAIL_HOST=localhost +ACKIFY_MAIL_PORT=1025 +ACKIFY_MAIL_FROM=noreply@ackify.local +ACKIFY_MAIL_FROM_NAME=Ackify CE +``` + +**.env (production Gmail)** : +```bash +ACKIFY_MAIL_HOST=smtp.gmail.com +ACKIFY_MAIL_PORT=587 +ACKIFY_MAIL_USERNAME=your-email@gmail.com +ACKIFY_MAIL_PASSWORD=your-app-password +ACKIFY_MAIL_TLS=false +ACKIFY_MAIL_STARTTLS=true +ACKIFY_MAIL_FROM=noreply@yourdomain.com +ACKIFY_MAIL_FROM_NAME="Ackify - Proof of Read" +ACKIFY_MAIL_SUBJECT_PREFIX="[Ackify] " +``` + +### DĂ©sactivation + +Si `ACKIFY_MAIL_HOST` n'est pas dĂ©fini, le service est **automatiquement dĂ©sactivĂ©** sans erreur. Les appels d'envoi d'email retournent `nil` avec un log informatif. + +## 📝 Utilisation dans le code + +### Initialisation + +```go +import ( + "github.com/btouchard/ackify-ce/internal/infrastructure/config" + "github.com/btouchard/ackify-ce/internal/infrastructure/email" +) + +// Charger config +cfg, err := config.Load() +if err != nil { + log.Fatal(err) +} + +// CrĂ©er renderer et sender +renderer := email.NewRenderer( + cfg.Mail.TemplateDir, + cfg.App.BaseURL, + cfg.App.Organisation, + cfg.Mail.FromName, + cfg.Mail.From, + cfg.Mail.DefaultLocale, +) + +sender := email.NewSMTPSender(cfg.Mail, renderer) +``` + +### Envoyer un rappel de signature + +```go +import ( + "context" + "github.com/btouchard/ackify-ce/internal/infrastructure/email" +) + +ctx := context.Background() + +err := email.SendSignatureReminderEmail( + ctx, + sender, + []string{"user@example.com"}, + "fr", // ou "en" + "doc_123abc", + "https://example.com/documents/doc_123abc", + "https://example.com/sign?doc=doc_123abc", +) + +if err != nil { + log.Printf("Failed to send reminder: %v", err) +} +``` + +### Envoyer un email personnalisĂ© + +```go +data := map[string]any{ + "UserName": "John Doe", + "CustomField": "custom value", +} + +err := email.SendEmail( + ctx, + sender, + "custom_template", // nom du template (sans extension) + []string{"user@example.com"}, + "en", + "Your Custom Subject", + data, +) +``` + +## 🎹 CrĂ©er des templates personnalisĂ©s + +### Structure des templates + +Les templates utilisent le systĂšme de `html/template` et `text/template` de Go. + +**RĂ©pertoire** : `/templates/emails/` + +**Fichiers requis** : +- `base.html.tmpl` - Template de base HTML +- `base.txt.tmpl` - Template de base texte +- `.en.html.tmpl` - Version anglaise HTML +- `.en.txt.tmpl` - Version anglaise texte +- `.fr.html.tmpl` - Version française HTML +- `.fr.txt.tmpl` - Version française texte + +### Variables automatiques + +Chaque template reçoit automatiquement : +- `.Organisation` - Nom de l'organisation (depuis config) +- `.BaseURL` - URL de base de l'application +- `.FromName` - Nom de l'expĂ©diteur +- `.FromMail` - Email de l'expĂ©diteur +- `.Data.*` - Vos donnĂ©es personnalisĂ©es + +### Exemple : Template de rappel de signature + +**signature_reminder.en.html.tmpl** : +```html +{{define "content"}} +

Document Signature Reminder

+ +

Hello,

+ +

The following document requires your signature:

+ +
+

Document ID: {{.Data.DocID}}

+
+ +

To sign: Click here

+ +

Best regards,
+The {{.Organisation}} Team

+{{end}} +``` + +**signature_reminder.en.txt.tmpl** : +``` +{{define "content"}} +Document Signature Reminder + +Hello, + +The following document requires your signature: +Document ID: {{.Data.DocID}} + +To sign, visit: {{.Data.SignURL}} + +Best regards, +The {{.Organisation}} Team +{{end}} +``` + +### RĂ©solution des templates + +Le systĂšme rĂ©sout les templates dans cet ordre : +1. `..html.tmpl` (ex: `welcome.fr.html.tmpl`) +2. `.en.html.tmpl` (fallback anglais) +3. Erreur si aucun template trouvĂ© + +## đŸ§Ș Tests locaux avec MailHog + +MailHog est inclus dans `compose.local.yml` pour tester l'envoi d'emails. + +### Lancement + +```bash +docker compose -f compose.local.yml up -d mailhog +``` + +### Interface web + +AccĂ©dez Ă  http://localhost:8025 pour voir les emails envoyĂ©s. + +### Configuration + +```bash +ACKIFY_MAIL_HOST=mailhog +ACKIFY_MAIL_PORT=1025 +ACKIFY_MAIL_FROM=test@ackify.local +``` + +## 🔍 Troubleshooting + +### Email non envoyĂ© + +**ProblĂšme** : Aucun email n'est envoyĂ©, pas d'erreur. + +**Solution** : VĂ©rifiez que `ACKIFY_MAIL_HOST` est dĂ©fini. Si non dĂ©fini, le service est dĂ©sactivĂ© silencieusement. + +### Erreur "failed to send email" + +**ProblĂšme** : Erreur lors de l'envoi. + +**Solutions** : +- VĂ©rifiez les credentials SMTP (`ACKIFY_MAIL_USERNAME`, `ACKIFY_MAIL_PASSWORD`) +- VĂ©rifiez le port et TLS/STARTTLS +- Pour Gmail, utilisez un "App Password" (pas votre mot de passe principal) + +### Template non trouvĂ© + +**ProblĂšme** : `template not found: (locale: )` + +**Solutions** : +- VĂ©rifiez que le template existe dans `ACKIFY_MAIL_TEMPLATE_DIR` +- VĂ©rifiez le nom du fichier : `...tmpl` +- Au minimum, crĂ©ez la version anglaise `.en.html.tmpl` et `.en.txt.tmpl` + +### Secrets dans les logs + +**ProblĂšme** : Mot de passe SMTP dans les logs. + +**Solution** : Le systĂšme ne logue **jamais** les secrets. Si vous voyez des secrets, c'est un bug Ă  signaler. + +## 📊 Monitoring + +Le service logue automatiquement : +- `INFO` : "SMTP not configured, email not sent" (si dĂ©sactivĂ©) +- `INFO` : "Sending email" avec destinataires, template, locale +- `INFO` : "Email sent successfully" avec destinataires +- `ERROR` : Erreurs de rendu ou d'envoi + +Exemple : +``` +{"level":"INFO","msg":"Sending email","to":["user@example.com"],"template":"signature_reminder","locale":"fr"} +{"level":"INFO","msg":"Email sent successfully","to":["user@example.com"]} +``` + +## 🔐 SĂ©curitĂ© + +- ✅ Aucun secret (password, credentials) n'est loggĂ© +- ✅ TLS/STARTTLS supportĂ© pour chiffrement +- ✅ Timeout pour Ă©viter les blocages +- ✅ Service dĂ©sactivĂ© par dĂ©faut (opt-in explicite) + +## 🚀 IntĂ©gration dans les handlers + +Exemple d'utilisation dans un handler : + +```go +func (h *SignatureHandlers) SendReminder(w http.ResponseWriter, r *http.Request) { + docID := r.URL.Query().Get("doc") + userEmail := getUserEmail(r) // votre logique + + docURL := fmt.Sprintf("%s/status?doc=%s", h.baseURL, docID) + signURL := fmt.Sprintf("%s/sign?doc=%s", h.baseURL, docID) + + locale := getLocaleFromRequest(r) // "en" ou "fr" + + err := email.SendSignatureReminderEmail( + r.Context(), + h.emailSender, + []string{userEmail}, + locale, + docID, + docURL, + signURL, + ) + + if err != nil { + http.Error(w, "Failed to send reminder", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} +``` + +--- + +**ImplĂ©mentation complĂšte et testĂ©e** ✅ diff --git a/examples/GOOGLE_INTEGRATION.md b/docs/integrations/google-doc/GOOGLE_INTEGRATION.md similarity index 100% rename from examples/GOOGLE_INTEGRATION.md rename to docs/integrations/google-doc/GOOGLE_INTEGRATION.md diff --git a/examples/google/appscript/Code.gs b/docs/integrations/google-doc/exemple/appscript/Code.gs similarity index 100% rename from examples/google/appscript/Code.gs rename to docs/integrations/google-doc/exemple/appscript/Code.gs diff --git a/examples/google/appscript/appsscript.json b/docs/integrations/google-doc/exemple/appscript/appsscript.json similarity index 100% rename from examples/google/appscript/appsscript.json rename to docs/integrations/google-doc/exemple/appscript/appsscript.json diff --git a/install/install.sh b/install/install.sh index df4a626..62fe2df 100755 --- a/install/install.sh +++ b/install/install.sh @@ -21,8 +21,8 @@ cd "$INSTALL_DIR" echo "📩 Downloading configuration files..." -# Download docker-compose.yml -curl -fsSL https://raw.githubusercontent.com/btouchard/ackify-ce/main/install/docker-compose.yml -o docker-compose.yml +# Download compose.yml +curl -fsSL https://raw.githubusercontent.com/btouchard/ackify-ce/main/install/compose.yml -o compose.yml # Download .env.example curl -fsSL https://raw.githubusercontent.com/btouchard/ackify-ce/main/install/.env.example -o .env.example diff --git a/internal/domain/models/expected_signer.go b/internal/domain/models/expected_signer.go new file mode 100644 index 0000000..5cc5daf --- /dev/null +++ b/internal/domain/models/expected_signer.go @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +package models + +import "time" + +// ExpectedSigner represents an expected signer for a document +type ExpectedSigner struct { + ID int64 `json:"id" db:"id"` + DocID string `json:"doc_id" db:"doc_id"` + Email string `json:"email" db:"email"` + AddedAt time.Time `json:"added_at" db:"added_at"` + AddedBy string `json:"added_by" db:"added_by"` + Notes *string `json:"notes,omitempty" db:"notes"` +} + +// ExpectedSignerWithStatus combines expected signer info with signature status +type ExpectedSignerWithStatus struct { + ExpectedSigner + HasSigned bool `json:"has_signed"` + SignedAt *time.Time `json:"signed_at,omitempty"` +} + +// DocCompletionStats provides completion statistics for a document +type DocCompletionStats struct { + DocID string `json:"doc_id"` + ExpectedCount int `json:"expected_count"` + SignedCount int `json:"signed_count"` + PendingCount int `json:"pending_count"` + CompletionRate float64 `json:"completion_rate"` // Percentage 0-100 +} diff --git a/internal/infrastructure/database/expected_signer_repository.go b/internal/infrastructure/database/expected_signer_repository.go new file mode 100644 index 0000000..3bc4cf3 --- /dev/null +++ b/internal/infrastructure/database/expected_signer_repository.go @@ -0,0 +1,219 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +package database + +import ( + "context" + "database/sql" + "fmt" + "strings" + + "github.com/btouchard/ackify-ce/internal/domain/models" +) + +// ExpectedSignerRepository handles database operations for expected signers +type ExpectedSignerRepository struct { + db *sql.DB +} + +// NewExpectedSignerRepository creates a new expected signer repository +func NewExpectedSignerRepository(db *sql.DB) *ExpectedSignerRepository { + return &ExpectedSignerRepository{db: db} +} + +// AddExpected adds multiple expected signers for a document (batch insert with conflict handling) +func (r *ExpectedSignerRepository) AddExpected(ctx context.Context, docID string, emails []string, addedBy string) error { + if len(emails) == 0 { + return nil + } + + // Build batch INSERT with ON CONFLICT DO NOTHING + valueStrings := make([]string, 0, len(emails)) + valueArgs := make([]interface{}, 0, len(emails)*3) + + for i, email := range emails { + valueStrings = append(valueStrings, fmt.Sprintf("($%d, $%d, $%d)", i*3+1, i*3+2, i*3+3)) + valueArgs = append(valueArgs, docID, email, addedBy) + } + + query := fmt.Sprintf(` + INSERT INTO expected_signers (doc_id, email, added_by) + VALUES %s + ON CONFLICT (doc_id, email) DO NOTHING + `, strings.Join(valueStrings, ",")) + + _, err := r.db.ExecContext(ctx, query, valueArgs...) + if err != nil { + return fmt.Errorf("failed to add expected signers: %w", err) + } + + return nil +} + +// ListByDocID returns all expected signers for a document +func (r *ExpectedSignerRepository) ListByDocID(ctx context.Context, docID string) ([]*models.ExpectedSigner, error) { + query := ` + SELECT id, doc_id, email, added_at, added_by, notes + FROM expected_signers + WHERE doc_id = $1 + ORDER BY added_at ASC + ` + + rows, err := r.db.QueryContext(ctx, query, docID) + if err != nil { + return nil, fmt.Errorf("failed to query expected signers: %w", err) + } + defer rows.Close() + + var signers []*models.ExpectedSigner + for rows.Next() { + signer := &models.ExpectedSigner{} + err := rows.Scan( + &signer.ID, + &signer.DocID, + &signer.Email, + &signer.AddedAt, + &signer.AddedBy, + &signer.Notes, + ) + if err != nil { + continue + } + signers = append(signers, signer) + } + + return signers, nil +} + +// ListWithStatusByDocID returns expected signers with their signature status +func (r *ExpectedSignerRepository) ListWithStatusByDocID(ctx context.Context, docID string) ([]*models.ExpectedSignerWithStatus, error) { + query := ` + SELECT + es.id, + es.doc_id, + es.email, + es.added_at, + es.added_by, + es.notes, + CASE WHEN s.id IS NOT NULL THEN true ELSE false END as has_signed, + s.signed_at + FROM expected_signers es + LEFT JOIN signatures s ON es.doc_id = s.doc_id AND es.email = s.user_email + WHERE es.doc_id = $1 + ORDER BY has_signed DESC, es.added_at ASC + ` + + rows, err := r.db.QueryContext(ctx, query, docID) + if err != nil { + return nil, fmt.Errorf("failed to query expected signers with status: %w", err) + } + defer rows.Close() + + var signers []*models.ExpectedSignerWithStatus + for rows.Next() { + signer := &models.ExpectedSignerWithStatus{} + err := rows.Scan( + &signer.ID, + &signer.DocID, + &signer.Email, + &signer.AddedAt, + &signer.AddedBy, + &signer.Notes, + &signer.HasSigned, + &signer.SignedAt, + ) + if err != nil { + continue + } + signers = append(signers, signer) + } + + return signers, nil +} + +// Remove removes an expected signer from a document +func (r *ExpectedSignerRepository) Remove(ctx context.Context, docID, email string) error { + query := ` + DELETE FROM expected_signers + WHERE doc_id = $1 AND email = $2 + ` + + result, err := r.db.ExecContext(ctx, query, docID, email) + if err != nil { + return fmt.Errorf("failed to remove expected signer: %w", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to check rows affected: %w", err) + } + + if rows == 0 { + return fmt.Errorf("expected signer not found") + } + + return nil +} + +// RemoveAllForDoc removes all expected signers for a document +func (r *ExpectedSignerRepository) RemoveAllForDoc(ctx context.Context, docID string) error { + query := ` + DELETE FROM expected_signers + WHERE doc_id = $1 + ` + + _, err := r.db.ExecContext(ctx, query, docID) + if err != nil { + return fmt.Errorf("failed to remove all expected signers: %w", err) + } + + return nil +} + +// IsExpected checks if an email is expected for a document +func (r *ExpectedSignerRepository) IsExpected(ctx context.Context, docID, email string) (bool, error) { + query := ` + SELECT EXISTS( + SELECT 1 FROM expected_signers + WHERE doc_id = $1 AND email = $2 + ) + ` + + var exists bool + err := r.db.QueryRowContext(ctx, query, docID, email).Scan(&exists) + if err != nil { + return false, fmt.Errorf("failed to check if email is expected: %w", err) + } + + return exists, nil +} + +// GetStats returns completion statistics for a document +func (r *ExpectedSignerRepository) GetStats(ctx context.Context, docID string) (*models.DocCompletionStats, error) { + query := ` + SELECT + COUNT(*) as expected_count, + COUNT(s.id) as signed_count + FROM expected_signers es + LEFT JOIN signatures s ON es.doc_id = s.doc_id AND es.email = s.user_email + WHERE es.doc_id = $1 + ` + + stats := &models.DocCompletionStats{ + DocID: docID, + } + + err := r.db.QueryRowContext(ctx, query, docID).Scan(&stats.ExpectedCount, &stats.SignedCount) + if err != nil { + return nil, fmt.Errorf("failed to get stats: %w", err) + } + + stats.PendingCount = stats.ExpectedCount - stats.SignedCount + + if stats.ExpectedCount > 0 { + stats.CompletionRate = float64(stats.SignedCount) / float64(stats.ExpectedCount) * 100 + } else { + stats.CompletionRate = 0 + } + + return stats, nil +} diff --git a/internal/infrastructure/database/expected_signer_repository_test.go b/internal/infrastructure/database/expected_signer_repository_test.go new file mode 100644 index 0000000..fe3cf84 --- /dev/null +++ b/internal/infrastructure/database/expected_signer_repository_test.go @@ -0,0 +1,329 @@ +//go:build integration + +// SPDX-License-Identifier: AGPL-3.0-or-later +package database + +import ( + "context" + "testing" +) + +func TestExpectedSignerRepository_AddExpected(t *testing.T) { + testDB := SetupTestDB(t) + setupExpectedSignersTable(t, testDB) + repo := NewExpectedSignerRepository(testDB.DB) + ctx := context.Background() + + tests := []struct { + name string + docID string + emails []string + addedBy string + wantError bool + }{ + { + name: "add single expected signer", + docID: "doc-001", + emails: []string{"user1@example.com"}, + addedBy: "admin@example.com", + wantError: false, + }, + { + name: "add multiple expected signers", + docID: "doc-002", + emails: []string{"user1@example.com", "user2@example.com", "user3@example.com"}, + addedBy: "admin@example.com", + wantError: false, + }, + { + name: "add duplicate emails (should not error)", + docID: "doc-003", + emails: []string{"duplicate@example.com", "duplicate@example.com"}, + addedBy: "admin@example.com", + wantError: false, + }, + { + name: "add empty list", + docID: "doc-004", + emails: []string{}, + addedBy: "admin@example.com", + wantError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + clearExpectedSignersTable(t, testDB) + + err := repo.AddExpected(ctx, tt.docID, tt.emails, tt.addedBy) + + if tt.wantError && err == nil { + t.Error("expected error, got nil") + } + if !tt.wantError && err != nil { + t.Errorf("unexpected error: %v", err) + } + + // Verify records were added + if !tt.wantError && len(tt.emails) > 0 { + signers, err := repo.ListByDocID(ctx, tt.docID) + if err != nil { + t.Fatalf("failed to list signers: %v", err) + } + + expectedCount := len(uniqueStrings(tt.emails)) + if len(signers) != expectedCount { + t.Errorf("expected %d signers, got %d", expectedCount, len(signers)) + } + } + }) + } +} + +func TestExpectedSignerRepository_ListWithStatusByDocID(t *testing.T) { + testDB := SetupTestDB(t) + setupExpectedSignersTable(t, testDB) + sigRepo := NewSignatureRepository(testDB.DB) + expectedRepo := NewExpectedSignerRepository(testDB.DB) + factory := NewSignatureFactory() + ctx := context.Background() + + // Setup test data + clearExpectedSignersTable(t, testDB) + testDB.ClearTable(t) + + docID := "doc-status-test" + emails := []string{"signed@example.com", "pending@example.com"} + + // Add expected signers + err := expectedRepo.AddExpected(ctx, docID, emails, "admin@example.com") + if err != nil { + t.Fatalf("failed to add expected signers: %v", err) + } + + // Add a signature for one of them + sig := factory.CreateSignatureWithDocAndUser(docID, "user-signed", "signed@example.com") + err = sigRepo.Create(ctx, sig) + if err != nil { + t.Fatalf("failed to create signature: %v", err) + } + + // Test ListWithStatusByDocID + signers, err := expectedRepo.ListWithStatusByDocID(ctx, docID) + if err != nil { + t.Fatalf("failed to list signers with status: %v", err) + } + + if len(signers) != 2 { + t.Fatalf("expected 2 signers, got %d", len(signers)) + } + + // Check that one has signed and one hasn't + signedCount := 0 + pendingCount := 0 + for _, s := range signers { + if s.HasSigned { + signedCount++ + if s.SignedAt == nil { + t.Error("signed signer should have signed_at timestamp") + } + } else { + pendingCount++ + if s.SignedAt != nil { + t.Error("pending signer should not have signed_at timestamp") + } + } + } + + if signedCount != 1 { + t.Errorf("expected 1 signed, got %d", signedCount) + } + if pendingCount != 1 { + t.Errorf("expected 1 pending, got %d", pendingCount) + } +} + +func TestExpectedSignerRepository_GetStats(t *testing.T) { + testDB := SetupTestDB(t) + setupExpectedSignersTable(t, testDB) + sigRepo := NewSignatureRepository(testDB.DB) + expectedRepo := NewExpectedSignerRepository(testDB.DB) + factory := NewSignatureFactory() + ctx := context.Background() + + // Setup test data + clearExpectedSignersTable(t, testDB) + testDB.ClearTable(t) + + docID := "doc-stats-test" + emails := []string{ + "user1@example.com", + "user2@example.com", + "user3@example.com", + "user4@example.com", + } + + // Add expected signers + err := expectedRepo.AddExpected(ctx, docID, emails, "admin@example.com") + if err != nil { + t.Fatalf("failed to add expected signers: %v", err) + } + + // Add signatures for 2 out of 4 + sig1 := factory.CreateSignatureWithDocAndUser(docID, "sub1", "user1@example.com") + sig2 := factory.CreateSignatureWithDocAndUser(docID, "sub2", "user2@example.com") + + if err := sigRepo.Create(ctx, sig1); err != nil { + t.Fatalf("failed to create sig1: %v", err) + } + if err := sigRepo.Create(ctx, sig2); err != nil { + t.Fatalf("failed to create sig2: %v", err) + } + + // Get stats + stats, err := expectedRepo.GetStats(ctx, docID) + if err != nil { + t.Fatalf("failed to get stats: %v", err) + } + + // Verify stats + if stats.DocID != docID { + t.Errorf("expected doc_id %s, got %s", docID, stats.DocID) + } + if stats.ExpectedCount != 4 { + t.Errorf("expected ExpectedCount 4, got %d", stats.ExpectedCount) + } + if stats.SignedCount != 2 { + t.Errorf("expected SignedCount 2, got %d", stats.SignedCount) + } + if stats.PendingCount != 2 { + t.Errorf("expected PendingCount 2, got %d", stats.PendingCount) + } + expectedRate := 50.0 + if stats.CompletionRate != expectedRate { + t.Errorf("expected CompletionRate %.2f, got %.2f", expectedRate, stats.CompletionRate) + } +} + +func TestExpectedSignerRepository_Remove(t *testing.T) { + testDB := SetupTestDB(t) + setupExpectedSignersTable(t, testDB) + repo := NewExpectedSignerRepository(testDB.DB) + ctx := context.Background() + + // Setup + clearExpectedSignersTable(t, testDB) + docID := "doc-remove-test" + emails := []string{"user1@example.com", "user2@example.com"} + err := repo.AddExpected(ctx, docID, emails, "admin@example.com") + if err != nil { + t.Fatalf("failed to add expected signers: %v", err) + } + + // Remove one + err = repo.Remove(ctx, docID, "user1@example.com") + if err != nil { + t.Fatalf("failed to remove signer: %v", err) + } + + // Verify only one remains + signers, err := repo.ListByDocID(ctx, docID) + if err != nil { + t.Fatalf("failed to list signers: %v", err) + } + + if len(signers) != 1 { + t.Errorf("expected 1 signer remaining, got %d", len(signers)) + } + if signers[0].Email != "user2@example.com" { + t.Errorf("expected user2@example.com to remain, got %s", signers[0].Email) + } + + // Try removing non-existent should error + err = repo.Remove(ctx, docID, "nonexistent@example.com") + if err == nil { + t.Error("expected error when removing non-existent signer") + } +} + +func TestExpectedSignerRepository_IsExpected(t *testing.T) { + testDB := SetupTestDB(t) + setupExpectedSignersTable(t, testDB) + repo := NewExpectedSignerRepository(testDB.DB) + ctx := context.Background() + + // Setup + clearExpectedSignersTable(t, testDB) + docID := "doc-check-test" + emails := []string{"expected@example.com"} + err := repo.AddExpected(ctx, docID, emails, "admin@example.com") + if err != nil { + t.Fatalf("failed to add expected signer: %v", err) + } + + // Check expected email + exists, err := repo.IsExpected(ctx, docID, "expected@example.com") + if err != nil { + t.Fatalf("failed to check expected: %v", err) + } + if !exists { + t.Error("expected email should exist") + } + + // Check non-expected email + exists, err = repo.IsExpected(ctx, docID, "notexpected@example.com") + if err != nil { + t.Fatalf("failed to check expected: %v", err) + } + if exists { + t.Error("non-expected email should not exist") + } +} + +// Helper functions + +func setupExpectedSignersTable(t *testing.T, testDB *TestDB) { + t.Helper() + + schema := ` + DROP TABLE IF EXISTS expected_signers; + + CREATE TABLE expected_signers ( + id BIGSERIAL PRIMARY KEY, + doc_id TEXT NOT NULL, + email TEXT NOT NULL, + added_at TIMESTAMPTZ NOT NULL DEFAULT now(), + added_by TEXT NOT NULL, + notes TEXT, + UNIQUE (doc_id, email) + ); + + CREATE INDEX idx_expected_signers_doc_id ON expected_signers(doc_id); + CREATE INDEX idx_expected_signers_email ON expected_signers(email); + ` + + _, err := testDB.DB.Exec(schema) + if err != nil { + t.Fatalf("failed to setup expected_signers table: %v", err) + } +} + +func clearExpectedSignersTable(t *testing.T, testDB *TestDB) { + t.Helper() + _, err := testDB.DB.Exec("TRUNCATE TABLE expected_signers RESTART IDENTITY") + if err != nil { + t.Fatalf("failed to clear expected_signers table: %v", err) + } +} + +func uniqueStrings(slice []string) []string { + seen := make(map[string]bool) + result := []string{} + for _, v := range slice { + if !seen[v] { + seen[v] = true + result = append(result, v) + } + } + return result +} diff --git a/internal/presentation/admin/handlers_expected_signers.go b/internal/presentation/admin/handlers_expected_signers.go new file mode 100644 index 0000000..9a39b3a --- /dev/null +++ b/internal/presentation/admin/handlers_expected_signers.go @@ -0,0 +1,306 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +package admin + +import ( + "encoding/json" + "html/template" + "net/http" + "regexp" + "strings" + + "github.com/go-chi/chi/v5" + + "github.com/btouchard/ackify-ce/internal/domain/models" + "github.com/btouchard/ackify-ce/internal/infrastructure/database" + "github.com/btouchard/ackify-ce/internal/infrastructure/i18n" + "github.com/btouchard/ackify-ce/pkg/logger" +) + +const maxTextareaSize = 10000 + +type ExpectedSignersHandlers struct { + expectedRepo *database.ExpectedSignerRepository + adminRepo *database.AdminRepository + userService userService + templates *template.Template + baseURL string +} + +func NewExpectedSignersHandlers( + expectedRepo *database.ExpectedSignerRepository, + adminRepo *database.AdminRepository, + userService userService, + templates *template.Template, + baseURL string, +) *ExpectedSignersHandlers { + return &ExpectedSignersHandlers{ + expectedRepo: expectedRepo, + adminRepo: adminRepo, + userService: userService, + templates: templates, + baseURL: baseURL, + } +} + +// HandleDocumentDetailsWithExpected displays document details with expected signers +func (h *ExpectedSignersHandlers) HandleDocumentDetailsWithExpected(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + docID := chi.URLParam(r, "docID") + + if docID == "" { + http.Error(w, "Document ID required", http.StatusBadRequest) + return + } + + user, err := h.userService.GetUser(r) + if err != nil { + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + // Get signatures + signatures, err := h.adminRepo.ListSignaturesByDoc(ctx, docID) + if err != nil { + http.Error(w, "Failed to retrieve signatures", http.StatusInternalServerError) + return + } + + // Get expected signers with status + expectedSigners, err := h.expectedRepo.ListWithStatusByDocID(ctx, docID) + if err != nil { + logger.Logger.Error("Failed to retrieve expected signers", "error", err.Error()) + expectedSigners = []*models.ExpectedSignerWithStatus{} + } + + // Get stats + stats, err := h.expectedRepo.GetStats(ctx, docID) + if err != nil { + logger.Logger.Error("Failed to retrieve stats", "error", err.Error()) + stats = &models.DocCompletionStats{ + DocID: docID, + ExpectedCount: 0, + SignedCount: 0, + PendingCount: 0, + CompletionRate: 0, + } + } + + // Check chain integrity + chainIntegrity, err := h.adminRepo.VerifyDocumentChainIntegrity(ctx, docID) + if err != nil { + chainIntegrity = &database.ChainIntegrityResult{ + IsValid: false, + TotalSigs: len(signatures), + ValidSigs: 0, + InvalidSigs: len(signatures), + Errors: []string{"Failed to verify chain integrity: " + err.Error()}, + DocID: docID, + } + } + + // Find unexpected signatures (signed but not in expected list) + unexpectedSignatures := []*models.Signature{} + if len(expectedSigners) > 0 { + expectedEmails := make(map[string]bool) + for _, es := range expectedSigners { + expectedEmails[es.Email] = true + } + + for _, sig := range signatures { + if !expectedEmails[sig.UserEmail] { + unexpectedSignatures = append(unexpectedSignatures, sig) + } + } + } + + data := struct { + TemplateName string + User *models.User + BaseURL string + DocID *string + Signatures []*models.Signature + ExpectedSigners []*models.ExpectedSignerWithStatus + Stats *models.DocCompletionStats + UnexpectedSignatures []*models.Signature + ChainIntegrity *database.ChainIntegrityResult + ShareLink string + IsAdmin bool + Lang string + T map[string]string + }{ + TemplateName: "admin_document_expected_signers", + User: user, + BaseURL: h.baseURL, + DocID: &docID, + Signatures: signatures, + ExpectedSigners: expectedSigners, + Stats: stats, + UnexpectedSignatures: unexpectedSignatures, + ChainIntegrity: chainIntegrity, + ShareLink: h.baseURL + "/sign?doc=" + docID, + IsAdmin: true, + Lang: i18n.GetLang(ctx), + T: i18n.GetTranslations(ctx), + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := h.templates.ExecuteTemplate(w, "base", data); err != nil { + http.Error(w, "Template error: "+err.Error(), http.StatusInternalServerError) + return + } +} + +// HandleAddExpectedSigners adds expected signers to a document +func (h *ExpectedSignersHandlers) HandleAddExpectedSigners(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + docID := chi.URLParam(r, "docID") + + if docID == "" { + http.Error(w, "Document ID required", http.StatusBadRequest) + return + } + + user, err := h.userService.GetUser(r) + if err != nil { + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + if err := r.ParseForm(); err != nil { + http.Error(w, "Failed to parse form", http.StatusBadRequest) + return + } + + emailsText := r.FormValue("emails") + if len(emailsText) > maxTextareaSize { + http.Error(w, "Input too large", http.StatusBadRequest) + return + } + + emails := parseEmailsFromText(emailsText) + + if len(emails) == 0 { + http.Redirect(w, r, "/admin/docs/"+docID, http.StatusSeeOther) + return + } + + // Validate emails + validEmails := []string{} + for _, email := range emails { + if isValidEmail(email) { + validEmails = append(validEmails, email) + } else { + logger.Logger.Warn("Invalid email format", "email", email) + } + } + + if len(validEmails) == 0 { + http.Error(w, "No valid emails provided", http.StatusBadRequest) + return + } + + err = h.expectedRepo.AddExpected(ctx, docID, validEmails, user.Email) + if err != nil { + logger.Logger.Error("Failed to add expected signers", "error", err.Error()) + http.Error(w, "Failed to add expected signers", http.StatusInternalServerError) + return + } + + http.Redirect(w, r, "/admin/docs/"+docID, http.StatusSeeOther) +} + +// HandleRemoveExpectedSigner removes an expected signer from a document +func (h *ExpectedSignersHandlers) HandleRemoveExpectedSigner(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + docID := chi.URLParam(r, "docID") + + if docID == "" { + http.Error(w, "Document ID required", http.StatusBadRequest) + return + } + + if err := r.ParseForm(); err != nil { + http.Error(w, "Failed to parse form", http.StatusBadRequest) + return + } + + email := r.FormValue("email") + if email == "" { + http.Error(w, "Email required", http.StatusBadRequest) + return + } + + err := h.expectedRepo.Remove(ctx, docID, email) + if err != nil { + logger.Logger.Error("Failed to remove expected signer", "error", err.Error()) + http.Error(w, "Failed to remove expected signer", http.StatusInternalServerError) + return + } + + http.Redirect(w, r, "/admin/docs/"+docID, http.StatusSeeOther) +} + +// HandleGetDocumentStatusJSON returns document status as JSON for AJAX requests +func (h *ExpectedSignersHandlers) HandleGetDocumentStatusJSON(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + docID := chi.URLParam(r, "docID") + + if docID == "" { + http.Error(w, "Document ID required", http.StatusBadRequest) + return + } + + stats, err := h.expectedRepo.GetStats(ctx, docID) + if err != nil { + http.Error(w, "Failed to get stats", http.StatusInternalServerError) + return + } + + signers, err := h.expectedRepo.ListWithStatusByDocID(ctx, docID) + if err != nil { + http.Error(w, "Failed to get signers", http.StatusInternalServerError) + return + } + + response := struct { + Stats *models.DocCompletionStats `json:"stats"` + Signers []*models.ExpectedSignerWithStatus `json:"signers"` + }{ + Stats: stats, + Signers: signers, + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(response); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } +} + +// parseEmailsFromText extracts emails from text (separated by newlines, commas, semicolons) +func parseEmailsFromText(text string) []string { + // Split by multiple separators: newline, comma, semicolon, space + separators := regexp.MustCompile(`[\n,;\s]+`) + parts := separators.Split(text, -1) + + emails := []string{} + for _, part := range parts { + email := strings.TrimSpace(part) + if email != "" { + emails = append(emails, email) + } + } + + return emails +} + +// isValidEmail performs basic email validation +func isValidEmail(email string) bool { + if email == "" { + return false + } + + // Basic regex: has @ and . after @ + emailRegex := regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`) + return emailRegex.MatchString(email) +} diff --git a/internal/presentation/admin/handlers_expected_signers_test.go b/internal/presentation/admin/handlers_expected_signers_test.go new file mode 100644 index 0000000..1a7c63b --- /dev/null +++ b/internal/presentation/admin/handlers_expected_signers_test.go @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +package admin + +import ( + "testing" +) + +func TestParseEmailsFromText(t *testing.T) { + tests := []struct { + name string + input string + expected []string + }{ + { + name: "newline separated", + input: "user1@example.com\nuser2@example.com\nuser3@example.com", + expected: []string{"user1@example.com", "user2@example.com", "user3@example.com"}, + }, + { + name: "comma separated", + input: "user1@example.com,user2@example.com,user3@example.com", + expected: []string{"user1@example.com", "user2@example.com", "user3@example.com"}, + }, + { + name: "semicolon separated", + input: "user1@example.com;user2@example.com;user3@example.com", + expected: []string{"user1@example.com", "user2@example.com", "user3@example.com"}, + }, + { + name: "mixed separators", + input: "user1@example.com\nuser2@example.com,user3@example.com;user4@example.com", + expected: []string{"user1@example.com", "user2@example.com", "user3@example.com", "user4@example.com"}, + }, + { + name: "with extra whitespace", + input: " user1@example.com \n user2@example.com ", + expected: []string{"user1@example.com", "user2@example.com"}, + }, + { + name: "empty string", + input: "", + expected: []string{}, + }, + { + name: "whitespace only", + input: " \n \n ", + expected: []string{}, + }, + { + name: "single email", + input: "user@example.com", + expected: []string{"user@example.com"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parseEmailsFromText(tt.input) + + if len(result) != len(tt.expected) { + t.Errorf("expected %d emails, got %d", len(tt.expected), len(result)) + return + } + + for i, email := range result { + if email != tt.expected[i] { + t.Errorf("at index %d: expected %s, got %s", i, tt.expected[i], email) + } + } + }) + } +} + +func TestIsValidEmail(t *testing.T) { + tests := []struct { + name string + email string + valid bool + }{ + { + name: "valid email", + email: "user@example.com", + valid: true, + }, + { + name: "valid email with subdomain", + email: "user@mail.example.com", + valid: true, + }, + { + name: "valid email with plus", + email: "user+tag@example.com", + valid: true, + }, + { + name: "valid email with dots", + email: "first.last@example.com", + valid: true, + }, + { + name: "missing @", + email: "userexample.com", + valid: false, + }, + { + name: "missing domain", + email: "user@", + valid: false, + }, + { + name: "missing username", + email: "@example.com", + valid: false, + }, + { + name: "no TLD", + email: "user@example", + valid: false, + }, + { + name: "empty string", + email: "", + valid: false, + }, + { + name: "whitespace", + email: " ", + valid: false, + }, + { + name: "multiple @", + email: "user@@example.com", + valid: false, + }, + { + name: "spaces in email", + email: "user name@example.com", + valid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isValidEmail(tt.email) + if result != tt.valid { + t.Errorf("expected %v, got %v for email: %s", tt.valid, result, tt.email) + } + }) + } +} diff --git a/internal/presentation/admin/routes_admin.go b/internal/presentation/admin/routes_admin.go index fcf8970..a187b7f 100644 --- a/internal/presentation/admin/routes_admin.go +++ b/internal/presentation/admin/routes_admin.go @@ -14,16 +14,21 @@ import ( // RegisterAdminRoutes returns a function that registers admin routes func RegisterAdminRoutes(cfg *config.Config, templates *template.Template, db *sql.DB, authService userService) func(r *chi.Mux) { return func(r *chi.Mux) { - // Initialize admin repository by reusing the existing DB connection + // Initialize repositories by reusing the existing DB connection adminRepo := database.NewAdminRepository(db) + expectedSignerRepo := database.NewExpectedSignerRepository(db) // Initialize middleware and handlers adminMiddleware := NewAdminMiddleware(authService, cfg.App.BaseURL, cfg.App.AdminEmails, templates) adminHandlers := NewAdminHandlers(adminRepo, authService, templates, cfg.App.BaseURL) + expectedHandlers := NewExpectedSignersHandlers(expectedSignerRepo, adminRepo, authService, templates, cfg.App.BaseURL) // Register admin routes r.Get("/admin", adminMiddleware.RequireAdmin(adminHandlers.HandleDashboard)) - r.Get("/admin/docs/{docID}", adminMiddleware.RequireAdmin(adminHandlers.HandleDocumentDetails)) + r.Get("/admin/docs/{docID}", adminMiddleware.RequireAdmin(expectedHandlers.HandleDocumentDetailsWithExpected)) + r.Post("/admin/docs/{docID}/expected", adminMiddleware.RequireAdmin(expectedHandlers.HandleAddExpectedSigners)) + r.Post("/admin/docs/{docID}/expected/remove", adminMiddleware.RequireAdmin(expectedHandlers.HandleRemoveExpectedSigner)) + r.Get("/admin/docs/{docID}/status.json", adminMiddleware.RequireAdmin(expectedHandlers.HandleGetDocumentStatusJSON)) r.Get("/admin/api/chain-integrity/{docID}", adminMiddleware.RequireAdmin(adminHandlers.HandleChainIntegrityAPI)) } } diff --git a/migrations/0002_expected_signers.down.sql b/migrations/0002_expected_signers.down.sql new file mode 100644 index 0000000..51f573f --- /dev/null +++ b/migrations/0002_expected_signers.down.sql @@ -0,0 +1,2 @@ +-- Drop expected_signers table +DROP TABLE IF EXISTS expected_signers CASCADE; diff --git a/migrations/0002_expected_signers.up.sql b/migrations/0002_expected_signers.up.sql new file mode 100644 index 0000000..e23239f --- /dev/null +++ b/migrations/0002_expected_signers.up.sql @@ -0,0 +1,18 @@ +-- Create expected_signers table for tracking who should sign a document +-- This table allows administrators to manage expected signers and track completion rates +CREATE TABLE expected_signers ( + id BIGSERIAL PRIMARY KEY, + doc_id TEXT NOT NULL, + email TEXT NOT NULL, + added_at TIMESTAMPTZ NOT NULL DEFAULT now(), + added_by TEXT NOT NULL, + notes TEXT, + UNIQUE (doc_id, email) +); + +-- Create indexes for efficient queries +CREATE INDEX idx_expected_signers_doc_id ON expected_signers(doc_id); +CREATE INDEX idx_expected_signers_email ON expected_signers(email); + +-- Add comment explaining the table purpose +COMMENT ON TABLE expected_signers IS 'Tracks expected signers for documents to monitor completion rates'; diff --git a/pkg/web/server.go b/pkg/web/server.go index 19db929..5b98dba 100644 --- a/pkg/web/server.go +++ b/pkg/web/server.go @@ -200,7 +200,7 @@ func initTemplates() (*template.Template, error) { return nil, fmt.Errorf("failed to parse base template: %w", err) } - additionalTemplates := []string{"index.html.tpl", "sign.html.tpl", "signatures.html.tpl", "embed.html.tpl", "admin_dashboard.html.tpl", "admin_doc_details.html.tpl", "error.html.tpl"} + additionalTemplates := []string{"index.html.tpl", "sign.html.tpl", "signatures.html.tpl", "embed.html.tpl", "admin_dashboard.html.tpl", "admin_doc_details.html.tpl", "admin_document_expected_signers.html.tpl", "error.html.tpl"} for _, templateFile := range additionalTemplates { templatePath := filepath.Join(templatesDir, templateFile) _, err = tmpl.ParseFiles(templatePath) diff --git a/scripts/docker_smoke.sh b/scripts/docker_smoke.sh index 6b106a3..d17bba9 100755 --- a/scripts/docker_smoke.sh +++ b/scripts/docker_smoke.sh @@ -3,7 +3,7 @@ # Purpose: Local Docker smoke test with clear PASS/FAIL output set -uo pipefail -COMPOSE_FILE=${COMPOSE_FILE:-docker-compose.local.yml} +COMPOSE_FILE=${COMPOSE_FILE:-compose.local.yml} BASE_URL=${BASE_URL:-http://localhost:8080} DOC_ID=${DOC_ID:-demo} USER1_EMAIL=${USER1_EMAIL:-user1@example.com} diff --git a/templates/admin_dashboard.html.tpl b/templates/admin_dashboard.html.tpl index 5733d80..a056884 100644 --- a/templates/admin_dashboard.html.tpl +++ b/templates/admin_dashboard.html.tpl @@ -1,5 +1,55 @@ {{define "admin_dashboard"}}
+ +
+

+ {{if eq .Lang "fr"}}Créer un nouveau document{{else}}Create New Document{{end}} +

+
+
+
+ + +

+ {{if eq .Lang "fr"}}Lettres, chiffres, tirets et underscores uniquement{{else}}Letters, numbers, hyphens and underscores only{{end}} +

+
+
+ +
+
+
+
+ + + +
diff --git a/templates/admin_document_expected_signers.html.tpl b/templates/admin_document_expected_signers.html.tpl new file mode 100644 index 0000000..343ab5d --- /dev/null +++ b/templates/admin_document_expected_signers.html.tpl @@ -0,0 +1,337 @@ +{{define "admin_document_expected_signers"}} +
+ +
+
+
+
+ + + + + +

Document {{.DocID}}

+
+

{{if eq .Lang "fr"}}Gestion des signataires attendus{{else}}Expected Signers Management{{end}}

+
+
+ + + {{if gt .Stats.ExpectedCount 0}} +
+
+
{{if eq .Lang "fr"}}Attendus{{else}}Expected{{end}}
+
{{.Stats.ExpectedCount}}
+
+
+
{{if eq .Lang "fr"}}Signés{{else}}Signed{{end}}
+
{{.Stats.SignedCount}}
+
+
+
{{if eq .Lang "fr"}}En attente{{else}}Pending{{end}}
+
{{.Stats.PendingCount}}
+
+
+
{{if eq .Lang "fr"}}Taux de complétion{{else}}Completion Rate{{end}}
+
{{printf "%.0f" .Stats.CompletionRate}}%
+
+
+ + +
+
+ {{if eq .Lang "fr"}}Progression{{else}}Progress{{end}} + {{.Stats.SignedCount}} / {{.Stats.ExpectedCount}} +
+
+
+
+
+ {{end}} + + +
+
+ {{if eq .Lang "fr"}}Lien Ă  partager{{else}}Share Link{{end}} +
+
+ + +
+ +
+
+ + +
+

+ {{if eq .Lang "fr"}}Ajouter des signataires attendus{{else}}Add Expected Signers{{end}} +

+
+
+ + +
+ +
+
+ + + {{if .ExpectedSigners}} +
+

+ {{if eq .Lang "fr"}}Liste des signataires attendus{{else}}Expected Signers List{{end}} +

+
+ + + + + + + + + + + {{range .ExpectedSigners}} + + + + + + + {{end}} + +
+ Email + + {{if eq .Lang "fr"}}Statut{{else}}Status{{end}} + + {{if eq .Lang "fr"}}Signé le{{else}}Signed At{{end}} + + Actions +
+
{{.Email}}
+
+ {{if .HasSigned}} + + ✓ {{if eq $.Lang "fr"}}SignĂ©{{else}}Signed{{end}} + + {{else}} + + ⏳ {{if eq $.Lang "fr"}}En attente{{else}}Pending{{end}} + + {{end}} + + {{if .SignedAt}} +
{{.SignedAt.Format "02/01/2006"}}
+
{{.SignedAt.Format "15:04:05"}}
+ {{else}} + - + {{end}} +
+
+ + +
+
+
+
+ {{end}} + + + {{if .UnexpectedSignatures}} +
+

+ {{if eq .Lang "fr"}}Signatures non attendues{{else}}Unexpected Signatures{{end}} +

+

+ {{if eq .Lang "fr"}}Ces utilisateurs ont signé mais n'étaient pas dans la liste des signataires attendus.{{else}}These users signed but were not in the expected signers list.{{end}} +

+
+ + + + + + + + + {{range .UnexpectedSignatures}} + + + + + {{end}} + +
+ Email + + {{if eq .Lang "fr"}}Signé le{{else}}Signed At{{end}} +
+
{{.UserEmail}}
+ {{if .UserName}} +
{{.UserName}}
+ {{end}} +
+
{{.SignedAtUTC.Format "02/01/2006"}}
+
{{.SignedAtUTC.Format "15:04:05"}}
+
+
+
+ {{end}} + + + {{if .Signatures}} +
+

+ {{if eq .Lang "fr"}}Toutes les signatures{{else}}All Signatures{{end}} ({{len .Signatures}}) +

+
+ + + + + + + + + + {{range .Signatures}} + + + + + + {{end}} + +
+ {{if eq .Lang "fr"}}Utilisateur{{else}}User{{end}} + + {{if eq .Lang "fr"}}Signé le{{else}}Signed At{{end}} + + Service +
+
+
+ + + +
+
+ {{if .UserName}} +
{{.UserName}}
+ {{end}} +
{{.UserEmail}}
+
+
+
+
{{.SignedAtUTC.Format "02/01/2006"}}
+
{{.SignedAtUTC.Format "15:04:05"}}
+
+ {{$serviceInfo := .GetServiceInfo}} + {{if $serviceInfo}} +
+ {{$serviceInfo.Name}} + {{$serviceInfo.Name}} +
+ {{else}} + - + {{end}} +
+
+
+ + + {{if .ChainIntegrity}} + {{if .ChainIntegrity.IsValid}} +
+
+
+ + + +
+
+

+ {{if eq .Lang "fr"}}Intégrité de la chaßne validée{{else}}Chain integrity valid{{end}} - {{.ChainIntegrity.ValidSigs}}/{{.ChainIntegrity.TotalSigs}} signatures +

+
+
+
+ {{else}} +
+
+
+ + + +
+
+

+ {{if eq .Lang "fr"}}ProblÚme d'intégrité détecté{{else}}Chain integrity issues{{end}} - {{.ChainIntegrity.InvalidSigs}} {{if eq .Lang "fr"}}erreurs{{else}}errors{{end}} +

+ {{if .ChainIntegrity.Errors}} +
+

{{if eq .Lang "fr"}}Erreurs détectées :{{else}}Detected errors:{{end}}

+
    + {{range .ChainIntegrity.Errors}} +
  • {{.}}
  • + {{end}} +
+
+ {{end}} +
+
+
+ {{end}} + {{end}} + {{else}} +
+
+
+ + + +
+

+ {{if eq .Lang "fr"}}Aucune signature pour le moment{{else}}No signatures yet{{end}} +

+

+ {{if eq .Lang "fr"}}Ce document n'a pas encore été signé.{{else}}This document has not been signed yet.{{end}} +

+
+
+ {{end}} +
+ + +{{end}} diff --git a/templates/base.html.tpl b/templates/base.html.tpl index f84a557..9195f55 100644 --- a/templates/base.html.tpl +++ b/templates/base.html.tpl @@ -4,7 +4,7 @@ {{index .T "site.title"}} -{{if and (ne .TemplateName "admin_dashboard") (ne .TemplateName "admin_doc_details")}}{{if .DocID}} +{{if and (ne .TemplateName "admin_dashboard") (ne .TemplateName "admin_doc_details") (ne .TemplateName "admin_document_expected_signers")}}{{if .DocID}} {{end}}{{end}} @@ -75,6 +75,8 @@ {{template "admin_dashboard" .}} {{else if eq .TemplateName "admin_doc_details"}} {{template "admin_doc_details" .}} + {{else if eq .TemplateName "admin_document_expected_signers"}} + {{template "admin_document_expected_signers" .}} {{else if eq .TemplateName "error"}} {{template "error" .}} {{else}}