From 249849b3ed83ff8b82db5b1f62fd6727484d357a Mon Sep 17 00:00:00 2001 From: Benjamin Date: Wed, 3 Dec 2025 21:26:31 +0100 Subject: [PATCH] feat(tenant): add tenant support - Add instance_metadata table with unique UUID per instance - Add tenant_id column to all business tables (documents, signatures, expected_signers, webhooks, reminder_logs, email_queue, checksum_verifications, webhook_deliveries) - Backfill existing data with instance tenant UUID - Create TenantProvider interface and SingleTenantProvider implementation - Update all repositories to filter by tenant_id - Add immutability triggers to prevent tenant_id modification after creation Migration 0015 includes: - Schema changes with indexes for tenant_id columns - SQL backfill for existing data - Trigger functions for data integrity --- .../domain/models/checksum_verification.go | 7 +- backend/internal/domain/models/document.go | 7 +- backend/internal/domain/models/email_queue.go | 3 + .../internal/domain/models/expected_signer.go | 21 ++- .../internal/domain/models/reminder_log.go | 7 +- backend/internal/domain/models/signature.go | 2 + backend/internal/domain/models/tenant.go | 7 + backend/internal/domain/models/webhook.go | 6 +- .../database/document_repository.go | 159 +++++++++++----- .../database/email_queue_repository.go | 22 ++- .../database/expected_signer_repository.go | 90 ++++++--- .../database/reminder_repository.go | 69 +++++-- .../database/signature_repository.go | 105 ++++++++--- .../database/webhook_delivery_repository.go | 39 ++-- .../database/webhook_repository.go | 93 ++++++--- .../infrastructure/tenant/provider.go | 41 ++++ .../0015_add_tenant_support.down.sql | 42 +++++ .../migrations/0015_add_tenant_support.up.sql | 176 ++++++++++++++++++ backend/pkg/web/server.go | 26 ++- go.mod | 1 + go.sum | 2 + 21 files changed, 738 insertions(+), 187 deletions(-) create mode 100644 backend/internal/domain/models/tenant.go create mode 100644 backend/internal/infrastructure/tenant/provider.go create mode 100644 backend/migrations/0015_add_tenant_support.down.sql create mode 100644 backend/migrations/0015_add_tenant_support.up.sql diff --git a/backend/internal/domain/models/checksum_verification.go b/backend/internal/domain/models/checksum_verification.go index 00c126b..fedd813 100644 --- a/backend/internal/domain/models/checksum_verification.go +++ b/backend/internal/domain/models/checksum_verification.go @@ -1,11 +1,16 @@ // SPDX-License-Identifier: AGPL-3.0-or-later package models -import "time" +import ( + "time" + + "github.com/google/uuid" +) // ChecksumVerification represents a verification attempt of a document's checksum type ChecksumVerification struct { ID int64 `json:"id" db:"id"` + TenantID uuid.UUID `json:"tenant_id" db:"tenant_id"` DocID string `json:"doc_id" db:"doc_id"` VerifiedBy string `json:"verified_by" db:"verified_by"` VerifiedAt time.Time `json:"verified_at" db:"verified_at"` diff --git a/backend/internal/domain/models/document.go b/backend/internal/domain/models/document.go index 836ab90..0fefe08 100644 --- a/backend/internal/domain/models/document.go +++ b/backend/internal/domain/models/document.go @@ -1,11 +1,16 @@ // SPDX-License-Identifier: AGPL-3.0-or-later package models -import "time" +import ( + "time" + + "github.com/google/uuid" +) // Document represents document metadata for tracking and integrity verification type Document struct { DocID string `json:"doc_id" db:"doc_id"` + TenantID uuid.UUID `json:"tenant_id" db:"tenant_id"` Title string `json:"title" db:"title"` URL string `json:"url" db:"url"` Checksum string `json:"checksum" db:"checksum"` diff --git a/backend/internal/domain/models/email_queue.go b/backend/internal/domain/models/email_queue.go index d20fdec..7de14df 100644 --- a/backend/internal/domain/models/email_queue.go +++ b/backend/internal/domain/models/email_queue.go @@ -5,6 +5,8 @@ import ( "database/sql/driver" "encoding/json" "time" + + "github.com/google/uuid" ) // EmailQueueStatus represents the status of an email in the queue @@ -30,6 +32,7 @@ const ( // EmailQueueItem represents an email in the processing queue type EmailQueueItem struct { ID int64 `json:"id"` + TenantID uuid.UUID `json:"tenant_id" db:"tenant_id"` ToAddresses []string `json:"to_addresses"` CcAddresses []string `json:"cc_addresses,omitempty"` BccAddresses []string `json:"bcc_addresses,omitempty"` diff --git a/backend/internal/domain/models/expected_signer.go b/backend/internal/domain/models/expected_signer.go index 030c7ec..39c1a34 100644 --- a/backend/internal/domain/models/expected_signer.go +++ b/backend/internal/domain/models/expected_signer.go @@ -1,17 +1,22 @@ // SPDX-License-Identifier: AGPL-3.0-or-later package models -import "time" +import ( + "time" + + "github.com/google/uuid" +) // 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"` - Name string `json:"name" db:"name"` - AddedAt time.Time `json:"added_at" db:"added_at"` - AddedBy string `json:"added_by" db:"added_by"` - Notes *string `json:"notes,omitempty" db:"notes"` + ID int64 `json:"id" db:"id"` + TenantID uuid.UUID `json:"tenant_id" db:"tenant_id"` + DocID string `json:"doc_id" db:"doc_id"` + Email string `json:"email" db:"email"` + Name string `json:"name" db:"name"` + 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 diff --git a/backend/internal/domain/models/reminder_log.go b/backend/internal/domain/models/reminder_log.go index d4be510..26c1f1d 100644 --- a/backend/internal/domain/models/reminder_log.go +++ b/backend/internal/domain/models/reminder_log.go @@ -1,11 +1,16 @@ // SPDX-License-Identifier: AGPL-3.0-or-later package models -import "time" +import ( + "time" + + "github.com/google/uuid" +) // ReminderLog represents a log entry for an email reminder sent to a signer type ReminderLog struct { ID int64 `json:"id" db:"id"` + TenantID uuid.UUID `json:"tenant_id" db:"tenant_id"` DocID string `json:"doc_id" db:"doc_id"` RecipientEmail string `json:"recipient_email" db:"recipient_email"` SentAt time.Time `json:"sent_at" db:"sent_at"` diff --git a/backend/internal/domain/models/signature.go b/backend/internal/domain/models/signature.go index 45831ef..184fcb0 100644 --- a/backend/internal/domain/models/signature.go +++ b/backend/internal/domain/models/signature.go @@ -9,10 +9,12 @@ import ( "time" "github.com/btouchard/ackify-ce/backend/pkg/services" + "github.com/google/uuid" ) type Signature struct { ID int64 `json:"id" db:"id"` + TenantID uuid.UUID `json:"tenant_id" db:"tenant_id"` DocID string `json:"doc_id" db:"doc_id"` UserSub string `json:"user_sub" db:"user_sub"` UserEmail string `json:"user_email" db:"user_email"` diff --git a/backend/internal/domain/models/tenant.go b/backend/internal/domain/models/tenant.go new file mode 100644 index 0000000..054eadd --- /dev/null +++ b/backend/internal/domain/models/tenant.go @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +package models + +import "github.com/google/uuid" + +// TenantID is an alias for uuid.UUID representing a tenant identifier. +type TenantID = uuid.UUID diff --git a/backend/internal/domain/models/webhook.go b/backend/internal/domain/models/webhook.go index b835fd6..3259c8a 100644 --- a/backend/internal/domain/models/webhook.go +++ b/backend/internal/domain/models/webhook.go @@ -4,11 +4,14 @@ package models import ( "encoding/json" "time" + + "github.com/google/uuid" ) type Webhook struct { - Title string `json:"title"` ID int64 `json:"id"` + TenantID uuid.UUID `json:"tenant_id" db:"tenant_id"` + Title string `json:"title"` TargetURL string `json:"targetUrl"` Secret string `json:"-"` Active bool `json:"active"` @@ -48,6 +51,7 @@ const ( type WebhookDelivery struct { ID int64 `json:"id"` + TenantID uuid.UUID `json:"tenant_id" db:"tenant_id"` WebhookID int64 `json:"webhookId"` EventType string `json:"eventType"` EventID string `json:"eventId"` diff --git a/backend/internal/infrastructure/database/document_repository.go b/backend/internal/infrastructure/database/document_repository.go index e12e2f4..a4c56bd 100644 --- a/backend/internal/infrastructure/database/document_repository.go +++ b/backend/internal/infrastructure/database/document_repository.go @@ -7,25 +7,32 @@ import ( "fmt" "github.com/btouchard/ackify-ce/backend/internal/domain/models" + "github.com/btouchard/ackify-ce/backend/internal/infrastructure/tenant" "github.com/btouchard/ackify-ce/backend/pkg/logger" ) // DocumentRepository handles document metadata persistence type DocumentRepository struct { - db *sql.DB + db *sql.DB + tenants tenant.Provider } // NewDocumentRepository creates a new DocumentRepository -func NewDocumentRepository(db *sql.DB) *DocumentRepository { - return &DocumentRepository{db: db} +func NewDocumentRepository(db *sql.DB, tenants tenant.Provider) *DocumentRepository { + return &DocumentRepository{db: db, tenants: tenants} } // Create persists a new document with metadata including optional checksum validation data func (r *DocumentRepository) Create(ctx context.Context, docID string, input models.DocumentInput, createdBy string) (*models.Document, error) { + tenantID, err := r.tenants.CurrentTenant(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get tenant: %w", err) + } + query := ` - INSERT INTO documents (doc_id, title, url, checksum, checksum_algorithm, description, created_by) - VALUES ($1, $2, $3, $4, $5, $6, $7) - RETURNING doc_id, title, url, checksum, checksum_algorithm, description, created_at, updated_at, created_by, deleted_at + INSERT INTO documents (tenant_id, doc_id, title, url, checksum, checksum_algorithm, description, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING doc_id, tenant_id, title, url, checksum, checksum_algorithm, description, created_at, updated_at, created_by, deleted_at ` // Use NULL for empty checksum fields to avoid constraint violation @@ -39,9 +46,10 @@ func (r *DocumentRepository) Create(ctx context.Context, docID string, input mod } doc := &models.Document{} - err := r.db.QueryRowContext( + err = r.db.QueryRowContext( ctx, query, + tenantID, docID, input.Title, input.URL, @@ -51,6 +59,7 @@ func (r *DocumentRepository) Create(ctx context.Context, docID string, input mod createdBy, ).Scan( &doc.DocID, + &doc.TenantID, &doc.Title, &doc.URL, &doc.Checksum, @@ -72,15 +81,21 @@ func (r *DocumentRepository) Create(ctx context.Context, docID string, input mod // GetByDocID retrieves document metadata by document ID (excluding soft-deleted documents) func (r *DocumentRepository) GetByDocID(ctx context.Context, docID string) (*models.Document, error) { + tenantID, err := r.tenants.CurrentTenant(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get tenant: %w", err) + } + query := ` - SELECT doc_id, title, url, checksum, checksum_algorithm, description, created_at, updated_at, created_by, deleted_at + SELECT doc_id, tenant_id, title, url, checksum, checksum_algorithm, description, created_at, updated_at, created_by, deleted_at FROM documents - WHERE doc_id = $1 AND deleted_at IS NULL + WHERE tenant_id = $1 AND doc_id = $2 AND deleted_at IS NULL ` doc := &models.Document{} - err := r.db.QueryRowContext(ctx, query, docID).Scan( + err = r.db.QueryRowContext(ctx, query, tenantID, docID).Scan( &doc.DocID, + &doc.TenantID, &doc.Title, &doc.URL, &doc.Checksum, @@ -106,6 +121,11 @@ func (r *DocumentRepository) GetByDocID(ctx context.Context, docID string) (*mod // FindByReference searches for a document by reference (URL, path, or doc_id) func (r *DocumentRepository) FindByReference(ctx context.Context, ref string, refType string) (*models.Document, error) { + tenantID, err := r.tenants.CurrentTenant(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get tenant: %w", err) + } + var query string var args []interface{} @@ -113,40 +133,41 @@ func (r *DocumentRepository) FindByReference(ctx context.Context, ref string, re case "url": // Search by URL field (excluding soft-deleted) query = ` - SELECT doc_id, title, url, checksum, checksum_algorithm, description, created_at, updated_at, created_by, deleted_at + SELECT doc_id, tenant_id, title, url, checksum, checksum_algorithm, description, created_at, updated_at, created_by, deleted_at FROM documents - WHERE url = $1 AND deleted_at IS NULL + WHERE tenant_id = $1 AND url = $2 AND deleted_at IS NULL LIMIT 1 ` - args = []interface{}{ref} + args = []interface{}{tenantID, ref} case "path": // Search by URL field (paths are also stored in url field, excluding soft-deleted) query = ` - SELECT doc_id, title, url, checksum, checksum_algorithm, description, created_at, updated_at, created_by, deleted_at + SELECT doc_id, tenant_id, title, url, checksum, checksum_algorithm, description, created_at, updated_at, created_by, deleted_at FROM documents - WHERE url = $1 AND deleted_at IS NULL + WHERE tenant_id = $1 AND url = $2 AND deleted_at IS NULL LIMIT 1 ` - args = []interface{}{ref} + args = []interface{}{tenantID, ref} case "reference": // Search by doc_id (excluding soft-deleted) query = ` - SELECT doc_id, title, url, checksum, checksum_algorithm, description, created_at, updated_at, created_by, deleted_at + SELECT doc_id, tenant_id, title, url, checksum, checksum_algorithm, description, created_at, updated_at, created_by, deleted_at FROM documents - WHERE doc_id = $1 AND deleted_at IS NULL + WHERE tenant_id = $1 AND doc_id = $2 AND deleted_at IS NULL LIMIT 1 ` - args = []interface{}{ref} + args = []interface{}{tenantID, ref} default: return nil, fmt.Errorf("unknown reference type: %s", refType) } doc := &models.Document{} - err := r.db.QueryRowContext(ctx, query, args...).Scan( + err = r.db.QueryRowContext(ctx, query, args...).Scan( &doc.DocID, + &doc.TenantID, &doc.Title, &doc.URL, &doc.Checksum, @@ -183,11 +204,16 @@ func (r *DocumentRepository) FindByReference(ctx context.Context, ref string, re // Update modifies existing document metadata while preserving creation timestamp and creator func (r *DocumentRepository) Update(ctx context.Context, docID string, input models.DocumentInput) (*models.Document, error) { + tenantID, err := r.tenants.CurrentTenant(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get tenant: %w", err) + } + query := ` UPDATE documents - SET title = $2, url = $3, checksum = $4, checksum_algorithm = $5, description = $6 - WHERE doc_id = $1 AND deleted_at IS NULL - RETURNING doc_id, title, url, checksum, checksum_algorithm, description, created_at, updated_at, created_by, deleted_at + SET title = $3, url = $4, checksum = $5, checksum_algorithm = $6, description = $7 + WHERE tenant_id = $1 AND doc_id = $2 AND deleted_at IS NULL + RETURNING doc_id, tenant_id, title, url, checksum, checksum_algorithm, description, created_at, updated_at, created_by, deleted_at ` // Use empty string for empty checksum fields (table has NOT NULL DEFAULT '') @@ -198,9 +224,10 @@ func (r *DocumentRepository) Update(ctx context.Context, docID string, input mod } doc := &models.Document{} - err := r.db.QueryRowContext( + err = r.db.QueryRowContext( ctx, query, + tenantID, docID, input.Title, input.URL, @@ -209,6 +236,7 @@ func (r *DocumentRepository) Update(ctx context.Context, docID string, input mod input.Description, ).Scan( &doc.DocID, + &doc.TenantID, &doc.Title, &doc.URL, &doc.Checksum, @@ -234,9 +262,14 @@ func (r *DocumentRepository) Update(ctx context.Context, docID string, input mod // CreateOrUpdate performs upsert operation, creating new document or updating existing one atomically func (r *DocumentRepository) CreateOrUpdate(ctx context.Context, docID string, input models.DocumentInput, createdBy string) (*models.Document, error) { + tenantID, err := r.tenants.CurrentTenant(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get tenant: %w", err) + } + query := ` - INSERT INTO documents (doc_id, title, url, checksum, checksum_algorithm, description, created_by) - VALUES ($1, $2, $3, $4, $5, $6, $7) + INSERT INTO documents (tenant_id, doc_id, title, url, checksum, checksum_algorithm, description, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT (doc_id) DO UPDATE SET title = EXCLUDED.title, url = EXCLUDED.url, @@ -244,7 +277,7 @@ func (r *DocumentRepository) CreateOrUpdate(ctx context.Context, docID string, i checksum_algorithm = EXCLUDED.checksum_algorithm, description = EXCLUDED.description, deleted_at = NULL - RETURNING doc_id, title, url, checksum, checksum_algorithm, description, created_at, updated_at, created_by, deleted_at + RETURNING doc_id, tenant_id, title, url, checksum, checksum_algorithm, description, created_at, updated_at, created_by, deleted_at ` // Use empty string for empty checksum fields (table has NOT NULL DEFAULT '') @@ -255,9 +288,10 @@ func (r *DocumentRepository) CreateOrUpdate(ctx context.Context, docID string, i } doc := &models.Document{} - err := r.db.QueryRowContext( + err = r.db.QueryRowContext( ctx, query, + tenantID, docID, input.Title, input.URL, @@ -267,6 +301,7 @@ func (r *DocumentRepository) CreateOrUpdate(ctx context.Context, docID string, i createdBy, ).Scan( &doc.DocID, + &doc.TenantID, &doc.Title, &doc.URL, &doc.Checksum, @@ -288,9 +323,14 @@ func (r *DocumentRepository) CreateOrUpdate(ctx context.Context, docID string, i // Delete soft-deletes document by setting deleted_at timestamp, preserving metadata and signature history func (r *DocumentRepository) Delete(ctx context.Context, docID string) error { - query := `UPDATE documents SET deleted_at = now() WHERE doc_id = $1 AND deleted_at IS NULL` + tenantID, err := r.tenants.CurrentTenant(ctx) + if err != nil { + return fmt.Errorf("failed to get tenant: %w", err) + } - result, err := r.db.ExecContext(ctx, query, docID) + query := `UPDATE documents SET deleted_at = now() WHERE tenant_id = $1 AND doc_id = $2 AND deleted_at IS NULL` + + result, err := r.db.ExecContext(ctx, query, tenantID, docID) if err != nil { logger.Logger.Error("Failed to delete document", "error", err.Error(), "doc_id", docID) return fmt.Errorf("failed to delete document: %w", err) @@ -310,15 +350,20 @@ func (r *DocumentRepository) Delete(ctx context.Context, docID string) error { // List retrieves paginated documents ordered by creation date, newest first (excluding soft-deleted) func (r *DocumentRepository) List(ctx context.Context, limit, offset int) ([]*models.Document, error) { + tenantID, err := r.tenants.CurrentTenant(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get tenant: %w", err) + } + query := ` - SELECT doc_id, title, url, checksum, checksum_algorithm, description, created_at, updated_at, created_by, deleted_at + SELECT doc_id, tenant_id, title, url, checksum, checksum_algorithm, description, created_at, updated_at, created_by, deleted_at FROM documents - WHERE deleted_at IS NULL + WHERE tenant_id = $1 AND deleted_at IS NULL ORDER BY created_at DESC - LIMIT $1 OFFSET $2 + LIMIT $2 OFFSET $3 ` - rows, err := r.db.QueryContext(ctx, query, limit, offset) + rows, err := r.db.QueryContext(ctx, query, tenantID, limit, offset) if err != nil { logger.Logger.Error("Failed to list documents", "error", err.Error()) return nil, fmt.Errorf("failed to list documents: %w", err) @@ -330,6 +375,7 @@ func (r *DocumentRepository) List(ctx context.Context, limit, offset int) ([]*mo doc := &models.Document{} err := rows.Scan( &doc.DocID, + &doc.TenantID, &doc.Title, &doc.URL, &doc.Checksum, @@ -357,22 +403,27 @@ func (r *DocumentRepository) List(ctx context.Context, limit, offset int) ([]*mo // Search retrieves paginated documents matching the search query (excluding soft-deleted) // Searches in doc_id, title, url, and description fields using case-insensitive pattern matching func (r *DocumentRepository) Search(ctx context.Context, query string, limit, offset int) ([]*models.Document, error) { + tenantID, err := r.tenants.CurrentTenant(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get tenant: %w", err) + } + searchQuery := ` - SELECT doc_id, title, url, checksum, checksum_algorithm, description, created_at, updated_at, created_by, deleted_at + SELECT doc_id, tenant_id, title, url, checksum, checksum_algorithm, description, created_at, updated_at, created_by, deleted_at FROM documents - WHERE deleted_at IS NULL + WHERE tenant_id = $1 AND deleted_at IS NULL AND ( - doc_id ILIKE $1 - OR title ILIKE $1 - OR url ILIKE $1 - OR description ILIKE $1 + doc_id ILIKE $2 + OR title ILIKE $2 + OR url ILIKE $2 + OR description ILIKE $2 ) ORDER BY created_at DESC - LIMIT $2 OFFSET $3 + LIMIT $3 OFFSET $4 ` searchPattern := "%" + query + "%" - rows, err := r.db.QueryContext(ctx, searchQuery, searchPattern, limit, offset) + rows, err := r.db.QueryContext(ctx, searchQuery, tenantID, searchPattern, limit, offset) if err != nil { logger.Logger.Error("Failed to search documents", "error", err.Error(), "query", query) return nil, fmt.Errorf("failed to search documents: %w", err) @@ -384,6 +435,7 @@ func (r *DocumentRepository) Search(ctx context.Context, query string, limit, of doc := &models.Document{} err := rows.Scan( &doc.DocID, + &doc.TenantID, &doc.Title, &doc.URL, &doc.Checksum, @@ -416,6 +468,11 @@ func (r *DocumentRepository) Search(ctx context.Context, query string, limit, of // Count returns the total number of documents matching the optional search query (excluding soft-deleted) func (r *DocumentRepository) Count(ctx context.Context, searchQuery string) (int, error) { + tenantID, err := r.tenants.CurrentTenant(ctx) + if err != nil { + return 0, fmt.Errorf("failed to get tenant: %w", err) + } + var query string var args []interface{} @@ -424,28 +481,28 @@ func (r *DocumentRepository) Count(ctx context.Context, searchQuery string) (int query = ` SELECT COUNT(*) FROM documents - WHERE deleted_at IS NULL + WHERE tenant_id = $1 AND deleted_at IS NULL AND ( - doc_id ILIKE $1 - OR title ILIKE $1 - OR url ILIKE $1 - OR description ILIKE $1 + doc_id ILIKE $2 + OR title ILIKE $2 + OR url ILIKE $2 + OR description ILIKE $2 ) ` searchPattern := "%" + searchQuery + "%" - args = []interface{}{searchPattern} + args = []interface{}{tenantID, searchPattern} } else { // Count all documents query = ` SELECT COUNT(*) FROM documents - WHERE deleted_at IS NULL + WHERE tenant_id = $1 AND deleted_at IS NULL ` - args = []interface{}{} + args = []interface{}{tenantID} } var count int - err := r.db.QueryRowContext(ctx, query, args...).Scan(&count) + err = r.db.QueryRowContext(ctx, query, args...).Scan(&count) if err != nil { logger.Logger.Error("Failed to count documents", "error", err.Error(), "search", searchQuery) return 0, fmt.Errorf("failed to count documents: %w", err) diff --git a/backend/internal/infrastructure/database/email_queue_repository.go b/backend/internal/infrastructure/database/email_queue_repository.go index b495a4c..068ae2a 100644 --- a/backend/internal/infrastructure/database/email_queue_repository.go +++ b/backend/internal/infrastructure/database/email_queue_repository.go @@ -11,21 +11,28 @@ import ( "github.com/lib/pq" "github.com/btouchard/ackify-ce/backend/internal/domain/models" + "github.com/btouchard/ackify-ce/backend/internal/infrastructure/tenant" "github.com/btouchard/ackify-ce/backend/pkg/logger" ) // EmailQueueRepository handles database operations for the email queue type EmailQueueRepository struct { - db *sql.DB + db *sql.DB + tenants tenant.Provider } // NewEmailQueueRepository creates a new email queue repository -func NewEmailQueueRepository(db *sql.DB) *EmailQueueRepository { - return &EmailQueueRepository{db: db} +func NewEmailQueueRepository(db *sql.DB, tenants tenant.Provider) *EmailQueueRepository { + return &EmailQueueRepository{db: db, tenants: tenants} } // Enqueue adds a new email to the queue func (r *EmailQueueRepository) Enqueue(ctx context.Context, input models.EmailQueueInput) (*models.EmailQueueItem, error) { + tenantID, err := r.tenants.CurrentTenant(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get tenant: %w", err) + } + // Prepare data as JSON dataJSON, err := json.Marshal(input.Data) if err != nil { @@ -56,18 +63,19 @@ func (r *EmailQueueRepository) Enqueue(ctx context.Context, input models.EmailQu query := ` INSERT INTO email_queue ( - to_addresses, cc_addresses, bcc_addresses, + tenant_id, 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 + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15 ) RETURNING - id, status, retry_count, created_at, processed_at, + id, tenant_id, status, retry_count, created_at, processed_at, next_retry_at, last_error, error_details ` item := &models.EmailQueueItem{ + TenantID: tenantID, ToAddresses: input.ToAddresses, CcAddresses: input.CcAddresses, BccAddresses: input.BccAddresses, @@ -87,6 +95,7 @@ func (r *EmailQueueRepository) Enqueue(ctx context.Context, input models.EmailQu err = r.db.QueryRowContext( ctx, query, + tenantID, pq.Array(input.ToAddresses), pq.Array(input.CcAddresses), pq.Array(input.BccAddresses), @@ -103,6 +112,7 @@ func (r *EmailQueueRepository) Enqueue(ctx context.Context, input models.EmailQu input.CreatedBy, ).Scan( &item.ID, + &item.TenantID, &item.Status, &item.RetryCount, &item.CreatedAt, diff --git a/backend/internal/infrastructure/database/expected_signer_repository.go b/backend/internal/infrastructure/database/expected_signer_repository.go index 004cd7d..111b0cf 100644 --- a/backend/internal/infrastructure/database/expected_signer_repository.go +++ b/backend/internal/infrastructure/database/expected_signer_repository.go @@ -8,17 +8,19 @@ import ( "strings" "github.com/btouchard/ackify-ce/backend/internal/domain/models" + "github.com/btouchard/ackify-ce/backend/internal/infrastructure/tenant" "github.com/btouchard/ackify-ce/backend/pkg/logger" ) // ExpectedSignerRepository handles database operations for expected signers type ExpectedSignerRepository struct { - db *sql.DB + db *sql.DB + tenants tenant.Provider } // NewExpectedSignerRepository creates a new expected signer repository -func NewExpectedSignerRepository(db *sql.DB) *ExpectedSignerRepository { - return &ExpectedSignerRepository{db: db} +func NewExpectedSignerRepository(db *sql.DB, tenants tenant.Provider) *ExpectedSignerRepository { + return &ExpectedSignerRepository{db: db, tenants: tenants} } // AddExpected batch-inserts multiple expected signers with conflict-safe deduplication on (doc_id, email) @@ -27,22 +29,27 @@ func (r *ExpectedSignerRepository) AddExpected(ctx context.Context, docID string return nil } + tenantID, err := r.tenants.CurrentTenant(ctx) + if err != nil { + return fmt.Errorf("failed to get tenant: %w", err) + } + // Build batch INSERT with ON CONFLICT DO NOTHING valueStrings := make([]string, 0, len(contacts)) - valueArgs := make([]interface{}, 0, len(contacts)*4) + valueArgs := make([]interface{}, 0, len(contacts)*5) for i, contact := range contacts { - valueStrings = append(valueStrings, fmt.Sprintf("($%d, $%d, $%d, $%d)", i*4+1, i*4+2, i*4+3, i*4+4)) - valueArgs = append(valueArgs, docID, contact.Email, contact.Name, addedBy) + valueStrings = append(valueStrings, fmt.Sprintf("($%d, $%d, $%d, $%d, $%d)", i*5+1, i*5+2, i*5+3, i*5+4, i*5+5)) + valueArgs = append(valueArgs, tenantID, docID, contact.Email, contact.Name, addedBy) } query := fmt.Sprintf(` - INSERT INTO expected_signers (doc_id, email, name, added_by) + INSERT INTO expected_signers (tenant_id, doc_id, email, name, added_by) VALUES %s ON CONFLICT (doc_id, email) DO NOTHING `, strings.Join(valueStrings, ",")) - _, err := r.db.ExecContext(ctx, query, valueArgs...) + _, err = r.db.ExecContext(ctx, query, valueArgs...) if err != nil { return fmt.Errorf("failed to add expected signers: %w", err) } @@ -52,14 +59,19 @@ func (r *ExpectedSignerRepository) AddExpected(ctx context.Context, docID string // ListByDocID retrieves all expected signers for a document, ordered chronologically by when they were added func (r *ExpectedSignerRepository) ListByDocID(ctx context.Context, docID string) ([]*models.ExpectedSigner, error) { + tenantID, err := r.tenants.CurrentTenant(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get tenant: %w", err) + } + query := ` - SELECT id, doc_id, email, name, added_at, added_by, notes + SELECT id, tenant_id, doc_id, email, name, added_at, added_by, notes FROM expected_signers - WHERE doc_id = $1 + WHERE tenant_id = $1 AND doc_id = $2 ORDER BY added_at ASC ` - rows, err := r.db.QueryContext(ctx, query, docID) + rows, err := r.db.QueryContext(ctx, query, tenantID, docID) if err != nil { return nil, fmt.Errorf("failed to query expected signers: %w", err) } @@ -75,6 +87,7 @@ func (r *ExpectedSignerRepository) ListByDocID(ctx context.Context, docID string signer := &models.ExpectedSigner{} err := rows.Scan( &signer.ID, + &signer.TenantID, &signer.DocID, &signer.Email, &signer.Name, @@ -93,9 +106,15 @@ func (r *ExpectedSignerRepository) ListByDocID(ctx context.Context, docID string // ListWithStatusByDocID enriches signer data with signature completion status and reminder tracking metrics func (r *ExpectedSignerRepository) ListWithStatusByDocID(ctx context.Context, docID string) ([]*models.ExpectedSignerWithStatus, error) { + tenantID, err := r.tenants.CurrentTenant(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get tenant: %w", err) + } + query := ` SELECT es.id, + es.tenant_id, es.doc_id, es.email, es.name, @@ -110,14 +129,14 @@ func (r *ExpectedSignerRepository) ListWithStatusByDocID(ctx context.Context, do EXTRACT(DAY FROM (NOW() - es.added_at))::int as days_since_added, EXTRACT(DAY FROM (NOW() - MAX(rl.sent_at)))::int as days_since_last_reminder FROM expected_signers es - LEFT JOIN signatures s ON es.doc_id = s.doc_id AND es.email = s.user_email - LEFT JOIN reminder_logs rl ON es.doc_id = rl.doc_id AND es.email = rl.recipient_email - WHERE es.doc_id = $1 - 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 + LEFT JOIN signatures s ON es.tenant_id = s.tenant_id AND es.doc_id = s.doc_id AND es.email = s.user_email + LEFT JOIN reminder_logs rl ON es.tenant_id = rl.tenant_id AND es.doc_id = rl.doc_id AND es.email = rl.recipient_email + WHERE es.tenant_id = $1 AND es.doc_id = $2 + GROUP BY es.id, es.tenant_id, es.doc_id, es.email, es.name, es.added_at, es.added_by, es.notes, s.id, s.signed_at, s.user_name ORDER BY has_signed DESC, es.added_at ASC ` - rows, err := r.db.QueryContext(ctx, query, docID) + rows, err := r.db.QueryContext(ctx, query, tenantID, docID) if err != nil { return nil, fmt.Errorf("failed to query expected signers with status: %w", err) } @@ -136,6 +155,7 @@ func (r *ExpectedSignerRepository) ListWithStatusByDocID(ctx context.Context, do err := rows.Scan( &signer.ID, + &signer.TenantID, &signer.DocID, &signer.Email, &signer.Name, @@ -171,12 +191,17 @@ func (r *ExpectedSignerRepository) ListWithStatusByDocID(ctx context.Context, do // Remove deletes a specific expected signer by document ID and email address func (r *ExpectedSignerRepository) Remove(ctx context.Context, docID, email string) error { + tenantID, err := r.tenants.CurrentTenant(ctx) + if err != nil { + return fmt.Errorf("failed to get tenant: %w", err) + } + query := ` DELETE FROM expected_signers - WHERE doc_id = $1 AND email = $2 + WHERE tenant_id = $1 AND doc_id = $2 AND email = $3 ` - result, err := r.db.ExecContext(ctx, query, docID, email) + result, err := r.db.ExecContext(ctx, query, tenantID, docID, email) if err != nil { return fmt.Errorf("failed to remove expected signer: %w", err) } @@ -195,12 +220,17 @@ func (r *ExpectedSignerRepository) Remove(ctx context.Context, docID, email stri // RemoveAllForDoc purges all expected signers associated with a document in a single operation func (r *ExpectedSignerRepository) RemoveAllForDoc(ctx context.Context, docID string) error { + tenantID, err := r.tenants.CurrentTenant(ctx) + if err != nil { + return fmt.Errorf("failed to get tenant: %w", err) + } + query := ` DELETE FROM expected_signers - WHERE doc_id = $1 + WHERE tenant_id = $1 AND doc_id = $2 ` - _, err := r.db.ExecContext(ctx, query, docID) + _, err = r.db.ExecContext(ctx, query, tenantID, docID) if err != nil { return fmt.Errorf("failed to remove all expected signers: %w", err) } @@ -210,15 +240,20 @@ func (r *ExpectedSignerRepository) RemoveAllForDoc(ctx context.Context, docID st // IsExpected efficiently verifies if an email address is in the expected signer list for a document func (r *ExpectedSignerRepository) IsExpected(ctx context.Context, docID, email string) (bool, error) { + tenantID, err := r.tenants.CurrentTenant(ctx) + if err != nil { + return false, fmt.Errorf("failed to get tenant: %w", err) + } + query := ` SELECT EXISTS( SELECT 1 FROM expected_signers - WHERE doc_id = $1 AND email = $2 + WHERE tenant_id = $1 AND doc_id = $2 AND email = $3 ) ` var exists bool - err := r.db.QueryRowContext(ctx, query, docID, email).Scan(&exists) + err = r.db.QueryRowContext(ctx, query, tenantID, docID, email).Scan(&exists) if err != nil { return false, fmt.Errorf("failed to check if email is expected: %w", err) } @@ -228,20 +263,25 @@ func (r *ExpectedSignerRepository) IsExpected(ctx context.Context, docID, email // GetStats calculates signature completion metrics including percentage progress for a document func (r *ExpectedSignerRepository) GetStats(ctx context.Context, docID string) (*models.DocCompletionStats, error) { + tenantID, err := r.tenants.CurrentTenant(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get tenant: %w", err) + } + 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 + LEFT JOIN signatures s ON es.tenant_id = s.tenant_id AND es.doc_id = s.doc_id AND es.email = s.user_email + WHERE es.tenant_id = $1 AND es.doc_id = $2 ` stats := &models.DocCompletionStats{ DocID: docID, } - err := r.db.QueryRowContext(ctx, query, docID).Scan(&stats.ExpectedCount, &stats.SignedCount) + err = r.db.QueryRowContext(ctx, query, tenantID, docID).Scan(&stats.ExpectedCount, &stats.SignedCount) if err != nil { return nil, fmt.Errorf("failed to get stats: %w", err) } diff --git a/backend/internal/infrastructure/database/reminder_repository.go b/backend/internal/infrastructure/database/reminder_repository.go index b28a1e5..8667b69 100644 --- a/backend/internal/infrastructure/database/reminder_repository.go +++ b/backend/internal/infrastructure/database/reminder_repository.go @@ -7,29 +7,37 @@ import ( "fmt" "github.com/btouchard/ackify-ce/backend/internal/domain/models" + "github.com/btouchard/ackify-ce/backend/internal/infrastructure/tenant" "github.com/btouchard/ackify-ce/backend/pkg/logger" ) // ReminderRepository handles database operations for reminder logs type ReminderRepository struct { - db *sql.DB + db *sql.DB + tenants tenant.Provider } // NewReminderRepository creates a new reminder repository -func NewReminderRepository(db *sql.DB) *ReminderRepository { - return &ReminderRepository{db: db} +func NewReminderRepository(db *sql.DB, tenants tenant.Provider) *ReminderRepository { + return &ReminderRepository{db: db, tenants: tenants} } // LogReminder records an email reminder event with delivery status for audit tracking func (r *ReminderRepository) LogReminder(ctx context.Context, log *models.ReminderLog) error { + tenantID, err := r.tenants.CurrentTenant(ctx) + if err != nil { + return fmt.Errorf("failed to get tenant: %w", err) + } + query := ` INSERT INTO reminder_logs - (doc_id, recipient_email, sent_at, sent_by, template_used, status, error_message) - VALUES ($1, $2, $3, $4, $5, $6, $7) + (tenant_id, doc_id, recipient_email, sent_at, sent_by, template_used, status, error_message) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id ` - err := r.db.QueryRowContext(ctx, query, + err = r.db.QueryRowContext(ctx, query, + tenantID, log.DocID, log.RecipientEmail, log.SentAt, @@ -43,19 +51,25 @@ func (r *ReminderRepository) LogReminder(ctx context.Context, log *models.Remind return fmt.Errorf("failed to log reminder: %w", err) } + log.TenantID = tenantID return nil } // GetReminderHistory retrieves complete reminder audit trail for a document, ordered by send time descending func (r *ReminderRepository) GetReminderHistory(ctx context.Context, docID string) ([]*models.ReminderLog, error) { + tenantID, err := r.tenants.CurrentTenant(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get tenant: %w", err) + } + query := ` - SELECT id, doc_id, recipient_email, sent_at, sent_by, template_used, status, error_message + SELECT id, tenant_id, doc_id, recipient_email, sent_at, sent_by, template_used, status, error_message FROM reminder_logs - WHERE doc_id = $1 + WHERE tenant_id = $1 AND doc_id = $2 ORDER BY sent_at DESC ` - rows, err := r.db.QueryContext(ctx, query, docID) + rows, err := r.db.QueryContext(ctx, query, tenantID, docID) if err != nil { return nil, fmt.Errorf("failed to query reminder history: %w", err) } @@ -71,6 +85,7 @@ func (r *ReminderRepository) GetReminderHistory(ctx context.Context, docID strin log := &models.ReminderLog{} err := rows.Scan( &log.ID, + &log.TenantID, &log.DocID, &log.RecipientEmail, &log.SentAt, @@ -90,17 +105,23 @@ func (r *ReminderRepository) GetReminderHistory(ctx context.Context, docID strin // GetLastReminderByEmail retrieves the most recent reminder sent to a specific recipient for throttling logic func (r *ReminderRepository) GetLastReminderByEmail(ctx context.Context, docID, email string) (*models.ReminderLog, error) { + tenantID, err := r.tenants.CurrentTenant(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get tenant: %w", err) + } + query := ` - SELECT id, doc_id, recipient_email, sent_at, sent_by, template_used, status, error_message + SELECT id, tenant_id, doc_id, recipient_email, sent_at, sent_by, template_used, status, error_message FROM reminder_logs - WHERE doc_id = $1 AND recipient_email = $2 + WHERE tenant_id = $1 AND doc_id = $2 AND recipient_email = $3 ORDER BY sent_at DESC LIMIT 1 ` log := &models.ReminderLog{} - err := r.db.QueryRowContext(ctx, query, docID, email).Scan( + err = r.db.QueryRowContext(ctx, query, tenantID, docID, email).Scan( &log.ID, + &log.TenantID, &log.DocID, &log.RecipientEmail, &log.SentAt, @@ -123,14 +144,19 @@ func (r *ReminderRepository) GetLastReminderByEmail(ctx context.Context, docID, // GetReminderCount tallies successfully delivered reminders to a recipient for rate limiting func (r *ReminderRepository) GetReminderCount(ctx context.Context, docID, email string) (int, error) { + tenantID, err := r.tenants.CurrentTenant(ctx) + if err != nil { + return 0, fmt.Errorf("failed to get tenant: %w", err) + } + query := ` SELECT COUNT(*) FROM reminder_logs - WHERE doc_id = $1 AND recipient_email = $2 AND status = 'sent' + WHERE tenant_id = $1 AND doc_id = $2 AND recipient_email = $3 AND status = 'sent' ` var count int - err := r.db.QueryRowContext(ctx, query, docID, email).Scan(&count) + err = r.db.QueryRowContext(ctx, query, tenantID, docID, email).Scan(&count) if err != nil { return 0, fmt.Errorf("failed to get reminder count: %w", err) } @@ -140,18 +166,23 @@ func (r *ReminderRepository) GetReminderCount(ctx context.Context, docID, email // GetReminderStats aggregates reminder metrics including pending signers and last send timestamp func (r *ReminderRepository) GetReminderStats(ctx context.Context, docID string) (*models.ReminderStats, error) { + tenantID, err := r.tenants.CurrentTenant(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get tenant: %w", err) + } + query := ` SELECT COUNT(*) as total_sent, MAX(sent_at) as last_sent_at FROM reminder_logs - WHERE doc_id = $1 AND status = 'sent' + WHERE tenant_id = $1 AND doc_id = $2 AND status = 'sent' ` stats := &models.ReminderStats{} var lastSent sql.NullTime - err := r.db.QueryRowContext(ctx, query, docID).Scan(&stats.TotalSent, &lastSent) + err = r.db.QueryRowContext(ctx, query, tenantID, docID).Scan(&stats.TotalSent, &lastSent) if err != nil { return nil, fmt.Errorf("failed to get reminder stats: %w", err) } @@ -163,11 +194,11 @@ func (r *ReminderRepository) GetReminderStats(ctx context.Context, docID string) pendingQuery := ` SELECT 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 AND s.id IS NULL + LEFT JOIN signatures s ON es.tenant_id = s.tenant_id AND es.doc_id = s.doc_id AND es.email = s.user_email + WHERE es.tenant_id = $1 AND es.doc_id = $2 AND s.id IS NULL ` - err = r.db.QueryRowContext(ctx, pendingQuery, docID).Scan(&stats.PendingCount) + err = r.db.QueryRowContext(ctx, pendingQuery, tenantID, docID).Scan(&stats.PendingCount) if err != nil { return nil, fmt.Errorf("failed to get pending count: %w", err) } diff --git a/backend/internal/infrastructure/database/signature_repository.go b/backend/internal/infrastructure/database/signature_repository.go index 982aaee..0bf5319 100644 --- a/backend/internal/infrastructure/database/signature_repository.go +++ b/backend/internal/infrastructure/database/signature_repository.go @@ -8,16 +8,18 @@ import ( "fmt" "github.com/btouchard/ackify-ce/backend/internal/domain/models" + "github.com/btouchard/ackify-ce/backend/internal/infrastructure/tenant" ) // SignatureRepository handles PostgreSQL persistence for cryptographic signatures type SignatureRepository struct { - db *sql.DB + db *sql.DB + tenants tenant.Provider } // NewSignatureRepository initializes a signature repository with the given database connection -func NewSignatureRepository(db *sql.DB) *SignatureRepository { - return &SignatureRepository{db: db} +func NewSignatureRepository(db *sql.DB, tenants tenant.Provider) *SignatureRepository { + return &SignatureRepository{db: db, tenants: tenants} } func scanSignature(scanner interface { @@ -31,6 +33,7 @@ func scanSignature(scanner interface { var docURL sql.NullString err := scanner.Scan( &signature.ID, + &signature.TenantID, &signature.DocID, &signature.UserSub, &signature.UserEmail, @@ -80,9 +83,14 @@ func scanSignature(scanner interface { // Create persists a new signature record to PostgreSQL with UNIQUE constraint enforcement on (doc_id, user_sub) func (r *SignatureRepository) Create(ctx context.Context, signature *models.Signature) error { + tenantID, err := r.tenants.CurrentTenant(ctx) + if err != nil { + return fmt.Errorf("failed to get tenant: %w", err) + } + query := ` - INSERT INTO signatures (doc_id, user_sub, user_email, user_name, signed_at, doc_checksum, payload_hash, signature, nonce, referer, prev_hash) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + INSERT INTO signatures (tenant_id, doc_id, user_sub, user_email, user_name, signed_at, doc_checksum, payload_hash, signature, nonce, referer, prev_hash) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id, created_at ` @@ -96,8 +104,9 @@ func (r *SignatureRepository) Create(ctx context.Context, signature *models.Sign docChecksum = sql.NullString{String: signature.DocChecksum, Valid: true} } - err := r.db.QueryRowContext( + err = r.db.QueryRowContext( ctx, query, + tenantID, signature.DocID, signature.UserSub, signature.UserEmail, @@ -115,22 +124,28 @@ func (r *SignatureRepository) Create(ctx context.Context, signature *models.Sign return fmt.Errorf("failed to create signature: %w", err) } + signature.TenantID = tenantID return nil } // GetByDocAndUser retrieves a specific signature by document ID and user OAuth subject identifier func (r *SignatureRepository) GetByDocAndUser(ctx context.Context, docID, userSub string) (*models.Signature, error) { + tenantID, err := r.tenants.CurrentTenant(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get tenant: %w", err) + } + query := ` - SELECT s.id, s.doc_id, s.user_sub, s.user_email, s.user_name, s.signed_at, s.doc_checksum, + SELECT s.id, s.tenant_id, s.doc_id, s.user_sub, s.user_email, s.user_name, s.signed_at, s.doc_checksum, s.payload_hash, s.signature, s.nonce, s.created_at, s.referer, s.prev_hash, s.hash_version, s.doc_deleted_at, d.title, d.url FROM signatures s LEFT JOIN documents d ON s.doc_id = d.doc_id - WHERE s.doc_id = $1 AND s.user_sub = $2 + WHERE s.tenant_id = $1 AND s.doc_id = $2 AND s.user_sub = $3 ` signature := &models.Signature{} - err := scanSignature(r.db.QueryRowContext(ctx, query, docID, userSub), signature) + err = scanSignature(r.db.QueryRowContext(ctx, query, tenantID, docID, userSub), signature) if err != nil { if errors.Is(err, sql.ErrNoRows) { @@ -144,17 +159,22 @@ func (r *SignatureRepository) GetByDocAndUser(ctx context.Context, docID, userSu // GetByDoc retrieves all signatures for a specific document, ordered by creation timestamp descending func (r *SignatureRepository) GetByDoc(ctx context.Context, docID string) ([]*models.Signature, error) { + tenantID, err := r.tenants.CurrentTenant(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get tenant: %w", err) + } + query := ` - SELECT s.id, s.doc_id, s.user_sub, s.user_email, s.user_name, s.signed_at, s.doc_checksum, + SELECT s.id, s.tenant_id, s.doc_id, s.user_sub, s.user_email, s.user_name, s.signed_at, s.doc_checksum, s.payload_hash, s.signature, s.nonce, s.created_at, s.referer, s.prev_hash, s.hash_version, s.doc_deleted_at, d.title, d.url FROM signatures s LEFT JOIN documents d ON s.doc_id = d.doc_id - WHERE s.doc_id = $1 + WHERE s.tenant_id = $1 AND s.doc_id = $2 ORDER BY s.created_at DESC ` - rows, err := r.db.QueryContext(ctx, query, docID) + rows, err := r.db.QueryContext(ctx, query, tenantID, docID) if err != nil { return nil, fmt.Errorf("failed to query signatures: %w", err) } @@ -176,17 +196,22 @@ func (r *SignatureRepository) GetByDoc(ctx context.Context, docID string) ([]*mo // GetByUser retrieves all signatures created by a specific user, ordered by creation timestamp descending func (r *SignatureRepository) GetByUser(ctx context.Context, userSub string) ([]*models.Signature, error) { + tenantID, err := r.tenants.CurrentTenant(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get tenant: %w", err) + } + query := ` - SELECT s.id, s.doc_id, s.user_sub, s.user_email, s.user_name, s.signed_at, s.doc_checksum, + SELECT s.id, s.tenant_id, s.doc_id, s.user_sub, s.user_email, s.user_name, s.signed_at, s.doc_checksum, s.payload_hash, s.signature, s.nonce, s.created_at, s.referer, s.prev_hash, s.hash_version, s.doc_deleted_at, d.title, d.url FROM signatures s LEFT JOIN documents d ON s.doc_id = d.doc_id - WHERE s.user_sub = $1 + WHERE s.tenant_id = $1 AND s.user_sub = $2 ORDER BY s.created_at DESC ` - rows, err := r.db.QueryContext(ctx, query, userSub) + rows, err := r.db.QueryContext(ctx, query, tenantID, userSub) if err != nil { return nil, fmt.Errorf("failed to query user signatures: %w", err) } @@ -208,10 +233,15 @@ func (r *SignatureRepository) GetByUser(ctx context.Context, userSub string) ([] // ExistsByDocAndUser efficiently checks if a signature already exists without retrieving full record data func (r *SignatureRepository) ExistsByDocAndUser(ctx context.Context, docID, userSub string) (bool, error) { - query := `SELECT EXISTS(SELECT 1 FROM signatures WHERE doc_id = $1 AND user_sub = $2)` + tenantID, err := r.tenants.CurrentTenant(ctx) + if err != nil { + return false, fmt.Errorf("failed to get tenant: %w", err) + } + + query := `SELECT EXISTS(SELECT 1 FROM signatures WHERE tenant_id = $1 AND doc_id = $2 AND user_sub = $3)` var exists bool - err := r.db.QueryRowContext(ctx, query, docID, userSub).Scan(&exists) + err = r.db.QueryRowContext(ctx, query, tenantID, docID, userSub).Scan(&exists) if err != nil { return false, fmt.Errorf("failed to check signature existence: %w", err) } @@ -221,15 +251,20 @@ func (r *SignatureRepository) ExistsByDocAndUser(ctx context.Context, docID, use // CheckUserSignatureStatus verifies if a user has signed, accepting either OAuth subject or email as identifier func (r *SignatureRepository) CheckUserSignatureStatus(ctx context.Context, docID, userIdentifier string) (bool, error) { + tenantID, err := r.tenants.CurrentTenant(ctx) + if err != nil { + return false, fmt.Errorf("failed to get tenant: %w", err) + } + query := ` SELECT EXISTS( - SELECT 1 FROM signatures - WHERE doc_id = $1 AND (user_sub = $2 OR LOWER(user_email) = LOWER($2)) + SELECT 1 FROM signatures + WHERE tenant_id = $1 AND doc_id = $2 AND (user_sub = $3 OR LOWER(user_email) = LOWER($3)) ) ` var exists bool - err := r.db.QueryRowContext(ctx, query, docID, userIdentifier).Scan(&exists) + err = r.db.QueryRowContext(ctx, query, tenantID, docID, userIdentifier).Scan(&exists) if err != nil { return false, fmt.Errorf("failed to check user signature status: %w", err) } @@ -239,19 +274,24 @@ func (r *SignatureRepository) CheckUserSignatureStatus(ctx context.Context, docI // GetLastSignature retrieves the most recent signature for hash chain linking (returns nil if no signatures exist) func (r *SignatureRepository) GetLastSignature(ctx context.Context, docID string) (*models.Signature, error) { + tenantID, err := r.tenants.CurrentTenant(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get tenant: %w", err) + } + query := ` - SELECT s.id, s.doc_id, s.user_sub, s.user_email, s.user_name, s.signed_at, s.doc_checksum, + SELECT s.id, s.tenant_id, s.doc_id, s.user_sub, s.user_email, s.user_name, s.signed_at, s.doc_checksum, s.payload_hash, s.signature, s.nonce, s.created_at, s.referer, s.prev_hash, s.hash_version, s.doc_deleted_at, d.title, d.url FROM signatures s LEFT JOIN documents d ON s.doc_id = d.doc_id - WHERE s.doc_id = $1 + WHERE s.tenant_id = $1 AND s.doc_id = $2 ORDER BY s.id DESC LIMIT 1 ` signature := &models.Signature{} - err := scanSignature(r.db.QueryRowContext(ctx, query, docID), signature) + err = scanSignature(r.db.QueryRowContext(ctx, query, tenantID, docID), signature) if err != nil { if errors.Is(err, sql.ErrNoRows) { @@ -265,15 +305,21 @@ func (r *SignatureRepository) GetLastSignature(ctx context.Context, docID string // GetAllSignaturesOrdered retrieves all signatures in chronological order for chain integrity verification func (r *SignatureRepository) GetAllSignaturesOrdered(ctx context.Context) ([]*models.Signature, error) { + tenantID, err := r.tenants.CurrentTenant(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get tenant: %w", err) + } + query := ` - SELECT s.id, s.doc_id, s.user_sub, s.user_email, s.user_name, s.signed_at, s.doc_checksum, + SELECT s.id, s.tenant_id, s.doc_id, s.user_sub, s.user_email, s.user_name, s.signed_at, s.doc_checksum, s.payload_hash, s.signature, s.nonce, s.created_at, s.referer, s.prev_hash, s.hash_version, s.doc_deleted_at, d.title, d.url FROM signatures s LEFT JOIN documents d ON s.doc_id = d.doc_id + WHERE s.tenant_id = $1 ORDER BY s.id ASC` - rows, err := r.db.QueryContext(ctx, query) + rows, err := r.db.QueryContext(ctx, query, tenantID) if err != nil { return nil, fmt.Errorf("failed to query all signatures: %w", err) } @@ -295,8 +341,13 @@ func (r *SignatureRepository) GetAllSignaturesOrdered(ctx context.Context) ([]*m // UpdatePrevHash modifies the previous hash pointer for chain reconstruction operations func (r *SignatureRepository) UpdatePrevHash(ctx context.Context, id int64, prevHash *string) error { - query := `UPDATE signatures SET prev_hash = $2 WHERE id = $1` - if _, err := r.db.ExecContext(ctx, query, id, prevHash); err != nil { + tenantID, err := r.tenants.CurrentTenant(ctx) + if err != nil { + return fmt.Errorf("failed to get tenant: %w", err) + } + + query := `UPDATE signatures SET prev_hash = $3 WHERE tenant_id = $1 AND id = $2` + if _, err := r.db.ExecContext(ctx, query, tenantID, id, prevHash); err != nil { return fmt.Errorf("failed to update prev_hash: %w", err) } return nil diff --git a/backend/internal/infrastructure/database/webhook_delivery_repository.go b/backend/internal/infrastructure/database/webhook_delivery_repository.go index 5fd6a3d..dd8d5ff 100644 --- a/backend/internal/infrastructure/database/webhook_delivery_repository.go +++ b/backend/internal/infrastructure/database/webhook_delivery_repository.go @@ -9,6 +9,7 @@ import ( "time" "github.com/btouchard/ackify-ce/backend/internal/domain/models" + "github.com/btouchard/ackify-ce/backend/internal/infrastructure/tenant" ) // Joined view of a delivery with webhook send data @@ -29,14 +30,20 @@ type WebhookDeliveryItem struct { } type WebhookDeliveryRepository struct { - db *sql.DB + db *sql.DB + tenants tenant.Provider } -func NewWebhookDeliveryRepository(db *sql.DB) *WebhookDeliveryRepository { - return &WebhookDeliveryRepository{db: db} +func NewWebhookDeliveryRepository(db *sql.DB, tenants tenant.Provider) *WebhookDeliveryRepository { + return &WebhookDeliveryRepository{db: db, tenants: tenants} } func (r *WebhookDeliveryRepository) Enqueue(ctx context.Context, input models.WebhookDeliveryInput) (*models.WebhookDelivery, error) { + tenantID, err := r.tenants.CurrentTenant(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get tenant: %w", err) + } + payloadJSON, err := json.Marshal(input.Payload) if err != nil { return nil, fmt.Errorf("failed to marshal payload: %w", err) @@ -51,11 +58,12 @@ func (r *WebhookDeliveryRepository) Enqueue(ctx context.Context, input models.We } q := ` - INSERT INTO webhook_deliveries (webhook_id, event_type, event_id, payload, priority, max_retries, scheduled_for) - VALUES ($1,$2,$3,$4,$5,$6,$7) - RETURNING id, status, retry_count, created_at, processed_at, next_retry_at + INSERT INTO webhook_deliveries (tenant_id, webhook_id, event_type, event_id, payload, priority, max_retries, scheduled_for) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8) + RETURNING id, tenant_id, status, retry_count, created_at, processed_at, next_retry_at ` item := &models.WebhookDelivery{ + TenantID: tenantID, WebhookID: input.WebhookID, EventType: input.EventType, EventID: input.EventID, @@ -65,8 +73,8 @@ func (r *WebhookDeliveryRepository) Enqueue(ctx context.Context, input models.We ScheduledFor: scheduled, } err = r.db.QueryRowContext(ctx, q, - input.WebhookID, input.EventType, input.EventID, payloadJSON, input.Priority, maxRetries, scheduled, - ).Scan(&item.ID, &item.Status, &item.RetryCount, &item.CreatedAt, &item.ProcessedAt, &item.NextRetryAt) + tenantID, input.WebhookID, input.EventType, input.EventID, payloadJSON, input.Priority, maxRetries, scheduled, + ).Scan(&item.ID, &item.TenantID, &item.Status, &item.RetryCount, &item.CreatedAt, &item.ProcessedAt, &item.NextRetryAt) if err != nil { return nil, fmt.Errorf("failed to enqueue webhook delivery: %w", err) } @@ -203,15 +211,20 @@ func (r *WebhookDeliveryRepository) MarkFailed(ctx context.Context, id int64, er } func (r *WebhookDeliveryRepository) ListByWebhook(ctx context.Context, webhookID int64, limit, offset int) ([]*models.WebhookDelivery, error) { + tenantID, err := r.tenants.CurrentTenant(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get tenant: %w", err) + } + q := ` - SELECT id, webhook_id, event_type, event_id, payload, status, retry_count, max_retries, priority, + SELECT id, tenant_id, webhook_id, event_type, event_id, payload, status, retry_count, max_retries, priority, created_at, scheduled_for, processed_at, next_retry_at, request_headers, response_status, response_headers, response_body, last_error FROM webhook_deliveries - WHERE webhook_id=$1 + WHERE webhook_id=$1 AND tenant_id=$2 ORDER BY id DESC - LIMIT $2 OFFSET $3 + LIMIT $3 OFFSET $4 ` - rows, err := r.db.QueryContext(ctx, q, webhookID, limit, offset) + rows, err := r.db.QueryContext(ctx, q, webhookID, tenantID, limit, offset) if err != nil { return nil, fmt.Errorf("failed to list deliveries: %w", err) } @@ -220,7 +233,7 @@ func (r *WebhookDeliveryRepository) ListByWebhook(ctx context.Context, webhookID for rows.Next() { d := &models.WebhookDelivery{} if err := rows.Scan( - &d.ID, &d.WebhookID, &d.EventType, &d.EventID, &d.Payload, &d.Status, &d.RetryCount, &d.MaxRetries, &d.Priority, + &d.ID, &d.TenantID, &d.WebhookID, &d.EventType, &d.EventID, &d.Payload, &d.Status, &d.RetryCount, &d.MaxRetries, &d.Priority, &d.CreatedAt, &d.ScheduledFor, &d.ProcessedAt, &d.NextRetryAt, &d.RequestHeaders, &d.ResponseStatus, &d.ResponseHeaders, &d.ResponseBody, &d.LastError, ); err != nil { return nil, err diff --git a/backend/internal/infrastructure/database/webhook_repository.go b/backend/internal/infrastructure/database/webhook_repository.go index 96e976c..d3d58ca 100644 --- a/backend/internal/infrastructure/database/webhook_repository.go +++ b/backend/internal/infrastructure/database/webhook_repository.go @@ -8,18 +8,25 @@ import ( "fmt" "github.com/btouchard/ackify-ce/backend/internal/domain/models" + "github.com/btouchard/ackify-ce/backend/internal/infrastructure/tenant" "github.com/lib/pq" ) type WebhookRepository struct { - db *sql.DB + db *sql.DB + tenants tenant.Provider } -func NewWebhookRepository(db *sql.DB) *WebhookRepository { - return &WebhookRepository{db: db} +func NewWebhookRepository(db *sql.DB, tenants tenant.Provider) *WebhookRepository { + return &WebhookRepository{db: db, tenants: tenants} } func (r *WebhookRepository) Create(ctx context.Context, input models.WebhookInput) (*models.Webhook, error) { + tenantID, err := r.tenants.CurrentTenant(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get tenant: %w", err) + } + headersIn := []byte("{}") if input.Headers != nil { if data, err := json.Marshal(input.Headers); err == nil { @@ -28,13 +35,14 @@ func (r *WebhookRepository) Create(ctx context.Context, input models.WebhookInpu } query := ` - INSERT INTO webhooks (title, target_url, secret, active, events, headers, description, created_by) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8) - RETURNING id, title, target_url, secret, active, events, headers, description, created_by, created_at, updated_at, last_delivered_at, failure_count + INSERT INTO webhooks (tenant_id, title, target_url, secret, active, events, headers, description, created_by) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) + RETURNING id, tenant_id, title, target_url, secret, active, events, headers, description, created_by, created_at, updated_at, last_delivered_at, failure_count ` wh := &models.Webhook{} var headersOut models.NullRawMessage - err := r.db.QueryRowContext(ctx, query, + err = r.db.QueryRowContext(ctx, query, + tenantID, input.Title, input.TargetURL, input.Secret, @@ -44,7 +52,7 @@ func (r *WebhookRepository) Create(ctx context.Context, input models.WebhookInpu input.Description, input.CreatedBy, ).Scan( - &wh.ID, &wh.Title, &wh.TargetURL, &wh.Secret, &wh.Active, pq.Array(&wh.Events), &headersOut, &wh.Description, &wh.CreatedBy, + &wh.ID, &wh.TenantID, &wh.Title, &wh.TargetURL, &wh.Secret, &wh.Active, pq.Array(&wh.Events), &headersOut, &wh.Description, &wh.CreatedBy, &wh.CreatedAt, &wh.UpdatedAt, &wh.LastDeliveredAt, &wh.FailureCount, ) if err != nil { @@ -57,6 +65,11 @@ func (r *WebhookRepository) Create(ctx context.Context, input models.WebhookInpu } func (r *WebhookRepository) Update(ctx context.Context, id int64, input models.WebhookInput) (*models.Webhook, error) { + tenantID, err := r.tenants.CurrentTenant(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get tenant: %w", err) + } + headersJSON := []byte("{}") if input.Headers != nil { if data, err := json.Marshal(input.Headers); err == nil { @@ -67,12 +80,12 @@ func (r *WebhookRepository) Update(ctx context.Context, id int64, input models.W query := ` UPDATE webhooks SET title=$1, target_url=$2, secret=COALESCE(NULLIF($3,''), secret), active=$4, events=$5, headers=$6, description=$7, updated_at=now() - WHERE id=$8 - RETURNING id, title, target_url, secret, active, events, headers, description, created_by, created_at, updated_at, last_delivered_at, failure_count + WHERE id=$8 AND tenant_id=$9 + RETURNING id, tenant_id, title, target_url, secret, active, events, headers, description, created_by, created_at, updated_at, last_delivered_at, failure_count ` wh := &models.Webhook{} var headersOut models.NullRawMessage - err := r.db.QueryRowContext(ctx, query, + err = r.db.QueryRowContext(ctx, query, input.Title, input.TargetURL, input.Secret, @@ -81,8 +94,9 @@ func (r *WebhookRepository) Update(ctx context.Context, id int64, input models.W headersJSON, input.Description, id, + tenantID, ).Scan( - &wh.ID, &wh.Title, &wh.TargetURL, &wh.Secret, &wh.Active, pq.Array(&wh.Events), &headersOut, &wh.Description, &wh.CreatedBy, + &wh.ID, &wh.TenantID, &wh.Title, &wh.TargetURL, &wh.Secret, &wh.Active, pq.Array(&wh.Events), &headersOut, &wh.Description, &wh.CreatedBy, &wh.CreatedAt, &wh.UpdatedAt, &wh.LastDeliveredAt, &wh.FailureCount, ) if err != nil { @@ -95,7 +109,12 @@ func (r *WebhookRepository) Update(ctx context.Context, id int64, input models.W } func (r *WebhookRepository) SetActive(ctx context.Context, id int64, active bool) error { - res, err := r.db.ExecContext(ctx, `UPDATE webhooks SET active=$1, updated_at=now() WHERE id=$2`, active, id) + tenantID, err := r.tenants.CurrentTenant(ctx) + if err != nil { + return fmt.Errorf("failed to get tenant: %w", err) + } + + res, err := r.db.ExecContext(ctx, `UPDATE webhooks SET active=$1, updated_at=now() WHERE id=$2 AND tenant_id=$3`, active, id, tenantID) if err != nil { return fmt.Errorf("failed to set active: %w", err) } @@ -107,7 +126,12 @@ func (r *WebhookRepository) SetActive(ctx context.Context, id int64, active bool } func (r *WebhookRepository) Delete(ctx context.Context, id int64) error { - _, err := r.db.ExecContext(ctx, `DELETE FROM webhooks WHERE id=$1`, id) + tenantID, err := r.tenants.CurrentTenant(ctx) + if err != nil { + return fmt.Errorf("failed to get tenant: %w", err) + } + + _, err = r.db.ExecContext(ctx, `DELETE FROM webhooks WHERE id=$1 AND tenant_id=$2`, id, tenantID) if err != nil { return fmt.Errorf("failed to delete webhook: %w", err) } @@ -115,16 +139,21 @@ func (r *WebhookRepository) Delete(ctx context.Context, id int64) error { } func (r *WebhookRepository) GetByID(ctx context.Context, id int64) (*models.Webhook, error) { + tenantID, err := r.tenants.CurrentTenant(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get tenant: %w", err) + } + query := ` - SELECT id, title, target_url, secret, active, events, headers, description, created_by, created_at, updated_at, last_delivered_at, failure_count + SELECT id, tenant_id, title, target_url, secret, active, events, headers, description, created_by, created_at, updated_at, last_delivered_at, failure_count FROM webhooks - WHERE id=$1 + WHERE id=$1 AND tenant_id=$2 ` wh := &models.Webhook{} var events []string var headersJSON models.NullRawMessage - err := r.db.QueryRowContext(ctx, query, id).Scan( - &wh.ID, &wh.Title, &wh.TargetURL, &wh.Secret, &wh.Active, pq.Array(&events), &headersJSON, &wh.Description, &wh.CreatedBy, + err = r.db.QueryRowContext(ctx, query, id, tenantID).Scan( + &wh.ID, &wh.TenantID, &wh.Title, &wh.TargetURL, &wh.Secret, &wh.Active, pq.Array(&events), &headersJSON, &wh.Description, &wh.CreatedBy, &wh.CreatedAt, &wh.UpdatedAt, &wh.LastDeliveredAt, &wh.FailureCount, ) if err != nil { @@ -138,13 +167,19 @@ func (r *WebhookRepository) GetByID(ctx context.Context, id int64) (*models.Webh } func (r *WebhookRepository) List(ctx context.Context, limit, offset int) ([]*models.Webhook, error) { + tenantID, err := r.tenants.CurrentTenant(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get tenant: %w", err) + } + query := ` - SELECT id, title, target_url, secret, active, events, headers, description, created_by, created_at, updated_at, last_delivered_at, failure_count + SELECT id, tenant_id, title, target_url, secret, active, events, headers, description, created_by, created_at, updated_at, last_delivered_at, failure_count FROM webhooks + WHERE tenant_id=$1 ORDER BY id DESC - LIMIT $1 OFFSET $2 + LIMIT $2 OFFSET $3 ` - rows, err := r.db.QueryContext(ctx, query, limit, offset) + rows, err := r.db.QueryContext(ctx, query, tenantID, limit, offset) if err != nil { return nil, fmt.Errorf("failed to list webhooks: %w", err) } @@ -156,7 +191,7 @@ func (r *WebhookRepository) List(ctx context.Context, limit, offset int) ([]*mod var events []string var headersJSON models.NullRawMessage if err := rows.Scan( - &wh.ID, &wh.Title, &wh.TargetURL, &wh.Secret, &wh.Active, pq.Array(&events), &headersJSON, &wh.Description, &wh.CreatedBy, + &wh.ID, &wh.TenantID, &wh.Title, &wh.TargetURL, &wh.Secret, &wh.Active, pq.Array(&events), &headersJSON, &wh.Description, &wh.CreatedBy, &wh.CreatedAt, &wh.UpdatedAt, &wh.LastDeliveredAt, &wh.FailureCount, ); err != nil { return nil, err @@ -172,13 +207,17 @@ func (r *WebhookRepository) List(ctx context.Context, limit, offset int) ([]*mod // ListActiveByEvent returns active webhooks subscribed to a given event type func (r *WebhookRepository) ListActiveByEvent(ctx context.Context, event string) ([]*models.Webhook, error) { - // Use ANY($1) against events[] + tenantID, err := r.tenants.CurrentTenant(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get tenant: %w", err) + } + query := ` - SELECT id, title, target_url, secret, active, events, headers, description, created_by, created_at, updated_at, last_delivered_at, failure_count + SELECT id, tenant_id, title, target_url, secret, active, events, headers, description, created_by, created_at, updated_at, last_delivered_at, failure_count FROM webhooks - WHERE active = TRUE AND $1 = ANY(events) + WHERE tenant_id=$1 AND active = TRUE AND $2 = ANY(events) ` - rows, err := r.db.QueryContext(ctx, query, event) + rows, err := r.db.QueryContext(ctx, query, tenantID, event) if err != nil { return nil, fmt.Errorf("failed to list active webhooks: %w", err) } @@ -190,7 +229,7 @@ func (r *WebhookRepository) ListActiveByEvent(ctx context.Context, event string) var events []string var headersJSON models.NullRawMessage if err := rows.Scan( - &wh.ID, &wh.Title, &wh.TargetURL, &wh.Secret, &wh.Active, pq.Array(&events), &headersJSON, &wh.Description, &wh.CreatedBy, + &wh.ID, &wh.TenantID, &wh.Title, &wh.TargetURL, &wh.Secret, &wh.Active, pq.Array(&events), &headersJSON, &wh.Description, &wh.CreatedBy, &wh.CreatedAt, &wh.UpdatedAt, &wh.LastDeliveredAt, &wh.FailureCount, ); err != nil { return nil, err diff --git a/backend/internal/infrastructure/tenant/provider.go b/backend/internal/infrastructure/tenant/provider.go new file mode 100644 index 0000000..548b0f3 --- /dev/null +++ b/backend/internal/infrastructure/tenant/provider.go @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +package tenant + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/google/uuid" +) + +// Provider defines the interface for obtaining the current tenant ID. +type Provider interface { + CurrentTenant(ctx context.Context) (uuid.UUID, error) +} + +// SingleTenantProvider implements Provider for single-tenant. +// It caches the tenant ID from instance_metadata at startup. +type SingleTenantProvider struct { + id uuid.UUID +} + +// CurrentTenant returns the cached instance tenant ID. +func (p *SingleTenantProvider) CurrentTenant(_ context.Context) (uuid.UUID, error) { + return p.id, nil +} + +// NewSingleTenantProviderWithContext is like NewSingleTenantProvider but accepts a context. +func NewSingleTenantProviderWithContext(ctx context.Context, db *sql.DB) (*SingleTenantProvider, error) { + var id uuid.UUID + err := db.QueryRowContext(ctx, `SELECT id FROM instance_metadata LIMIT 1`).Scan(&id) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("instance_metadata table is empty - migrations may not have run correctly") + } + return nil, fmt.Errorf("failed to read tenant ID from instance_metadata: %w", err) + } + + return &SingleTenantProvider{id: id}, nil +} diff --git a/backend/migrations/0015_add_tenant_support.down.sql b/backend/migrations/0015_add_tenant_support.down.sql new file mode 100644 index 0000000..aa0d0d3 --- /dev/null +++ b/backend/migrations/0015_add_tenant_support.down.sql @@ -0,0 +1,42 @@ +-- SPDX-License-Identifier: AGPL-3.0-or-later + +-- Rollback: Remove Tenant Support +-- This migration reverts the tenant support changes + +-- Step 1: Drop triggers for tenant_id immutability +DROP TRIGGER IF EXISTS tr_documents_tenant_id_immutable ON documents; +DROP TRIGGER IF EXISTS tr_signatures_tenant_id_immutable ON signatures; +DROP TRIGGER IF EXISTS tr_expected_signers_tenant_id_immutable ON expected_signers; +DROP TRIGGER IF EXISTS tr_webhooks_tenant_id_immutable ON webhooks; +DROP TRIGGER IF EXISTS tr_reminder_logs_tenant_id_immutable ON reminder_logs; +DROP TRIGGER IF EXISTS tr_email_queue_tenant_id_immutable ON email_queue; +DROP TRIGGER IF EXISTS tr_checksum_verifications_tenant_id_immutable ON checksum_verifications; +DROP TRIGGER IF EXISTS tr_webhook_deliveries_tenant_id_immutable ON webhook_deliveries; +DROP TRIGGER IF EXISTS tr_instance_metadata_id_immutable ON instance_metadata; + +-- Step 2: Drop the trigger functions +DROP FUNCTION IF EXISTS prevent_tenant_id_modification(); +DROP FUNCTION IF EXISTS prevent_instance_metadata_modification(); + +-- Step 3: Drop indexes +DROP INDEX IF EXISTS idx_documents_tenant_id; +DROP INDEX IF EXISTS idx_signatures_tenant_id; +DROP INDEX IF EXISTS idx_expected_signers_tenant_id; +DROP INDEX IF EXISTS idx_webhooks_tenant_id; +DROP INDEX IF EXISTS idx_reminder_logs_tenant_id; +DROP INDEX IF EXISTS idx_email_queue_tenant_id; +DROP INDEX IF EXISTS idx_checksum_verifications_tenant_id; +DROP INDEX IF EXISTS idx_webhook_deliveries_tenant_id; + +-- Step 4: Drop tenant_id columns from all tables +ALTER TABLE documents DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE signatures DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE expected_signers DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE webhooks DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE reminder_logs DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE email_queue DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE checksum_verifications DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE webhook_deliveries DROP COLUMN IF EXISTS tenant_id; + +-- Step 5: Drop instance_metadata table +DROP TABLE IF EXISTS instance_metadata; diff --git a/backend/migrations/0015_add_tenant_support.up.sql b/backend/migrations/0015_add_tenant_support.up.sql new file mode 100644 index 0000000..3540851 --- /dev/null +++ b/backend/migrations/0015_add_tenant_support.up.sql @@ -0,0 +1,176 @@ +-- SPDX-License-Identifier: AGPL-3.0-or-later + +-- ============================================================================ +-- Migration: Add Tenant Support (tenant-ready) +-- ============================================================================ +-- This migration prepares Ackify CE for multi-tenancy by: +-- 1. Creating an instance_metadata table with a unique tenant UUID +-- 2. Adding tenant_id columns to all business tables +-- 3. Backfilling existing data with the instance tenant UUID +-- ============================================================================ + +-- Step 1: Create instance_metadata table to store the unique instance tenant UUID +CREATE TABLE IF NOT EXISTS instance_metadata ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +COMMENT ON TABLE instance_metadata IS 'Stores the unique tenant UUID for this Ackify instance (one row per instance)'; +COMMENT ON COLUMN instance_metadata.id IS 'The unique tenant identifier for this instance'; + +-- Ensure exactly one row exists (the instance tenant) +INSERT INTO instance_metadata DEFAULT VALUES +ON CONFLICT DO NOTHING; + +-- Step 2: Add nullable tenant_id columns to all business tables +-- NOTE: We use UUID type to match instance_metadata.id + +-- Documents table +ALTER TABLE documents ADD COLUMN IF NOT EXISTS tenant_id UUID; + +-- Signatures table +ALTER TABLE signatures ADD COLUMN IF NOT EXISTS tenant_id UUID; + +-- Expected signers table +ALTER TABLE expected_signers ADD COLUMN IF NOT EXISTS tenant_id UUID; + +-- Webhooks table +ALTER TABLE webhooks ADD COLUMN IF NOT EXISTS tenant_id UUID; + +-- Reminder logs table +ALTER TABLE reminder_logs ADD COLUMN IF NOT EXISTS tenant_id UUID; + +-- Email queue table +ALTER TABLE email_queue ADD COLUMN IF NOT EXISTS tenant_id UUID; + +-- Checksum verifications table (audit log, also tenant-scoped) +ALTER TABLE checksum_verifications ADD COLUMN IF NOT EXISTS tenant_id UUID; + +-- Webhook deliveries table (child of webhooks, also tenant-scoped for easier querying) +ALTER TABLE webhook_deliveries ADD COLUMN IF NOT EXISTS tenant_id UUID; + +-- Step 3: Backfill all existing rows with the instance tenant UUID +-- This ensures all existing data belongs to the single instance tenant + +UPDATE documents +SET tenant_id = (SELECT id FROM instance_metadata LIMIT 1) +WHERE tenant_id IS NULL; + +UPDATE signatures +SET tenant_id = (SELECT id FROM instance_metadata LIMIT 1) +WHERE tenant_id IS NULL; + +UPDATE expected_signers +SET tenant_id = (SELECT id FROM instance_metadata LIMIT 1) +WHERE tenant_id IS NULL; + +UPDATE webhooks +SET tenant_id = (SELECT id FROM instance_metadata LIMIT 1) +WHERE tenant_id IS NULL; + +UPDATE reminder_logs +SET tenant_id = (SELECT id FROM instance_metadata LIMIT 1) +WHERE tenant_id IS NULL; + +UPDATE email_queue +SET tenant_id = (SELECT id FROM instance_metadata LIMIT 1) +WHERE tenant_id IS NULL; + +UPDATE checksum_verifications +SET tenant_id = (SELECT id FROM instance_metadata LIMIT 1) +WHERE tenant_id IS NULL; + +UPDATE webhook_deliveries +SET tenant_id = (SELECT id FROM instance_metadata LIMIT 1) +WHERE tenant_id IS NULL; + +-- Step 4: Add indexes for tenant_id columns (for future tenant-scoped queries) +CREATE INDEX IF NOT EXISTS idx_documents_tenant_id ON documents(tenant_id); +CREATE INDEX IF NOT EXISTS idx_signatures_tenant_id ON signatures(tenant_id); +CREATE INDEX IF NOT EXISTS idx_expected_signers_tenant_id ON expected_signers(tenant_id); +CREATE INDEX IF NOT EXISTS idx_webhooks_tenant_id ON webhooks(tenant_id); +CREATE INDEX IF NOT EXISTS idx_reminder_logs_tenant_id ON reminder_logs(tenant_id); +CREATE INDEX IF NOT EXISTS idx_email_queue_tenant_id ON email_queue(tenant_id); +CREATE INDEX IF NOT EXISTS idx_checksum_verifications_tenant_id ON checksum_verifications(tenant_id); +CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_tenant_id ON webhook_deliveries(tenant_id); + +-- Add comments explaining the tenant_id columns +COMMENT ON COLUMN documents.tenant_id IS 'Tenant identifier (references instance_metadata.id in CE mode)'; +COMMENT ON COLUMN signatures.tenant_id IS 'Tenant identifier (references instance_metadata.id in CE mode)'; +COMMENT ON COLUMN expected_signers.tenant_id IS 'Tenant identifier (references instance_metadata.id in CE mode)'; +COMMENT ON COLUMN webhooks.tenant_id IS 'Tenant identifier (references instance_metadata.id in CE mode)'; +COMMENT ON COLUMN reminder_logs.tenant_id IS 'Tenant identifier (references instance_metadata.id in CE mode)'; +COMMENT ON COLUMN email_queue.tenant_id IS 'Tenant identifier (references instance_metadata.id in CE mode)'; +COMMENT ON COLUMN checksum_verifications.tenant_id IS 'Tenant identifier (references instance_metadata.id in CE mode)'; +COMMENT ON COLUMN webhook_deliveries.tenant_id IS 'Tenant identifier (references instance_metadata.id in CE mode)'; + +-- Step 5: Create trigger to prevent tenant_id modification after creation (immutability) +-- This ensures data cannot be moved between tenants once created +CREATE OR REPLACE FUNCTION prevent_tenant_id_modification() +RETURNS TRIGGER AS $$ +BEGIN + IF OLD.tenant_id IS NOT NULL AND NEW.tenant_id IS DISTINCT FROM OLD.tenant_id THEN + RAISE EXCEPTION 'tenant_id cannot be modified after creation'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION prevent_tenant_id_modification() IS 'Prevents modification of tenant_id column after initial assignment'; + +-- Apply trigger to all tables with tenant_id +CREATE TRIGGER tr_documents_tenant_id_immutable + BEFORE UPDATE ON documents + FOR EACH ROW + EXECUTE FUNCTION prevent_tenant_id_modification(); + +CREATE TRIGGER tr_signatures_tenant_id_immutable + BEFORE UPDATE ON signatures + FOR EACH ROW + EXECUTE FUNCTION prevent_tenant_id_modification(); + +CREATE TRIGGER tr_expected_signers_tenant_id_immutable + BEFORE UPDATE ON expected_signers + FOR EACH ROW + EXECUTE FUNCTION prevent_tenant_id_modification(); + +CREATE TRIGGER tr_webhooks_tenant_id_immutable + BEFORE UPDATE ON webhooks + FOR EACH ROW + EXECUTE FUNCTION prevent_tenant_id_modification(); + +CREATE TRIGGER tr_reminder_logs_tenant_id_immutable + BEFORE UPDATE ON reminder_logs + FOR EACH ROW + EXECUTE FUNCTION prevent_tenant_id_modification(); + +CREATE TRIGGER tr_email_queue_tenant_id_immutable + BEFORE UPDATE ON email_queue + FOR EACH ROW + EXECUTE FUNCTION prevent_tenant_id_modification(); + +CREATE TRIGGER tr_checksum_verifications_tenant_id_immutable + BEFORE UPDATE ON checksum_verifications + FOR EACH ROW + EXECUTE FUNCTION prevent_tenant_id_modification(); + +CREATE TRIGGER tr_webhook_deliveries_tenant_id_immutable + BEFORE UPDATE ON webhook_deliveries + FOR EACH ROW + EXECUTE FUNCTION prevent_tenant_id_modification(); + +-- Also protect instance_metadata.id from modifications (the tenant UUID should never change) +CREATE OR REPLACE FUNCTION prevent_instance_metadata_modification() +RETURNS TRIGGER AS $$ +BEGIN + IF OLD.id IS NOT NULL AND NEW.id IS DISTINCT FROM OLD.id THEN + RAISE EXCEPTION 'instance_metadata.id cannot be modified after creation'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER tr_instance_metadata_id_immutable + BEFORE UPDATE ON instance_metadata + FOR EACH ROW + EXECUTE FUNCTION prevent_instance_metadata_modification(); \ No newline at end of file diff --git a/backend/pkg/web/server.go b/backend/pkg/web/server.go index 24fc809..df9ce24 100644 --- a/backend/pkg/web/server.go +++ b/backend/pkg/web/server.go @@ -19,6 +19,7 @@ import ( "github.com/btouchard/ackify-ce/backend/internal/infrastructure/database" "github.com/btouchard/ackify-ce/backend/internal/infrastructure/email" "github.com/btouchard/ackify-ce/backend/internal/infrastructure/i18n" + "github.com/btouchard/ackify-ce/backend/internal/infrastructure/tenant" whworker "github.com/btouchard/ackify-ce/backend/internal/infrastructure/webhook" "github.com/btouchard/ackify-ce/backend/internal/infrastructure/workers" "github.com/btouchard/ackify-ce/backend/internal/presentation/api" @@ -48,14 +49,25 @@ func NewServer(ctx context.Context, cfg *config.Config, frontend embed.FS, versi return nil, fmt.Errorf("failed to initialize infrastructure: %w", err) } + // Initialize tenant provider + tenantProvider, err := tenant.NewSingleTenantProviderWithContext(ctx, db) + if err != nil { + return nil, fmt.Errorf("failed to initialize tenant provider: %w", err) + } + tenantID, err := tenantProvider.CurrentTenant(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get current tenant ID: %w", err) + } + logger.Logger.Info("Tenant provider initialized", "tenant_id", tenantID) + // Initialize repositories - signatureRepo := database.NewSignatureRepository(db) - documentRepo := database.NewDocumentRepository(db) - expectedSignerRepo := database.NewExpectedSignerRepository(db) - reminderRepo := database.NewReminderRepository(db) - emailQueueRepo := database.NewEmailQueueRepository(db) - webhookRepo := database.NewWebhookRepository(db) - webhookDeliveryRepo := database.NewWebhookDeliveryRepository(db) + signatureRepo := database.NewSignatureRepository(db, tenantProvider) + documentRepo := database.NewDocumentRepository(db, tenantProvider) + expectedSignerRepo := database.NewExpectedSignerRepository(db, tenantProvider) + reminderRepo := database.NewReminderRepository(db, tenantProvider) + emailQueueRepo := database.NewEmailQueueRepository(db, tenantProvider) + webhookRepo := database.NewWebhookRepository(db, tenantProvider) + webhookDeliveryRepo := database.NewWebhookDeliveryRepository(db, tenantProvider) magicLinkRepo := database.NewMagicLinkRepository(db) // Initialize webhook publisher and worker diff --git a/go.mod b/go.mod index e0e589e..1221f4d 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/go-chi/chi/v5 v5.2.3 github.com/go-mail/mail/v2 v2.3.0 github.com/golang-migrate/migrate/v4 v4.19.0 + github.com/google/uuid v1.6.0 github.com/gorilla/securecookie v1.1.2 github.com/gorilla/sessions v1.4.0 github.com/lib/pq v1.10.9 diff --git a/go.sum b/go.sum index f18f0f8..a6569ac 100644 --- a/go.sum +++ b/go.sum @@ -35,6 +35,8 @@ github.com/golang-migrate/migrate/v4 v4.19.0 h1:RcjOnCGz3Or6HQYEJ/EEVLfWnmw9Knoi github.com/golang-migrate/migrate/v4 v4.19.0/go.mod h1:9dyEcu+hO+G9hPSw8AIg50yg622pXJsoHItQnDGZkI0= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=