Saltar al contenido principal

Recipient Targeting System - Hexagonal Architecture Implementation βœ…

Status: Phase 1 Complete! πŸŽ‰β€‹

Date Completed: October 25, 2025


βœ… What's Been Accomplished​

1. Database Layer βœ…β€‹

  • Migration file created and approved
  • Migration successfully run
  • 4 new tables created:
    • communication_recipient_group
    • communication_group_member
    • business_communication_recipient_rule
    • communication_recipient_log
  • 2 new enum types created:
    • recipient_member_type
    • recipient_targeting_type

2. Domain Layer (Ports) βœ…β€‹

Following hexagonal architecture principles:

Created Domain Interfaces:

  • apps/backend/src/recipient-groups/domain/recipient-groups-repository.domain.ts

    • IRecipientGroupsRepository interface
    • Defines contract for group operations
  • apps/backend/src/recipient-rules/domain/recipient-rules-repository.domain.ts

    • IRecipientRulesRepository interface
    • Defines contract for rule operations

Purpose: Domain layer defines the business rules and contracts (ports) without any implementation details.


3. Infrastructure Layer (Adapters) βœ…β€‹

Created Repository Implementations:

  • apps/backend/src/recipient-groups/infrastructure/recipient-groups.repository.ts

    • Implements IRecipientGroupsRepository
    • Handles database operations via Kysely
    • Methods: create, find, update, delete groups and members
  • apps/backend/src/recipient-rules/infrastructure/recipient-rules.repository.ts

    • Implements IRecipientRulesRepository
    • Handles rule queries and mutations
    • Key method: findActiveRules() for recipient resolution

Purpose: Infrastructure layer provides concrete implementations of domain interfaces using external dependencies (database).


4. Application Layer (Use Cases) βœ…β€‹

Created Core Service:

  • apps/backend/src/communications/application/services/recipient-resolver.service.ts
    • RecipientResolverService - Core business logic
    • Depends only on domain interfaces (ports), not infrastructure
    • Main method: resolveRecipients() - resolves rules to actual recipients
    • Handles: role-based, group-based, and ad-hoc targeting
    • Implements deduplication logic
    • Includes fallback for backward compatibility

Purpose: Application layer orchestrates business logic using domain contracts.


5. Module Configuration (DI) βœ…β€‹

Created Modules with Proper Dependency Injection:

  • apps/backend/src/recipient-groups/recipient-groups.module.ts

    • Binds IRecipientGroupsRepository β†’ RecipientGroupsRepository
    • Exports both interface and implementation
  • apps/backend/src/recipient-rules/recipient-rules.module.ts

    • Binds IRecipientRulesRepository β†’ RecipientRulesRepository
    • Exports both interface and implementation

Updated Existing Module:

  • apps/backend/src/communications/communications.module.ts
    • Imports: RecipientGroupsModule, RecipientRulesModule
    • Provides: RecipientResolverService
    • Exports: RecipientResolverService (for use in other modules)

6. Type Definitions βœ…β€‹

Created Type System:

  • packages/backend/database/src/types/recipient-targeting.types.ts
    • Selectable, Insertable, Updateable types for all tables
    • Enum types: RecipientMemberType, RecipientTargetingType
    • ResolvedRecipient interface (core DTO)
    • RecipientRuleFilters interface
    • GroupMemberWithUser interface (for joins)

7. Build & Compilation βœ…β€‹

  • All TypeScript compilation errors fixed
  • Backend builds successfully
  • Hexagonal architecture properly implemented
  • Dependency injection working correctly

πŸ—οΈ Hexagonal Architecture Implemented​

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ DOMAIN LAYER (Ports) β”‚
β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
β”‚ β”‚ IRecipientGroupsRepository (Interface) β”‚ β”‚
β”‚ β”‚ IRecipientRulesRepository (Interface) β”‚ β”‚
β”‚ β”‚ β†’ Defines business contracts β”‚ β”‚
β”‚ β”‚ β†’ No dependencies β”‚ β”‚
β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β–²
β”‚ depends on
β”‚
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ APPLICATION LAYER (Use Cases) β”‚
β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
β”‚ β”‚ RecipientResolverService β”‚ β”‚
β”‚ β”‚ β†’ Orchestrates business logic β”‚ β”‚
β”‚ β”‚ β†’ Depends on domain interfaces (ports) β”‚ β”‚
β”‚ β”‚ β†’ resolveRecipients(), previewRecipients() β”‚ β”‚
β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β–²
β”‚ implemented by
β”‚
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ INFRASTRUCTURE LAYER (Adapters) β”‚
β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
β”‚ β”‚ RecipientGroupsRepository (implements port) β”‚ β”‚
β”‚ β”‚ RecipientRulesRepository (implements port) β”‚ β”‚
β”‚ β”‚ β†’ Concrete implementations β”‚ β”‚
β”‚ β”‚ β†’ Uses Kysely for database access β”‚ β”‚
β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Benefits:

  • βœ… Business logic independent of infrastructure
  • βœ… Easy to test (mock interfaces)
  • βœ… Easy to swap implementations
  • βœ… Clear separation of concerns
  • βœ… Dependency inversion principle

πŸ“¦ File Structure Created​

apps/backend/src/
β”œβ”€β”€ recipient-groups/
β”‚ β”œβ”€β”€ domain/
β”‚ β”‚ └── recipient-groups-repository.domain.ts ← PORT
β”‚ β”œβ”€β”€ infrastructure/
β”‚ β”‚ └── recipient-groups.repository.ts ← ADAPTER
β”‚ └── recipient-groups.module.ts ← DI CONFIG
β”‚
β”œβ”€β”€ recipient-rules/
β”‚ β”œβ”€β”€ domain/
β”‚ β”‚ └── recipient-rules-repository.domain.ts ← PORT
β”‚ β”œβ”€β”€ infrastructure/
β”‚ β”‚ └── recipient-rules.repository.ts ← ADAPTER
β”‚ └── recipient-rules.module.ts ← DI CONFIG
β”‚
└── communications/
└── application/
└── services/
└── recipient-resolver.service.ts ← USE CASE

packages/backend/database/src/
└── types/
└── recipient-targeting.types.ts ← TYPES

πŸ§ͺ What You Can Test Now​

Test 1: Create a Test Group (SQL)​

-- Insert a test group
INSERT INTO communication_recipient_group
(business_id, name, description, is_active, created_by, created_at)
VALUES
(
'YOUR_BUSINESS_ID',
'Test Recipient Group',
'Testing the recipient targeting system',
true,
'YOUR_USER_ID',
NOW()
)
RETURNING *;

Test 2: Add Members to Group​

-- Add a business user
INSERT INTO communication_group_member
(group_id, member_type, business_user_id, is_active, added_by, added_at)
VALUES
('GROUP_ID_FROM_PREVIOUS_STEP', 'business_user', 'BUSINESS_USER_ID', true, 'YOUR_USER_ID', NOW())
RETURNING *;

-- Add an external email
INSERT INTO communication_group_member
(group_id, member_type, email_address, display_name, is_active, added_by, added_at)
VALUES
('GROUP_ID_FROM_PREVIOUS_STEP', 'email', 'test@example.com', 'Test Contact', true, 'YOUR_USER_ID', NOW())
RETURNING *;

Test 3: Create Test Rules​

-- Rule: Send to inventory_manager role
INSERT INTO business_communication_recipient_rule
(business_id, communication_type, channel, targeting_type, role_name, is_active, priority, created_by, created_at)
VALUES
('YOUR_BUSINESS_ID', 'low_stock_alert', 'email', 'role', 'inventory_manager', true, 1, 'YOUR_USER_ID', NOW())
RETURNING *;

-- Rule: Send to test group
INSERT INTO business_communication_recipient_rule
(business_id, communication_type, channel, targeting_type, group_id, is_active, priority, created_by, created_at)
VALUES
('YOUR_BUSINESS_ID', 'low_stock_alert', 'email', 'group', 'GROUP_ID', true, 2, 'YOUR_USER_ID', NOW())
RETURNING *;

🎯 What's Next (Phase 2)​

Step 1: Update Low Stock Alert Handler​

File to modify: apps/backend/src/communications/application/events/on-create-low-stock-alert.handler.ts

Changes needed:

  1. Inject RecipientResolverService in constructor
  2. Replace hardcoded recipient logic with recipientResolverService.resolveRecipients()
  3. Update send loop to use resolved recipients

Template code:

constructor(
// ... existing dependencies
private readonly recipientResolverService: RecipientResolverService,
) {}

async handleOnCreateLowStockAlert(event: OnCreateLowStockAlertEvent) {
// OLD CODE (remove):
// const businessUsers = await this.businessUsersService.findByBusinessId(...);

// NEW CODE (add):
const recipients = await this.recipientResolverService.resolveRecipients(
businessId,
'low_stock_alert',
'email'
);

// Update send loop to use recipients instead of businessUsers
for (const recipient of recipients) {
await this.communicationsService.send({
recipientType: recipient.type,
recipientId: recipient.userId,
recipientContact: recipient.contact,
recipientName: recipient.name,
// ... rest of your config
});
}
}

Step 2: Test End-to-End​

  1. Create test data (groups + rules) via SQL
  2. Trigger a low stock alert
  3. Verify recipients are resolved correctly
  4. Check logs for recipient selection details

Step 3: Create APIs (Optional for now)​

Create REST APIs for managing groups and rules:

  • Group CRUD endpoints
  • Member management endpoints
  • Rule CRUD endpoints
  • Preview endpoint (see who will receive before saving)

Step 4: Create Admin UI (Later)​

Build frontend interfaces for:

  • Group management page
  • Rule configuration page
  • Recipient preview feature

πŸ“Š Progress Summary​

PhaseStatusCompletion
Phase 1: Foundationβœ… Complete100%
β”œβ”€ Database Migrationβœ…
β”œβ”€ Domain Layer (Ports)βœ…
β”œβ”€ Infrastructure Layerβœ…
β”œβ”€ Application Layerβœ…
β”œβ”€ Module Configurationβœ…
└─ Build & Compilationβœ…
Phase 2: IntegrationπŸ“‹ Next0%
β”œβ”€ Update Low Stock HandlerπŸ“‹
β”œβ”€ End-to-end TestingπŸ“‹
└─ Logging & DebuggingπŸ“‹
Phase 3: APIsπŸ”œ Future0%
Phase 4: Admin UIπŸ”œ Future0%

πŸŽ“ Key Architectural Decisions​

1. Hexagonal Architecture​

Why: Separates business logic from infrastructure concerns

  • Domain defines contracts (ports)
  • Infrastructure provides implementations (adapters)
  • Application orchestrates using ports

2. Dependency Injection​

Why: Loose coupling, testability

  • Modules bind interfaces to implementations
  • Services depend on interfaces, not concrete classes

3. Polymorphic Data Model​

Why: Flexibility for future extensions

  • Groups can contain different member types
  • Rules support multiple targeting types
  • Easy to add new types without schema changes

4. Deduplication Logic​

Why: Same person via multiple rules should receive once

  • Prevents spam
  • Better user experience

5. Fallback Behavior​

Why: Backward compatibility

  • If no rules configured β†’ send to all business users
  • Existing handlers continue to work

πŸ“ Notes & Considerations​

Type Casting in Kysely​

We use as any for enum types in Kysely queries because:

  • Kysely's type system is strict about enums
  • Our database enums need explicit casting
  • Alternative: Use sql tagged template for enums (more verbose)

Example:

.where("communicationType", "=", filters.communicationType as any)

Null vs Undefined​

Database columns can be null, but TypeScript prefers undefined for optional properties.

  • Joined user fields: userName?: string | null
  • This allows proper handling of LEFT JOINs

πŸš€ Ready to Proceed​

You are now ready to:

  1. βœ… Test the database manually (create groups/rules via SQL)
  2. βœ… Update the low stock alert handler to use RecipientResolverService
  3. βœ… Test end-to-end flow with real low stock alerts
  4. βœ… Build APIs when you're ready
  5. βœ… Build admin UI when you're ready

All foundational work is complete following hexagonal architecture principles! πŸŽ‰


πŸ“ž Support​

Documentation:

  • QUICK-START-IMPLEMENTATION.md - Step-by-step guide
  • RECIPIENT-TARGETING-PROPOSAL.md - Full proposal
  • recipient-targeting-system-design.md - Architecture details
  • recipient-targeting-erd.md - Database diagrams

Need Help?

  • Review the implementation checklist
  • Check the architecture design doc
  • Test manually with SQL queries first

Congratulations! Phase 1 is complete and builds successfully! πŸŽ‰