Skip to content

Commit 6e12fb6

Browse files
committed
feat: add email reminder system for pending signers
Enable admins to send reminder emails to expected signers who haven't signed yet. This addresses the need to follow up with pending signers without manual tracking. - Add reminder_logs table to track all email sends (success and failures) - Implement ReminderService with SMTP integration - Extend admin dashboard with reminder stats and send interface - Support bulk send (all pending) or selective send (manual selection) - Track reminder count and last sent date per signer - Change terminology from "signature" to "lecture/confirmation de lecture" across all templates and emails
1 parent af3ab1f commit 6e12fb6

17 files changed

+967
-148
lines changed

cmd/community/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ func main() {
3131
log.Fatalf("Failed to create server: %v", err)
3232
}
3333

34-
server.RegisterRoutes(admin.RegisterAdminRoutes(cfg, server.GetTemplates(), server.GetDB(), server.GetAuthService()))
34+
server.RegisterRoutes(admin.RegisterAdminRoutes(cfg, server.GetTemplates(), server.GetDB(), server.GetAuthService(), server.GetEmailSender()))
3535

3636
go func() {
3737
log.Printf("Community Edition server starting on %s", server.GetAddr())
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
package services
3+
4+
import (
5+
"context"
6+
"fmt"
7+
"time"
8+
9+
"github.com/btouchard/ackify-ce/internal/domain/models"
10+
"github.com/btouchard/ackify-ce/internal/infrastructure/database"
11+
"github.com/btouchard/ackify-ce/internal/infrastructure/email"
12+
"github.com/btouchard/ackify-ce/pkg/logger"
13+
)
14+
15+
type ReminderService struct {
16+
expectedSignerRepo *database.ExpectedSignerRepository
17+
reminderRepo *database.ReminderRepository
18+
emailSender email.Sender
19+
baseURL string
20+
}
21+
22+
func NewReminderService(
23+
expectedSignerRepo *database.ExpectedSignerRepository,
24+
reminderRepo *database.ReminderRepository,
25+
emailSender email.Sender,
26+
baseURL string,
27+
) *ReminderService {
28+
return &ReminderService{
29+
expectedSignerRepo: expectedSignerRepo,
30+
reminderRepo: reminderRepo,
31+
emailSender: emailSender,
32+
baseURL: baseURL,
33+
}
34+
}
35+
36+
// SendReminders sends reminder emails to pending signers
37+
func (s *ReminderService) SendReminders(
38+
ctx context.Context,
39+
docID string,
40+
sentBy string,
41+
specificEmails []string,
42+
docURL string,
43+
) (*models.ReminderSendResult, error) {
44+
45+
allSigners, err := s.expectedSignerRepo.ListWithStatusByDocID(ctx, docID)
46+
if err != nil {
47+
return nil, fmt.Errorf("failed to get expected signers: %w", err)
48+
}
49+
50+
var pendingSigners []*models.ExpectedSignerWithStatus
51+
for _, signer := range allSigners {
52+
if !signer.HasSigned {
53+
if len(specificEmails) > 0 {
54+
if containsEmail(specificEmails, signer.Email) {
55+
pendingSigners = append(pendingSigners, signer)
56+
}
57+
} else {
58+
pendingSigners = append(pendingSigners, signer)
59+
}
60+
}
61+
}
62+
63+
if len(pendingSigners) == 0 {
64+
return &models.ReminderSendResult{
65+
TotalAttempted: 0,
66+
SuccessfullySent: 0,
67+
Failed: 0,
68+
}, nil
69+
}
70+
71+
result := &models.ReminderSendResult{
72+
TotalAttempted: len(pendingSigners),
73+
}
74+
75+
for _, signer := range pendingSigners {
76+
err := s.sendSingleReminder(ctx, docID, signer.Email, signer.Name, sentBy, docURL)
77+
if err != nil {
78+
result.Failed++
79+
result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", signer.Email, err))
80+
} else {
81+
result.SuccessfullySent++
82+
}
83+
}
84+
85+
return result, nil
86+
}
87+
88+
// sendSingleReminder sends a reminder to a single signer
89+
func (s *ReminderService) sendSingleReminder(
90+
ctx context.Context,
91+
docID string,
92+
recipientEmail string,
93+
recipientName string,
94+
sentBy string,
95+
docURL string,
96+
) error {
97+
98+
signURL := fmt.Sprintf("%s/sign?doc=%s", s.baseURL, docID)
99+
100+
log := &models.ReminderLog{
101+
DocID: docID,
102+
RecipientEmail: recipientEmail,
103+
SentAt: time.Now(),
104+
SentBy: sentBy,
105+
TemplateUsed: "signature_reminder",
106+
Status: "sent",
107+
}
108+
109+
err := email.SendSignatureReminderEmail(ctx, s.emailSender, []string{recipientEmail}, "fr", docID, docURL, signURL, recipientName)
110+
if err != nil {
111+
log.Status = "failed"
112+
errMsg := err.Error()
113+
log.ErrorMessage = &errMsg
114+
115+
if logErr := s.reminderRepo.LogReminder(ctx, log); logErr != nil {
116+
logger.Logger.Error("failed to log reminder error", "error", logErr, "original_error", err)
117+
}
118+
119+
return fmt.Errorf("failed to send email: %w", err)
120+
}
121+
122+
if err := s.reminderRepo.LogReminder(ctx, log); err != nil {
123+
logger.Logger.Error("failed to log successful reminder", "error", err)
124+
return fmt.Errorf("email sent but failed to log: %w", err)
125+
}
126+
127+
return nil
128+
}
129+
130+
// GetReminderStats returns reminder statistics for a document
131+
func (s *ReminderService) GetReminderStats(ctx context.Context, docID string) (*models.ReminderStats, error) {
132+
return s.reminderRepo.GetReminderStats(ctx, docID)
133+
}
134+
135+
// GetReminderHistory returns reminder history for a document
136+
func (s *ReminderService) GetReminderHistory(ctx context.Context, docID string) ([]*models.ReminderLog, error) {
137+
return s.reminderRepo.GetReminderHistory(ctx, docID)
138+
}
139+
140+
func containsEmail(slice []string, item string) bool {
141+
for _, s := range slice {
142+
if s == item {
143+
return true
144+
}
145+
}
146+
return false
147+
}

internal/domain/models/expected_signer.go

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ type ExpectedSigner struct {
88
ID int64 `json:"id" db:"id"`
99
DocID string `json:"doc_id" db:"doc_id"`
1010
Email string `json:"email" db:"email"`
11+
Name string `json:"name" db:"name"`
1112
AddedAt time.Time `json:"added_at" db:"added_at"`
1213
AddedBy string `json:"added_by" db:"added_by"`
1314
Notes *string `json:"notes,omitempty" db:"notes"`
@@ -16,9 +17,13 @@ type ExpectedSigner struct {
1617
// ExpectedSignerWithStatus combines expected signer info with signature status
1718
type ExpectedSignerWithStatus struct {
1819
ExpectedSigner
19-
HasSigned bool `json:"has_signed"`
20-
SignedAt *time.Time `json:"signed_at,omitempty"`
21-
UserName *string `json:"user_name,omitempty"`
20+
HasSigned bool `json:"has_signed"`
21+
SignedAt *time.Time `json:"signed_at,omitempty"`
22+
UserName *string `json:"user_name,omitempty"`
23+
LastReminderSent *time.Time `json:"last_reminder_sent,omitempty"`
24+
ReminderCount int `json:"reminder_count"`
25+
DaysSinceAdded int `json:"days_since_added"`
26+
DaysSinceLastReminder *int `json:"days_since_last_reminder,omitempty"`
2227
}
2328

2429
// DocCompletionStats provides completion statistics for a document
@@ -29,3 +34,9 @@ type DocCompletionStats struct {
2934
PendingCount int `json:"pending_count"`
3035
CompletionRate float64 `json:"completion_rate"` // Percentage 0-100
3136
}
37+
38+
// ContactInfo represents a contact with optional name and email
39+
type ContactInfo struct {
40+
Name string
41+
Email string
42+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
package models
3+
4+
import "time"
5+
6+
// ReminderLog represents a log entry for an email reminder sent to a signer
7+
type ReminderLog struct {
8+
ID int64 `json:"id" db:"id"`
9+
DocID string `json:"doc_id" db:"doc_id"`
10+
RecipientEmail string `json:"recipient_email" db:"recipient_email"`
11+
SentAt time.Time `json:"sent_at" db:"sent_at"`
12+
SentBy string `json:"sent_by" db:"sent_by"`
13+
TemplateUsed string `json:"template_used" db:"template_used"`
14+
Status string `json:"status" db:"status"`
15+
ErrorMessage *string `json:"error_message,omitempty" db:"error_message"`
16+
}
17+
18+
// ReminderStats provides statistics about reminders for a document
19+
type ReminderStats struct {
20+
TotalSent int `json:"total_sent"`
21+
LastSentAt *time.Time `json:"last_sent_at,omitempty"`
22+
PendingCount int `json:"pending_count"`
23+
}
24+
25+
// ReminderSendResult represents the result of a bulk reminder send operation
26+
type ReminderSendResult struct {
27+
TotalAttempted int `json:"total_attempted"`
28+
SuccessfullySent int `json:"successfully_sent"`
29+
Failed int `json:"failed"`
30+
Errors []string `json:"errors,omitempty"`
31+
}

internal/infrastructure/database/expected_signer_repository.go

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,22 +22,22 @@ func NewExpectedSignerRepository(db *sql.DB) *ExpectedSignerRepository {
2222
}
2323

2424
// AddExpected adds multiple expected signers for a document (batch insert with conflict handling)
25-
func (r *ExpectedSignerRepository) AddExpected(ctx context.Context, docID string, emails []string, addedBy string) error {
26-
if len(emails) == 0 {
25+
func (r *ExpectedSignerRepository) AddExpected(ctx context.Context, docID string, contacts []models.ContactInfo, addedBy string) error {
26+
if len(contacts) == 0 {
2727
return nil
2828
}
2929

3030
// Build batch INSERT with ON CONFLICT DO NOTHING
31-
valueStrings := make([]string, 0, len(emails))
32-
valueArgs := make([]interface{}, 0, len(emails)*3)
31+
valueStrings := make([]string, 0, len(contacts))
32+
valueArgs := make([]interface{}, 0, len(contacts)*4)
3333

34-
for i, email := range emails {
35-
valueStrings = append(valueStrings, fmt.Sprintf("($%d, $%d, $%d)", i*3+1, i*3+2, i*3+3))
36-
valueArgs = append(valueArgs, docID, email, addedBy)
34+
for i, contact := range contacts {
35+
valueStrings = append(valueStrings, fmt.Sprintf("($%d, $%d, $%d, $%d)", i*4+1, i*4+2, i*4+3, i*4+4))
36+
valueArgs = append(valueArgs, docID, contact.Email, contact.Name, addedBy)
3737
}
3838

3939
query := fmt.Sprintf(`
40-
INSERT INTO expected_signers (doc_id, email, added_by)
40+
INSERT INTO expected_signers (doc_id, email, name, added_by)
4141
VALUES %s
4242
ON CONFLICT (doc_id, email) DO NOTHING
4343
`, strings.Join(valueStrings, ","))
@@ -53,7 +53,7 @@ func (r *ExpectedSignerRepository) AddExpected(ctx context.Context, docID string
5353
// ListByDocID returns all expected signers for a document
5454
func (r *ExpectedSignerRepository) ListByDocID(ctx context.Context, docID string) ([]*models.ExpectedSigner, error) {
5555
query := `
56-
SELECT id, doc_id, email, added_at, added_by, notes
56+
SELECT id, doc_id, email, name, added_at, added_by, notes
5757
FROM expected_signers
5858
WHERE doc_id = $1
5959
ORDER BY added_at ASC
@@ -77,6 +77,7 @@ func (r *ExpectedSignerRepository) ListByDocID(ctx context.Context, docID string
7777
&signer.ID,
7878
&signer.DocID,
7979
&signer.Email,
80+
&signer.Name,
8081
&signer.AddedAt,
8182
&signer.AddedBy,
8283
&signer.Notes,
@@ -97,15 +98,22 @@ func (r *ExpectedSignerRepository) ListWithStatusByDocID(ctx context.Context, do
9798
es.id,
9899
es.doc_id,
99100
es.email,
101+
es.name,
100102
es.added_at,
101103
es.added_by,
102104
es.notes,
103105
CASE WHEN s.id IS NOT NULL THEN true ELSE false END as has_signed,
104106
s.signed_at,
105-
s.user_name
107+
s.user_name,
108+
MAX(rl.sent_at) as last_reminder_sent,
109+
COUNT(CASE WHEN rl.status = 'sent' THEN 1 END) as reminder_count,
110+
EXTRACT(DAY FROM (NOW() - es.added_at))::int as days_since_added,
111+
EXTRACT(DAY FROM (NOW() - MAX(rl.sent_at)))::int as days_since_last_reminder
106112
FROM expected_signers es
107113
LEFT JOIN signatures s ON es.doc_id = s.doc_id AND es.email = s.user_email
114+
LEFT JOIN reminder_logs rl ON es.doc_id = rl.doc_id AND es.email = rl.recipient_email
108115
WHERE es.doc_id = $1
116+
GROUP BY es.id, es.doc_id, es.email, es.name, es.added_at, es.added_by, es.notes, s.id, s.signed_at, s.user_name
109117
ORDER BY has_signed DESC, es.added_at ASC
110118
`
111119

@@ -123,20 +131,38 @@ func (r *ExpectedSignerRepository) ListWithStatusByDocID(ctx context.Context, do
123131
var signers []*models.ExpectedSignerWithStatus
124132
for rows.Next() {
125133
signer := &models.ExpectedSignerWithStatus{}
134+
var lastReminderSent sql.NullTime
135+
var daysSinceLastReminder sql.NullInt64
136+
126137
err := rows.Scan(
127138
&signer.ID,
128139
&signer.DocID,
129140
&signer.Email,
141+
&signer.Name,
130142
&signer.AddedAt,
131143
&signer.AddedBy,
132144
&signer.Notes,
133145
&signer.HasSigned,
134146
&signer.SignedAt,
135147
&signer.UserName,
148+
&lastReminderSent,
149+
&signer.ReminderCount,
150+
&signer.DaysSinceAdded,
151+
&daysSinceLastReminder,
136152
)
137153
if err != nil {
138154
continue
139155
}
156+
157+
if lastReminderSent.Valid {
158+
signer.LastReminderSent = &lastReminderSent.Time
159+
}
160+
161+
if daysSinceLastReminder.Valid {
162+
days := int(daysSinceLastReminder.Int64)
163+
signer.DaysSinceLastReminder = &days
164+
}
165+
140166
signers = append(signers, signer)
141167
}
142168

0 commit comments

Comments
 (0)