Skip to content

Timeline

Granit.Timeline adds a unified activity stream (Odoo-style chatter) to any entity — comments, internal notes, system logs, threaded replies, file attachments, and a follow/notify pattern.

  • DirectoryGranit.Timeline/ Core stream engine, in-memory default
    • Granit.Timeline.EntityFrameworkCore Durable persistence (PostgreSQL)
    • Granit.Timeline.Endpoints Minimal API routes, permissions
    • Granit.Timeline.Notifications Follow/notify via Granit.Notifications
PackageRoleDepends on
Granit.TimelineITimelined, ITimelineReader/Writer, stream DTOsGranit.Timing
Granit.Timeline.EntityFrameworkCoreDurable persistence for entries + attachmentsGranit.Timeline, Granit.Persistence
Granit.Timeline.EndpointsREST API, permission policiesGranit.Timeline, Granit.Authorization
Granit.Timeline.NotificationsNotification-backed follower/notifier bridgeGranit.Timeline
graph TD
    TL[Granit.Timeline] --> T[Granit.Timing]
    TLEF[Granit.Timeline.EntityFrameworkCore] --> TL
    TLEF --> P[Granit.Persistence]
    TLE[Granit.Timeline.Endpoints] --> TL
    TLE --> A[Granit.Authorization]
    TLN[Granit.Timeline.Notifications] --> TL

[DependsOn(typeof(GranitTimelineModule))]
public class AppModule : GranitModule { }

In-memory stores for entries and followers. No database required.

Add the ITimelined marker interface to any entity that should have an activity stream:

public class Invoice : FullAuditedEntity, ITimelined
{
public string InvoiceNumber { get; set; } = string.Empty;
public decimal Amount { get; set; }
public InvoiceStatus Status { get; set; }
}

The entity type name is derived from the CLR type name by convention ("Invoice"), or can be overridden with [Timelined("custom-name")].

public class InvoiceApprovalHandler(ITimelineWriter timeline)
{
public async Task HandleAsync(
InvoiceApproved evt, CancellationToken cancellationToken)
{
// System log — immutable, cannot be deleted (ISO 27001)
await timeline.PostEntryAsync(
"Invoice",
evt.InvoiceId.ToString(),
TimelineEntryType.SystemLog,
$"Invoice approved by **{evt.ApprovedBy}** for amount {evt.Amount:C}.",
cancellationToken: cancellationToken).ConfigureAwait(false);
}
}

Entry bodies support Markdown formatting. The @mention syntax (@userId) triggers automatic follower subscription and mention notifications when the Endpoints module processes the entry.

public sealed record TimelineStreamEntry
{
public required Guid Id { get; init; }
public required DateTimeOffset OccurredAt { get; init; }
public required TimelineStreamEntryType EntryType { get; init; }
public string? AuthorId { get; init; }
public string? AuthorName { get; init; }
public string Body { get; init; } = string.Empty;
public IReadOnlyList<TimelineAttachmentInfo> Attachments { get; init; } = [];
public Guid? ParentEntryId { get; init; }
}
Entry typeDescriptionDeletable
CommentHuman-authored comment visible to all usersYes (soft-delete, GDPR)
InternalNoteStaff-only internal noteYes (soft-delete, GDPR)
SystemLogAuto-generated audit entryNo (immutable, ISO 27001)

All endpoints are prefixed with /api/granit/timeline.

MethodPathDescriptionPermission
GET/{entityType}/{entityId}Paginated activity stream (newest first)Timeline.Entries.Read
POST/{entityType}/{entityId}/entriesPost a comment, note, or system logTimeline.Entries.Create
DELETE/{entityType}/{entityId}/entries/{entryId}Soft-delete an entry (GDPR)Timeline.Entries.Create
POST/{entityType}/{entityId}/followSubscribe current user as followerTimeline.Entries.Create
DELETE/{entityType}/{entityId}/followUnsubscribe current userTimeline.Entries.Create
GET/{entityType}/{entityId}/followersList follower user IDsTimeline.Entries.Read
sequenceDiagram
    participant U as User A
    participant API as Timeline API
    participant FS as FollowerService
    participant N as Notifier

    U->>API: POST /Invoice/42/entries (body: "@user-b reviewed")
    API->>API: PostEntryAsync (persist)
    API->>FS: FollowAsync("user-b") (auto-subscribe @mention)
    API->>FS: GetFollowerIdsAsync() → ["user-a", "user-b"]
    API->>N: NotifyEntryPostedAsync (fan-out to followers)
    API->>N: NotifyMentionedUsersAsync (extra channels for @user-b)

Without Granit.Timeline.Notifications, the follower service uses an in-memory store and the notifier is a no-op (NullTimelineNotifier). Installing the Notifications package replaces both with notification-backed implementations that leverage Granit.Notifications for multi-channel delivery (email, push, SignalR).

Timeline entries support file attachments via ITimelineWriter.AddAttachmentAsync(), referencing blob IDs managed by Granit.BlobStorage:

await timeline.AddAttachmentAsync(
entryId,
blobId: uploadResult.BlobId,
fileName: "scan.pdf",
contentType: "application/pdf",
sizeBytes: 245_000,
cancellationToken).ConfigureAwait(false);
CategoryKey typesPackage
ModulesGranitTimelineModule, GranitTimelineEntityFrameworkCoreModule, GranitTimelineEndpointsModule, GranitTimelineNotificationsModuleTimeline
Timeline coreITimelined, ITimelineReader, ITimelineWriter, ITimelineFollowerService, ITimelineNotifierGranit.Timeline
DTOsTimelineStreamEntry, TimelineStreamEntryType, TimelineAttachmentInfo, PostTimelineEntryRequestGranit.Timeline
PermissionsTimelinePermissions.Entries.Read, TimelinePermissions.Entries.CreateGranit.Timeline.Endpoints
ExtensionsAddGranitTimeline(), AddGranitTimelineEntityFrameworkCore(), MapTimelineEndpoints()