Implement the audit timeline
Granit.Timeline provides a unified activity feed (inspired by Odoo Chatter) that combines system-generated audit logs, human comments, and internal notes on any entity. It supports @mentions, threaded replies, file attachments, and follower notifications.
Prerequisites
Section titled “Prerequisites”- A .NET 10 project referencing
Granit.Core - An EF Core
DbContextfor persistence Granit.Notificationsfor follower notifications (optional)Granit.BlobStoragefor file attachments (optional)
Step 1 — Install packages
Section titled “Step 1 — Install packages”dotnet add package Granit.Timelinedotnet add package Granit.Timeline.EntityFrameworkCoredotnet add package Granit.Timelinedotnet add package Granit.Timeline.EntityFrameworkCoredotnet add package Granit.Timeline.Notificationsdotnet add package Granit.Timelinedotnet add package Granit.Timeline.EntityFrameworkCoredotnet add package Granit.Timeline.EndpointsStep 2 — Mark entities as timelined
Section titled “Step 2 — Mark entities as timelined”Implement the ITimelined marker interface on entities that need an activity feed.
The interface has zero members — it is purely opt-in:
using Granit.Timeline;
namespace MyApp.Domain;
public sealed class Patient : AuditedEntity, ITimelined{ public string Name { get; set; } = string.Empty; public string Email { get; set; } = string.Empty;}To customize the entity type name displayed in the feed:
[Timelined("Patient Record")]public sealed class Patient : AuditedEntity, ITimelined{ public string Name { get; set; } = string.Empty;}Step 3 — Register Timeline services
Section titled “Step 3 — Register Timeline services”services.AddGranitTimeline();builder.AddGranitTimelineEntityFrameworkCore(opts => opts.UseNpgsql(connectionString));For notification integration (followers, @mentions):
services.AddGranitTimelineNotifications();Step 4 — Post timeline entries
Section titled “Step 4 — Post timeline entries”Use ITimelineStoreWriter to create entries programmatically. Three entry types
are available:
| Type | Purpose | Editable | Deletable |
|---|---|---|---|
Comment | User-facing comment, visible to followers | No | Yes (soft-delete, GDPR) |
SystemLog | Auto-generated audit log | No | No (INSERT-only, ISO 27001) |
InternalNote | Staff-only note, hidden from external users | No | Yes (soft-delete, GDPR) |
Post a system log entry
Section titled “Post a system log entry”using Granit.Timeline;
namespace MyApp.Services;
public sealed class PatientWorkflowService( ITimelineStoreWriter timelineWriter){ public async Task RecordStatusChangeAsync( Guid patientId, string oldStatus, string newStatus, CancellationToken cancellationToken) { await timelineWriter.PostEntryAsync( entityType: nameof(Patient), entityId: patientId, entryType: TimelineEntryType.SystemLog, body: $"Status changed from {oldStatus} to {newStatus}", cancellationToken: cancellationToken); }}Post a comment with @mentions
Section titled “Post a comment with @mentions”The MentionParser extracts user GUIDs from Markdown-formatted mentions:
using Granit.Timeline;
namespace MyApp.Services;
public sealed class TimelineCommentService( ITimelineStoreWriter timelineWriter, ITimelineFollowerService followerService, ITimelineNotifier notifier){ public async Task PostCommentAsync( string entityType, Guid entityId, string body, CancellationToken cancellationToken) { // 1. Persist the entry var entry = await timelineWriter.PostEntryAsync( entityType, entityId, TimelineEntryType.Comment, body, cancellationToken: cancellationToken);
// 2. Extract @mentions from Markdown var mentionedUserIds = MentionParser.ExtractMentionedUserIds(body);
// 3. Auto-follow mentioned users foreach (var userId in mentionedUserIds) { await followerService.FollowAsync( entityType, entityId, userId, cancellationToken); }
// 4. Notify followers await notifier.NotifyEntryPostedAsync( entry, cancellationToken);
// 5. Notify mentioned users specifically await notifier.NotifyMentionedUsersAsync( entry, mentionedUserIds, cancellationToken); }}The mention format is: @[Dr. Martin](user:550e8400-e29b-41d4-a716-446655440000)
Step 5 — Query the timeline
Section titled “Step 5 — Query the timeline”Use ITimelineStoreReader to retrieve the paginated activity feed for an entity:
using Granit.Timeline;
namespace MyApp.Services;
public sealed class PatientTimelineService( ITimelineStoreReader timelineReader){ public async Task<TimelineResult> GetFeedAsync( Guid patientId, int skip, int take, CancellationToken cancellationToken) => await timelineReader.GetFeedAsync( entityType: nameof(Patient), entityId: patientId, skip: skip, take: take, cancellationToken: cancellationToken);}The feed is sorted in reverse chronological order. Soft-deleted entries are automatically excluded by the EF Core global filter.
Step 6 — Map REST endpoints
Section titled “Step 6 — Map REST endpoints”Granit.Timeline.Endpoints provides a full Minimal API surface:
app.MapTimelineEndpoints();
// With a custom route prefix:app.MapTimelineEndpoints(opts => opts.RoutePrefix = "admin/timeline");Available endpoints
Section titled “Available endpoints”| Method | Route | Description |
|---|---|---|
GET | /{entityType}/{entityId} | Paginated feed (skip, take) |
POST | /{entityType}/{entityId}/entries | Post a comment or note |
DELETE | /{entityType}/{entityId}/entries/{id} | Soft-delete (GDPR) |
POST | /{entityType}/{entityId}/follow | Subscribe to the entity |
DELETE | /{entityType}/{entityId}/follow | Unsubscribe |
GET | /{entityType}/{entityId}/followers | List followers |
Example POST request
Section titled “Example POST request”{ "entryType": 0, "body": "Please review @[Dr. Martin](user:550e8400-e29b-41d4-a716-446655440000).", "parentEntryId": null, "attachmentBlobIds": ["a1b2c3d4-e5f6-7890-abcd-ef1234567890"]}Step 7 — Manage followers and notifications
Section titled “Step 7 — Manage followers and notifications”The follower system controls who receives notifications when new entries are posted.
// Subscribe a user to an entity's timelineawait followerService.FollowAsync( nameof(Patient), patientId, userId, cancellationToken);
// Unsubscribeawait followerService.UnfollowAsync( nameof(Patient), patientId, userId, cancellationToken);Notification types
Section titled “Notification types”| Type | Name | Default channels |
|---|---|---|
TimelineCommentNotificationType | timeline.comment_posted | InApp, SignalR |
TimelineMentionNotificationType | timeline.user_mentioned | InApp, SignalR, Email |
The author is automatically excluded from notifications for their own entries.
Graceful degradation
Section titled “Graceful degradation”The timeline adapts to available dependencies:
| Dependency | Present | Absent |
|---|---|---|
Granit.Notifications | Real follower store, fan-out notifications | InMemoryTimelineFollowerService, NullTimelineNotifier |
Granit.BlobStorage | Pre-signed URLs for attachments | Metadata only |
| Multi-tenancy | Automatic TenantId, query filters | TenantId = null |
EF Core schema
Section titled “EF Core schema”The module creates two tables:
timeline_entries— primary index on(EntityType, EntityId, TenantId, CreatedAt DESC), optimized for paginated feed queriestimeline_attachments— indexed by(EntryId)for fast attachment lookup
Threaded replies use the ParentEntryId column. The API returns a flat list sorted
chronologically; the frontend component handles visual threading.
Next steps
Section titled “Next steps”- Encrypt sensitive data — protect sensitive timeline content
- Use reference data — manage lookup tables with audit trail
- Timeline reference — full API and configuration details