Files
ackify-ce/backend/internal/infrastructure/database/email_queue_repository.go
Benjamin e95185f9c7 feat: migrate to Vue.js SPA with API-first architecture
Major refactoring to modernize the application architecture:

Backend changes:
- Restructure API with v1 versioning and modular handlers
- Add comprehensive OpenAPI specification
- Implement RESTful endpoints for documents, signatures, admin
- Add checksum verification system for document integrity
- Add server-side runtime injection of ACKIFY_BASE_URL and meta tags
- Generate dynamic Open Graph/Twitter Card meta tags for unfurling
- Remove legacy HTML template handlers
- Isolate backend source on dedicated folder
- Improve tests suite

Frontend changes:
- Migrate from Go templates to Vue.js 3 SPA with TypeScript
- Add Tailwind CSS with shadcn/vue components
- Implement i18n support (fr, en, es, de, it)
- Add admin dashboard for document and signer management
- Add signature tracking with file checksum verification
- Add embed page with sign button linking to main app
- Implement dark mode and accessibility features
- Auto load file to compute checksum

Infrastructure:
- Update Dockerfile for SPA build process
- Simplify deployment with embedded frontend assets
- Add migration for checksum_verifications table

This enables better UX, proper link previews on social platforms,
and provides a foundation for future enhancements.
2025-10-26 02:32:10 +02:00

486 lines
13 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-or-later
package database
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"time"
"github.com/lib/pq"
"github.com/btouchard/ackify-ce/backend/internal/domain/models"
"github.com/btouchard/ackify-ce/backend/pkg/logger"
)
// EmailQueueRepository handles database operations for the email queue
type EmailQueueRepository struct {
db *sql.DB
}
// NewEmailQueueRepository creates a new email queue repository
func NewEmailQueueRepository(db *sql.DB) *EmailQueueRepository {
return &EmailQueueRepository{db: db}
}
// Enqueue adds a new email to the queue
func (r *EmailQueueRepository) Enqueue(ctx context.Context, input models.EmailQueueInput) (*models.EmailQueueItem, error) {
// Prepare data as JSON
dataJSON, err := json.Marshal(input.Data)
if err != nil {
return nil, fmt.Errorf("failed to marshal email data: %w", err)
}
var headersJSON []byte
if input.Headers != nil {
headersJSON, err = json.Marshal(input.Headers)
if err != nil {
return nil, fmt.Errorf("failed to marshal email headers: %w", err)
}
} else {
// Use empty JSON object instead of nil for PostgreSQL JSONB compatibility
headersJSON = []byte("{}")
}
// Default values
maxRetries := input.MaxRetries
if maxRetries == 0 {
maxRetries = 3
}
scheduledFor := time.Now()
if input.ScheduledFor != nil {
scheduledFor = *input.ScheduledFor
}
query := `
INSERT INTO email_queue (
to_addresses, cc_addresses, bcc_addresses,
subject, template, locale, data, headers,
priority, scheduled_for, max_retries,
reference_type, reference_id, created_by
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14
) RETURNING
id, status, retry_count, created_at, processed_at,
next_retry_at, last_error, error_details
`
item := &models.EmailQueueItem{
ToAddresses: input.ToAddresses,
CcAddresses: input.CcAddresses,
BccAddresses: input.BccAddresses,
Subject: input.Subject,
Template: input.Template,
Locale: input.Locale,
Data: dataJSON,
Headers: models.NullRawMessage{RawMessage: headersJSON, Valid: input.Headers != nil},
Priority: input.Priority,
ScheduledFor: scheduledFor,
MaxRetries: maxRetries,
ReferenceType: input.ReferenceType,
ReferenceID: input.ReferenceID,
CreatedBy: input.CreatedBy,
}
err = r.db.QueryRowContext(
ctx,
query,
pq.Array(input.ToAddresses),
pq.Array(input.CcAddresses),
pq.Array(input.BccAddresses),
input.Subject,
input.Template,
input.Locale,
dataJSON,
headersJSON,
input.Priority,
scheduledFor,
maxRetries,
input.ReferenceType,
input.ReferenceID,
input.CreatedBy,
).Scan(
&item.ID,
&item.Status,
&item.RetryCount,
&item.CreatedAt,
&item.ProcessedAt,
&item.NextRetryAt,
&item.LastError,
&item.ErrorDetails,
)
if err != nil {
logger.Logger.Error("Failed to enqueue email",
"error", err.Error(),
"template", input.Template)
return nil, fmt.Errorf("failed to enqueue email: %w", err)
}
logger.Logger.Info("Email enqueued successfully",
"id", item.ID,
"template", input.Template,
"priority", input.Priority)
return item, nil
}
// GetNextToProcess fetches the next email(s) to process from the queue
func (r *EmailQueueRepository) GetNextToProcess(ctx context.Context, limit int) ([]*models.EmailQueueItem, error) {
query := `
UPDATE email_queue
SET status = 'processing'
WHERE id IN (
SELECT id FROM email_queue
WHERE status = 'pending'
AND scheduled_for <= $1
ORDER BY priority DESC, scheduled_for ASC
LIMIT $2
FOR UPDATE SKIP LOCKED
)
RETURNING
id, to_addresses, cc_addresses, bcc_addresses,
subject, template, locale, data, headers,
status, priority, retry_count, max_retries,
created_at, scheduled_for, processed_at, next_retry_at,
last_error, error_details, reference_type, reference_id, created_by
`
rows, err := r.db.QueryContext(ctx, query, time.Now(), limit)
if err != nil {
return nil, fmt.Errorf("failed to get next emails to process: %w", err)
}
defer rows.Close()
var items []*models.EmailQueueItem
for rows.Next() {
item := &models.EmailQueueItem{}
err := rows.Scan(
&item.ID,
pq.Array(&item.ToAddresses),
pq.Array(&item.CcAddresses),
pq.Array(&item.BccAddresses),
&item.Subject,
&item.Template,
&item.Locale,
&item.Data,
&item.Headers,
&item.Status,
&item.Priority,
&item.RetryCount,
&item.MaxRetries,
&item.CreatedAt,
&item.ScheduledFor,
&item.ProcessedAt,
&item.NextRetryAt,
&item.LastError,
&item.ErrorDetails,
&item.ReferenceType,
&item.ReferenceID,
&item.CreatedBy,
)
if err != nil {
return nil, fmt.Errorf("failed to scan email queue item: %w", err)
}
items = append(items, item)
}
return items, nil
}
// MarkAsSent marks an email as successfully sent
func (r *EmailQueueRepository) MarkAsSent(ctx context.Context, id int64) error {
query := `
UPDATE email_queue
SET status = 'sent',
processed_at = $1
WHERE id = $2
`
result, err := r.db.ExecContext(ctx, query, time.Now(), id)
if err != nil {
return fmt.Errorf("failed to mark email as sent: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get rows affected: %w", err)
}
if rowsAffected == 0 {
return fmt.Errorf("email not found: %d", id)
}
logger.Logger.Debug("Email marked as sent", "id", id)
return nil
}
// MarkAsFailed marks an email as failed with error details
func (r *EmailQueueRepository) MarkAsFailed(ctx context.Context, id int64, err error, shouldRetry bool) error {
errorMsg := err.Error()
errorDetails := map[string]interface{}{
"error": errorMsg,
"timestamp": time.Now().Format(time.RFC3339),
"should_retry": shouldRetry,
}
errorDetailsJSON, _ := json.Marshal(errorDetails)
var query string
var args []interface{}
if shouldRetry {
// If retrying, increment retry count and calculate next retry time
query = `
UPDATE email_queue
SET status = 'pending',
retry_count = retry_count + 1,
last_error = $1,
error_details = $2,
scheduled_for = calculate_next_retry_time(retry_count + 1)
WHERE id = $3 AND retry_count < max_retries
`
args = []interface{}{errorMsg, errorDetailsJSON, id}
} else {
// If not retrying, mark as failed
query = `
UPDATE email_queue
SET status = 'failed',
processed_at = $1,
last_error = $2,
error_details = $3
WHERE id = $4
`
args = []interface{}{time.Now(), errorMsg, errorDetailsJSON, id}
}
result, err := r.db.ExecContext(ctx, query, args...)
if err != nil {
return fmt.Errorf("failed to mark email as failed: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get rows affected: %w", err)
}
if rowsAffected == 0 && shouldRetry {
// Max retries reached, mark as permanently failed
query = `
UPDATE email_queue
SET status = 'failed',
processed_at = $1,
last_error = $2,
error_details = $3
WHERE id = $4
`
_, err = r.db.ExecContext(ctx, query, time.Now(), errorMsg, errorDetailsJSON, id)
if err != nil {
return fmt.Errorf("failed to mark email as permanently failed: %w", err)
}
logger.Logger.Warn("Email max retries reached, marked as failed", "id", id)
}
logger.Logger.Debug("Email marked as failed", "id", id, "should_retry", shouldRetry)
return nil
}
// GetRetryableEmails fetches emails that should be retried
func (r *EmailQueueRepository) GetRetryableEmails(ctx context.Context, limit int) ([]*models.EmailQueueItem, error) {
query := `
UPDATE email_queue
SET status = 'processing'
WHERE id IN (
SELECT id FROM email_queue
WHERE status = 'pending'
AND retry_count > 0
AND retry_count < max_retries
AND scheduled_for <= $1
ORDER BY priority DESC, scheduled_for ASC
LIMIT $2
FOR UPDATE SKIP LOCKED
)
RETURNING
id, to_addresses, cc_addresses, bcc_addresses,
subject, template, locale, data, headers,
status, priority, retry_count, max_retries,
created_at, scheduled_for, processed_at, next_retry_at,
last_error, error_details, reference_type, reference_id, created_by
`
rows, err := r.db.QueryContext(ctx, query, time.Now(), limit)
if err != nil {
return nil, fmt.Errorf("failed to get retryable emails: %w", err)
}
defer rows.Close()
var items []*models.EmailQueueItem
for rows.Next() {
item := &models.EmailQueueItem{}
err := rows.Scan(
&item.ID,
pq.Array(&item.ToAddresses),
pq.Array(&item.CcAddresses),
pq.Array(&item.BccAddresses),
&item.Subject,
&item.Template,
&item.Locale,
&item.Data,
&item.Headers,
&item.Status,
&item.Priority,
&item.RetryCount,
&item.MaxRetries,
&item.CreatedAt,
&item.ScheduledFor,
&item.ProcessedAt,
&item.NextRetryAt,
&item.LastError,
&item.ErrorDetails,
&item.ReferenceType,
&item.ReferenceID,
&item.CreatedBy,
)
if err != nil {
return nil, fmt.Errorf("failed to scan email queue item: %w", err)
}
items = append(items, item)
}
return items, nil
}
// GetQueueStats returns statistics about the email queue
func (r *EmailQueueRepository) GetQueueStats(ctx context.Context) (*models.EmailQueueStats, error) {
stats := &models.EmailQueueStats{
ByStatus: make(map[string]int),
ByPriority: make(map[string]int),
}
// Get counts by status
statusQuery := `
SELECT status, COUNT(*)
FROM email_queue
GROUP BY status
`
rows, err := r.db.QueryContext(ctx, statusQuery)
if err != nil {
return nil, fmt.Errorf("failed to get status counts: %w", err)
}
defer rows.Close()
for rows.Next() {
var status string
var count int
if err := rows.Scan(&status, &count); err != nil {
return nil, fmt.Errorf("failed to scan status count: %w", err)
}
stats.ByStatus[status] = count
switch models.EmailQueueStatus(status) {
case models.EmailStatusPending:
stats.TotalPending = count
case models.EmailStatusProcessing:
stats.TotalProcessing = count
case models.EmailStatusSent:
stats.TotalSent = count
case models.EmailStatusFailed:
stats.TotalFailed = count
}
}
// Get oldest pending email
var oldestPending sql.NullTime
err = r.db.QueryRowContext(ctx, `
SELECT MIN(created_at)
FROM email_queue
WHERE status = 'pending'
`).Scan(&oldestPending)
if err != nil && err != sql.ErrNoRows {
return nil, fmt.Errorf("failed to get oldest pending: %w", err)
}
if oldestPending.Valid {
stats.OldestPending = &oldestPending.Time
}
// Get average retry count
err = r.db.QueryRowContext(ctx, `
SELECT AVG(retry_count)::float
FROM email_queue
WHERE status IN ('sent', 'failed')
`).Scan(&stats.AverageRetries)
if err != nil && err != sql.ErrNoRows {
return nil, fmt.Errorf("failed to get average retries: %w", err)
}
// Get last 24 hours stats
err = r.db.QueryRowContext(ctx, `
SELECT
COUNT(*) FILTER (WHERE status = 'sent' AND processed_at >= NOW() - INTERVAL '24 hours') as sent,
COUNT(*) FILTER (WHERE status = 'failed' AND processed_at >= NOW() - INTERVAL '24 hours') as failed,
COUNT(*) FILTER (WHERE created_at >= NOW() - INTERVAL '24 hours') as queued
FROM email_queue
`).Scan(&stats.Last24Hours.Sent, &stats.Last24Hours.Failed, &stats.Last24Hours.Queued)
if err != nil {
return nil, fmt.Errorf("failed to get 24h stats: %w", err)
}
return stats, nil
}
// CancelEmail cancels a pending email
func (r *EmailQueueRepository) CancelEmail(ctx context.Context, id int64) error {
query := `
UPDATE email_queue
SET status = 'cancelled',
processed_at = $1
WHERE id = $2 AND status IN ('pending', 'processing')
`
result, err := r.db.ExecContext(ctx, query, time.Now(), id)
if err != nil {
return fmt.Errorf("failed to cancel email: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get rows affected: %w", err)
}
if rowsAffected == 0 {
return fmt.Errorf("email not found or already processed: %d", id)
}
logger.Logger.Info("Email cancelled", "id", id)
return nil
}
// CleanupOldEmails removes old processed emails from the queue
func (r *EmailQueueRepository) CleanupOldEmails(ctx context.Context, olderThan time.Duration) (int64, error) {
query := `
DELETE FROM email_queue
WHERE status IN ('sent', 'failed', 'cancelled')
AND processed_at < $1
`
cutoff := time.Now().Add(-olderThan)
result, err := r.db.ExecContext(ctx, query, cutoff)
if err != nil {
return 0, fmt.Errorf("failed to cleanup old emails: %w", err)
}
deleted, err := result.RowsAffected()
if err != nil {
return 0, fmt.Errorf("failed to get deleted count: %w", err)
}
if deleted > 0 {
logger.Logger.Info("Old emails cleaned up", "count", deleted, "older_than", olderThan)
}
return deleted, nil
}