diff --git a/src/FluentCMS.Core/Builtin/Checks/AltTextCheckType.cs b/src/FluentCMS.Core/Builtin/Checks/AltTextCheckType.cs index ba8565503..cf5139c00 100644 --- a/src/FluentCMS.Core/Builtin/Checks/AltTextCheckType.cs +++ b/src/FluentCMS.Core/Builtin/Checks/AltTextCheckType.cs @@ -1,12 +1,18 @@ using System.Text.Json; using System.Text.RegularExpressions; +using FluentCMS.Core.ChangeSets; using FluentCMS.Core.Checks; +using FluentCMS.Core.Media; using FluentCMS.Core.Results; +using Microsoft.Extensions.DependencyInjection; namespace FluentCMS.Core.Builtin.Checks; // Every image in the text has alt text that describes it (CHK-01): Markdown images, inline or by reference, and HTML img elements. -// Alt text of assets in the media library is checked there (F-MED). +// A media field that names an image passes when the version readers will see has alt text (MED-02): the live one once the content's +// change set publishes what it proposes, so a person's later write in the library counts too. An image only another set proposes +// fails, since nothing checks what goes live with it later. Other media, such as a PDF, needs no alt text. An asset the library does +// not hold is left to the reference checks of entries. internal sealed class AltTextCheckType : ICheckType { private const RegexOptions Linear = RegexOptions.NonBacktracking | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase; @@ -29,7 +35,7 @@ public IReadOnlyList ValidateSettings(JsonElement settings) return new CheckSettingsReader(Name, settings).Errors(); } - public Task EvaluateAsync(CheckInput input, CancellationToken cancellationToken) + public async Task EvaluateAsync(CheckInput input, CancellationToken cancellationToken) { var findings = new List(); @@ -46,7 +52,70 @@ public Task EvaluateAsync(CheckInput input, CancellationToken canc image.Value))); } - return Task.FromResult(findings.Count == 0 ? CheckOutcome.Pass() : CheckOutcome.Fail(findings)); + findings.AddRange(await MediaFindingsAsync(input, cancellationToken)); + + return findings.Count == 0 ? CheckOutcome.Pass() : CheckOutcome.Fail(findings); + } + + private static async Task> MediaFindingsAsync(CheckInput input, CancellationToken cancellationToken) + { + var findings = new List(); + if (input.Subject.Definition is not { } definition || input.Services.GetService() is not { } library) + { + return findings; + } + + var proposals = (input.Subject.SetOperations ?? []) + .Where(operation => operation.ItemKind == ItemKind.Media) + .DistinctBy(operation => operation.ItemId) + .ToDictionary(operation => operation.ItemId, operation => operation.ProposedVersion); + foreach (var (pointer, value) in CheckContent.FieldValues(input.Subject)) + { + var field = definition.Fields.FirstOrDefault(candidate => candidate.Name == CheckContent.FieldOf(pointer)); + if (field is null + || !MediaFields.Holds(field) + || value.ValueKind != JsonValueKind.String + || !Guid.TryParse(value.GetString(), out var id) + || await library.GetAsync(id, cancellationToken) is not { IsSuccess: true } asset + || !asset.Value.Latest.Blob.ContentType.StartsWith("image/", StringComparison.Ordinal)) + { + continue; + } + + // A set that takes the asset offline leaves nothing to show with this content. + var held = proposals.TryGetValue(id, out var proposed); + if (held && proposed is null) + { + continue; + } + + var shown = (await library.LiveVersionAsync(id, proposed, cancellationToken)) is { IsSuccess: true } live ? live.Value : null; + if (shown is null) + { + // Only a change set proposes it: whichever version some other set publishes later, this content is not checked + // against it, so the image goes live here with no alt text a reader can rely on. + if (!held) + { + findings.Add(new CheckFinding( + $"The image '{asset.Value.Latest.Metadata.FileName}' in {field.Name} is not published; give it alt text with update_media " + + "in the change set this content goes into, so both go live together.", + pointer, + id.ToString())); + } + + continue; + } + + if (string.IsNullOrWhiteSpace(shown.Metadata.AltText)) + { + findings.Add(new CheckFinding( + $"Add alt text to the image '{shown.Metadata.FileName}' in {field.Name} that says what it shows; set it with update_media.", + pointer, + id.ToString())); + } + } + + return findings; } // The value of the element's alt attribute, read attribute by attribute so a quoted value or a data-alt is not taken for it. diff --git a/src/FluentCMS.Core/Builtin/Tools/CheckTools.cs b/src/FluentCMS.Core/Builtin/Tools/CheckTools.cs index a7aacfe11..828764faa 100644 --- a/src/FluentCMS.Core/Builtin/Tools/CheckTools.cs +++ b/src/FluentCMS.Core/Builtin/Tools/CheckTools.cs @@ -1,5 +1,6 @@ using System.Text.Json; using System.Text.Json.Nodes; +using FluentCMS.Core.ChangeSets; using FluentCMS.Core.Checks; using FluentCMS.Core.Content; using FluentCMS.Core.Results; @@ -39,7 +40,11 @@ public sealed class CheckTools : IToolProvider "exports/catalog-2026-10-18.csv", required: false), ToolSchemas.String("checks", "Which checks to run: \"all\", the default, or \"deterministic\".", "deterministic", required: false), - ToolSchemas.Uuid("id", "For a draft of an update: the entry it updates, so checks that ask about changes see only what differs.", required: false)), + ToolSchemas.Uuid("id", "For a draft of an update: the entry it updates, so checks that ask about changes see only what differs.", required: false), + ToolSchemas.Uuid( + "change_set_id", + "The change set the draft will go into, so checks read the media versions it proposes, such as alt text added with update_media.", + required: false)), // Not read-only: it stores nothing, but an LLM-judged check spends the agent's model budget. readOnly: false, @@ -63,6 +68,7 @@ private static async Task CheckDraftAsync(ToolInvocation call, Cance var sources = arguments.OptionalStrings("sources", "exports/catalog-2026-10-18.csv") ?? []; var checks = arguments.OptionalString("checks", "deterministic"); var id = arguments.OptionalGuid("id"); + var changeSetId = arguments.OptionalGuid("change_set_id"); var errors = arguments.Errors().ToList(); if (checks is not (null or "all" or "deterministic")) { @@ -81,6 +87,26 @@ private static async Task CheckDraftAsync(ToolInvocation call, Cance return ToolResult.Failure(type.Errors, type.Errors.Any(error => error.Code == SchemaErrorCodes.ContentTypeNotFound) ? FindContentTypes : null); } + // Checks read what the set proposes, so it must be a set the agent may read and one that can still go live. + IReadOnlyList? setOperations = null; + if (changeSetId is { } setId) + { + var set = await services.GetRequiredService().GetAsync(setId, cancellationToken); + if (!set.IsSuccess) + { + return ToolResult.Failure(set.Errors); + } + + if (set.Value.State is ChangeSetState.Published or ChangeSetState.Rejected or ChangeSetState.Reverted) + { + return ToolResult.Failure([new Error( + ToolErrorCodes.ArgumentInvalid, + $"Change set {setId} is {ToolJson.Name(set.Value.State)}, so nothing goes live with it any more; give the set the draft goes into.")]); + } + + setOperations = set.Value.Operations; + } + IReadOnlyList? changed = null; if (id is { } entryId) { @@ -103,7 +129,7 @@ [new Error(ToolErrorCodes.ArgumentInvalid, $"The entry {entryId} is of the conte var definition = type.Value.Definition; var refused = EntryFields.Validate(fields!.Value, definition, services.GetRequiredService()).Errors; var run = await services.GetRequiredService().RunAsync( - new CheckSubject(fields.Value, sources, slug, definition, changed), + new CheckSubject(fields.Value, sources, slug, definition, changed, setOperations), checks == "deterministic" ? CheckSelection.Deterministic : CheckSelection.All, cancellationToken: cancellationToken); diff --git a/src/FluentCMS.Core/Builtin/Tools/MediaTools.cs b/src/FluentCMS.Core/Builtin/Tools/MediaTools.cs index d19228a6f..8fba1842f 100644 --- a/src/FluentCMS.Core/Builtin/Tools/MediaTools.cs +++ b/src/FluentCMS.Core/Builtin/Tools/MediaTools.cs @@ -274,12 +274,12 @@ private static async Task> AttachToEntryAsync( var definition = type.Value.Definition; var media = definition.Fields.FirstOrDefault(candidate => candidate.Name == field); - if (media is null || !IsMediaField(media)) + if (media is null || !MediaFields.Holds(media)) { return Result.Failure( MediaErrorCodes.FieldNotMedia, $"'{field}' is not a media field of '{entry.Value.ContentType}'; its media fields are " - + string.Join(", ", definition.Fields.Where(IsMediaField).Select(candidate => candidate.Name)) + + string.Join(", ", definition.Fields.Where(MediaFields.Holds).Select(candidate => candidate.Name)) + "."); } @@ -329,13 +329,6 @@ private static async Task> AttachToEntryAsync( return await entries.UpdateAsync(entryId, baseVersion, JsonSerializer.SerializeToElement(fields), cancellationToken); } - // A single media field or a list of media. - private static bool IsMediaField(FieldDefinition field) - { - return field.Type == "media" - || (field.Type == "list" && field.Settings.TryGetProperty("itemType", out var itemType) && itemType.GetString() == "media"); - } - // The version an agent writes and its operation commit together or not at all; uploaded bytes that end up in no asset are deleted // again, so a refused upload leaves nothing in the storage. private static async Task ProposeAsync( diff --git a/src/FluentCMS.Core/ChangeSets/ChangeSet.cs b/src/FluentCMS.Core/ChangeSets/ChangeSet.cs index a3b1f4583..5838744d3 100644 --- a/src/FluentCMS.Core/ChangeSets/ChangeSet.cs +++ b/src/FluentCMS.Core/ChangeSets/ChangeSet.cs @@ -177,7 +177,7 @@ public Result AddOperation(ChangeOperation operation) } // One operation per item keeps one diff per item and one version to restore on revert (CHG-03, CHG-06). A newer version of an - // entry or entity the set already proposes, as when a reopened set is fixed, moves that operation on to it. + // entry, entity or media asset the set already proposes, as when a reopened set is fixed, moves that operation on to it. var position = _operations.FindIndex(existing => existing.ItemKind == operation.ItemKind && existing.ItemId == operation.ItemId); if (position >= 0) { @@ -345,7 +345,7 @@ private static string Describe(ChangeSetState state) private static string Duplicate(ChangeOperation existing, ChangeOperation operation) { var item = $"The {Describe(operation.ItemKind)} {operation.ItemId}"; - if (operation.ItemKind is not (ItemKind.Entry or ItemKind.Entity) || existing.ProposedVersion is not { } proposed) + if (operation.ItemKind is not (ItemKind.Entry or ItemKind.Entity or ItemKind.Media) || existing.ProposedVersion is not { } proposed) { return $"{item} already has an operation in this change set."; } @@ -358,7 +358,7 @@ private static string Duplicate(ChangeOperation existing, ChangeOperation operat // The same change from the same published version, proposing a later draft. private static bool Supersedes(ChangeOperation existing, ChangeOperation operation) { - return operation.ItemKind is ItemKind.Entry or ItemKind.Entity + return operation.ItemKind is ItemKind.Entry or ItemKind.Entity or ItemKind.Media && operation.Kind is OperationKind.Create or OperationKind.Update && operation.Kind == existing.Kind && operation.BaseVersion == existing.BaseVersion diff --git a/src/FluentCMS.Core/Checks/ChangeSetChecks.cs b/src/FluentCMS.Core/Checks/ChangeSetChecks.cs index 9a83da529..6fcbf0694 100644 --- a/src/FluentCMS.Core/Checks/ChangeSetChecks.cs +++ b/src/FluentCMS.Core/Checks/ChangeSetChecks.cs @@ -363,7 +363,7 @@ private async Task AnyJudgedAsync(CancellationToken cancellationToken) } var changed = before is null ? null : Changed(proposed.Value.Fields, before.Value.Fields); - subjects.Add((operation, entry.Value.ContentType, new CheckSubject(proposed.Value.Fields, set.Sources, entry.Value.ContentType, type.Definition, changed))); + subjects.Add((operation, entry.Value.ContentType, new CheckSubject(proposed.Value.Fields, set.Sources, entry.Value.ContentType, type.Definition, changed, set.Operations))); } return (subjects, problems); diff --git a/src/FluentCMS.Core/Checks/CheckInput.cs b/src/FluentCMS.Core/Checks/CheckInput.cs index fcae632a1..a6cbc2402 100644 --- a/src/FluentCMS.Core/Checks/CheckInput.cs +++ b/src/FluentCMS.Core/Checks/CheckInput.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using FluentCMS.Core.ChangeSets; using FluentCMS.Core.Identity; using FluentCMS.Core.Schema; @@ -11,10 +12,12 @@ public sealed record CheckInput(JsonElement Settings, CheckSubject Subject, ISer // One piece of content as a check sees it: its fields as an entry stores them, the sources its change cites, and its content type // when it has one. The fields hold only the ones the check's scope names. ChangedFields names the fields the change touches, and is -// null when every field counts as changed, as for new content or a draft. +// null when every field counts as changed, as for new content or a draft. SetOperations are those of the change set the content +// goes live with, so a check can see what that set proposes for the items the content refers to; null outside a set. public sealed record CheckSubject( JsonElement Fields, IReadOnlyList Sources, string? ContentType = null, ContentTypeDefinition? Definition = null, - IReadOnlyList? ChangedFields = null); + IReadOnlyList? ChangedFields = null, + IReadOnlyList? SetOperations = null); diff --git a/src/FluentCMS.Core/Media/IMediaLibrary.cs b/src/FluentCMS.Core/Media/IMediaLibrary.cs index 965383983..f2706bdb7 100644 --- a/src/FluentCMS.Core/Media/IMediaLibrary.cs +++ b/src/FluentCMS.Core/Media/IMediaLibrary.cs @@ -19,6 +19,11 @@ public interface IMediaLibrary Task> GetVersionAsync(Guid id, int version, CancellationToken cancellationToken = default); + // The version readers see: the newest a person wrote in the library after the published one, since those take effect at once, + // else the published one. A version any change set has proposed goes live only when its set publishes it. Publishing names the + // version a set will publish, to read what readers see once it has. Null when none is live. + Task> LiveVersionAsync(Guid id, int? publishing = null, CancellationToken cancellationToken = default); + // By the latest versions' metadata; oldest asset first. Task> ListAsync(MediaQuery query, CancellationToken cancellationToken = default); diff --git a/src/FluentCMS.Core/Media/MediaFields.cs b/src/FluentCMS.Core/Media/MediaFields.cs new file mode 100644 index 000000000..525506081 --- /dev/null +++ b/src/FluentCMS.Core/Media/MediaFields.cs @@ -0,0 +1,13 @@ +using FluentCMS.Core.Schema; + +namespace FluentCMS.Core.Media; + +internal static class MediaFields +{ + // A field that holds an asset of the media library, or a list of them. + public static bool Holds(FieldDefinition field) + { + return field.Type == "media" + || (field.Type == "list" && field.Settings.TryGetProperty("itemType", out var itemType) && itemType.GetString() == "media"); + } +} diff --git a/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs b/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs index 16a581265..ecc1d06e2 100644 --- a/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs +++ b/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs @@ -653,6 +653,7 @@ private async Task InsertOperationAsync(ChangeSet set, int position, Guid? previ BaseVersion = operation.BaseVersion, ProposedVersion = operation.ProposedVersion, }); + KeepProposedMedia(set.Id, operation); await auditLog.AppendAsync( new NewAuditEvent("change_set.operation_added") @@ -675,6 +676,7 @@ private async Task ExtendOperationAsync(ChangeSet set, int position, Guid? previ await context.ChangeSetOperations .Where(row => row.ChangeSetId == set.Id && row.Position == position) .ExecuteUpdateAsync(setters => setters.SetProperty(row => row.ProposedVersion, operation.ProposedVersion), cancellationToken); + KeepProposedMedia(set.Id, operation); await auditLog.AppendAsync( new NewAuditEvent("change_set.operation_updated") @@ -688,6 +690,22 @@ await auditLog.AppendAsync( cancellationToken); } + // The operation keeps only the version a set proposes last; the media library tells a person's write from a set's by every + // version a set has proposed (IMediaLibrary.LiveVersionAsync). + private void KeepProposedMedia(Guid setId, ChangeOperation operation) + { + if (operation.ItemKind == ItemKind.Media && operation.ProposedVersion is { } version) + { + context.ProposedMediaVersions.Add(new ProposedMediaVersionRow + { + Id = Guid.CreateVersion7(), + ChangeSetId = setId, + AssetId = operation.ItemId, + Version = version, + }); + } + } + // Writes only over the revision this call read, so a concurrent writer that committed first leaves nothing to update. private async Task UpdateAsync(int revision, ChangeSet set, CancellationToken cancellationToken) { diff --git a/src/FluentCMS.Infrastructure/Media/MediaAssetConfigurations.cs b/src/FluentCMS.Infrastructure/Media/MediaAssetConfigurations.cs index 977f804f1..1de91fe35 100644 --- a/src/FluentCMS.Infrastructure/Media/MediaAssetConfigurations.cs +++ b/src/FluentCMS.Infrastructure/Media/MediaAssetConfigurations.cs @@ -1,5 +1,6 @@ using FluentCMS.Core.Media; using FluentCMS.Core.Schema; +using FluentCMS.Infrastructure.ChangeSets; using FluentCMS.Infrastructure.Sites; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; @@ -54,3 +55,18 @@ public void Configure(EntityTypeBuilder row) row.HasIndex(version => version.Folder); } } + +internal sealed class ProposedMediaVersionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder row) + { + row.ToTable("ProposedMediaVersions", table => table.HasCheckConstraint("CK_ProposedMediaVersions_Version", "\"Version\" >= 1")); + row.HasKey(proposed => proposed.Id); + row.Property(proposed => proposed.Id).ValueGeneratedNever(); + + row.HasOne().WithMany().HasForeignKey(proposed => proposed.ChangeSetId).OnDelete(DeleteBehavior.Restrict); + row.HasOne().WithMany().HasForeignKey(proposed => proposed.AssetId).OnDelete(DeleteBehavior.Restrict); + + row.HasIndex(proposed => new { proposed.AssetId, proposed.Version, proposed.ChangeSetId }).IsUnique(); + } +} diff --git a/src/FluentCMS.Infrastructure/Media/MediaAssetRows.cs b/src/FluentCMS.Infrastructure/Media/MediaAssetRows.cs index 6b28fac2f..8be6b2f2c 100644 --- a/src/FluentCMS.Infrastructure/Media/MediaAssetRows.cs +++ b/src/FluentCMS.Infrastructure/Media/MediaAssetRows.cs @@ -14,6 +14,19 @@ public sealed class MediaAssetRow public int? PublishedVersion { get; set; } } +// A version of an asset that a change set proposed, kept when the set moves on to a newer version, so a version no set ever proposed +// is known to be a person's write in the library (MED-02). Version rows cannot record it themselves, since they never change. +public sealed class ProposedMediaVersionRow +{ + public Guid Id { get; set; } + + public Guid ChangeSetId { get; set; } + + public Guid AssetId { get; set; } + + public int Version { get; set; } +} + // One version of an asset's metadata with the bytes it points at; inserted once and never changed, which a trigger enforces. public sealed class MediaAssetVersionRow { diff --git a/src/FluentCMS.Infrastructure/Media/MediaLibrary.cs b/src/FluentCMS.Infrastructure/Media/MediaLibrary.cs index 9a10e1dcf..9173d67f2 100644 --- a/src/FluentCMS.Infrastructure/Media/MediaLibrary.cs +++ b/src/FluentCMS.Infrastructure/Media/MediaLibrary.cs @@ -181,6 +181,43 @@ public async Task> GetVersionAsync(Guid id, int versio : Result.Success(stored.ToVersion()); } + public async Task> LiveVersionAsync(Guid id, int? publishing = null, CancellationToken cancellationToken = default) + { + var row = await FindAsync(id, cancellationToken); + if (row is null) + { + return Result.Failure([NotFound(id)]); + } + + // Every other write is proposed in a change set, and kept as proposed whatever becomes of the set, so a version no set has + // proposed is a person's write in the library. + var published = publishing ?? row.PublishedVersion; + var after = published ?? 0; + var written = await context.MediaAssetVersions + .AsNoTracking() + .Where(version => version.AssetId == id + && version.Version > after + && !context.ProposedMediaVersions.Any(proposed => proposed.AssetId == id && proposed.Version == version.Version)) + .OrderByDescending(version => version.Version) + .FirstOrDefaultAsync(cancellationToken); + if (written is not null || published is not { } number) + { + return Result.Success(written?.ToVersion()); + } + + var stored = await VersionRowAsync(id, number, cancellationToken); + if (stored is null) + { + return publishing is null + ? throw new InvalidOperationException($"Media asset {id} points at version {number}, which is not stored.") + : Result.Failure( + MediaErrorCodes.VersionNotFound, + $"Media asset {id} has no version {number}; its latest version is {row.LatestVersion}."); + } + + return Result.Success(stored.ToVersion()); + } + public async Task> ListAsync(MediaQuery query, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(query); diff --git a/src/FluentCMS.Infrastructure/Persistence/FluentCmsDbContext.cs b/src/FluentCMS.Infrastructure/Persistence/FluentCmsDbContext.cs index 108cda4d1..a32f0b998 100644 --- a/src/FluentCMS.Infrastructure/Persistence/FluentCmsDbContext.cs +++ b/src/FluentCMS.Infrastructure/Persistence/FluentCmsDbContext.cs @@ -50,6 +50,8 @@ public abstract class FluentCmsDbContext(DbContextOptions options) : IdentityDbC public DbSet MediaAssetVersions => Set(); + public DbSet ProposedMediaVersions => Set(); + public DbSet Checks => Set(); public DbSet CheckVersions => Set(); @@ -94,6 +96,7 @@ protected override void OnModelCreating(ModelBuilder builder) builder.ApplyConfiguration(new EntityFactConfiguration()); builder.ApplyConfiguration(new MediaAssetConfiguration()); builder.ApplyConfiguration(new MediaAssetVersionConfiguration()); + builder.ApplyConfiguration(new ProposedMediaVersionConfiguration()); builder.ApplyConfiguration(new CheckConfiguration()); builder.ApplyConfiguration(new CheckVersionConfiguration()); builder.ApplyConfiguration(new RoutingRuleConfiguration()); diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261026003917_ProposedMediaVersions.Designer.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261026003917_ProposedMediaVersions.Designer.cs new file mode 100644 index 000000000..444c5c670 --- /dev/null +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261026003917_ProposedMediaVersions.Designer.cs @@ -0,0 +1,1802 @@ +// +using System; +using System.Collections.Generic; +using FluentCMS.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FluentCMS.Infrastructure.Persistence.Migrations.PostgreSql +{ + [DbContext(typeof(PostgreSqlFluentCmsDbContext))] + [Migration("20261026003917_ProposedMediaVersions")] + partial class ProposedMediaVersions + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.13") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.AgentCredentialRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CallPeriod") + .HasColumnType("interval"); + + b.Property("CallsPerPeriod") + .HasColumnType("integer"); + + b.Property("ChangeSetsPerDay") + .HasColumnType("integer"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.PrimitiveCollection>("Fields") + .HasColumnType("text[]"); + + b.Property("IssuedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ItemsPerChangeSet") + .HasColumnType("integer"); + + b.Property("KeyHash") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("LlmSpendPerPeriod") + .HasColumnType("numeric"); + + b.Property("LlmSpendPeriod") + .HasColumnType("interval"); + + b.PrimitiveCollection("Operations") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.PrimitiveCollection>("Sites") + .IsRequired() + .HasColumnType("uuid[]"); + + b.PrimitiveCollection>("Types") + .HasColumnType("text[]"); + + b.HasKey("Id"); + + b.HasIndex("KeyHash") + .IsUnique(); + + b.ToTable("AgentCredentials", null, t => + { + t.HasCheckConstraint("CK_AgentCredentials_Caps", "\"CallsPerPeriod\" >= 1 AND \"ItemsPerChangeSet\" >= 1 AND \"ChangeSetsPerDay\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.ToolCallRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AgentCredentialId") + .HasColumnType("uuid"); + + b.Property("AgentName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Duration") + .HasColumnType("interval"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("TaskId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("Tool") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("AgentCredentialId", "Timestamp"); + + b.ToTable("ToolCalls", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Audit.AuditEventRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AgentCredentialId") + .HasColumnType("uuid"); + + b.Property("ChangeSetId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("InitiatorKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("InitiatorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Operation") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("PreviousVersionId") + .HasColumnType("uuid"); + + b.Property("PromptHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.PrimitiveCollection>("Sources") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("SubjectId") + .HasColumnType("uuid"); + + b.Property("SubjectType") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AgentCredentialId"); + + b.HasIndex("ChangeSetId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("Operation", "Timestamp"); + + b.HasIndex("SubjectType", "SubjectId"); + + b.ToTable("AuditEvents", null, t => + { + t.HasCheckConstraint("CK_AuditEvents_AgentCredentialId", "(\"InitiatorKind\" = 'agent') = (\"AgentCredentialId\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetOperationRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BaseVersion") + .HasColumnType("integer"); + + b.Property("ChangeSetId") + .HasColumnType("uuid"); + + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("ItemKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("ProposedVersion") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId", "Position") + .IsUnique(); + + b.HasIndex("ItemKind", "ItemId"); + + b.HasIndex("ChangeSetId", "ItemKind", "ItemId") + .IsUnique(); + + b.ToTable("ChangeSetOperations", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("InitiatorKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("InitiatorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Rationale") + .IsRequired() + .HasColumnType("text"); + + b.Property("RejectionReason") + .HasColumnType("text"); + + b.Property("RevertsChangeSetId") + .HasColumnType("uuid"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("SessionId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.PrimitiveCollection>("Sources") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("State") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("RevertsChangeSetId"); + + b.HasIndex("SiteId"); + + b.HasIndex("State"); + + b.HasIndex("InitiatorKind", "InitiatorId", "SessionId") + .IsUnique() + .HasFilter("\"State\" = 'Open' AND \"SessionId\" IS NOT NULL"); + + b.ToTable("ChangeSets", null, t => + { + t.HasCheckConstraint("CK_ChangeSets_RejectionReason", "(\"State\" = 'Rejected') = (\"RejectionReason\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckResultRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Check") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CheckId") + .HasColumnType("uuid"); + + b.Property("CheckType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CheckVersion") + .HasColumnType("integer"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Cost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("Findings") + .IsRequired() + .HasColumnType("text"); + + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("ItemKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("ItemVersion") + .HasColumnType("integer"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ModelCallId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("PromptHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Provider") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("RunId") + .HasColumnType("uuid"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.HasKey("Id"); + + b.HasIndex("RunId", "Position") + .IsUnique(); + + b.ToTable("CheckResults", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ActiveVersion") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Name") + .IsUnique(); + + b.ToTable("Checks", null, t => + { + t.HasCheckConstraint("CK_Checks_ActiveVersion", "\"ActiveVersion\" IS NULL OR (\"ActiveVersion\" >= 1 AND \"ActiveVersion\" <= \"Version\")"); + + t.HasCheckConstraint("CK_Checks_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRunRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChangeSetId") + .HasColumnType("uuid"); + + b.Property("Cost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("JobId") + .HasColumnType("uuid"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Number") + .HasColumnType("integer"); + + b.Property("Problems") + .IsRequired() + .HasColumnType("text"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId", "Number") + .IsUnique(); + + b.ToTable("CheckRuns", null, t => + { + t.HasCheckConstraint("CK_CheckRuns_Number", "\"Number\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckVersionRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CheckId") + .HasColumnType("uuid"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CheckId", "Version") + .IsUnique(); + + b.ToTable("CheckVersions", null, t => + { + t.HasCheckConstraint("CK_CheckVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryReferenceRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("EntryId") + .HasColumnType("uuid"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("TargetField") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("EntryId", "Version"); + + b.HasIndex("TargetKind", "TargetId", "TargetField"); + + b.ToTable("EntryReferences", null, t => + { + t.HasCheckConstraint("CK_EntryReferences_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ContentTypeId") + .HasColumnType("uuid"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LatestVersion") + .HasColumnType("integer"); + + b.Property("PublishedVersion") + .HasColumnType("integer"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.HasKey("Id"); + + b.HasIndex("ContentTypeId"); + + b.HasIndex("SiteId", "ContentTypeId"); + + b.ToTable("Entries", null, t => + { + t.HasCheckConstraint("CK_Entries_LatestVersion", "\"LatestVersion\" >= 1"); + + t.HasCheckConstraint("CK_Entries_PublishedVersion", "\"PublishedVersion\" IS NULL OR (\"PublishedVersion\" >= 1 AND \"PublishedVersion\" <= \"LatestVersion\")"); + + t.HasCheckConstraint("CK_Entries_State", "\"State\" IN ('Draft', 'Published', 'Reverted', 'Archived')"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryVersionRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ContentTypeVersion") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EntryId") + .HasColumnType("uuid"); + + b.Property("Fields") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("EntryId", "Version") + .IsUnique(); + + b.ToTable("EntryVersions", null, t => + { + t.HasTrigger("TR_EntryVersions_NoUpdate"); + + t.HasCheckConstraint("CK_EntryVersions_ContentTypeVersion", "\"ContentTypeVersion\" >= 1"); + + t.HasCheckConstraint("CK_EntryVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Delivery.PreviewTokenRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChangeSetId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("bytea"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId"); + + b.HasIndex("SiteId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("PreviewTokens", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Entities.EntityFactRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("EntryId") + .HasColumnType("uuid"); + + b.Property("Field") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LastVerified") + .HasColumnType("timestamp with time zone"); + + b.Property("Owner") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("SourceKind") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("SourceReference") + .HasColumnType("text"); + + b.Property("Unsourced") + .HasColumnType("boolean"); + + b.Property("ValidFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("LastVerified"); + + b.HasIndex("Unsourced"); + + b.HasIndex("ValidUntil"); + + b.HasIndex("EntryId", "Version", "Field") + .IsUnique(); + + b.ToTable("EntityFacts", null, t => + { + t.HasCheckConstraint("CK_EntityFacts_Unsourced", "\"Unsourced\" = (\"SourceKind\" IS NULL)"); + + t.HasCheckConstraint("CK_EntityFacts_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Jobs.JobRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Attempts") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("InitiatorKind") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("InitiatorName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("LeaseOwner") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LeaseUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("MaxAttempts") + .HasColumnType("integer"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProgressDone") + .HasColumnType("integer"); + + b.Property("ProgressMessage") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ProgressTotal") + .HasColumnType("integer"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("ScheduledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("SubjectId") + .HasColumnType("uuid"); + + b.Property("SubjectType") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("State", "ScheduledAt"); + + b.HasIndex("SubjectType", "SubjectId"); + + b.ToTable("Jobs", null, t => + { + t.HasCheckConstraint("CK_Jobs_Attempts", "\"Attempts\" >= 0 AND \"MaxAttempts\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("LatestVersion") + .HasColumnType("integer"); + + b.Property("PublishedVersion") + .HasColumnType("integer"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SiteId"); + + b.ToTable("MediaAssets", null, t => + { + t.HasCheckConstraint("CK_MediaAssets_LatestVersion", "\"LatestVersion\" >= 1"); + + t.HasCheckConstraint("CK_MediaAssets_PublishedVersion", "\"PublishedVersion\" IS NULL OR (\"PublishedVersion\" >= 1 AND \"PublishedVersion\" <= \"LatestVersion\")"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetVersionRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AltText") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("AssetId") + .HasColumnType("uuid"); + + b.Property("Caption") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(127) + .HasColumnType("character varying(127)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Folder") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Length") + .HasColumnType("bigint"); + + b.Property("Sha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Storage") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("StorageKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.PrimitiveCollection>("Tags") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Folder"); + + b.HasIndex("AssetId", "Version") + .IsUnique(); + + b.ToTable("MediaAssetVersions", null, t => + { + t.HasTrigger("TR_MediaAssetVersions_NoUpdate"); + + t.HasCheckConstraint("CK_MediaAssetVersions_Length", "\"Length\" > 0"); + + t.HasCheckConstraint("CK_MediaAssetVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.ProposedMediaVersionRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AssetId") + .HasColumnType("uuid"); + + b.Property("ChangeSetId") + .HasColumnType("uuid"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId"); + + b.HasIndex("AssetId", "Version", "ChangeSetId") + .IsUnique(); + + b.ToTable("ProposedMediaVersions", null, t => + { + t.HasCheckConstraint("CK_ProposedMediaVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Models.ModelCallRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AgentCredentialId") + .HasColumnType("uuid"); + + b.Property("Cost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("Duration") + .HasColumnType("interval"); + + b.Property("Error") + .HasColumnType("text"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("InitiatorKind") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("InputTokens") + .HasColumnType("bigint"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ModelVersion") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("OutputTokens") + .HasColumnType("bigint"); + + b.Property("Profile") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Prompt") + .HasColumnType("text"); + + b.Property("PromptHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("PurgedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Purpose") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Response") + .HasColumnType("text"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("PromptHash"); + + b.HasIndex("Timestamp"); + + b.HasIndex("AgentCredentialId", "Timestamp"); + + b.ToTable("ModelCalls", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Persistence.FluentCmsRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("Roles", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Persistence.FluentCmsUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Routing.RoutingRuleRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ActiveVersion") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Name") + .IsUnique(); + + b.ToTable("RoutingRules", null, t => + { + t.HasCheckConstraint("CK_RoutingRules_ActiveVersion", "\"ActiveVersion\" IS NULL OR (\"ActiveVersion\" >= 1 AND \"ActiveVersion\" <= \"Version\")"); + + t.HasCheckConstraint("CK_RoutingRules_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Routing.RoutingRuleVersionRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("RoutingRuleId") + .HasColumnType("uuid"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("RoutingRuleId", "Version") + .IsUnique(); + + b.ToTable("RoutingRuleVersions", null, t => + { + t.HasCheckConstraint("CK_RoutingRuleVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Rules.RuleRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ActiveVersion") + .HasColumnType("integer"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Slug") + .IsUnique(); + + b.ToTable("Rules", null, t => + { + t.HasCheckConstraint("CK_Rules_ActiveVersion", "\"ActiveVersion\" IS NULL OR \"ActiveVersion\" >= 1"); + + t.HasCheckConstraint("CK_Rules_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Rules.RuleVersionRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("RuleId") + .HasColumnType("uuid"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("RuleId", "Version") + .IsUnique(); + + b.ToTable("RuleVersions", null, t => + { + t.HasCheckConstraint("CK_RuleVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("PublishedVersion") + .HasColumnType("integer"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Slug") + .IsUnique(); + + b.ToTable("ContentTypes", null, t => + { + t.HasCheckConstraint("CK_ContentTypes_PublishedVersion", "\"PublishedVersion\" IS NULL OR \"PublishedVersion\" >= 1"); + + t.HasCheckConstraint("CK_ContentTypes_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeVersionRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ContentTypeId") + .HasColumnType("uuid"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ContentTypeId", "Version") + .IsUnique(); + + b.ToTable("ContentTypeVersions", null, t => + { + t.HasCheckConstraint("CK_ContentTypeVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Sites.SiteRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.ToTable("Sites", (string)null); + + b.HasData( + new + { + Id = new Guid("00000000-0000-0000-0000-000000000001"), + Name = "Default" + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Webhooks.WebhookDeliveryRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Attempt") + .HasColumnType("integer"); + + b.Property("AttemptedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ChangeSetId") + .HasColumnType("uuid"); + + b.Property("DeliveryId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Duration") + .HasColumnType("interval"); + + b.Property("Error") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Event") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("StatusCode") + .HasColumnType("integer"); + + b.Property("SubscriptionId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SubscriptionId", "Id"); + + b.ToTable("WebhookDeliveries", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Webhooks.WebhookSubscriptionRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AddedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AddedById") + .HasColumnType("uuid"); + + b.Property("AddedByName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("DisabledAt") + .HasColumnType("timestamp with time zone"); + + b.PrimitiveCollection>("Events") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("ProtectedSecret") + .IsRequired() + .HasColumnType("text"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.HasKey("Id"); + + b.ToTable("WebhookSubscriptions", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("UserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("UserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.ToolCallRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Agents.AgentCredentialRow", null) + .WithMany() + .HasForeignKey("AgentCredentialId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetOperationRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("RevertsChangeSetId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckResultRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Checks.CheckRunRow", null) + .WithMany() + .HasForeignKey("RunId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRunRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Checks.CheckRow", null) + .WithMany() + .HasForeignKey("CheckId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryReferenceRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Schema.ContentTypeRow", null) + .WithMany() + .HasForeignKey("ContentTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Delivery.PreviewTokenRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Entities.EntityFactRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Media.MediaAssetRow", null) + .WithMany() + .HasForeignKey("AssetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.ProposedMediaVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Media.MediaAssetRow", null) + .WithMany() + .HasForeignKey("AssetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Models.ModelCallRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Agents.AgentCredentialRow", null) + .WithMany() + .HasForeignKey("AgentCredentialId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Routing.RoutingRuleRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Routing.RoutingRuleVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Routing.RoutingRuleRow", null) + .WithMany() + .HasForeignKey("RoutingRuleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Rules.RuleRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Rules.RuleVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Rules.RuleRow", null) + .WithMany() + .HasForeignKey("RuleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Schema.ContentTypeRow", null) + .WithMany() + .HasForeignKey("ContentTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261026003917_ProposedMediaVersions.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261026003917_ProposedMediaVersions.cs new file mode 100644 index 000000000..2bbe83f99 --- /dev/null +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261026003917_ProposedMediaVersions.cs @@ -0,0 +1,67 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FluentCMS.Infrastructure.Persistence.Migrations.PostgreSql +{ + /// + public partial class ProposedMediaVersions : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ProposedMediaVersions", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ChangeSetId = table.Column(type: "uuid", nullable: false), + AssetId = table.Column(type: "uuid", nullable: false), + Version = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ProposedMediaVersions", x => x.Id); + table.CheckConstraint("CK_ProposedMediaVersions_Version", "\"Version\" >= 1"); + table.ForeignKey( + name: "FK_ProposedMediaVersions_ChangeSets_ChangeSetId", + column: x => x.ChangeSetId, + principalTable: "ChangeSets", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_ProposedMediaVersions_MediaAssets_AssetId", + column: x => x.AssetId, + principalTable: "MediaAssets", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_ProposedMediaVersions_AssetId_Version_ChangeSetId", + table: "ProposedMediaVersions", + columns: new[] { "AssetId", "Version", "ChangeSetId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_ProposedMediaVersions_ChangeSetId", + table: "ProposedMediaVersions", + column: "ChangeSetId"); + + // The versions sets propose now; an earlier one a set has moved on from is no longer known. + migrationBuilder.Sql(""" + INSERT INTO "ProposedMediaVersions" ("Id", "ChangeSetId", "AssetId", "Version") + SELECT "Id", "ChangeSetId", "ItemId", "ProposedVersion" FROM "ChangeSetOperations" + WHERE "ItemKind" = 'Media' AND "ProposedVersion" IS NOT NULL; + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ProposedMediaVersions"); + } + } +} diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs index e5effdb2c..072f75912 100644 --- a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs @@ -918,6 +918,33 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); + modelBuilder.Entity("FluentCMS.Infrastructure.Media.ProposedMediaVersionRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AssetId") + .HasColumnType("uuid"); + + b.Property("ChangeSetId") + .HasColumnType("uuid"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId"); + + b.HasIndex("AssetId", "Version", "ChangeSetId") + .IsUnique(); + + b.ToTable("ProposedMediaVersions", null, t => + { + t.HasCheckConstraint("CK_ProposedMediaVersions_Version", "\"Version\" >= 1"); + }); + }); + modelBuilder.Entity("FluentCMS.Infrastructure.Models.ModelCallRow", b => { b.Property("Id") @@ -1639,6 +1666,21 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("FluentCMS.Infrastructure.Media.ProposedMediaVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Media.MediaAssetRow", null) + .WithMany() + .HasForeignKey("AssetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + modelBuilder.Entity("FluentCMS.Infrastructure.Models.ModelCallRow", b => { b.HasOne("FluentCMS.Infrastructure.Agents.AgentCredentialRow", null) diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261026003907_ProposedMediaVersions.Designer.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261026003907_ProposedMediaVersions.Designer.cs new file mode 100644 index 000000000..34452a7cd --- /dev/null +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261026003907_ProposedMediaVersions.Designer.cs @@ -0,0 +1,1792 @@ +// +using System; +using FluentCMS.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace FluentCMS.Infrastructure.Persistence.Migrations.Sqlite +{ + [DbContext(typeof(SqliteFluentCmsDbContext))] + [Migration("20261026003907_ProposedMediaVersions")] + partial class ProposedMediaVersions + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.13"); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.AgentCredentialRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CallPeriod") + .HasColumnType("TEXT"); + + b.Property("CallsPerPeriod") + .HasColumnType("INTEGER"); + + b.Property("ChangeSetsPerDay") + .HasColumnType("INTEGER"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Fields") + .HasColumnType("TEXT"); + + b.Property("IssuedAt") + .HasColumnType("TEXT"); + + b.Property("ItemsPerChangeSet") + .HasColumnType("INTEGER"); + + b.Property("KeyHash") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("LlmSpendPerPeriod") + .HasColumnType("TEXT"); + + b.Property("LlmSpendPeriod") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Operations") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RevokedAt") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Sites") + .IsRequired() + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Types") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("KeyHash") + .IsUnique(); + + b.ToTable("AgentCredentials", null, t => + { + t.HasCheckConstraint("CK_AgentCredentials_Caps", "\"CallsPerPeriod\" >= 1 AND \"ItemsPerChangeSet\" >= 1 AND \"ChangeSetsPerDay\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.ToolCallRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AgentCredentialId") + .HasColumnType("TEXT"); + + b.Property("AgentName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Duration") + .HasColumnType("TEXT"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("TaskId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.Property("Tool") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AgentCredentialId", "Timestamp"); + + b.ToTable("ToolCalls", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Audit.AuditEventRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AgentCredentialId") + .HasColumnType("TEXT"); + + b.Property("ChangeSetId") + .HasColumnType("TEXT"); + + b.Property("InitiatorId") + .HasColumnType("TEXT"); + + b.Property("InitiatorKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("InitiatorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Operation") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PreviousVersionId") + .HasColumnType("TEXT"); + + b.Property("PromptHash") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Sources") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SubjectId") + .HasColumnType("TEXT"); + + b.Property("SubjectType") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AgentCredentialId"); + + b.HasIndex("ChangeSetId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("Operation", "Timestamp"); + + b.HasIndex("SubjectType", "SubjectId"); + + b.ToTable("AuditEvents", null, t => + { + t.HasCheckConstraint("CK_AuditEvents_AgentCredentialId", "(\"InitiatorKind\" = 'agent') = (\"AgentCredentialId\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetOperationRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("BaseVersion") + .HasColumnType("INTEGER"); + + b.Property("ChangeSetId") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ItemKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("ProposedVersion") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId", "Position") + .IsUnique(); + + b.HasIndex("ItemKind", "ItemId"); + + b.HasIndex("ChangeSetId", "ItemKind", "ItemId") + .IsUnique(); + + b.ToTable("ChangeSetOperations", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("InitiatorId") + .HasColumnType("TEXT"); + + b.Property("InitiatorKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("InitiatorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Rationale") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RejectionReason") + .HasColumnType("TEXT"); + + b.Property("RevertsChangeSetId") + .HasColumnType("TEXT"); + + b.Property("Revision") + .HasColumnType("INTEGER"); + + b.Property("SessionId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Sources") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("State") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RevertsChangeSetId"); + + b.HasIndex("SiteId"); + + b.HasIndex("State"); + + b.HasIndex("InitiatorKind", "InitiatorId", "SessionId") + .IsUnique() + .HasFilter("\"State\" = 'Open' AND \"SessionId\" IS NOT NULL"); + + b.ToTable("ChangeSets", null, t => + { + t.HasCheckConstraint("CK_ChangeSets_RejectionReason", "(\"State\" = 'Rejected') = (\"RejectionReason\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckResultRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Check") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CheckId") + .HasColumnType("TEXT"); + + b.Property("CheckType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CheckVersion") + .HasColumnType("INTEGER"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Cost") + .HasPrecision(18, 6) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Findings") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ItemKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("ItemVersion") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ModelCallId") + .HasColumnType("TEXT"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("PromptHash") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("RunId") + .HasColumnType("TEXT"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RunId", "Position") + .IsUnique(); + + b.ToTable("CheckResults", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ActiveVersion") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Name") + .IsUnique(); + + b.ToTable("Checks", null, t => + { + t.HasCheckConstraint("CK_Checks_ActiveVersion", "\"ActiveVersion\" IS NULL OR (\"ActiveVersion\" >= 1 AND \"ActiveVersion\" <= \"Version\")"); + + t.HasCheckConstraint("CK_Checks_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRunRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ChangeSetId") + .HasColumnType("TEXT"); + + b.Property("Cost") + .HasPrecision(18, 6) + .HasColumnType("TEXT"); + + b.Property("FinishedAt") + .HasColumnType("TEXT"); + + b.Property("JobId") + .HasColumnType("TEXT"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Problems") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId", "Number") + .IsUnique(); + + b.ToTable("CheckRuns", null, t => + { + t.HasCheckConstraint("CK_CheckRuns_Number", "\"Number\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckVersionRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CheckId") + .HasColumnType("TEXT"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CheckId", "Version") + .IsUnique(); + + b.ToTable("CheckVersions", null, t => + { + t.HasCheckConstraint("CK_CheckVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryReferenceRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("EntryId") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TargetField") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("TargetId") + .HasColumnType("TEXT"); + + b.Property("TargetKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EntryId", "Version"); + + b.HasIndex("TargetKind", "TargetId", "TargetField"); + + b.ToTable("EntryReferences", null, t => + { + t.HasCheckConstraint("CK_EntryReferences_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ContentTypeId") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("LatestVersion") + .HasColumnType("INTEGER"); + + b.Property("PublishedVersion") + .HasColumnType("INTEGER"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ContentTypeId"); + + b.HasIndex("SiteId", "ContentTypeId"); + + b.ToTable("Entries", null, t => + { + t.HasCheckConstraint("CK_Entries_LatestVersion", "\"LatestVersion\" >= 1"); + + t.HasCheckConstraint("CK_Entries_PublishedVersion", "\"PublishedVersion\" IS NULL OR (\"PublishedVersion\" >= 1 AND \"PublishedVersion\" <= \"LatestVersion\")"); + + t.HasCheckConstraint("CK_Entries_State", "\"State\" IN ('Draft', 'Published', 'Reverted', 'Archived')"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryVersionRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ContentTypeVersion") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EntryId") + .HasColumnType("TEXT"); + + b.Property("Fields") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EntryId", "Version") + .IsUnique(); + + b.ToTable("EntryVersions", null, t => + { + t.HasTrigger("TR_EntryVersions_NoUpdate"); + + t.HasCheckConstraint("CK_EntryVersions_ContentTypeVersion", "\"ContentTypeVersion\" >= 1"); + + t.HasCheckConstraint("CK_EntryVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Delivery.PreviewTokenRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ChangeSetId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("RevokedAt") + .HasColumnType("TEXT"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("BLOB"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId"); + + b.HasIndex("SiteId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("PreviewTokens", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Entities.EntityFactRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("EntryId") + .HasColumnType("TEXT"); + + b.Property("Field") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastVerified") + .HasColumnType("TEXT"); + + b.Property("Owner") + .HasColumnType("TEXT"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("SourceKind") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SourceReference") + .HasColumnType("TEXT"); + + b.Property("Unsourced") + .HasColumnType("INTEGER"); + + b.Property("ValidFrom") + .HasColumnType("TEXT"); + + b.Property("ValidUntil") + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LastVerified"); + + b.HasIndex("Unsourced"); + + b.HasIndex("ValidUntil"); + + b.HasIndex("EntryId", "Version", "Field") + .IsUnique(); + + b.ToTable("EntityFacts", null, t => + { + t.HasCheckConstraint("CK_EntityFacts_Unsourced", "\"Unsourced\" = (\"SourceKind\" IS NULL)"); + + t.HasCheckConstraint("CK_EntityFacts_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Jobs.JobRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Attempts") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("FinishedAt") + .HasColumnType("TEXT"); + + b.Property("InitiatorId") + .HasColumnType("TEXT"); + + b.Property("InitiatorKind") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("InitiatorName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasColumnType("TEXT"); + + b.Property("LeaseOwner") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("LeaseUntil") + .HasColumnType("TEXT"); + + b.Property("MaxAttempts") + .HasColumnType("INTEGER"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ProgressDone") + .HasColumnType("INTEGER"); + + b.Property("ProgressMessage") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("ProgressTotal") + .HasColumnType("INTEGER"); + + b.Property("Revision") + .HasColumnType("INTEGER"); + + b.Property("ScheduledAt") + .HasColumnType("TEXT"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SubjectId") + .HasColumnType("TEXT"); + + b.Property("SubjectType") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("State", "ScheduledAt"); + + b.HasIndex("SubjectType", "SubjectId"); + + b.ToTable("Jobs", null, t => + { + t.HasCheckConstraint("CK_Jobs_Attempts", "\"Attempts\" >= 0 AND \"MaxAttempts\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("LatestVersion") + .HasColumnType("INTEGER"); + + b.Property("PublishedVersion") + .HasColumnType("INTEGER"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SiteId"); + + b.ToTable("MediaAssets", null, t => + { + t.HasCheckConstraint("CK_MediaAssets_LatestVersion", "\"LatestVersion\" >= 1"); + + t.HasCheckConstraint("CK_MediaAssets_PublishedVersion", "\"PublishedVersion\" IS NULL OR (\"PublishedVersion\" >= 1 AND \"PublishedVersion\" <= \"LatestVersion\")"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetVersionRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AltText") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("AssetId") + .HasColumnType("TEXT"); + + b.Property("Caption") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(127) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Folder") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Length") + .HasColumnType("INTEGER"); + + b.Property("Sha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Storage") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("StorageKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Tags") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Folder"); + + b.HasIndex("AssetId", "Version") + .IsUnique(); + + b.ToTable("MediaAssetVersions", null, t => + { + t.HasTrigger("TR_MediaAssetVersions_NoUpdate"); + + t.HasCheckConstraint("CK_MediaAssetVersions_Length", "\"Length\" > 0"); + + t.HasCheckConstraint("CK_MediaAssetVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.ProposedMediaVersionRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AssetId") + .HasColumnType("TEXT"); + + b.Property("ChangeSetId") + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId"); + + b.HasIndex("AssetId", "Version", "ChangeSetId") + .IsUnique(); + + b.ToTable("ProposedMediaVersions", null, t => + { + t.HasCheckConstraint("CK_ProposedMediaVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Models.ModelCallRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AgentCredentialId") + .HasColumnType("TEXT"); + + b.Property("Cost") + .HasPrecision(18, 6) + .HasColumnType("TEXT"); + + b.Property("Duration") + .HasColumnType("TEXT"); + + b.Property("Error") + .HasColumnType("TEXT"); + + b.Property("InitiatorId") + .HasColumnType("TEXT"); + + b.Property("InitiatorKind") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("InputTokens") + .HasColumnType("INTEGER"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ModelVersion") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("OutputTokens") + .HasColumnType("INTEGER"); + + b.Property("Profile") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Prompt") + .HasColumnType("TEXT"); + + b.Property("PromptHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("PurgedAt") + .HasColumnType("TEXT"); + + b.Property("Purpose") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Response") + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PromptHash"); + + b.HasIndex("Timestamp"); + + b.HasIndex("AgentCredentialId", "Timestamp"); + + b.ToTable("ModelCalls", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Persistence.FluentCmsRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("Roles", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Persistence.FluentCmsUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Routing.RoutingRuleRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ActiveVersion") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Name") + .IsUnique(); + + b.ToTable("RoutingRules", null, t => + { + t.HasCheckConstraint("CK_RoutingRules_ActiveVersion", "\"ActiveVersion\" IS NULL OR (\"ActiveVersion\" >= 1 AND \"ActiveVersion\" <= \"Version\")"); + + t.HasCheckConstraint("CK_RoutingRules_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Routing.RoutingRuleVersionRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RoutingRuleId") + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoutingRuleId", "Version") + .IsUnique(); + + b.ToTable("RoutingRuleVersions", null, t => + { + t.HasCheckConstraint("CK_RoutingRuleVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Rules.RuleRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ActiveVersion") + .HasColumnType("INTEGER"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Slug") + .IsUnique(); + + b.ToTable("Rules", null, t => + { + t.HasCheckConstraint("CK_Rules_ActiveVersion", "\"ActiveVersion\" IS NULL OR \"ActiveVersion\" >= 1"); + + t.HasCheckConstraint("CK_Rules_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Rules.RuleVersionRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RuleId") + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RuleId", "Version") + .IsUnique(); + + b.ToTable("RuleVersions", null, t => + { + t.HasCheckConstraint("CK_RuleVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PublishedVersion") + .HasColumnType("INTEGER"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Slug") + .IsUnique(); + + b.ToTable("ContentTypes", null, t => + { + t.HasCheckConstraint("CK_ContentTypes_PublishedVersion", "\"PublishedVersion\" IS NULL OR \"PublishedVersion\" >= 1"); + + t.HasCheckConstraint("CK_ContentTypes_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeVersionRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ContentTypeId") + .HasColumnType("TEXT"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ContentTypeId", "Version") + .IsUnique(); + + b.ToTable("ContentTypeVersions", null, t => + { + t.HasCheckConstraint("CK_ContentTypeVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Sites.SiteRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Sites", (string)null); + + b.HasData( + new + { + Id = new Guid("00000000-0000-0000-0000-000000000001"), + Name = "Default" + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Webhooks.WebhookDeliveryRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Attempt") + .HasColumnType("INTEGER"); + + b.Property("AttemptedAt") + .HasColumnType("TEXT"); + + b.Property("ChangeSetId") + .HasColumnType("TEXT"); + + b.Property("DeliveryId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Duration") + .HasColumnType("TEXT"); + + b.Property("Error") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Event") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("StatusCode") + .HasColumnType("INTEGER"); + + b.Property("SubscriptionId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SubscriptionId", "Id"); + + b.ToTable("WebhookDeliveries", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Webhooks.WebhookSubscriptionRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AddedAt") + .HasColumnType("TEXT"); + + b.Property("AddedById") + .HasColumnType("TEXT"); + + b.Property("AddedByName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("DisabledAt") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Events") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ProtectedSecret") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("WebhookSubscriptions", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("UserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("UserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.ToolCallRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Agents.AgentCredentialRow", null) + .WithMany() + .HasForeignKey("AgentCredentialId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetOperationRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("RevertsChangeSetId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckResultRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Checks.CheckRunRow", null) + .WithMany() + .HasForeignKey("RunId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRunRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Checks.CheckRow", null) + .WithMany() + .HasForeignKey("CheckId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryReferenceRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Schema.ContentTypeRow", null) + .WithMany() + .HasForeignKey("ContentTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Delivery.PreviewTokenRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Entities.EntityFactRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Media.MediaAssetRow", null) + .WithMany() + .HasForeignKey("AssetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.ProposedMediaVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Media.MediaAssetRow", null) + .WithMany() + .HasForeignKey("AssetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Models.ModelCallRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Agents.AgentCredentialRow", null) + .WithMany() + .HasForeignKey("AgentCredentialId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Routing.RoutingRuleRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Routing.RoutingRuleVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Routing.RoutingRuleRow", null) + .WithMany() + .HasForeignKey("RoutingRuleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Rules.RuleRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Rules.RuleVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Rules.RuleRow", null) + .WithMany() + .HasForeignKey("RuleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Schema.ContentTypeRow", null) + .WithMany() + .HasForeignKey("ContentTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261026003907_ProposedMediaVersions.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261026003907_ProposedMediaVersions.cs new file mode 100644 index 000000000..c4ac75176 --- /dev/null +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261026003907_ProposedMediaVersions.cs @@ -0,0 +1,67 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FluentCMS.Infrastructure.Persistence.Migrations.Sqlite +{ + /// + public partial class ProposedMediaVersions : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ProposedMediaVersions", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + ChangeSetId = table.Column(type: "TEXT", nullable: false), + AssetId = table.Column(type: "TEXT", nullable: false), + Version = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ProposedMediaVersions", x => x.Id); + table.CheckConstraint("CK_ProposedMediaVersions_Version", "\"Version\" >= 1"); + table.ForeignKey( + name: "FK_ProposedMediaVersions_ChangeSets_ChangeSetId", + column: x => x.ChangeSetId, + principalTable: "ChangeSets", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_ProposedMediaVersions_MediaAssets_AssetId", + column: x => x.AssetId, + principalTable: "MediaAssets", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_ProposedMediaVersions_AssetId_Version_ChangeSetId", + table: "ProposedMediaVersions", + columns: new[] { "AssetId", "Version", "ChangeSetId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_ProposedMediaVersions_ChangeSetId", + table: "ProposedMediaVersions", + column: "ChangeSetId"); + + // The versions sets propose now; an earlier one a set has moved on from is no longer known. + migrationBuilder.Sql(""" + INSERT INTO "ProposedMediaVersions" ("Id", "ChangeSetId", "AssetId", "Version") + SELECT "Id", "ChangeSetId", "ItemId", "ProposedVersion" FROM "ChangeSetOperations" + WHERE "ItemKind" = 'Media' AND "ProposedVersion" IS NOT NULL; + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ProposedMediaVersions"); + } + } +} diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs index 578ea20f7..745fa11e9 100644 --- a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs @@ -912,6 +912,33 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); + modelBuilder.Entity("FluentCMS.Infrastructure.Media.ProposedMediaVersionRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AssetId") + .HasColumnType("TEXT"); + + b.Property("ChangeSetId") + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId"); + + b.HasIndex("AssetId", "Version", "ChangeSetId") + .IsUnique(); + + b.ToTable("ProposedMediaVersions", null, t => + { + t.HasCheckConstraint("CK_ProposedMediaVersions_Version", "\"Version\" >= 1"); + }); + }); + modelBuilder.Entity("FluentCMS.Infrastructure.Models.ModelCallRow", b => { b.Property("Id") @@ -1629,6 +1656,21 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("FluentCMS.Infrastructure.Media.ProposedMediaVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Media.MediaAssetRow", null) + .WithMany() + .HasForeignKey("AssetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + modelBuilder.Entity("FluentCMS.Infrastructure.Models.ModelCallRow", b => { b.HasOne("FluentCMS.Infrastructure.Agents.AgentCredentialRow", null) diff --git a/tests/FluentCMS.IntegrationTests/Mcp/MediaToolsTests.cs b/tests/FluentCMS.IntegrationTests/Mcp/MediaToolsTests.cs index 77389a3fb..c35b806c5 100644 --- a/tests/FluentCMS.IntegrationTests/Mcp/MediaToolsTests.cs +++ b/tests/FluentCMS.IntegrationTests/Mcp/MediaToolsTests.cs @@ -2,6 +2,7 @@ using System.Text.Json.Nodes; using FluentCMS.Core.Agents; using FluentCMS.Core.ChangeSets; +using FluentCMS.Core.Checks; using FluentCMS.Core.Content; using FluentCMS.Core.Media; using FluentCMS.Core.Schema; @@ -65,6 +66,355 @@ public async Task An_agent_uploads_an_image_and_attaches_it_to_a_page_in_one_set Assert.False((bool)listed["media"]![0]!["hasAltText"]!); } + // S2 in one set (MED-02): the agent uploads an image without alt text and attaches it to a page, the check names the field, the + // agent adds alt text to the image in the same set, and the set passes the check it runs when submitted. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task An_agent_uploads_attaches_and_describes_an_image_in_one_set_until_the_check_passes(string provider) + { + await using var agent = await ConnectedAgent.StartAsync(provider); + var page = await PageAsync(agent); + await ActiveAltTextCheckAsync(agent); + + var uploaded = await agent.CallAsync("upload_media", new() + { + ["content_base64"] = Image, + ["content_type"] = "image/png", + ["file_name"] = "drill.png", + ["session_id"] = "launch", + ["rationale"] = "Show the drill on its page.", + }); + var assetId = (string)uploaded["media"]!["id"]!; + var setId = (string)uploaded["changeSet"]!["id"]!; + await agent.CallAsync("attach_media", new() + { + ["entry_id"] = page.ToString(), + ["base_version"] = 1, + ["field"] = "hero", + ["asset_id"] = assetId, + ["session_id"] = "launch", + ["rationale"] = "Ignored once the set is open.", + }); + Dictionary Draft() => new() + { + ["content_type"] = "product-page", + ["fields"] = JsonNode.Parse($$"""{"headline": "The Widget", "hero": "{{assetId}}"}"""), + ["id"] = page.ToString(), + ["change_set_id"] = setId, + }; + + var before = await agent.CallAsync("check_draft", Draft()); + var described = await agent.CallAsync("update_media", new() + { + ["id"] = assetId, + ["base_version"] = 1, + ["alt_text"] = "A cordless drill on a workbench.", + ["session_id"] = "launch", + ["rationale"] = "Describe the drill for people who cannot see it.", + }); + var after = await agent.CallAsync("check_draft", Draft()); + var submitted = await agent.CallAsync("submit_change_set", new() { ["id"] = setId }); + + Assert.Equal("fail", (string?)before["status"]); + Assert.Equal("/hero", (string?)before["results"]![0]!["findings"]![0]!["pointer"]); + var media = described["changeSet"]!["operations"]!.AsArray().Single(operation => (string?)operation!["itemKind"] == "media")!; + Assert.Equal(("create", 2), ((string)media["kind"]!, (int)media["proposedVersion"]!)); + Assert.Equal("pass", (string?)after["status"]); + var altText = submitted["checks"]!["items"]!.AsArray().Single()!["results"]!.AsArray().Single(result => (string?)result!["check"] == "alt-text")!; + Assert.Equal("pass", (string?)altText["status"]); + } + + // The same for an entity's media fact: the photo of a product passes once update_media gives it alt text in the product's set. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task An_entity_s_media_fact_passes_once_update_media_describes_it_in_the_same_set(string provider) + { + await using var agent = await ConnectedAgent.StartAsync(provider); + await agent.AsPersonAsync(directory => directory.CreateAsync( + new NewContentType("Product", "product", new ContentTypeDefinition([new FieldDefinition("name", "text"), new FieldDefinition("photo", "media")], isEntity: true)), + TestChangeSetTargets.ContentModel)); + var check = new CheckDefinition( + "altText", + "Every image a product shows has alt text.", + JsonSerializer.Deserialize("{}"), + [ + new CheckExample("described", CheckVerdict.Pass, JsonSerializer.Deserialize("""{"name": {"value": "A drill"}}"""), contentType: "product"), + new CheckExample("bare", CheckVerdict.Fail, JsonSerializer.Deserialize("""{"name": {"value": "![](/drill.jpg)"}}"""), contentType: "product"), + ], + scope: new CheckScope(["product"])); + await agent.AsPersonAsync(directory => directory.CreateAsync(new NewCheck("alt-text", check))); + await agent.AsPersonAsync(directory => directory.ActivateAsync("alt-text", 1)); + + var uploaded = await agent.CallAsync("upload_media", new() + { + ["content_base64"] = Image, + ["content_type"] = "image/png", + ["file_name"] = "drill.png", + ["session_id"] = "catalog", + ["rationale"] = "A photo for the drill.", + }); + var assetId = (string)uploaded["media"]!["id"]!; + var setId = (string)uploaded["changeSet"]!["id"]!; + Dictionary Draft() => new() + { + ["content_type"] = "product", + ["fields"] = JsonNode.Parse($$""" + { + "name": {"value": "Drill", "source": {"kind": "system", "reference": "seed"}, "owner": null, "validFrom": null, "validUntil": null, "lastVerified": null}, + "photo": {"value": "{{assetId}}", "source": {"kind": "system", "reference": "seed"}, "owner": null, "validFrom": null, "validUntil": null, "lastVerified": null} + } + """), + ["change_set_id"] = setId, + }; + + var before = await agent.CallAsync("check_draft", Draft()); + await agent.CallAsync("update_media", new() + { + ["id"] = assetId, + ["base_version"] = 1, + ["alt_text"] = "A cordless drill on a workbench.", + ["session_id"] = "catalog", + ["rationale"] = "Describe the photo.", + }); + var after = await agent.CallAsync("check_draft", Draft()); + + Assert.Equal("/photo/value", (string?)before["results"]![0]!["findings"]![0]!["pointer"]); + Assert.Equal("pass", (string?)after["status"]); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task Check_draft_refuses_a_change_set_it_cannot_read_or_that_has_ended(string provider) + { + await using var agent = await ConnectedAgent.StartAsync(provider); + await PageAsync(agent); + var opened = await agent.CallAsync("upload_media", new() + { + ["content_base64"] = Image, + ["content_type"] = "image/png", + ["file_name"] = "drill.png", + ["session_id"] = "rejected", + ["rationale"] = "A set to reject.", + }); + var rejectedId = Guid.Parse((string)opened["changeSet"]!["id"]!); + await agent.AsPersonAsync(directory => directory.TransitionAsync(rejectedId, ChangeSetState.ChecksRunning)); + await agent.AsPersonAsync(directory => directory.TransitionAsync(rejectedId, ChangeSetState.AwaitingReview)); + await agent.AsPersonAsync(directory => directory.RejectAsync(rejectedId, "Not now.")); + Dictionary Draft(Guid changeSetId) => new() + { + ["content_type"] = "product-page", + ["fields"] = JsonNode.Parse("""{"headline": "The Widget"}"""), + ["change_set_id"] = changeSetId.ToString(), + }; + + var unknown = await agent.FailAsync("check_draft", Draft(Guid.CreateVersion7())); + var ended = await agent.FailAsync("check_draft", Draft(rejectedId)); + + Assert.Equal(ChangeSetErrorCodes.ChangeSetNotFound, Code(unknown)); + Assert.Equal("argument_invalid", Code(ended)); + Assert.Contains("rejected", (string?)ended["errors"]![0]!["message"]); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task The_alt_text_check_passes_once_the_pages_set_adds_alt_text_with_update_media(string provider) + { + await using var agent = await ConnectedAgent.StartAsync(provider); + var page = await PageAsync(agent); + var uploaded = await agent.CallAsync("upload_media", new() + { + ["content_base64"] = Image, + ["content_type"] = "image/png", + ["file_name"] = "drill.png", + ["session_id"] = "library", + ["rationale"] = "An image for the catalog.", + }); + var assetId = (string)uploaded["media"]!["id"]!; + await PublishAsPersonAsync(agent, (string)uploaded["changeSet"]!["id"]!); + await ActiveAltTextCheckAsync(agent); + + var attached = await agent.CallAsync("attach_media", new() + { + ["entry_id"] = page.ToString(), + ["base_version"] = 1, + ["field"] = "hero", + ["asset_id"] = assetId, + ["session_id"] = "launch", + ["rationale"] = "Show the drill on its page.", + }); + var setId = (string)attached["changeSet"]!["id"]!; + Dictionary Draft() => new() + { + ["content_type"] = "product-page", + ["fields"] = JsonNode.Parse($$"""{"headline": "The Widget", "hero": "{{assetId}}"}"""), + ["id"] = page.ToString(), + ["change_set_id"] = setId, + }; + + var before = await agent.CallAsync("check_draft", Draft()); + await agent.CallAsync("update_media", new() + { + ["id"] = assetId, + ["base_version"] = 1, + ["alt_text"] = "A cordless drill on a workbench.", + ["session_id"] = "launch", + ["rationale"] = "Describe the drill for people who cannot see it.", + }); + var after = await agent.CallAsync("check_draft", Draft()); + + Assert.Equal("fail", (string?)before["status"]); + Assert.Equal("/hero", (string?)before["results"]![0]!["findings"]![0]!["pointer"]); + Assert.Equal("pass", (string?)after["status"]); + + // Submitted, the set's own checks read the version it proposes too. + var submitted = await agent.CallAsync("submit_change_set", new() { ["id"] = setId }); + var altText = submitted["checks"]!["items"]!.AsArray().Single()!["results"]!.AsArray().Single(result => (string?)result!["check"] == "alt-text")!; + Assert.Equal("pass", (string?)altText["status"]); + } + + // A version a set moved past within the set is still the set's, so outside it the alt text never counts, also once the set is + // rejected: readers keep seeing the published version without alt text, or no version at all. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task Versions_a_set_moved_past_do_not_count_outside_it_while_pending_or_once_rejected(string provider) + { + await using var agent = await ConnectedAgent.StartAsync(provider); + await PageAsync(agent); + await ActiveAltTextCheckAsync(agent); + var library = await agent.CallAsync("upload_media", new() + { + ["content_base64"] = Image, + ["content_type"] = "image/png", + ["file_name"] = "drill.png", + ["session_id"] = "library", + ["rationale"] = "An image for the catalog.", + }); + var published = (string)library["media"]!["id"]!; + await PublishAsPersonAsync(agent, (string)library["changeSet"]!["id"]!); + + var fresh = (string)(await agent.CallAsync("upload_media", new() + { + ["content_base64"] = Image, + ["content_type"] = "image/png", + ["file_name"] = "saw.png", + ["alt_text"] = "A circular saw.", + ["session_id"] = "describe", + ["rationale"] = "A new image.", + }))["media"]!["id"]!; + var described = await agent.CallAsync("update_media", new() + { + ["id"] = published, + ["base_version"] = 1, + ["alt_text"] = "A cordless drill on a workbench.", + ["session_id"] = "describe", + ["rationale"] = "Describe the drill.", + }); + foreach (var (id, baseVersion) in new[] { (published, 2), (fresh, 1) }) + { + await agent.CallAsync("update_media", new() + { + ["id"] = id, + ["base_version"] = baseVersion, + ["caption"] = "From the spring catalog.", + ["session_id"] = "describe", + ["rationale"] = "Caption the image.", + }); + } + + var setId = Guid.Parse((string)described["changeSet"]!["id"]!); + async Task CheckAsync(string assetId, Guid? changeSetId = null) + { + var draft = new Dictionary + { + ["content_type"] = "product-page", + ["fields"] = JsonNode.Parse($$"""{"headline": "The Widget", "hero": "{{assetId}}"}"""), + }; + if (changeSetId is { } id) + { + draft["change_set_id"] = id.ToString(); + } + + return await agent.CallAsync("check_draft", draft); + } + + string? Finding(JsonObject result) => (string?)result["results"]![0]!["findings"]![0]!["message"]; + + var pending = await CheckAsync(published); + var pendingFresh = await CheckAsync(fresh); + var inTheSet = await CheckAsync(published, setId); + await agent.AsPersonAsync(directory => directory.TransitionAsync(setId, ChangeSetState.ChecksRunning)); + await agent.AsPersonAsync(directory => directory.TransitionAsync(setId, ChangeSetState.AwaitingReview)); + await agent.AsPersonAsync(directory => directory.RejectAsync(setId, "Not now.")); + var rejected = await CheckAsync(published); + var rejectedFresh = await CheckAsync(fresh); + + Assert.Equal("Add alt text to the image 'drill.png' in hero that says what it shows; set it with update_media.", Finding(pending)); + Assert.StartsWith("The image 'saw.png' in hero is not published", Finding(pendingFresh)); + Assert.Equal("pass", (string?)inTheSet["status"]); + Assert.Equal(Finding(pending), Finding(rejected)); + Assert.Equal(Finding(pendingFresh), Finding(rejectedFresh)); + } + + // A person who describes an image in the library while an agent's set holds it changes what goes live with that set, at once. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_person_s_alt_text_in_the_library_counts_for_the_set_that_holds_the_image(string provider) + { + await using var agent = await ConnectedAgent.StartAsync(provider); + var page = await PageAsync(agent); + await ActiveAltTextCheckAsync(agent); + var uploaded = await agent.CallAsync("upload_media", new() + { + ["content_base64"] = Image, + ["content_type"] = "image/png", + ["file_name"] = "drill.png", + ["session_id"] = "launch", + ["rationale"] = "Show the drill on its page.", + }); + var assetId = (string)uploaded["media"]!["id"]!; + var setId = (string)uploaded["changeSet"]!["id"]!; + await agent.CallAsync("attach_media", new() + { + ["entry_id"] = page.ToString(), + ["base_version"] = 1, + ["field"] = "hero", + ["asset_id"] = assetId, + ["session_id"] = "launch", + ["rationale"] = "Ignored once the set is open.", + }); + async Task StatusAsync(string? changeSetId) + { + var draft = new Dictionary + { + ["content_type"] = "product-page", + ["fields"] = JsonNode.Parse($$"""{"headline": "The Widget", "hero": "{{assetId}}"}"""), + }; + if (changeSetId is not null) + { + draft["change_set_id"] = changeSetId; + } + + return (string?)(await agent.CallAsync("check_draft", draft))["status"]; + } + + + var before = await StatusAsync(setId); + await agent.AsPersonAsync(media => + media.UpdateAsync(Guid.Parse(assetId), 1, AssetMetadata.Create("drill.png", null, [], "A cordless drill on a workbench.", null).Value)); + var after = await StatusAsync(setId); + await PublishAsPersonAsync(agent, setId); + var live = await StatusAsync(null); + + Assert.Equal(("fail", "pass", "pass"), (before, after, live)); + Assert.Equal(1, await agent.QueryAsync(context => context.MediaAssets.Select(row => row.PublishedVersion).SingleAsync())); + } + [Theory] [InlineData(DatabaseProviders.Sqlite)] [PostgresData] @@ -198,6 +548,30 @@ await agent.QueryAsync(context => context.Entries return page.Id; } + private static readonly CheckDefinition AltTextCheck = new( + "altText", + "Every image a product page shows has alt text.", + JsonSerializer.Deserialize("{}"), + [ + new CheckExample("described", CheckVerdict.Pass, JsonSerializer.Deserialize("""{"headline": "A drill"}"""), contentType: "product-page"), + new CheckExample("bare", CheckVerdict.Fail, JsonSerializer.Deserialize("""{"headline": "![](/drill.jpg)"}"""), contentType: "product-page"), + ], + scope: new CheckScope(["product-page"])); + + private static async Task ActiveAltTextCheckAsync(ConnectedAgent agent) + { + await agent.AsPersonAsync(directory => directory.CreateAsync(new NewCheck("alt-text", AltTextCheck))); + await agent.AsPersonAsync(directory => directory.ActivateAsync("alt-text", 1)); + } + + private static async Task PublishAsPersonAsync(ConnectedAgent agent, string setId) + { + var id = Guid.Parse(setId); + await agent.CallAsync("submit_change_set", new() { ["id"] = setId }); + await agent.AsPersonAsync(directory => directory.TransitionAsync(id, ChangeSetState.Approved)); + await agent.AsPersonAsync(directory => directory.PublishAsync(id)); + } + private static string? Code(JsonObject failure) { return (string?)failure["errors"]![0]!["code"]; diff --git a/tests/FluentCMS.IntegrationTests/Media/AltTextCheckTests.cs b/tests/FluentCMS.IntegrationTests/Media/AltTextCheckTests.cs new file mode 100644 index 000000000..277b4b0de --- /dev/null +++ b/tests/FluentCMS.IntegrationTests/Media/AltTextCheckTests.cs @@ -0,0 +1,230 @@ +using System.Text.Json; +using FluentCMS.Core.ChangeSets; +using FluentCMS.Core.Checks; +using FluentCMS.Core.Media; +using FluentCMS.Core.Schema; +using FluentCMS.Infrastructure.Persistence; +using FluentCMS.IntegrationTests.Content; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace FluentCMS.IntegrationTests.Media; + +public sealed class AltTextCheckTests +{ + private static readonly ContentTypeDefinition ProductPage = new( + [ + new FieldDefinition("headline", "text"), + new FieldDefinition("hero", "media"), + new FieldDefinition("gallery", "list", settings: JsonSerializer.Deserialize("""{"itemType": "media"}""")), + ]); + + private static readonly ContentTypeDefinition Product = new([new FieldDefinition("photo", "media")], isEntity: true); + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_media_field_naming_an_asset_without_alt_text_fails_and_names_the_field(string provider) + { + await using var scenario = await EntryScenario.CreateAsync(provider); + var bare = await PublishedAsync(scenario, await UploadAsync(scenario, "drill.jpg", altText: null)); + var described = await PublishedAsync(scenario, await UploadAsync(scenario, "saw.jpg", altText: "A circular saw on a bench.")); + + var outcome = await EvaluateAsync(scenario, ProductPage, $$"""{"headline": "Drills", "hero": "{{bare.Id}}", "gallery": ["{{described.Id}}", "{{bare.Id}}"]}"""); + + Assert.Equal(CheckVerdict.Fail, outcome.Verdict); + Assert.Equal(["/hero", "/gallery/1"], outcome.Findings.Select(finding => finding.Pointer)); + Assert.Equal( + "Add alt text to the image 'drill.jpg' in hero that says what it shows; set it with update_media.", + outcome.Findings[0].Message); + Assert.Equal(bare.Id.ToString(), outcome.Findings[0].Excerpt); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task Alt_text_of_the_published_version_passes_on_pages_and_entities(string provider) + { + await using var scenario = await EntryScenario.CreateAsync(provider); + var asset = await PublishedAsync(scenario, await UploadAsync(scenario, "drill.jpg", altText: null)); + var entity = $$"""{"photo": {"value": "{{asset.Id}}", "source": null, "owner": null, "validFrom": null, "validUntil": null, "lastVerified": null} }"""; + + var before = await EvaluateAsync(scenario, Product, entity); + await LibraryAsync(scenario, library => library.UpdateAsync(asset.Id, 1, AssetMetadata.Create("drill.jpg", null, [], "A cordless drill.", null).Value)); + await PublishedAsync(scenario, asset, 2); + var page = await EvaluateAsync(scenario, ProductPage, $$"""{"hero": "{{asset.Id}}"}"""); + var entityAfter = await EvaluateAsync(scenario, Product, entity); + var unknown = await EvaluateAsync(scenario, ProductPage, $$"""{"hero": "{{Guid.NewGuid()}}"}"""); + + Assert.Equal("/photo/value", Assert.Single(before.Findings).Pointer); + Assert.Equal( + [CheckVerdict.Pass, CheckVerdict.Pass, CheckVerdict.Pass], + new[] { page, entityAfter, unknown }.Select(outcome => outcome.Verdict)); + } + + // An image only a change set proposes can go live later in any version, even one a rejected set described, so content outside + // that set fails until the image is published or goes live with it. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task An_image_only_a_change_set_proposes_fails_outside_that_set_whatever_its_latest_version_says(string provider) + { + await using var scenario = await EntryScenario.CreateAsync(provider); + var asset = await UploadAsync(scenario, "drill.jpg", altText: null); + await ProposedAsync(scenario, asset.Id, version: 1); + await LibraryAsync(scenario, library => library.UpdateAsync(asset.Id, 1, AssetMetadata.Create("drill.jpg", null, [], "A cordless drill.", null).Value)); + var page = $$"""{"hero": "{{asset.Id}}"}"""; + + var other = await OpenAsync(scenario, "Something else."); + var holding = await ProposedAsync(scenario, asset.Id, version: 2); + + var alone = await EvaluateAsync(scenario, ProductPage, page); + var withOther = await EvaluateAsync(scenario, ProductPage, page, other); + var withHolding = await EvaluateAsync(scenario, ProductPage, page, holding); + await RejectAsync(scenario, holding); + var rejected = await EvaluateAsync(scenario, ProductPage, page); + + var finding = Assert.Single(alone.Findings); + Assert.Equal( + "The image 'drill.jpg' in hero is not published; give it alt text with update_media in the change set this content goes into, so both go live together.", + finding.Message); + Assert.Equal(CheckVerdict.Fail, withOther.Verdict); + Assert.Equal(CheckVerdict.Pass, withHolding.Verdict); + Assert.Equal(finding.Message, Assert.Single(rejected.Findings).Message); + } + + // A person's write in the library takes effect at once (MED-02), so its alt text counts on every page; a version a change set + // proposes later, pending or rejected, does not replace it. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_person_s_library_write_counts_at_once_and_a_set_s_later_version_does_not_replace_it(string provider) + { + await using var scenario = await EntryScenario.CreateAsync(provider); + var asset = await UploadAsync(scenario, "drill.jpg", altText: null); + var page = $$"""{"hero": "{{asset.Id}}"}"""; + var other = await OpenAsync(scenario, "Something else."); + + var uploaded = await EvaluateAsync(scenario, ProductPage, page); + await LibraryAsync(scenario, library => library.UpdateAsync(asset.Id, 1, AssetMetadata.Create("drill.jpg", null, [], "A cordless drill.", null).Value)); + var described = await EvaluateAsync(scenario, ProductPage, page); + var describedWithOther = await EvaluateAsync(scenario, ProductPage, page, other); + + await LibraryAsync(scenario, library => library.UpdateAsync(asset.Id, 2, AssetMetadata.Create("drill.jpg", null, [], null, null).Value)); + var proposing = await ProposedAsync(scenario, asset.Id, version: 3); + var pending = await EvaluateAsync(scenario, ProductPage, page); + var withProposing = await EvaluateAsync(scenario, ProductPage, page, proposing); + await RejectAsync(scenario, proposing); + var rejected = await EvaluateAsync(scenario, ProductPage, page); + + Assert.Equal( + "Add alt text to the image 'drill.jpg' in hero that says what it shows; set it with update_media.", + Assert.Single(uploaded.Findings).Message); + Assert.Equal( + [CheckVerdict.Pass, CheckVerdict.Pass, CheckVerdict.Pass, CheckVerdict.Pass], + new[] { described, describedWithOther, pending, rejected }.Select(outcome => outcome.Verdict)); + Assert.Equal(CheckVerdict.Fail, withProposing.Verdict); + } + + // Once a later version is published, an earlier write in the library no longer counts. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_library_write_older_than_the_published_version_does_not_count(string provider) + { + await using var scenario = await EntryScenario.CreateAsync(provider); + var asset = await UploadAsync(scenario, "drill.jpg", altText: "A cordless drill."); + await LibraryAsync(scenario, library => library.UpdateAsync(asset.Id, 1, AssetMetadata.Create("drill.jpg", null, [], null, null).Value)); + await ProposedAsync(scenario, asset.Id, version: 2); + await PublishedAsync(scenario, asset, 2); + + Assert.Equal(CheckVerdict.Fail, (await EvaluateAsync(scenario, ProductPage, $$"""{"hero": "{{asset.Id}}"}""")).Verdict); + } + + // MED-02: what goes live counts. A published image without alt text fails while a newer version with alt text waits in a set, + // and passes when the page goes live with that set. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task The_published_version_counts_unless_the_pages_change_set_proposes_another(string provider) + { + await using var scenario = await EntryScenario.CreateAsync(provider); + var asset = await PublishedAsync(scenario, await UploadAsync(scenario, "drill.jpg", altText: null)); + await LibraryAsync(scenario, library => library.UpdateAsync(asset.Id, 1, AssetMetadata.Create("drill.jpg", null, [], "A cordless drill.", null).Value)); + var page = $$"""{"hero": "{{asset.Id}}"}"""; + + var set = await ProposedAsync(scenario, asset.Id, version: 2, baseVersion: 1); + var other = await OpenAsync(scenario, "Something else."); + + Assert.Equal(CheckVerdict.Fail, (await EvaluateAsync(scenario, ProductPage, page)).Verdict); + Assert.Equal(CheckVerdict.Fail, (await EvaluateAsync(scenario, ProductPage, page, other)).Verdict); + Assert.Equal(CheckVerdict.Pass, (await EvaluateAsync(scenario, ProductPage, page, set)).Verdict); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task Media_that_is_not_an_image_needs_no_alt_text(string provider) + { + await using var scenario = await EntryScenario.CreateAsync(provider); + var metadata = AssetMetadata.Create("manual.pdf", null, [], null, null).Value; + var manual = (await LibraryAsync(scenario, library => library.UploadAsync(new NewAsset(new MemoryStream("%PDF-1.7"u8.ToArray()), "application/pdf", metadata)))).Value; + + Assert.Equal(CheckVerdict.Pass, (await EvaluateAsync(scenario, ProductPage, $$"""{"hero": "{{manual.Id}}"}""")).Verdict); + } + + private static async Task PublishedAsync(EntryScenario scenario, MediaAsset asset, int version = 1) + { + await scenario.Schema.QueryAsync(context => context.MediaAssets + .Where(row => row.Id == asset.Id) + .ExecuteUpdateAsync(setters => setters.SetProperty(row => row.PublishedVersion, version))); + return asset; + } + + private static async Task UploadAsync(EntryScenario scenario, string fileName, string? altText) + { + var metadata = AssetMetadata.Create(fileName, null, [], altText, null).Value; + return (await LibraryAsync(scenario, library => library.UploadAsync(new NewAsset(new MemoryStream(MediaFiles.Jpeg()), "image/jpeg", metadata)))).Value; + } + + private static async Task EvaluateAsync(EntryScenario scenario, ContentTypeDefinition definition, string fields, ChangeSet? set = null) + { + await using var scope = scenario.Services.CreateAsyncScope(); + var subject = new CheckSubject(JsonSerializer.Deserialize(fields), [], "product-page", definition, SetOperations: set?.Operations); + var input = new CheckInput(JsonSerializer.Deserialize("{}"), subject, scope.ServiceProvider); + + return await scope.ServiceProvider.GetRequiredService().EvaluateAsync("altText", input); + } + + private static async Task OpenAsync(EntryScenario scenario, string description) + { + await using var scope = scenario.Services.CreateAsyncScope(); + return (await scope.ServiceProvider.GetRequiredService().OpenAsync(new NewChangeSet(description, []))).Value; + } + + // A set of its own that proposes this version of the asset, as upload_media and update_media leave one. + private static async Task ProposedAsync(EntryScenario scenario, Guid assetId, int version, int? baseVersion = null) + { + var set = await OpenAsync(scenario, "Describe the drill."); + var kind = baseVersion is null ? OperationKind.Create : OperationKind.Update; + await using var scope = scenario.Services.CreateAsyncScope(); + var added = await scope.ServiceProvider.GetRequiredService() + .AddOperationAsync(set.Id, ChangeOperation.Create(kind, ItemKind.Media, assetId, baseVersion, version).Value); + return added.Value; + } + + private static Task RejectAsync(EntryScenario scenario, ChangeSet set) + { + return scenario.Schema.QueryAsync(context => context.ChangeSets + .Where(row => row.Id == set.Id) + .ExecuteUpdateAsync(setters => setters + .SetProperty(row => row.State, ChangeSetState.Rejected) + .SetProperty(row => row.RejectionReason, "Not now."))); + } + + private static async Task LibraryAsync(EntryScenario scenario, Func> action) + { + await using var scope = scenario.Services.CreateAsyncScope(); + return await action(scope.ServiceProvider.GetRequiredService()); + } +} diff --git a/tests/FluentCMS.Tests/Admin/MediaViewsTests.cs b/tests/FluentCMS.Tests/Admin/MediaViewsTests.cs index f4a8e16dd..440e911c8 100644 --- a/tests/FluentCMS.Tests/Admin/MediaViewsTests.cs +++ b/tests/FluentCMS.Tests/Admin/MediaViewsTests.cs @@ -226,6 +226,8 @@ public Task> GetAsync(Guid id, CancellationToken cancellation public Task> GetVersionAsync(Guid id, int version, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> LiveVersionAsync(Guid id, int? publishing = null, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> OpenAsync(Guid id, int? version = null, CancellationToken cancellationToken = default) => throw new NotSupportedException(); } diff --git a/tests/FluentCMS.Tests/Admin/PickerFieldTests.cs b/tests/FluentCMS.Tests/Admin/PickerFieldTests.cs index 897067187..f23d48891 100644 --- a/tests/FluentCMS.Tests/Admin/PickerFieldTests.cs +++ b/tests/FluentCMS.Tests/Admin/PickerFieldTests.cs @@ -248,6 +248,8 @@ public Task> ListAsync(MediaQuery query, Cancel public Task> GetVersionAsync(Guid id, int version, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> LiveVersionAsync(Guid id, int? publishing = null, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> FoldersAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task> OpenAsync(Guid id, int? version = null, CancellationToken cancellationToken = default) => throw new NotSupportedException(); diff --git a/tests/FluentCMS.Tests/ChangeSets/ChangeSetTests.cs b/tests/FluentCMS.Tests/ChangeSets/ChangeSetTests.cs index d97336b99..6dc004700 100644 --- a/tests/FluentCMS.Tests/ChangeSets/ChangeSetTests.cs +++ b/tests/FluentCMS.Tests/ChangeSets/ChangeSetTests.cs @@ -97,15 +97,17 @@ public void An_item_appears_at_most_once_in_a_set() // A set reopened to fix an item takes the item's newer draft: the same change from the same published version, proposing the // later draft, so the item still has one diff and one version to restore (CHG-03, CHG-06). [Theory] - [InlineData(OperationKind.Create, null, 1)] - [InlineData(OperationKind.Update, 3, 4)] - public void An_update_from_the_version_a_set_proposes_moves_the_item_s_operation_on(OperationKind kind, int? baseVersion, int proposed) + [InlineData(ItemKind.Entry, OperationKind.Create, null, 1)] + [InlineData(ItemKind.Entry, OperationKind.Update, 3, 4)] + [InlineData(ItemKind.Media, OperationKind.Create, null, 1)] + [InlineData(ItemKind.Media, OperationKind.Update, 3, 4)] + public void An_update_from_the_version_a_set_proposes_moves_the_item_s_operation_on(ItemKind itemKind, OperationKind kind, int? baseVersion, int proposed) { var changeSet = TestChangeSets.Open(); var item = Guid.NewGuid(); - changeSet.AddOperation(ChangeOperation.Create(kind, ItemKind.Entry, item, baseVersion, proposed).Value); + changeSet.AddOperation(ChangeOperation.Create(kind, itemKind, item, baseVersion, proposed).Value); - var result = changeSet.AddOperation(ChangeOperation.Create(kind, ItemKind.Entry, item, baseVersion, proposed + 1).Value); + var result = changeSet.AddOperation(ChangeOperation.Create(kind, itemKind, item, baseVersion, proposed + 1).Value); Assert.True(result.IsSuccess); var operation = Assert.Single(changeSet.Operations); @@ -118,16 +120,21 @@ public void An_operation_that_proposes_no_later_draft_or_that_changes_a_content_ var changeSet = TestChangeSets.Open(); var entry = Guid.NewGuid(); var type = Guid.NewGuid(); + var asset = Guid.NewGuid(); changeSet.AddOperation(TestChangeSets.Update(ItemKind.Entry, entry)); changeSet.AddOperation(TestChangeSets.Update(ItemKind.Schema, type)); + changeSet.AddOperation(TestChangeSets.Update(ItemKind.Media, asset)); var stale = changeSet.AddOperation(ChangeOperation.Create(OperationKind.Update, ItemKind.Entry, entry, 1, 2).Value); var schema = changeSet.AddOperation(ChangeOperation.Create(OperationKind.Update, ItemKind.Schema, type, 2, 3).Value); + var media = changeSet.AddOperation(ChangeOperation.Create(OperationKind.Update, ItemKind.Media, asset, 1, 2).Value); Assert.Equal(ChangeSetErrorCodes.ItemDuplicate, Assert.Single(stale.Errors).Code); Assert.Contains("update it from version 2", stale.Errors[0].Message); Assert.Equal(ChangeSetErrorCodes.ItemDuplicate, Assert.Single(schema.Errors).Code); - Assert.Equal([2, 2], changeSet.Operations.Select(operation => operation.ProposedVersion!.Value)); + Assert.Contains("The media " + asset, media.Errors[0].Message); + Assert.Contains("update it from version 2", media.Errors[0].Message); + Assert.Equal([2, 2, 2], changeSet.Operations.Select(operation => operation.ProposedVersion!.Value)); } // An item another set published after this one proposed it comes back as an update from that published version, which this set diff --git a/tests/FluentCMS.Tests/Checks/BuiltinCheckTypeTests.cs b/tests/FluentCMS.Tests/Checks/BuiltinCheckTypeTests.cs index d54af19d3..fe1738720 100644 --- a/tests/FluentCMS.Tests/Checks/BuiltinCheckTypeTests.cs +++ b/tests/FluentCMS.Tests/Checks/BuiltinCheckTypeTests.cs @@ -1,5 +1,8 @@ using System.Text.Json; +using FluentCMS.Core.ChangeSets; using FluentCMS.Core.Checks; +using FluentCMS.Core.Media; +using FluentCMS.Core.Results; using FluentCMS.Core.Schema; using Microsoft.Extensions.DependencyInjection; @@ -212,6 +215,83 @@ public sealed class AltTextCheckTypeTests : CheckTypeConformanceTests ("{}", Subject("""{"body": ""}""")), ("{}", Subject("""{"body": "![][logo]"}""")), ]; + + private static readonly Guid Image = Guid.Parse("0199f0a0-0000-7000-8000-000000000001"); + + // A media field comes from an agent too, so it can hold anything, and the set it goes live with can name the image in any way. + [Fact] + public async Task Never_throws_whatever_a_media_field_holds() + { + var type = Registry.Find(CheckTypeName)!; + var page = new ContentTypeDefinition( + [new FieldDefinition("hero", "media"), new FieldDefinition("gallery", "list", settings: Parse("""{"itemType": "media"}"""))]); + var entity = new ContentTypeDefinition([new FieldDefinition("photo", "media")], isEntity: true); + string[] values = ["null", "1", "\"\"", "\"not-an-id\"", $"\"{Guid.Empty}\"", $"\"{Image}\"", $$"""["{{Image}}", 1, null, {}]""", "{}", """{"value": []}"""]; + IReadOnlyList?[] sets = + [ + null, + [ChangeOperation.Create(OperationKind.Create, ItemKind.Media, Image, null, 1).Value], + [ChangeOperation.Create(OperationKind.Delete, ItemKind.Media, Image, 1, null).Value, ChangeOperation.Create(OperationKind.Create, ItemKind.Media, Image, null, 9).Value], + ]; + var subjects = values.SelectMany(value => new[] + { + (page, $$"""{"hero": {{value}}, "gallery": {{value}}}"""), + (entity, $$"""{"photo": {{value}}}"""), + (entity, $$$"""{"photo": {"value": {{{value}}}}}"""), + }); + + foreach (var (definition, fields) in subjects) + { + foreach (var operations in sets) + { + var subject = new CheckSubject(Parse(fields), [], "product-page", definition, SetOperations: operations); + + var exception = await Record.ExceptionAsync(() => type.EvaluateAsync(new CheckInput(Parse("{}"), subject, Provider), CancellationToken.None)); + + Assert.True(exception is null, $"{fields} threw {exception}"); + } + } + } + + protected override void AddServices(IServiceCollection services) + { + services.AddSingleton(); + } + + // One unpublished image at version 2, with no older version stored. + private sealed class OneImage : IMediaLibrary + { + public long MaxBytes => 1; + + public Task> GetAsync(Guid id, CancellationToken cancellationToken = default) + { + var version = new MediaAssetVersion( + Image, + 2, + new MediaBlob("image/png", 1, "00", "local", "key"), + AssetMetadata.Create("drill.png", null, [], null, null).Value, + DateTimeOffset.UnixEpoch); + return Task.FromResult(id == Image + ? Result.Success(new MediaAsset(Image, Guid.Empty, 2, null, version)) + : Result.Failure(MediaErrorCodes.AssetNotFound, "No such asset.")); + } + + public Task> GetVersionAsync(Guid id, int version, CancellationToken cancellationToken = default) => + Task.FromResult(Result.Failure(MediaErrorCodes.VersionNotFound, "No such version.")); + + public Task> LiveVersionAsync(Guid id, int? publishing = null, CancellationToken cancellationToken = default) => + Task.FromResult(Result.Success(null)); + + public Task> UploadAsync(NewAsset asset, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + + public Task> UpdateAsync(Guid id, int baseVersion, AssetMetadata metadata, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + + public Task> ListAsync(MediaQuery query, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + + public Task> FoldersAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException(); + + public Task> OpenAsync(Guid id, int? version = null, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + } } public sealed class SourcesCheckTypeTests : CheckTypeConformanceTests