Skip to content

Commit 796d327

Browse files
committed
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
1 parent 249849b commit 796d327

24 files changed

+254
-227
lines changed

backend/internal/application/services/reminder.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88

99
"github.com/btouchard/ackify-ce/backend/internal/domain/models"
1010
"github.com/btouchard/ackify-ce/backend/internal/infrastructure/email"
11+
"github.com/btouchard/ackify-ce/backend/internal/infrastructure/i18n"
1112
"github.com/btouchard/ackify-ce/backend/pkg/logger"
1213
)
1314

@@ -34,6 +35,7 @@ type ReminderService struct {
3435
reminderRepo reminderRepository
3536
emailSender email.Sender
3637
magicLinkService magicLinkService
38+
i18n *i18n.I18n
3739
baseURL string
3840
}
3941

@@ -43,13 +45,15 @@ func NewReminderService(
4345
reminderRepo reminderRepository,
4446
emailSender email.Sender,
4547
magicLinkService magicLinkService,
48+
i18nService *i18n.I18n,
4649
baseURL string,
4750
) *ReminderService {
4851
return &ReminderService{
4952
expectedSignerRepo: expectedSignerRepo,
5053
reminderRepo: reminderRepo,
5154
emailSender: emailSender,
5255
magicLinkService: magicLinkService,
56+
i18n: i18nService,
5357
baseURL: baseURL,
5458
}
5559
}
@@ -177,7 +181,7 @@ func (s *ReminderService) sendSingleReminder(
177181
Status: "sent",
178182
}
179183

180-
err = email.SendSignatureReminderEmail(ctx, s.emailSender, []string{recipientEmail}, locale, docID, docURL, authSignURL, recipientName)
184+
err = email.SendSignatureReminderEmail(ctx, s.emailSender, s.i18n, []string{recipientEmail}, locale, docID, docURL, authSignURL, recipientName)
181185
if err != nil {
182186
log.Status = "failed"
183187
errMsg := err.Error()

backend/internal/application/services/reminder_async.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"time"
88

99
"github.com/btouchard/ackify-ce/backend/internal/domain/models"
10+
"github.com/btouchard/ackify-ce/backend/internal/infrastructure/i18n"
1011
"github.com/btouchard/ackify-ce/backend/pkg/logger"
1112
)
1213

@@ -22,6 +23,7 @@ type ReminderAsyncService struct {
2223
reminderRepo reminderRepository
2324
queueRepo emailQueueRepository
2425
magicLinkService magicLinkService
26+
i18n *i18n.I18n
2527
baseURL string
2628
useAsyncQueue bool // Feature flag to enable/disable async queue
2729
}
@@ -32,13 +34,15 @@ func NewReminderAsyncService(
3234
reminderRepo reminderRepository,
3335
queueRepo emailQueueRepository,
3436
magicLinkService magicLinkService,
37+
i18nService *i18n.I18n,
3538
baseURL string,
3639
) *ReminderAsyncService {
3740
return &ReminderAsyncService{
3841
expectedSignerRepo: expectedSignerRepo,
3942
reminderRepo: reminderRepo,
4043
queueRepo: queueRepo,
4144
magicLinkService: magicLinkService,
45+
i18n: i18nService,
4246
baseURL: baseURL,
4347
useAsyncQueue: true, // Enable async by default
4448
}
@@ -169,11 +173,17 @@ func (s *ReminderAsyncService) queueSingleReminder(
169173
"Locale": locale,
170174
}
171175

176+
// Get translated subject using i18n
177+
subject := "Document Reading Confirmation Reminder" // Fallback
178+
if s.i18n != nil {
179+
subject = s.i18n.T(locale, "email.reminder.subject")
180+
}
181+
172182
// Create email queue input
173183
refType := "signature_reminder"
174184
input := models.EmailQueueInput{
175185
ToAddresses: []string{recipientEmail},
176-
Subject: "Reminder: Document signature required",
186+
Subject: subject,
177187
Template: "signature_reminder",
178188
Locale: locale,
179189
Data: data,

backend/internal/application/services/reminder_test.go

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ func TestReminderService_SendReminders_NoPendingSigners(t *testing.T) {
136136
mockEmailSender := &mockEmailSender{}
137137
mockMagicLink := &mockMagicLinkService{}
138138

139-
service := NewReminderService(mockExpectedRepo, mockReminderRepo, mockEmailSender, mockMagicLink, "https://example.com")
139+
service := NewReminderService(mockExpectedRepo, mockReminderRepo, mockEmailSender, mockMagicLink, nil, "https://example.com")
140140

141141
result, err := service.SendReminders(ctx, "doc1", "admin@example.com", nil, "https://example.com/doc.pdf", "en")
142142

@@ -189,7 +189,7 @@ func TestReminderService_SendReminders_Success(t *testing.T) {
189189
}
190190
mockMagicLink := &mockMagicLinkService{}
191191

192-
service := NewReminderService(mockExpectedRepo, mockReminderRepo, mockEmailSender, mockMagicLink, "https://example.com")
192+
service := NewReminderService(mockExpectedRepo, mockReminderRepo, mockEmailSender, mockMagicLink, nil, "https://example.com")
193193

194194
result, err := service.SendReminders(ctx, "doc1", "admin@example.com", nil, "https://example.com/doc.pdf", "en")
195195

@@ -252,7 +252,7 @@ func TestReminderService_SendReminders_EmailFailure(t *testing.T) {
252252
}
253253
mockMagicLink := &mockMagicLinkService{}
254254

255-
service := NewReminderService(mockExpectedRepo, mockReminderRepo, mockEmailSender, mockMagicLink, "https://example.com")
255+
service := NewReminderService(mockExpectedRepo, mockReminderRepo, mockEmailSender, mockMagicLink, nil, "https://example.com")
256256

257257
result, err := service.SendReminders(ctx, "doc1", "admin@example.com", nil, "https://example.com/doc.pdf", "en")
258258

@@ -311,7 +311,7 @@ func TestReminderService_SendReminders_SpecificEmails(t *testing.T) {
311311
}
312312
mockMagicLink := &mockMagicLinkService{}
313313

314-
service := NewReminderService(mockExpectedRepo, mockReminderRepo, mockEmailSender, mockMagicLink, "https://example.com")
314+
service := NewReminderService(mockExpectedRepo, mockReminderRepo, mockEmailSender, mockMagicLink, nil, "https://example.com")
315315

316316
specificEmails := []string{"pending2@example.com"}
317317
result, err := service.SendReminders(ctx, "doc1", "admin@example.com", specificEmails, "https://example.com/doc.pdf", "en")
@@ -344,7 +344,7 @@ func TestReminderService_SendReminders_RepositoryError(t *testing.T) {
344344
mockEmailSender := &mockEmailSender{}
345345
mockMagicLink := &mockMagicLinkService{}
346346

347-
service := NewReminderService(mockExpectedRepo, mockReminderRepo, mockEmailSender, mockMagicLink, "https://example.com")
347+
service := NewReminderService(mockExpectedRepo, mockReminderRepo, mockEmailSender, mockMagicLink, nil, "https://example.com")
348348

349349
result, err := service.SendReminders(ctx, "doc1", "admin@example.com", nil, "https://example.com/doc.pdf", "en")
350350

@@ -381,7 +381,7 @@ func TestReminderService_GetReminderHistory(t *testing.T) {
381381
},
382382
}
383383

384-
service := NewReminderService(&mockExpectedSignerRepository{}, mockReminderRepo, &mockEmailSender{}, &mockMagicLinkService{}, "https://example.com")
384+
service := NewReminderService(&mockExpectedSignerRepository{}, mockReminderRepo, &mockEmailSender{}, &mockMagicLinkService{}, nil, "https://example.com")
385385

386386
logs, err := service.GetReminderHistory(ctx, "doc1")
387387

@@ -419,7 +419,7 @@ func TestReminderService_GetReminderStats(t *testing.T) {
419419
},
420420
}
421421

422-
service := NewReminderService(&mockExpectedSignerRepository{}, mockReminderRepo, &mockEmailSender{}, &mockMagicLinkService{}, "https://example.com")
422+
service := NewReminderService(&mockExpectedSignerRepository{}, mockReminderRepo, &mockEmailSender{}, &mockMagicLinkService{}, nil, "https://example.com")
423423

424424
stats, err := service.GetReminderStats(ctx, "doc1")
425425

@@ -470,7 +470,7 @@ func TestReminderService_SendReminders_MultiplePending(t *testing.T) {
470470
}
471471
mockMagicLink := &mockMagicLinkService{}
472472

473-
service := NewReminderService(mockExpectedRepo, mockReminderRepo, mockEmailSender, mockMagicLink, "https://example.com")
473+
service := NewReminderService(mockExpectedRepo, mockReminderRepo, mockEmailSender, mockMagicLink, nil, "https://example.com")
474474

475475
result, err := service.SendReminders(ctx, "doc1", "admin@example.com", nil, "https://example.com/doc.pdf", "en")
476476

@@ -517,7 +517,7 @@ func TestReminderService_SendReminders_LogFailure(t *testing.T) {
517517
}
518518
mockMagicLink := &mockMagicLinkService{}
519519

520-
service := NewReminderService(mockExpectedRepo, mockReminderRepo, mockEmailSender, mockMagicLink, "https://example.com")
520+
service := NewReminderService(mockExpectedRepo, mockReminderRepo, mockEmailSender, mockMagicLink, nil, "https://example.com")
521521

522522
result, err := service.SendReminders(ctx, "doc1", "admin@example.com", nil, "https://example.com/doc.pdf", "en")
523523

backend/internal/domain/models/magic_link.go

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
// SPDX-License-Identifier: AGPL-3.0-or-later
22
package models
33

4-
import "time"
4+
import (
5+
"time"
6+
7+
"github.com/google/uuid"
8+
)
59

610
// MagicLinkToken représente un token de connexion Magic Link
711
type MagicLinkToken struct {
812
ID int64 `json:"id" db:"id"`
13+
TenantID *uuid.UUID `json:"tenant_id,omitempty" db:"tenant_id"` // NULL for login requests, set for admin reminders
914
Token string `json:"token" db:"token"`
1015
Email string `json:"email" db:"email"`
1116
CreatedAt time.Time `json:"created_at" db:"created_at"`
@@ -33,11 +38,12 @@ func (t *MagicLinkToken) IsValid() bool {
3338

3439
// MagicLinkAuthAttempt représente une tentative d'authentification
3540
type MagicLinkAuthAttempt struct {
36-
ID int64 `json:"id" db:"id"`
37-
Email string `json:"email" db:"email"`
38-
Success bool `json:"success" db:"success"`
39-
FailureReason string `json:"failure_reason,omitempty" db:"failure_reason"`
40-
IPAddress string `json:"ip_address" db:"ip_address"`
41-
UserAgent string `json:"user_agent,omitempty" db:"user_agent"`
42-
AttemptedAt time.Time `json:"attempted_at" db:"attempted_at"`
41+
ID int64 `json:"id" db:"id"`
42+
TenantID *uuid.UUID `json:"tenant_id,omitempty" db:"tenant_id"` // May be NULL before authentication
43+
Email string `json:"email" db:"email"`
44+
Success bool `json:"success" db:"success"`
45+
FailureReason string `json:"failure_reason,omitempty" db:"failure_reason"`
46+
IPAddress string `json:"ip_address" db:"ip_address"`
47+
UserAgent string `json:"user_agent,omitempty" db:"user_agent"`
48+
AttemptedAt time.Time `json:"attempted_at" db:"attempted_at"`
4349
}

backend/internal/domain/models/oauth_session.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
// SPDX-License-Identifier: AGPL-3.0-or-later
22
package models
33

4-
import "time"
4+
import (
5+
"time"
6+
7+
"github.com/google/uuid"
8+
)
59

610
// OAuthSession represents an OAuth session with encrypted refresh token
711
type OAuthSession struct {
812
ID int64
13+
TenantID uuid.UUID
914
SessionID string
1015
UserSub string
1116
RefreshTokenEncrypted []byte

backend/internal/infrastructure/database/document_repository_test.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ func TestDocumentRepository_Create(t *testing.T) {
2525
testDB := SetupTestDB(t)
2626

2727
ctx := context.Background()
28-
repo := NewDocumentRepository(testDB.DB)
28+
repo := NewDocumentRepository(testDB.DB, testDB.TenantProvider)
2929

3030
input := models.DocumentInput{
3131
Title: "Test Document",
@@ -81,7 +81,7 @@ func TestDocumentRepository_GetByDocID(t *testing.T) {
8181
testDB := SetupTestDB(t)
8282

8383
ctx := context.Background()
84-
repo := NewDocumentRepository(testDB.DB)
84+
repo := NewDocumentRepository(testDB.DB, testDB.TenantProvider)
8585

8686
input := models.DocumentInput{
8787
Title: "Get Test Document",
@@ -128,7 +128,7 @@ func TestDocumentRepository_Update(t *testing.T) {
128128
testDB := SetupTestDB(t)
129129

130130
ctx := context.Background()
131-
repo := NewDocumentRepository(testDB.DB)
131+
repo := NewDocumentRepository(testDB.DB, testDB.TenantProvider)
132132

133133
input := models.DocumentInput{
134134
Title: "Original Title",
@@ -191,7 +191,7 @@ func TestDocumentRepository_CreateOrUpdate(t *testing.T) {
191191
testDB := SetupTestDB(t)
192192

193193
ctx := context.Background()
194-
repo := NewDocumentRepository(testDB.DB)
194+
repo := NewDocumentRepository(testDB.DB, testDB.TenantProvider)
195195

196196
input := models.DocumentInput{
197197
Title: "CreateOrUpdate Test",
@@ -248,7 +248,7 @@ func TestDocumentRepository_Delete(t *testing.T) {
248248
testDB := SetupTestDB(t)
249249

250250
ctx := context.Background()
251-
repo := NewDocumentRepository(testDB.DB)
251+
repo := NewDocumentRepository(testDB.DB, testDB.TenantProvider)
252252

253253
input := models.DocumentInput{
254254
Title: "Delete Test",
@@ -289,7 +289,7 @@ func TestDocumentRepository_List(t *testing.T) {
289289
testDB := SetupTestDB(t)
290290

291291
ctx := context.Background()
292-
repo := NewDocumentRepository(testDB.DB)
292+
repo := NewDocumentRepository(testDB.DB, testDB.TenantProvider)
293293

294294
// Create multiple documents
295295
for i := 1; i <= 5; i++ {
@@ -348,7 +348,7 @@ func TestDocumentRepository_FindByReference_Integration(t *testing.T) {
348348
testDB := SetupTestDB(t)
349349

350350
ctx := context.Background()
351-
repo := NewDocumentRepository(testDB.DB)
351+
repo := NewDocumentRepository(testDB.DB, testDB.TenantProvider)
352352

353353
// Create a document first
354354
input := models.DocumentInput{

backend/internal/infrastructure/database/expected_signer_repository_test.go

Lines changed: 6 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,7 @@ import (
1212

1313
func TestExpectedSignerRepository_AddExpected(t *testing.T) {
1414
testDB := SetupTestDB(t)
15-
setupExpectedSignersTable(t, testDB)
16-
repo := NewExpectedSignerRepository(testDB.DB)
15+
repo := NewExpectedSignerRepository(testDB.DB, testDB.TenantProvider)
1716
ctx := context.Background()
1817

1918
tests := []struct {
@@ -98,9 +97,8 @@ func TestExpectedSignerRepository_AddExpected(t *testing.T) {
9897

9998
func TestExpectedSignerRepository_ListWithStatusByDocID(t *testing.T) {
10099
testDB := SetupTestDB(t)
101-
setupExpectedSignersTable(t, testDB)
102-
sigRepo := NewSignatureRepository(testDB.DB)
103-
expectedRepo := NewExpectedSignerRepository(testDB.DB)
100+
sigRepo := NewSignatureRepository(testDB.DB, testDB.TenantProvider)
101+
expectedRepo := NewExpectedSignerRepository(testDB.DB, testDB.TenantProvider)
104102
factory := NewSignatureFactory()
105103
ctx := context.Background()
106104

@@ -161,9 +159,8 @@ func TestExpectedSignerRepository_ListWithStatusByDocID(t *testing.T) {
161159

162160
func TestExpectedSignerRepository_GetStats(t *testing.T) {
163161
testDB := SetupTestDB(t)
164-
setupExpectedSignersTable(t, testDB)
165-
sigRepo := NewSignatureRepository(testDB.DB)
166-
expectedRepo := NewExpectedSignerRepository(testDB.DB)
162+
sigRepo := NewSignatureRepository(testDB.DB, testDB.TenantProvider)
163+
expectedRepo := NewExpectedSignerRepository(testDB.DB, testDB.TenantProvider)
167164
factory := NewSignatureFactory()
168165
ctx := context.Background()
169166

@@ -223,8 +220,7 @@ func TestExpectedSignerRepository_GetStats(t *testing.T) {
223220

224221
func TestExpectedSignerRepository_Remove(t *testing.T) {
225222
testDB := SetupTestDB(t)
226-
setupExpectedSignersTable(t, testDB)
227-
repo := NewExpectedSignerRepository(testDB.DB)
223+
repo := NewExpectedSignerRepository(testDB.DB, testDB.TenantProvider)
228224
ctx := context.Background()
229225

230226
// Setup
@@ -264,49 +260,6 @@ func TestExpectedSignerRepository_Remove(t *testing.T) {
264260

265261
// Helper functions
266262

267-
func setupExpectedSignersTable(t *testing.T, testDB *TestDB) {
268-
t.Helper()
269-
270-
schema := `
271-
DROP TABLE IF EXISTS reminder_logs;
272-
DROP TABLE IF EXISTS expected_signers;
273-
274-
CREATE TABLE expected_signers (
275-
id BIGSERIAL PRIMARY KEY,
276-
doc_id TEXT NOT NULL,
277-
email TEXT NOT NULL,
278-
name TEXT NOT NULL DEFAULT '',
279-
added_at TIMESTAMPTZ NOT NULL DEFAULT now(),
280-
added_by TEXT NOT NULL,
281-
notes TEXT,
282-
UNIQUE (doc_id, email)
283-
);
284-
285-
CREATE INDEX idx_expected_signers_doc_id ON expected_signers(doc_id);
286-
CREATE INDEX idx_expected_signers_email ON expected_signers(email);
287-
288-
CREATE TABLE reminder_logs (
289-
id BIGSERIAL PRIMARY KEY,
290-
doc_id TEXT NOT NULL,
291-
recipient_email TEXT NOT NULL,
292-
sent_at TIMESTAMPTZ NOT NULL DEFAULT now(),
293-
sent_by TEXT NOT NULL,
294-
template_used TEXT NOT NULL,
295-
status TEXT NOT NULL CHECK (status IN ('sent', 'failed', 'bounced')),
296-
error_message TEXT,
297-
FOREIGN KEY (doc_id, recipient_email) REFERENCES expected_signers(doc_id, email) ON DELETE CASCADE
298-
);
299-
300-
CREATE INDEX idx_reminder_logs_doc_id ON reminder_logs(doc_id);
301-
CREATE INDEX idx_reminder_logs_recipient_email ON reminder_logs(recipient_email);
302-
`
303-
304-
_, err := testDB.DB.Exec(schema)
305-
if err != nil {
306-
t.Fatalf("failed to setup expected_signers table: %v", err)
307-
}
308-
}
309-
310263
func clearExpectedSignersTable(t *testing.T, testDB *TestDB) {
311264
t.Helper()
312265
_, err := testDB.DB.Exec("TRUNCATE TABLE reminder_logs, expected_signers RESTART IDENTITY CASCADE")

0 commit comments

Comments
 (0)