mirror of
https://github.com/btouchard/ackify.git
synced 2026-01-05 20:39:39 -06:00
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:
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
|
||||
7
backend/internal/domain/models/tenant.go
Normal file
7
backend/internal/domain/models/tenant.go
Normal 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
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
41
backend/internal/infrastructure/tenant/provider.go
Normal file
41
backend/internal/infrastructure/tenant/provider.go
Normal 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
|
||||
}
|
||||
42
backend/migrations/0015_add_tenant_support.down.sql
Normal file
42
backend/migrations/0015_add_tenant_support.down.sql
Normal 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;
|
||||
176
backend/migrations/0015_add_tenant_support.up.sql
Normal file
176
backend/migrations/0015_add_tenant_support.up.sql
Normal 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();
|
||||
@@ -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
1
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
|
||||
|
||||
2
go.sum
2
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=
|
||||
|
||||
Reference in New Issue
Block a user