From 38d01776f74d1f3aed89bb229a38eee5c33947ba Mon Sep 17 00:00:00 2001 From: Miguel Tremblay Date: Tue, 27 Jan 2026 20:07:12 -0500 Subject: [PATCH 1/3] Improve API usability with individual parameters and CalendarEntryType Breaking changes: - CreateRecurrenceAsync now accepts individual parameters instead of RecurrenceCreate object - CreateOccurrenceAsync now accepts individual parameters instead of OccurrenceCreate object - Renamed GetAsync to GetOccurrencesAsync - Renamed UpdateAsync to UpdateOccurrenceAsync - Renamed DeleteAsync to DeleteOccurrenceAsync - Added GetRecurrencesAsync to retrieve recurrence patterns - Added DeleteRecurrenceAsync(organization, resourcePath, recurrenceId) - Removed RecurrenceOccurrenceDetails, added CalendarEntry.Original and CalendarEntry.EntryType - Renamed OccurrenceOriginal to OriginalDetails - Added CalendarEntryType enum (Standalone, Virtualized, Recurrence) - Added CalendarEntry.IsOverridden computed property API improvements: - README examples now show RecurrencePattern usage from Ical.Net - Examples use DateTime.Now (local time) which is more natural for users Co-Authored-By: Claude Opus 4.5 --- README.md | 41 +- src/RecurringThings.MongoDB/README.md | 56 ++- .../RecurringThings.MongoDB.csproj | 2 + src/RecurringThings.PostgreSQL/README.md | 56 ++- .../RecurringThings.PostgreSQL.csproj | 2 + .../ServiceCollectionExtensions.cs | 2 +- .../Engine/IRecurrenceEngine.cs | 217 ++++++--- .../Engine/RecurrenceEngine.cs | 394 +++++++++-------- src/RecurringThings/Models/CalendarEntry.cs | 65 ++- .../Models/CalendarEntryType.cs | 22 + .../Models/OccurrenceCreate.cs | 76 ---- ...currenceOriginal.cs => OriginalDetails.cs} | 4 +- .../Models/RecurrenceCreate.cs | 91 ---- .../Models/RecurrenceOccurrenceDetails.cs | 28 -- src/RecurringThings/RecurringThings.csproj | 2 + src/RecurringThings/Validation/Validator.cs | 267 ++++++++++- .../Validators/OccurrenceCreateValidator.cs | 54 --- .../Validators/RecurrenceCreateValidator.cs | 177 -------- .../Engine/RecurrenceEngineCrudTests.cs | 396 +++++------------ .../Engine/RecurrenceEngineTests.cs | 84 +--- .../OccurrenceCreateValidatorTests.cs | 192 -------- .../RecurrenceCreateValidatorTests.cs | 416 ------------------ 22 files changed, 912 insertions(+), 1732 deletions(-) create mode 100644 src/RecurringThings/Models/CalendarEntryType.cs delete mode 100644 src/RecurringThings/Models/OccurrenceCreate.cs rename src/RecurringThings/Models/{OccurrenceOriginal.cs => OriginalDetails.cs} (86%) delete mode 100644 src/RecurringThings/Models/RecurrenceCreate.cs delete mode 100644 src/RecurringThings/Models/RecurrenceOccurrenceDetails.cs delete mode 100644 src/RecurringThings/Validation/Validators/OccurrenceCreateValidator.cs delete mode 100644 src/RecurringThings/Validation/Validators/RecurrenceCreateValidator.cs delete mode 100644 tests/RecurringThings.Tests/Validation/Validators/OccurrenceCreateValidatorTests.cs delete mode 100644 tests/RecurringThings.Tests/Validation/Validators/RecurrenceCreateValidatorTests.cs diff --git a/README.md b/README.md index 8371003..328f12d 100644 --- a/README.md +++ b/README.md @@ -58,23 +58,38 @@ public class CalendarService(IRecurrenceEngine engine) ## Quick Example ```csharp +using Ical.Net.DataTypes; +using RecurringThings; + +// Build a recurrence pattern using Ical.Net +var pattern = new RecurrencePattern +{ + Frequency = FrequencyType.Weekly, + Until = new CalDateTime(new DateTime(2025, 12, 31, 23, 59, 59, DateTimeKind.Local).ToUniversalTime()) +}; +pattern.ByDay.Add(new WeekDay(DayOfWeek.Monday)); + // Create a weekly recurring meeting // Note: RecurrenceEndTime is automatically extracted from the RRule UNTIL clause -await engine.CreateRecurrenceAsync(new RecurrenceCreate +await engine.CreateRecurrenceAsync( + organization: "tenant1", + resourcePath: "user123/calendar", + type: "meeting", + startTime: DateTime.Now, + duration: TimeSpan.FromHours(1), + rrule: pattern.ToString(), + timeZone: "America/New_York"); + +// Query occurrences in a date range +await foreach (var entry in engine.GetOccurrencesAsync("tenant1", "user123/calendar", start, end, null)) { - Organization = "tenant1", - ResourcePath = "user123/calendar", - Type = "meeting", - StartTime = new DateTime(2025, 1, 6, 14, 0, 0, DateTimeKind.Utc), // Or DateTime.Now for local time - Duration = TimeSpan.FromHours(1), - RRule = "FREQ=WEEKLY;BYDAY=MO;UNTIL=20251231T235959Z", - TimeZone = "America/New_York" -}); - -// Query entries in a date range -await foreach (var entry in engine.GetAsync("tenant1", "user123/calendar", start, end, null)) + Console.WriteLine($"{entry.Type}: {entry.StartTime} ({entry.EntryType})"); +} + +// Query recurrence patterns in a date range +await foreach (var entry in engine.GetRecurrencesAsync("tenant1", "user123/calendar", start, end, null)) { - Console.WriteLine($"{entry.Type}: {entry.StartTime}"); + Console.WriteLine($"Recurrence: {entry.RecurrenceDetails?.RRule}"); } ``` diff --git a/src/RecurringThings.MongoDB/README.md b/src/RecurringThings.MongoDB/README.md index fbae13b..e74c92a 100644 --- a/src/RecurringThings.MongoDB/README.md +++ b/src/RecurringThings.MongoDB/README.md @@ -70,26 +70,34 @@ public class CalendarService(IRecurrenceEngine engine, IMongoTransactionManager ### Basic Setup ```csharp +using Ical.Net.DataTypes; + public class CalendarService(IRecurrenceEngine engine) { public async Task CreateWeeklyMeetingAsync() { - // RecurrenceEndTime is automatically extracted from the RRule UNTIL clause - var recurrence = await engine.CreateRecurrenceAsync(new RecurrenceCreate + // Build a recurrence pattern using Ical.Net + var pattern = new RecurrencePattern { - Organization = "tenant1", - ResourcePath = "user123/calendar", - Type = "meeting", - StartTime = new DateTime(2025, 1, 6, 14, 0, 0, DateTimeKind.Utc), // Or DateTime.Now for local time - Duration = TimeSpan.FromHours(1), - RRule = "FREQ=WEEKLY;BYDAY=MO;UNTIL=20251231T235959Z", - TimeZone = "America/New_York", - Extensions = new Dictionary + Frequency = FrequencyType.Weekly, + Until = new CalDateTime(new DateTime(2025, 12, 31, 23, 59, 59, DateTimeKind.Local).ToUniversalTime()) + }; + pattern.ByDay.Add(new WeekDay(DayOfWeek.Monday)); + + // RecurrenceEndTime is automatically extracted from the RRule UNTIL clause + var recurrence = await engine.CreateRecurrenceAsync( + organization: "tenant1", + resourcePath: "user123/calendar", + type: "meeting", + startTime: DateTime.Now, + duration: TimeSpan.FromHours(1), + rrule: pattern.ToString(), + timeZone: "America/New_York", + extensions: new Dictionary { ["title"] = "Weekly Team Standup", ["location"] = "Conference Room A" - } - }); + }); } public async Task GetJanuaryEntriesAsync() @@ -97,9 +105,9 @@ public class CalendarService(IRecurrenceEngine engine) var start = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc); var end = new DateTime(2025, 1, 31, 23, 59, 59, DateTimeKind.Utc); - await foreach (var entry in engine.GetAsync("tenant1", "user123/calendar", start, end, null)) + await foreach (var entry in engine.GetOccurrencesAsync("tenant1", "user123/calendar", start, end, null)) { - Console.WriteLine($"{entry.Type}: {entry.StartTime} - {entry.EndTime}"); + Console.WriteLine($"{entry.Type}: {entry.StartTime} - {entry.EndTime} ({entry.EntryType})"); } } } @@ -109,7 +117,7 @@ public class CalendarService(IRecurrenceEngine engine) ```csharp // Get only appointments and meetings -await foreach (var entry in engine.GetAsync( +await foreach (var entry in engine.GetOccurrencesAsync( "tenant1", "user123/calendar", start, end, types: ["appointment", "meeting"])) { @@ -121,32 +129,32 @@ await foreach (var entry in engine.GetAsync( ```csharp // Update a standalone occurrence -var entries = await engine.GetAsync(org, path, start, end, null).ToListAsync(); -var entry = entries.First(e => e.OccurrenceId.HasValue); +var entries = await engine.GetOccurrencesAsync(org, path, start, end, null).ToListAsync(); +var entry = entries.First(e => e.EntryType == CalendarEntryType.Standalone); entry.StartTime = entry.StartTime.AddHours(1); entry.Duration = TimeSpan.FromMinutes(45); -var updated = await engine.UpdateAsync(entry); +var updated = await engine.UpdateOccurrenceAsync(entry); // EndTime is automatically recomputed // Update a virtualized occurrence (creates an override) -var virtualizedEntry = entries.First(e => e.RecurrenceOccurrenceDetails != null); +var virtualizedEntry = entries.First(e => e.EntryType == CalendarEntryType.Virtualized); virtualizedEntry.Duration = TimeSpan.FromMinutes(45); -var overridden = await engine.UpdateAsync(virtualizedEntry); -// Original values preserved in RecurrenceOccurrenceDetails.Original +var overridden = await engine.UpdateOccurrenceAsync(virtualizedEntry); +// Original values preserved in entry.Original ``` ### Deleting Entries ```csharp // Delete entire recurrence series (cascade deletes exceptions/overrides) -await engine.DeleteAsync(recurrenceEntry); +await engine.DeleteRecurrenceAsync(org, path, recurrenceId); // Delete a virtualized occurrence (creates an exception) -await engine.DeleteAsync(virtualizedEntry); +await engine.DeleteOccurrenceAsync(virtualizedEntry); // Restore an overridden occurrence to original state -if (entry.OverrideId.HasValue) +if (entry.IsOverridden) { await engine.RestoreAsync(entry); } diff --git a/src/RecurringThings.MongoDB/RecurringThings.MongoDB.csproj b/src/RecurringThings.MongoDB/RecurringThings.MongoDB.csproj index 9c6b3b1..7e4b676 100644 --- a/src/RecurringThings.MongoDB/RecurringThings.MongoDB.csproj +++ b/src/RecurringThings.MongoDB/RecurringThings.MongoDB.csproj @@ -15,10 +15,12 @@ https://github.com/ChuckNovice/RecurringThings Apache-2.0 README.md + logo_green.png + diff --git a/src/RecurringThings.PostgreSQL/README.md b/src/RecurringThings.PostgreSQL/README.md index b49e3ab..05748f5 100644 --- a/src/RecurringThings.PostgreSQL/README.md +++ b/src/RecurringThings.PostgreSQL/README.md @@ -80,26 +80,34 @@ public class CalendarService(IRecurrenceEngine engine, IPostgresTransactionManag ### Basic Setup ```csharp +using Ical.Net.DataTypes; + public class CalendarService(IRecurrenceEngine engine) { public async Task CreateWeeklyMeetingAsync() { - // RecurrenceEndTime is automatically extracted from the RRule UNTIL clause - var recurrence = await engine.CreateRecurrenceAsync(new RecurrenceCreate + // Build a recurrence pattern using Ical.Net + var pattern = new RecurrencePattern { - Organization = "tenant1", - ResourcePath = "user123/calendar", - Type = "meeting", - StartTime = new DateTime(2025, 1, 6, 14, 0, 0, DateTimeKind.Utc), // Or DateTime.Now for local time - Duration = TimeSpan.FromHours(1), - RRule = "FREQ=WEEKLY;BYDAY=MO;UNTIL=20251231T235959Z", - TimeZone = "America/New_York", - Extensions = new Dictionary + Frequency = FrequencyType.Weekly, + Until = new CalDateTime(new DateTime(2025, 12, 31, 23, 59, 59, DateTimeKind.Local).ToUniversalTime()) + }; + pattern.ByDay.Add(new WeekDay(DayOfWeek.Monday)); + + // RecurrenceEndTime is automatically extracted from the RRule UNTIL clause + var recurrence = await engine.CreateRecurrenceAsync( + organization: "tenant1", + resourcePath: "user123/calendar", + type: "meeting", + startTime: DateTime.Now, + duration: TimeSpan.FromHours(1), + rrule: pattern.ToString(), + timeZone: "America/New_York", + extensions: new Dictionary { ["title"] = "Weekly Team Standup", ["location"] = "Conference Room A" - } - }); + }); } public async Task GetJanuaryEntriesAsync() @@ -107,9 +115,9 @@ public class CalendarService(IRecurrenceEngine engine) var start = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc); var end = new DateTime(2025, 1, 31, 23, 59, 59, DateTimeKind.Utc); - await foreach (var entry in engine.GetAsync("tenant1", "user123/calendar", start, end, null)) + await foreach (var entry in engine.GetOccurrencesAsync("tenant1", "user123/calendar", start, end, null)) { - Console.WriteLine($"{entry.Type}: {entry.StartTime} - {entry.EndTime}"); + Console.WriteLine($"{entry.Type}: {entry.StartTime} - {entry.EndTime} ({entry.EntryType})"); } } } @@ -119,7 +127,7 @@ public class CalendarService(IRecurrenceEngine engine) ```csharp // Get only appointments and meetings -await foreach (var entry in engine.GetAsync( +await foreach (var entry in engine.GetOccurrencesAsync( "tenant1", "user123/calendar", start, end, types: ["appointment", "meeting"])) { @@ -131,32 +139,32 @@ await foreach (var entry in engine.GetAsync( ```csharp // Update a standalone occurrence -var entries = await engine.GetAsync(org, path, start, end, null).ToListAsync(); -var entry = entries.First(e => e.OccurrenceId.HasValue); +var entries = await engine.GetOccurrencesAsync(org, path, start, end, null).ToListAsync(); +var entry = entries.First(e => e.EntryType == CalendarEntryType.Standalone); entry.StartTime = entry.StartTime.AddHours(1); entry.Duration = TimeSpan.FromMinutes(45); -var updated = await engine.UpdateAsync(entry); +var updated = await engine.UpdateOccurrenceAsync(entry); // EndTime is automatically recomputed // Update a virtualized occurrence (creates an override) -var virtualizedEntry = entries.First(e => e.RecurrenceOccurrenceDetails != null); +var virtualizedEntry = entries.First(e => e.EntryType == CalendarEntryType.Virtualized); virtualizedEntry.Duration = TimeSpan.FromMinutes(45); -var overridden = await engine.UpdateAsync(virtualizedEntry); -// Original values preserved in RecurrenceOccurrenceDetails.Original +var overridden = await engine.UpdateOccurrenceAsync(virtualizedEntry); +// Original values preserved in entry.Original ``` ### Deleting Entries ```csharp // Delete entire recurrence series (cascade deletes exceptions/overrides) -await engine.DeleteAsync(recurrenceEntry); +await engine.DeleteRecurrenceAsync(org, path, recurrenceId); // Delete a virtualized occurrence (creates an exception) -await engine.DeleteAsync(virtualizedEntry); +await engine.DeleteOccurrenceAsync(virtualizedEntry); // Restore an overridden occurrence to original state -if (entry.OverrideId.HasValue) +if (entry.IsOverridden) { await engine.RestoreAsync(entry); } diff --git a/src/RecurringThings.PostgreSQL/RecurringThings.PostgreSQL.csproj b/src/RecurringThings.PostgreSQL/RecurringThings.PostgreSQL.csproj index 9279ed9..d50367e 100644 --- a/src/RecurringThings.PostgreSQL/RecurringThings.PostgreSQL.csproj +++ b/src/RecurringThings.PostgreSQL/RecurringThings.PostgreSQL.csproj @@ -19,10 +19,12 @@ https://github.com/ChuckNovice/RecurringThings Apache-2.0 README.md + logo_blue.png + diff --git a/src/RecurringThings/Configuration/ServiceCollectionExtensions.cs b/src/RecurringThings/Configuration/ServiceCollectionExtensions.cs index 3103363..9b9f172 100644 --- a/src/RecurringThings/Configuration/ServiceCollectionExtensions.cs +++ b/src/RecurringThings/Configuration/ServiceCollectionExtensions.cs @@ -65,7 +65,7 @@ public static IServiceCollection AddRecurringThings( builder.Validate(); // Register FluentValidation validators from the assembly (including internal validators) - services.AddValidatorsFromAssemblyContaining( + services.AddValidatorsFromAssemblyContaining( ServiceLifetime.Scoped, includeInternalTypes: true); diff --git a/src/RecurringThings/Engine/IRecurrenceEngine.cs b/src/RecurringThings/Engine/IRecurrenceEngine.cs index d6f9c4e..cfccb7f 100644 --- a/src/RecurringThings/Engine/IRecurrenceEngine.cs +++ b/src/RecurringThings/Engine/IRecurrenceEngine.cs @@ -24,7 +24,7 @@ namespace RecurringThings.Engine; public interface IRecurrenceEngine { /// - /// Gets all calendar entries that overlap with the specified date range. + /// Gets all occurrences (standalone and virtualized) that overlap with the specified date range. /// /// The tenant identifier. /// The resource path scope. @@ -44,6 +44,7 @@ public interface IRecurrenceEngine /// /// /// Recurrence patterns themselves are not returned, only their virtualized occurrences. + /// Use to retrieve recurrence patterns. /// /// /// Excepted (cancelled) occurrences are excluded from results. @@ -59,7 +60,43 @@ public interface IRecurrenceEngine /// /// Thrown when or has DateTimeKind.Unspecified. /// - IAsyncEnumerable GetAsync( + IAsyncEnumerable GetOccurrencesAsync( + string organization, + string resourcePath, + DateTime start, + DateTime end, + string[]? types, + ITransactionContext? transactionContext = null, + CancellationToken cancellationToken = default); + + /// + /// Gets all recurrence patterns that overlap with the specified date range. + /// + /// The tenant identifier. + /// The resource path scope. + /// The start of the date range. Can be UTC or Local time (Unspecified is not allowed). + /// The end of the date range. Can be UTC or Local time (Unspecified is not allowed). + /// Optional type filter. Null returns all types. + /// Optional transaction context. + /// A token to cancel the operation. + /// + /// An async enumerable of objects representing recurrence patterns. + /// Each entry has set to . + /// + /// + /// + /// This method returns only the recurrence patterns themselves, not their virtualized occurrences. + /// Use to retrieve virtualized occurrences. + /// + /// + /// A recurrence overlaps with the date range if its recurrence time window + /// (StartTime to RecurrenceEndTime extracted from UNTIL) intersects with the query range. + /// + /// + /// + /// Thrown when or has DateTimeKind.Unspecified. + /// + IAsyncEnumerable GetRecurrencesAsync( string organization, string resourcePath, DateTime start, @@ -71,67 +108,94 @@ IAsyncEnumerable GetAsync( /// /// Creates a new recurrence pattern. /// - /// The recurrence creation request. + /// The tenant identifier for multi-tenant isolation (0-100 chars). + /// The hierarchical resource scope (0-100 chars). + /// The user-defined type of this recurrence (1-100 chars). + /// The timestamp when occurrences start. Can be UTC or Local (Unspecified not allowed). + /// The duration of each occurrence. + /// The RFC 5545 recurrence rule. Must use UNTIL in UTC (Z suffix); COUNT is not supported. + /// The IANA time zone identifier (e.g., "America/New_York"). + /// Optional user-defined key-value metadata. /// Optional transaction context. /// A token to cancel the operation. /// The created . - /// Thrown when is null. /// /// Thrown when validation fails (invalid RRule, missing UNTIL, COUNT used, field length violations, etc.). /// /// /// - /// var recurrence = await engine.CreateRecurrenceAsync(new RecurrenceCreate + /// var pattern = new RecurrencePattern /// { - /// Organization = "tenant1", - /// ResourcePath = "user123/calendar", - /// Type = "appointment", - /// StartTime = DateTime.UtcNow, // Or DateTime.Now for local time - /// Duration = TimeSpan.FromHours(1), - /// RRule = "FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR;UNTIL=20251231T235959Z", - /// TimeZone = "America/New_York" - /// }); + /// Frequency = FrequencyType.Daily, + /// Until = new CalDateTime(new DateTime(2025, 12, 31, 23, 59, 59, DateTimeKind.Local).ToUniversalTime()) + /// }; + /// pattern.ByDay.Add(new WeekDay(DayOfWeek.Monday)); + /// + /// var recurrence = await engine.CreateRecurrenceAsync( + /// organization: "tenant1", + /// resourcePath: "user123/calendar", + /// type: "appointment", + /// startTime: DateTime.Now, + /// duration: TimeSpan.FromHours(1), + /// rrule: pattern.ToString(), + /// timeZone: "America/New_York"); /// /// Task CreateRecurrenceAsync( - RecurrenceCreate request, + string organization, + string resourcePath, + string type, + DateTime startTime, + TimeSpan duration, + string rrule, + string timeZone, + Dictionary? extensions = null, ITransactionContext? transactionContext = null, CancellationToken cancellationToken = default); /// /// Creates a new standalone occurrence. /// - /// The occurrence creation request. + /// The tenant identifier for multi-tenant isolation (0-100 chars). + /// The hierarchical resource scope (0-100 chars). + /// The user-defined type of this occurrence (1-100 chars). + /// The timestamp when this occurrence starts. Can be UTC or Local (Unspecified not allowed). + /// The duration of this occurrence. + /// The IANA time zone identifier (e.g., "America/New_York"). + /// Optional user-defined key-value metadata. /// Optional transaction context. /// A token to cancel the operation. /// The created . /// /// EndTime is automatically computed as StartTime + Duration. /// - /// Thrown when is null. /// /// Thrown when validation fails (field length violations, invalid time zone, etc.). /// /// /// - /// var occurrence = await engine.CreateOccurrenceAsync(new OccurrenceCreate - /// { - /// Organization = "tenant1", - /// ResourcePath = "user123/calendar", - /// Type = "meeting", - /// StartTime = DateTime.UtcNow, // Or DateTime.Now for local time - /// Duration = TimeSpan.FromMinutes(30), - /// TimeZone = "America/New_York" - /// }); + /// var occurrence = await engine.CreateOccurrenceAsync( + /// organization: "tenant1", + /// resourcePath: "user123/calendar", + /// type: "meeting", + /// startTime: DateTime.Now, + /// duration: TimeSpan.FromMinutes(30), + /// timeZone: "America/New_York"); /// /// Task CreateOccurrenceAsync( - OccurrenceCreate request, + string organization, + string resourcePath, + string type, + DateTime startTime, + TimeSpan duration, + string timeZone, + Dictionary? extensions = null, ITransactionContext? transactionContext = null, CancellationToken cancellationToken = default); /// - /// Updates a calendar entry with immutability enforcement. + /// Updates an occurrence (standalone or virtualized). /// /// The calendar entry with updated values. /// Optional transaction context. @@ -139,14 +203,13 @@ Task CreateOccurrenceAsync( /// The updated . /// /// + /// Recurrence patterns cannot be updated. To modify a recurrence, delete and recreate it. + /// + /// /// Update behavior varies by entry type: /// /// /// - /// Recurrence - /// Only Duration and Extensions can be modified. - /// - /// /// Standalone Occurrence /// StartTime, Duration, and Extensions can be modified. EndTime is recomputed. /// @@ -162,34 +225,30 @@ Task CreateOccurrenceAsync( /// /// Thrown when is null. /// - /// Thrown when attempting to modify immutable fields (Organization, ResourcePath, Type, TimeZone, - /// or recurrence-specific fields like RRule and StartTime). + /// Thrown when attempting to update a recurrence pattern, or when attempting to modify + /// immutable fields (Organization, ResourcePath, Type, TimeZone). /// /// - /// Thrown when the underlying entity (recurrence, occurrence, or override) is not found. + /// Thrown when the underlying entity (occurrence or override) is not found. /// /// /// - /// // Update a recurrence's duration - /// entry.Duration = TimeSpan.FromHours(2); - /// var updated = await engine.UpdateAsync(entry); - /// /// // Update a standalone occurrence's start time /// entry.StartTime = entry.StartTime.AddHours(1); - /// var updated = await engine.UpdateAsync(entry); + /// var updated = await engine.UpdateOccurrenceAsync(entry); /// /// // Modify a virtualized occurrence (creates override) /// entry.Duration = TimeSpan.FromMinutes(45); - /// var updated = await engine.UpdateAsync(entry); + /// var updated = await engine.UpdateOccurrenceAsync(entry); /// /// - Task UpdateAsync( + Task UpdateOccurrenceAsync( CalendarEntry entry, ITransactionContext? transactionContext = null, CancellationToken cancellationToken = default); /// - /// Deletes a calendar entry with appropriate cascade behavior. + /// Deletes an occurrence (standalone or virtualized). /// /// The calendar entry to delete. /// Optional transaction context. @@ -197,14 +256,14 @@ Task UpdateAsync( /// A task representing the asynchronous delete operation. /// /// + /// This method deletes individual occurrences. To delete an entire recurrence pattern + /// and all its data, use . + /// + /// /// Delete behavior varies by entry type: /// /// /// - /// Recurrence - /// Deletes the entire series including all exceptions and overrides (cascade delete). - /// - /// /// Standalone Occurrence /// Deletes the occurrence directly. /// @@ -220,28 +279,63 @@ Task UpdateAsync( /// /// Thrown when is null. /// - /// Thrown when the entry type cannot be determined. + /// Thrown when the entry is a recurrence pattern. Use instead. /// /// - /// Thrown when the underlying entity (recurrence, occurrence, or override) is not found. + /// Thrown when the underlying entity (occurrence or override) is not found. /// /// /// - /// // Delete an entire recurrence series - /// await engine.DeleteAsync(recurrenceEntry); - /// /// // Cancel a single virtualized occurrence (creates exception) - /// await engine.DeleteAsync(virtualizedOccurrenceEntry); + /// await engine.DeleteOccurrenceAsync(virtualizedOccurrenceEntry); /// /// // Delete a standalone occurrence - /// await engine.DeleteAsync(standaloneEntry); + /// await engine.DeleteOccurrenceAsync(standaloneEntry); /// /// - Task DeleteAsync( + Task DeleteOccurrenceAsync( CalendarEntry entry, ITransactionContext? transactionContext = null, CancellationToken cancellationToken = default); + /// + /// Deletes a recurrence pattern and all associated exceptions and overrides. + /// + /// The tenant identifier. + /// The resource path scope. + /// The ID of the recurrence to delete. + /// Optional transaction context. + /// A token to cancel the operation. + /// A task representing the asynchronous delete operation. + /// + /// + /// This operation performs a cascade delete: + /// + /// + /// Deletes all occurrence exceptions for the recurrence + /// Deletes all occurrence overrides for the recurrence + /// Deletes the recurrence pattern itself + /// + /// + /// The organization and resourcePath parameters ensure multi-tenant isolation. + /// + /// + /// + /// Thrown when the recurrence is not found. + /// + /// + /// + /// // Delete a recurrence and all its data + /// await engine.DeleteRecurrenceAsync("tenant1", "user123/calendar", recurrenceId); + /// + /// + Task DeleteRecurrenceAsync( + string organization, + string resourcePath, + Guid recurrenceId, + ITransactionContext? transactionContext = null, + CancellationToken cancellationToken = default); + /// /// Restores an overridden virtualized occurrence to its original state. /// @@ -251,13 +345,14 @@ Task DeleteAsync( /// A task representing the asynchronous restore operation. /// /// - /// This operation is only valid for virtualized occurrences that have an override applied. + /// This operation is only valid for virtualized occurrences that have an override applied + /// ( is true). /// The override is deleted, and the occurrence will revert to its virtualized state /// (computed from the parent recurrence) on the next query. /// /// /// Important: Excepted (deleted) occurrences cannot be restored because they are not - /// returned by . To restore an excepted occurrence, you must delete + /// returned by . To restore an excepted occurrence, you must delete /// the exception directly from the exception repository. /// /// @@ -265,9 +360,9 @@ Task DeleteAsync( /// /// Thrown when attempting to restore: /// - /// A recurrence pattern (RecurrenceId set without RecurrenceOccurrenceDetails) - /// A standalone occurrence (OccurrenceId set) - /// A virtualized occurrence without an override (OverrideId is null) + /// A recurrence pattern + /// A standalone occurrence + /// A virtualized occurrence without an override ( is false) /// /// /// @@ -276,8 +371,8 @@ Task DeleteAsync( /// /// /// // Get an overridden occurrence - /// var entries = await engine.GetAsync(org, path, start, end, null).ToListAsync(); - /// var overriddenEntry = entries.First(e => e.OverrideId.HasValue); + /// var entries = await engine.GetOccurrencesAsync(org, path, start, end, null).ToListAsync(); + /// var overriddenEntry = entries.First(e => e.IsOverridden); /// /// // Restore it to original virtualized state /// await engine.RestoreAsync(overriddenEntry); diff --git a/src/RecurringThings/Engine/RecurrenceEngine.cs b/src/RecurringThings/Engine/RecurrenceEngine.cs index 2856c86..7d17921 100644 --- a/src/RecurringThings/Engine/RecurrenceEngine.cs +++ b/src/RecurringThings/Engine/RecurrenceEngine.cs @@ -6,7 +6,6 @@ namespace RecurringThings.Engine; using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; -using FluentValidation; using Ical.Net; using Ical.Net.CalendarComponents; using Ical.Net.DataTypes; @@ -38,8 +37,6 @@ public sealed class RecurrenceEngine : IRecurrenceEngine private readonly IOccurrenceRepository _occurrenceRepository; private readonly IOccurrenceExceptionRepository _exceptionRepository; private readonly IOccurrenceOverrideRepository _overrideRepository; - private readonly IValidator _recurrenceCreateValidator; - private readonly IValidator _occurrenceCreateValidator; /// /// Initializes a new instance of the class. @@ -48,34 +45,26 @@ public sealed class RecurrenceEngine : IRecurrenceEngine /// The occurrence repository. /// The exception repository. /// The override repository. - /// The validator for recurrence create requests. - /// The validator for occurrence create requests. /// Thrown when any dependency is null. public RecurrenceEngine( IRecurrenceRepository recurrenceRepository, IOccurrenceRepository occurrenceRepository, IOccurrenceExceptionRepository exceptionRepository, - IOccurrenceOverrideRepository overrideRepository, - IValidator recurrenceCreateValidator, - IValidator occurrenceCreateValidator) + IOccurrenceOverrideRepository overrideRepository) { ArgumentNullException.ThrowIfNull(recurrenceRepository); ArgumentNullException.ThrowIfNull(occurrenceRepository); ArgumentNullException.ThrowIfNull(exceptionRepository); ArgumentNullException.ThrowIfNull(overrideRepository); - ArgumentNullException.ThrowIfNull(recurrenceCreateValidator); - ArgumentNullException.ThrowIfNull(occurrenceCreateValidator); _recurrenceRepository = recurrenceRepository; _occurrenceRepository = occurrenceRepository; _exceptionRepository = exceptionRepository; _overrideRepository = overrideRepository; - _recurrenceCreateValidator = recurrenceCreateValidator; - _occurrenceCreateValidator = occurrenceCreateValidator; } /// - public async IAsyncEnumerable GetAsync( + public async IAsyncEnumerable GetOccurrencesAsync( string organization, string resourcePath, DateTime start, @@ -223,33 +212,39 @@ public async IAsyncEnumerable GetAsync( /// public async Task CreateRecurrenceAsync( - RecurrenceCreate request, + string organization, + string resourcePath, + string type, + DateTime startTime, + TimeSpan duration, + string rrule, + string timeZone, + Dictionary? extensions = null, ITransactionContext? transactionContext = null, CancellationToken cancellationToken = default) { - // Validate the request - var validationResult = await _recurrenceCreateValidator.ValidateAsync(request, cancellationToken).ConfigureAwait(false); - validationResult.ThrowIfInvalid(); + // Validate parameters + Validator.ValidateRecurrenceCreate(organization, resourcePath, type, startTime, duration, rrule, timeZone, extensions); // Convert input time to UTC if it's local - var startTimeUtc = ConvertToUtc(request.StartTime, request.TimeZone); + var startTimeUtc = ConvertToUtc(startTime, timeZone); // Extract RecurrenceEndTime from RRule UNTIL clause - var recurrenceEndTimeUtc = ExtractUntilFromRRule(request.RRule); + var recurrenceEndTimeUtc = ExtractUntilFromRRule(rrule); // Create the recurrence entity var recurrence = new Recurrence { Id = Guid.NewGuid(), - Organization = request.Organization, - ResourcePath = request.ResourcePath, - Type = request.Type, + Organization = organization, + ResourcePath = resourcePath, + Type = type, StartTime = startTimeUtc, - Duration = request.Duration, + Duration = duration, RecurrenceEndTime = recurrenceEndTimeUtc, - RRule = request.RRule, - TimeZone = request.TimeZone, - Extensions = request.Extensions + RRule = rrule, + TimeZone = timeZone, + Extensions = extensions }; // Persist via repository @@ -261,30 +256,35 @@ public async Task CreateRecurrenceAsync( /// public async Task CreateOccurrenceAsync( - OccurrenceCreate request, + string organization, + string resourcePath, + string type, + DateTime startTime, + TimeSpan duration, + string timeZone, + Dictionary? extensions = null, ITransactionContext? transactionContext = null, CancellationToken cancellationToken = default) { - // Validate the request - var validationResult = await _occurrenceCreateValidator.ValidateAsync(request, cancellationToken).ConfigureAwait(false); - validationResult.ThrowIfInvalid(); + // Validate parameters + Validator.ValidateOccurrenceCreate(organization, resourcePath, type, startTime, duration, timeZone, extensions); // Convert input time to UTC if it's local - var startTimeUtc = ConvertToUtc(request.StartTime, request.TimeZone); + var startTimeUtc = ConvertToUtc(startTime, timeZone); // Create the occurrence entity var occurrence = new Domain.Occurrence { Id = Guid.NewGuid(), - Organization = request.Organization, - ResourcePath = request.ResourcePath, - Type = request.Type, - TimeZone = request.TimeZone, - Extensions = request.Extensions + Organization = organization, + ResourcePath = resourcePath, + Type = type, + TimeZone = timeZone, + Extensions = extensions }; // Initialize with StartTime and Duration (auto-computes EndTime) - occurrence.Initialize(startTimeUtc, request.Duration); + occurrence.Initialize(startTimeUtc, duration); // Persist via repository return await _occurrenceRepository.CreateAsync( @@ -392,15 +392,13 @@ private static CalendarEntry CreateVirtualizedEntry(Recurrence recurrence, DateT TimeZone = recurrence.TimeZone, Extensions = recurrence.Extensions, RecurrenceId = recurrence.Id, - RecurrenceOccurrenceDetails = new RecurrenceOccurrenceDetails + EntryType = CalendarEntryType.Virtualized, + RecurrenceDetails = new RecurrenceDetails { RRule = recurrence.RRule }, + Original = new OriginalDetails { - RecurrenceId = recurrence.Id, - Original = new OccurrenceOriginal - { - StartTime = occurrenceTimeUtc, - Duration = recurrence.Duration, - Extensions = recurrence.Extensions - } + StartTime = occurrenceTimeUtc, + Duration = recurrence.Duration, + Extensions = recurrence.Extensions } }; } @@ -425,15 +423,13 @@ private static CalendarEntry CreateOverriddenEntry(Recurrence recurrence, Occurr Extensions = @override.Extensions, RecurrenceId = recurrence.Id, OverrideId = @override.Id, - RecurrenceOccurrenceDetails = new RecurrenceOccurrenceDetails + EntryType = CalendarEntryType.Virtualized, + RecurrenceDetails = new RecurrenceDetails { RRule = recurrence.RRule }, + Original = new OriginalDetails { - RecurrenceId = recurrence.Id, - Original = new OccurrenceOriginal - { - StartTime = @override.OriginalTimeUtc, - Duration = @override.OriginalDuration, - Extensions = @override.OriginalExtensions - } + StartTime = @override.OriginalTimeUtc, + Duration = @override.OriginalDuration, + Extensions = @override.OriginalExtensions } }; } @@ -456,7 +452,34 @@ private static CalendarEntry CreateStandaloneEntry(Domain.Occurrence occurrence) Duration = occurrence.Duration, TimeZone = occurrence.TimeZone, Extensions = occurrence.Extensions, - OccurrenceId = occurrence.Id + OccurrenceId = occurrence.Id, + EntryType = CalendarEntryType.Standalone, + Original = null + }; + } + + /// + /// Creates a CalendarEntry for a recurrence pattern. + /// + private static CalendarEntry CreateRecurrenceEntry(Recurrence recurrence) + { + var startTimeLocal = ConvertToLocal(recurrence.StartTime, recurrence.TimeZone); + var endTimeLocal = ConvertToLocal(recurrence.StartTime + recurrence.Duration, recurrence.TimeZone); + + return new CalendarEntry + { + Organization = recurrence.Organization, + ResourcePath = recurrence.ResourcePath, + Type = recurrence.Type, + StartTime = startTimeLocal, + EndTime = endTimeLocal, + Duration = recurrence.Duration, + TimeZone = recurrence.TimeZone, + Extensions = recurrence.Extensions, + RecurrenceId = recurrence.Id, + EntryType = CalendarEntryType.Recurrence, + RecurrenceDetails = new RecurrenceDetails { RRule = recurrence.RRule }, + Original = null }; } @@ -477,13 +500,20 @@ private static async Task> MaterializeAsync( } /// - public async Task UpdateAsync( + public async Task UpdateOccurrenceAsync( CalendarEntry entry, ITransactionContext? transactionContext = null, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(entry); + // Block recurrence updates + if (entry.EntryType == CalendarEntryType.Recurrence) + { + throw new InvalidOperationException( + "Cannot update a recurrence pattern. Delete and recreate the recurrence instead."); + } + // Determine entry type and delegate to appropriate handler if (entry.OccurrenceId.HasValue) { @@ -491,7 +521,7 @@ public async Task UpdateAsync( return await UpdateStandaloneOccurrenceAsync(entry, transactionContext, cancellationToken).ConfigureAwait(false); } - if (entry.RecurrenceOccurrenceDetails is not null) + if (entry.EntryType == CalendarEntryType.Virtualized) { // Virtualized occurrence (from recurrence) if (entry.OverrideId.HasValue) @@ -504,106 +534,8 @@ public async Task UpdateAsync( return await CreateOverrideForVirtualizedOccurrenceAsync(entry, transactionContext, cancellationToken).ConfigureAwait(false); } - if (entry.RecurrenceId.HasValue) - { - // Recurrence pattern - return await UpdateRecurrenceAsync(entry, transactionContext, cancellationToken).ConfigureAwait(false); - } - throw new InvalidOperationException( - "Cannot determine entry type. Entry must have RecurrenceId, OccurrenceId, or RecurrenceOccurrenceDetails set."); - } - - /// - /// Updates a recurrence pattern. Only Duration and Extensions are mutable. - /// - private async Task UpdateRecurrenceAsync( - CalendarEntry entry, - ITransactionContext? transactionContext, - CancellationToken cancellationToken) - { - var recurrence = await _recurrenceRepository.GetByIdAsync( - entry.RecurrenceId!.Value, - entry.Organization, - entry.ResourcePath, - transactionContext, - cancellationToken).ConfigureAwait(false) ?? throw new KeyNotFoundException( - $"Recurrence with ID '{entry.RecurrenceId}' not found."); - - // Validate immutable fields - ValidateImmutableFields(entry, recurrence); - - // Apply mutable changes - recurrence.Duration = entry.Duration; - recurrence.Extensions = entry.Extensions; - - var updated = await _recurrenceRepository.UpdateAsync( - recurrence, - transactionContext, - cancellationToken).ConfigureAwait(false); - - return new CalendarEntry - { - Organization = updated.Organization, - ResourcePath = updated.ResourcePath, - Type = updated.Type, - StartTime = updated.StartTime, - EndTime = updated.StartTime + updated.Duration, - Duration = updated.Duration, - TimeZone = updated.TimeZone, - Extensions = updated.Extensions, - RecurrenceId = updated.Id, - RecurrenceDetails = new RecurrenceDetails - { - RRule = updated.RRule - } - }; - } - - /// - /// Validates that immutable fields on a recurrence have not been changed. - /// - private static void ValidateImmutableFields(CalendarEntry entry, Recurrence existing) - { - if (entry.Organization != existing.Organization) - { - throw new InvalidOperationException( - "Cannot modify Organization. This field is immutable after creation."); - } - - if (entry.ResourcePath != existing.ResourcePath) - { - throw new InvalidOperationException( - "Cannot modify ResourcePath. This field is immutable after creation."); - } - - if (entry.Type != existing.Type) - { - throw new InvalidOperationException( - "Cannot modify Type. This field is immutable after creation."); - } - - if (entry.TimeZone != existing.TimeZone) - { - throw new InvalidOperationException( - "Cannot modify TimeZone. This field is immutable after creation."); - } - - if (entry.StartTime != existing.StartTime) - { - throw new InvalidOperationException( - "Cannot modify StartTime on a recurrence. This field is immutable after creation."); - } - - // Only validate RRule if RecurrenceDetails is provided - if (entry.RecurrenceDetails is not null) - { - if (entry.RecurrenceDetails.RRule != existing.RRule) - { - throw new InvalidOperationException( - "Cannot modify RRule. This field is immutable after creation."); - } - } + "Cannot determine entry type. Entry must have OccurrenceId set (standalone) or EntryType set to Virtualized."); } /// @@ -676,7 +608,8 @@ private async Task CreateOverrideForVirtualizedOccurrenceAsync( ITransactionContext? transactionContext, CancellationToken cancellationToken) { - var recurrenceId = entry.RecurrenceOccurrenceDetails!.RecurrenceId; + var recurrenceId = entry.RecurrenceId + ?? throw new InvalidOperationException("Cannot create override: RecurrenceId is missing."); var recurrence = await _recurrenceRepository.GetByIdAsync( recurrenceId, @@ -689,11 +622,11 @@ private async Task CreateOverrideForVirtualizedOccurrenceAsync( // Validate immutable fields against the parent recurrence ValidateImmutableVirtualizedFields(entry, recurrence); - // Get the original time from RecurrenceOccurrenceDetails.Original + // Get the original time from Original.StartTime // This is populated by CreateVirtualizedEntry when the entry is first queried - var originalTime = entry.RecurrenceOccurrenceDetails.Original?.StartTime + var originalTime = entry.Original?.StartTime ?? throw new InvalidOperationException( - "Cannot create override: Original start time is missing from RecurrenceOccurrenceDetails."); + "Cannot create override: Original start time is missing."); // Create new override with denormalized original values var newOverride = new OccurrenceOverride @@ -793,13 +726,20 @@ private static void ValidateImmutableVirtualizedFields(CalendarEntry entry, Recu } /// - public async Task DeleteAsync( + public async Task DeleteOccurrenceAsync( CalendarEntry entry, ITransactionContext? transactionContext = null, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(entry); + // Block recurrence deletion - use DeleteRecurrenceAsync instead + if (entry.EntryType == CalendarEntryType.Recurrence) + { + throw new InvalidOperationException( + "Cannot delete a recurrence pattern using DeleteOccurrenceAsync. Use DeleteRecurrenceAsync instead."); + } + // Determine entry type and delegate to appropriate handler if (entry.OccurrenceId.HasValue) { @@ -808,7 +748,7 @@ public async Task DeleteAsync( return; } - if (entry.RecurrenceOccurrenceDetails is not null) + if (entry.EntryType == CalendarEntryType.Virtualized) { // Virtualized occurrence (from recurrence) if (entry.OverrideId.HasValue) @@ -825,30 +765,46 @@ public async Task DeleteAsync( return; } - if (entry.RecurrenceId.HasValue) - { - // Recurrence pattern - cascade delete - await DeleteRecurrenceAsync(entry, transactionContext, cancellationToken).ConfigureAwait(false); - return; - } - throw new InvalidOperationException( - "Cannot determine entry type. Entry must have RecurrenceId, OccurrenceId, or RecurrenceOccurrenceDetails set."); + "Cannot determine entry type. Entry must have OccurrenceId set (standalone) or EntryType set to Virtualized."); } - /// - /// Deletes a recurrence pattern with cascade delete behavior. - /// - private async Task DeleteRecurrenceAsync( - CalendarEntry entry, - ITransactionContext? transactionContext, - CancellationToken cancellationToken) + /// + public async Task DeleteRecurrenceAsync( + string organization, + string resourcePath, + Guid recurrenceId, + ITransactionContext? transactionContext = null, + CancellationToken cancellationToken = default) { - // Repository handles cascade delete (PostgreSQL via ON DELETE CASCADE, MongoDB via explicit transaction) + // Verify recurrence exists and belongs to organization/resourcePath + var recurrence = await _recurrenceRepository.GetByIdAsync( + recurrenceId, + organization, + resourcePath, + transactionContext, + cancellationToken).ConfigureAwait(false) ?? throw new KeyNotFoundException( + $"Recurrence with ID '{recurrenceId}' not found."); + + // Delete cascade: exceptions, overrides, then recurrence + await _exceptionRepository.DeleteByRecurrenceIdAsync( + recurrenceId, + organization, + resourcePath, + transactionContext, + cancellationToken).ConfigureAwait(false); + + await _overrideRepository.DeleteByRecurrenceIdAsync( + recurrenceId, + organization, + resourcePath, + transactionContext, + cancellationToken).ConfigureAwait(false); + await _recurrenceRepository.DeleteAsync( - entry.RecurrenceId!.Value, - entry.Organization, - entry.ResourcePath, + recurrenceId, + organization, + resourcePath, transactionContext, cancellationToken).ConfigureAwait(false); } @@ -877,17 +833,20 @@ private async Task CreateExceptionForVirtualizedOccurrenceAsync( ITransactionContext? transactionContext, CancellationToken cancellationToken) { - // Get the original time from RecurrenceOccurrenceDetails.Original - var originalTime = entry.RecurrenceOccurrenceDetails!.Original?.StartTime + // Get the original time from Original.StartTime + var originalTime = entry.Original?.StartTime ?? throw new InvalidOperationException( - "Cannot create exception: Original start time is missing from RecurrenceOccurrenceDetails."); + "Cannot create exception: Original start time is missing."); + + var recurrenceId = entry.RecurrenceId + ?? throw new InvalidOperationException("Cannot create exception: RecurrenceId is missing."); var exception = new OccurrenceException { Id = Guid.NewGuid(), Organization = entry.Organization, ResourcePath = entry.ResourcePath, - RecurrenceId = entry.RecurrenceOccurrenceDetails.RecurrenceId, + RecurrenceId = recurrenceId, OriginalTimeUtc = originalTime }; @@ -905,10 +864,13 @@ private async Task DeleteVirtualizedOccurrenceWithOverrideAsync( ITransactionContext? transactionContext, CancellationToken cancellationToken) { - // Get the original time from RecurrenceOccurrenceDetails.Original - var originalTime = entry.RecurrenceOccurrenceDetails!.Original?.StartTime + // Get the original time from Original.StartTime + var originalTime = entry.Original?.StartTime ?? throw new InvalidOperationException( - "Cannot create exception: Original start time is missing from RecurrenceOccurrenceDetails."); + "Cannot create exception: Original start time is missing."); + + var recurrenceId = entry.RecurrenceId + ?? throw new InvalidOperationException("Cannot create exception: RecurrenceId is missing."); // Delete the override await _overrideRepository.DeleteAsync( @@ -924,7 +886,7 @@ await _overrideRepository.DeleteAsync( Id = Guid.NewGuid(), Organization = entry.Organization, ResourcePath = entry.ResourcePath, - RecurrenceId = entry.RecurrenceOccurrenceDetails.RecurrenceId, + RecurrenceId = recurrenceId, OriginalTimeUtc = originalTime }; @@ -943,40 +905,82 @@ public async Task RestoreAsync( ArgumentNullException.ThrowIfNull(entry); // Validate entry type - RestoreAsync is only valid for overridden virtualized occurrences - if (entry.OccurrenceId.HasValue) + if (entry.EntryType == CalendarEntryType.Standalone || entry.OccurrenceId.HasValue) { throw new InvalidOperationException( "Cannot restore a standalone occurrence. RestoreAsync is only valid for overridden virtualized occurrences."); } - if (entry.RecurrenceId.HasValue && entry.RecurrenceOccurrenceDetails is null) + if (entry.EntryType == CalendarEntryType.Recurrence) { throw new InvalidOperationException( "Cannot restore a recurrence pattern. RestoreAsync is only valid for overridden virtualized occurrences."); } - if (entry.RecurrenceOccurrenceDetails is null) + if (entry.EntryType != CalendarEntryType.Virtualized) { throw new InvalidOperationException( - "Cannot determine entry type. Entry must have RecurrenceOccurrenceDetails set for RestoreAsync."); + "Cannot determine entry type. Entry must have EntryType set to Virtualized for RestoreAsync."); } - if (!entry.OverrideId.HasValue) + if (!entry.IsOverridden) { throw new InvalidOperationException( "Cannot restore a virtualized occurrence without an override. " + - "RestoreAsync is only valid for occurrences that have been modified (OverrideId must be set)."); + "RestoreAsync is only valid for occurrences that have been modified (IsOverridden must be true)."); } // Delete the override to restore the occurrence to its original virtualized state await _overrideRepository.DeleteAsync( - entry.OverrideId.Value, + entry.OverrideId!.Value, entry.Organization, entry.ResourcePath, transactionContext, cancellationToken).ConfigureAwait(false); } + /// + public async IAsyncEnumerable GetRecurrencesAsync( + string organization, + string resourcePath, + DateTime start, + DateTime end, + string[]? types, + ITransactionContext? transactionContext = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + // Validate that start and end have a specified Kind (UTC or Local, not Unspecified) + if (start.Kind == DateTimeKind.Unspecified) + { + throw new ArgumentException( + "start must have a specified Kind (Utc or Local). DateTimeKind.Unspecified is not allowed.", + nameof(start)); + } + + if (end.Kind == DateTimeKind.Unspecified) + { + throw new ArgumentException( + "end must have a specified Kind (Utc or Local). DateTimeKind.Unspecified is not allowed.", + nameof(end)); + } + + // Convert to UTC if needed (using system timezone for Local times) + var startUtc = start.Kind == DateTimeKind.Utc ? start : start.ToUniversalTime(); + var endUtc = end.Kind == DateTimeKind.Utc ? end : end.ToUniversalTime(); + + // Validate types filter + Validator.ValidateTypesFilter(types); + + // Get recurrences from repository + var recurrences = _recurrenceRepository.GetInRangeAsync( + organization, resourcePath, startUtc, endUtc, types, transactionContext, cancellationToken); + + await foreach (var recurrence in recurrences.WithCancellation(cancellationToken).ConfigureAwait(false)) + { + yield return CreateRecurrenceEntry(recurrence); + } + } + /// /// Extracts the UNTIL value from an RRule string and returns it as a UTC DateTime. /// diff --git a/src/RecurringThings/Models/CalendarEntry.cs b/src/RecurringThings/Models/CalendarEntry.cs index b5a8c36..76f948f 100644 --- a/src/RecurringThings/Models/CalendarEntry.cs +++ b/src/RecurringThings/Models/CalendarEntry.cs @@ -9,20 +9,20 @@ namespace RecurringThings.Models; /// /// /// -/// The type of entry can be determined by examining which ID properties are set: +/// The type of entry can be determined by examining the property: /// /// /// /// Recurrence -/// is set and is populated +/// is /// /// /// Standalone Occurrence -/// is set and both detail properties are null +/// is /// /// /// Virtualized Occurrence -/// is populated (optionally with ) +/// is /// /// /// @@ -47,13 +47,24 @@ public sealed class CalendarEntry public required string Type { get; set; } /// - /// Gets or sets the UTC timestamp when this entry starts. + /// Gets or sets the type of this calendar entry. /// + public CalendarEntryType EntryType { get; set; } + + /// + /// Gets or sets the local timestamp when this entry starts. + /// + /// + /// The time is in the local timezone specified by . + /// public DateTime StartTime { get; set; } /// - /// Gets or sets the UTC timestamp when this entry ends. + /// Gets or sets the local timestamp when this entry ends. /// + /// + /// The time is in the local timezone specified by . + /// public DateTime EndTime { get; set; } /// @@ -72,19 +83,19 @@ public sealed class CalendarEntry public Dictionary? Extensions { get; set; } /// - /// Gets or sets the recurrence ID if this entry represents a recurrence pattern or a virtualized occurrence. + /// Gets or sets the recurrence ID. /// /// - /// Set when this entry is a recurrence (with populated) - /// or when this is a virtualized occurrence from a recurrence. + /// Set when is + /// or . /// public Guid? RecurrenceId { get; set; } /// - /// Gets or sets the standalone occurrence ID if this entry represents a standalone occurrence. + /// Gets or sets the standalone occurrence ID. /// /// - /// Set only for standalone occurrences (not from a recurrence pattern). + /// Set only when is . /// public Guid? OccurrenceId { get; set; } @@ -93,6 +104,7 @@ public sealed class CalendarEntry /// /// /// Set when this is a virtualized occurrence that has an override applied. + /// Check for a convenient boolean check. /// public Guid? OverrideId { get; set; } @@ -101,25 +113,40 @@ public sealed class CalendarEntry /// /// /// This property is never set in query results because excepted (deleted) occurrences - /// are not returned by GetAsync queries. + /// are not returned by queries. /// public Guid? ExceptionId { get; set; } /// - /// Gets or sets the recurrence-specific details. + /// Gets a value indicating whether this entry has an override applied. + /// + /// + /// Returns true only for virtualized occurrences with an override applied. + /// When true, contains the original values before the override. + /// + public bool IsOverridden => OverrideId.HasValue; + + /// + /// Gets or sets the recurrence details. /// /// - /// Populated only when this entry represents a recurrence pattern (not a virtualized occurrence). - /// Mutually exclusive with . + /// Populated when is + /// or . Contains the RRule that defines or + /// generated this entry. /// public RecurrenceDetails? RecurrenceDetails { get; set; } /// - /// Gets or sets the virtualized occurrence details. + /// Gets or sets the original values before an override was applied. /// /// - /// Populated when this entry represents an occurrence generated from a recurrence pattern. - /// Mutually exclusive with . + /// + /// Populated only when is true. + /// + /// + /// When an override is applied to a virtualized occurrence, this property contains + /// the original start time, duration, and extensions before modification. + /// /// - public RecurrenceOccurrenceDetails? RecurrenceOccurrenceDetails { get; set; } + public OriginalDetails? Original { get; set; } } diff --git a/src/RecurringThings/Models/CalendarEntryType.cs b/src/RecurringThings/Models/CalendarEntryType.cs new file mode 100644 index 0000000..63bb75d --- /dev/null +++ b/src/RecurringThings/Models/CalendarEntryType.cs @@ -0,0 +1,22 @@ +namespace RecurringThings.Models; + +/// +/// Specifies the type of a calendar entry. +/// +public enum CalendarEntryType +{ + /// + /// A standalone occurrence that is not part of a recurrence pattern. + /// + Standalone, + + /// + /// A virtualized occurrence generated from a recurrence pattern. + /// + Virtualized, + + /// + /// A recurrence pattern that generates virtualized occurrences. + /// + Recurrence +} diff --git a/src/RecurringThings/Models/OccurrenceCreate.cs b/src/RecurringThings/Models/OccurrenceCreate.cs deleted file mode 100644 index f4aaf9e..0000000 --- a/src/RecurringThings/Models/OccurrenceCreate.cs +++ /dev/null @@ -1,76 +0,0 @@ -namespace RecurringThings.Models; - -using System; -using System.Collections.Generic; - -/// -/// Request model for creating a new standalone occurrence. -/// -/// -/// DateTime values can be provided in UTC or Local time (Unspecified is not allowed). -/// Local times are converted to UTC internally using the specified . -/// EndTime will be computed automatically as StartTime + Duration. -/// -public sealed class OccurrenceCreate -{ - /// - /// Gets the tenant identifier for multi-tenant isolation. - /// - /// - /// Must be between 0 and 100 characters. Empty string is allowed for single-tenant scenarios. - /// - public required string Organization { get; init; } - - /// - /// Gets the hierarchical resource scope. - /// - /// - /// Used for organizing resources hierarchically (e.g., "user123/calendar", "store456"). - /// Must be between 0 and 100 characters. Empty string is allowed. - /// - public required string ResourcePath { get; init; } - - /// - /// Gets the user-defined type of this occurrence. - /// - /// - /// Used to differentiate between different kinds of occurrences (e.g., "appointment", "meeting"). - /// Must be between 1 and 100 characters. Empty string is NOT allowed. - /// - public required string Type { get; init; } - - /// - /// Gets the timestamp when this occurrence starts. - /// - /// - /// Can be provided in UTC or Local time (Unspecified is not allowed). - /// Local times are converted to UTC internally using . - /// - public required DateTime StartTime { get; init; } - - /// - /// Gets the duration of this occurrence. - /// - /// - /// EndTime will be computed as StartTime + Duration. - /// - public required TimeSpan Duration { get; init; } - - /// - /// Gets the IANA time zone identifier. - /// - /// - /// Must be a valid IANA timezone (e.g., "America/New_York", not "Eastern Standard Time"). - /// - public required string TimeZone { get; init; } - - /// - /// Gets the user-defined key-value metadata. - /// - /// - /// Optional. Can be null. - /// Key constraints: 1-100 characters, non-null. - /// Value constraints: 0-1024 characters, non-null. - /// - public Dictionary? Extensions { get; init; } -} diff --git a/src/RecurringThings/Models/OccurrenceOriginal.cs b/src/RecurringThings/Models/OriginalDetails.cs similarity index 86% rename from src/RecurringThings/Models/OccurrenceOriginal.cs rename to src/RecurringThings/Models/OriginalDetails.cs index 7154b06..433f7e3 100644 --- a/src/RecurringThings/Models/OccurrenceOriginal.cs +++ b/src/RecurringThings/Models/OriginalDetails.cs @@ -7,10 +7,10 @@ namespace RecurringThings.Models; /// Contains the original values of a virtualized occurrence before an override was applied. /// /// -/// This class is populated on when +/// This class is populated on when /// the virtualized occurrence has been modified by an override. /// -public sealed class OccurrenceOriginal +public sealed class OriginalDetails { /// /// Gets or sets the original UTC start time before the override was applied. diff --git a/src/RecurringThings/Models/RecurrenceCreate.cs b/src/RecurringThings/Models/RecurrenceCreate.cs deleted file mode 100644 index c88eb9f..0000000 --- a/src/RecurringThings/Models/RecurrenceCreate.cs +++ /dev/null @@ -1,91 +0,0 @@ -namespace RecurringThings.Models; - -using System; -using System.Collections.Generic; - -/// -/// Request model for creating a new recurrence pattern. -/// -/// -/// DateTime values can be provided in UTC or Local time (Unspecified is not allowed). -/// Local times are converted to UTC internally using the specified . -/// The RRule UNTIL value must be in UTC (Z suffix). The recurrence end time is automatically -/// extracted from the RRule UNTIL clause. -/// -public sealed class RecurrenceCreate -{ - /// - /// Gets the tenant identifier for multi-tenant isolation. - /// - /// - /// Must be between 0 and 100 characters. Empty string is allowed for single-tenant scenarios. - /// - public required string Organization { get; init; } - - /// - /// Gets the hierarchical resource scope. - /// - /// - /// Used for organizing resources hierarchically (e.g., "user123/calendar", "store456"). - /// Must be between 0 and 100 characters. Empty string is allowed. - /// - public required string ResourcePath { get; init; } - - /// - /// Gets the user-defined type of this recurrence. - /// - /// - /// Used to differentiate between different kinds of recurring things (e.g., "appointment", "open-hours"). - /// Must be between 1 and 100 characters. Empty string is NOT allowed. - /// - public required string Type { get; init; } - - /// - /// Gets the timestamp representing the time-of-day that occurrences start. - /// - /// - /// Can be provided in UTC or Local time (Unspecified is not allowed). - /// Local times are converted to UTC internally using . - /// During virtualization, the stored UTC time is converted to local time - /// using before applying the RRule. - /// - public required DateTime StartTime { get; init; } - - /// - /// Gets the duration of each occurrence. - /// - /// - /// Individual occurrence end times are computed as StartTime + Duration during virtualization. - /// - public required TimeSpan Duration { get; init; } - - /// - /// Gets the RFC 5545 recurrence rule defining the pattern. - /// - /// - /// Must use UNTIL (in UTC with Z suffix); COUNT is not supported. - /// The recurrence end time is automatically extracted from the UNTIL clause. - /// Maximum length is 2000 characters. - /// Example: "FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR;UNTIL=20251231T235959Z" - /// - public required string RRule { get; init; } - - /// - /// Gets the IANA time zone identifier. - /// - /// - /// Used for local time conversion during virtualization to correctly handle DST transitions. - /// Must be a valid IANA timezone (e.g., "America/New_York", not "Eastern Standard Time"). - /// - public required string TimeZone { get; init; } - - /// - /// Gets the user-defined key-value metadata. - /// - /// - /// Optional. Can be null. - /// Key constraints: 1-100 characters, non-null. - /// Value constraints: 0-1024 characters, non-null. - /// - public Dictionary? Extensions { get; init; } -} diff --git a/src/RecurringThings/Models/RecurrenceOccurrenceDetails.cs b/src/RecurringThings/Models/RecurrenceOccurrenceDetails.cs deleted file mode 100644 index 3109595..0000000 --- a/src/RecurringThings/Models/RecurrenceOccurrenceDetails.cs +++ /dev/null @@ -1,28 +0,0 @@ -namespace RecurringThings.Models; - -using System; - -/// -/// Contains details for a that represents a virtualized occurrence -/// generated from a recurrence pattern. -/// -/// -/// This class is populated on when -/// the entry is an occurrence generated from a recurrence's RRule pattern. -/// -public sealed class RecurrenceOccurrenceDetails -{ - /// - /// Gets or sets the identifier of the parent recurrence that generated this occurrence. - /// - public Guid RecurrenceId { get; set; } - - /// - /// Gets or sets the original values before an override was applied. - /// - /// - /// Non-null when this occurrence has an override applied. - /// Null when this is a "clean" virtualized occurrence without modifications. - /// - public OccurrenceOriginal? Original { get; set; } -} diff --git a/src/RecurringThings/RecurringThings.csproj b/src/RecurringThings/RecurringThings.csproj index 4fe5b82..690ab9d 100644 --- a/src/RecurringThings/RecurringThings.csproj +++ b/src/RecurringThings/RecurringThings.csproj @@ -15,10 +15,12 @@ https://github.com/ChuckNovice/RecurringThings Apache-2.0 README.md + logo_gray.png + diff --git a/src/RecurringThings/Validation/Validator.cs b/src/RecurringThings/Validation/Validator.cs index 4379ebf..58747ac 100644 --- a/src/RecurringThings/Validation/Validator.cs +++ b/src/RecurringThings/Validation/Validator.cs @@ -1,16 +1,16 @@ namespace RecurringThings.Validation; using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; +using Ical.Net.DataTypes; +using NodaTime; using RecurringThings.Domain; /// /// Provides validation logic for RecurringThings entities and request models. /// -/// -/// Most entity validation is handled by FluentValidation validators in the Validators namespace. -/// This class contains relationship validation that doesn't fit the FluentValidation pattern. -/// -public static class Validator +public static partial class Validator { /// /// Validates that an exception or override belongs to the same tenant scope as its parent recurrence. @@ -55,4 +55,261 @@ public static void ValidateTypesFilter(string[]? types) throw new ArgumentException("Types filter cannot be an empty array. Use null to include all types.", nameof(types)); } } + + /// + /// Validates parameters for creating a recurrence. + /// + /// Thrown when any parameter is invalid. + public static void ValidateRecurrenceCreate( + string organization, + string resourcePath, + string type, + DateTime startTime, + TimeSpan duration, + string rrule, + string timeZone, + Dictionary? extensions) + { + ValidateOrganization(organization); + ValidateResourcePath(resourcePath); + ValidateType(type); + ValidateStartTime(startTime); + ValidateDuration(duration); + ValidateRRule(rrule); + ValidateTimeZone(timeZone); + ValidateExtensions(extensions); + } + + /// + /// Validates parameters for creating an occurrence. + /// + /// Thrown when any parameter is invalid. + public static void ValidateOccurrenceCreate( + string organization, + string resourcePath, + string type, + DateTime startTime, + TimeSpan duration, + string timeZone, + Dictionary? extensions) + { + ValidateOrganization(organization); + ValidateResourcePath(resourcePath); + ValidateType(type); + ValidateStartTime(startTime); + ValidateDuration(duration); + ValidateTimeZone(timeZone); + ValidateExtensions(extensions); + } + + /// + /// Validates the organization parameter. + /// + private static void ValidateOrganization(string organization) + { + ArgumentNullException.ThrowIfNull(organization, nameof(organization)); + + if (organization.Length > ValidationConstants.MaxOrganizationLength) + { + throw new ArgumentException( + $"Organization must not exceed {ValidationConstants.MaxOrganizationLength} characters. Actual length: {organization.Length}.", + nameof(organization)); + } + } + + /// + /// Validates the resource path parameter. + /// + private static void ValidateResourcePath(string resourcePath) + { + ArgumentNullException.ThrowIfNull(resourcePath, nameof(resourcePath)); + + if (resourcePath.Length > ValidationConstants.MaxResourcePathLength) + { + throw new ArgumentException( + $"ResourcePath must not exceed {ValidationConstants.MaxResourcePathLength} characters. Actual length: {resourcePath.Length}.", + nameof(resourcePath)); + } + } + + /// + /// Validates the type parameter. + /// + private static void ValidateType(string type) + { + ArgumentNullException.ThrowIfNull(type, nameof(type)); + + if (type.Length < ValidationConstants.MinTypeLength) + { + throw new ArgumentException( + $"Type must be at least {ValidationConstants.MinTypeLength} character(s).", + nameof(type)); + } + + if (type.Length > ValidationConstants.MaxTypeLength) + { + throw new ArgumentException( + $"Type must not exceed {ValidationConstants.MaxTypeLength} characters. Actual length: {type.Length}.", + nameof(type)); + } + } + + /// + /// Validates the start time parameter. + /// + private static void ValidateStartTime(DateTime startTime) + { + if (startTime.Kind == DateTimeKind.Unspecified) + { + throw new ArgumentException( + "StartTime must have a specified Kind (Utc or Local). DateTimeKind.Unspecified is not allowed.", + nameof(startTime)); + } + } + + /// + /// Validates the duration parameter. + /// + private static void ValidateDuration(TimeSpan duration) + { + if (duration <= TimeSpan.Zero) + { + throw new ArgumentException("Duration must be positive.", nameof(duration)); + } + } + + /// + /// Validates the RRule parameter. + /// + private static void ValidateRRule(string rrule) + { + ArgumentNullException.ThrowIfNull(rrule, nameof(rrule)); + + if (rrule.Length < ValidationConstants.MinRRuleLength) + { + throw new ArgumentException( + $"RRule must be at least {ValidationConstants.MinRRuleLength} character(s).", + nameof(rrule)); + } + + if (rrule.Length > ValidationConstants.MaxRRuleLength) + { + throw new ArgumentException( + $"RRule must not exceed {ValidationConstants.MaxRRuleLength} characters. Actual length: {rrule.Length}.", + nameof(rrule)); + } + + // Validate that RRule can be parsed + RecurrencePattern pattern; + try + { + pattern = new RecurrencePattern(rrule); + } + catch + { + throw new ArgumentException("RRule is not a valid RFC 5545 recurrence rule.", nameof(rrule)); + } + + // Validate that COUNT is not used + if (pattern.Count.HasValue) + { + throw new ArgumentException("RRule COUNT is not supported. Use UNTIL instead.", nameof(rrule)); + } + + // Validate that UNTIL is present + if (pattern.Until is null) + { + throw new ArgumentException("RRule must contain UNTIL. COUNT is not supported.", nameof(rrule)); + } + + // Validate that UNTIL is in UTC (ends with Z) + var untilMatch = UntilRegex().Match(rrule); + if (untilMatch.Success) + { + var untilValue = untilMatch.Groups["until"].Value; + if (!untilValue.EndsWith('Z')) + { + throw new ArgumentException( + $"RRule UNTIL must be in UTC (must end with 'Z'). Found: {untilValue}", + nameof(rrule)); + } + } + } + + /// + /// Validates the time zone parameter. + /// + private static void ValidateTimeZone(string timeZone) + { + ArgumentNullException.ThrowIfNull(timeZone, nameof(timeZone)); + + if (timeZone.Length < ValidationConstants.MinTimeZoneLength) + { + throw new ArgumentException( + $"TimeZone must be at least {ValidationConstants.MinTimeZoneLength} character(s).", + nameof(timeZone)); + } + + if (timeZone.Length > ValidationConstants.MaxTimeZoneLength) + { + throw new ArgumentException( + $"TimeZone must not exceed {ValidationConstants.MaxTimeZoneLength} characters. Actual length: {timeZone.Length}.", + nameof(timeZone)); + } + + if (DateTimeZoneProviders.Tzdb.GetZoneOrNull(timeZone) is null) + { + throw new ArgumentException( + $"TimeZone '{timeZone}' is not a valid IANA time zone identifier.", + nameof(timeZone)); + } + } + + /// + /// Validates the extensions dictionary. + /// + private static void ValidateExtensions(Dictionary? extensions) + { + if (extensions is null) + { + return; + } + + foreach (var (key, value) in extensions) + { + if (key is null) + { + throw new ArgumentException("Extension keys cannot be null.", nameof(extensions)); + } + + if (key.Length < ValidationConstants.MinExtensionKeyLength) + { + throw new ArgumentException( + $"Extension keys must be at least {ValidationConstants.MinExtensionKeyLength} character(s). Found empty key.", + nameof(extensions)); + } + + if (key.Length > ValidationConstants.MaxExtensionKeyLength) + { + throw new ArgumentException( + $"Extension keys must not exceed {ValidationConstants.MaxExtensionKeyLength} characters. Key '{key}' has length {key.Length}.", + nameof(extensions)); + } + + if (value is null) + { + throw new ArgumentException($"Extension values cannot be null. Key: '{key}'.", nameof(extensions)); + } + + if (value.Length > ValidationConstants.MaxExtensionValueLength) + { + throw new ArgumentException( + $"Extension values must not exceed {ValidationConstants.MaxExtensionValueLength} characters. Key '{key}' has value length {value.Length}.", + nameof(extensions)); + } + } + } + + [GeneratedRegex(@"(?:^|;)UNTIL\s*=\s*(?[^;]+)", RegexOptions.IgnoreCase)] + private static partial Regex UntilRegex(); } diff --git a/src/RecurringThings/Validation/Validators/OccurrenceCreateValidator.cs b/src/RecurringThings/Validation/Validators/OccurrenceCreateValidator.cs deleted file mode 100644 index 763200a..0000000 --- a/src/RecurringThings/Validation/Validators/OccurrenceCreateValidator.cs +++ /dev/null @@ -1,54 +0,0 @@ -namespace RecurringThings.Validation.Validators; - -using FluentValidation; -using RecurringThings.Models; - -/// -/// FluentValidation validator for requests. -/// -internal sealed class OccurrenceCreateValidator : AbstractValidator -{ - /// - /// Initializes a new instance of the class. - /// - public OccurrenceCreateValidator() - { - RuleFor(x => x.Organization) - .NotNull() - .WithMessage("Organization cannot be null.") - .MaximumLength(ValidationConstants.MaxOrganizationLength) - .WithMessage($"Organization must not exceed {ValidationConstants.MaxOrganizationLength} characters. Actual length: {{TotalLength}}."); - - RuleFor(x => x.ResourcePath) - .NotNull() - .WithMessage("ResourcePath cannot be null.") - .MaximumLength(ValidationConstants.MaxResourcePathLength) - .WithMessage($"ResourcePath must not exceed {ValidationConstants.MaxResourcePathLength} characters. Actual length: {{TotalLength}}."); - - RuleFor(x => x.Type) - .NotNull() - .WithMessage("Type cannot be null.") - .MinimumLength(ValidationConstants.MinTypeLength) - .WithMessage($"Type must be at least {ValidationConstants.MinTypeLength} character(s).") - .MaximumLength(ValidationConstants.MaxTypeLength) - .WithMessage($"Type must not exceed {ValidationConstants.MaxTypeLength} characters. Actual length: {{TotalLength}}."); - - RuleFor(x => x.TimeZone) - .NotNull() - .WithMessage("TimeZone cannot be null.") - .MinimumLength(ValidationConstants.MinTimeZoneLength) - .WithMessage($"TimeZone must be at least {ValidationConstants.MinTimeZoneLength} character(s).") - .MaximumLength(ValidationConstants.MaxTimeZoneLength) - .WithMessage($"TimeZone must not exceed {ValidationConstants.MaxTimeZoneLength} characters. Actual length: {{TotalLength}}.") - .MustBeValidIanaTimeZone(); - - RuleFor(x => x.StartTime) - .MustNotBeUnspecified(); - - RuleFor(x => x.Duration) - .MustBePositive(); - - RuleFor(x => x.Extensions) - .ValidExtensions(); - } -} diff --git a/src/RecurringThings/Validation/Validators/RecurrenceCreateValidator.cs b/src/RecurringThings/Validation/Validators/RecurrenceCreateValidator.cs deleted file mode 100644 index eceb1bc..0000000 --- a/src/RecurringThings/Validation/Validators/RecurrenceCreateValidator.cs +++ /dev/null @@ -1,177 +0,0 @@ -namespace RecurringThings.Validation.Validators; - -using System.Text.RegularExpressions; -using FluentValidation; -using Ical.Net.DataTypes; -using RecurringThings.Models; - -/// -/// FluentValidation validator for requests. -/// -internal sealed partial class RecurrenceCreateValidator : AbstractValidator -{ - /// - /// Initializes a new instance of the class. - /// - public RecurrenceCreateValidator() - { - RuleFor(x => x.Organization) - .NotNull() - .WithMessage("Organization cannot be null.") - .MaximumLength(ValidationConstants.MaxOrganizationLength) - .WithMessage($"Organization must not exceed {ValidationConstants.MaxOrganizationLength} characters. Actual length: {{TotalLength}}."); - - RuleFor(x => x.ResourcePath) - .NotNull() - .WithMessage("ResourcePath cannot be null.") - .MaximumLength(ValidationConstants.MaxResourcePathLength) - .WithMessage($"ResourcePath must not exceed {ValidationConstants.MaxResourcePathLength} characters. Actual length: {{TotalLength}}."); - - RuleFor(x => x.Type) - .NotNull() - .WithMessage("Type cannot be null.") - .MinimumLength(ValidationConstants.MinTypeLength) - .WithMessage($"Type must be at least {ValidationConstants.MinTypeLength} character(s).") - .MaximumLength(ValidationConstants.MaxTypeLength) - .WithMessage($"Type must not exceed {ValidationConstants.MaxTypeLength} characters. Actual length: {{TotalLength}}."); - - RuleFor(x => x.TimeZone) - .NotNull() - .WithMessage("TimeZone cannot be null.") - .MinimumLength(ValidationConstants.MinTimeZoneLength) - .WithMessage($"TimeZone must be at least {ValidationConstants.MinTimeZoneLength} character(s).") - .MaximumLength(ValidationConstants.MaxTimeZoneLength) - .WithMessage($"TimeZone must not exceed {ValidationConstants.MaxTimeZoneLength} characters. Actual length: {{TotalLength}}.") - .MustBeValidIanaTimeZone(); - - RuleFor(x => x.RRule) - .NotNull() - .WithMessage("RRule cannot be null.") - .MinimumLength(ValidationConstants.MinRRuleLength) - .WithMessage($"RRule must be at least {ValidationConstants.MinRRuleLength} character(s).") - .MaximumLength(ValidationConstants.MaxRRuleLength) - .WithMessage($"RRule must not exceed {ValidationConstants.MaxRRuleLength} characters. Actual length: {{TotalLength}}.") - .Must(BeValidRRule) - .WithMessage("RRule is not a valid RFC 5545 recurrence rule.") - .Must(MustNotHaveCount) - .WithMessage("RRule COUNT is not supported. Use UNTIL instead.") - .Must(MustHaveUntil) - .WithMessage("RRule must contain UNTIL. COUNT is not supported.") - .Must(HasUtcUntil) - .WithMessage(GetUntilNotUtcMessage); - - RuleFor(x => x.StartTime) - .MustNotBeUnspecified(); - - RuleFor(x => x.Duration) - .MustBePositive(); - - RuleFor(x => x.Extensions) - .ValidExtensions(); - } - - /// - /// Validates that the RRule can be parsed by Ical.Net. - /// - private static bool BeValidRRule(string rrule) - { - if (string.IsNullOrEmpty(rrule)) - { - return true; // Will be caught by NotNull/MinimumLength rules - } - - try - { - _ = new RecurrencePattern(rrule); - return true; - } - catch - { - return false; - } - } - - /// - /// Validates that the RRule does not use COUNT. - /// - private static bool MustNotHaveCount(string rrule) - { - if (string.IsNullOrEmpty(rrule)) - { - return true; // Will be caught by NotNull/MinimumLength rules - } - - try - { - var pattern = new RecurrencePattern(rrule); - - // COUNT must not be specified (null when not set in Ical.Net) - return !pattern.Count.HasValue; - } - catch - { - return true; // Will be caught by BeValidRRule - } - } - - /// - /// Validates that the RRule contains UNTIL. - /// - private static bool MustHaveUntil(string rrule) - { - if (string.IsNullOrEmpty(rrule)) - { - return true; // Will be caught by NotNull/MinimumLength rules - } - - try - { - var pattern = new RecurrencePattern(rrule); - - // UNTIL must be specified (null when not set) - return pattern.Until is not null; - } - catch - { - return true; // Will be caught by BeValidRRule - } - } - - /// - /// Validates that the UNTIL value in the RRule is in UTC (ends with Z). - /// - private static bool HasUtcUntil(string rrule) - { - if (string.IsNullOrEmpty(rrule)) - { - return true; // Will be caught by NotNull/MinimumLength rules - } - - var untilMatch = UntilRegex().Match(rrule); - if (!untilMatch.Success) - { - return true; // Will be caught by HaveUntilNotCount - } - - var untilValue = untilMatch.Groups["until"].Value; - return untilValue.EndsWith('Z'); - } - - /// - /// Gets the error message when UNTIL is not in UTC format. - /// - private static string GetUntilNotUtcMessage(RecurrenceCreate request) - { - var untilMatch = UntilRegex().Match(request.RRule); - if (!untilMatch.Success) - { - return "RRule must contain UNTIL."; - } - - var untilValue = untilMatch.Groups["until"].Value; - return $"RRule UNTIL must be in UTC (must end with 'Z'). Found: {untilValue}"; - } - - [GeneratedRegex(@"(?:^|;)UNTIL\s*=\s*(?[^;]+)", RegexOptions.IgnoreCase)] - private static partial Regex UntilRegex(); -} diff --git a/tests/RecurringThings.Tests/Engine/RecurrenceEngineCrudTests.cs b/tests/RecurringThings.Tests/Engine/RecurrenceEngineCrudTests.cs index 4a27d83..c36e4bb 100644 --- a/tests/RecurringThings.Tests/Engine/RecurrenceEngineCrudTests.cs +++ b/tests/RecurringThings.Tests/Engine/RecurrenceEngineCrudTests.cs @@ -5,13 +5,11 @@ namespace RecurringThings.Tests.Engine; using System.Threading; using System.Threading.Tasks; using FluentAssertions; -using FluentValidation; using Moq; using RecurringThings.Domain; using RecurringThings.Engine; using RecurringThings.Models; using RecurringThings.Repository; -using RecurringThings.Validation.Validators; using Transactional.Abstractions; using Xunit; @@ -24,14 +22,13 @@ public class RecurrenceEngineCrudTests private readonly Mock _occurrenceRepo; private readonly Mock _exceptionRepo; private readonly Mock _overrideRepo; - private readonly IValidator _recurrenceCreateValidator; - private readonly IValidator _occurrenceCreateValidator; private readonly RecurrenceEngine _engine; private const string TestOrganization = "test-org"; private const string TestResourcePath = "test/path"; private const string TestType = "appointment"; private const string TestTimeZone = "Etc/UTC"; + private const string TestRRule = "FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR;UNTIL=20251231T235959Z"; public RecurrenceEngineCrudTests() { @@ -39,58 +36,58 @@ public RecurrenceEngineCrudTests() _occurrenceRepo = new Mock(); _exceptionRepo = new Mock(); _overrideRepo = new Mock(); - _recurrenceCreateValidator = new RecurrenceCreateValidator(); - _occurrenceCreateValidator = new OccurrenceCreateValidator(); _engine = new RecurrenceEngine( _recurrenceRepo.Object, _occurrenceRepo.Object, _exceptionRepo.Object, - _overrideRepo.Object, - _recurrenceCreateValidator, - _occurrenceCreateValidator); + _overrideRepo.Object); } #region CreateRecurrenceAsync Tests [Fact] - public async Task CreateRecurrenceAsync_WithValidRequest_ReturnsRecurrenceWithGeneratedId() + public async Task CreateRecurrenceAsync_WithValidParameters_ReturnsRecurrenceWithGeneratedId() { // Arrange - var request = CreateValidRecurrenceRequest(); - Recurrence? capturedRecurrence = null; + var startTime = new DateTime(2024, 1, 1, 9, 0, 0, DateTimeKind.Utc); + var duration = TimeSpan.FromHours(1); + var extensions = new Dictionary { ["key"] = "value" }; _recurrenceRepo .Setup(r => r.CreateAsync( It.IsAny(), It.IsAny(), It.IsAny())) - .Callback((r, _, _) => capturedRecurrence = r) .ReturnsAsync((Recurrence r, ITransactionContext? _, CancellationToken _) => r); // Act - var result = await _engine.CreateRecurrenceAsync(request); + var result = await _engine.CreateRecurrenceAsync( + TestOrganization, TestResourcePath, TestType, + startTime, duration, TestRRule, TestTimeZone, extensions); // Assert result.Should().NotBeNull(); result.Id.Should().NotBe(Guid.Empty); - result.Organization.Should().Be(request.Organization); - result.ResourcePath.Should().Be(request.ResourcePath); - result.Type.Should().Be(request.Type); - result.StartTime.Should().Be(request.StartTime); - result.Duration.Should().Be(request.Duration); + result.Organization.Should().Be(TestOrganization); + result.ResourcePath.Should().Be(TestResourcePath); + result.Type.Should().Be(TestType); + result.StartTime.Should().Be(startTime); + result.Duration.Should().Be(duration); // RecurrenceEndTime is extracted from RRule UNTIL clause result.RecurrenceEndTime.Should().Be(new DateTime(2025, 12, 31, 23, 59, 59, DateTimeKind.Utc)); - result.RRule.Should().Be(request.RRule); - result.TimeZone.Should().Be(request.TimeZone); - result.Extensions.Should().BeEquivalentTo(request.Extensions); + result.RRule.Should().Be(TestRRule); + result.TimeZone.Should().Be(TestTimeZone); + result.Extensions.Should().BeEquivalentTo(extensions); } [Fact] - public async Task CreateRecurrenceAsync_WithNullRequest_ThrowsArgumentNullException() + public async Task CreateRecurrenceAsync_WithNullOrganization_ThrowsArgumentNullException() { // Act - var act = () => _engine.CreateRecurrenceAsync(null!); + var act = async () => await _engine.CreateRecurrenceAsync( + null!, TestResourcePath, TestType, + DateTime.UtcNow, TimeSpan.FromHours(1), TestRRule, TestTimeZone); // Assert await act.Should().ThrowAsync(); @@ -99,25 +96,23 @@ public async Task CreateRecurrenceAsync_WithNullRequest_ThrowsArgumentNullExcept [Fact] public async Task CreateRecurrenceAsync_WithEmptyType_ThrowsArgumentException() { - // Arrange - var request = CreateValidRecurrenceRequest(type: ""); - // Act - var act = () => _engine.CreateRecurrenceAsync(request); + var act = async () => await _engine.CreateRecurrenceAsync( + TestOrganization, TestResourcePath, "", + DateTime.UtcNow, TimeSpan.FromHours(1), TestRRule, TestTimeZone); // Assert await act.Should().ThrowAsync() - .WithParameterName("Type"); + .WithParameterName("type"); } [Fact] public async Task CreateRecurrenceAsync_WithRRuleContainingCount_ThrowsArgumentException() { - // Arrange - var request = CreateValidRecurrenceRequest(rrule: "FREQ=DAILY;COUNT=10"); - // Act - var act = () => _engine.CreateRecurrenceAsync(request); + var act = async () => await _engine.CreateRecurrenceAsync( + TestOrganization, TestResourcePath, TestType, + DateTime.UtcNow, TimeSpan.FromHours(1), "FREQ=DAILY;COUNT=10", TestTimeZone); // Assert await act.Should().ThrowAsync() @@ -127,11 +122,10 @@ await act.Should().ThrowAsync() [Fact] public async Task CreateRecurrenceAsync_WithRRuleMissingUntil_ThrowsArgumentException() { - // Arrange - var request = CreateValidRecurrenceRequest(rrule: "FREQ=DAILY;BYDAY=MO,TU,WE"); - // Act - var act = () => _engine.CreateRecurrenceAsync(request); + var act = async () => await _engine.CreateRecurrenceAsync( + TestOrganization, TestResourcePath, TestType, + DateTime.UtcNow, TimeSpan.FromHours(1), "FREQ=DAILY;BYDAY=MO,TU,WE", TestTimeZone); // Assert await act.Should().ThrowAsync() @@ -141,11 +135,10 @@ await act.Should().ThrowAsync() [Fact] public async Task CreateRecurrenceAsync_WithNonUtcUntil_ThrowsArgumentException() { - // Arrange - Missing Z suffix means non-UTC - var request = CreateValidRecurrenceRequest(rrule: "FREQ=DAILY;UNTIL=20251231T235959"); - - // Act - var act = () => _engine.CreateRecurrenceAsync(request); + // Act - Missing Z suffix means non-UTC + var act = async () => await _engine.CreateRecurrenceAsync( + TestOrganization, TestResourcePath, TestType, + DateTime.UtcNow, TimeSpan.FromHours(1), "FREQ=DAILY;UNTIL=20251231T235959", TestTimeZone); // Assert await act.Should().ThrowAsync() @@ -155,22 +148,20 @@ await act.Should().ThrowAsync() [Fact] public async Task CreateRecurrenceAsync_WithInvalidTimeZone_ThrowsArgumentException() { - // Arrange - var request = CreateValidRecurrenceRequest(timeZone: "Invalid/TimeZone"); - // Act - var act = () => _engine.CreateRecurrenceAsync(request); + var act = async () => await _engine.CreateRecurrenceAsync( + TestOrganization, TestResourcePath, TestType, + DateTime.UtcNow, TimeSpan.FromHours(1), TestRRule, "Invalid/TimeZone"); // Assert await act.Should().ThrowAsync() - .WithParameterName("TimeZone"); + .WithParameterName("timeZone"); } [Fact] public async Task CreateRecurrenceAsync_PassesTransactionContextToRepository() { // Arrange - var request = CreateValidRecurrenceRequest(); var mockContext = new Mock(); _recurrenceRepo @@ -181,7 +172,10 @@ public async Task CreateRecurrenceAsync_PassesTransactionContextToRepository() .ReturnsAsync((Recurrence r, ITransactionContext? _, CancellationToken _) => r); // Act - await _engine.CreateRecurrenceAsync(request, mockContext.Object); + await _engine.CreateRecurrenceAsync( + TestOrganization, TestResourcePath, TestType, + DateTime.UtcNow, TimeSpan.FromHours(1), TestRRule, TestTimeZone, + transactionContext: mockContext.Object); // Assert _recurrenceRepo.Verify(r => r.CreateAsync( @@ -195,11 +189,12 @@ public async Task CreateRecurrenceAsync_PassesTransactionContextToRepository() #region CreateOccurrenceAsync Tests [Fact] - public async Task CreateOccurrenceAsync_WithValidRequest_ReturnsOccurrenceWithComputedEndTime() + public async Task CreateOccurrenceAsync_WithValidParameters_ReturnsOccurrenceWithComputedEndTime() { // Arrange - var request = CreateValidOccurrenceRequest(); - var expectedEndTime = request.StartTime + request.Duration; + var startTime = new DateTime(2024, 1, 15, 10, 0, 0, DateTimeKind.Utc); + var duration = TimeSpan.FromMinutes(30); + var expectedEndTime = startTime + duration; _occurrenceRepo .Setup(r => r.CreateAsync( @@ -209,22 +204,26 @@ public async Task CreateOccurrenceAsync_WithValidRequest_ReturnsOccurrenceWithCo .ReturnsAsync((Occurrence o, ITransactionContext? _, CancellationToken _) => o); // Act - var result = await _engine.CreateOccurrenceAsync(request); + var result = await _engine.CreateOccurrenceAsync( + TestOrganization, TestResourcePath, TestType, + startTime, duration, TestTimeZone); // Assert result.Should().NotBeNull(); result.Id.Should().NotBe(Guid.Empty); - result.Organization.Should().Be(request.Organization); - result.StartTime.Should().Be(request.StartTime); - result.Duration.Should().Be(request.Duration); + result.Organization.Should().Be(TestOrganization); + result.StartTime.Should().Be(startTime); + result.Duration.Should().Be(duration); result.EndTime.Should().Be(expectedEndTime); } [Fact] - public async Task CreateOccurrenceAsync_WithNullRequest_ThrowsArgumentNullException() + public async Task CreateOccurrenceAsync_WithNullOrganization_ThrowsArgumentNullException() { // Act - var act = () => _engine.CreateOccurrenceAsync(null!); + var act = async () => await _engine.CreateOccurrenceAsync( + null!, TestResourcePath, TestType, + DateTime.UtcNow, TimeSpan.FromMinutes(30), TestTimeZone); // Assert await act.Should().ThrowAsync(); @@ -233,22 +232,20 @@ public async Task CreateOccurrenceAsync_WithNullRequest_ThrowsArgumentNullExcept [Fact] public async Task CreateOccurrenceAsync_WithZeroDuration_ThrowsArgumentException() { - // Arrange - var request = CreateValidOccurrenceRequest(duration: TimeSpan.Zero); - // Act - var act = () => _engine.CreateOccurrenceAsync(request); + var act = async () => await _engine.CreateOccurrenceAsync( + TestOrganization, TestResourcePath, TestType, + DateTime.UtcNow, TimeSpan.Zero, TestTimeZone); // Assert await act.Should().ThrowAsync() - .WithParameterName("Duration"); + .WithParameterName("duration"); } [Fact] public async Task CreateOccurrenceAsync_PassesTransactionContextToRepository() { // Arrange - var request = CreateValidOccurrenceRequest(); var mockContext = new Mock(); _occurrenceRepo @@ -259,7 +256,10 @@ public async Task CreateOccurrenceAsync_PassesTransactionContextToRepository() .ReturnsAsync((Occurrence o, ITransactionContext? _, CancellationToken _) => o); // Act - await _engine.CreateOccurrenceAsync(request, mockContext.Object); + await _engine.CreateOccurrenceAsync( + TestOrganization, TestResourcePath, TestType, + DateTime.UtcNow, TimeSpan.FromMinutes(30), TestTimeZone, + transactionContext: mockContext.Object); // Assert _occurrenceRepo.Verify(r => r.CreateAsync( @@ -270,164 +270,30 @@ public async Task CreateOccurrenceAsync_PassesTransactionContextToRepository() #endregion - #region UpdateAsync Tests - Recurrence - - [Fact] - public async Task UpdateAsync_RecurrenceDuration_UpdatesSuccessfully() - { - // Arrange - var recurrenceId = Guid.NewGuid(); - var existingRecurrence = CreateRecurrence(recurrenceId); - var newDuration = TimeSpan.FromHours(2); - - _recurrenceRepo - .Setup(r => r.GetByIdAsync(recurrenceId, TestOrganization, TestResourcePath, null, default)) - .ReturnsAsync(existingRecurrence); - - _recurrenceRepo - .Setup(r => r.UpdateAsync(It.IsAny(), null, default)) - .ReturnsAsync((Recurrence r, ITransactionContext? _, CancellationToken _) => r); - - var entry = new CalendarEntry - { - Organization = TestOrganization, - ResourcePath = TestResourcePath, - Type = TestType, - StartTime = existingRecurrence.StartTime, - Duration = newDuration, - TimeZone = TestTimeZone, - RecurrenceId = recurrenceId - }; - - // Act - var result = await _engine.UpdateAsync(entry); - - // Assert - result.Duration.Should().Be(newDuration); - _recurrenceRepo.Verify(r => r.UpdateAsync( - It.Is(rec => rec.Duration == newDuration), - null, default), Times.Once); - } - - [Fact] - public async Task UpdateAsync_RecurrenceExtensions_UpdatesSuccessfully() - { - // Arrange - var recurrenceId = Guid.NewGuid(); - var existingRecurrence = CreateRecurrence(recurrenceId); - var newExtensions = new Dictionary { ["newKey"] = "newValue" }; - - _recurrenceRepo - .Setup(r => r.GetByIdAsync(recurrenceId, TestOrganization, TestResourcePath, null, default)) - .ReturnsAsync(existingRecurrence); - - _recurrenceRepo - .Setup(r => r.UpdateAsync(It.IsAny(), null, default)) - .ReturnsAsync((Recurrence r, ITransactionContext? _, CancellationToken _) => r); - - var entry = new CalendarEntry - { - Organization = TestOrganization, - ResourcePath = TestResourcePath, - Type = TestType, - StartTime = existingRecurrence.StartTime, - Duration = existingRecurrence.Duration, - TimeZone = TestTimeZone, - Extensions = newExtensions, - RecurrenceId = recurrenceId - }; - - // Act - var result = await _engine.UpdateAsync(entry); - - // Assert - result.Extensions.Should().BeEquivalentTo(newExtensions); - } - - [Fact] - public async Task UpdateAsync_RecurrenceWithImmutableTypeChange_ThrowsInvalidOperationException() - { - // Arrange - var recurrenceId = Guid.NewGuid(); - var existingRecurrence = CreateRecurrence(recurrenceId); - - _recurrenceRepo - .Setup(r => r.GetByIdAsync(recurrenceId, TestOrganization, TestResourcePath, null, default)) - .ReturnsAsync(existingRecurrence); - - var entry = new CalendarEntry - { - Organization = TestOrganization, - ResourcePath = TestResourcePath, - Type = "different-type", // Changed! - StartTime = existingRecurrence.StartTime, - Duration = existingRecurrence.Duration, - TimeZone = TestTimeZone, - RecurrenceId = recurrenceId - }; - - // Act - var act = () => _engine.UpdateAsync(entry); - - // Assert - await act.Should().ThrowAsync() - .WithMessage("*Type*immutable*"); - } + #region UpdateOccurrenceAsync Tests - Recurrence (Blocked) [Fact] - public async Task UpdateAsync_RecurrenceWithImmutableStartTimeChange_ThrowsInvalidOperationException() + public async Task UpdateOccurrenceAsync_Recurrence_ThrowsInvalidOperationException() { // Arrange var recurrenceId = Guid.NewGuid(); - var existingRecurrence = CreateRecurrence(recurrenceId); - - _recurrenceRepo - .Setup(r => r.GetByIdAsync(recurrenceId, TestOrganization, TestResourcePath, null, default)) - .ReturnsAsync(existingRecurrence); var entry = new CalendarEntry { Organization = TestOrganization, ResourcePath = TestResourcePath, Type = TestType, - StartTime = existingRecurrence.StartTime.AddHours(1), // Changed! - Duration = existingRecurrence.Duration, TimeZone = TestTimeZone, - RecurrenceId = recurrenceId + RecurrenceId = recurrenceId, + EntryType = CalendarEntryType.Recurrence }; // Act - var act = () => _engine.UpdateAsync(entry); + var act = async () => await _engine.UpdateOccurrenceAsync(entry); // Assert await act.Should().ThrowAsync() - .WithMessage("*StartTime*immutable*"); - } - - [Fact] - public async Task UpdateAsync_RecurrenceNotFound_ThrowsKeyNotFoundException() - { - // Arrange - var recurrenceId = Guid.NewGuid(); - - _recurrenceRepo - .Setup(r => r.GetByIdAsync(recurrenceId, TestOrganization, TestResourcePath, null, default)) - .ReturnsAsync((Recurrence?)null); - - var entry = new CalendarEntry - { - Organization = TestOrganization, - ResourcePath = TestResourcePath, - Type = TestType, - TimeZone = TestTimeZone, - RecurrenceId = recurrenceId - }; - - // Act - var act = () => _engine.UpdateAsync(entry); - - // Assert - await act.Should().ThrowAsync(); + .WithMessage("*Cannot update a recurrence pattern*"); } #endregion @@ -463,7 +329,7 @@ public async Task UpdateAsync_StandaloneOccurrence_UpdatesStartTimeDurationExten }; // Act - var result = await _engine.UpdateAsync(entry); + var result = await _engine.UpdateOccurrenceAsync(entry); // Assert result.StartTime.Should().Be(newStartTime); @@ -494,7 +360,7 @@ public async Task UpdateAsync_StandaloneOccurrenceWithImmutableTypeChange_Throws }; // Act - var act = () => _engine.UpdateAsync(entry); + var act = () => _engine.UpdateOccurrenceAsync(entry); // Assert await act.Should().ThrowAsync() @@ -533,20 +399,17 @@ public async Task UpdateAsync_VirtualizedOccurrenceWithoutOverride_CreatesOverri Duration = newDuration, TimeZone = TestTimeZone, RecurrenceId = recurrenceId, - RecurrenceOccurrenceDetails = new RecurrenceOccurrenceDetails - { - RecurrenceId = recurrenceId, - Original = new OccurrenceOriginal + EntryType = CalendarEntryType.Virtualized, + Original = new OriginalDetails { StartTime = originalStartTime, Duration = recurrence.Duration, Extensions = recurrence.Extensions - } } }; // Act - var result = await _engine.UpdateAsync(entry); + var result = await _engine.UpdateOccurrenceAsync(entry); // Assert capturedOverride.Should().NotBeNull(); @@ -589,20 +452,17 @@ public async Task UpdateAsync_VirtualizedOccurrenceWithOverride_UpdatesExistingO TimeZone = TestTimeZone, RecurrenceId = recurrenceId, OverrideId = overrideId, - RecurrenceOccurrenceDetails = new RecurrenceOccurrenceDetails - { - RecurrenceId = recurrenceId, - Original = new OccurrenceOriginal + EntryType = CalendarEntryType.Virtualized, + Original = new OriginalDetails { StartTime = existingOverride.OriginalTimeUtc, Duration = existingOverride.OriginalDuration, Extensions = existingOverride.OriginalExtensions - } } }; // Act - var result = await _engine.UpdateAsync(entry); + var result = await _engine.UpdateOccurrenceAsync(entry); // Assert _overrideRepo.Verify(r => r.UpdateAsync( @@ -619,7 +479,7 @@ public async Task UpdateAsync_VirtualizedOccurrenceWithOverride_UpdatesExistingO public async Task UpdateAsync_WithNullEntry_ThrowsArgumentNullException() { // Act - var act = () => _engine.UpdateAsync(null!); + var act = () => _engine.UpdateOccurrenceAsync(null!); // Assert await act.Should().ThrowAsync(); @@ -639,7 +499,7 @@ public async Task UpdateAsync_WithIndeterminateEntryType_ThrowsInvalidOperationE }; // Act - var act = () => _engine.UpdateAsync(entry); + var act = () => _engine.UpdateOccurrenceAsync(entry); // Assert await act.Should().ThrowAsync() @@ -648,10 +508,10 @@ await act.Should().ThrowAsync() #endregion - #region DeleteAsync Tests + #region DeleteOccurrenceAsync Tests [Fact] - public async Task DeleteAsync_Recurrence_CallsRepositoryDelete() + public async Task DeleteOccurrenceAsync_Recurrence_ThrowsInvalidOperationException() { // Arrange var recurrenceId = Guid.NewGuid(); @@ -661,19 +521,20 @@ public async Task DeleteAsync_Recurrence_CallsRepositoryDelete() ResourcePath = TestResourcePath, Type = TestType, TimeZone = TestTimeZone, - RecurrenceId = recurrenceId + RecurrenceId = recurrenceId, + EntryType = CalendarEntryType.Recurrence }; // Act - await _engine.DeleteAsync(entry); + var act = async () => await _engine.DeleteOccurrenceAsync(entry); // Assert - _recurrenceRepo.Verify(r => r.DeleteAsync( - recurrenceId, TestOrganization, TestResourcePath, null, default), Times.Once); + await act.Should().ThrowAsync() + .WithMessage("*Use DeleteRecurrenceAsync*"); } [Fact] - public async Task DeleteAsync_StandaloneOccurrence_CallsRepositoryDelete() + public async Task DeleteOccurrenceAsync_StandaloneOccurrence_CallsRepositoryDelete() { // Arrange var occurrenceId = Guid.NewGuid(); @@ -687,7 +548,7 @@ public async Task DeleteAsync_StandaloneOccurrence_CallsRepositoryDelete() }; // Act - await _engine.DeleteAsync(entry); + await _engine.DeleteOccurrenceAsync(entry); // Assert _occurrenceRepo.Verify(r => r.DeleteAsync( @@ -715,20 +576,17 @@ public async Task DeleteAsync_VirtualizedOccurrenceWithoutOverride_CreatesExcept StartTime = originalTime, TimeZone = TestTimeZone, RecurrenceId = recurrenceId, - RecurrenceOccurrenceDetails = new RecurrenceOccurrenceDetails - { - RecurrenceId = recurrenceId, - Original = new OccurrenceOriginal + EntryType = CalendarEntryType.Virtualized, + Original = new OriginalDetails { StartTime = originalTime, Duration = TimeSpan.FromHours(1), Extensions = null - } } }; // Act - await _engine.DeleteAsync(entry); + await _engine.DeleteOccurrenceAsync(entry); // Assert capturedExc.Should().NotBeNull(); @@ -759,20 +617,17 @@ public async Task DeleteAsync_VirtualizedOccurrenceWithOverride_DeletesOverrideA TimeZone = TestTimeZone, RecurrenceId = recurrenceId, OverrideId = overrideId, - RecurrenceOccurrenceDetails = new RecurrenceOccurrenceDetails - { - RecurrenceId = recurrenceId, - Original = new OccurrenceOriginal + EntryType = CalendarEntryType.Virtualized, + Original = new OriginalDetails { StartTime = originalTime, // Original time Duration = TimeSpan.FromHours(1), Extensions = null - } } }; // Act - await _engine.DeleteAsync(entry); + await _engine.DeleteOccurrenceAsync(entry); // Assert _overrideRepo.Verify(r => r.DeleteAsync( @@ -785,7 +640,7 @@ public async Task DeleteAsync_VirtualizedOccurrenceWithOverride_DeletesOverrideA public async Task DeleteAsync_WithNullEntry_ThrowsArgumentNullException() { // Act - var act = () => _engine.DeleteAsync(null!); + var act = () => _engine.DeleteOccurrenceAsync(null!); // Assert await act.Should().ThrowAsync(); @@ -811,15 +666,12 @@ public async Task RestoreAsync_OverriddenOccurrence_DeletesOverride() TimeZone = TestTimeZone, RecurrenceId = recurrenceId, OverrideId = overrideId, - RecurrenceOccurrenceDetails = new RecurrenceOccurrenceDetails - { - RecurrenceId = recurrenceId, - Original = new OccurrenceOriginal + EntryType = CalendarEntryType.Virtualized, + Original = new OriginalDetails { StartTime = originalTime, Duration = TimeSpan.FromHours(1), Extensions = null - } } }; @@ -842,12 +694,12 @@ public async Task RestoreAsync_Recurrence_ThrowsInvalidOperationException() ResourcePath = TestResourcePath, Type = TestType, TimeZone = TestTimeZone, - RecurrenceId = recurrenceId - // No RecurrenceOccurrenceDetails = it's a recurrence pattern + RecurrenceId = recurrenceId, + EntryType = CalendarEntryType.Recurrence }; // Act - var act = () => _engine.RestoreAsync(entry); + var act = async () => await _engine.RestoreAsync(entry); // Assert await act.Should().ThrowAsync() @@ -865,11 +717,12 @@ public async Task RestoreAsync_StandaloneOccurrence_ThrowsInvalidOperationExcept ResourcePath = TestResourcePath, Type = TestType, TimeZone = TestTimeZone, - OccurrenceId = occurrenceId + OccurrenceId = occurrenceId, + EntryType = CalendarEntryType.Standalone }; // Act - var act = () => _engine.RestoreAsync(entry); + var act = async () => await _engine.RestoreAsync(entry); // Assert await act.Should().ThrowAsync() @@ -889,15 +742,12 @@ public async Task RestoreAsync_VirtualizedOccurrenceWithoutOverride_ThrowsInvali TimeZone = TestTimeZone, RecurrenceId = recurrenceId, // No OverrideId - RecurrenceOccurrenceDetails = new RecurrenceOccurrenceDetails - { - RecurrenceId = recurrenceId, - Original = new OccurrenceOriginal + EntryType = CalendarEntryType.Virtualized, + Original = new OriginalDetails { StartTime = DateTime.UtcNow, Duration = TimeSpan.FromHours(1), Extensions = null - } } }; @@ -923,38 +773,6 @@ public async Task RestoreAsync_WithNullEntry_ThrowsArgumentNullException() #region Helper Methods - private static RecurrenceCreate CreateValidRecurrenceRequest( - string? type = null, - string? rrule = null, - string? timeZone = null) - { - return new RecurrenceCreate - { - Organization = TestOrganization, - ResourcePath = TestResourcePath, - Type = type ?? TestType, - StartTime = new DateTime(2024, 1, 1, 9, 0, 0, DateTimeKind.Utc), - Duration = TimeSpan.FromHours(1), - RRule = rrule ?? "FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR;UNTIL=20251231T235959Z", - TimeZone = timeZone ?? TestTimeZone, - Extensions = new Dictionary { ["key"] = "value" } - }; - } - - private static OccurrenceCreate CreateValidOccurrenceRequest(TimeSpan? duration = null) - { - return new OccurrenceCreate - { - Organization = TestOrganization, - ResourcePath = TestResourcePath, - Type = TestType, - StartTime = new DateTime(2024, 1, 15, 10, 0, 0, DateTimeKind.Utc), - Duration = duration ?? TimeSpan.FromMinutes(30), - TimeZone = TestTimeZone, - Extensions = new Dictionary { ["meeting"] = "standup" } - }; - } - private static Recurrence CreateRecurrence(Guid id) { return new Recurrence diff --git a/tests/RecurringThings.Tests/Engine/RecurrenceEngineTests.cs b/tests/RecurringThings.Tests/Engine/RecurrenceEngineTests.cs index 4970444..5a27106 100644 --- a/tests/RecurringThings.Tests/Engine/RecurrenceEngineTests.cs +++ b/tests/RecurringThings.Tests/Engine/RecurrenceEngineTests.cs @@ -6,13 +6,11 @@ namespace RecurringThings.Tests.Engine; using System.Threading; using System.Threading.Tasks; using FluentAssertions; -using FluentValidation; using Moq; using RecurringThings.Domain; using RecurringThings.Engine; using RecurringThings.Models; using RecurringThings.Repository; -using RecurringThings.Validation.Validators; using Transactional.Abstractions; using Xunit; @@ -22,8 +20,6 @@ public class RecurrenceEngineTests private readonly Mock _occurrenceRepo; private readonly Mock _exceptionRepo; private readonly Mock _overrideRepo; - private readonly IValidator _recurrenceCreateValidator; - private readonly IValidator _occurrenceCreateValidator; private readonly RecurrenceEngine _engine; private const string TestOrganization = "test-org"; @@ -37,8 +33,6 @@ public RecurrenceEngineTests() _occurrenceRepo = new Mock(); _exceptionRepo = new Mock(); _overrideRepo = new Mock(); - _recurrenceCreateValidator = new RecurrenceCreateValidator(); - _occurrenceCreateValidator = new OccurrenceCreateValidator(); // Default empty results SetupEmptyRepositories(); @@ -47,9 +41,7 @@ public RecurrenceEngineTests() _recurrenceRepo.Object, _occurrenceRepo.Object, _exceptionRepo.Object, - _overrideRepo.Object, - _recurrenceCreateValidator, - _occurrenceCreateValidator); + _overrideRepo.Object); } #region Constructor Tests @@ -61,9 +53,7 @@ public void Constructor_WithNullRecurrenceRepository_ThrowsArgumentNullException null!, _occurrenceRepo.Object, _exceptionRepo.Object, - _overrideRepo.Object, - _recurrenceCreateValidator, - _occurrenceCreateValidator); + _overrideRepo.Object); act.Should().Throw() .WithParameterName("recurrenceRepository"); @@ -76,9 +66,7 @@ public void Constructor_WithNullOccurrenceRepository_ThrowsArgumentNullException _recurrenceRepo.Object, null!, _exceptionRepo.Object, - _overrideRepo.Object, - _recurrenceCreateValidator, - _occurrenceCreateValidator); + _overrideRepo.Object); act.Should().Throw() .WithParameterName("occurrenceRepository"); @@ -91,9 +79,7 @@ public void Constructor_WithNullExceptionRepository_ThrowsArgumentNullException( _recurrenceRepo.Object, _occurrenceRepo.Object, null!, - _overrideRepo.Object, - _recurrenceCreateValidator, - _occurrenceCreateValidator); + _overrideRepo.Object); act.Should().Throw() .WithParameterName("exceptionRepository"); @@ -106,42 +92,10 @@ public void Constructor_WithNullOverrideRepository_ThrowsArgumentNullException() _recurrenceRepo.Object, _occurrenceRepo.Object, _exceptionRepo.Object, - null!, - _recurrenceCreateValidator, - _occurrenceCreateValidator); - - act.Should().Throw() - .WithParameterName("overrideRepository"); - } - - [Fact] - public void Constructor_WithNullRecurrenceCreateValidator_ThrowsArgumentNullException() - { - var act = () => new RecurrenceEngine( - _recurrenceRepo.Object, - _occurrenceRepo.Object, - _exceptionRepo.Object, - _overrideRepo.Object, - null!, - _occurrenceCreateValidator); - - act.Should().Throw() - .WithParameterName("recurrenceCreateValidator"); - } - - [Fact] - public void Constructor_WithNullOccurrenceCreateValidator_ThrowsArgumentNullException() - { - var act = () => new RecurrenceEngine( - _recurrenceRepo.Object, - _occurrenceRepo.Object, - _exceptionRepo.Object, - _overrideRepo.Object, - _recurrenceCreateValidator, null!); act.Should().Throw() - .WithParameterName("occurrenceCreateValidator"); + .WithParameterName("overrideRepository"); } #endregion @@ -179,8 +133,8 @@ public async Task GetAsync_DailyRecurrence_ReturnsCorrectOccurrences() r.Duration.Should().Be(duration); r.Type.Should().Be(TestType); r.TimeZone.Should().Be(TestTimeZone); - r.RecurrenceOccurrenceDetails.Should().NotBeNull(); - r.RecurrenceOccurrenceDetails!.RecurrenceId.Should().Be(recurrenceId); + r.EntryType.Should().Be(CalendarEntryType.Virtualized); + r.Original.Should().NotBeNull(); }); } @@ -421,9 +375,9 @@ public async Task GetAsync_WithOverride_ReturnsOverriddenValues() overriddenEntry.StartTime.Hour.Should().Be(14); overriddenEntry.Duration.Should().Be(TimeSpan.FromHours(2)); overriddenEntry.Extensions.Should().ContainKey("modified"); - overriddenEntry.RecurrenceOccurrenceDetails!.Original.Should().NotBeNull(); - overriddenEntry.RecurrenceOccurrenceDetails.Original!.StartTime.Should().Be(new DateTime(2024, 1, 3, 9, 0, 0, DateTimeKind.Utc)); - overriddenEntry.RecurrenceOccurrenceDetails.Original!.Duration.Should().Be(duration); + overriddenEntry.Original.Should().NotBeNull(); + overriddenEntry.Original!.StartTime.Should().Be(new DateTime(2024, 1, 3, 9, 0, 0, DateTimeKind.Utc)); + overriddenEntry.Original!.Duration.Should().Be(duration); } [Fact] @@ -562,7 +516,7 @@ public async Task GetAsync_WithStandaloneOccurrences_MergesWithVirtualized() var standaloneEntry = results.Single(r => r.OccurrenceId == occurrenceId); standaloneEntry.RecurrenceId.Should().BeNull(); - standaloneEntry.RecurrenceOccurrenceDetails.Should().BeNull(); + standaloneEntry.EntryType.Should().Be(CalendarEntryType.Standalone); standaloneEntry.StartTime.Hour.Should().Be(15); } @@ -826,13 +780,13 @@ public async Task GetAsync_VirtualizedOccurrence_HasCorrectStructure() entry.OccurrenceId.Should().BeNull(); entry.OverrideId.Should().BeNull(); entry.ExceptionId.Should().BeNull(); - entry.RecurrenceDetails.Should().BeNull(); - entry.RecurrenceOccurrenceDetails.Should().NotBeNull(); - entry.RecurrenceOccurrenceDetails!.RecurrenceId.Should().Be(recurrenceId); - entry.RecurrenceOccurrenceDetails.Original.Should().NotBeNull(); - entry.RecurrenceOccurrenceDetails.Original!.StartTime.Should().Be(startTime); - entry.RecurrenceOccurrenceDetails.Original.Duration.Should().Be(duration); - entry.RecurrenceOccurrenceDetails.Original.Extensions.Should().BeEquivalentTo(extensions); + entry.EntryType.Should().Be(CalendarEntryType.Virtualized); + entry.RecurrenceDetails.Should().NotBeNull(); + entry.RecurrenceDetails!.RRule.Should().NotBeNullOrEmpty(); + entry.Original.Should().NotBeNull(); + entry.Original!.StartTime.Should().Be(startTime); + entry.Original.Duration.Should().Be(duration); + entry.Original.Extensions.Should().BeEquivalentTo(extensions); } #endregion @@ -968,7 +922,7 @@ private async Task> GetResultsAsync( string[]? types = null) { var results = new List(); - await foreach (var entry in _engine.GetAsync( + await foreach (var entry in _engine.GetOccurrencesAsync( TestOrganization, TestResourcePath, start, diff --git a/tests/RecurringThings.Tests/Validation/Validators/OccurrenceCreateValidatorTests.cs b/tests/RecurringThings.Tests/Validation/Validators/OccurrenceCreateValidatorTests.cs deleted file mode 100644 index b7a6302..0000000 --- a/tests/RecurringThings.Tests/Validation/Validators/OccurrenceCreateValidatorTests.cs +++ /dev/null @@ -1,192 +0,0 @@ -namespace RecurringThings.Tests.Validation.Validators; - -using System; -using System.Collections.Generic; -using FluentAssertions; -using FluentValidation.TestHelper; -using RecurringThings.Models; -using RecurringThings.Validation.Validators; -using Xunit; - -/// -/// Tests for the class. -/// -public class OccurrenceCreateValidatorTests -{ - private readonly OccurrenceCreateValidator _validator = new(); - - [Fact] - public void Validate_WhenValid_ShouldNotHaveErrors() - { - // Arrange - var request = new OccurrenceCreate - { - Organization = "org", - ResourcePath = "path", - Type = "meeting", - StartTime = DateTime.UtcNow, - Duration = TimeSpan.FromHours(1), - TimeZone = "Europe/London" - }; - - // Act - var result = _validator.TestValidate(request); - - // Assert - result.ShouldNotHaveAnyValidationErrors(); - } - - [Fact] - public void Validate_WhenOrganizationNull_ShouldHaveError() - { - // Arrange - var request = new OccurrenceCreate - { - Organization = null!, - ResourcePath = "path", - Type = "meeting", - StartTime = DateTime.UtcNow, - Duration = TimeSpan.FromHours(1), - TimeZone = "Europe/London" - }; - - // Act - var result = _validator.TestValidate(request); - - // Assert - result.ShouldHaveValidationErrorFor(x => x.Organization); - } - - [Fact] - public void Validate_WhenTypeEmpty_ShouldHaveError() - { - // Arrange - var request = new OccurrenceCreate - { - Organization = "org", - ResourcePath = "path", - Type = "", - StartTime = DateTime.UtcNow, - Duration = TimeSpan.FromHours(1), - TimeZone = "Europe/London" - }; - - // Act - var result = _validator.TestValidate(request); - - // Assert - result.ShouldHaveValidationErrorFor(x => x.Type); - } - - [Fact] - public void Validate_WhenTimeZoneInvalid_ShouldHaveError() - { - // Arrange - var request = new OccurrenceCreate - { - Organization = "org", - ResourcePath = "path", - Type = "meeting", - StartTime = DateTime.UtcNow, - Duration = TimeSpan.FromHours(1), - TimeZone = "Invalid/TimeZone" - }; - - // Act - var result = _validator.TestValidate(request); - - // Assert - result.ShouldHaveValidationErrorFor(x => x.TimeZone); - result.Errors.Should().Contain(e => e.PropertyName == "TimeZone" && e.ErrorMessage.Contains("not a valid IANA time zone")); - } - - [Fact] - public void Validate_WhenStartTimeLocal_ShouldNotHaveError() - { - // Arrange - Local time is now accepted and converted to UTC internally - var request = new OccurrenceCreate - { - Organization = "org", - ResourcePath = "path", - Type = "meeting", - StartTime = DateTime.Now, - Duration = TimeSpan.FromHours(1), - TimeZone = "Europe/London" - }; - - // Act - var result = _validator.TestValidate(request); - - // Assert - result.ShouldNotHaveValidationErrorFor(x => x.StartTime); - } - - [Fact] - public void Validate_WhenStartTimeUnspecified_ShouldHaveError() - { - // Arrange - var request = new OccurrenceCreate - { - Organization = "org", - ResourcePath = "path", - Type = "meeting", - StartTime = new DateTime(2025, 6, 1, 9, 0, 0, DateTimeKind.Unspecified), - Duration = TimeSpan.FromHours(1), - TimeZone = "Europe/London" - }; - - // Act - var result = _validator.TestValidate(request); - - // Assert - result.ShouldHaveValidationErrorFor(x => x.StartTime); - result.Errors.Should().Contain(e => e.PropertyName == "StartTime" && e.ErrorMessage.Contains("Unspecified")); - } - - [Fact] - public void Validate_WhenDurationZero_ShouldHaveError() - { - // Arrange - var request = new OccurrenceCreate - { - Organization = "org", - ResourcePath = "path", - Type = "meeting", - StartTime = DateTime.UtcNow, - Duration = TimeSpan.Zero, - TimeZone = "Europe/London" - }; - - // Act - var result = _validator.TestValidate(request); - - // Assert - result.ShouldHaveValidationErrorFor(x => x.Duration); - result.Errors.Should().Contain(e => e.PropertyName == "Duration" && e.ErrorMessage.Contains("must be positive")); - } - - [Fact] - public void Validate_WhenExtensionKeyEmpty_ShouldHaveError() - { - // Arrange - var request = new OccurrenceCreate - { - Organization = "org", - ResourcePath = "path", - Type = "meeting", - StartTime = DateTime.UtcNow, - Duration = TimeSpan.FromHours(1), - TimeZone = "Europe/London", - Extensions = new Dictionary - { - [""] = "value" - } - }; - - // Act - var result = _validator.TestValidate(request); - - // Assert - result.ShouldHaveValidationErrorFor(x => x.Extensions); - } -} diff --git a/tests/RecurringThings.Tests/Validation/Validators/RecurrenceCreateValidatorTests.cs b/tests/RecurringThings.Tests/Validation/Validators/RecurrenceCreateValidatorTests.cs deleted file mode 100644 index 7d7e7b1..0000000 --- a/tests/RecurringThings.Tests/Validation/Validators/RecurrenceCreateValidatorTests.cs +++ /dev/null @@ -1,416 +0,0 @@ -namespace RecurringThings.Tests.Validation.Validators; - -using System; -using System.Collections.Generic; -using FluentAssertions; -using FluentValidation.TestHelper; -using RecurringThings.Models; -using RecurringThings.Validation.Validators; -using Xunit; - -/// -/// Tests for the class. -/// -public class RecurrenceCreateValidatorTests -{ - private readonly RecurrenceCreateValidator _validator = new(); - - #region Valid Request Tests - - [Fact] - public void Validate_WhenValid_ShouldNotHaveErrors() - { - // Arrange - var request = new RecurrenceCreate - { - Organization = "org", - ResourcePath = "path", - Type = "appointment", - StartTime = DateTime.UtcNow, - Duration = TimeSpan.FromHours(1), - RRule = "FREQ=DAILY;UNTIL=20261231T235959Z", - TimeZone = "America/New_York" - }; - - // Act - var result = _validator.TestValidate(request); - - // Assert - result.ShouldNotHaveAnyValidationErrors(); - } - - [Fact] - public void Validate_WhenValidWithExtensions_ShouldNotHaveErrors() - { - // Arrange - var request = new RecurrenceCreate - { - Organization = "org", - ResourcePath = "path", - Type = "appointment", - StartTime = DateTime.UtcNow, - Duration = TimeSpan.FromHours(1), - RRule = "FREQ=DAILY;UNTIL=20261231T235959Z", - TimeZone = "America/New_York", - Extensions = new Dictionary - { - ["color"] = "blue", - ["priority"] = "high" - } - }; - - // Act - var result = _validator.TestValidate(request); - - // Assert - result.ShouldNotHaveAnyValidationErrors(); - } - - #endregion - - #region Organization Tests - - [Fact] - public void Validate_WhenOrganizationNull_ShouldHaveError() - { - // Arrange - var request = new RecurrenceCreate - { - Organization = null!, - ResourcePath = "path", - Type = "appointment", - StartTime = DateTime.UtcNow, - Duration = TimeSpan.FromHours(1), - RRule = "FREQ=DAILY;UNTIL=20261231T235959Z", - TimeZone = "America/New_York" - }; - - // Act - var result = _validator.TestValidate(request); - - // Assert - result.ShouldHaveValidationErrorFor(x => x.Organization); - } - - [Fact] - public void Validate_WhenOrganizationEmpty_ShouldNotHaveError() - { - // Arrange - Empty is allowed - var request = new RecurrenceCreate - { - Organization = "", - ResourcePath = "path", - Type = "appointment", - StartTime = DateTime.UtcNow, - Duration = TimeSpan.FromHours(1), - RRule = "FREQ=DAILY;UNTIL=20261231T235959Z", - TimeZone = "America/New_York" - }; - - // Act - var result = _validator.TestValidate(request); - - // Assert - result.ShouldNotHaveValidationErrorFor(x => x.Organization); - } - - [Fact] - public void Validate_WhenOrganizationExceedsMaxLength_ShouldHaveError() - { - // Arrange - var request = new RecurrenceCreate - { - Organization = new string('a', 101), - ResourcePath = "path", - Type = "appointment", - StartTime = DateTime.UtcNow, - Duration = TimeSpan.FromHours(1), - RRule = "FREQ=DAILY;UNTIL=20261231T235959Z", - TimeZone = "America/New_York" - }; - - // Act - var result = _validator.TestValidate(request); - - // Assert - result.ShouldHaveValidationErrorFor(x => x.Organization); - result.Errors.Should().Contain(e => e.PropertyName == "Organization" && e.ErrorMessage.Contains("must not exceed 100 characters")); - } - - #endregion - - #region Type Tests - - [Fact] - public void Validate_WhenTypeNull_ShouldHaveError() - { - // Arrange - var request = new RecurrenceCreate - { - Organization = "org", - ResourcePath = "path", - Type = null!, - StartTime = DateTime.UtcNow, - Duration = TimeSpan.FromHours(1), - RRule = "FREQ=DAILY;UNTIL=20261231T235959Z", - TimeZone = "America/New_York" - }; - - // Act - var result = _validator.TestValidate(request); - - // Assert - result.ShouldHaveValidationErrorFor(x => x.Type); - } - - [Fact] - public void Validate_WhenTypeEmpty_ShouldHaveError() - { - // Arrange - var request = new RecurrenceCreate - { - Organization = "org", - ResourcePath = "path", - Type = "", - StartTime = DateTime.UtcNow, - Duration = TimeSpan.FromHours(1), - RRule = "FREQ=DAILY;UNTIL=20261231T235959Z", - TimeZone = "America/New_York" - }; - - // Act - var result = _validator.TestValidate(request); - - // Assert - result.ShouldHaveValidationErrorFor(x => x.Type); - } - - #endregion - - #region TimeZone Tests - - [Fact] - public void Validate_WhenTimeZoneInvalidIana_ShouldHaveError() - { - // Arrange - var request = new RecurrenceCreate - { - Organization = "org", - ResourcePath = "path", - Type = "appointment", - StartTime = DateTime.UtcNow, - Duration = TimeSpan.FromHours(1), - RRule = "FREQ=DAILY;UNTIL=20261231T235959Z", - TimeZone = "Eastern Standard Time" // Windows time zone, not IANA - }; - - // Act - var result = _validator.TestValidate(request); - - // Assert - result.ShouldHaveValidationErrorFor(x => x.TimeZone); - result.Errors.Should().Contain(e => e.PropertyName == "TimeZone" && e.ErrorMessage.Contains("not a valid IANA time zone")); - } - - #endregion - - #region RRule Tests - - [Fact] - public void Validate_WhenRRuleContainsCount_ShouldHaveError() - { - // Arrange - var request = new RecurrenceCreate - { - Organization = "org", - ResourcePath = "path", - Type = "appointment", - StartTime = DateTime.UtcNow, - Duration = TimeSpan.FromHours(1), - RRule = "FREQ=DAILY;COUNT=10", - TimeZone = "America/New_York" - }; - - // Act - var result = _validator.TestValidate(request); - - // Assert - result.ShouldHaveValidationErrorFor(x => x.RRule); - result.Errors.Should().Contain(e => e.PropertyName == "RRule" && e.ErrorMessage.Contains("COUNT is not supported")); - } - - [Fact] - public void Validate_WhenRRuleMissingUntil_ShouldHaveError() - { - // Arrange - var request = new RecurrenceCreate - { - Organization = "org", - ResourcePath = "path", - Type = "appointment", - StartTime = DateTime.UtcNow, - Duration = TimeSpan.FromHours(1), - RRule = "FREQ=DAILY;BYDAY=MO,TU,WE", - TimeZone = "America/New_York" - }; - - // Act - var result = _validator.TestValidate(request); - - // Assert - result.ShouldHaveValidationErrorFor(x => x.RRule); - result.Errors.Should().Contain(e => e.PropertyName == "RRule" && e.ErrorMessage.Contains("must contain UNTIL")); - } - - [Fact] - public void Validate_WhenRRuleUntilNotUtc_ShouldHaveError() - { - // Arrange - var request = new RecurrenceCreate - { - Organization = "org", - ResourcePath = "path", - Type = "appointment", - StartTime = DateTime.UtcNow, - Duration = TimeSpan.FromHours(1), - RRule = "FREQ=DAILY;UNTIL=20261231T235959", // Missing Z - TimeZone = "America/New_York" - }; - - // Act - var result = _validator.TestValidate(request); - - // Assert - result.ShouldHaveValidationErrorFor(x => x.RRule); - } - - #endregion - - #region StartTime Tests - - [Fact] - public void Validate_WhenStartTimeUtc_ShouldNotHaveError() - { - // Arrange - var request = new RecurrenceCreate - { - Organization = "org", - ResourcePath = "path", - Type = "appointment", - StartTime = DateTime.UtcNow, - Duration = TimeSpan.FromHours(1), - RRule = "FREQ=DAILY;UNTIL=20261231T235959Z", - TimeZone = "America/New_York" - }; - - // Act - var result = _validator.TestValidate(request); - - // Assert - result.ShouldNotHaveValidationErrorFor(x => x.StartTime); - } - - [Fact] - public void Validate_WhenStartTimeLocal_ShouldNotHaveError() - { - // Arrange - Local time is now accepted and converted to UTC internally - var request = new RecurrenceCreate - { - Organization = "org", - ResourcePath = "path", - Type = "appointment", - StartTime = DateTime.Now, // Local time - Duration = TimeSpan.FromHours(1), - RRule = "FREQ=DAILY;UNTIL=20261231T235959Z", - TimeZone = "America/New_York" - }; - - // Act - var result = _validator.TestValidate(request); - - // Assert - result.ShouldNotHaveValidationErrorFor(x => x.StartTime); - } - - [Fact] - public void Validate_WhenStartTimeUnspecified_ShouldHaveError() - { - // Arrange - var request = new RecurrenceCreate - { - Organization = "org", - ResourcePath = "path", - Type = "appointment", - StartTime = new DateTime(2025, 6, 1, 9, 0, 0, DateTimeKind.Unspecified), - Duration = TimeSpan.FromHours(1), - RRule = "FREQ=DAILY;UNTIL=20261231T235959Z", - TimeZone = "America/New_York" - }; - - // Act - var result = _validator.TestValidate(request); - - // Assert - result.ShouldHaveValidationErrorFor(x => x.StartTime); - result.Errors.Should().Contain(e => e.PropertyName == "StartTime" && e.ErrorMessage.Contains("Unspecified")); - } - - #endregion - - #region Duration Tests - - [Fact] - public void Validate_WhenDurationZero_ShouldHaveError() - { - // Arrange - var request = new RecurrenceCreate - { - Organization = "org", - ResourcePath = "path", - Type = "appointment", - StartTime = DateTime.UtcNow, - Duration = TimeSpan.Zero, - RRule = "FREQ=DAILY;UNTIL=20261231T235959Z", - TimeZone = "America/New_York" - }; - - // Act - var result = _validator.TestValidate(request); - - // Assert - result.ShouldHaveValidationErrorFor(x => x.Duration); - result.Errors.Should().Contain(e => e.PropertyName == "Duration" && e.ErrorMessage.Contains("must be positive")); - } - - #endregion - - #region Extensions Tests - - [Fact] - public void Validate_WhenExtensionKeyEmpty_ShouldHaveError() - { - // Arrange - var request = new RecurrenceCreate - { - Organization = "org", - ResourcePath = "path", - Type = "appointment", - StartTime = DateTime.UtcNow, - Duration = TimeSpan.FromHours(1), - RRule = "FREQ=DAILY;UNTIL=20261231T235959Z", - TimeZone = "America/New_York", - Extensions = new Dictionary - { - [""] = "value" - } - }; - - // Act - var result = _validator.TestValidate(request); - - // Assert - result.ShouldHaveValidationErrorFor(x => x.Extensions); - } - - #endregion -} From 8fcf53576cffd61ef8176c003e989d57c1d8c242 Mon Sep 17 00:00:00 2001 From: Miguel Tremblay Date: Tue, 27 Jan 2026 20:10:48 -0500 Subject: [PATCH 2/3] Add package logo and remove PRD file Co-Authored-By: Claude Opus 4.5 --- PRD.md | 1439 ---------------------------------------- assets/logo_blue.png | Bin 0 -> 2653 bytes assets/logo_gray.png | Bin 0 -> 2283 bytes assets/logo_green.png | Bin 0 -> 2529 bytes assets/logo_orange.png | Bin 0 -> 2615 bytes assets/logo_pink.png | Bin 0 -> 2801 bytes assets/logo_purple.png | Bin 0 -> 2696 bytes assets/logo_red.png | Bin 0 -> 2442 bytes assets/logo_yellow.png | Bin 0 -> 2563 bytes 9 files changed, 1439 deletions(-) delete mode 100644 PRD.md create mode 100644 assets/logo_blue.png create mode 100644 assets/logo_gray.png create mode 100644 assets/logo_green.png create mode 100644 assets/logo_orange.png create mode 100644 assets/logo_pink.png create mode 100644 assets/logo_purple.png create mode 100644 assets/logo_red.png create mode 100644 assets/logo_yellow.png diff --git a/PRD.md b/PRD.md deleted file mode 100644 index 9d432a2..0000000 --- a/PRD.md +++ /dev/null @@ -1,1439 +0,0 @@ -# Product Requirements Document: RecurringThings Library - -## Document Information -- **Product Name**: RecurringThings -- **Version**: 1.1 -- **Target Framework**: .NET 10.0 -- **Document Status**: Draft -- **Last Updated**: January 27, 2026 - ---- - -## 1. Executive Summary - -### 1.1 Product Overview -RecurringThings is a .NET library that provides a robust, database-agnostic solution for managing recurring events, appointments, and time-based entries. The library virtualizes recurring patterns on-demand rather than materializing all future instances, enabling efficient querying and manipulation of calendar-like data with support for time zones, daylight saving time, exceptions, and overrides. - -### 1.2 Target Audience -- .NET developers building calendar systems -- SaaS applications requiring multi-tenant scheduling -- Enterprise applications managing recurring workflows -- Systems handling appointments, service hours, or recurring tasks - -### 1.3 Core Value Proposition -- **On-demand virtualization**: Generate recurring instances only when queried, not stored -- **Multi-tenant isolation**: Built-in organization and resource path scoping -- **Database flexibility**: Pluggable persistence with MongoDB and PostgreSQL implementations -- **Transaction support**: Integration with Transactional library for ACID operations -- **Time zone correctness**: Proper DST handling using IANA time zones and NodaTime - ---- - -## 2. Product Architecture - -### 2.1 Package Structure - -The product consists of three NuGet packages: - -#### 2.1.1 RecurringThings (Core) -- **Purpose**: Core virtualization engine and abstractions -- **Dependencies**: - - NodaTime (time zone handling) - - Ical.Net (RRule parsing) - - Transactional.Abstractions -- **Exports**: - - `IRecurrenceEngine` interface - - Domain models (CalendarEntry, Recurrence, Occurrence, etc.) - - Repository abstractions - - Validation logic - -#### 2.1.2 RecurringThings.MongoDB -- **Purpose**: MongoDB persistence implementation -- **Dependencies**: - - RecurringThings (core) - - MongoDB.Driver 3.x - - Transactional.MongoDB -- **Exports**: - - MongoDB repository implementations - - DI extension methods - - Migration logic - -#### 2.1.3 RecurringThings.PostgreSQL -- **Purpose**: PostgreSQL persistence implementation -- **Dependencies**: - - RecurringThings (core) - - Npgsql 8.x - - Transactional.PostgreSQL -- **Exports**: - - PostgreSQL repository implementations - - DI extension methods - - Migration scripts - -### 2.2 Dependency Flow -``` -RecurringThings.MongoDB ──→ RecurringThings (core) - ↓ -RecurringThings.PostgreSQL ──→ Transactional.Abstractions -``` - ---- - -## 3. Domain Model - -### 3.1 Core Entities - -#### 3.1.1 Recurrence - -| Field | Type | Nullable | Min | Max | Description | -|-------|------|----------|-----|-----|-------------| -| `Id` | `Guid` | No | - | - | Primary key, generated by library | -| `Organization` | `string` | No | 0 | 100 | Tenant identifier for multi-tenant isolation | -| `ResourcePath` | `string` | No | 0 | 100 | Hierarchical resource scope (e.g., "user123/calendar") | -| `Type` | `string` | No | 1 | 100 | User-defined type (e.g., "appointment", "open-hours") | -| `StartTime` | `DateTime` | No | - | - | UTC timestamp representing the time-of-day that occurrences start | -| `Duration` | `TimeSpan` | No | - | - | Duration of each occurrence | -| `RecurrenceEndTime` | `DateTime` | No | - | - | UTC timestamp when recurrence series ends (must match RRule UNTIL) | -| `RRule` | `string` | No | 1 | 2000 | RFC 5545 recurrence rule (must use UNTIL in UTC, COUNT not supported) | -| `TimeZone` | `string` | No | 1 | 100 | IANA time zone identifier | -| `Extensions` | `Dictionary` | Yes | - | - | User-defined key-value data (key min 1 max 100, value max 1024) | - -**Note**: Recurrences do not store an `EndTime` field. Query filtering uses `StartTime` and `RecurrenceEndTime` only. The end time of individual virtualized occurrences is computed dynamically as `occurrenceStartTime + Duration`. - -#### 3.1.2 Occurrence - -| Field | Type | Nullable | Min | Max | Description | -|-------|------|----------|-----|-----|-------------| -| `Id` | `Guid` | No | - | - | Primary key, generated by library | -| `Organization` | `string` | No | 0 | 100 | Tenant identifier | -| `ResourcePath` | `string` | No | 0 | 100 | Hierarchical resource scope | -| `Type` | `string` | No | 1 | 100 | User-defined type | -| `StartTime` | `DateTime` | No | - | - | UTC timestamp | -| `EndTime` | `DateTime` | No | - | - | UTC timestamp computed from StartTime + Duration | -| `Duration` | `TimeSpan` | No | - | - | Duration | -| `TimeZone` | `string` | No | 1 | 100 | IANA time zone identifier | -| `Extensions` | `Dictionary` | Yes | - | - | User-defined key-value data | - -#### 3.1.3 OccurrenceException - -| Field | Type | Nullable | Min | Max | Description | -|-------|------|----------|-----|-----|-------------| -| `Id` | `Guid` | No | - | - | Primary key, generated by library | -| `Organization` | `string` | No | 0 | 100 | Tenant identifier | -| `ResourcePath` | `string` | No | 0 | 100 | Hierarchical resource scope | -| `RecurrenceId` | `Guid` | No | - | - | Foreign key to parent recurrence | -| `OriginalTimeUtc` | `DateTime` | No | - | - | UTC timestamp of occurrence to cancel | -| `Extensions` | `Dictionary` | Yes | - | - | User-defined key-value data | - -#### 3.1.4 OccurrenceOverride - -| Field | Type | Nullable | Min | Max | Description | -|-------|------|----------|-----|-----|-------------| -| `Id` | `Guid` | No | - | - | Primary key, generated by library | -| `Organization` | `string` | No | 0 | 100 | Tenant identifier | -| `ResourcePath` | `string` | No | 0 | 100 | Hierarchical resource scope | -| `RecurrenceId` | `Guid` | No | - | - | Foreign key to parent recurrence | -| `OriginalTimeUtc` | `DateTime` | No | - | - | UTC timestamp of occurrence being replaced | -| `StartTime` | `DateTime` | No | - | - | New UTC timestamp | -| `EndTime` | `DateTime` | No | - | - | New UTC timestamp computed from StartTime + Duration | -| `Duration` | `TimeSpan` | No | - | - | New duration | -| `OriginalDuration` | `TimeSpan` | No | - | - | Original duration (denormalized from recurrence at creation) | -| `OriginalExtensions` | `Dictionary` | Yes | - | - | Original extensions (denormalized from recurrence at creation) | -| `Extensions` | `Dictionary` | Yes | - | - | User-defined key-value data | - -### 3.2 Query Result Models - -#### 3.2.1 CalendarEntry -Unified abstraction returned from queries, representing recurrences, standalone occurrences, or virtualized occurrences. - -```csharp -public class CalendarEntry -{ - // Common fields - public string Organization { get; set; } - public string ResourcePath { get; set; } - public string Type { get; set; } - public DateTime StartTime { get; set; } - public DateTime EndTime { get; set; } - public TimeSpan Duration { get; set; } - public string TimeZone { get; set; } - public Dictionary? Extensions { get; set; } - - // Type-specific IDs (all nullable - exactly one will be set based on entry type) - public Guid? RecurrenceId { get; set; } // Set for recurrence entries - public Guid? OccurrenceId { get; set; } // Set for standalone occurrence entries - public Guid? OverrideId { get; set; } // Set for overridden recurrence occurrences - public Guid? ExceptionId { get; set; } // Never set in query results (excepted occurrences are not returned) - - // Type-specific details (mutually exclusive) - public RecurrenceDetails? RecurrenceDetails { get; set; } - public RecurrenceOccurrenceDetails? RecurrenceOccurrenceDetails { get; set; } - - // If both null → Standalone occurrence (OccurrenceId will be set) -} -``` - -#### 3.2.2 RecurrenceDetails -Present when `CalendarEntry` represents a recurrence pattern. - -```csharp -public class RecurrenceDetails -{ - public DateTime RecurrenceEndTime { get; set; } - public string RRule { get; set; } -} -``` - -#### 3.2.3 RecurrenceOccurrenceDetails -Present when `CalendarEntry` represents an occurrence generated from a recurrence. - -```csharp -public class RecurrenceOccurrenceDetails -{ - public Guid RecurrenceId { get; set; } - public OccurrenceOriginal? Original { get; set; } // Non-null if override applied -} -``` - -#### 3.2.4 OccurrenceOriginal -Contains original values before an override was applied. - -```csharp -public class OccurrenceOriginal -{ - public DateTime StartTime { get; set; } - public TimeSpan Duration { get; set; } - public Dictionary? Extensions { get; set; } -} -``` - -### 3.3 Request Models - -#### 3.3.1 RecurrenceCreate -```csharp -public class RecurrenceCreate -{ - public required string Organization { get; init; } - public required string ResourcePath { get; init; } - public required string Type { get; init; } - public required DateTime StartTimeUtc { get; init; } - public required TimeSpan Duration { get; init; } - public required DateTime RecurrenceEndTimeUtc { get; init; } - public required string RRule { get; init; } - public required string TimeZone { get; init; } - public Dictionary? Extensions { get; init; } -} -``` - -#### 3.3.2 OccurrenceCreate -```csharp -public class OccurrenceCreate -{ - public required string Organization { get; init; } - public required string ResourcePath { get; init; } - public required string Type { get; init; } - public required DateTime StartTimeUtc { get; init; } - public required TimeSpan Duration { get; init; } - public required string TimeZone { get; init; } - public Dictionary? Extensions { get; init; } -} -``` - ---- - -## 4. Public API - -### 4.1 Primary Interface: IRecurrenceEngine - -```csharp -public interface IRecurrenceEngine -{ - /// - /// Retrieves all calendar entries (recurrences, standalone occurrences, and - /// virtualized occurrences) within the specified time range. - /// Excepted (deleted) virtualized occurrences are not returned. - /// Results are streamed via IAsyncEnumerable to avoid materializing large result sets. - /// - IAsyncEnumerable GetAsync( - string organization, - string resourcePath, - DateTime startUtc, - DateTime endUtc, - string[]? types = null, - [EnumeratorCancellation] CancellationToken cancellationToken = default - ); - - /// - /// Creates a new recurrence pattern. - /// - /// Created recurrence with generated Id - Task CreateRecurrenceAsync( - RecurrenceCreate request, - ITransactionContext? transactionContext = null, - CancellationToken cancellationToken = default - ); - - /// - /// Creates a new standalone occurrence. - /// - /// Created occurrence with generated Id - Task CreateOccurrenceAsync( - OccurrenceCreate request, - ITransactionContext? transactionContext = null, - CancellationToken cancellationToken = default - ); - - /// - /// Updates a calendar entry. Behavior depends on entry type: - /// - Recurrence: Updates Duration and Extensions only - /// - Standalone occurrence: Updates StartTime, Duration, and Extensions only - /// - Recurrence occurrence: Creates or updates an override - /// - /// - /// Thrown when attempting to modify immutable fields - /// - Task UpdateAsync( - CalendarEntry entry, - ITransactionContext? transactionContext = null, - CancellationToken cancellationToken = default - ); - - /// - /// Deletes a calendar entry. Behavior depends on entry type: - /// - Recurrence: Deletes entire series including all exceptions and overrides - /// - Standalone occurrence: Deletes the occurrence - /// - Recurrence occurrence: Creates an exception (and removes override if present) - /// - Task DeleteAsync( - CalendarEntry entry, - ITransactionContext? transactionContext = null, - CancellationToken cancellationToken = default - ); - - /// - /// Restores a recurrence occurrence by removing its override. - /// - /// - /// Thrown when called on a recurrence, standalone occurrence, or - /// recurrence occurrence without an override - /// - Task RestoreAsync( - CalendarEntry entry, - ITransactionContext? transactionContext = null, - CancellationToken cancellationToken = default - ); -} -``` - -### 4.2 Service Registration - -#### 4.2.1 MongoDB Configuration -```csharp -services.AddRecurringThings(cfg => -{ - cfg.UseMongoDb(mongo => - { - mongo.ConnectionString = "mongodb://localhost:27017"; - mongo.DatabaseName = "MyApplication"; // Required - mongo.CollectionName = "recurring_things"; // Optional, default: "recurring_things" - }); -}); -``` - -#### 4.2.2 PostgreSQL Configuration -```csharp -services.AddRecurringThings(cfg => -{ - cfg.UsePostgreSQL(pg => - { - pg.ConnectionString = "Host=localhost;Database=myapp;Username=user;Password=pass"; - pg.SchemaName = "public"; // Optional, default: "public" - }); -}); -``` - -### 4.3 Configuration Rules -- **Exactly one persistence provider required**: Application fails at startup if neither or both MongoDB and PostgreSQL are configured -- **MongoDB**: `DatabaseName` is required, `CollectionName` is optional -- **PostgreSQL**: Database must exist, schema is auto-created if missing -- **Transaction managers**: Automatically registered by `UseMongoDb()` and `UsePostgreSQL()` via Transactional library integration - ---- - -## 5. Functional Requirements - -### 5.1 Multi-Tenancy - -#### 5.1.1 Organization Isolation -- **Requirement**: All operations must be scoped to an `Organization` identifier -- **Purpose**: Enable multi-tenant SaaS applications with data isolation -- **Implementation**: - - Organization is mandatory on all entities (non-null, 0-100 characters) - - MongoDB: Organization is first field in compound shard key - - PostgreSQL: Organization is first column in composite indexes - - Empty string is allowed for single-tenant scenarios - -#### 5.1.2 Resource Path Scoping -- **Requirement**: All operations must be scoped to a `ResourcePath` identifier -- **Purpose**: Enable hierarchical resource organization (e.g., "user123/calendar", "store456") -- **Implementation**: - - ResourcePath is mandatory on all entities (non-null, 0-100 characters) - - Freeform string, no format validation - - Exact match filtering only (no prefix/wildcard queries in v1.0) - - Empty string is allowed - -#### 5.1.3 Exception/Override Tenant Validation -- **Requirement**: Exceptions and overrides must belong to the same Organization and ResourcePath as their parent recurrence -- **Implementation**: - - Core library validates on creation: - ```csharp - var recurrence = await _recurrenceRepo.GetByIdAsync(request.RecurrenceId); - if (recurrence == null) - throw new ArgumentException("Recurrence not found"); - if (recurrence.Organization != request.Organization || - recurrence.ResourcePath != request.ResourcePath) - throw new InvalidOperationException("Cannot create exception/override for recurrence in different scope"); - ``` - -### 5.2 Type-Based Filtering - -#### 5.2.1 Type Field -- **Requirement**: Recurrences and occurrences must have a user-defined `Type` field -- **Purpose**: Enable differentiation between different kinds of recurring things (appointments, open hours, service offerings, etc.) -- **Implementation**: - - Type is mandatory (non-null, 1-100 characters) - - Freeform string, no predefined values - - Empty string is NOT allowed - -#### 5.2.2 Type Filtering -- **Requirement**: `GetAsync()` must support filtering by one or more types -- **Implementation**: - - `types` parameter accepts `string[]?` - - `null`: Return all types - - `[]`: Throw `ArgumentException` (invalid) - - `["type1", "type2"]`: Return entries matching any of the specified types (IN clause) - -### 5.3 Virtualization Logic - -#### 5.3.1 On-Demand Generation -- **Requirement**: Recurrence instances must be generated on-demand during queries, not pre-materialized -- **Implementation**: - - Query recurrences whose series intersect with query range (using StartTime and RecurrenceEndTime) - - Convert UTC to local time using IANA time zone - - Apply RRule using Ical.Net to generate theoretical instances - - Convert instances back to UTC - - Apply exceptions and overrides - - Filter by query range and RecurrenceEndTime - -#### 5.3.2 Exception Handling -- **Requirement**: Exceptions must cancel specific instances of a recurrence -- **Implementation**: - - Exception matches instance by `OriginalTimeUtc` (UTC) - - Matched instance is excluded from query results entirely - - Excepted occurrences are never returned by `GetAsync()` - -#### 5.3.3 Override Handling -- **Requirement**: Overrides must replace specific instances with modified time/duration/extensions -- **Implementation**: - - Override matches instance by `OriginalTimeUtc` (UTC) - - Matched instance is replaced with override values - - `Original` property in CalendarEntry contains pre-override values (denormalized on override) - - Override is included if either: - - `OriginalTimeUtc` falls within query range (to suppress virtualized instance), OR - - Override's time range [StartTime, EndTime] overlaps with query range (to show the moved occurrence) - -#### 5.3.4 Override Query Logic -When processing overrides: -- If override's `OriginalTimeUtc` is in range: suppress the virtualized instance, show override instead (if override's time range overlaps with query range) -- If override's `OriginalTimeUtc` is outside range but override's time range [StartTime, EndTime] overlaps with query range: show override (no virtualized instance to suppress) -- If override's time range is outside query range but `OriginalTimeUtc` is in range: suppress virtualized instance, don't show override - -#### 5.3.5 Exception and Override Precedence -- **Constraint**: An occurrence cannot have both an exception and an override simultaneously -- **Validation**: Core library must prevent this illegal state - -### 5.4 Immutability Rules - -#### 5.4.1 Recurrence Immutable Fields -After creation, the following fields cannot be modified: -- `Organization` -- `ResourcePath` -- `Type` -- `TimeZone` -- `StartTime` -- `RRule` -- `RecurrenceEndTime` - -To change any of these, delete and recreate the recurrence. - -#### 5.4.2 Recurrence Mutable Fields -- `Duration` -- `Extensions` - -#### 5.4.3 Standalone Occurrence Immutable Fields -- `Organization` -- `ResourcePath` -- `Type` -- `TimeZone` - -#### 5.4.4 Standalone Occurrence Mutable Fields -- `StartTime` -- `Duration` -- `Extensions` - -**Note**: When `StartTime` or `Duration` is modified on a standalone occurrence, `EndTime` must be recomputed as `StartTime + Duration`. - -#### 5.4.5 Override Fields -Overrides inherit `TimeZone` from parent recurrence (not stored separately). Mutable fields: -- `StartTime` -- `Duration` -- `Extensions` - -**Note**: When `StartTime` or `Duration` is modified on an override, `EndTime` must be recomputed as `StartTime + Duration`. - -### 5.5 State Transition Rules - -#### 5.5.1 Update Operations -| Entry Type | Has Override | Allowed | Behavior | -|------------|--------------|---------|----------| -| Recurrence | N/A | Yes | Update Duration/Extensions only | -| Standalone | N/A | Yes | Update StartTime/Duration/Extensions, recompute EndTime | -| Recurrence Occurrence | No | Yes | Create override (denormalize original values) | -| Recurrence Occurrence | Yes | Yes | Update override, recompute EndTime | - -#### 5.5.2 Delete Operations -| Entry Type | Has Override | Allowed | Behavior | -|------------|--------------|---------|----------| -| Recurrence | N/A | Yes | Delete series + all exceptions/overrides | -| Standalone | N/A | Yes | Direct delete | -| Recurrence Occurrence | No | Yes | Create exception | -| Recurrence Occurrence | Yes | Yes | Delete override, create exception at original time | - -#### 5.5.3 Restore Operations -| Entry Type | Has Override | Allowed | Behavior | -|------------|--------------|---------|----------| -| Recurrence | N/A | **No** | Throw `InvalidOperationException` | -| Standalone | N/A | **No** | Throw `InvalidOperationException` | -| Recurrence Occurrence | No | **No** | Throw `InvalidOperationException` | -| Recurrence Occurrence | Yes | Yes | Delete override | - -**Note**: Restoring excepted occurrences is not supported. Once an occurrence is deleted (excepted), it cannot be restored through the API since excepted occurrences are not returned by `GetAsync()`. - -### 5.6 RRule Validation - -#### 5.6.1 UNTIL Required, COUNT Not Supported -- **Requirement**: RRule must specify UNTIL; COUNT is not supported -- **Rationale**: COUNT prevents efficient query range filtering since the recurrence end time cannot be determined without full virtualization -- **Implementation**: - ```csharp - var parsed = ParseRRule(request.RRule); - - if (parsed.Count != null) - throw new ArgumentException("RRule with COUNT is not supported. Use UNTIL instead."); - - if (parsed.Until == null) - throw new ArgumentException("RRule must specify UNTIL."); - ``` - -#### 5.6.2 UNTIL Must Be UTC -- **Requirement**: RRule UNTIL must be specified in UTC (Z suffix) to eliminate DST ambiguity -- **Rationale**: Local-time UNTIL values can map to two different UTC times during DST fall-back transitions -- **Implementation**: - ```csharp - var parsed = ParseRRule(request.RRule); - - if (!parsed.Until.HasTimeZone || !parsed.Until.IsUtc) - throw new ArgumentException("RRule UNTIL must be specified in UTC (Z suffix)."); - - var untilUtc = parsed.Until.ToDateTimeUtc(); - - if (request.RecurrenceEndTimeUtc != untilUtc) - throw new ArgumentException("RecurrenceEndTimeUtc must match RRule UNTIL."); - ``` - -### 5.7 Extensibility - -#### 5.7.1 Extensions Dictionary -- **Requirement**: Users must be able to store custom key-value data on all entities -- **Implementation**: - - `Dictionary?` (nullable) - - Key: Non-null, min 1 character, max 100 characters - - Value: Non-null, max 1024 characters - - Validation in core library - - MongoDB: Embedded document - - PostgreSQL: JSONB column on each table - -### 5.8 Time Zone Handling - -#### 5.8.1 UTC Storage -- **Requirement**: All `DateTime` fields must be stored in UTC -- **Implementation**: - - User provides UTC timestamps - - No timezone conversion on storage/retrieval - - Library does not validate or enforce UTC (trusts caller) - -#### 5.8.2 IANA Time Zones -- **Requirement**: Recurrences and occurrences must use IANA time zone identifiers -- **Implementation**: - - Stored as string (e.g., "America/New_York") - - Validated by NodaTime during virtualization - - Used for local time conversion when applying RRules - -#### 5.8.3 DST Handling -- **Requirement**: Virtualization must correctly handle daylight saving time transitions -- **Implementation**: - - Convert UTC to local time using NodaTime - - Apply RRule in local time - - Convert results back to UTC - - Ical.Net handles DST edge cases - -### 5.9 EndTime Computation and Storage - -#### 5.9.1 Computed EndTime Field -- **Requirement**: Standalone occurrences and overrides must store a computed EndTime -- **Purpose**: Enable efficient database queries that filter by both start and end time ranges -- **Implementation**: - - EndTime = StartTime + Duration - - Computed and stored at creation time - - Recomputed when StartTime or Duration is updated - - Used in database queries to efficiently filter entries that overlap with query range - -#### 5.9.2 Recurrences Do Not Store EndTime -- **Requirement**: Recurrences do not store an EndTime field -- **Rationale**: The recurrence's `StartTime` represents the time-of-day for occurrences, not when the series starts. Query filtering uses `StartTime` and `RecurrenceEndTime` to determine if the series potentially overlaps with the query range. Individual occurrence end times are computed dynamically during virtualization. - -#### 5.9.3 Query Range Filtering -- **Requirement**: GetAsync must efficiently query entries that overlap with the requested range -- **Implementation**: - - Standalone occurrences/overrides: `StartTime <= queryEndUtc AND EndTime >= queryStartUtc` - - Recurrences: `StartTime <= queryEndUtc AND RecurrenceEndTime >= queryStartUtc` - ---- - -## 6. Database Implementation - -### 6.1 MongoDB - -#### 6.1.1 Collection Schema -**Single collection design** (default name: `recurring_things`) - -```javascript -{ - "_id": "guid", - "organization": "tenant1", - "resourcePath": "user123/calendar", - "type": "appointment", - "documentType": "recurrence", // "recurrence" | "occurrence" | "exception" | "override" - "startTime": ISODate("2025-01-15T14:00:00Z"), - "endTime": ISODate("2025-01-15T15:00:00Z"), // Not present on recurrences - "duration": NumberLong(3600000), // Stored as milliseconds - "timeZone": "America/New_York", - - // Recurrence-specific (null for others) - "recurrenceEndTime": ISODate("2025-12-31T23:59:59Z"), - "rRule": "FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR;UNTIL=20251231T235959Z", - - // Override/Exception-specific (null for others) - "recurrenceId": "guid", - "originalTimeUtc": ISODate("2025-01-20T14:00:00Z"), - - // Override-specific denormalized original values - "originalDuration": NumberLong(3600000), // Stored as milliseconds - "originalExtensions": { "title": "Original Title" }, - - "extensions": { - "title": "Team Standup", - "location": "Conference Room A" - } -} -``` - -#### 6.1.2 Indexes - -**Index 1: For recurrences and occurrences** -```javascript -{ organization: 1, resourcePath: 1, documentType: 1, type: 1, startTime: 1, endTime: 1 } -``` - -**Index 2: For exceptions/overrides by originalTimeUtc** -```javascript -{ organization: 1, resourcePath: 1, documentType: 1, originalTimeUtc: 1 } -``` - -**Index 3: For exceptions/overrides by startTime (moved occurrences)** -```javascript -{ organization: 1, resourcePath: 1, documentType: 1, startTime: 1, endTime: 1 } -``` - -**Index 4: For cascade deletes by recurrenceId** -```javascript -{ organization: 1, recurrenceId: 1 } -``` - -#### 6.1.3 Query Strategy -Execute two queries in parallel: -1. Recurrences and occurrences using Index 1 -2. Exceptions and overrides using Index 2 and Index 3 - -```csharp -var recurrencesTask = collection.Find(recurrenceOccurrenceFilter).ToListAsync(); -var exceptionsOverridesTask = collection.Find(exceptionOverrideFilter).ToListAsync(); -await Task.WhenAll(recurrencesTask, exceptionsOverridesTask); -``` - -#### 6.1.4 Cascade Delete -When deleting a recurrence, exceptions and overrides must be deleted within a transaction to ensure atomicity: - -```csharp -await using var session = await client.StartSessionAsync(); -session.StartTransaction(); -try -{ - // Delete the recurrence - await collection.DeleteOneAsync(session, recurrenceFilter); - - // Delete all related exceptions and overrides - await collection.DeleteManyAsync(session, Builders.Filter.And( - Builders.Filter.Eq("organization", organization), - Builders.Filter.Eq("recurrenceId", recurrenceId), - Builders.Filter.In("documentType", new[] { "exception", "override" }) - )); - - await session.CommitTransactionAsync(); -} -catch -{ - await session.AbortTransactionAsync(); - throw; -} -``` - -**Important**: Recurrence deletion with cascade MUST use a transaction to ensure atomicity and prevent orphaned exception/override documents. - -#### 6.1.5 Sharding Configuration -- **Shard key**: `{ organization: 1 }` -- **Purpose**: Enable horizontal scaling across tenants -- **Query pattern**: All queries filter by organization first - -#### 6.1.6 Auto-Migration -- **Strategy**: Create indexes on first operation if missing -- **Implementation**: Check index existence, create if needed -- **No explicit migration API**: Automatic on startup - -### 6.2 PostgreSQL - -#### 6.2.1 Table Schema - -**recurrences** -```sql -CREATE TABLE recurrences ( - id UUID PRIMARY KEY, - organization VARCHAR(100) NOT NULL, - resource_path VARCHAR(100) NOT NULL, - type VARCHAR(100) NOT NULL, - start_time TIMESTAMP NOT NULL, - duration INTERVAL NOT NULL, - recurrence_end_time TIMESTAMP NOT NULL, - r_rule VARCHAR(2000) NOT NULL, - time_zone VARCHAR(100) NOT NULL, - extensions JSONB -); -``` - -**occurrences** -```sql -CREATE TABLE occurrences ( - id UUID PRIMARY KEY, - organization VARCHAR(100) NOT NULL, - resource_path VARCHAR(100) NOT NULL, - type VARCHAR(100) NOT NULL, - start_time TIMESTAMP NOT NULL, - end_time TIMESTAMP NOT NULL, - duration INTERVAL NOT NULL, - time_zone VARCHAR(100) NOT NULL, - extensions JSONB -); -``` - -**occurrence_exceptions** -```sql -CREATE TABLE occurrence_exceptions ( - id UUID PRIMARY KEY, - organization VARCHAR(100) NOT NULL, - resource_path VARCHAR(100) NOT NULL, - recurrence_id UUID NOT NULL, - original_time_utc TIMESTAMP NOT NULL, - extensions JSONB, - FOREIGN KEY (recurrence_id) REFERENCES recurrences(id) ON DELETE CASCADE -); -``` - -**occurrence_overrides** -```sql -CREATE TABLE occurrence_overrides ( - id UUID PRIMARY KEY, - organization VARCHAR(100) NOT NULL, - resource_path VARCHAR(100) NOT NULL, - recurrence_id UUID NOT NULL, - original_time_utc TIMESTAMP NOT NULL, - start_time TIMESTAMP NOT NULL, - end_time TIMESTAMP NOT NULL, - duration INTERVAL NOT NULL, - original_duration INTERVAL NOT NULL, - original_extensions JSONB, - extensions JSONB, - FOREIGN KEY (recurrence_id) REFERENCES recurrences(id) ON DELETE CASCADE -); -``` - -#### 6.2.2 Indexes -```sql --- Composite indexes for query optimization -CREATE INDEX idx_recurrences_query -ON recurrences(organization, resource_path, type, start_time, recurrence_end_time); - -CREATE INDEX idx_occurrences_query -ON occurrences(organization, resource_path, type, start_time, end_time); - --- Exception lookups -CREATE INDEX idx_exceptions_query -ON occurrence_exceptions(organization, resource_path, original_time_utc); - --- Override lookups (by original time OR new time) -CREATE INDEX idx_overrides_original -ON occurrence_overrides(organization, resource_path, original_time_utc); - -CREATE INDEX idx_overrides_start -ON occurrence_overrides(organization, resource_path, start_time, end_time); - --- Foreign key indexes -CREATE INDEX idx_exceptions_recurrence -ON occurrence_exceptions(recurrence_id); - -CREATE INDEX idx_overrides_recurrence -ON occurrence_overrides(recurrence_id); -``` - -#### 6.2.3 Cascade Delete -PostgreSQL uses database-level cascade via `ON DELETE CASCADE` foreign key constraints. The PostgreSQL implementation should not make explicit calls to delete exceptions/overrides when deleting a recurrence—the database handles this automatically. - -#### 6.2.4 Auto-Migration (Hangfire Approach) -- **Strategy**: Check schema version, apply migrations on startup -- **Implementation**: - - Embedded SQL scripts in assembly - - Version tracking in metadata table: `CREATE TABLE __recurring_things_schema (version INT)` - - Execute migrations sequentially if current version < target version -- **Schema creation**: Auto-create schema if missing (tables, indexes, constraints) -- **Database creation**: Database must exist (not auto-created) - ---- - -## 7. Transaction Support - -### 7.1 Integration with Transactional Library - -#### 7.1.1 Transaction Context -- **Type**: `ITransactionContext?` (from Transactional.Abstractions) -- **Behavior**: Optional parameter on all engine methods -- **Null handling**: When null, operations execute without transaction - -#### 7.1.2 MongoDB Transactions -- **Implementation**: `IMongoTransactionContext` wraps `IClientSessionHandle` -- **Registration**: `UseMongoDb()` internally calls `AddMongoDbTransactionManager()` -- **Usage**: Pass session to MongoDB operations - -#### 7.1.3 PostgreSQL Transactions -- **Implementation**: `IPostgresTransactionContext` wraps `NpgsqlTransaction` -- **Registration**: `UsePostgreSQL()` internally calls `AddPostgresTransactionManager()` -- **Usage**: Set transaction on commands - -#### 7.1.4 User Workflow -```csharp -// User creates transaction via transaction manager -var transactionManager = serviceProvider.GetRequiredService(); -await using var context = await transactionManager.BeginTransactionAsync(); - -try -{ - // Pass context to engine operations - await engine.CreateRecurrenceAsync(request, context); - await engine.CreateOccurrenceAsync(request2, context); - - await context.CommitAsync(); -} -catch -{ - await context.RollbackAsync(); - throw; -} -``` - ---- - -## 8. Validation Requirements - -### 8.1 Core Library Validations - -#### 8.1.1 Field Length Constraints -| Field | Min | Max | Validation Location | -|-------|-----|-----|---------------------| -| Organization | 0 | 100 | Core library | -| ResourcePath | 0 | 100 | Core library | -| Type | 1 | 100 | Core library | -| RRule | 1 | 2000 | Core library | -| TimeZone | 1 | 100 | Core library | -| Extension Key | 1 | 100 | Core library | -| Extension Value | 0 | 1024 | Core library | - -#### 8.1.2 Null Constraints -- **Non-nullable strings**: Organization, ResourcePath, Type, RRule, TimeZone -- **Empty string allowed**: Organization, ResourcePath -- **Empty string not allowed**: Type, RRule, TimeZone - -#### 8.1.3 Business Rule Validations -- `types` parameter: Empty array throws `ArgumentException` -- Immutable field modification: Throws `InvalidOperationException` -- Restore on clean occurrence: Throws `InvalidOperationException` -- Restore on recurrence/standalone: Throws `InvalidOperationException` -- Exception + Override on same occurrence: Prevented by repository logic -- RRule with COUNT: Throws `ArgumentException` -- RRule without UNTIL: Throws `ArgumentException` -- RRule UNTIL not in UTC: Throws `ArgumentException` -- RecurrenceEndTimeUtc not matching RRule UNTIL: Throws `ArgumentException` -- Exception/Override for recurrence in different scope: Throws `InvalidOperationException` - -### 8.2 Database Layer Validations - -#### 8.2.1 PostgreSQL Schema Constraints -- Column sizes match field length constraints -- NOT NULL constraints on required fields -- Foreign key constraints with CASCADE delete - ---- - -## 9. Testing Requirements - -### 9.1 Unit Tests (RecurringThings.Tests) - -#### 9.1.1 Framework and Tools -- **Framework**: xUnit -- **Mocking**: Moq -- **Coverage Target**: 90%+ code coverage - -#### 9.1.2 Test Categories - -**Validation Tests** -- Field length constraints (all fields) -- Null constraint violations -- Empty array type filter throws exception -- Extensions key/value length validation -- RRule COUNT rejection -- RRule UNTIL requirement -- RRule UNTIL must be UTC -- RecurrenceEndTimeUtc/UNTIL mismatch -- Immutable field modification rejection -- Exception/Override tenant isolation - -**State Transition Tests** -- Update allowed/disallowed scenarios (per state transition table) -- Delete allowed/disallowed scenarios (per state transition table) -- Restore allowed/disallowed scenarios (per state transition table) - -**Virtualization Tests** -- Basic recurrence generation (daily, weekly, monthly, yearly) -- Exception application (occurrence removed from results) -- Override application (occurrence modified) -- Override with moved time (in range by time range overlap only) -- Override with moved time (in range by originalTimeUtc only) -- Override + delete (override removed, exception created at original time) -- Boundary conditions (query range intersections) -- Empty results when no matches -- Type filtering (null, single type, multiple types) -- Long duration entries correctly included in queries - -**Time Zone Tests** -- DST spring forward (2 AM → 3 AM) -- DST fall back (2 AM → 1 AM) -- Cross-timezone recurrences -- IANA timezone validation - -**Edge Cases** -- Recurrence with zero occurrences in range -- Overlapping recurrences -- Override original values are denormalized correctly -- EndTime correctly computed from StartTime + Duration -- EndTime correctly recomputed on update - -#### 9.1.3 Mock Strategy -- Mock `IRecurrenceRepository`, `IOccurrenceRepository`, `IOccurrenceExceptionRepository`, `IOccurrenceOverrideRepository` -- Verify repository calls with expected parameters -- Setup return values for test scenarios - -### 9.2 Integration Tests - -#### 9.2.1 MongoDB Tests (RecurringThings.MongoDB.Tests) - -**Setup** -- Read `MONGODB_CONNECTION_STRING` environment variable -- Create unique database per test run (append timestamp) -- Drop database after all tests complete - -**Test Scenarios** -- CRUD operations on all entity types -- Transaction support (commit/rollback) -- Index creation verification -- Sharding key query patterns -- Extensions storage/retrieval -- Parallel query execution -- Cascade delete atomicity - -#### 9.2.2 PostgreSQL Tests (RecurringThings.PostgreSQL.Tests) - -**Setup** -- Read `POSTGRES_CONNECTION_STRING` environment variable -- Create unique schema per test run (append timestamp) -- Drop schema after all tests complete - -**Test Scenarios** -- CRUD operations on all entity types -- Transaction support (commit/rollback) -- Schema migration (version tracking) -- Foreign key cascade deletes -- Extensions storage/retrieval (JSONB) - -#### 9.2.3 Integration Test Exclusions -- **Not testing**: Full engine virtualization logic (covered by unit tests) -- **Focus**: Database persistence layer only - -### 9.3 CI/CD Testing - -#### 9.3.1 GitHub Actions Workflow -```yaml -- Run unit tests (always) -- Run integration tests (requires MONGODB_CONNECTION_STRING and POSTGRES_CONNECTION_STRING secrets) -- Run `dotnet format --verify-no-changes` -- All must pass before merge -``` - -#### 9.3.2 Test Execution on Feature Development -- Every PR triggers full test suite -- Feature branches cannot merge until all tests pass -- Code formatting violations block merge - ---- - -## 10. Documentation Requirements - -### 10.1 README.md Structure - -#### 10.1.1 Sections -1. **Overview**: Product description, value proposition -2. **Installation**: NuGet package installation commands -3. **Quick Start**: Minimal setup example (MongoDB and PostgreSQL) -4. **Configuration**: Detailed DI registration options -5. **Usage Examples**: - - Creating recurrences - - Creating standalone occurrences - - Querying occurrences (streaming with IAsyncEnumerable) - - Updating occurrences (standalone vs virtualized) - - Deleting occurrences (standalone vs virtualized) - - Restoring overridden occurrences - - Using transactions -6. **Domain Model**: Entity descriptions and relationships -7. **Immutability Rules**: Which fields can/cannot be modified -8. **State Transition Rules**: Tables showing allowed operations -9. **RRule Requirements**: UNTIL required (must be UTC), COUNT not supported -10. **Time Zone Handling**: DST examples, IANA timezone usage -11. **Multi-Tenancy**: Organization and ResourcePath usage patterns -12. **Contributing**: Development setup, testing, code formatting -13. **License**: Apache 2.0 - -#### 10.1.2 Update Frequency -- README updated with each new feature before PR merge -- Examples validated against actual code -- Architecture diagrams updated if structure changes - -### 10.2 XML Documentation - -#### 10.2.1 Requirements -- All public types have `` tags -- All public methods have ``, ``, ``, `` tags -- Complex behaviors documented with `` and code examples - -#### 10.2.2 Code Example Format -```csharp -/// -/// Updates a calendar entry. -/// -/// -/// Behavior varies by entry type: -/// -/// Recurrence: Updates Duration and Extensions only -/// Standalone: Updates StartTime, Duration, and Extensions (recomputes EndTime) -/// Recurrence occurrence: Creates or updates an override (recomputes EndTime) -/// -/// -/// Immutable fields (Organization, ResourcePath, Type, TimeZone, and for recurrences: -/// StartTime, RRule, RecurrenceEndTime) cannot be modified. Attempting to change -/// these throws InvalidOperationException. -/// -/// -/// Thrown when attempting to modify immutable fields -/// -``` - ---- - -## 11. Code Quality Requirements - -### 11.1 Formatting - -#### 11.1.1 EditorConfig -- Use standard .NET EditorConfig -- 4 spaces for indentation -- LF line endings -- UTF-8 encoding - -#### 11.1.2 dotnet format -- Run `dotnet format` after completing each feature -- CI/CD enforces `dotnet format --verify-no-changes` -- No merge allowed if formatting violations exist - -### 11.2 Code Analysis - -#### 11.2.1 Analyzer Rules -- Enable all Roslyn analyzers -- Treat warnings as errors in Release builds -- Nullable reference types enabled (`enable`) - -### 11.3 Performance - -#### 11.3.1 Optimization Targets -- Minimize allocations in hot paths (virtualization loop) -- Use `ValueTask` where appropriate -- Avoid LINQ in performance-critical sections -- Reuse collections where possible -- Execute independent database queries in parallel -- Stream results via IAsyncEnumerable to avoid materializing large result sets - -#### 11.3.2 Benchmarking -- **Status**: No benchmark tests maintained in v1.0 -- **Future**: May add in v2.0 if performance concerns arise - ---- - -## 12. Versioning and Release Strategy - -### 12.1 Semantic Versioning - -#### 12.1.1 Version Format -- **Pattern**: `vMAJOR.MINOR.PATCH[-SUFFIX.NUMBER]` -- **Major**: Aligned with .NET target framework (v10.x.x for .NET 10) -- **Minor**: New features, backwards compatible -- **Patch**: Bug fixes, backwards compatible -- **Suffix**: Prerelease identifier (`alpha`, `beta`, `rc`) - -#### 12.1.2 Examples -- `v10.0.0`: First stable release for .NET 10 -- `v10.1.0`: New feature (e.g., add prefix matching for ResourcePath) -- `v10.1.1`: Bug fix -- `v10.2.0-beta.1`: Prerelease for upcoming feature - -### 12.2 Git Workflow - -#### 12.2.1 Branch Strategy -- **main**: Protected, single source of truth -- **feature/[name]**: Temporary branches for development -- **No long-lived branches**: All features merge to main after approval - -#### 12.2.2 Pull Request Process -1. Create feature branch from main -2. Implement feature with tests and documentation -3. Run `dotnet format` -4. Create PR to main -5. Code review and approval -6. Merge and delete feature branch -7. **No automatic publishing** - -### 12.3 Publishing - -#### 12.3.1 Prerelease -1. Ensure main is up to date -2. Create annotated tag: `git tag -a v10.1.0-beta.1 -m "Beta release"` -3. Push tag: `git push origin v10.1.0-beta.1` -4. GitHub Action triggers automatically -5. Package published to NuGet.org with prerelease flag - -#### 12.3.2 Stable Release -1. Ensure main is up to date -2. Create annotated tag: `git tag -a v10.1.0 -m "Stable release"` -3. Push tag: `git push origin v10.1.0` -4. GitHub Action triggers automatically -5. Package published to NuGet.org as stable - -### 12.4 CI/CD Workflow - -#### 12.4.1 GitHub Action (`.github/workflows/publish.yml`) - -**Trigger**: Push of tag matching `v[0-9]+.[0-9]+.[0-9]+*` - -**Steps**: -1. Checkout repository -2. Setup .NET 10 SDK -3. Extract version from tag (remove `refs/tags/v`) -4. Build and pack all three projects: - - `dotnet pack RecurringThings -c Release /p:Version={version}` - - `dotnet pack RecurringThings.MongoDB -c Release /p:Version={version}` - - `dotnet pack RecurringThings.PostgreSQL -c Release /p:Version={version}` -5. Push packages to NuGet.org using `NUGET_API_KEY` secret -6. Use `--skip-duplicate` to prevent errors on re-runs - -#### 12.4.2 Project Configuration - -**Required in all .csproj files**: -```xml - - RecurringThings - YourName - Package description - https://github.com/ChuckNovice/RecurringThings - Apache-2.0 - net10.0 - - -``` - -### 12.5 Unified Versioning -- All three packages (RecurringThings, RecurringThings.MongoDB, RecurringThings.PostgreSQL) use identical version numbers -- Single tag triggers publishing of all three packages -- Example: `v10.1.0` publishes all three as `10.1.0` - ---- - -## 13. Out of Scope for v1.0 - -### 13.1 Explicitly Excluded Features - -#### 13.1.1 Query Features -- Prefix/wildcard matching on ResourcePath -- Full-text search on Extensions -- Pagination on GetAsync results -- Sorting options - -#### 13.1.2 Recurrence Features -- RRule with COUNT (only UNTIL supported) -- Recurrence templates/presets -- Bulk operations (create/update/delete multiple) -- Optimistic concurrency (removed from scope) - -#### 13.1.3 UI/Client Features -- REST API layer -- GraphQL API -- Client SDKs -- Admin dashboard - -#### 13.1.4 Advanced Persistence -- Elasticsearch integration -- Redis caching layer -- Event sourcing -- Change data capture - -#### 13.1.5 Operational Features -- Metrics/telemetry -- Health checks -- Rate limiting -- Audit logging (beyond user-defined Extensions) - -### 13.2 Potential Future Enhancements (v2.0+) -- Additional database providers (SQL Server, SQLite) -- Advanced querying (GraphQL, OData) -- Performance benchmarking suite -- Caching layer for frequently accessed recurrences -- Batch operations API -- Optimistic concurrency support - ---- - -## 14. Design Decisions - -This section documents intentional design decisions that are final and should not be revisited. - -### 14.1 No Conflict Detection -Two recurrences can create overlapping occurrences. Two users can simultaneously override the same occurrence to different times. This library does not implement optimistic concurrency or conflict detection. Consumers must implement their own conflict detection if needed. - -### 14.2 No Query Result Limits -`GetAsync()` does not enforce a maximum number of results. If a consumer queries a 10-year range with a daily recurrence, they will receive 3,650+ virtualized occurrences streamed via `IAsyncEnumerable`. It is the consumer's responsibility to validate query ranges at their API boundaries to prevent excessive queries. - -### 14.3 No Distributed Lock for Index Creation -MongoDB auto-migration creates indexes on first operation if missing. In multi-replica deployments, this may result in concurrent index creation attempts. MongoDB handles this gracefully with duplicate key errors that are safely ignored. No distributed locking is implemented. - -### 14.4 No "Now" Timestamps -This library never uses the current system time. All DateTime values are provided by the user and are deterministic. Clock skew between distributed systems is not a concern because timestamps always originate from user input (e.g., form submissions). - -### 14.5 No Protection Against Direct Database Modification -The library stores `RecurrenceEndTime` as a denormalized copy of the RRule UNTIL value. These must stay in sync. If someone directly modifies the database and desyncs these values, behavior is undefined. The library is not designed to protect against manual database corruption. - -### 14.6 No Restoring Excepted Occurrences -Once an occurrence is deleted (an exception is created), it cannot be restored through the API. Excepted occurrences are not returned by `GetAsync()`, so there is no `CalendarEntry` to pass to `RestoreAsync()`. This is intentional—deletion is permanent for recurrence occurrences. - -### 14.7 Database-Specific Cascade Delete Implementation -MongoDB uses application-level cascade deletes within a transaction to ensure atomicity. PostgreSQL uses database-level `ON DELETE CASCADE` foreign key constraints and skips explicit cascade delete calls. Each database implementation handles this appropriately. - ---- - -## 15. Success Criteria - -### 15.1 Functional Success -- ✅ All core operations work correctly (create, update, delete, restore, query) -- ✅ Virtualization generates correct occurrences with exceptions and overrides -- ✅ Time zones and DST handled correctly -- ✅ Multi-tenancy isolation verified -- ✅ Transactions work correctly with Transactional library -- ✅ Immutability rules enforced -- ✅ RRule UNTIL validation working (UTC requirement enforced) -- ✅ EndTime correctly computed and recomputed on updates - -### 15.2 Quality Success -- ✅ 90%+ unit test coverage -- ✅ All integration tests pass -- ✅ Zero code formatting violations -- ✅ All XML documentation complete -- ✅ README comprehensive and accurate - -### 15.3 Performance Success -- ✅ GetAsync streams results efficiently via IAsyncEnumerable -- ✅ MongoDB queries use appropriate indexes -- ✅ PostgreSQL queries use appropriate indexes -- ✅ No N+1 query issues -- ✅ Parallel query execution for independent data fetches - -### 15.4 Developer Experience Success -- ✅ Simple DI registration (single `AddRecurringThings()` call) -- ✅ Intuitive API (no repository exposure, unified CalendarEntry abstraction) -- ✅ Clear error messages for illegal operations -- ✅ Comprehensive examples in README - ---- - -## 16. Appendices - -### 16.1 State Transition Reference Table - -| Operation | Entry Type | Override | Result | -|-----------|------------|----------|--------| -| **Update** | Recurrence | - | Update Duration/Extensions only | -| | Standalone | - | Update StartTime/Duration/Extensions, recompute EndTime | -| | Recurrence Occ | No | Create override (denormalize originals) | -| | Recurrence Occ | Yes | Update override, recompute EndTime | -| **Delete** | Recurrence | - | Delete series + all exceptions/overrides (in transaction for MongoDB) | -| | Standalone | - | Direct delete | -| | Recurrence Occ | No | Create exception | -| | Recurrence Occ | Yes | Delete override, create exception | -| **Restore** | Recurrence | - | **Error** | -| | Standalone | - | **Error** | -| | Recurrence Occ | No | **Error** | -| | Recurrence Occ | Yes | Delete override | - -### 16.2 Repository Interface Signatures - -```csharp -public interface IRecurrenceRepository -{ - Task CreateAsync( - Recurrence recurrence, - ITransactionContext? context); - - Task UpdateAsync( - Recurrence recurrence, - ITransactionContext? context); - - Task DeleteAsync( - Guid id, - string organization, - string resourcePath, - ITransactionContext? context); - - IAsyncEnumerable GetInRangeAsync( - string organization, - string resourcePath, - DateTime startUtc, - DateTime endUtc, - string[]? types, - ITransactionContext? context, - [EnumeratorCancellation] CancellationToken cancellationToken = default); -} - -// Similar interfaces for IOccurrenceRepository, -// IOccurrenceExceptionRepository, IOccurrenceOverrideRepository -``` - -### 16.3 MongoDB Query Example - -Two parallel queries: - -**Query 1: Recurrences and Occurrences** -```javascript -db.recurring_things.find({ - organization: "tenant1", - resourcePath: "user123/calendar", - documentType: { $in: ["recurrence", "occurrence"] }, - type: { $in: ["appointment", "meeting"] }, - $or: [ - { - documentType: "occurrence", - startTime: { $lte: ISODate("2025-01-31") }, - endTime: { $gte: ISODate("2025-01-01") } - }, - { - documentType: "recurrence", - startTime: { $lte: ISODate("2025-01-31") }, - recurrenceEndTime: { $gte: ISODate("2025-01-01") } - } - ] -}) -``` - -**Query 2: Exceptions and Overrides** -```javascript -db.recurring_things.find({ - organization: "tenant1", - resourcePath: "user123/calendar", - documentType: { $in: ["exception", "override"] }, - $or: [ - { originalTimeUtc: { $gte: ISODate("2025-01-01"), $lte: ISODate("2025-01-31") } }, - { startTime: { $lte: ISODate("2025-01-31") }, endTime: { $gte: ISODate("2025-01-01") } } - ] -}) -``` - -### 16.4 PostgreSQL Query Example - -```sql --- Query 1: Get recurrences -SELECT * FROM recurrences -WHERE organization = 'tenant1' - AND resource_path = 'user123/calendar' - AND type IN ('appointment', 'meeting') - AND start_time <= '2025-01-31' - AND recurrence_end_time >= '2025-01-01'; - --- Query 2: Get standalone occurrences -SELECT * FROM occurrences -WHERE organization = 'tenant1' - AND resource_path = 'user123/calendar' - AND type IN ('appointment', 'meeting') - AND start_time <= '2025-01-31' - AND end_time >= '2025-01-01'; - --- Query 3: Get exceptions -SELECT * FROM occurrence_exceptions -WHERE organization = 'tenant1' - AND resource_path = 'user123/calendar' - AND original_time_utc BETWEEN '2025-01-01' AND '2025-01-31'; - --- Query 4: Get overrides (by original time OR new time range) -SELECT * FROM occurrence_overrides -WHERE organization = 'tenant1' - AND resource_path = 'user123/calendar' - AND (original_time_utc BETWEEN '2025-01-01' AND '2025-01-31' - OR (start_time <= '2025-01-31' AND end_time >= '2025-01-01')); -``` \ No newline at end of file diff --git a/assets/logo_blue.png b/assets/logo_blue.png new file mode 100644 index 0000000000000000000000000000000000000000..b07746a0cd157039cece760fac10dc6cbcdc6f70 GIT binary patch literal 2653 zcmYLLc{tR27yr&;#t1{jSi;;WTegI;&TuV5mn5#baV>eTElf;y<~K+r`&G#lqI6Z3 z>ngjsxMbh5WNW18TF1T(?%d~j-{*bKbI#}ae9k$~dCp(wB-+|o2=Gbp0RRLn$)aWOPh)Fi0LoL3aol*g9DjpsXAMC3S#DDt0DD{s z`Ub#lbpYmF0nmE{0FJ__xop5qpxiH7n1Z8!Ew8>PldE7Tab9LD_to3tJzL8HCAm1>7%9A+`KAyuc z@%9IfDi%I|^C%Ckq+p4`2?D!3JCP3D4DLKOO`MpPp~67pd6b4)cJ3e{-eRZDwsQa;6tJ+g^U-a;-vdS3;0ya;;zFMhOtq{$Q=~`0GzTiXv z@7ImL7K=580`>REyuecK2o0!G9Johf;AU`$41_S?G>jw%67Hp=M_q0m|E__C2)=+mREVW&+$!OLaU>gnmwU75B-4$$>6uYAn4C^R` z>cAIA=HKgr^lR?WL9<-4Tdvya$Mq?#MKn_mOF0df7x@*--ZKYn78iLSolVg$76mb_ zllv@eMy)K_0g0m>qfvb#2JwJe{o1Ui2AbU@$~s5$Yew@Nu3CB;(m|R0T_&q4kv&XI zH+XVKcOBNnuF5n-!SGo0tpu-GVwrD}`iYfTD?>PG74LPrZa0K^zGEeN(dh~v{E@F) zmaNs1>Q67Wm=?$t1^3W&v$aqZ`Shb)1}dxp*b-3B^ggeYd_NS=xW|Kpw3VU_1Z+$m zl`B?dCSIHp{xqggq*VO&0(X~-GLfL3)yit|86UN z-0=zdggy^cyp>Pg-CoMB>fnE?NbtcshbMen_GG~Lh0zwYuerBJXuKka-uE`bxF~q?IH#U!d)vQ7_W5lU0Pz{P|#Lt4(mb> z8dCJc>#B?5!Qx_*A?W$+^V# zp>X9!elP6JnThf^K!LP!3sBDjh`md1fBTTRRKz%I!^m2!{^ezN$ylm2XQvziglhKH z=xRafbSoJGebOVe`STmo-$A_G!CSi-n(x%Wm-~C&tCpeiQ{5Q?U1U+2n3;G@qOl-+ zENG}-y}4*Q)n&@S;ZCMd`^5b#k`@>c;Id@gdY86#ysp3f4dOy}Mfd6XS2hO{FqR5G z_Dz2H9xK&_uA^R)mF|)BrTyIJU40vV*f9{{wcv0`h7&RsD=juz9vbJ?d^;}WaG`~j z3d+zk=s9AXZg6l=$*0cW7nHrCcOBWSn)0-#bB{H&w^yPH2(Pl{dS@PFyXhof9CvYX zF(x8I4(vvx4M1B{-;BqYj5_2{5Hjq?`M|3dgeZ}+?EJ7OUCrnZY4tJsPsPONbynn zdS89)F1(3gLfw{7ApB}fvI3NN*i5tJla9Y@#J4^g3_Q0`Z|gDM6yyV{VX%Nu_#T_m z;O9CZ*1w2@hPkQtbYUBfJgu1DQc1>V9M8FqhzdvT>()(ULJ!(_Kv6Wcnoo0WkP!jX z$f(vYlT>EQ(=wMZa-b?xl5O5&{B`0(_6+K?g-h%YeQ+xb=;5&Ku$)BfniQLP|I2>PdPO)nI2Ot=y9jEEsR<`*4QX=kN0r&Bw+&Rj08E$J4b?*9l5T= zq=aSX6W~3Px931_&TOk6QMYr?4r_HqrgGW@Jen3rmCbG(EWg+)I83b0IO97Q)tVY0 zo1N26lgLKW{X6u-;ircGw3GwX+)&@5@=0meG?#eYsOJTjPCl%pM`%RZThwfX?q_H| zn~WVNbZMvhzs7*c)7NP!?5#$o&V->ZuR=>UHmn8G1|H^YO3 z|MUNyW_y0WT%ji7j>p}fgTz*j4k`kILfH#b<6kQ#?f`YMiL0?%bo-={1W8|(YQAeH zqEN9Z`;uNb%D3(P3)16YallIXnTgN9+x~&wlL>#JP3r1_5ZwO_Sen_GmYBH4`~#J| Bsm%ZY literal 0 HcmV?d00001 diff --git a/assets/logo_gray.png b/assets/logo_gray.png new file mode 100644 index 0000000000000000000000000000000000000000..34979f068ef063f3ee33b5ba63aae1327cefe6d0 GIT binary patch literal 2283 zcmY*b3pmqXA3wj*%q*!WA*Rf&Ed2Y+)>^aN%cYUDkZW&Ykqs$ohRL5wxpYD9w`BfH zE0mhtDzA{m#?fvz<@AJIR`<&;T?>V3IJ?DI$bH3+uPKv9u-DV&kAG%9Fl>{GUq85yo48xpz4yL2^L#gR}$qdG|qG`_q(MZ`M^PrZHDZ) zm+n|Pi8q)xVRQ0b6@1gK&KUGVhCdV|!P$lN(?E4uem6YT%>zg(sv*aL3DD;`o7v3E zETU7BOOc%G9PE?J3^d=Q&b5l(aZ;Jm|`o6A76GuKg3XfiHd55cwu=_s%cqHF>7A=L)kzRbtzt@Fi)Sh~n+gfZCvW||T?yuv9b{9}EV_sK)(@dLAfXA2&Hu0gbGtCRWZM%d z4*qU zu;5N)9|e#Qi7x^UTVUc5ay@O7KpaN`{ zI6Nwf`-SyaLV}#ht`;m%9dE#s&o85la6XM^OT70BTH$U?H@U3~{HM_{%Y~nhJ2;2o)29HOEEYx_NY9+qVG zh;qj)`JvJ&DYs)gv?%g4XBgo)ShlD^9fPc^tAi^q8C>v5 z9Fx`_fWV#_iryub-ft#cd21C|dnES99CUfiP_R3{Yg9@O9c&9UkD13)2tplobP{4} zvBBJ{V|*PPv7vpD2;W1yI82z*I3rA}J$_c&)2}4YKCa?!tX3*aib+zTrp^PzNDa@I z$#kpF8;?-z-KhvRN7ic3@fS#Ia+xNdSE1Ou8=!v~^hx5vUl}QM3p8C>%t zcvMyNi4I;Ps$*bwPM(l~g~P+z!GeM6dYM?v z+`d7n@%%>gMDvo=3!l8Q5~rHldcxW~*XHq)CvQ&;S|UbnN9A`O=RbOs>d!~@?hIRz zM%1_T)f^M3FX4dp+U4Tnt)XFIAC7$9lhawm7`s3Hilx-H&w%5ee5Euq8hO|;r9YJG zl21OV89M`>Pl(TmKX*3jrqezyEUe3Owo~MYL?Q45Td#LOKfEO$W+kViqL6)&f?<5N zXcCOx$&pFTF>>e5oqK+~)-hl6tx{tA?XC$}?!vW_zoXxdW>xox8drb6O7q{TQP?N4 z2^u5n$EL^wU}0h5x_v{|0KAk`G8?XCmlJtGUZ}H)8-KQu_xSq-V&2E4C5?CH5mTRZNCx#yXkG&VNo>3fY< zO0{Lg!M2xAEn^C16@VW6>On4IeGh+5qfzQH}E`rkYD{|vFP}~Sn{`UmQgA{;W z3;zFI`Y%|r=;ppwue8OV-yKzxQ~t)E@0l80a1_W}v1@B<1^sf-S@iPGqOd(*c_JFi dNCAs;kO8tduze2S|NA!q9BiDec~oD{zX37e4=n%y literal 0 HcmV?d00001 diff --git a/assets/logo_green.png b/assets/logo_green.png new file mode 100644 index 0000000000000000000000000000000000000000..ac20874c6221d279c772c391ab3cb8969db2e848 GIT binary patch literal 2529 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 literal 0 HcmV?d00001 diff --git a/assets/logo_purple.png b/assets/logo_purple.png new file mode 100644 index 0000000000000000000000000000000000000000..4786f2346ad969fba396a985ecc5600bf47a5e70 GIT binary patch literal 2696 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 Ay#N3J literal 0 HcmV?d00001 diff --git a/assets/logo_yellow.png b/assets/logo_yellow.png new file mode 100644 index 0000000000000000000000000000000000000000..e802b7af2c3bdce10d4caddb6e688361ce53c627 GIT binary patch literal 2563 zcmV+e3jFnnP)z@;j|==^1poj5AY({UO#lFTCIA3{ga82g0001h=l}q9FaQARU;qF* zm;eA5aGbhPJOBUy24YJ`L;(K){{a7>y{D4^000SaNLh0L01m?d01m?e$8V@)00007 zbV*G`2kHSE7dZkrkTMAX00~D)L_t(|+U=eBZxlxs$3IowbNB*$5MvV)du=c#fsjK< zR@p>~l$BQ6zjuFHX%9t<6ek;p93&y!Awa+wV|zS3GhOxehqjGvju?8(PEEfr8jEI{ zo^iiVz4y9~SLUyuMuMuavU&@Ot$6b<#z3_KQDpYzgG3-ryTjRdfmW-_04SH>lMiEt zhxWlK2-Fjyo^;1ed$25gzL4Tv7Xe@`96Vqc88TZfcDr}jeZ0GMKkL1EB3Nta>)rcx zcB_-l_3rWi7d!yJcVnLQ3qR|cYT-P%K#i{< z;P;JR6I!X>z`_CGJ*?G)RE=Le0G!L*z8b%PIe;3!z#brVSb>!{Us-n4O7&=_(GYE4W;O!9(V;%BAqAn(1CDe{P=ZJtPfS-&p^U zw^z5-g1d{tYp)vS7eKrM&{iEfX!!J_61E+AmSoG`>WOgsuJHADj--(f0WYw4-!nB4 zGdyTmye}O;*D@#ojLB*m+jjWZjcb)khJk*=!2$T}bH{o;pUGC%Aa%mnsAX}g#RdSxUV?aSMnx4yB2zP~SwA!7w#^F~-WdE8R1J6bZifaYn zu>_8MwGa*TR1D%J2m%y+zC? zF#na~w|n{H|3IIi6dPJ;80B??0-)K1-xfW#EyHX=qY3x!3sZ+eqT-AFfCc{QyA$AOfy~@9Oxd^xdbkS#Roo!+5rkc8)OU|J8Bu|&tlWBcYH8At0(~NX#H`v7Qou9 z_wVPcKA6fO3P2l-e`YS?@NhPhXn93=upCaoWd$H-)_-C;V(xgfwbLYArT{!g zrXSgCdD@$&BF4w;)|@{Pxbds!+6^CE**FDYkH#N7Xn1`#V*H4qT6xqJDUz)QTKLul zx!HySum|IR@J`J1WY*$$4?{d$75;rL<>6|W6(hr29BSD6SinXI$yvzi0$Z~ovUvVJ;F8t(JSryK{5h(yc{h}6T zrYs{v`C4wH3Fj|3dV36KUyp+>6<;U-Alvq3b|!zzy}RhSb;mP3nQbkj00fi}PEA-2 z59Lb;(-gk@(Ggk0^kk$PMuem&f` zymH;MyaIoGH>Ofi0D=kyGgFq{-h9_d8p6dZj!M~Ze9{I{5=H^o<^|q49p|eDByjB) z&z*aolgA^f)$sZT3JZWS@Y<`EiE*2|F5vuv+ZZ;E6oAkmm^+cbI%sWOxOCMsHflL` z)G7cW0WdOXb5{rT9)7y)X*Pw~X&Y2RSom4N*20-nmIFD5jMwYJ{DNcXkmcBTIH5qN zg_HFVo_#&aS0R*alE}^5o_F7j80b>~f(iv=qn6RbHg_nvc*WtoaQ2OOYs$C+5P-@h zIQvGFdo$?8CE@A~&(R}xYkM07;0Z16MPB>;orVVubH_6XUi3S9pN$sQ8^XJ1;@n|n zzxC3blr@H-L)mugFFKEI@Q~Se2xez2>vf^FE;+7a`1Q8u#B{`|*@*MsI|ZOzhWFlz z+hU~sR9~;*!}r>ATSQ>;Rm+v@p4BxeHUKZfc%Ak=FI~+hk~SKhJ9Su-zW^1wf?$s1yK|0-#a=R0@Df0Z=IbDg{8L0H_oI zl>(qr094(lM>B#1)@nkfyfKAo`_$+s9Gh*sWo*8;dCHC{P==kQy`OUK*7ctDO?SOv zo>DPfcAfjA^PY9?_J7*_)i)2;Y68+_0HhAS_}b+vnNk!v*FD9ecgzV)8lu+K96(hh Z{SS}FzyB#LD6jwk002ovPDHLkV1mYBq*?#~ literal 0 HcmV?d00001 From b839293496d5ee3ad62ce9a6883bfee6c37f0ecb Mon Sep 17 00:00:00 2001 From: Miguel Tremblay Date: Tue, 27 Jan 2026 20:16:57 -0500 Subject: [PATCH 3/3] Simplify provider README files - Remove usage examples (kept in main README) - Remove index documentation - Remove transaction samples - Apply code formatting Co-Authored-By: Claude Opus 4.5 --- src/RecurringThings.MongoDB/README.md | 141 ------------------ src/RecurringThings.PostgreSQL/README.md | 138 ----------------- .../Engine/RecurrenceEngineCrudTests.cs | 48 +++--- 3 files changed, 24 insertions(+), 303 deletions(-) diff --git a/src/RecurringThings.MongoDB/README.md b/src/RecurringThings.MongoDB/README.md index e74c92a..cad0729 100644 --- a/src/RecurringThings.MongoDB/README.md +++ b/src/RecurringThings.MongoDB/README.md @@ -26,140 +26,6 @@ services.AddRecurringThings(builder => })); ``` -## Indexes - -The provider automatically creates indexes for efficient querying: - -- `organization + resourcePath + documentType` -- `organization + resourcePath + startTime + recurrenceEndTime` (for recurrences) -- `organization + resourcePath + startTime + endTime` (for occurrences) -- `recurrenceId` (for exceptions and overrides) - -## Transactions - -Use `IMongoTransactionManager` from the [Transactional](https://github.com/ChuckNovice/transactional) library: - -```csharp -using Transactional.MongoDB; - -public class CalendarService(IRecurrenceEngine engine, IMongoTransactionManager transactionManager) -{ - public async Task CreateMultipleEntriesAsync() - { - await using var context = await transactionManager.BeginTransactionAsync(); - - try - { - await engine.CreateRecurrenceAsync(request1, context); - await engine.CreateOccurrenceAsync(request2, context); - await context.CommitAsync(); - } - catch - { - await context.RollbackAsync(); - throw; - } - } -} -``` - -**Note**: MongoDB transactions require a replica set. Standalone MongoDB instances do not support transactions. - -## Usage Examples - -### Basic Setup - -```csharp -using Ical.Net.DataTypes; - -public class CalendarService(IRecurrenceEngine engine) -{ - public async Task CreateWeeklyMeetingAsync() - { - // Build a recurrence pattern using Ical.Net - var pattern = new RecurrencePattern - { - Frequency = FrequencyType.Weekly, - Until = new CalDateTime(new DateTime(2025, 12, 31, 23, 59, 59, DateTimeKind.Local).ToUniversalTime()) - }; - pattern.ByDay.Add(new WeekDay(DayOfWeek.Monday)); - - // RecurrenceEndTime is automatically extracted from the RRule UNTIL clause - var recurrence = await engine.CreateRecurrenceAsync( - organization: "tenant1", - resourcePath: "user123/calendar", - type: "meeting", - startTime: DateTime.Now, - duration: TimeSpan.FromHours(1), - rrule: pattern.ToString(), - timeZone: "America/New_York", - extensions: new Dictionary - { - ["title"] = "Weekly Team Standup", - ["location"] = "Conference Room A" - }); - } - - public async Task GetJanuaryEntriesAsync() - { - var start = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc); - var end = new DateTime(2025, 1, 31, 23, 59, 59, DateTimeKind.Utc); - - await foreach (var entry in engine.GetOccurrencesAsync("tenant1", "user123/calendar", start, end, null)) - { - Console.WriteLine($"{entry.Type}: {entry.StartTime} - {entry.EndTime} ({entry.EntryType})"); - } - } -} -``` - -### Querying with Type Filter - -```csharp -// Get only appointments and meetings -await foreach (var entry in engine.GetOccurrencesAsync( - "tenant1", "user123/calendar", start, end, - types: ["appointment", "meeting"])) -{ - // Process filtered entries -} -``` - -### Updating Entries - -```csharp -// Update a standalone occurrence -var entries = await engine.GetOccurrencesAsync(org, path, start, end, null).ToListAsync(); -var entry = entries.First(e => e.EntryType == CalendarEntryType.Standalone); - -entry.StartTime = entry.StartTime.AddHours(1); -entry.Duration = TimeSpan.FromMinutes(45); -var updated = await engine.UpdateOccurrenceAsync(entry); -// EndTime is automatically recomputed - -// Update a virtualized occurrence (creates an override) -var virtualizedEntry = entries.First(e => e.EntryType == CalendarEntryType.Virtualized); -virtualizedEntry.Duration = TimeSpan.FromMinutes(45); -var overridden = await engine.UpdateOccurrenceAsync(virtualizedEntry); -// Original values preserved in entry.Original -``` - -### Deleting Entries - -```csharp -// Delete entire recurrence series (cascade deletes exceptions/overrides) -await engine.DeleteRecurrenceAsync(org, path, recurrenceId); - -// Delete a virtualized occurrence (creates an exception) -await engine.DeleteOccurrenceAsync(virtualizedEntry); - -// Restore an overridden occurrence to original state -if (entry.IsOverridden) -{ - await engine.RestoreAsync(entry); -} -``` - ## Integration Tests Set the environment variable before running integration tests: @@ -168,10 +34,3 @@ Set the environment variable before running integration tests: export MONGODB_CONNECTION_STRING="mongodb://localhost:27017" dotnet test --filter 'Category=Integration' ``` - -## Limitations - -- MongoDB transactions require replica set (not available on standalone instances) -- DateTime values can be UTC or Local (`DateTimeKind.Unspecified` is not allowed) -- RRule must use UNTIL (COUNT is not supported) -- UNTIL must have UTC suffix (Z) diff --git a/src/RecurringThings.PostgreSQL/README.md b/src/RecurringThings.PostgreSQL/README.md index 05748f5..e1ab172 100644 --- a/src/RecurringThings.PostgreSQL/README.md +++ b/src/RecurringThings.PostgreSQL/README.md @@ -39,137 +39,6 @@ builder.UsePostgreSql(options => }); ``` -## Indexes - -The provider creates indexes for efficient querying: - -- `(organization, resource_path, start_time, recurrence_end_time)` on recurrences -- `(organization, resource_path, start_time, end_time)` on occurrences -- `(recurrence_id)` on exceptions and overrides - -## Transactions - -Use `IPostgresTransactionManager` from the [Transactional](https://github.com/ChuckNovice/transactional) library: - -```csharp -using Transactional.PostgreSQL; - -public class CalendarService(IRecurrenceEngine engine, IPostgresTransactionManager transactionManager) -{ - public async Task CreateMultipleEntriesAsync() - { - await using var context = await transactionManager.BeginTransactionAsync(); - - try - { - await engine.CreateRecurrenceAsync(request1, context); - await engine.CreateOccurrenceAsync(request2, context); - await context.CommitAsync(); - } - catch - { - await context.RollbackAsync(); - throw; - } - } -} -``` - -## Usage Examples - -### Basic Setup - -```csharp -using Ical.Net.DataTypes; - -public class CalendarService(IRecurrenceEngine engine) -{ - public async Task CreateWeeklyMeetingAsync() - { - // Build a recurrence pattern using Ical.Net - var pattern = new RecurrencePattern - { - Frequency = FrequencyType.Weekly, - Until = new CalDateTime(new DateTime(2025, 12, 31, 23, 59, 59, DateTimeKind.Local).ToUniversalTime()) - }; - pattern.ByDay.Add(new WeekDay(DayOfWeek.Monday)); - - // RecurrenceEndTime is automatically extracted from the RRule UNTIL clause - var recurrence = await engine.CreateRecurrenceAsync( - organization: "tenant1", - resourcePath: "user123/calendar", - type: "meeting", - startTime: DateTime.Now, - duration: TimeSpan.FromHours(1), - rrule: pattern.ToString(), - timeZone: "America/New_York", - extensions: new Dictionary - { - ["title"] = "Weekly Team Standup", - ["location"] = "Conference Room A" - }); - } - - public async Task GetJanuaryEntriesAsync() - { - var start = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc); - var end = new DateTime(2025, 1, 31, 23, 59, 59, DateTimeKind.Utc); - - await foreach (var entry in engine.GetOccurrencesAsync("tenant1", "user123/calendar", start, end, null)) - { - Console.WriteLine($"{entry.Type}: {entry.StartTime} - {entry.EndTime} ({entry.EntryType})"); - } - } -} -``` - -### Querying with Type Filter - -```csharp -// Get only appointments and meetings -await foreach (var entry in engine.GetOccurrencesAsync( - "tenant1", "user123/calendar", start, end, - types: ["appointment", "meeting"])) -{ - // Process filtered entries -} -``` - -### Updating Entries - -```csharp -// Update a standalone occurrence -var entries = await engine.GetOccurrencesAsync(org, path, start, end, null).ToListAsync(); -var entry = entries.First(e => e.EntryType == CalendarEntryType.Standalone); - -entry.StartTime = entry.StartTime.AddHours(1); -entry.Duration = TimeSpan.FromMinutes(45); -var updated = await engine.UpdateOccurrenceAsync(entry); -// EndTime is automatically recomputed - -// Update a virtualized occurrence (creates an override) -var virtualizedEntry = entries.First(e => e.EntryType == CalendarEntryType.Virtualized); -virtualizedEntry.Duration = TimeSpan.FromMinutes(45); -var overridden = await engine.UpdateOccurrenceAsync(virtualizedEntry); -// Original values preserved in entry.Original -``` - -### Deleting Entries - -```csharp -// Delete entire recurrence series (cascade deletes exceptions/overrides) -await engine.DeleteRecurrenceAsync(org, path, recurrenceId); - -// Delete a virtualized occurrence (creates an exception) -await engine.DeleteOccurrenceAsync(virtualizedEntry); - -// Restore an overridden occurrence to original state -if (entry.IsOverridden) -{ - await engine.RestoreAsync(entry); -} -``` - ## Integration Tests Set the environment variable before running integration tests: @@ -178,10 +47,3 @@ Set the environment variable before running integration tests: export POSTGRES_CONNECTION_STRING="Host=localhost;Database=test;Username=user;Password=pass" dotnet test --filter 'Category=Integration' ``` - -## Limitations - -- Database must exist before running the application (schema is auto-created) -- DateTime values can be UTC or Local (`DateTimeKind.Unspecified` is not allowed) -- RRule must use UNTIL (COUNT is not supported) -- UNTIL must have UTC suffix (Z) diff --git a/tests/RecurringThings.Tests/Engine/RecurrenceEngineCrudTests.cs b/tests/RecurringThings.Tests/Engine/RecurrenceEngineCrudTests.cs index c36e4bb..dc36794 100644 --- a/tests/RecurringThings.Tests/Engine/RecurrenceEngineCrudTests.cs +++ b/tests/RecurringThings.Tests/Engine/RecurrenceEngineCrudTests.cs @@ -401,10 +401,10 @@ public async Task UpdateAsync_VirtualizedOccurrenceWithoutOverride_CreatesOverri RecurrenceId = recurrenceId, EntryType = CalendarEntryType.Virtualized, Original = new OriginalDetails - { - StartTime = originalStartTime, - Duration = recurrence.Duration, - Extensions = recurrence.Extensions + { + StartTime = originalStartTime, + Duration = recurrence.Duration, + Extensions = recurrence.Extensions } }; @@ -454,10 +454,10 @@ public async Task UpdateAsync_VirtualizedOccurrenceWithOverride_UpdatesExistingO OverrideId = overrideId, EntryType = CalendarEntryType.Virtualized, Original = new OriginalDetails - { - StartTime = existingOverride.OriginalTimeUtc, - Duration = existingOverride.OriginalDuration, - Extensions = existingOverride.OriginalExtensions + { + StartTime = existingOverride.OriginalTimeUtc, + Duration = existingOverride.OriginalDuration, + Extensions = existingOverride.OriginalExtensions } }; @@ -578,10 +578,10 @@ public async Task DeleteAsync_VirtualizedOccurrenceWithoutOverride_CreatesExcept RecurrenceId = recurrenceId, EntryType = CalendarEntryType.Virtualized, Original = new OriginalDetails - { - StartTime = originalTime, - Duration = TimeSpan.FromHours(1), - Extensions = null + { + StartTime = originalTime, + Duration = TimeSpan.FromHours(1), + Extensions = null } }; @@ -619,10 +619,10 @@ public async Task DeleteAsync_VirtualizedOccurrenceWithOverride_DeletesOverrideA OverrideId = overrideId, EntryType = CalendarEntryType.Virtualized, Original = new OriginalDetails - { - StartTime = originalTime, // Original time - Duration = TimeSpan.FromHours(1), - Extensions = null + { + StartTime = originalTime, // Original time + Duration = TimeSpan.FromHours(1), + Extensions = null } }; @@ -668,10 +668,10 @@ public async Task RestoreAsync_OverriddenOccurrence_DeletesOverride() OverrideId = overrideId, EntryType = CalendarEntryType.Virtualized, Original = new OriginalDetails - { - StartTime = originalTime, - Duration = TimeSpan.FromHours(1), - Extensions = null + { + StartTime = originalTime, + Duration = TimeSpan.FromHours(1), + Extensions = null } }; @@ -744,10 +744,10 @@ public async Task RestoreAsync_VirtualizedOccurrenceWithoutOverride_ThrowsInvali // No OverrideId EntryType = CalendarEntryType.Virtualized, Original = new OriginalDetails - { - StartTime = DateTime.UtcNow, - Duration = TimeSpan.FromHours(1), - Extensions = null + { + StartTime = DateTime.UtcNow, + Duration = TimeSpan.FromHours(1), + Extensions = null } };