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
GlobalTemplateIdand customization status viaIsCustomized
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
| Decision | Choice | Reason |
|---|---|---|
| Storage | MongoDB GlobalActivityTemplates collection | Consistent with all other templates |
| Propagation model | Async background worker | 10K+ copies per template; cannot block HTTP |
| Worker transport | Azure Service Bus via MassTransit | Existing infrastructure |
| Task pattern | GlobalTemplatePropagationTask : TaskMessage | Matches existing TaskMessage/TaskHandlerBase pattern |
| Soft delete | IsActive = false instead of hard delete | Allows recovery; tenant copies deactivated, not deleted |
| Customization tracking | IsCustomized flag on ActivityTemplate | Skip customized copies during “sync” propagation |
| Auto-push on create | No | Super-admin explicitly calls POST /{id}/push |
| Audit logging — global templates | IAuditLogService 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 entity | 10K+ per-copy entries would be impractical; summary entry records count + initiating user |
| Audit user attribution in worker | InitiatedByUserId carried in GlobalTemplatePropagationTask message | Worker 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
});
InitiatedByUserIdandInitiatedByTenantIdare 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 file8.2 After
// In EntitiesDefaultActivitiesHandler.cs
var templates = await _globalTemplateService.GetByTriggerAsync(entitySubType, triggerEvent);
// Now reads from GlobalActivityTemplates collection8.3 Update Helper Signature
IDefaultActivityHelper.cs and its implementations (ChannelDefaultActivityHelper.cs, CampaignDefaultActivityHelper.cs) need to:
- Accept
List<GlobalActivityTemplate>instead of reading internally - When creating an
ActivityTemplatecopy 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, skippedPhase 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 templatePhase 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.csusing the existingCreateIndex<T>andCreateCompoundIndex<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:
| Phase | Task | Gate |
|---|---|---|
| 1 | Add GlobalTemplateId + IsCustomized to ActivityTemplate entity; create GlobalActivityTemplate entity | dotnet build |
| 2 | Create DTOs, model, service interface + implementation; register in ActivityAutoModule | dotnet build |
| 2.5 | Add all 5 index registrations to MongoIndexManager.cs (see Section 14) | dotnet build |
| 3 | Create GlobalTemplatePropagationTask + handler; register in ServerWorkerModule | dotnet build |
| 4 | Update EntitiesDefaultActivitiesHandler + helpers to use service instead of JSON | dotnet build |
| 5 | Create GlobalActivityTemplatesController in Admin API | dotnet build |
| 6 | Write and run GlobalTemplatesMigrationHelper | Verify via DB |
| 7 | Delete the 10 JSON files | dotnet build |
11. Files Summary
New files
| File | Project |
|---|---|
Entities/GlobalActivityTemplate.cs | Leadmetrics.Feature.Activities |
DTOs/GlobalActivityTemplateDTOs.cs | Leadmetrics.Feature.Activities |
Models/NewGlobalActivityTemplateModel.cs | Leadmetrics.Feature.Activities |
Services/IGlobalActivityTemplateService.cs | Leadmetrics.Feature.Activities |
Services/GlobalActivityTemplateService.cs | Leadmetrics.Feature.Activities |
Tasks/GlobalTemplatePropagationTask.cs | Leadmetrics.Server.Workers |
Handlers/GlobalTemplatePropagationHandler.cs | Leadmetrics.Server.Workers |
Controllers/GlobalActivityTemplatesController.cs | Leadmetrics.Admin.Api |
Migrations/GlobalTemplatesMigrationHelper.cs | Leadmetrics.Data.Mongo.DBMigrations |
Modified files
| File | Change |
|---|---|
Entities/ActivityTemplate.cs | Add GlobalTemplateId, IsCustomized |
ActivityAutoModule.cs | Register GlobalActivityTemplateService |
ServerWorkerModule.cs | Register GlobalTemplatePropagationHandler keyed "global-template-propagation" |
EntitiesDefaultActivitiesHandler.cs | Replace JSON file read with GetByTriggerAsync |
IDefaultActivityHelper.cs | Update signature to accept List<GlobalActivityTemplate> |
ChannelDefaultActivityHelper.cs | Use passed-in templates; stamp GlobalTemplateId/IsCustomized |
CampaignDefaultActivityHelper.cs | Same as above |
Constants/AuditLogEntityType.cs | Add public const string GlobalActivityTemplate = "GlobalActivityTemplate"; |
MongoIndexManager.cs | Add 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 buildpasses with 0 errors -
GlobalActivityTemplatescollection exists in MongoDB with seeded data - All existing
ActivityTemplaterecords haveGlobalTemplateIdpopulated (Phase B migration ran) - Compound index
{ GlobalTemplateId: 1, IsCustomized: 1 }exists onActivityTemplate - Compound index
{ TenantId: 1, IsActive: 1 }exists onActivityTemplate - Compound index
{ EntitySubType: 1, TriggerEvent: 1, IsActive: 1 }exists onGlobalActivityTemplate - Single indexes
EntityTypeandIsActiveexist onGlobalActivityTemplate - 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}/pushenqueues 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
GlobalActivityTemplateCreate - Audit log entry created for
GlobalActivityTemplateUpdate - Audit log entry created for
GlobalActivityTemplateDeactivate (Delete operation type) - Audit log entry created for
GlobalActivityTemplateReactivate (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
CreatedByUsername (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
MongoIndexManageris called at application startup and handles existence checks.
14.2 Indexes Required for This Feature
GlobalActivityTemplate collection
| Index | Type | Reason |
|---|---|---|
{ EntitySubType, TriggerEvent, IsActive } | Compound | GetByTriggerAsync — called every time a new entity is created; must be fast |
{ EntityType } | Single | Admin portal filter |
{ IsActive } | Single | Admin portal filter for active/inactive view |
await CreateCompoundIndex<GlobalActivityTemplate>("EntitySubType", "TriggerEvent", "IsActive");
await CreateIndex<GlobalActivityTemplate>("EntityType");
await CreateIndex<GlobalActivityTemplate>("IsActive");ActivityTemplate collection
| Index | Type | Reason |
|---|---|---|
{ GlobalTemplateId, IsCustomized } | Compound | Worker 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 } | Compound | Every 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)
| Index | Collection | Notes |
|---|---|---|
{ CreatedOn } | ActivityTemplate | Already 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
ActivityTemplatecollection - 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.
| Layer | Who writes the audit | When |
|---|---|---|
| Admin API (controller → service) | GlobalActivityTemplateService | Immediately after each write |
| Background worker (propagation) | GlobalActivityTemplateService | After 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 Endpoint | Service Method | Operation | Info Field |
|---|---|---|---|
POST / | CreateAsync | Create | Serialized GlobalActivityTemplate JSON |
PUT /{id} | UpdateAsync | Update | Serialized updated GlobalActivityTemplate JSON |
DELETE /{id} | DeactivateAsync | Delete | Serialized GlobalActivityTemplate JSON (with IsActive=false) |
PUT /{id}/reactivate | ReactivateAsync | Update | "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 Operation | Operation Type | Example Info |
|---|---|---|
| Sync (edit fan-out) | BulkUpdate | "Sync propagation complete. Updated 342 tenant activity template copies." |
| Deactivate fan-out | BulkDelete | "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:
- The controller extracts
userId = GetLoggedInUserId()andtenantId = GetLoggedInUserTenantId()before enqueuing. - Both values are set on
GlobalTemplatePropagationTask.InitiatedByUserIdandInitiatedByTenantId. - The handler passes these values to
SyncToTenantTemplatesAsync(globalTemplateId, userId, tenantId)(and the other propagation methods). - 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.