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
This commit is contained in:
Benjamin
2025-12-03 21:26:31 +01:00
parent 6d2dd8b000
commit 249849b3ed
21 changed files with 738 additions and 187 deletions

View File

@@ -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"`

View File

@@ -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"`

View File

@@ -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"`

View File

@@ -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

View File

@@ -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"`

View File

@@ -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"`

View File

@@ -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

View File

@@ -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"`

View File

@@ -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)

View File

@@ -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,

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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
}

View File

@@ -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;

View File

@@ -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();

View File

@@ -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

1
go.mod
View File

@@ -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

2
go.sum
View File

@@ -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=