Skip to content

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.

  • A .NET 10 project referencing Granit.Core
  • An EF Core DbContext for persistence
  • Granit.Notifications for follower notifications (optional)
  • Granit.BlobStorage for file attachments (optional)
Terminal window
dotnet add package Granit.Timeline
dotnet add package Granit.Timeline.EntityFrameworkCore

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;
}
services.AddGranitTimeline();

For notification integration (followers, @mentions):

services.AddGranitTimelineNotifications();

Use ITimelineStoreWriter to create entries programmatically. Three entry types are available:

TypePurposeEditableDeletable
CommentUser-facing comment, visible to followersNoYes (soft-delete, GDPR)
SystemLogAuto-generated audit logNoNo (INSERT-only, ISO 27001)
InternalNoteStaff-only note, hidden from external usersNoYes (soft-delete, GDPR)
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);
}
}

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)

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.

Granit.Timeline.Endpoints provides a full Minimal API surface:

app.MapTimelineEndpoints();
// With a custom route prefix:
app.MapTimelineEndpoints(opts => opts.RoutePrefix = "admin/timeline");
MethodRouteDescription
GET/{entityType}/{entityId}Paginated feed (skip, take)
POST/{entityType}/{entityId}/entriesPost a comment or note
DELETE/{entityType}/{entityId}/entries/{id}Soft-delete (GDPR)
POST/{entityType}/{entityId}/followSubscribe to the entity
DELETE/{entityType}/{entityId}/followUnsubscribe
GET/{entityType}/{entityId}/followersList followers
{
"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 timeline
await followerService.FollowAsync(
nameof(Patient), patientId, userId, cancellationToken);
// Unsubscribe
await followerService.UnfollowAsync(
nameof(Patient), patientId, userId, cancellationToken);
TypeNameDefault channels
TimelineCommentNotificationTypetimeline.comment_postedInApp, SignalR
TimelineMentionNotificationTypetimeline.user_mentionedInApp, SignalR, Email

The author is automatically excluded from notifications for their own entries.

The timeline adapts to available dependencies:

DependencyPresentAbsent
Granit.NotificationsReal follower store, fan-out notificationsInMemoryTimelineFollowerService, NullTimelineNotifier
Granit.BlobStoragePre-signed URLs for attachmentsMetadata only
Multi-tenancyAutomatic TenantId, query filtersTenantId = null

The module creates two tables:

  • timeline_entries — primary index on (EntityType, EntityId, TenantId, CreatedAt DESC), optimized for paginated feed queries
  • timeline_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.