using ErsatzTV.Core; namespace ErsatzTV.Application.Scheduling; /// /// Validates the three recurrence sets shared by ProgramScheduleAlternate and /// PlayoutTemplate (ersatztv#880). One validator called from BOTH replace handlers, mirroring /// FFmpegProfileBounds — the exemplar for `api.ffmpeg-profile-numeric-bounds`, whose shape this /// follows deliberately. /// /// /// /// An EMPTY set is rejected because the three are read CONJUNCTIVELY by /// AlternateScheduleSelector.GetScheduleForDate — a miss on any one continues to the next /// item — so an empty one matches NO date and stores an item that can never apply. Rejecting /// rather than substituting is the point: accept-then-rewrite would make an explicit `[]` /// indistinguishable from an omitted field, which is the very collapse this issue removed. /// /// /// An UNCHANGED empty set that the row ALREADY holds is let through. Both PUT paths are /// whole-list replaces, so a hard rejection would make every OTHER item in the playout /// uneditable over a row the operator never touched — the same reason /// `api.ffmpeg-profile-numeric-bounds` rejects only a NEWLY submitted out-of-range value. A row /// whose stored set is NULL is NOT exempt: null means unrestricted, so submitting `[]` for it is /// a new emptying, not an unchanged legacy value. /// /// /// This runs on the COMMAND, after the request records have normalized an ABSENT array to the /// All*() sets, so an empty set reaching here is one a caller sent EXPLICITLY. That also means a /// direct (non-HTTP) caller is held to the same rule rather than being able to write a dead row. /// /// public static class RecurrenceSetBounds { public static Option Validate( ICollection daysOfWeek, ICollection daysOfMonth, ICollection monthsOfYear, ICollection storedDaysOfWeek, ICollection storedDaysOfMonth, ICollection storedMonthsOfYear) { if (IsNewlyEmpty(daysOfWeek, storedDaysOfWeek)) { return Some(BaseError.New(Message("DaysOfWeek", "no day of the week"))); } if (IsNewlyEmpty(daysOfMonth, storedDaysOfMonth)) { return Some(BaseError.New(Message("DaysOfMonth", "no day of the month"))); } if (IsNewlyEmpty(monthsOfYear, storedMonthsOfYear)) { return Some(BaseError.New(Message("MonthsOfYear", "no month"))); } return Option.None; } // "send null" rather than "omit the property": all three are listed in the schema's `required` array // in v1.json (they are nullable, not optional), so a client generated from the published contract // cannot omit them. Omitting also works at runtime -- Newtonsoft maps a missing property and an // explicit null to the same thing -- but naming only that would tell a conforming client to send // something its own schema forbids. private static string Message(string field, string consequence) => $"[{field}] must not be empty; an empty set matches {consequence}, so the item would never apply. " + "Send null to leave it unrestricted"; // A new item (no stored row) has `stored` null, so an empty set is newly empty and is rejected. // Only a stored set that is ITSELF already empty exempts an empty submission. private static bool IsNewlyEmpty(ICollection submitted, ICollection stored) => submitted is { Count: 0 } && stored is not { Count: 0 }; }