Skip to Content
ActivitytemplatesFeature: Global Activity Templates

Feature: Global Activity Templates

Version: 1.0
Status: Ready for Implementation
Last Updated: 2026-03-24


1. Problem

Currently, default activity templates are stored in 10 JSON files on disk. These files are loaded at runtime whenever a new entity is created and used to seed per-tenant ActivityTemplate copies.

Pain points:

  • JSON files cannot be edited without a code deployment
  • No audit trail for changes
  • No way to push updates to existing tenant copies after initial seeding
  • Cannot deactivate a template globally without touching every tenant
  • At scale (100+ tenants × 50+ entities), maintaining consistency is impossible

2. Solution Overview

Replace the 10 JSON files with a GlobalActivityTemplate MongoDB collection.

  • Super-admins manage global templates via the Admin API
  • When a new entity is created, the connect handler reads from the DB instead of JSON files
  • When a global template is edited or deactivated, an async background worker propagates changes to all tenant copies
  • Tenant copies track their origin via GlobalTemplateId and customization status via IsCustomized

Scale reality: 100+ tenants × 50+ entities = 10,000+ copies per global template. All propagation must be async. HTTP endpoints return { status: "queued" } immediately.


3. Architecture Decisions

DecisionChoiceReason
StorageMongoDB GlobalActivityTemplates collectionConsistent with all other templates
Propagation modelAsync background worker10K+ copies per template; cannot block HTTP
Worker transportAzure Service Bus via MassTransitExisting infrastructure
Task patternGlobalTemplatePropagationTask : TaskMessageMatches existing TaskMessage/TaskHandlerBase pattern
Soft deleteIsActive = false instead of hard deleteAllows recovery; tenant copies deactivated, not deleted
Customization trackingIsCustomized flag on ActivityTemplateSkip customized copies during “sync” propagation
Auto-push on createNoSuper-admin explicitly calls POST /{id}/push
Audit logging — global templatesIAuditLogService called from service layer (Admin API)Who created/updated/deleted the master template, stored in PostgreSQL AuditLogs table
Audit logging — tenant copies (propagation)One summary audit entry per propagation run on the global template entity10K+ per-copy entries would be impractical; summary entry records count + initiating user
Audit user attribution in workerInitiatedByUserId carried in GlobalTemplatePropagationTask messageWorker has no HTTP context; user ID from original request is passed through the queue message

4. Data Model

4.1 New Entity: GlobalActivityTemplate

File: Leadmetrics.Feature.Activities/Entities/GlobalActivityTemplate.cs

using Leadmetrics.Data.Shared.Attributes; using Leadmetrics.Data.Shared.Entities; using System; using System.Collections.Generic; namespace Leadmetrics.Feature.Activities.Entities { [CollectionName("GlobalActivityTemplates")] public class GlobalActivityTemplate : BaseEntity { public string Title { get; set; } public string Description { get; set; } public List<string> Channels { get; set; } = new(); public List<string> Tags { get; set; } = new(); public string Type { get; set; } public int DueInDays { get; set; } public string Frequency { get; set; } public string EntityType { get; set; } public string EntitySubType { get; set; } public string TriggerEvent { get; set; } public bool IsActive { get; set; } = true; public DateTime? UpdatedOn { get; set; } } }

4.2 Modified Entity: ActivityTemplate

File: Leadmetrics.Feature.Activities/Entities/ActivityTemplate.cs

Add two fields to the existing class:

// Link back to the global master template (null = fully custom) public string GlobalTemplateId { get; set; } // True if the tenant has manually edited this copy public bool IsCustomized { get; set; } = false;

5. Service Layer

5.1 Interface

File: Leadmetrics.Feature.Activities/Services/IGlobalActivityTemplateService.cs

using Leadmetrics.Feature.Activities.DTOs; using Leadmetrics.Feature.Activities.Entities; using Leadmetrics.Feature.Activities.Models; using System.Collections.Generic; using System.Threading.Tasks; namespace Leadmetrics.Feature.Activities.Services { public interface IGlobalActivityTemplateService { // Read Task<GlobalActivityTemplate> GetByIdAsync(string id); Task<List<GlobalActivityTemplate>> GetByTriggerAsync(string entitySubType, string triggerEvent); Task<PagedResult<GlobalActivityTemplate>> SearchAsync(GlobalActivityTemplateFilterDTO filter, int page, int size); Task<GlobalActivityTemplateCopiesSummaryDTO> GetCopiesSummaryAsync(string globalTemplateId); // Write — userId and tenantId are passed from the controller for audit logging Task<GlobalActivityTemplate> CreateAsync(NewGlobalActivityTemplateModel model, string userId, string tenantId); Task<GlobalActivityTemplate> UpdateAsync(string id, NewGlobalActivityTemplateModel model, string userId, string tenantId); Task<GlobalActivityTemplate> ReactivateAsync(string id, string userId, string tenantId); Task<GlobalActivityTemplate> DeactivateAsync(string id, string userId, string tenantId); // Propagation (called by background worker; userId = initiating admin from task message) Task<GlobalActivityTemplateSyncResultDTO> SyncToTenantTemplatesAsync(string globalTemplateId, string userId, string tenantId); Task<GlobalActivityTemplateDeactivateResultDTO> PropagateDeactivationAsync(string globalTemplateId, string userId, string tenantId); Task<int> PushToExistingEntitiesAsync(string globalTemplateId, string userId, string tenantId); } }

5.2 DTOs

File: Leadmetrics.Feature.Activities/DTOs/GlobalActivityTemplateDTOs.cs

namespace Leadmetrics.Feature.Activities.DTOs { public record GlobalActivityTemplateFilterDTO( string EntityType, string EntitySubType, string TriggerEvent, string Type, bool? IsActive, string Search ); public record GlobalActivityTemplateSyncResultDTO(int UpdatedCount); public record GlobalActivityTemplateDeactivateResultDTO( int DeactivatedCount, int SkippedCustomizedCount ); public record GlobalActivityTemplateCopiesSummaryDTO( int TotalCopies, int NonCustomizedCopies, int CustomizedCopies ); public record GlobalTemplatePropagationQueuedDTO( string GlobalTemplateId, string OperationType, string Status = "queued" ); }

5.3 Create/Update Model

File: Leadmetrics.Feature.Activities/Models/NewGlobalActivityTemplateModel.cs

using System.Collections.Generic; namespace Leadmetrics.Feature.Activities.Models { public class NewGlobalActivityTemplateModel { public string Title { get; set; } public string Description { get; set; } public List<string> Channels { get; set; } = new(); public List<string> Tags { get; set; } = new(); public string Type { get; set; } public int DueInDays { get; set; } public string Frequency { get; set; } public string EntityType { get; set; } public string EntitySubType { get; set; } public string TriggerEvent { get; set; } } }

5.4 Service Implementation (Key Methods)

File: Leadmetrics.Feature.Activities/Services/GlobalActivityTemplateService.cs

using AutoMapper; using Leadmetrics.Data.Common.Repositories; using Leadmetrics.Data.Shared.Entities; using Leadmetrics.Feature.Activities.DTOs; using Leadmetrics.Feature.Activities.Entities; using Leadmetrics.Feature.Activities.Models; using MongoDB.Driver; using System; using System.Collections.Generic; using System.Threading.Tasks; namespace Leadmetrics.Feature.Activities.Services { public class GlobalActivityTemplateService : IGlobalActivityTemplateService { private readonly IGenericRepository<GlobalActivityTemplate> _globalRepo; private readonly IGenericRepository<ActivityTemplate> _templateRepo; private readonly IMapper _mapper; private readonly IAuditLogService _auditLogService; public GlobalActivityTemplateService( IGenericRepository<GlobalActivityTemplate> globalRepo, IGenericRepository<ActivityTemplate> templateRepo, IMapper mapper, IAuditLogService auditLogService) { _globalRepo = globalRepo; _templateRepo = templateRepo; _mapper = mapper; _auditLogService = auditLogService; } public async Task<GlobalActivityTemplate> GetByIdAsync(string id) => await _globalRepo.GetByIdAsync(id); public async Task<List<GlobalActivityTemplate>> GetByTriggerAsync(string entitySubType, string triggerEvent) { var filter = Builders<GlobalActivityTemplate>.Filter.And( Builders<GlobalActivityTemplate>.Filter.Eq(x => x.EntitySubType, entitySubType), Builders<GlobalActivityTemplate>.Filter.Eq(x => x.TriggerEvent, triggerEvent), Builders<GlobalActivityTemplate>.Filter.Eq(x => x.IsActive, true) ); return await _globalRepo.FindAsync(filter); } public async Task<GlobalActivityTemplate> CreateAsync(NewGlobalActivityTemplateModel model, string userId, string tenantId) { var entity = _mapper.Map<GlobalActivityTemplate>(model); entity.IsActive = true; entity.UpdatedOn = DateTime.UtcNow; await _globalRepo.InsertAsync(entity); var auditLog = AuditLogModel.Create( AuditLogCategory.EntityCRUD, AuditLogEntityType.GlobalActivityTemplate, AuditLogOperationType.Create, entity.Id, JsonConvert.SerializeObject(entity)); await _auditLogService.CreateAuditLogAsync(auditLog, userId, tenantId); return entity; } public async Task<GlobalActivityTemplate> UpdateAsync(string id, NewGlobalActivityTemplateModel model, string userId, string tenantId) { var entity = await _globalRepo.GetByIdAsync(id); _mapper.Map(model, entity); entity.UpdatedOn = DateTime.UtcNow; await _globalRepo.UpdateAsync(entity); var auditLog = AuditLogModel.Create( AuditLogCategory.EntityCRUD, AuditLogEntityType.GlobalActivityTemplate, AuditLogOperationType.Update, entity.Id, JsonConvert.SerializeObject(entity)); await _auditLogService.CreateAuditLogAsync(auditLog, userId, tenantId); return entity; } public async Task<GlobalActivityTemplate> DeactivateAsync(string id, string userId, string tenantId) { var entity = await _globalRepo.GetByIdAsync(id); entity.IsActive = false; entity.UpdatedOn = DateTime.UtcNow; await _globalRepo.UpdateAsync(entity); var auditLog = AuditLogModel.Create( AuditLogCategory.EntityCRUD, AuditLogEntityType.GlobalActivityTemplate, AuditLogOperationType.Delete, entity.Id, JsonConvert.SerializeObject(entity)); await _auditLogService.CreateAuditLogAsync(auditLog, userId, tenantId); return entity; } public async Task<GlobalActivityTemplate> ReactivateAsync(string id, string userId, string tenantId) { var entity = await _globalRepo.GetByIdAsync(id); entity.IsActive = true; entity.UpdatedOn = DateTime.UtcNow; await _globalRepo.UpdateAsync(entity); var auditLog = AuditLogModel.Create( AuditLogCategory.EntityCRUD, AuditLogEntityType.GlobalActivityTemplate, AuditLogOperationType.Update, entity.Id, $"Reactivated GlobalActivityTemplate: {entity.Title}"); await _auditLogService.CreateAuditLogAsync(auditLog, userId, tenantId); return entity; } public async Task<GlobalActivityTemplateCopiesSummaryDTO> GetCopiesSummaryAsync(string globalTemplateId) { var filter = Builders<ActivityTemplate>.Filter.Eq(x => x.GlobalTemplateId, globalTemplateId); var copies = await _templateRepo.FindAsync(filter); int total = copies.Count; int customized = copies.Count(x => x.IsCustomized); return new GlobalActivityTemplateCopiesSummaryDTO(total, total - customized, customized); } public async Task<GlobalActivityTemplateSyncResultDTO> SyncToTenantTemplatesAsync(string globalTemplateId, string userId, string tenantId) { var global = await _globalRepo.GetByIdAsync(globalTemplateId); var filter = Builders<ActivityTemplate>.Filter.And( Builders<ActivityTemplate>.Filter.Eq(x => x.GlobalTemplateId, globalTemplateId), Builders<ActivityTemplate>.Filter.Eq(x => x.IsCustomized, false) ); var copies = await _templateRepo.FindAsync(filter); int updated = 0; foreach (var copy in copies) { copy.Title = global.Title; copy.Description = global.Description; copy.Channels = global.Channels; copy.Tags = global.Tags; copy.Type = global.Type; copy.DueInDays = global.DueInDays; copy.Frequency = global.Frequency; await _templateRepo.UpdateAsync(copy); updated++; } // One summary audit entry for the propagation — not one per copy var auditLog = AuditLogModel.CreateWithInfo( AuditLogCategory.EntityCRUD, AuditLogEntityType.GlobalActivityTemplate, AuditLogOperationType.BulkUpdate, globalTemplateId, $"Sync propagation complete. Updated {updated} tenant activity template copies."); await _auditLogService.CreateAuditLogAsync(auditLog, userId, tenantId); return new GlobalActivityTemplateSyncResultDTO(updated); } public async Task<GlobalActivityTemplateDeactivateResultDTO> PropagateDeactivationAsync(string globalTemplateId, string userId, string tenantId) { var filter = Builders<ActivityTemplate>.Filter.Eq(x => x.GlobalTemplateId, globalTemplateId); var copies = await _templateRepo.FindAsync(filter); int deactivated = 0; int skipped = 0; foreach (var copy in copies) { if (copy.IsCustomized) { skipped++; continue; } copy.IsActive = false; await _templateRepo.UpdateAsync(copy); deactivated++; } // One summary audit entry — not one per copy var auditLog = AuditLogModel.CreateWithInfo( AuditLogCategory.EntityCRUD, AuditLogEntityType.GlobalActivityTemplate, AuditLogOperationType.BulkDelete, globalTemplateId, $"Deactivation propagation complete. Deactivated {deactivated} copies, skipped {skipped} customized copies."); await _auditLogService.CreateAuditLogAsync(auditLog, userId, tenantId); return new GlobalActivityTemplateDeactivateResultDTO(deactivated, skipped); } public async Task<int> PushToExistingEntitiesAsync(string globalTemplateId, string userId, string tenantId) { // Implementation: fan-out new template to all existing entities // that match the EntitySubType+TriggerEvent but have no copy yet // This is complex and entity-type-specific; stub for now // Audit the push operation when implemented: // var auditLog = AuditLogModel.CreateWithInfo( // AuditLogCategory.EntityCRUD, // AuditLogEntityType.GlobalActivityTemplate, // AuditLogOperationType.BulkCreate, // globalTemplateId, // $"Push propagation complete. Created {count} new activity template copies."); // await _auditLogService.CreateAuditLogAsync(auditLog, userId, tenantId); throw new NotImplementedException("Push to existing entities not yet implemented"); } } }

5.5 DI Registration

File: Leadmetrics.Feature.Activities/ActivityAutoModule.cs

Add to the existing Autofac module:

builder.RegisterType<GlobalActivityTemplateService>() .As<IGlobalActivityTemplateService>() .InstancePerLifetimeScope();

6. Background Worker

6.1 Task Message

File: Leadmetrics.Server.Workers/Tasks/GlobalTemplatePropagationTask.cs

using Leadmetrics.Api.Shared.Tasks; namespace Leadmetrics.Server.Workers.Tasks { public class GlobalTemplatePropagationTask : TaskMessage { public GlobalTemplatePropagationTask() : base("global-template-propagation") { } /// <summary> /// "sync" — edit: push field changes to non-customized copies /// "deactivate" — delete: mark all copies inactive /// "push" — new: fan-out template to existing entities /// </summary> public string OperationType { get; set; } public string GlobalTemplateId { get; set; } /// <summary> /// ID of the admin user who triggered this operation. /// Carried through the queue so the worker can attribute audit log entries /// without any HTTP context. /// </summary> public string InitiatedByUserId { get; set; } /// <summary> /// Admin tenant ID (or "global") used as the tenantId when writing audit logs. /// </summary> public string InitiatedByTenantId { get; set; } } }

6.2 Task Handler

File: Leadmetrics.Server.Workers/Handlers/GlobalTemplatePropagationHandler.cs

using Leadmetrics.Api.Shared.Tasks; using Leadmetrics.Feature.Activities.Services; using Microsoft.Extensions.Logging; using System; using System.Threading.Tasks; namespace Leadmetrics.Server.Workers.Handlers { public class GlobalTemplatePropagationHandler : TaskHandlerBase, ITaskHandler { private readonly IGlobalActivityTemplateService _service; private readonly ILogger<GlobalTemplatePropagationHandler> _logger; public GlobalTemplatePropagationHandler( IGlobalActivityTemplateService service, ILogger<GlobalTemplatePropagationHandler> logger) { _service = service; _logger = logger; // Note: IAuditLogService is called inside GlobalActivityTemplateService, // not directly in the handler. The handler passes InitiatedByUserId and // InitiatedByTenantId through to the service methods. } public async Task ProcessAsync(TaskMessage message) { var task = message as GlobalTemplatePropagationTask; if (task == null) return; _logger.LogInformation( "GlobalTemplatePropagation starting. TemplateId={TemplateId} Op={Op}", task.GlobalTemplateId, task.OperationType); try { switch (task.OperationType) { case "sync": var syncResult = await _service.SyncToTenantTemplatesAsync( task.GlobalTemplateId, task.InitiatedByUserId, task.InitiatedByTenantId); _logger.LogInformation( "GlobalTemplatePropagation sync complete. TemplateId={Id} Updated={Count}", task.GlobalTemplateId, syncResult.UpdatedCount); break; case "deactivate": var deactivateResult = await _service.PropagateDeactivationAsync( task.GlobalTemplateId, task.InitiatedByUserId, task.InitiatedByTenantId); _logger.LogInformation( "GlobalTemplatePropagation deactivate complete. TemplateId={Id} Deactivated={D} SkippedCustomized={S}", task.GlobalTemplateId, deactivateResult.DeactivatedCount, deactivateResult.SkippedCustomizedCount); break; case "push": var pushed = await _service.PushToExistingEntitiesAsync( task.GlobalTemplateId, task.InitiatedByUserId, task.InitiatedByTenantId); _logger.LogInformation( "GlobalTemplatePropagation push complete. TemplateId={Id} Pushed={Count}", task.GlobalTemplateId, pushed); break; default: _logger.LogWarning( "GlobalTemplatePropagation unknown OperationType={Op}", task.OperationType); break; } } catch (Exception ex) { _logger.LogError(ex, "GlobalTemplatePropagation failed. TemplateId={Id} Op={Op}", task.GlobalTemplateId, task.OperationType); throw; } } } }

6.3 DI Registration

File: Leadmetrics.Server.Workers/ServerWorkerModule.cs

Add alongside the other handler registrations:

builder.RegisterType<GlobalTemplatePropagationHandler>() .Keyed<ITaskHandler>("global-template-propagation") .InstancePerLifetimeScope();

6.4 Enqueue Pattern (used by the Admin API controller)

var userId = GetLoggedInUserId(); var tenantId = GetLoggedInUserTenantId(); await _busClient.SendAsync("global-template-propagation", new GlobalTemplatePropagationTask { GlobalTemplateId = id, OperationType = "sync", // or "deactivate" or "push" InitiatedByUserId = userId, InitiatedByTenantId = tenantId });

InitiatedByUserId and InitiatedByTenantId are passed in the queue message so the background worker can write audit log entries attributed to the original requesting admin, even though the worker runs with no HTTP context.


7. Admin API Controller

File: Leadmetrics.Admin.Api/Controllers/GlobalActivityTemplatesController.cs

This controller lives in the Admin API (super-admin only). No tenant filtering required.

using Leadmetrics.Api.Shared.Controllers; using Leadmetrics.Api.Shared.Pagination; using Leadmetrics.Feature.Activities.DTOs; using Leadmetrics.Feature.Activities.Models; using Leadmetrics.Feature.Activities.Services; using Leadmetrics.Server.Workers.Tasks; using Leadmetrics.Api.Shared.Bus; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using System.Threading.Tasks; namespace Leadmetrics.Admin.Api.Controllers { [Authorize] [ApiController] [Route("api/global-activity-templates")] public class GlobalActivityTemplatesController : BaseController { private readonly IGlobalActivityTemplateService _service; private readonly IBusClient _busClient; public GlobalActivityTemplatesController( IGlobalActivityTemplateService service, IBusClient busClient) { _service = service; _busClient = busClient; // Note: audit logging is handled inside GlobalActivityTemplateService. // The controller extracts userId + tenantId from JWT/header and passes them // to every write method so the service can record who performed the action. } /// <summary> /// GET /api/global-activity-templates?page=1&size=20 /// Search and filter paged list of global templates. /// </summary> [HttpGet] public async Task<IActionResult> SearchAsync( [FromQuery] GlobalActivityTemplateFilterDTO filter, int page = Paging.DefaultPage, int size = Paging.DefaultPageSize) { return await TryExecuteAsync(async () => { ApplyPagingLimits(ref page, ref size); var result = await _service.SearchAsync(filter, page, size); return result; }); } /// <summary> /// GET /api/global-activity-templates/{id} /// Get a single global template by ID. /// </summary> [HttpGet("{id}")] public async Task<IActionResult> GetByIdAsync(string id) { return await TryExecuteAsync(async () => { var result = await _service.GetByIdAsync(id); return result; }); } /// <summary> /// GET /api/global-activity-templates/{id}/copies /// Preview how many tenant copies exist (impact before sync/deactivate). /// </summary> [HttpGet("{id}/copies")] public async Task<IActionResult> GetCopiesSummaryAsync(string id) { return await TryExecuteAsync(async () => { var result = await _service.GetCopiesSummaryAsync(id); return result; }); } /// <summary> /// POST /api/global-activity-templates /// Create a new global template (does NOT auto-push to tenants). /// Use POST /{id}/push to fan-out when ready. /// </summary> [HttpPost] public async Task<IActionResult> CreateAsync([FromBody] NewGlobalActivityTemplateModel model) { return await TryExecuteAsync(async () => { var userId = GetLoggedInUserId(); var tenantId = GetLoggedInUserTenantId(); var result = await _service.CreateAsync(model, userId, tenantId); return result; }); } /// <summary> /// PUT /api/global-activity-templates/{id} /// Update a global template and enqueue a background "sync" job /// to push changes to all non-customized tenant copies. /// </summary> [HttpPut("{id}")] public async Task<IActionResult> UpdateAsync(string id, [FromBody] NewGlobalActivityTemplateModel model) { return await TryExecuteAsync(async () => { var userId = GetLoggedInUserId(); var tenantId = GetLoggedInUserTenantId(); await _service.UpdateAsync(id, model, userId, tenantId); await _busClient.SendAsync("global-template-propagation", new GlobalTemplatePropagationTask { GlobalTemplateId = id, OperationType = "sync", InitiatedByUserId = userId, InitiatedByTenantId = tenantId }); return new GlobalTemplatePropagationQueuedDTO(id, "sync"); }); } /// <summary> /// DELETE /api/global-activity-templates/{id} /// Soft-delete (IsActive=false) the global template and enqueue /// a background "deactivate" job to deactivate all tenant copies. /// Customized copies are skipped. /// </summary> [HttpDelete("{id}")] public async Task<IActionResult> DeleteAsync(string id) { return await TryExecuteAsync(async () => { var userId = GetLoggedInUserId(); var tenantId = GetLoggedInUserTenantId(); await _service.DeactivateAsync(id, userId, tenantId); await _busClient.SendAsync("global-template-propagation", new GlobalTemplatePropagationTask { GlobalTemplateId = id, OperationType = "deactivate", InitiatedByUserId = userId, InitiatedByTenantId = tenantId }); return new GlobalTemplatePropagationQueuedDTO(id, "deactivate"); }); } /// <summary> /// PUT /api/global-activity-templates/{id}/reactivate /// Restore a previously deactivated global template. /// Does NOT cascade-reactivate tenant copies (use /sync for that). /// </summary> [HttpPut("{id}/reactivate")] public async Task<IActionResult> ReactivateAsync(string id) { return await TryExecuteAsync(async () => { var userId = GetLoggedInUserId(); var tenantId = GetLoggedInUserTenantId(); var result = await _service.ReactivateAsync(id, userId, tenantId); return result; }); } /// <summary> /// POST /api/global-activity-templates/{id}/sync /// Manually re-push global template fields to all non-customized /// tenant copies. Runs in background. /// </summary> [HttpPost("{id}/sync")] public async Task<IActionResult> SyncAsync(string id) { return await TryExecuteAsync(async () => { var userId = GetLoggedInUserId(); var tenantId = GetLoggedInUserTenantId(); await _busClient.SendAsync("global-template-propagation", new GlobalTemplatePropagationTask { GlobalTemplateId = id, OperationType = "sync", InitiatedByUserId = userId, InitiatedByTenantId = tenantId }); return new GlobalTemplatePropagationQueuedDTO(id, "sync"); }); } /// <summary> /// POST /api/global-activity-templates/{id}/push /// Fan-out this template to ALL existing entities that match /// EntitySubType + TriggerEvent but don't yet have a copy. /// Runs in background. Use sparingly — potentially very large batch. /// </summary> [HttpPost("{id}/push")] public async Task<IActionResult> PushAsync(string id) { return await TryExecuteAsync(async () => { var userId = GetLoggedInUserId(); var tenantId = GetLoggedInUserTenantId(); await _busClient.SendAsync("global-template-propagation", new GlobalTemplatePropagationTask { GlobalTemplateId = id, OperationType = "push", InitiatedByUserId = userId, InitiatedByTenantId = tenantId }); return new GlobalTemplatePropagationQueuedDTO(id, "push"); }); } } }

8. Connect Handler Changes

When a new entity is created, the connect handler currently reads from JSON files. Replace that with a DB call.

8.1 Before (current code, simplified)

// In EntitiesDefaultActivitiesHandler.cs var templates = _helper.GetDefaultActivities(entitySubType, triggerEvent); // _helper reads from JSON file

8.2 After

// In EntitiesDefaultActivitiesHandler.cs var templates = await _globalTemplateService.GetByTriggerAsync(entitySubType, triggerEvent); // Now reads from GlobalActivityTemplates collection

8.3 Update Helper Signature

IDefaultActivityHelper.cs and its implementations (ChannelDefaultActivityHelper.cs, CampaignDefaultActivityHelper.cs) need to:

  1. Accept List<GlobalActivityTemplate> instead of reading internally
  2. When creating an ActivityTemplate copy from a global, stamp:
newTemplate.GlobalTemplateId = globalTemplate.Id; newTemplate.IsCustomized = false;

9. Migration

Phase A — Seed GlobalActivityTemplate from JSON files

File: Leadmetrics.Data.Mongo.DBMigrations/Migrations/GlobalTemplatesMigrationHelper.cs

// Read each of the 10 JSON files // For each entry, check if a GlobalActivityTemplate already exists // matching: Title + EntitySubType + TriggerEvent (idempotent) // If not found, insert it // Log counts: inserted, skipped

Phase B — Backfill GlobalTemplateId on existing ActivityTemplate copies

// For each GlobalActivityTemplate just seeded: // Find all ActivityTemplate documents where Title matches // AND EntitySubType matches AND GlobalTemplateId is null // Set GlobalTemplateId = global.Id, IsCustomized = false // Log backfill counts per global template

Phase C — Register MongoDB indexes in MongoIndexManager

Important: This codebase does not use JS scripts or migration files for indexes. All indexes are defined in C# inside Leadmetrics.Api.Data.Shared/MongoIndexManager.cs using the existing CreateIndex<T> and CreateCompoundIndex<T> helpers. See Section 14 for the full list.

Add the following calls inside the appropriate method in MongoIndexManager.cs:

// === GlobalActivityTemplate indexes === // Primary runtime query: GetByTriggerAsync (called every time an entity is created) await CreateCompoundIndex<GlobalActivityTemplate>("EntitySubType", "TriggerEvent", "IsActive"); // Admin portal search/filter await CreateIndex<GlobalActivityTemplate>("EntityType"); await CreateIndex<GlobalActivityTemplate>("IsActive"); // === ActivityTemplate indexes (new + pre-existing gap) === // Worker propagation queries: both sync (+ IsCustomized filter) and deactivate await CreateCompoundIndex<ActivityTemplate>("GlobalTemplateId", "IsCustomized"); // Pre-existing gap: every ActivityTemplate query starts with TenantId await CreateCompoundIndex<ActivityTemplate>("TenantId", "IsActive");

10. Implementation Order

Follow this order to avoid compile errors at each phase:

PhaseTaskGate
1Add GlobalTemplateId + IsCustomized to ActivityTemplate entity; create GlobalActivityTemplate entitydotnet build
2Create DTOs, model, service interface + implementation; register in ActivityAutoModuledotnet build
2.5Add all 5 index registrations to MongoIndexManager.cs (see Section 14)dotnet build
3Create GlobalTemplatePropagationTask + handler; register in ServerWorkerModuledotnet build
4Update EntitiesDefaultActivitiesHandler + helpers to use service instead of JSONdotnet build
5Create GlobalActivityTemplatesController in Admin APIdotnet build
6Write and run GlobalTemplatesMigrationHelperVerify via DB
7Delete the 10 JSON filesdotnet build

11. Files Summary

New files

FileProject
Entities/GlobalActivityTemplate.csLeadmetrics.Feature.Activities
DTOs/GlobalActivityTemplateDTOs.csLeadmetrics.Feature.Activities
Models/NewGlobalActivityTemplateModel.csLeadmetrics.Feature.Activities
Services/IGlobalActivityTemplateService.csLeadmetrics.Feature.Activities
Services/GlobalActivityTemplateService.csLeadmetrics.Feature.Activities
Tasks/GlobalTemplatePropagationTask.csLeadmetrics.Server.Workers
Handlers/GlobalTemplatePropagationHandler.csLeadmetrics.Server.Workers
Controllers/GlobalActivityTemplatesController.csLeadmetrics.Admin.Api
Migrations/GlobalTemplatesMigrationHelper.csLeadmetrics.Data.Mongo.DBMigrations

Modified files

FileChange
Entities/ActivityTemplate.csAdd GlobalTemplateId, IsCustomized
ActivityAutoModule.csRegister GlobalActivityTemplateService
ServerWorkerModule.csRegister GlobalTemplatePropagationHandler keyed "global-template-propagation"
EntitiesDefaultActivitiesHandler.csReplace JSON file read with GetByTriggerAsync
IDefaultActivityHelper.csUpdate signature to accept List<GlobalActivityTemplate>
ChannelDefaultActivityHelper.csUse passed-in templates; stamp GlobalTemplateId/IsCustomized
CampaignDefaultActivityHelper.csSame as above
Constants/AuditLogEntityType.csAdd public const string GlobalActivityTemplate = "GlobalActivityTemplate";
MongoIndexManager.csAdd 5 index registrations for GlobalActivityTemplate and ActivityTemplate (see Section 14)

Deleted files (Phase 7 only — after migration verified)

The 10 JSON activity template files in Leadmetrics.Feature.Activities (or wherever they are stored).


12. Verification Checklist

After full implementation:

  • dotnet build passes with 0 errors
  • GlobalActivityTemplates collection exists in MongoDB with seeded data
  • All existing ActivityTemplate records have GlobalTemplateId populated (Phase B migration ran)
  • Compound index { GlobalTemplateId: 1, IsCustomized: 1 } exists on ActivityTemplate
  • Compound index { TenantId: 1, IsActive: 1 } exists on ActivityTemplate
  • Compound index { EntitySubType: 1, TriggerEvent: 1, IsActive: 1 } exists on GlobalActivityTemplate
  • Single indexes EntityType and IsActive exist on GlobalActivityTemplate
  • Creating a new entity still seeds default activities (now from DB)
  • PUT /{id} on a global template enqueues a “sync” task
  • The sync worker updates non-customized copies only
  • DELETE /{id} enqueues a “deactivate” task
  • The deactivate worker skips customized copies
  • POST /{id}/push enqueues a “push” task
  • Admin API endpoints all return correct HTTP status codes
  • 10 JSON files are deleted and build still passes
  • Audit log entry created for GlobalActivityTemplate Create
  • Audit log entry created for GlobalActivityTemplate Update
  • Audit log entry created for GlobalActivityTemplate Deactivate (Delete operation type)
  • Audit log entry created for GlobalActivityTemplate Reactivate (Update operation type)
  • Audit log summary entry created after sync propagation (shows updated count)
  • Audit log summary entry created after deactivate propagation (shows deactivated + skipped counts)
  • Audit log entries show correct CreatedByUser name (not “System”) for admin-triggered operations
  • Audit log entries for worker-triggered propagation show the initiating admin’s name (via InitiatedByUserId)

14. MongoDB Indexes

14.1 Where Indexes Live

All MongoDB indexes in this codebase are registered in C# inside:

File: Leadmetrics.Api.Data.Shared/MongoIndexManager.cs

Two helper methods are available:

// Single-field ascending index (idempotent — checks before creating) await CreateIndex<T>("FieldName"); // Compound ascending index on N fields await CreateCompoundIndex<T>("Field1", "Field2", "Field3");

Do not use JS scripts or migration files for indexes. The MongoIndexManager is called at application startup and handles existence checks.


14.2 Indexes Required for This Feature

GlobalActivityTemplate collection

IndexTypeReason
{ EntitySubType, TriggerEvent, IsActive }CompoundGetByTriggerAsync — called every time a new entity is created; must be fast
{ EntityType }SingleAdmin portal filter
{ IsActive }SingleAdmin portal filter for active/inactive view
await CreateCompoundIndex<GlobalActivityTemplate>("EntitySubType", "TriggerEvent", "IsActive"); await CreateIndex<GlobalActivityTemplate>("EntityType"); await CreateIndex<GlobalActivityTemplate>("IsActive");

ActivityTemplate collection

IndexTypeReason
{ GlobalTemplateId, IsCustomized }CompoundWorker propagation queries filter on both fields simultaneously (sync: GlobalTemplateId = X AND IsCustomized = false; deactivate: GlobalTemplateId = X). Without this, propagation runs a full collection scan across potentially 500K+ documents.
{ TenantId, IsActive }CompoundEvery tenant query starts with TenantId. Pre-existing gap — only CreatedOn is indexed today. This covers the most common read pattern.
await CreateCompoundIndex<ActivityTemplate>("GlobalTemplateId", "IsCustomized"); await CreateCompoundIndex<ActivityTemplate>("TenantId", "IsActive");

14.3 Pre-Existing Index (Already in MongoIndexManager)

IndexCollectionNotes
{ CreatedOn }ActivityTemplateAlready exists — default sort field. No change needed.

14.4 Why Not Per-Copy Indexes on ActivityTemplate?

At 10K+ copies per global template, the { GlobalTemplateId, IsCustomized } compound index is essential. Without it:

  • A sync job for one global template scans the entire ActivityTemplate collection
  • At 100 tenants × 50 entities = 500K+ documents, this would cause severe latency and lock pressure

With the compound index, MongoDB resolves the propagation query in a single index scan returning only the relevant copies.


13. Audit Logging

13.1 Overview

All write operations on GlobalActivityTemplate and propagation operations on tenant ActivityTemplate copies are recorded in the PostgreSQL AuditLogs table via the existing IAuditLogService.

LayerWho writes the auditWhen
Admin API (controller → service)GlobalActivityTemplateServiceImmediately after each write
Background worker (propagation)GlobalActivityTemplateServiceAfter each propagation batch completes

All audit log entries use AuditLogCategory.EntityCRUD and the new constant AuditLogEntityType.GlobalActivityTemplate.

For tenant copy changes (propagation), one summary entry per operation is written rather than one per copy. At scale (10K+ copies) per-copy entries would be impractical and would bloat the audit table.


13.2 New AuditLogEntityType Constant

File: Leadmetrics.Feature.Audit/Constants/AuditLogEntityType.cs

Add one line alongside the existing constants:

public const string GlobalActivityTemplate = "GlobalActivityTemplate";

13.3 Audit Events — GlobalActivityTemplate (Admin-triggered)

HTTP EndpointService MethodOperationInfo Field
POST /CreateAsyncCreateSerialized GlobalActivityTemplate JSON
PUT /{id}UpdateAsyncUpdateSerialized updated GlobalActivityTemplate JSON
DELETE /{id}DeactivateAsyncDeleteSerialized GlobalActivityTemplate JSON (with IsActive=false)
PUT /{id}/reactivateReactivateAsyncUpdate"Reactivated GlobalActivityTemplate: {Title}"

13.4 Audit Events — ActivityTemplate Propagation (Worker-triggered)

These summary entries are written inside the service after the propagation loop completes.

Propagation OperationOperation TypeExample Info
Sync (edit fan-out)BulkUpdate"Sync propagation complete. Updated 342 tenant activity template copies."
Deactivate fan-outBulkDelete"Deactivation propagation complete. Deactivated 280 copies, skipped 62 customized copies."
Push (new template fan-out)BulkCreate"Push propagation complete. Created 412 new activity template copies."

13.5 User Attribution Without HTTP Context

The background worker runs detached from any HTTP request, so HttpContext and JWT claims are not available. To keep audit entries attributed to the correct admin user:

  1. The controller extracts userId = GetLoggedInUserId() and tenantId = GetLoggedInUserTenantId() before enqueuing.
  2. Both values are set on GlobalTemplatePropagationTask.InitiatedByUserId and InitiatedByTenantId.
  3. The handler passes these values to SyncToTenantTemplatesAsync(globalTemplateId, userId, tenantId) (and the other propagation methods).
  4. The service uses them when calling _auditLogService.CreateAuditLogAsync(auditLog, userId, tenantId).

Result: audit logs in PostgreSQL show the admin’s name in CreatedByUser — not “System”.


13.6 Call Pattern (for reference)

The existing pattern used across the codebase (e.g. ContentService, ChannelService):

// From service method, after the DB write: var auditLog = AuditLogModel.Create( AuditLogCategory.EntityCRUD, AuditLogEntityType.GlobalActivityTemplate, AuditLogOperationType.Create, // or Update / Delete entity.Id, JsonConvert.SerializeObject(entity) // Info field — snapshot of the entity ); await _auditLogService.CreateAuditLogAsync(auditLog, userId, tenantId);

For propagation summary entries use AuditLogModel.CreateWithInfo:

var auditLog = AuditLogModel.CreateWithInfo( AuditLogCategory.EntityCRUD, AuditLogEntityType.GlobalActivityTemplate, AuditLogOperationType.BulkUpdate, globalTemplateId, $"Sync propagation complete. Updated {updated} tenant activity template copies." ); await _auditLogService.CreateAuditLogAsync(auditLog, userId, tenantId);

13.7 ActivityTemplate Audit (Tenant-side Edits)

When a tenant user edits an ActivityTemplate copy directly (setting IsCustomized = true), the existing ActivityTemplateService should also write an audit log for that write — using AuditLogEntityType.ActivityTemplate (which already exists). This is existing behaviour and requires no changes for this feature.

The only new audit constant required for this feature is AuditLogEntityType.GlobalActivityTemplate.

© 2026 Leadmetrics — Internal use only