diff --git a/app/api/Data/ProductModels.cs b/app/api/Data/ProductModels.cs index f4087af..2261eff 100644 --- a/app/api/Data/ProductModels.cs +++ b/app/api/Data/ProductModels.cs @@ -31,9 +31,25 @@ public sealed class Requirement // migration RequirementUpdatedBy after that migration ran; read it as the creator (ADR-050). public string? UpdatedBy { get; set; } public DateTime? ArchivedAt { get; set; } + // One milestone or sprint label, or null (ADR-053). Setting it is not an edit: it does not + // change Version, so a decision never becomes outdated because of it. + public string? Milestone { get; set; } public int Version { get; set; } = 1; } +// A requirement's acceptance criteria as they were saved at one point (ADR-051). Criteria is a +// JSON array of the criterion texts in order. A new version is written only when that list +// changes, numbered from 1 per requirement. Rows are only ever added. +public sealed class RequirementCriteriaVersion +{ + public Guid Id { get; set; } + public Guid RequirementId { get; set; } + public int Number { get; set; } + public required string Criteria { get; set; } + public required string CreatedBy { get; set; } + public DateTime CreatedAt { get; set; } +} + public sealed class AcceptanceCriterion { public Guid Id { get; set; } @@ -99,6 +115,28 @@ public sealed class RequirementEvidence public DateTime RefreshedAt { get; set; } } +// One requirement marked as a follow-up of another in the same project (ADR-052). Linking does +// not change either requirement's version. +public sealed class RequirementFollowUp +{ + public Guid RequirementId { get; set; } + public Guid OriginalRequirementId { get; set; } + public required string LinkedBy { get; set; } + public DateTime LinkedAt { get; set; } +} + +// Who added or removed a follow-up link, and when (ADR-052). Action is "added" or "removed". +// Rows are only ever added. +public sealed class RequirementFollowUpEvent +{ + public Guid Id { get; set; } + public Guid RequirementId { get; set; } + public Guid OriginalRequirementId { get; set; } + public required string Action { get; set; } + public required string Actor { get; set; } + public DateTime OccurredAt { get; set; } +} + // A person's decision on a requirement (ADR-038). Evidence is a JSON array of the evidence links // as they were when the decision was made. Rows are only ever added. public sealed class RequirementReview @@ -108,6 +146,9 @@ public sealed class RequirementReview public required string Decision { get; set; } public required string Note { get; set; } public int RequirementVersion { get; set; } + // The criteria version the decision was made against (ADR-051). Null for a decision recorded + // before criteria versions were kept on an earlier requirement version, or by an older API. + public int? CriteriaVersion { get; set; } public required string Evidence { get; set; } public required string DecidedBy { get; set; } public DateTime DecidedAt { get; set; } diff --git a/app/api/Data/SpecThreadDbContext.cs b/app/api/Data/SpecThreadDbContext.cs index ba0b880..d865d17 100644 --- a/app/api/Data/SpecThreadDbContext.cs +++ b/app/api/Data/SpecThreadDbContext.cs @@ -9,6 +9,7 @@ public sealed class SpecThreadDbContext(DbContextOptions op public DbSet ProjectMembers => Set(); public DbSet Requirements => Set(); public DbSet AcceptanceCriteria => Set(); + public DbSet RequirementCriteriaVersions => Set(); public DbSet ProjectRepositories => Set(); public DbSet RequirementEvidence => Set(); public DbSet RequirementReviews => Set(); @@ -19,6 +20,8 @@ public sealed class SpecThreadDbContext(DbContextOptions op public DbSet Documents => Set(); public DbSet DocumentContents => Set(); public DbSet RequirementDocuments => Set(); + public DbSet RequirementFollowUps => Set(); + public DbSet RequirementFollowUpEvents => Set(); protected override void OnModelCreating(ModelBuilder model) { @@ -135,6 +138,7 @@ protected override void OnModelCreating(ModelBuilder model) { table.HasCheckConstraint("ck_requirements_title", "length(btrim(title)) > 0"); table.HasCheckConstraint("ck_requirements_version", "version > 0"); + table.HasCheckConstraint("ck_requirements_milestone", "milestone IS NULL OR (btrim(milestone) = milestone AND length(milestone) BETWEEN 1 AND 100)"); }); requirement.HasKey(x => x.Id); requirement.Property(x => x.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()"); @@ -146,6 +150,7 @@ protected override void OnModelCreating(ModelBuilder model) requirement.Property(x => x.UpdatedAt).HasColumnName("updated_at").HasDefaultValueSql("now()"); requirement.Property(x => x.UpdatedBy).HasColumnName("updated_by"); requirement.Property(x => x.ArchivedAt).HasColumnName("archived_at"); + requirement.Property(x => x.Milestone).HasColumnName("milestone").HasMaxLength(100); requirement.Property(x => x.Version).HasColumnName("version").HasDefaultValue(1).IsConcurrencyToken(); requirement.HasOne().WithMany().HasForeignKey(x => x.ProjectId).OnDelete(DeleteBehavior.Restrict); requirement.HasOne().WithMany().HasForeignKey(x => x.CreatedBy).OnDelete(DeleteBehavior.Restrict); @@ -165,6 +170,25 @@ protected override void OnModelCreating(ModelBuilder model) criterion.HasIndex(x => new { x.RequirementId, x.Position }).IsUnique(); criterion.HasOne().WithMany().HasForeignKey(x => x.RequirementId).OnDelete(DeleteBehavior.Restrict); + var criteriaVersion = model.Entity(); + criteriaVersion.ToTable("requirement_criteria_versions", table => + { + table.HasCheckConstraint("ck_requirement_criteria_versions_number", "number > 0"); + // An array of criterion texts, possibly empty; nothing else is ever read back (ADR-051). + table.HasCheckConstraint("ck_requirement_criteria_versions_criteria", + "jsonb_typeof(criteria) = 'array' AND NOT jsonb_path_exists(criteria, 'strict $[*] ? (@.type() != \"string\")')"); + }); + criteriaVersion.HasKey(x => x.Id); + criteriaVersion.Property(x => x.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()"); + criteriaVersion.Property(x => x.RequirementId).HasColumnName("requirement_id"); + criteriaVersion.Property(x => x.Number).HasColumnName("number"); + criteriaVersion.Property(x => x.Criteria).HasColumnName("criteria").HasColumnType("jsonb"); + criteriaVersion.Property(x => x.CreatedBy).HasColumnName("created_by"); + criteriaVersion.Property(x => x.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("now()"); + criteriaVersion.HasIndex(x => new { x.RequirementId, x.Number }).IsUnique(); + criteriaVersion.HasOne().WithMany().HasForeignKey(x => x.RequirementId).OnDelete(DeleteBehavior.Restrict); + criteriaVersion.HasOne().WithMany().HasForeignKey(x => x.CreatedBy).OnDelete(DeleteBehavior.Restrict); + var repository = model.Entity(); repository.ToTable("project_repositories", table => { @@ -243,6 +267,7 @@ protected override void OnModelCreating(ModelBuilder model) { table.HasCheckConstraint("ck_requirement_reviews_decision", "decision IN ('accepted', 'rejected', 'more_evidence')"); table.HasCheckConstraint("ck_requirement_reviews_version", "requirement_version > 0"); + table.HasCheckConstraint("ck_requirement_reviews_criteria_version", "criteria_version IS NULL OR criteria_version > 0"); // Rejecting or asking for more has to say why. table.HasCheckConstraint("ck_requirement_reviews_note", "decision = 'accepted' OR length(btrim(note)) > 0"); table.HasCheckConstraint("ck_requirement_reviews_evidence", "jsonb_typeof(evidence) = 'array'"); @@ -253,6 +278,7 @@ protected override void OnModelCreating(ModelBuilder model) review.Property(x => x.Decision).HasColumnName("decision"); review.Property(x => x.Note).HasColumnName("note").HasDefaultValue(""); review.Property(x => x.RequirementVersion).HasColumnName("requirement_version"); + review.Property(x => x.CriteriaVersion).HasColumnName("criteria_version"); review.Property(x => x.Evidence).HasColumnName("evidence").HasColumnType("jsonb"); review.Property(x => x.DecidedBy).HasColumnName("decided_by"); review.Property(x => x.DecidedAt).HasColumnName("decided_at").HasDefaultValueSql("now()"); @@ -300,5 +326,37 @@ protected override void OnModelCreating(ModelBuilder model) citation.HasOne().WithMany().HasForeignKey(x => x.RequirementId).OnDelete(DeleteBehavior.Restrict); citation.HasOne().WithMany().HasForeignKey(x => x.DocumentId).OnDelete(DeleteBehavior.Restrict); citation.HasOne().WithMany().HasForeignKey(x => x.LinkedBy).OnDelete(DeleteBehavior.Restrict); + + var followUp = model.Entity(); + followUp.ToTable("requirement_follow_ups", table => + table.HasCheckConstraint("ck_requirement_follow_ups_distinct", "requirement_id <> original_requirement_id")); + followUp.HasKey(x => new { x.RequirementId, x.OriginalRequirementId }); + followUp.Property(x => x.RequirementId).HasColumnName("requirement_id"); + followUp.Property(x => x.OriginalRequirementId).HasColumnName("original_requirement_id"); + followUp.Property(x => x.LinkedBy).HasColumnName("linked_by"); + followUp.Property(x => x.LinkedAt).HasColumnName("linked_at").HasDefaultValueSql("now()"); + followUp.HasIndex(x => x.OriginalRequirementId); + followUp.HasOne().WithMany().HasForeignKey(x => x.RequirementId).OnDelete(DeleteBehavior.Restrict); + followUp.HasOne().WithMany().HasForeignKey(x => x.OriginalRequirementId).OnDelete(DeleteBehavior.Restrict); + followUp.HasOne().WithMany().HasForeignKey(x => x.LinkedBy).OnDelete(DeleteBehavior.Restrict); + + var followUpEvent = model.Entity(); + followUpEvent.ToTable("requirement_follow_up_events", table => + { + table.HasCheckConstraint("ck_requirement_follow_up_events_action", "action IN ('added', 'removed')"); + table.HasCheckConstraint("ck_requirement_follow_up_events_distinct", "requirement_id <> original_requirement_id"); + }); + followUpEvent.HasKey(x => x.Id); + followUpEvent.Property(x => x.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()"); + followUpEvent.Property(x => x.RequirementId).HasColumnName("requirement_id"); + followUpEvent.Property(x => x.OriginalRequirementId).HasColumnName("original_requirement_id"); + followUpEvent.Property(x => x.Action).HasColumnName("action"); + followUpEvent.Property(x => x.Actor).HasColumnName("actor"); + followUpEvent.Property(x => x.OccurredAt).HasColumnName("occurred_at").HasDefaultValueSql("now()"); + followUpEvent.HasIndex(x => x.RequirementId); + followUpEvent.HasIndex(x => x.OriginalRequirementId); + followUpEvent.HasOne().WithMany().HasForeignKey(x => x.RequirementId).OnDelete(DeleteBehavior.Restrict); + followUpEvent.HasOne().WithMany().HasForeignKey(x => x.OriginalRequirementId).OnDelete(DeleteBehavior.Restrict); + followUpEvent.HasOne().WithMany().HasForeignKey(x => x.Actor).OnDelete(DeleteBehavior.Restrict); } } diff --git a/app/api/Migrations/20261030001133_RequirementCriteriaVersions.Designer.cs b/app/api/Migrations/20261030001133_RequirementCriteriaVersions.Designer.cs new file mode 100644 index 0000000..18e6eed --- /dev/null +++ b/app/api/Migrations/20261030001133_RequirementCriteriaVersions.Designer.cs @@ -0,0 +1,1325 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using SpecThread.Api.Data; + +#nullable disable + +namespace SpecThread.Api.Migrations +{ + [DbContext(typeof(SpecThreadDbContext))] + [Migration("20261030001133_RequirementCriteriaVersions")] + partial class RequirementCriteriaVersions + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("public") + .HasAnnotation("ProductVersion", "10.0.12") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("SpecThread.Api.Data.AcceptanceCriterion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.Property("RequirementId") + .HasColumnType("uuid") + .HasColumnName("requirement_id"); + + b.Property("Text") + .IsRequired() + .HasColumnType("text") + .HasColumnName("text"); + + b.HasKey("Id"); + + b.HasIndex("RequirementId", "Position") + .IsUnique(); + + b.ToTable("acceptance_criteria", "public", t => + { + t.HasCheckConstraint("ck_criteria_position", "position >= 0"); + + t.HasCheckConstraint("ck_criteria_text", "length(btrim(text)) > 0"); + }); + }); + + modelBuilder.Entity("SpecThread.Api.Data.AuthAccount", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("AccessToken") + .HasColumnType("text") + .HasColumnName("accessToken"); + + b.Property("AccessTokenExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("accessTokenExpiresAt"); + + b.Property("AccountId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("accountId"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("createdAt"); + + b.Property("IdToken") + .HasColumnType("text") + .HasColumnName("idToken"); + + b.Property("Password") + .HasColumnType("text") + .HasColumnName("password"); + + b.Property("ProviderId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("providerId"); + + b.Property("RefreshToken") + .HasColumnType("text") + .HasColumnName("refreshToken"); + + b.Property("RefreshTokenExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("refreshTokenExpiresAt"); + + b.Property("Scope") + .HasColumnType("text") + .HasColumnName("scope"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updatedAt"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("userId"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("ProviderId", "AccountId") + .IsUnique(); + + b.ToTable("account", "public"); + }); + + modelBuilder.Entity("SpecThread.Api.Data.AuthRateLimit", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("Count") + .HasColumnType("integer") + .HasColumnName("count"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text") + .HasColumnName("key"); + + b.Property("LastRequest") + .HasColumnType("bigint") + .HasColumnName("lastRequest"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.HasIndex("LastRequest"); + + b.ToTable("rateLimit", "public"); + }); + + modelBuilder.Entity("SpecThread.Api.Data.AuthSession", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("createdAt"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expiresAt"); + + b.Property("IpAddress") + .HasColumnType("text") + .HasColumnName("ipAddress"); + + b.Property("Token") + .IsRequired() + .HasColumnType("text") + .HasColumnName("token"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updatedAt"); + + b.Property("UserAgent") + .HasColumnType("text") + .HasColumnName("userAgent"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("userId"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("session", "public"); + }); + + modelBuilder.Entity("SpecThread.Api.Data.AuthSigningKey", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("Alg") + .HasColumnType("text") + .HasColumnName("alg"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("createdAt"); + + b.Property("Crv") + .HasColumnType("text") + .HasColumnName("crv"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expiresAt"); + + b.Property("PrivateKey") + .IsRequired() + .HasColumnType("text") + .HasColumnName("privateKey"); + + b.Property("PublicKey") + .IsRequired() + .HasColumnType("text") + .HasColumnName("publicKey"); + + b.HasKey("Id"); + + b.ToTable("jwks", "public"); + }); + + modelBuilder.Entity("SpecThread.Api.Data.AuthUser", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("createdAt"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text") + .HasColumnName("email"); + + b.Property("EmailVerified") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("emailVerified"); + + b.Property("Image") + .HasColumnType("text") + .HasColumnName("image"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updatedAt"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("user", "public"); + }); + + modelBuilder.Entity("SpecThread.Api.Data.AuthVerification", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("createdAt"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expiresAt"); + + b.Property("Identifier") + .IsRequired() + .HasColumnType("text") + .HasColumnName("identifier"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updatedAt"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.HasKey("Id"); + + b.HasIndex("Identifier"); + + b.ToTable("verification", "public"); + }); + + modelBuilder.Entity("SpecThread.Api.Data.Document", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ArchivedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("archived_at"); + + b.Property("ContentType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("content_type"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("file_name"); + + b.Property("ProjectId") + .HasColumnType("uuid") + .HasColumnName("project_id"); + + b.Property("SizeBytes") + .HasColumnType("bigint") + .HasColumnName("size_bytes"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text") + .HasColumnName("title"); + + b.Property("UploadedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("uploaded_at") + .HasDefaultValueSql("now()"); + + b.Property("UploadedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("uploaded_by"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.HasIndex("UploadedBy"); + + b.ToTable("documents", "public", t => + { + t.HasCheckConstraint("ck_documents_content_type", "content_type IN ('application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document')"); + + t.HasCheckConstraint("ck_documents_file_name", "length(btrim(file_name)) > 0"); + + t.HasCheckConstraint("ck_documents_size", "size_bytes BETWEEN 1 AND 4194304"); + + t.HasCheckConstraint("ck_documents_title", "length(btrim(title)) > 0"); + }); + }); + + modelBuilder.Entity("SpecThread.Api.Data.DocumentContent", b => + { + b.Property("DocumentId") + .HasColumnType("uuid") + .HasColumnName("document_id"); + + b.Property("Content") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("content"); + + b.HasKey("DocumentId"); + + b.ToTable("document_contents", "public"); + }); + + modelBuilder.Entity("SpecThread.Api.Data.Project", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ArchivedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("archived_at"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("owner_user_id"); + + b.Property("TeamId") + .HasColumnType("uuid") + .HasColumnName("team_id"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.HasIndex("TeamId"); + + b.ToTable("projects", "public", t => + { + t.HasCheckConstraint("ck_projects_name", "length(btrim(name)) > 0"); + }); + }); + + modelBuilder.Entity("SpecThread.Api.Data.ProjectMember", b => + { + b.Property("ProjectId") + .HasColumnType("uuid") + .HasColumnName("project_id"); + + b.Property("UserId") + .HasColumnType("text") + .HasColumnName("user_id"); + + b.Property("JoinedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_at") + .HasDefaultValueSql("now()"); + + b.HasKey("ProjectId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("project_members", "public"); + }); + + modelBuilder.Entity("SpecThread.Api.Data.ProjectRepository", b => + { + b.Property("ProjectId") + .HasColumnType("uuid") + .HasColumnName("project_id"); + + b.Property("ConnectedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("connected_at") + .HasDefaultValueSql("now()"); + + b.Property("ConnectedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("connected_by"); + + b.Property("InstallationId") + .HasColumnType("bigint") + .HasColumnName("installation_id"); + + b.Property("IsPrivate") + .HasColumnType("boolean") + .HasColumnName("is_private"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Owner") + .IsRequired() + .HasColumnType("text") + .HasColumnName("owner"); + + b.Property("RepositoryId") + .HasColumnType("bigint") + .HasColumnName("repository_id"); + + b.HasKey("ProjectId"); + + b.HasIndex("ConnectedBy"); + + b.ToTable("project_repositories", "public", t => + { + t.HasCheckConstraint("ck_project_repositories_ids", "installation_id > 0 AND repository_id > 0"); + + t.HasCheckConstraint("ck_project_repositories_name", "length(btrim(owner)) > 0 AND length(btrim(name)) > 0"); + }); + }); + + modelBuilder.Entity("SpecThread.Api.Data.Requirement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ArchivedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("archived_at"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("ProjectId") + .HasColumnType("uuid") + .HasColumnName("project_id"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text") + .HasColumnName("title"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UpdatedBy") + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1) + .HasColumnName("version"); + + b.HasKey("Id"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("ProjectId"); + + b.HasIndex("UpdatedBy"); + + b.ToTable("requirements", "public", t => + { + t.HasCheckConstraint("ck_requirements_title", "length(btrim(title)) > 0"); + + t.HasCheckConstraint("ck_requirements_version", "version > 0"); + }); + }); + + modelBuilder.Entity("SpecThread.Api.Data.RequirementCriteriaVersion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("Criteria") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("criteria"); + + b.Property("Number") + .HasColumnType("integer") + .HasColumnName("number"); + + b.Property("RequirementId") + .HasColumnType("uuid") + .HasColumnName("requirement_id"); + + b.HasKey("Id"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("RequirementId", "Number") + .IsUnique(); + + b.ToTable("requirement_criteria_versions", "public", t => + { + t.HasCheckConstraint("ck_requirement_criteria_versions_criteria", "jsonb_typeof(criteria) = 'array'"); + + t.HasCheckConstraint("ck_requirement_criteria_versions_number", "number > 0"); + }); + }); + + modelBuilder.Entity("SpecThread.Api.Data.RequirementDocument", b => + { + b.Property("RequirementId") + .HasColumnType("uuid") + .HasColumnName("requirement_id"); + + b.Property("DocumentId") + .HasColumnType("uuid") + .HasColumnName("document_id"); + + b.Property("LinkedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("linked_at") + .HasDefaultValueSql("now()"); + + b.Property("LinkedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("linked_by"); + + b.HasKey("RequirementId", "DocumentId"); + + b.HasIndex("DocumentId"); + + b.HasIndex("LinkedBy"); + + b.ToTable("requirement_documents", "public"); + }); + + modelBuilder.Entity("SpecThread.Api.Data.RequirementEvidence", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Additions") + .HasColumnType("integer") + .HasColumnName("additions"); + + b.Property("Author") + .HasColumnType("text") + .HasColumnName("author"); + + b.Property("ChangedFiles") + .HasColumnType("integer") + .HasColumnName("changed_files"); + + b.Property("CheckCount") + .HasColumnType("integer") + .HasColumnName("check_count"); + + b.Property("Checks") + .HasColumnType("jsonb") + .HasColumnName("checks"); + + b.Property("ChecksReadAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("checks_read_at"); + + b.Property("CommitCount") + .HasColumnType("integer") + .HasColumnName("commit_count"); + + b.Property("Commits") + .HasColumnType("jsonb") + .HasColumnName("commits"); + + b.Property("Contains") + .HasColumnType("jsonb") + .HasColumnName("contains"); + + b.Property("Deletions") + .HasColumnType("integer") + .HasColumnName("deletions"); + + b.Property("GitHubClosedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("github_closed_at"); + + b.Property("GitHubCreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("github_created_at"); + + b.Property("GitHubUpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("github_updated_at"); + + b.Property("Kind") + .IsRequired() + .HasColumnType("text") + .HasColumnName("kind"); + + b.Property("LinkedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("linked_at") + .HasDefaultValueSql("now()"); + + b.Property("LinkedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("linked_by"); + + b.Property("MergeSha") + .HasColumnType("text") + .HasColumnName("merge_sha"); + + b.Property("Number") + .HasColumnType("integer") + .HasColumnName("number"); + + b.Property("Prerelease") + .HasColumnType("boolean") + .HasColumnName("prerelease"); + + b.Property("RefreshedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("refreshed_at") + .HasDefaultValueSql("now()"); + + b.Property("RepositoryId") + .HasColumnType("bigint") + .HasColumnName("repository_id"); + + b.Property("RepositoryName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("repository_name"); + + b.Property("RepositoryOwner") + .IsRequired() + .HasColumnType("text") + .HasColumnName("repository_owner"); + + b.Property("RequirementId") + .HasColumnType("uuid") + .HasColumnName("requirement_id"); + + b.Property("Sha") + .HasColumnType("text") + .HasColumnName("sha"); + + b.Property("Source") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("manual") + .HasColumnName("source"); + + b.Property("State") + .HasColumnType("text") + .HasColumnName("state"); + + b.Property("Tag") + .HasColumnType("text") + .HasColumnName("tag"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text") + .HasColumnName("title"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text") + .HasColumnName("url"); + + b.HasKey("Id"); + + b.HasIndex("LinkedBy"); + + b.HasIndex("RequirementId", "RepositoryId", "Number") + .IsUnique(); + + b.HasIndex("RequirementId", "RepositoryId", "Sha") + .IsUnique() + .HasFilter("kind = 'commit'"); + + b.HasIndex("RequirementId", "RepositoryId", "Tag") + .IsUnique() + .HasFilter("kind = 'release'"); + + b.ToTable("requirement_evidence", "public", t => + { + t.HasCheckConstraint("ck_requirement_evidence_changes", "COALESCE(additions, 0) >= 0 AND COALESCE(deletions, 0) >= 0 AND COALESCE(changed_files, 0) >= 0 AND COALESCE(commit_count, 0) >= 0"); + + t.HasCheckConstraint("ck_requirement_evidence_checks", "COALESCE(check_count, 0) >= 0"); + + t.HasCheckConstraint("ck_requirement_evidence_identity", "(kind = 'commit' AND sha IS NOT NULL AND sha ~ '^[0-9a-f]{40}$' AND number IS NULL AND state IS NULL AND tag IS NULL) OR (kind IN ('issue', 'pull_request') AND number IS NOT NULL AND number > 0 AND state IS NOT NULL AND tag IS NULL) OR (kind = 'release' AND tag IS NOT NULL AND length(btrim(tag)) > 0 AND number IS NULL AND state IS NULL)"); + + t.HasCheckConstraint("ck_requirement_evidence_kind", "kind IN ('issue', 'pull_request', 'commit', 'release')"); + + t.HasCheckConstraint("ck_requirement_evidence_repository", "repository_id > 0"); + + t.HasCheckConstraint("ck_requirement_evidence_source", "source IN ('manual', 'suggested')"); + + t.HasCheckConstraint("ck_requirement_evidence_state", "state IS NULL OR state IN ('open', 'closed', 'merged')"); + }); + }); + + modelBuilder.Entity("SpecThread.Api.Data.RequirementReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CriteriaVersion") + .HasColumnType("integer") + .HasColumnName("criteria_version"); + + b.Property("DecidedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("decided_at") + .HasDefaultValueSql("now()"); + + b.Property("DecidedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("decided_by"); + + b.Property("Decision") + .IsRequired() + .HasColumnType("text") + .HasColumnName("decision"); + + b.Property("Evidence") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("evidence"); + + b.Property("Note") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("") + .HasColumnName("note"); + + b.Property("RequirementId") + .HasColumnType("uuid") + .HasColumnName("requirement_id"); + + b.Property("RequirementVersion") + .HasColumnType("integer") + .HasColumnName("requirement_version"); + + b.HasKey("Id"); + + b.HasIndex("DecidedBy"); + + b.HasIndex("RequirementId", "DecidedAt"); + + b.ToTable("requirement_reviews", "public", t => + { + t.HasCheckConstraint("ck_requirement_reviews_criteria_version", "criteria_version IS NULL OR criteria_version > 0"); + + t.HasCheckConstraint("ck_requirement_reviews_decision", "decision IN ('accepted', 'rejected', 'more_evidence')"); + + t.HasCheckConstraint("ck_requirement_reviews_evidence", "jsonb_typeof(evidence) = 'array'"); + + t.HasCheckConstraint("ck_requirement_reviews_note", "decision = 'accepted' OR length(btrim(note)) > 0"); + + t.HasCheckConstraint("ck_requirement_reviews_version", "requirement_version > 0"); + }); + }); + + modelBuilder.Entity("SpecThread.Api.Data.Team", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("owner_user_id"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("teams", "public", t => + { + t.HasCheckConstraint("ck_teams_name", "length(btrim(name)) > 0"); + }); + }); + + modelBuilder.Entity("SpecThread.Api.Data.TeamInvitation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AcceptedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("accepted_at"); + + b.Property("AcceptedBy") + .HasColumnType("text") + .HasColumnName("accepted_by"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(254) + .HasColumnType("character varying(254)") + .HasColumnName("email"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("InvitedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("invited_by"); + + b.Property("IssuedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("issued_at"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at"); + + b.Property("Role") + .IsRequired() + .HasColumnType("text") + .HasColumnName("role"); + + b.Property("TeamId") + .HasColumnType("uuid") + .HasColumnName("team_id"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("token_hash"); + + b.HasKey("Id"); + + b.HasIndex("AcceptedBy"); + + b.HasIndex("InvitedBy"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("TeamId", "Email") + .IsUnique() + .HasFilter("accepted_at IS NULL AND revoked_at IS NULL"); + + b.ToTable("team_invitations", "public", t => + { + t.HasCheckConstraint("ck_team_invitations_expiry", "expires_at > issued_at"); + + t.HasCheckConstraint("ck_team_invitations_resolution", "accepted_at IS NULL OR revoked_at IS NULL"); + + t.HasCheckConstraint("ck_team_invitations_role", "role IN ('admin', 'member')"); + }); + }); + + modelBuilder.Entity("SpecThread.Api.Data.TeamMember", b => + { + b.Property("TeamId") + .HasColumnType("uuid") + .HasColumnName("team_id"); + + b.Property("UserId") + .HasColumnType("text") + .HasColumnName("user_id"); + + b.Property("IsExpanded") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("is_expanded"); + + b.Property("IsFavorite") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_favorite"); + + b.Property("JoinedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_at") + .HasDefaultValueSql("now()"); + + b.Property("Role") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("member") + .HasColumnName("role"); + + b.HasKey("TeamId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("team_members", "public", t => + { + t.HasCheckConstraint("ck_team_members_role", "role IN ('admin', 'member')"); + }); + }); + + modelBuilder.Entity("SpecThread.Api.Data.UserOnboarding", b => + { + b.Property("UserId") + .HasColumnType("text") + .HasColumnName("user_id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.HasKey("UserId"); + + b.ToTable("user_onboarding", "public"); + }); + + modelBuilder.Entity("SpecThread.Api.Data.AcceptanceCriterion", b => + { + b.HasOne("SpecThread.Api.Data.Requirement", null) + .WithMany() + .HasForeignKey("RequirementId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.AuthAccount", b => + { + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.AuthSession", b => + { + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.Document", b => + { + b.HasOne("SpecThread.Api.Data.Project", null) + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("UploadedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.DocumentContent", b => + { + b.HasOne("SpecThread.Api.Data.Document", null) + .WithOne() + .HasForeignKey("SpecThread.Api.Data.DocumentContent", "DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.Project", b => + { + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.Team", null) + .WithMany() + .HasForeignKey("TeamId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.ProjectMember", b => + { + b.HasOne("SpecThread.Api.Data.Project", null) + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.ProjectRepository", b => + { + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("ConnectedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.Project", null) + .WithOne() + .HasForeignKey("SpecThread.Api.Data.ProjectRepository", "ProjectId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.Requirement", b => + { + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.Project", null) + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("UpdatedBy") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("SpecThread.Api.Data.RequirementCriteriaVersion", b => + { + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.Requirement", null) + .WithMany() + .HasForeignKey("RequirementId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.RequirementDocument", b => + { + b.HasOne("SpecThread.Api.Data.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("LinkedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.Requirement", null) + .WithMany() + .HasForeignKey("RequirementId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.RequirementEvidence", b => + { + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("LinkedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.Requirement", null) + .WithMany() + .HasForeignKey("RequirementId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.RequirementReview", b => + { + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("DecidedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.Requirement", null) + .WithMany() + .HasForeignKey("RequirementId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.Team", b => + { + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.TeamInvitation", b => + { + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("AcceptedBy") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("InvitedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.Team", null) + .WithMany() + .HasForeignKey("TeamId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.TeamMember", b => + { + b.HasOne("SpecThread.Api.Data.Team", null) + .WithMany() + .HasForeignKey("TeamId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.UserOnboarding", b => + { + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/app/api/Migrations/20261030001133_RequirementCriteriaVersions.cs b/app/api/Migrations/20261030001133_RequirementCriteriaVersions.cs new file mode 100644 index 0000000..2384ccf --- /dev/null +++ b/app/api/Migrations/20261030001133_RequirementCriteriaVersions.cs @@ -0,0 +1,114 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SpecThread.Api.Migrations +{ + /// + public partial class RequirementCriteriaVersions : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "criteria_version", + schema: "public", + table: "requirement_reviews", + type: "integer", + nullable: true); + + migrationBuilder.CreateTable( + name: "requirement_criteria_versions", + schema: "public", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"), + requirement_id = table.Column(type: "uuid", nullable: false), + number = table.Column(type: "integer", nullable: false), + criteria = table.Column(type: "jsonb", nullable: false), + created_by = table.Column(type: "text", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()") + }, + constraints: table => + { + table.PrimaryKey("PK_requirement_criteria_versions", x => x.id); + table.CheckConstraint("ck_requirement_criteria_versions_criteria", "jsonb_typeof(criteria) = 'array'"); + table.CheckConstraint("ck_requirement_criteria_versions_number", "number > 0"); + table.ForeignKey( + name: "FK_requirement_criteria_versions_requirements_requirement_id", + column: x => x.requirement_id, + principalSchema: "public", + principalTable: "requirements", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_requirement_criteria_versions_user_created_by", + column: x => x.created_by, + principalSchema: "public", + principalTable: "user", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.AddCheckConstraint( + name: "ck_requirement_reviews_criteria_version", + schema: "public", + table: "requirement_reviews", + sql: "criteria_version IS NULL OR criteria_version > 0"); + + migrationBuilder.CreateIndex( + name: "IX_requirement_criteria_versions_created_by", + schema: "public", + table: "requirement_criteria_versions", + column: "created_by"); + + migrationBuilder.CreateIndex( + name: "IX_requirement_criteria_versions_requirement_id_number", + schema: "public", + table: "requirement_criteria_versions", + columns: new[] { "requirement_id", "number" }, + unique: true); + + migrationBuilder.Sql(""" + ALTER TABLE public."requirement_criteria_versions" ENABLE ROW LEVEL SECURITY; + REVOKE ALL ON TABLE public."requirement_criteria_versions" FROM PUBLIC; + DO $security$ + BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN + REVOKE ALL ON TABLE public."requirement_criteria_versions" FROM anon; + END IF; + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN + REVOKE ALL ON TABLE public."requirement_criteria_versions" FROM authenticated; + END IF; + END $security$; + INSERT INTO public.requirement_criteria_versions (requirement_id, number, criteria, created_by, created_at) + SELECT r.id, 1, + COALESCE((SELECT jsonb_agg(c.text ORDER BY c.position) FROM public.acceptance_criteria c WHERE c.requirement_id = r.id), '[]'::jsonb), + COALESCE(r.updated_by, r.created_by), r.updated_at + FROM public.requirements r; + UPDATE public.requirement_reviews v SET criteria_version = 1 + FROM public.requirements r + WHERE r.id = v.requirement_id AND v.requirement_version = r.version AND v.criteria_version IS NULL; + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "requirement_criteria_versions", + schema: "public"); + + migrationBuilder.DropCheckConstraint( + name: "ck_requirement_reviews_criteria_version", + schema: "public", + table: "requirement_reviews"); + + migrationBuilder.DropColumn( + name: "criteria_version", + schema: "public", + table: "requirement_reviews"); + } + } +} diff --git a/app/api/Migrations/20261030001226_RequirementMilestone.Designer.cs b/app/api/Migrations/20261030001226_RequirementMilestone.Designer.cs new file mode 100644 index 0000000..8ae8864 --- /dev/null +++ b/app/api/Migrations/20261030001226_RequirementMilestone.Designer.cs @@ -0,0 +1,1332 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using SpecThread.Api.Data; + +#nullable disable + +namespace SpecThread.Api.Migrations +{ + [DbContext(typeof(SpecThreadDbContext))] + [Migration("20261030001226_RequirementMilestone")] + partial class RequirementMilestone + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("public") + .HasAnnotation("ProductVersion", "10.0.12") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("SpecThread.Api.Data.AcceptanceCriterion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.Property("RequirementId") + .HasColumnType("uuid") + .HasColumnName("requirement_id"); + + b.Property("Text") + .IsRequired() + .HasColumnType("text") + .HasColumnName("text"); + + b.HasKey("Id"); + + b.HasIndex("RequirementId", "Position") + .IsUnique(); + + b.ToTable("acceptance_criteria", "public", t => + { + t.HasCheckConstraint("ck_criteria_position", "position >= 0"); + + t.HasCheckConstraint("ck_criteria_text", "length(btrim(text)) > 0"); + }); + }); + + modelBuilder.Entity("SpecThread.Api.Data.AuthAccount", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("AccessToken") + .HasColumnType("text") + .HasColumnName("accessToken"); + + b.Property("AccessTokenExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("accessTokenExpiresAt"); + + b.Property("AccountId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("accountId"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("createdAt"); + + b.Property("IdToken") + .HasColumnType("text") + .HasColumnName("idToken"); + + b.Property("Password") + .HasColumnType("text") + .HasColumnName("password"); + + b.Property("ProviderId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("providerId"); + + b.Property("RefreshToken") + .HasColumnType("text") + .HasColumnName("refreshToken"); + + b.Property("RefreshTokenExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("refreshTokenExpiresAt"); + + b.Property("Scope") + .HasColumnType("text") + .HasColumnName("scope"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updatedAt"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("userId"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("ProviderId", "AccountId") + .IsUnique(); + + b.ToTable("account", "public"); + }); + + modelBuilder.Entity("SpecThread.Api.Data.AuthRateLimit", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("Count") + .HasColumnType("integer") + .HasColumnName("count"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text") + .HasColumnName("key"); + + b.Property("LastRequest") + .HasColumnType("bigint") + .HasColumnName("lastRequest"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.HasIndex("LastRequest"); + + b.ToTable("rateLimit", "public"); + }); + + modelBuilder.Entity("SpecThread.Api.Data.AuthSession", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("createdAt"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expiresAt"); + + b.Property("IpAddress") + .HasColumnType("text") + .HasColumnName("ipAddress"); + + b.Property("Token") + .IsRequired() + .HasColumnType("text") + .HasColumnName("token"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updatedAt"); + + b.Property("UserAgent") + .HasColumnType("text") + .HasColumnName("userAgent"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("userId"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("session", "public"); + }); + + modelBuilder.Entity("SpecThread.Api.Data.AuthSigningKey", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("Alg") + .HasColumnType("text") + .HasColumnName("alg"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("createdAt"); + + b.Property("Crv") + .HasColumnType("text") + .HasColumnName("crv"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expiresAt"); + + b.Property("PrivateKey") + .IsRequired() + .HasColumnType("text") + .HasColumnName("privateKey"); + + b.Property("PublicKey") + .IsRequired() + .HasColumnType("text") + .HasColumnName("publicKey"); + + b.HasKey("Id"); + + b.ToTable("jwks", "public"); + }); + + modelBuilder.Entity("SpecThread.Api.Data.AuthUser", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("createdAt"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text") + .HasColumnName("email"); + + b.Property("EmailVerified") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("emailVerified"); + + b.Property("Image") + .HasColumnType("text") + .HasColumnName("image"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updatedAt"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("user", "public"); + }); + + modelBuilder.Entity("SpecThread.Api.Data.AuthVerification", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("createdAt"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expiresAt"); + + b.Property("Identifier") + .IsRequired() + .HasColumnType("text") + .HasColumnName("identifier"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updatedAt"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.HasKey("Id"); + + b.HasIndex("Identifier"); + + b.ToTable("verification", "public"); + }); + + modelBuilder.Entity("SpecThread.Api.Data.Document", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ArchivedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("archived_at"); + + b.Property("ContentType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("content_type"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("file_name"); + + b.Property("ProjectId") + .HasColumnType("uuid") + .HasColumnName("project_id"); + + b.Property("SizeBytes") + .HasColumnType("bigint") + .HasColumnName("size_bytes"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text") + .HasColumnName("title"); + + b.Property("UploadedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("uploaded_at") + .HasDefaultValueSql("now()"); + + b.Property("UploadedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("uploaded_by"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.HasIndex("UploadedBy"); + + b.ToTable("documents", "public", t => + { + t.HasCheckConstraint("ck_documents_content_type", "content_type IN ('application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document')"); + + t.HasCheckConstraint("ck_documents_file_name", "length(btrim(file_name)) > 0"); + + t.HasCheckConstraint("ck_documents_size", "size_bytes BETWEEN 1 AND 4194304"); + + t.HasCheckConstraint("ck_documents_title", "length(btrim(title)) > 0"); + }); + }); + + modelBuilder.Entity("SpecThread.Api.Data.DocumentContent", b => + { + b.Property("DocumentId") + .HasColumnType("uuid") + .HasColumnName("document_id"); + + b.Property("Content") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("content"); + + b.HasKey("DocumentId"); + + b.ToTable("document_contents", "public"); + }); + + modelBuilder.Entity("SpecThread.Api.Data.Project", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ArchivedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("archived_at"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("owner_user_id"); + + b.Property("TeamId") + .HasColumnType("uuid") + .HasColumnName("team_id"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.HasIndex("TeamId"); + + b.ToTable("projects", "public", t => + { + t.HasCheckConstraint("ck_projects_name", "length(btrim(name)) > 0"); + }); + }); + + modelBuilder.Entity("SpecThread.Api.Data.ProjectMember", b => + { + b.Property("ProjectId") + .HasColumnType("uuid") + .HasColumnName("project_id"); + + b.Property("UserId") + .HasColumnType("text") + .HasColumnName("user_id"); + + b.Property("JoinedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_at") + .HasDefaultValueSql("now()"); + + b.HasKey("ProjectId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("project_members", "public"); + }); + + modelBuilder.Entity("SpecThread.Api.Data.ProjectRepository", b => + { + b.Property("ProjectId") + .HasColumnType("uuid") + .HasColumnName("project_id"); + + b.Property("ConnectedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("connected_at") + .HasDefaultValueSql("now()"); + + b.Property("ConnectedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("connected_by"); + + b.Property("InstallationId") + .HasColumnType("bigint") + .HasColumnName("installation_id"); + + b.Property("IsPrivate") + .HasColumnType("boolean") + .HasColumnName("is_private"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Owner") + .IsRequired() + .HasColumnType("text") + .HasColumnName("owner"); + + b.Property("RepositoryId") + .HasColumnType("bigint") + .HasColumnName("repository_id"); + + b.HasKey("ProjectId"); + + b.HasIndex("ConnectedBy"); + + b.ToTable("project_repositories", "public", t => + { + t.HasCheckConstraint("ck_project_repositories_ids", "installation_id > 0 AND repository_id > 0"); + + t.HasCheckConstraint("ck_project_repositories_name", "length(btrim(owner)) > 0 AND length(btrim(name)) > 0"); + }); + }); + + modelBuilder.Entity("SpecThread.Api.Data.Requirement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ArchivedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("archived_at"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Milestone") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("milestone"); + + b.Property("ProjectId") + .HasColumnType("uuid") + .HasColumnName("project_id"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text") + .HasColumnName("title"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UpdatedBy") + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1) + .HasColumnName("version"); + + b.HasKey("Id"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("ProjectId"); + + b.HasIndex("UpdatedBy"); + + b.ToTable("requirements", "public", t => + { + t.HasCheckConstraint("ck_requirements_milestone", "milestone IS NULL OR (btrim(milestone) = milestone AND length(milestone) BETWEEN 1 AND 100)"); + + t.HasCheckConstraint("ck_requirements_title", "length(btrim(title)) > 0"); + + t.HasCheckConstraint("ck_requirements_version", "version > 0"); + }); + }); + + modelBuilder.Entity("SpecThread.Api.Data.RequirementCriteriaVersion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("Criteria") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("criteria"); + + b.Property("Number") + .HasColumnType("integer") + .HasColumnName("number"); + + b.Property("RequirementId") + .HasColumnType("uuid") + .HasColumnName("requirement_id"); + + b.HasKey("Id"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("RequirementId", "Number") + .IsUnique(); + + b.ToTable("requirement_criteria_versions", "public", t => + { + t.HasCheckConstraint("ck_requirement_criteria_versions_criteria", "jsonb_typeof(criteria) = 'array'"); + + t.HasCheckConstraint("ck_requirement_criteria_versions_number", "number > 0"); + }); + }); + + modelBuilder.Entity("SpecThread.Api.Data.RequirementDocument", b => + { + b.Property("RequirementId") + .HasColumnType("uuid") + .HasColumnName("requirement_id"); + + b.Property("DocumentId") + .HasColumnType("uuid") + .HasColumnName("document_id"); + + b.Property("LinkedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("linked_at") + .HasDefaultValueSql("now()"); + + b.Property("LinkedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("linked_by"); + + b.HasKey("RequirementId", "DocumentId"); + + b.HasIndex("DocumentId"); + + b.HasIndex("LinkedBy"); + + b.ToTable("requirement_documents", "public"); + }); + + modelBuilder.Entity("SpecThread.Api.Data.RequirementEvidence", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Additions") + .HasColumnType("integer") + .HasColumnName("additions"); + + b.Property("Author") + .HasColumnType("text") + .HasColumnName("author"); + + b.Property("ChangedFiles") + .HasColumnType("integer") + .HasColumnName("changed_files"); + + b.Property("CheckCount") + .HasColumnType("integer") + .HasColumnName("check_count"); + + b.Property("Checks") + .HasColumnType("jsonb") + .HasColumnName("checks"); + + b.Property("ChecksReadAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("checks_read_at"); + + b.Property("CommitCount") + .HasColumnType("integer") + .HasColumnName("commit_count"); + + b.Property("Commits") + .HasColumnType("jsonb") + .HasColumnName("commits"); + + b.Property("Contains") + .HasColumnType("jsonb") + .HasColumnName("contains"); + + b.Property("Deletions") + .HasColumnType("integer") + .HasColumnName("deletions"); + + b.Property("GitHubClosedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("github_closed_at"); + + b.Property("GitHubCreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("github_created_at"); + + b.Property("GitHubUpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("github_updated_at"); + + b.Property("Kind") + .IsRequired() + .HasColumnType("text") + .HasColumnName("kind"); + + b.Property("LinkedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("linked_at") + .HasDefaultValueSql("now()"); + + b.Property("LinkedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("linked_by"); + + b.Property("MergeSha") + .HasColumnType("text") + .HasColumnName("merge_sha"); + + b.Property("Number") + .HasColumnType("integer") + .HasColumnName("number"); + + b.Property("Prerelease") + .HasColumnType("boolean") + .HasColumnName("prerelease"); + + b.Property("RefreshedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("refreshed_at") + .HasDefaultValueSql("now()"); + + b.Property("RepositoryId") + .HasColumnType("bigint") + .HasColumnName("repository_id"); + + b.Property("RepositoryName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("repository_name"); + + b.Property("RepositoryOwner") + .IsRequired() + .HasColumnType("text") + .HasColumnName("repository_owner"); + + b.Property("RequirementId") + .HasColumnType("uuid") + .HasColumnName("requirement_id"); + + b.Property("Sha") + .HasColumnType("text") + .HasColumnName("sha"); + + b.Property("Source") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("manual") + .HasColumnName("source"); + + b.Property("State") + .HasColumnType("text") + .HasColumnName("state"); + + b.Property("Tag") + .HasColumnType("text") + .HasColumnName("tag"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text") + .HasColumnName("title"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text") + .HasColumnName("url"); + + b.HasKey("Id"); + + b.HasIndex("LinkedBy"); + + b.HasIndex("RequirementId", "RepositoryId", "Number") + .IsUnique(); + + b.HasIndex("RequirementId", "RepositoryId", "Sha") + .IsUnique() + .HasFilter("kind = 'commit'"); + + b.HasIndex("RequirementId", "RepositoryId", "Tag") + .IsUnique() + .HasFilter("kind = 'release'"); + + b.ToTable("requirement_evidence", "public", t => + { + t.HasCheckConstraint("ck_requirement_evidence_changes", "COALESCE(additions, 0) >= 0 AND COALESCE(deletions, 0) >= 0 AND COALESCE(changed_files, 0) >= 0 AND COALESCE(commit_count, 0) >= 0"); + + t.HasCheckConstraint("ck_requirement_evidence_checks", "COALESCE(check_count, 0) >= 0"); + + t.HasCheckConstraint("ck_requirement_evidence_identity", "(kind = 'commit' AND sha IS NOT NULL AND sha ~ '^[0-9a-f]{40}$' AND number IS NULL AND state IS NULL AND tag IS NULL) OR (kind IN ('issue', 'pull_request') AND number IS NOT NULL AND number > 0 AND state IS NOT NULL AND tag IS NULL) OR (kind = 'release' AND tag IS NOT NULL AND length(btrim(tag)) > 0 AND number IS NULL AND state IS NULL)"); + + t.HasCheckConstraint("ck_requirement_evidence_kind", "kind IN ('issue', 'pull_request', 'commit', 'release')"); + + t.HasCheckConstraint("ck_requirement_evidence_repository", "repository_id > 0"); + + t.HasCheckConstraint("ck_requirement_evidence_source", "source IN ('manual', 'suggested')"); + + t.HasCheckConstraint("ck_requirement_evidence_state", "state IS NULL OR state IN ('open', 'closed', 'merged')"); + }); + }); + + modelBuilder.Entity("SpecThread.Api.Data.RequirementReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CriteriaVersion") + .HasColumnType("integer") + .HasColumnName("criteria_version"); + + b.Property("DecidedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("decided_at") + .HasDefaultValueSql("now()"); + + b.Property("DecidedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("decided_by"); + + b.Property("Decision") + .IsRequired() + .HasColumnType("text") + .HasColumnName("decision"); + + b.Property("Evidence") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("evidence"); + + b.Property("Note") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("") + .HasColumnName("note"); + + b.Property("RequirementId") + .HasColumnType("uuid") + .HasColumnName("requirement_id"); + + b.Property("RequirementVersion") + .HasColumnType("integer") + .HasColumnName("requirement_version"); + + b.HasKey("Id"); + + b.HasIndex("DecidedBy"); + + b.HasIndex("RequirementId", "DecidedAt"); + + b.ToTable("requirement_reviews", "public", t => + { + t.HasCheckConstraint("ck_requirement_reviews_criteria_version", "criteria_version IS NULL OR criteria_version > 0"); + + t.HasCheckConstraint("ck_requirement_reviews_decision", "decision IN ('accepted', 'rejected', 'more_evidence')"); + + t.HasCheckConstraint("ck_requirement_reviews_evidence", "jsonb_typeof(evidence) = 'array'"); + + t.HasCheckConstraint("ck_requirement_reviews_note", "decision = 'accepted' OR length(btrim(note)) > 0"); + + t.HasCheckConstraint("ck_requirement_reviews_version", "requirement_version > 0"); + }); + }); + + modelBuilder.Entity("SpecThread.Api.Data.Team", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("owner_user_id"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("teams", "public", t => + { + t.HasCheckConstraint("ck_teams_name", "length(btrim(name)) > 0"); + }); + }); + + modelBuilder.Entity("SpecThread.Api.Data.TeamInvitation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AcceptedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("accepted_at"); + + b.Property("AcceptedBy") + .HasColumnType("text") + .HasColumnName("accepted_by"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(254) + .HasColumnType("character varying(254)") + .HasColumnName("email"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("InvitedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("invited_by"); + + b.Property("IssuedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("issued_at"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at"); + + b.Property("Role") + .IsRequired() + .HasColumnType("text") + .HasColumnName("role"); + + b.Property("TeamId") + .HasColumnType("uuid") + .HasColumnName("team_id"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("token_hash"); + + b.HasKey("Id"); + + b.HasIndex("AcceptedBy"); + + b.HasIndex("InvitedBy"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("TeamId", "Email") + .IsUnique() + .HasFilter("accepted_at IS NULL AND revoked_at IS NULL"); + + b.ToTable("team_invitations", "public", t => + { + t.HasCheckConstraint("ck_team_invitations_expiry", "expires_at > issued_at"); + + t.HasCheckConstraint("ck_team_invitations_resolution", "accepted_at IS NULL OR revoked_at IS NULL"); + + t.HasCheckConstraint("ck_team_invitations_role", "role IN ('admin', 'member')"); + }); + }); + + modelBuilder.Entity("SpecThread.Api.Data.TeamMember", b => + { + b.Property("TeamId") + .HasColumnType("uuid") + .HasColumnName("team_id"); + + b.Property("UserId") + .HasColumnType("text") + .HasColumnName("user_id"); + + b.Property("IsExpanded") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("is_expanded"); + + b.Property("IsFavorite") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_favorite"); + + b.Property("JoinedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_at") + .HasDefaultValueSql("now()"); + + b.Property("Role") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("member") + .HasColumnName("role"); + + b.HasKey("TeamId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("team_members", "public", t => + { + t.HasCheckConstraint("ck_team_members_role", "role IN ('admin', 'member')"); + }); + }); + + modelBuilder.Entity("SpecThread.Api.Data.UserOnboarding", b => + { + b.Property("UserId") + .HasColumnType("text") + .HasColumnName("user_id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.HasKey("UserId"); + + b.ToTable("user_onboarding", "public"); + }); + + modelBuilder.Entity("SpecThread.Api.Data.AcceptanceCriterion", b => + { + b.HasOne("SpecThread.Api.Data.Requirement", null) + .WithMany() + .HasForeignKey("RequirementId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.AuthAccount", b => + { + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.AuthSession", b => + { + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.Document", b => + { + b.HasOne("SpecThread.Api.Data.Project", null) + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("UploadedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.DocumentContent", b => + { + b.HasOne("SpecThread.Api.Data.Document", null) + .WithOne() + .HasForeignKey("SpecThread.Api.Data.DocumentContent", "DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.Project", b => + { + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.Team", null) + .WithMany() + .HasForeignKey("TeamId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.ProjectMember", b => + { + b.HasOne("SpecThread.Api.Data.Project", null) + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.ProjectRepository", b => + { + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("ConnectedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.Project", null) + .WithOne() + .HasForeignKey("SpecThread.Api.Data.ProjectRepository", "ProjectId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.Requirement", b => + { + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.Project", null) + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("UpdatedBy") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("SpecThread.Api.Data.RequirementCriteriaVersion", b => + { + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.Requirement", null) + .WithMany() + .HasForeignKey("RequirementId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.RequirementDocument", b => + { + b.HasOne("SpecThread.Api.Data.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("LinkedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.Requirement", null) + .WithMany() + .HasForeignKey("RequirementId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.RequirementEvidence", b => + { + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("LinkedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.Requirement", null) + .WithMany() + .HasForeignKey("RequirementId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.RequirementReview", b => + { + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("DecidedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.Requirement", null) + .WithMany() + .HasForeignKey("RequirementId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.Team", b => + { + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.TeamInvitation", b => + { + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("AcceptedBy") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("InvitedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.Team", null) + .WithMany() + .HasForeignKey("TeamId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.TeamMember", b => + { + b.HasOne("SpecThread.Api.Data.Team", null) + .WithMany() + .HasForeignKey("TeamId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.UserOnboarding", b => + { + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/app/api/Migrations/20261030001226_RequirementMilestone.cs b/app/api/Migrations/20261030001226_RequirementMilestone.cs new file mode 100644 index 0000000..8a38f72 --- /dev/null +++ b/app/api/Migrations/20261030001226_RequirementMilestone.cs @@ -0,0 +1,42 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SpecThread.Api.Migrations +{ + /// + public partial class RequirementMilestone : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "milestone", + schema: "public", + table: "requirements", + type: "character varying(100)", + maxLength: 100, + nullable: true); + + migrationBuilder.AddCheckConstraint( + name: "ck_requirements_milestone", + schema: "public", + table: "requirements", + sql: "milestone IS NULL OR (btrim(milestone) = milestone AND length(milestone) BETWEEN 1 AND 100)"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropCheckConstraint( + name: "ck_requirements_milestone", + schema: "public", + table: "requirements"); + + migrationBuilder.DropColumn( + name: "milestone", + schema: "public", + table: "requirements"); + } + } +} diff --git a/app/api/Migrations/20261030001248_RequirementFollowUps.Designer.cs b/app/api/Migrations/20261030001248_RequirementFollowUps.Designer.cs new file mode 100644 index 0000000..82c78e8 --- /dev/null +++ b/app/api/Migrations/20261030001248_RequirementFollowUps.Designer.cs @@ -0,0 +1,1455 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using SpecThread.Api.Data; + +#nullable disable + +namespace SpecThread.Api.Migrations +{ + [DbContext(typeof(SpecThreadDbContext))] + [Migration("20261030001248_RequirementFollowUps")] + partial class RequirementFollowUps + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("public") + .HasAnnotation("ProductVersion", "10.0.12") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("SpecThread.Api.Data.AcceptanceCriterion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.Property("RequirementId") + .HasColumnType("uuid") + .HasColumnName("requirement_id"); + + b.Property("Text") + .IsRequired() + .HasColumnType("text") + .HasColumnName("text"); + + b.HasKey("Id"); + + b.HasIndex("RequirementId", "Position") + .IsUnique(); + + b.ToTable("acceptance_criteria", "public", t => + { + t.HasCheckConstraint("ck_criteria_position", "position >= 0"); + + t.HasCheckConstraint("ck_criteria_text", "length(btrim(text)) > 0"); + }); + }); + + modelBuilder.Entity("SpecThread.Api.Data.AuthAccount", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("AccessToken") + .HasColumnType("text") + .HasColumnName("accessToken"); + + b.Property("AccessTokenExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("accessTokenExpiresAt"); + + b.Property("AccountId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("accountId"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("createdAt"); + + b.Property("IdToken") + .HasColumnType("text") + .HasColumnName("idToken"); + + b.Property("Password") + .HasColumnType("text") + .HasColumnName("password"); + + b.Property("ProviderId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("providerId"); + + b.Property("RefreshToken") + .HasColumnType("text") + .HasColumnName("refreshToken"); + + b.Property("RefreshTokenExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("refreshTokenExpiresAt"); + + b.Property("Scope") + .HasColumnType("text") + .HasColumnName("scope"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updatedAt"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("userId"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("ProviderId", "AccountId") + .IsUnique(); + + b.ToTable("account", "public"); + }); + + modelBuilder.Entity("SpecThread.Api.Data.AuthRateLimit", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("Count") + .HasColumnType("integer") + .HasColumnName("count"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text") + .HasColumnName("key"); + + b.Property("LastRequest") + .HasColumnType("bigint") + .HasColumnName("lastRequest"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.HasIndex("LastRequest"); + + b.ToTable("rateLimit", "public"); + }); + + modelBuilder.Entity("SpecThread.Api.Data.AuthSession", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("createdAt"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expiresAt"); + + b.Property("IpAddress") + .HasColumnType("text") + .HasColumnName("ipAddress"); + + b.Property("Token") + .IsRequired() + .HasColumnType("text") + .HasColumnName("token"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updatedAt"); + + b.Property("UserAgent") + .HasColumnType("text") + .HasColumnName("userAgent"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("userId"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("session", "public"); + }); + + modelBuilder.Entity("SpecThread.Api.Data.AuthSigningKey", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("Alg") + .HasColumnType("text") + .HasColumnName("alg"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("createdAt"); + + b.Property("Crv") + .HasColumnType("text") + .HasColumnName("crv"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expiresAt"); + + b.Property("PrivateKey") + .IsRequired() + .HasColumnType("text") + .HasColumnName("privateKey"); + + b.Property("PublicKey") + .IsRequired() + .HasColumnType("text") + .HasColumnName("publicKey"); + + b.HasKey("Id"); + + b.ToTable("jwks", "public"); + }); + + modelBuilder.Entity("SpecThread.Api.Data.AuthUser", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("createdAt"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text") + .HasColumnName("email"); + + b.Property("EmailVerified") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("emailVerified"); + + b.Property("Image") + .HasColumnType("text") + .HasColumnName("image"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updatedAt"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("user", "public"); + }); + + modelBuilder.Entity("SpecThread.Api.Data.AuthVerification", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("createdAt"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expiresAt"); + + b.Property("Identifier") + .IsRequired() + .HasColumnType("text") + .HasColumnName("identifier"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updatedAt"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.HasKey("Id"); + + b.HasIndex("Identifier"); + + b.ToTable("verification", "public"); + }); + + modelBuilder.Entity("SpecThread.Api.Data.Document", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ArchivedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("archived_at"); + + b.Property("ContentType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("content_type"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("file_name"); + + b.Property("ProjectId") + .HasColumnType("uuid") + .HasColumnName("project_id"); + + b.Property("SizeBytes") + .HasColumnType("bigint") + .HasColumnName("size_bytes"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text") + .HasColumnName("title"); + + b.Property("UploadedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("uploaded_at") + .HasDefaultValueSql("now()"); + + b.Property("UploadedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("uploaded_by"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.HasIndex("UploadedBy"); + + b.ToTable("documents", "public", t => + { + t.HasCheckConstraint("ck_documents_content_type", "content_type IN ('application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document')"); + + t.HasCheckConstraint("ck_documents_file_name", "length(btrim(file_name)) > 0"); + + t.HasCheckConstraint("ck_documents_size", "size_bytes BETWEEN 1 AND 4194304"); + + t.HasCheckConstraint("ck_documents_title", "length(btrim(title)) > 0"); + }); + }); + + modelBuilder.Entity("SpecThread.Api.Data.DocumentContent", b => + { + b.Property("DocumentId") + .HasColumnType("uuid") + .HasColumnName("document_id"); + + b.Property("Content") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("content"); + + b.HasKey("DocumentId"); + + b.ToTable("document_contents", "public"); + }); + + modelBuilder.Entity("SpecThread.Api.Data.Project", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ArchivedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("archived_at"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("owner_user_id"); + + b.Property("TeamId") + .HasColumnType("uuid") + .HasColumnName("team_id"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.HasIndex("TeamId"); + + b.ToTable("projects", "public", t => + { + t.HasCheckConstraint("ck_projects_name", "length(btrim(name)) > 0"); + }); + }); + + modelBuilder.Entity("SpecThread.Api.Data.ProjectMember", b => + { + b.Property("ProjectId") + .HasColumnType("uuid") + .HasColumnName("project_id"); + + b.Property("UserId") + .HasColumnType("text") + .HasColumnName("user_id"); + + b.Property("JoinedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_at") + .HasDefaultValueSql("now()"); + + b.HasKey("ProjectId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("project_members", "public"); + }); + + modelBuilder.Entity("SpecThread.Api.Data.ProjectRepository", b => + { + b.Property("ProjectId") + .HasColumnType("uuid") + .HasColumnName("project_id"); + + b.Property("ConnectedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("connected_at") + .HasDefaultValueSql("now()"); + + b.Property("ConnectedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("connected_by"); + + b.Property("InstallationId") + .HasColumnType("bigint") + .HasColumnName("installation_id"); + + b.Property("IsPrivate") + .HasColumnType("boolean") + .HasColumnName("is_private"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Owner") + .IsRequired() + .HasColumnType("text") + .HasColumnName("owner"); + + b.Property("RepositoryId") + .HasColumnType("bigint") + .HasColumnName("repository_id"); + + b.HasKey("ProjectId"); + + b.HasIndex("ConnectedBy"); + + b.ToTable("project_repositories", "public", t => + { + t.HasCheckConstraint("ck_project_repositories_ids", "installation_id > 0 AND repository_id > 0"); + + t.HasCheckConstraint("ck_project_repositories_name", "length(btrim(owner)) > 0 AND length(btrim(name)) > 0"); + }); + }); + + modelBuilder.Entity("SpecThread.Api.Data.Requirement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ArchivedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("archived_at"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Milestone") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("milestone"); + + b.Property("ProjectId") + .HasColumnType("uuid") + .HasColumnName("project_id"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text") + .HasColumnName("title"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UpdatedBy") + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1) + .HasColumnName("version"); + + b.HasKey("Id"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("ProjectId"); + + b.HasIndex("UpdatedBy"); + + b.ToTable("requirements", "public", t => + { + t.HasCheckConstraint("ck_requirements_milestone", "milestone IS NULL OR (btrim(milestone) = milestone AND length(milestone) BETWEEN 1 AND 100)"); + + t.HasCheckConstraint("ck_requirements_title", "length(btrim(title)) > 0"); + + t.HasCheckConstraint("ck_requirements_version", "version > 0"); + }); + }); + + modelBuilder.Entity("SpecThread.Api.Data.RequirementCriteriaVersion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("Criteria") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("criteria"); + + b.Property("Number") + .HasColumnType("integer") + .HasColumnName("number"); + + b.Property("RequirementId") + .HasColumnType("uuid") + .HasColumnName("requirement_id"); + + b.HasKey("Id"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("RequirementId", "Number") + .IsUnique(); + + b.ToTable("requirement_criteria_versions", "public", t => + { + t.HasCheckConstraint("ck_requirement_criteria_versions_criteria", "jsonb_typeof(criteria) = 'array'"); + + t.HasCheckConstraint("ck_requirement_criteria_versions_number", "number > 0"); + }); + }); + + modelBuilder.Entity("SpecThread.Api.Data.RequirementDocument", b => + { + b.Property("RequirementId") + .HasColumnType("uuid") + .HasColumnName("requirement_id"); + + b.Property("DocumentId") + .HasColumnType("uuid") + .HasColumnName("document_id"); + + b.Property("LinkedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("linked_at") + .HasDefaultValueSql("now()"); + + b.Property("LinkedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("linked_by"); + + b.HasKey("RequirementId", "DocumentId"); + + b.HasIndex("DocumentId"); + + b.HasIndex("LinkedBy"); + + b.ToTable("requirement_documents", "public"); + }); + + modelBuilder.Entity("SpecThread.Api.Data.RequirementEvidence", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Additions") + .HasColumnType("integer") + .HasColumnName("additions"); + + b.Property("Author") + .HasColumnType("text") + .HasColumnName("author"); + + b.Property("ChangedFiles") + .HasColumnType("integer") + .HasColumnName("changed_files"); + + b.Property("CheckCount") + .HasColumnType("integer") + .HasColumnName("check_count"); + + b.Property("Checks") + .HasColumnType("jsonb") + .HasColumnName("checks"); + + b.Property("ChecksReadAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("checks_read_at"); + + b.Property("CommitCount") + .HasColumnType("integer") + .HasColumnName("commit_count"); + + b.Property("Commits") + .HasColumnType("jsonb") + .HasColumnName("commits"); + + b.Property("Contains") + .HasColumnType("jsonb") + .HasColumnName("contains"); + + b.Property("Deletions") + .HasColumnType("integer") + .HasColumnName("deletions"); + + b.Property("GitHubClosedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("github_closed_at"); + + b.Property("GitHubCreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("github_created_at"); + + b.Property("GitHubUpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("github_updated_at"); + + b.Property("Kind") + .IsRequired() + .HasColumnType("text") + .HasColumnName("kind"); + + b.Property("LinkedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("linked_at") + .HasDefaultValueSql("now()"); + + b.Property("LinkedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("linked_by"); + + b.Property("MergeSha") + .HasColumnType("text") + .HasColumnName("merge_sha"); + + b.Property("Number") + .HasColumnType("integer") + .HasColumnName("number"); + + b.Property("Prerelease") + .HasColumnType("boolean") + .HasColumnName("prerelease"); + + b.Property("RefreshedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("refreshed_at") + .HasDefaultValueSql("now()"); + + b.Property("RepositoryId") + .HasColumnType("bigint") + .HasColumnName("repository_id"); + + b.Property("RepositoryName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("repository_name"); + + b.Property("RepositoryOwner") + .IsRequired() + .HasColumnType("text") + .HasColumnName("repository_owner"); + + b.Property("RequirementId") + .HasColumnType("uuid") + .HasColumnName("requirement_id"); + + b.Property("Sha") + .HasColumnType("text") + .HasColumnName("sha"); + + b.Property("Source") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("manual") + .HasColumnName("source"); + + b.Property("State") + .HasColumnType("text") + .HasColumnName("state"); + + b.Property("Tag") + .HasColumnType("text") + .HasColumnName("tag"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text") + .HasColumnName("title"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text") + .HasColumnName("url"); + + b.HasKey("Id"); + + b.HasIndex("LinkedBy"); + + b.HasIndex("RequirementId", "RepositoryId", "Number") + .IsUnique(); + + b.HasIndex("RequirementId", "RepositoryId", "Sha") + .IsUnique() + .HasFilter("kind = 'commit'"); + + b.HasIndex("RequirementId", "RepositoryId", "Tag") + .IsUnique() + .HasFilter("kind = 'release'"); + + b.ToTable("requirement_evidence", "public", t => + { + t.HasCheckConstraint("ck_requirement_evidence_changes", "COALESCE(additions, 0) >= 0 AND COALESCE(deletions, 0) >= 0 AND COALESCE(changed_files, 0) >= 0 AND COALESCE(commit_count, 0) >= 0"); + + t.HasCheckConstraint("ck_requirement_evidence_checks", "COALESCE(check_count, 0) >= 0"); + + t.HasCheckConstraint("ck_requirement_evidence_identity", "(kind = 'commit' AND sha IS NOT NULL AND sha ~ '^[0-9a-f]{40}$' AND number IS NULL AND state IS NULL AND tag IS NULL) OR (kind IN ('issue', 'pull_request') AND number IS NOT NULL AND number > 0 AND state IS NOT NULL AND tag IS NULL) OR (kind = 'release' AND tag IS NOT NULL AND length(btrim(tag)) > 0 AND number IS NULL AND state IS NULL)"); + + t.HasCheckConstraint("ck_requirement_evidence_kind", "kind IN ('issue', 'pull_request', 'commit', 'release')"); + + t.HasCheckConstraint("ck_requirement_evidence_repository", "repository_id > 0"); + + t.HasCheckConstraint("ck_requirement_evidence_source", "source IN ('manual', 'suggested')"); + + t.HasCheckConstraint("ck_requirement_evidence_state", "state IS NULL OR state IN ('open', 'closed', 'merged')"); + }); + }); + + modelBuilder.Entity("SpecThread.Api.Data.RequirementFollowUp", b => + { + b.Property("RequirementId") + .HasColumnType("uuid") + .HasColumnName("requirement_id"); + + b.Property("OriginalRequirementId") + .HasColumnType("uuid") + .HasColumnName("original_requirement_id"); + + b.Property("LinkedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("linked_at") + .HasDefaultValueSql("now()"); + + b.Property("LinkedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("linked_by"); + + b.HasKey("RequirementId", "OriginalRequirementId"); + + b.HasIndex("LinkedBy"); + + b.HasIndex("OriginalRequirementId"); + + b.ToTable("requirement_follow_ups", "public", t => + { + t.HasCheckConstraint("ck_requirement_follow_ups_distinct", "requirement_id <> original_requirement_id"); + }); + }); + + modelBuilder.Entity("SpecThread.Api.Data.RequirementFollowUpEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Action") + .IsRequired() + .HasColumnType("text") + .HasColumnName("action"); + + b.Property("Actor") + .IsRequired() + .HasColumnType("text") + .HasColumnName("actor"); + + b.Property("OccurredAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_at") + .HasDefaultValueSql("now()"); + + b.Property("OriginalRequirementId") + .HasColumnType("uuid") + .HasColumnName("original_requirement_id"); + + b.Property("RequirementId") + .HasColumnType("uuid") + .HasColumnName("requirement_id"); + + b.HasKey("Id"); + + b.HasIndex("Actor"); + + b.HasIndex("OriginalRequirementId"); + + b.HasIndex("RequirementId"); + + b.ToTable("requirement_follow_up_events", "public", t => + { + t.HasCheckConstraint("ck_requirement_follow_up_events_action", "action IN ('added', 'removed')"); + + t.HasCheckConstraint("ck_requirement_follow_up_events_distinct", "requirement_id <> original_requirement_id"); + }); + }); + + modelBuilder.Entity("SpecThread.Api.Data.RequirementReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CriteriaVersion") + .HasColumnType("integer") + .HasColumnName("criteria_version"); + + b.Property("DecidedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("decided_at") + .HasDefaultValueSql("now()"); + + b.Property("DecidedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("decided_by"); + + b.Property("Decision") + .IsRequired() + .HasColumnType("text") + .HasColumnName("decision"); + + b.Property("Evidence") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("evidence"); + + b.Property("Note") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("") + .HasColumnName("note"); + + b.Property("RequirementId") + .HasColumnType("uuid") + .HasColumnName("requirement_id"); + + b.Property("RequirementVersion") + .HasColumnType("integer") + .HasColumnName("requirement_version"); + + b.HasKey("Id"); + + b.HasIndex("DecidedBy"); + + b.HasIndex("RequirementId", "DecidedAt"); + + b.ToTable("requirement_reviews", "public", t => + { + t.HasCheckConstraint("ck_requirement_reviews_criteria_version", "criteria_version IS NULL OR criteria_version > 0"); + + t.HasCheckConstraint("ck_requirement_reviews_decision", "decision IN ('accepted', 'rejected', 'more_evidence')"); + + t.HasCheckConstraint("ck_requirement_reviews_evidence", "jsonb_typeof(evidence) = 'array'"); + + t.HasCheckConstraint("ck_requirement_reviews_note", "decision = 'accepted' OR length(btrim(note)) > 0"); + + t.HasCheckConstraint("ck_requirement_reviews_version", "requirement_version > 0"); + }); + }); + + modelBuilder.Entity("SpecThread.Api.Data.Team", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("owner_user_id"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("teams", "public", t => + { + t.HasCheckConstraint("ck_teams_name", "length(btrim(name)) > 0"); + }); + }); + + modelBuilder.Entity("SpecThread.Api.Data.TeamInvitation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AcceptedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("accepted_at"); + + b.Property("AcceptedBy") + .HasColumnType("text") + .HasColumnName("accepted_by"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(254) + .HasColumnType("character varying(254)") + .HasColumnName("email"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("InvitedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("invited_by"); + + b.Property("IssuedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("issued_at"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at"); + + b.Property("Role") + .IsRequired() + .HasColumnType("text") + .HasColumnName("role"); + + b.Property("TeamId") + .HasColumnType("uuid") + .HasColumnName("team_id"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("token_hash"); + + b.HasKey("Id"); + + b.HasIndex("AcceptedBy"); + + b.HasIndex("InvitedBy"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("TeamId", "Email") + .IsUnique() + .HasFilter("accepted_at IS NULL AND revoked_at IS NULL"); + + b.ToTable("team_invitations", "public", t => + { + t.HasCheckConstraint("ck_team_invitations_expiry", "expires_at > issued_at"); + + t.HasCheckConstraint("ck_team_invitations_resolution", "accepted_at IS NULL OR revoked_at IS NULL"); + + t.HasCheckConstraint("ck_team_invitations_role", "role IN ('admin', 'member')"); + }); + }); + + modelBuilder.Entity("SpecThread.Api.Data.TeamMember", b => + { + b.Property("TeamId") + .HasColumnType("uuid") + .HasColumnName("team_id"); + + b.Property("UserId") + .HasColumnType("text") + .HasColumnName("user_id"); + + b.Property("IsExpanded") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("is_expanded"); + + b.Property("IsFavorite") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_favorite"); + + b.Property("JoinedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_at") + .HasDefaultValueSql("now()"); + + b.Property("Role") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("member") + .HasColumnName("role"); + + b.HasKey("TeamId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("team_members", "public", t => + { + t.HasCheckConstraint("ck_team_members_role", "role IN ('admin', 'member')"); + }); + }); + + modelBuilder.Entity("SpecThread.Api.Data.UserOnboarding", b => + { + b.Property("UserId") + .HasColumnType("text") + .HasColumnName("user_id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.HasKey("UserId"); + + b.ToTable("user_onboarding", "public"); + }); + + modelBuilder.Entity("SpecThread.Api.Data.AcceptanceCriterion", b => + { + b.HasOne("SpecThread.Api.Data.Requirement", null) + .WithMany() + .HasForeignKey("RequirementId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.AuthAccount", b => + { + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.AuthSession", b => + { + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.Document", b => + { + b.HasOne("SpecThread.Api.Data.Project", null) + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("UploadedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.DocumentContent", b => + { + b.HasOne("SpecThread.Api.Data.Document", null) + .WithOne() + .HasForeignKey("SpecThread.Api.Data.DocumentContent", "DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.Project", b => + { + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.Team", null) + .WithMany() + .HasForeignKey("TeamId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.ProjectMember", b => + { + b.HasOne("SpecThread.Api.Data.Project", null) + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.ProjectRepository", b => + { + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("ConnectedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.Project", null) + .WithOne() + .HasForeignKey("SpecThread.Api.Data.ProjectRepository", "ProjectId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.Requirement", b => + { + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.Project", null) + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("UpdatedBy") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("SpecThread.Api.Data.RequirementCriteriaVersion", b => + { + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.Requirement", null) + .WithMany() + .HasForeignKey("RequirementId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.RequirementDocument", b => + { + b.HasOne("SpecThread.Api.Data.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("LinkedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.Requirement", null) + .WithMany() + .HasForeignKey("RequirementId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.RequirementEvidence", b => + { + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("LinkedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.Requirement", null) + .WithMany() + .HasForeignKey("RequirementId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.RequirementFollowUp", b => + { + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("LinkedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.Requirement", null) + .WithMany() + .HasForeignKey("OriginalRequirementId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.Requirement", null) + .WithMany() + .HasForeignKey("RequirementId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.RequirementFollowUpEvent", b => + { + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("Actor") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.Requirement", null) + .WithMany() + .HasForeignKey("OriginalRequirementId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.Requirement", null) + .WithMany() + .HasForeignKey("RequirementId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.RequirementReview", b => + { + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("DecidedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.Requirement", null) + .WithMany() + .HasForeignKey("RequirementId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.Team", b => + { + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.TeamInvitation", b => + { + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("AcceptedBy") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("InvitedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.Team", null) + .WithMany() + .HasForeignKey("TeamId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.TeamMember", b => + { + b.HasOne("SpecThread.Api.Data.Team", null) + .WithMany() + .HasForeignKey("TeamId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.UserOnboarding", b => + { + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/app/api/Migrations/20261030001248_RequirementFollowUps.cs b/app/api/Migrations/20261030001248_RequirementFollowUps.cs new file mode 100644 index 0000000..1090de1 --- /dev/null +++ b/app/api/Migrations/20261030001248_RequirementFollowUps.cs @@ -0,0 +1,162 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SpecThread.Api.Migrations +{ + /// + public partial class RequirementFollowUps : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "requirement_follow_up_events", + schema: "public", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"), + requirement_id = table.Column(type: "uuid", nullable: false), + original_requirement_id = table.Column(type: "uuid", nullable: false), + action = table.Column(type: "text", nullable: false), + actor = table.Column(type: "text", nullable: false), + occurred_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()") + }, + constraints: table => + { + table.PrimaryKey("PK_requirement_follow_up_events", x => x.id); + table.CheckConstraint("ck_requirement_follow_up_events_action", "action IN ('added', 'removed')"); + table.CheckConstraint("ck_requirement_follow_up_events_distinct", "requirement_id <> original_requirement_id"); + table.ForeignKey( + name: "FK_requirement_follow_up_events_requirements_original_requirem~", + column: x => x.original_requirement_id, + principalSchema: "public", + principalTable: "requirements", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_requirement_follow_up_events_requirements_requirement_id", + column: x => x.requirement_id, + principalSchema: "public", + principalTable: "requirements", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_requirement_follow_up_events_user_actor", + column: x => x.actor, + principalSchema: "public", + principalTable: "user", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "requirement_follow_ups", + schema: "public", + columns: table => new + { + requirement_id = table.Column(type: "uuid", nullable: false), + original_requirement_id = table.Column(type: "uuid", nullable: false), + linked_by = table.Column(type: "text", nullable: false), + linked_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()") + }, + constraints: table => + { + table.PrimaryKey("PK_requirement_follow_ups", x => new { x.requirement_id, x.original_requirement_id }); + table.CheckConstraint("ck_requirement_follow_ups_distinct", "requirement_id <> original_requirement_id"); + table.ForeignKey( + name: "FK_requirement_follow_ups_requirements_original_requirement_id", + column: x => x.original_requirement_id, + principalSchema: "public", + principalTable: "requirements", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_requirement_follow_ups_requirements_requirement_id", + column: x => x.requirement_id, + principalSchema: "public", + principalTable: "requirements", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_requirement_follow_ups_user_linked_by", + column: x => x.linked_by, + principalSchema: "public", + principalTable: "user", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_requirement_follow_up_events_actor", + schema: "public", + table: "requirement_follow_up_events", + column: "actor"); + + migrationBuilder.CreateIndex( + name: "IX_requirement_follow_up_events_original_requirement_id", + schema: "public", + table: "requirement_follow_up_events", + column: "original_requirement_id"); + + migrationBuilder.CreateIndex( + name: "IX_requirement_follow_up_events_requirement_id", + schema: "public", + table: "requirement_follow_up_events", + column: "requirement_id"); + + migrationBuilder.CreateIndex( + name: "IX_requirement_follow_ups_linked_by", + schema: "public", + table: "requirement_follow_ups", + column: "linked_by"); + + migrationBuilder.CreateIndex( + name: "IX_requirement_follow_ups_original_requirement_id", + schema: "public", + table: "requirement_follow_ups", + column: "original_requirement_id"); + + migrationBuilder.Sql(""" + ALTER TABLE public."requirement_follow_ups" ENABLE ROW LEVEL SECURITY; + REVOKE ALL ON TABLE public."requirement_follow_ups" FROM PUBLIC; + DO $security$ + BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN + REVOKE ALL ON TABLE public."requirement_follow_ups" FROM anon; + END IF; + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN + REVOKE ALL ON TABLE public."requirement_follow_ups" FROM authenticated; + END IF; + END $security$; + """); + + migrationBuilder.Sql(""" + ALTER TABLE public."requirement_follow_up_events" ENABLE ROW LEVEL SECURITY; + REVOKE ALL ON TABLE public."requirement_follow_up_events" FROM PUBLIC; + DO $security$ + BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN + REVOKE ALL ON TABLE public."requirement_follow_up_events" FROM anon; + END IF; + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN + REVOKE ALL ON TABLE public."requirement_follow_up_events" FROM authenticated; + END IF; + END $security$; + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "requirement_follow_up_events", + schema: "public"); + + migrationBuilder.DropTable( + name: "requirement_follow_ups", + schema: "public"); + } + } +} diff --git a/app/api/Migrations/20261030013056_RequirementCriteriaVersionTexts.Designer.cs b/app/api/Migrations/20261030013056_RequirementCriteriaVersionTexts.Designer.cs new file mode 100644 index 0000000..9c979f8 --- /dev/null +++ b/app/api/Migrations/20261030013056_RequirementCriteriaVersionTexts.Designer.cs @@ -0,0 +1,1455 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using SpecThread.Api.Data; + +#nullable disable + +namespace SpecThread.Api.Migrations +{ + [DbContext(typeof(SpecThreadDbContext))] + [Migration("20261030013056_RequirementCriteriaVersionTexts")] + partial class RequirementCriteriaVersionTexts + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("public") + .HasAnnotation("ProductVersion", "10.0.12") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("SpecThread.Api.Data.AcceptanceCriterion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.Property("RequirementId") + .HasColumnType("uuid") + .HasColumnName("requirement_id"); + + b.Property("Text") + .IsRequired() + .HasColumnType("text") + .HasColumnName("text"); + + b.HasKey("Id"); + + b.HasIndex("RequirementId", "Position") + .IsUnique(); + + b.ToTable("acceptance_criteria", "public", t => + { + t.HasCheckConstraint("ck_criteria_position", "position >= 0"); + + t.HasCheckConstraint("ck_criteria_text", "length(btrim(text)) > 0"); + }); + }); + + modelBuilder.Entity("SpecThread.Api.Data.AuthAccount", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("AccessToken") + .HasColumnType("text") + .HasColumnName("accessToken"); + + b.Property("AccessTokenExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("accessTokenExpiresAt"); + + b.Property("AccountId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("accountId"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("createdAt"); + + b.Property("IdToken") + .HasColumnType("text") + .HasColumnName("idToken"); + + b.Property("Password") + .HasColumnType("text") + .HasColumnName("password"); + + b.Property("ProviderId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("providerId"); + + b.Property("RefreshToken") + .HasColumnType("text") + .HasColumnName("refreshToken"); + + b.Property("RefreshTokenExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("refreshTokenExpiresAt"); + + b.Property("Scope") + .HasColumnType("text") + .HasColumnName("scope"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updatedAt"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("userId"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("ProviderId", "AccountId") + .IsUnique(); + + b.ToTable("account", "public"); + }); + + modelBuilder.Entity("SpecThread.Api.Data.AuthRateLimit", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("Count") + .HasColumnType("integer") + .HasColumnName("count"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text") + .HasColumnName("key"); + + b.Property("LastRequest") + .HasColumnType("bigint") + .HasColumnName("lastRequest"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.HasIndex("LastRequest"); + + b.ToTable("rateLimit", "public"); + }); + + modelBuilder.Entity("SpecThread.Api.Data.AuthSession", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("createdAt"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expiresAt"); + + b.Property("IpAddress") + .HasColumnType("text") + .HasColumnName("ipAddress"); + + b.Property("Token") + .IsRequired() + .HasColumnType("text") + .HasColumnName("token"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updatedAt"); + + b.Property("UserAgent") + .HasColumnType("text") + .HasColumnName("userAgent"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("userId"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("session", "public"); + }); + + modelBuilder.Entity("SpecThread.Api.Data.AuthSigningKey", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("Alg") + .HasColumnType("text") + .HasColumnName("alg"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("createdAt"); + + b.Property("Crv") + .HasColumnType("text") + .HasColumnName("crv"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expiresAt"); + + b.Property("PrivateKey") + .IsRequired() + .HasColumnType("text") + .HasColumnName("privateKey"); + + b.Property("PublicKey") + .IsRequired() + .HasColumnType("text") + .HasColumnName("publicKey"); + + b.HasKey("Id"); + + b.ToTable("jwks", "public"); + }); + + modelBuilder.Entity("SpecThread.Api.Data.AuthUser", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("createdAt"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text") + .HasColumnName("email"); + + b.Property("EmailVerified") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("emailVerified"); + + b.Property("Image") + .HasColumnType("text") + .HasColumnName("image"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updatedAt"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("user", "public"); + }); + + modelBuilder.Entity("SpecThread.Api.Data.AuthVerification", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("createdAt"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expiresAt"); + + b.Property("Identifier") + .IsRequired() + .HasColumnType("text") + .HasColumnName("identifier"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updatedAt"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.HasKey("Id"); + + b.HasIndex("Identifier"); + + b.ToTable("verification", "public"); + }); + + modelBuilder.Entity("SpecThread.Api.Data.Document", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ArchivedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("archived_at"); + + b.Property("ContentType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("content_type"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("file_name"); + + b.Property("ProjectId") + .HasColumnType("uuid") + .HasColumnName("project_id"); + + b.Property("SizeBytes") + .HasColumnType("bigint") + .HasColumnName("size_bytes"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text") + .HasColumnName("title"); + + b.Property("UploadedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("uploaded_at") + .HasDefaultValueSql("now()"); + + b.Property("UploadedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("uploaded_by"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.HasIndex("UploadedBy"); + + b.ToTable("documents", "public", t => + { + t.HasCheckConstraint("ck_documents_content_type", "content_type IN ('application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document')"); + + t.HasCheckConstraint("ck_documents_file_name", "length(btrim(file_name)) > 0"); + + t.HasCheckConstraint("ck_documents_size", "size_bytes BETWEEN 1 AND 4194304"); + + t.HasCheckConstraint("ck_documents_title", "length(btrim(title)) > 0"); + }); + }); + + modelBuilder.Entity("SpecThread.Api.Data.DocumentContent", b => + { + b.Property("DocumentId") + .HasColumnType("uuid") + .HasColumnName("document_id"); + + b.Property("Content") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("content"); + + b.HasKey("DocumentId"); + + b.ToTable("document_contents", "public"); + }); + + modelBuilder.Entity("SpecThread.Api.Data.Project", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ArchivedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("archived_at"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("owner_user_id"); + + b.Property("TeamId") + .HasColumnType("uuid") + .HasColumnName("team_id"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.HasIndex("TeamId"); + + b.ToTable("projects", "public", t => + { + t.HasCheckConstraint("ck_projects_name", "length(btrim(name)) > 0"); + }); + }); + + modelBuilder.Entity("SpecThread.Api.Data.ProjectMember", b => + { + b.Property("ProjectId") + .HasColumnType("uuid") + .HasColumnName("project_id"); + + b.Property("UserId") + .HasColumnType("text") + .HasColumnName("user_id"); + + b.Property("JoinedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_at") + .HasDefaultValueSql("now()"); + + b.HasKey("ProjectId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("project_members", "public"); + }); + + modelBuilder.Entity("SpecThread.Api.Data.ProjectRepository", b => + { + b.Property("ProjectId") + .HasColumnType("uuid") + .HasColumnName("project_id"); + + b.Property("ConnectedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("connected_at") + .HasDefaultValueSql("now()"); + + b.Property("ConnectedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("connected_by"); + + b.Property("InstallationId") + .HasColumnType("bigint") + .HasColumnName("installation_id"); + + b.Property("IsPrivate") + .HasColumnType("boolean") + .HasColumnName("is_private"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Owner") + .IsRequired() + .HasColumnType("text") + .HasColumnName("owner"); + + b.Property("RepositoryId") + .HasColumnType("bigint") + .HasColumnName("repository_id"); + + b.HasKey("ProjectId"); + + b.HasIndex("ConnectedBy"); + + b.ToTable("project_repositories", "public", t => + { + t.HasCheckConstraint("ck_project_repositories_ids", "installation_id > 0 AND repository_id > 0"); + + t.HasCheckConstraint("ck_project_repositories_name", "length(btrim(owner)) > 0 AND length(btrim(name)) > 0"); + }); + }); + + modelBuilder.Entity("SpecThread.Api.Data.Requirement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ArchivedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("archived_at"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Milestone") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("milestone"); + + b.Property("ProjectId") + .HasColumnType("uuid") + .HasColumnName("project_id"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text") + .HasColumnName("title"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UpdatedBy") + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1) + .HasColumnName("version"); + + b.HasKey("Id"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("ProjectId"); + + b.HasIndex("UpdatedBy"); + + b.ToTable("requirements", "public", t => + { + t.HasCheckConstraint("ck_requirements_milestone", "milestone IS NULL OR (btrim(milestone) = milestone AND length(milestone) BETWEEN 1 AND 100)"); + + t.HasCheckConstraint("ck_requirements_title", "length(btrim(title)) > 0"); + + t.HasCheckConstraint("ck_requirements_version", "version > 0"); + }); + }); + + modelBuilder.Entity("SpecThread.Api.Data.RequirementCriteriaVersion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("Criteria") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("criteria"); + + b.Property("Number") + .HasColumnType("integer") + .HasColumnName("number"); + + b.Property("RequirementId") + .HasColumnType("uuid") + .HasColumnName("requirement_id"); + + b.HasKey("Id"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("RequirementId", "Number") + .IsUnique(); + + b.ToTable("requirement_criteria_versions", "public", t => + { + t.HasCheckConstraint("ck_requirement_criteria_versions_criteria", "jsonb_typeof(criteria) = 'array' AND NOT jsonb_path_exists(criteria, 'strict $[*] ? (@.type() != \"string\")')"); + + t.HasCheckConstraint("ck_requirement_criteria_versions_number", "number > 0"); + }); + }); + + modelBuilder.Entity("SpecThread.Api.Data.RequirementDocument", b => + { + b.Property("RequirementId") + .HasColumnType("uuid") + .HasColumnName("requirement_id"); + + b.Property("DocumentId") + .HasColumnType("uuid") + .HasColumnName("document_id"); + + b.Property("LinkedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("linked_at") + .HasDefaultValueSql("now()"); + + b.Property("LinkedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("linked_by"); + + b.HasKey("RequirementId", "DocumentId"); + + b.HasIndex("DocumentId"); + + b.HasIndex("LinkedBy"); + + b.ToTable("requirement_documents", "public"); + }); + + modelBuilder.Entity("SpecThread.Api.Data.RequirementEvidence", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Additions") + .HasColumnType("integer") + .HasColumnName("additions"); + + b.Property("Author") + .HasColumnType("text") + .HasColumnName("author"); + + b.Property("ChangedFiles") + .HasColumnType("integer") + .HasColumnName("changed_files"); + + b.Property("CheckCount") + .HasColumnType("integer") + .HasColumnName("check_count"); + + b.Property("Checks") + .HasColumnType("jsonb") + .HasColumnName("checks"); + + b.Property("ChecksReadAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("checks_read_at"); + + b.Property("CommitCount") + .HasColumnType("integer") + .HasColumnName("commit_count"); + + b.Property("Commits") + .HasColumnType("jsonb") + .HasColumnName("commits"); + + b.Property("Contains") + .HasColumnType("jsonb") + .HasColumnName("contains"); + + b.Property("Deletions") + .HasColumnType("integer") + .HasColumnName("deletions"); + + b.Property("GitHubClosedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("github_closed_at"); + + b.Property("GitHubCreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("github_created_at"); + + b.Property("GitHubUpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("github_updated_at"); + + b.Property("Kind") + .IsRequired() + .HasColumnType("text") + .HasColumnName("kind"); + + b.Property("LinkedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("linked_at") + .HasDefaultValueSql("now()"); + + b.Property("LinkedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("linked_by"); + + b.Property("MergeSha") + .HasColumnType("text") + .HasColumnName("merge_sha"); + + b.Property("Number") + .HasColumnType("integer") + .HasColumnName("number"); + + b.Property("Prerelease") + .HasColumnType("boolean") + .HasColumnName("prerelease"); + + b.Property("RefreshedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("refreshed_at") + .HasDefaultValueSql("now()"); + + b.Property("RepositoryId") + .HasColumnType("bigint") + .HasColumnName("repository_id"); + + b.Property("RepositoryName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("repository_name"); + + b.Property("RepositoryOwner") + .IsRequired() + .HasColumnType("text") + .HasColumnName("repository_owner"); + + b.Property("RequirementId") + .HasColumnType("uuid") + .HasColumnName("requirement_id"); + + b.Property("Sha") + .HasColumnType("text") + .HasColumnName("sha"); + + b.Property("Source") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("manual") + .HasColumnName("source"); + + b.Property("State") + .HasColumnType("text") + .HasColumnName("state"); + + b.Property("Tag") + .HasColumnType("text") + .HasColumnName("tag"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text") + .HasColumnName("title"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text") + .HasColumnName("url"); + + b.HasKey("Id"); + + b.HasIndex("LinkedBy"); + + b.HasIndex("RequirementId", "RepositoryId", "Number") + .IsUnique(); + + b.HasIndex("RequirementId", "RepositoryId", "Sha") + .IsUnique() + .HasFilter("kind = 'commit'"); + + b.HasIndex("RequirementId", "RepositoryId", "Tag") + .IsUnique() + .HasFilter("kind = 'release'"); + + b.ToTable("requirement_evidence", "public", t => + { + t.HasCheckConstraint("ck_requirement_evidence_changes", "COALESCE(additions, 0) >= 0 AND COALESCE(deletions, 0) >= 0 AND COALESCE(changed_files, 0) >= 0 AND COALESCE(commit_count, 0) >= 0"); + + t.HasCheckConstraint("ck_requirement_evidence_checks", "COALESCE(check_count, 0) >= 0"); + + t.HasCheckConstraint("ck_requirement_evidence_identity", "(kind = 'commit' AND sha IS NOT NULL AND sha ~ '^[0-9a-f]{40}$' AND number IS NULL AND state IS NULL AND tag IS NULL) OR (kind IN ('issue', 'pull_request') AND number IS NOT NULL AND number > 0 AND state IS NOT NULL AND tag IS NULL) OR (kind = 'release' AND tag IS NOT NULL AND length(btrim(tag)) > 0 AND number IS NULL AND state IS NULL)"); + + t.HasCheckConstraint("ck_requirement_evidence_kind", "kind IN ('issue', 'pull_request', 'commit', 'release')"); + + t.HasCheckConstraint("ck_requirement_evidence_repository", "repository_id > 0"); + + t.HasCheckConstraint("ck_requirement_evidence_source", "source IN ('manual', 'suggested')"); + + t.HasCheckConstraint("ck_requirement_evidence_state", "state IS NULL OR state IN ('open', 'closed', 'merged')"); + }); + }); + + modelBuilder.Entity("SpecThread.Api.Data.RequirementFollowUp", b => + { + b.Property("RequirementId") + .HasColumnType("uuid") + .HasColumnName("requirement_id"); + + b.Property("OriginalRequirementId") + .HasColumnType("uuid") + .HasColumnName("original_requirement_id"); + + b.Property("LinkedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("linked_at") + .HasDefaultValueSql("now()"); + + b.Property("LinkedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("linked_by"); + + b.HasKey("RequirementId", "OriginalRequirementId"); + + b.HasIndex("LinkedBy"); + + b.HasIndex("OriginalRequirementId"); + + b.ToTable("requirement_follow_ups", "public", t => + { + t.HasCheckConstraint("ck_requirement_follow_ups_distinct", "requirement_id <> original_requirement_id"); + }); + }); + + modelBuilder.Entity("SpecThread.Api.Data.RequirementFollowUpEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Action") + .IsRequired() + .HasColumnType("text") + .HasColumnName("action"); + + b.Property("Actor") + .IsRequired() + .HasColumnType("text") + .HasColumnName("actor"); + + b.Property("OccurredAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_at") + .HasDefaultValueSql("now()"); + + b.Property("OriginalRequirementId") + .HasColumnType("uuid") + .HasColumnName("original_requirement_id"); + + b.Property("RequirementId") + .HasColumnType("uuid") + .HasColumnName("requirement_id"); + + b.HasKey("Id"); + + b.HasIndex("Actor"); + + b.HasIndex("OriginalRequirementId"); + + b.HasIndex("RequirementId"); + + b.ToTable("requirement_follow_up_events", "public", t => + { + t.HasCheckConstraint("ck_requirement_follow_up_events_action", "action IN ('added', 'removed')"); + + t.HasCheckConstraint("ck_requirement_follow_up_events_distinct", "requirement_id <> original_requirement_id"); + }); + }); + + modelBuilder.Entity("SpecThread.Api.Data.RequirementReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CriteriaVersion") + .HasColumnType("integer") + .HasColumnName("criteria_version"); + + b.Property("DecidedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("decided_at") + .HasDefaultValueSql("now()"); + + b.Property("DecidedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("decided_by"); + + b.Property("Decision") + .IsRequired() + .HasColumnType("text") + .HasColumnName("decision"); + + b.Property("Evidence") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("evidence"); + + b.Property("Note") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("") + .HasColumnName("note"); + + b.Property("RequirementId") + .HasColumnType("uuid") + .HasColumnName("requirement_id"); + + b.Property("RequirementVersion") + .HasColumnType("integer") + .HasColumnName("requirement_version"); + + b.HasKey("Id"); + + b.HasIndex("DecidedBy"); + + b.HasIndex("RequirementId", "DecidedAt"); + + b.ToTable("requirement_reviews", "public", t => + { + t.HasCheckConstraint("ck_requirement_reviews_criteria_version", "criteria_version IS NULL OR criteria_version > 0"); + + t.HasCheckConstraint("ck_requirement_reviews_decision", "decision IN ('accepted', 'rejected', 'more_evidence')"); + + t.HasCheckConstraint("ck_requirement_reviews_evidence", "jsonb_typeof(evidence) = 'array'"); + + t.HasCheckConstraint("ck_requirement_reviews_note", "decision = 'accepted' OR length(btrim(note)) > 0"); + + t.HasCheckConstraint("ck_requirement_reviews_version", "requirement_version > 0"); + }); + }); + + modelBuilder.Entity("SpecThread.Api.Data.Team", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("owner_user_id"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("teams", "public", t => + { + t.HasCheckConstraint("ck_teams_name", "length(btrim(name)) > 0"); + }); + }); + + modelBuilder.Entity("SpecThread.Api.Data.TeamInvitation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AcceptedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("accepted_at"); + + b.Property("AcceptedBy") + .HasColumnType("text") + .HasColumnName("accepted_by"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(254) + .HasColumnType("character varying(254)") + .HasColumnName("email"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("InvitedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("invited_by"); + + b.Property("IssuedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("issued_at"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at"); + + b.Property("Role") + .IsRequired() + .HasColumnType("text") + .HasColumnName("role"); + + b.Property("TeamId") + .HasColumnType("uuid") + .HasColumnName("team_id"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("token_hash"); + + b.HasKey("Id"); + + b.HasIndex("AcceptedBy"); + + b.HasIndex("InvitedBy"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("TeamId", "Email") + .IsUnique() + .HasFilter("accepted_at IS NULL AND revoked_at IS NULL"); + + b.ToTable("team_invitations", "public", t => + { + t.HasCheckConstraint("ck_team_invitations_expiry", "expires_at > issued_at"); + + t.HasCheckConstraint("ck_team_invitations_resolution", "accepted_at IS NULL OR revoked_at IS NULL"); + + t.HasCheckConstraint("ck_team_invitations_role", "role IN ('admin', 'member')"); + }); + }); + + modelBuilder.Entity("SpecThread.Api.Data.TeamMember", b => + { + b.Property("TeamId") + .HasColumnType("uuid") + .HasColumnName("team_id"); + + b.Property("UserId") + .HasColumnType("text") + .HasColumnName("user_id"); + + b.Property("IsExpanded") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("is_expanded"); + + b.Property("IsFavorite") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_favorite"); + + b.Property("JoinedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_at") + .HasDefaultValueSql("now()"); + + b.Property("Role") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("member") + .HasColumnName("role"); + + b.HasKey("TeamId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("team_members", "public", t => + { + t.HasCheckConstraint("ck_team_members_role", "role IN ('admin', 'member')"); + }); + }); + + modelBuilder.Entity("SpecThread.Api.Data.UserOnboarding", b => + { + b.Property("UserId") + .HasColumnType("text") + .HasColumnName("user_id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.HasKey("UserId"); + + b.ToTable("user_onboarding", "public"); + }); + + modelBuilder.Entity("SpecThread.Api.Data.AcceptanceCriterion", b => + { + b.HasOne("SpecThread.Api.Data.Requirement", null) + .WithMany() + .HasForeignKey("RequirementId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.AuthAccount", b => + { + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.AuthSession", b => + { + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.Document", b => + { + b.HasOne("SpecThread.Api.Data.Project", null) + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("UploadedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.DocumentContent", b => + { + b.HasOne("SpecThread.Api.Data.Document", null) + .WithOne() + .HasForeignKey("SpecThread.Api.Data.DocumentContent", "DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.Project", b => + { + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.Team", null) + .WithMany() + .HasForeignKey("TeamId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.ProjectMember", b => + { + b.HasOne("SpecThread.Api.Data.Project", null) + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.ProjectRepository", b => + { + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("ConnectedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.Project", null) + .WithOne() + .HasForeignKey("SpecThread.Api.Data.ProjectRepository", "ProjectId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.Requirement", b => + { + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.Project", null) + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("UpdatedBy") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("SpecThread.Api.Data.RequirementCriteriaVersion", b => + { + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.Requirement", null) + .WithMany() + .HasForeignKey("RequirementId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.RequirementDocument", b => + { + b.HasOne("SpecThread.Api.Data.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("LinkedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.Requirement", null) + .WithMany() + .HasForeignKey("RequirementId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.RequirementEvidence", b => + { + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("LinkedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.Requirement", null) + .WithMany() + .HasForeignKey("RequirementId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.RequirementFollowUp", b => + { + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("LinkedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.Requirement", null) + .WithMany() + .HasForeignKey("OriginalRequirementId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.Requirement", null) + .WithMany() + .HasForeignKey("RequirementId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.RequirementFollowUpEvent", b => + { + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("Actor") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.Requirement", null) + .WithMany() + .HasForeignKey("OriginalRequirementId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.Requirement", null) + .WithMany() + .HasForeignKey("RequirementId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.RequirementReview", b => + { + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("DecidedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.Requirement", null) + .WithMany() + .HasForeignKey("RequirementId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.Team", b => + { + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.TeamInvitation", b => + { + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("AcceptedBy") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("InvitedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.Team", null) + .WithMany() + .HasForeignKey("TeamId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.TeamMember", b => + { + b.HasOne("SpecThread.Api.Data.Team", null) + .WithMany() + .HasForeignKey("TeamId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.UserOnboarding", b => + { + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/app/api/Migrations/20261030013056_RequirementCriteriaVersionTexts.cs b/app/api/Migrations/20261030013056_RequirementCriteriaVersionTexts.cs new file mode 100644 index 0000000..1a80cf9 --- /dev/null +++ b/app/api/Migrations/20261030013056_RequirementCriteriaVersionTexts.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SpecThread.Api.Migrations +{ + /// + public partial class RequirementCriteriaVersionTexts : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropCheckConstraint( + name: "ck_requirement_criteria_versions_criteria", + schema: "public", + table: "requirement_criteria_versions"); + + migrationBuilder.AddCheckConstraint( + name: "ck_requirement_criteria_versions_criteria", + schema: "public", + table: "requirement_criteria_versions", + sql: "jsonb_typeof(criteria) = 'array' AND NOT jsonb_path_exists(criteria, 'strict $[*] ? (@.type() != \"string\")')"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropCheckConstraint( + name: "ck_requirement_criteria_versions_criteria", + schema: "public", + table: "requirement_criteria_versions"); + + migrationBuilder.AddCheckConstraint( + name: "ck_requirement_criteria_versions_criteria", + schema: "public", + table: "requirement_criteria_versions", + sql: "jsonb_typeof(criteria) = 'array'"); + } + } +} diff --git a/app/api/Migrations/SpecThreadDbContextModelSnapshot.cs b/app/api/Migrations/SpecThreadDbContextModelSnapshot.cs index 42342f6..5733137 100644 --- a/app/api/Migrations/SpecThreadDbContextModelSnapshot.cs +++ b/app/api/Migrations/SpecThreadDbContextModelSnapshot.cs @@ -539,6 +539,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("text") .HasColumnName("description"); + b.Property("Milestone") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("milestone"); + b.Property("ProjectId") .HasColumnType("uuid") .HasColumnName("project_id"); @@ -575,12 +580,61 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("requirements", "public", t => { + t.HasCheckConstraint("ck_requirements_milestone", "milestone IS NULL OR (btrim(milestone) = milestone AND length(milestone) BETWEEN 1 AND 100)"); + t.HasCheckConstraint("ck_requirements_title", "length(btrim(title)) > 0"); t.HasCheckConstraint("ck_requirements_version", "version > 0"); }); }); + modelBuilder.Entity("SpecThread.Api.Data.RequirementCriteriaVersion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("Criteria") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("criteria"); + + b.Property("Number") + .HasColumnType("integer") + .HasColumnName("number"); + + b.Property("RequirementId") + .HasColumnType("uuid") + .HasColumnName("requirement_id"); + + b.HasKey("Id"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("RequirementId", "Number") + .IsUnique(); + + b.ToTable("requirement_criteria_versions", "public", t => + { + t.HasCheckConstraint("ck_requirement_criteria_versions_criteria", "jsonb_typeof(criteria) = 'array' AND NOT jsonb_path_exists(criteria, 'strict $[*] ? (@.type() != \"string\")')"); + + t.HasCheckConstraint("ck_requirement_criteria_versions_number", "number > 0"); + }); + }); + modelBuilder.Entity("SpecThread.Api.Data.RequirementDocument", b => { b.Property("RequirementId") @@ -785,6 +839,87 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); + modelBuilder.Entity("SpecThread.Api.Data.RequirementFollowUp", b => + { + b.Property("RequirementId") + .HasColumnType("uuid") + .HasColumnName("requirement_id"); + + b.Property("OriginalRequirementId") + .HasColumnType("uuid") + .HasColumnName("original_requirement_id"); + + b.Property("LinkedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("linked_at") + .HasDefaultValueSql("now()"); + + b.Property("LinkedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("linked_by"); + + b.HasKey("RequirementId", "OriginalRequirementId"); + + b.HasIndex("LinkedBy"); + + b.HasIndex("OriginalRequirementId"); + + b.ToTable("requirement_follow_ups", "public", t => + { + t.HasCheckConstraint("ck_requirement_follow_ups_distinct", "requirement_id <> original_requirement_id"); + }); + }); + + modelBuilder.Entity("SpecThread.Api.Data.RequirementFollowUpEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Action") + .IsRequired() + .HasColumnType("text") + .HasColumnName("action"); + + b.Property("Actor") + .IsRequired() + .HasColumnType("text") + .HasColumnName("actor"); + + b.Property("OccurredAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_at") + .HasDefaultValueSql("now()"); + + b.Property("OriginalRequirementId") + .HasColumnType("uuid") + .HasColumnName("original_requirement_id"); + + b.Property("RequirementId") + .HasColumnType("uuid") + .HasColumnName("requirement_id"); + + b.HasKey("Id"); + + b.HasIndex("Actor"); + + b.HasIndex("OriginalRequirementId"); + + b.HasIndex("RequirementId"); + + b.ToTable("requirement_follow_up_events", "public", t => + { + t.HasCheckConstraint("ck_requirement_follow_up_events_action", "action IN ('added', 'removed')"); + + t.HasCheckConstraint("ck_requirement_follow_up_events_distinct", "requirement_id <> original_requirement_id"); + }); + }); + modelBuilder.Entity("SpecThread.Api.Data.RequirementReview", b => { b.Property("Id") @@ -793,6 +928,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnName("id") .HasDefaultValueSql("gen_random_uuid()"); + b.Property("CriteriaVersion") + .HasColumnType("integer") + .HasColumnName("criteria_version"); + b.Property("DecidedAt") .ValueGeneratedOnAdd() .HasColumnType("timestamp with time zone") @@ -837,6 +976,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("requirement_reviews", "public", t => { + t.HasCheckConstraint("ck_requirement_reviews_criteria_version", "criteria_version IS NULL OR criteria_version > 0"); + t.HasCheckConstraint("ck_requirement_reviews_decision", "decision IN ('accepted', 'rejected', 'more_evidence')"); t.HasCheckConstraint("ck_requirement_reviews_evidence", "jsonb_typeof(evidence) = 'array'"); @@ -1145,6 +1286,21 @@ protected override void BuildModel(ModelBuilder modelBuilder) .OnDelete(DeleteBehavior.Restrict); }); + modelBuilder.Entity("SpecThread.Api.Data.RequirementCriteriaVersion", b => + { + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.Requirement", null) + .WithMany() + .HasForeignKey("RequirementId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + modelBuilder.Entity("SpecThread.Api.Data.RequirementDocument", b => { b.HasOne("SpecThread.Api.Data.Document", null) @@ -1181,6 +1337,48 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("SpecThread.Api.Data.RequirementFollowUp", b => + { + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("LinkedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.Requirement", null) + .WithMany() + .HasForeignKey("OriginalRequirementId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.Requirement", null) + .WithMany() + .HasForeignKey("RequirementId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.RequirementFollowUpEvent", b => + { + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("Actor") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.Requirement", null) + .WithMany() + .HasForeignKey("OriginalRequirementId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.Requirement", null) + .WithMany() + .HasForeignKey("RequirementId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + modelBuilder.Entity("SpecThread.Api.Data.RequirementReview", b => { b.HasOne("SpecThread.Api.Data.AuthUser", null) diff --git a/app/api/Program.cs b/app/api/Program.cs index 5b173e6..2ea9d04 100644 --- a/app/api/Program.cs +++ b/app/api/Program.cs @@ -66,6 +66,9 @@ app.MapRepositoryEndpoints(); app.MapEvidenceEndpoints(); app.MapReviewEndpoints(); +app.MapCriteriaVersionEndpoints(); +app.MapFollowUpEndpoints(); +app.MapMilestoneEndpoints(); app.MapTeamEndpoints(); app.MapTeamManagementEndpoints(); app.MapInvitationEndpoints(); diff --git a/app/api/Projects/ProjectAccess.cs b/app/api/Projects/ProjectAccess.cs index 8ecc22a..5c4c171 100644 --- a/app/api/Projects/ProjectAccess.cs +++ b/app/api/Projects/ProjectAccess.cs @@ -31,6 +31,7 @@ internal sealed class InputErrors public const int MaxCriterionLength = 2_000; public const int MaxRequirementDocuments = 20; // ADR-049 public const int MaxEmailLength = 254; + public const int MaxMilestoneLength = 100; // ADR-053 private readonly Dictionary errors = []; @@ -53,6 +54,13 @@ internal sealed class InputErrors return trimmed.Length > maxLength ? Add(field, $"Use at most {maxLength} characters.") : trimmed; } + // A milestone or sprint label: trimmed, and null when empty. + public string? Milestone(string field, string? value) + { + var trimmed = Optional(field, value, MaxMilestoneLength); + return string.IsNullOrEmpty(trimmed) ? null : trimmed; + } + public List? Criteria(string field, IReadOnlyList? values) { values ??= []; diff --git a/app/api/Requirements/CriteriaVersionEndpoints.cs b/app/api/Requirements/CriteriaVersionEndpoints.cs new file mode 100644 index 0000000..04bf158 --- /dev/null +++ b/app/api/Requirements/CriteriaVersionEndpoints.cs @@ -0,0 +1,96 @@ +using System.Security.Claims; +using System.Text.Json; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.EntityFrameworkCore; +using SpecThread.Api.Data; +using SpecThread.Api.Projects; + +namespace SpecThread.Api.Requirements; + +// Every saved change to a requirement's acceptance criteria is kept as a numbered version, so a +// decision can be read against the criteria it was made on (ADR-051). Any project member reads +// them, archived or not. Versions are written only by creating and saving requirements. +internal static class CriteriaVersionEndpoints +{ + private static readonly JsonSerializerOptions CriteriaJson = new(JsonSerializerDefaults.Web); + + public static void MapCriteriaVersionEndpoints(this IEndpointRouteBuilder app) + { + var versions = app.MapGroup("/requirements/{requirementId:guid}/criteria-versions").WithTags("Requirements") + .WithMetadata(ProjectEndpoints.Unauthorized); + versions.MapGet("/", List).WithName("ListCriteriaVersions"); + versions.MapGet("/{number:int}", Get).WithName("GetCriteriaVersion"); + } + + private static async Task>, NotFound>> List( + Guid requirementId, ClaimsPrincipal user, SpecThreadDbContext db, CancellationToken cancel) + { + if (!await IsMemberAsync(db, user.CallerId(), requirementId, cancel)) return TypedResults.NotFound(); + var versions = await db.RequirementCriteriaVersions.AsNoTracking().Where(v => v.RequirementId == requirementId) + .OrderByDescending(v => v.Number).ToListAsync(cancel); + return TypedResults.Ok(versions.ConvertAll(ToResponse)); + } + + private static async Task, NotFound>> Get( + Guid requirementId, int number, ClaimsPrincipal user, SpecThreadDbContext db, CancellationToken cancel) + { + if (!await IsMemberAsync(db, user.CallerId(), requirementId, cancel)) return TypedResults.NotFound(); + var version = await db.RequirementCriteriaVersions.AsNoTracking() + .SingleOrDefaultAsync(v => v.RequirementId == requirementId && v.Number == number, cancel); + return version is null ? TypedResults.NotFound() : TypedResults.Ok(ToResponse(version)); + } + + // The requirement's current criteria and the number of the version that holds them. When the + // newest version does not match them (none yet, or the previous API saved during a deploy, + // ADR-044), a catch-up version is added, credited to whoever saved the requirement last. Call + // after locking the requirement and before changing its criteria. + internal static async Task EnsureCurrentAsync(SpecThreadDbContext db, Requirement requirement, CancellationToken cancel) + { + var current = await db.AcceptanceCriteria.AsNoTracking().Where(c => c.RequirementId == requirement.Id) + .OrderBy(c => c.Position).Select(c => c.Text).ToListAsync(cancel); + var latest = await db.RequirementCriteriaVersions.AsNoTracking().Where(v => v.RequirementId == requirement.Id) + .OrderByDescending(v => v.Number).FirstOrDefaultAsync(cancel); + if (latest is not null && ReadCriteria(latest).SequenceEqual(current)) return new(latest.Number, current); + var number = (latest?.Number ?? 0) + 1; + Add(db, requirement.Id, number, current, requirement.UpdatedBy ?? requirement.CreatedBy, requirement.UpdatedAt); + return new(number, current); + } + + internal static void Add(SpecThreadDbContext db, Guid requirementId, int number, IEnumerable criteria, string userId, DateTime at) => + db.RequirementCriteriaVersions.Add(new RequirementCriteriaVersion + { + Id = Guid.NewGuid(), + RequirementId = requirementId, + Number = number, + Criteria = JsonSerializer.Serialize(criteria, CriteriaJson), + CreatedBy = userId, + CreatedAt = at, + }); + + // The newest version number of each requirement that has one. + internal static Task> LatestNumbersAsync(SpecThreadDbContext db, List requirementIds, CancellationToken cancel) => + db.RequirementCriteriaVersions.AsNoTracking().Where(v => requirementIds.Contains(v.RequirementId)) + .GroupBy(v => v.RequirementId) + .Select(group => new { RequirementId = group.Key, Number = group.Max(v => v.Number) }) + .ToDictionaryAsync(x => x.RequirementId, x => x.Number, cancel); + + internal static async Task LatestNumberAsync(SpecThreadDbContext db, Guid requirementId, CancellationToken cancel) => + await db.RequirementCriteriaVersions.AsNoTracking().Where(v => v.RequirementId == requirementId) + .MaxAsync(v => (int?)v.Number, cancel); + + private static List ReadCriteria(RequirementCriteriaVersion version) => + JsonSerializer.Deserialize>(version.Criteria, CriteriaJson) ?? []; + + // False when the requirement does not exist or the caller is not a member of its project. + private static Task IsMemberAsync(SpecThreadDbContext db, string userId, Guid requirementId, CancellationToken cancel) => + db.Requirements.Where(r => r.Id == requirementId) + .Join(db.ProjectsFor(userId), r => r.ProjectId, p => p.Id, (r, p) => r.Id) + .AnyAsync(cancel); + + private static CriteriaVersionResponse ToResponse(RequirementCriteriaVersion v) => + new(v.Number, ReadCriteria(v), v.CreatedBy, v.CreatedAt); +} + +internal sealed record CurrentCriteria(int Number, List Criteria); + +internal sealed record CriteriaVersionResponse(int Number, List Criteria, string CreatedBy, DateTime CreatedAt); diff --git a/app/api/Requirements/EvidencePolicy.cs b/app/api/Requirements/EvidencePolicy.cs new file mode 100644 index 0000000..f91e4e3 --- /dev/null +++ b/app/api/Requirements/EvidencePolicy.cs @@ -0,0 +1,33 @@ +using System.Text.Json; +using SpecThread.Api.GitHub; + +namespace SpecThread.Api.Requirements; + +// The fixed rule for "Evidence complete" until a configurable evidence policy exists (ADR-053). +// It describes the recorded evidence only; it is not acceptance, which a person decides (ADR-004). +internal static class EvidencePolicy +{ + private static readonly JsonSerializerOptions ChecksJson = new(JsonSerializerDefaults.Web); + private static readonly string[] Code = ["pull_request", "commit"]; + private static readonly string[] Acceptable = ["passed", "skipped", "neutral"]; + + // Complete when at least one pull request or commit is linked, the checks of every linked one + // could be read, none of them failed, is still running, or was cancelled, and at least one passed. + public static bool IsComplete(IEnumerable evidence) + { + var code = evidence.Where(e => Code.Contains(e.Kind)).ToList(); + if (code.Count == 0) return false; + var passed = false; + foreach (var item in code) + { + if (item.Checks is null) return false; + var checks = JsonSerializer.Deserialize>(item.Checks, ChecksJson) ?? []; + if (checks.Any(c => !Acceptable.Contains(c.Result))) return false; + passed |= checks.Any(c => c.Result == "passed"); + } + return passed; + } +} + +// The kind of an evidence link and its stored check results (JSON, or null when unreadable). +internal sealed record EvidenceChecks(string Kind, string? Checks); diff --git a/app/api/Requirements/FollowUpEndpoints.cs b/app/api/Requirements/FollowUpEndpoints.cs new file mode 100644 index 0000000..a9f431b --- /dev/null +++ b/app/api/Requirements/FollowUpEndpoints.cs @@ -0,0 +1,146 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.EntityFrameworkCore; +using SpecThread.Api.Data; +using SpecThread.Api.Projects; + +namespace SpecThread.Api.Requirements; + +// A requirement can be marked as a follow-up of other requirements in its project, so a later +// change is traceable without rewriting what was accepted (ADR-052). Any project member links and +// unlinks; both are recorded with who did it and when. Links do not change either requirement's +// version, so they never make a decision outdated. +internal static class FollowUpEndpoints +{ + public const int MaxFollowUps = 20; + + public static void MapFollowUpEndpoints(this IEndpointRouteBuilder app) + { + var followUps = app.MapGroup("/requirements/{requirementId:guid}/follow-ups").WithTags("Requirements") + .WithMetadata(ProjectEndpoints.Unauthorized); + followUps.MapGet("/", List).WithName("ListFollowUps"); + followUps.MapPost("/", Add).WithName("AddFollowUp").ProducesProblem(StatusCodes.Status409Conflict); + followUps.MapDelete("/{originalRequirementId:guid}", Remove).WithName("RemoveFollowUp").ProducesProblem(StatusCodes.Status409Conflict); + } + + private static async Task, NotFound>> List( + Guid requirementId, ClaimsPrincipal user, SpecThreadDbContext db, CancellationToken cancel) + { + var member = await db.Requirements.Where(r => r.Id == requirementId) + .Join(db.ProjectsFor(user.CallerId()), r => r.ProjectId, p => p.Id, (r, p) => r.Id).AnyAsync(cancel); + if (!member) return TypedResults.NotFound(); + + var followUpOf = await db.RequirementFollowUps.AsNoTracking().Where(f => f.RequirementId == requirementId) + .Join(db.Requirements, f => f.OriginalRequirementId, r => r.Id, (f, r) => new { Link = f, Other = r }) + .OrderBy(x => x.Other.Title).ThenBy(x => x.Other.Id) + .Select(x => new FollowUpLink(x.Other.Id, x.Other.Title, x.Other.ArchivedAt, x.Link.LinkedBy, x.Link.LinkedAt)) + .ToListAsync(cancel); + var followedUpBy = await db.RequirementFollowUps.AsNoTracking().Where(f => f.OriginalRequirementId == requirementId) + .Join(db.Requirements, f => f.RequirementId, r => r.Id, (f, r) => new { Link = f, Other = r }) + .OrderBy(x => x.Other.Title).ThenBy(x => x.Other.Id) + .Select(x => new FollowUpLink(x.Other.Id, x.Other.Title, x.Other.ArchivedAt, x.Link.LinkedBy, x.Link.LinkedAt)) + .ToListAsync(cancel); + var history = await db.RequirementFollowUpEvents.AsNoTracking() + .Where(e => e.RequirementId == requirementId || e.OriginalRequirementId == requirementId) + .Join(db.Requirements, e => e.RequirementId, r => r.Id, (e, r) => new { Event = e, FollowUpTitle = r.Title }) + .Join(db.Requirements, x => x.Event.OriginalRequirementId, r => r.Id, (x, r) => new { x.Event, x.FollowUpTitle, OriginalTitle = r.Title }) + .OrderByDescending(x => x.Event.OccurredAt).ThenByDescending(x => x.Event.Id) + .Select(x => new FollowUpEventResponse(x.Event.Id, x.Event.RequirementId, x.FollowUpTitle, x.Event.OriginalRequirementId, + x.OriginalTitle, x.Event.Action, x.Event.Actor, x.Event.OccurredAt)) + .ToListAsync(cancel); + return TypedResults.Ok(new FollowUpsResponse(followUpOf, followedUpBy, history)); + } + + private static async Task, ValidationProblem, NotFound, ProblemHttpResult>> Add( + Guid requirementId, FollowUpRequest request, ClaimsPrincipal user, SpecThreadDbContext db, CancellationToken cancel) + { + if (request.OriginalRequirementId is not Guid originalId || originalId == Guid.Empty) return Invalid("Choose a requirement."); + + var userId = user.CallerId(); + // Serialize with archiving, membership changes, and other links in this project's team, + // so the checks and the limit below still hold when the link is saved. + await using var transaction = await db.Database.BeginTransactionAsync(cancel); + var found = await RequirementEndpoints.LockForMember(db, userId, requirementId, cancel); + if (found is null) return TypedResults.NotFound(); + if (found.Archived) return ProjectEndpoints.Archived(); + if (originalId == requirementId) return Invalid("A requirement can't be a follow-up of itself."); + var original = await db.Requirements.AsNoTracking() + .SingleOrDefaultAsync(r => r.Id == originalId && r.ProjectId == found.Requirement.ProjectId, cancel); + if (original is null) return Invalid("Choose a requirement in this project."); + if (original.ArchivedAt is not null) + { + return TypedResults.Problem("An archived requirement can't be linked as an original.", statusCode: StatusCodes.Status409Conflict); + } + var links = await db.RequirementFollowUps + .Where(f => (f.RequirementId == requirementId && f.OriginalRequirementId == originalId) || + (f.RequirementId == originalId && f.OriginalRequirementId == requirementId)) + .Select(f => f.RequirementId).ToListAsync(cancel); + if (links.Contains(requirementId)) + { + return TypedResults.Problem("This requirement is already a follow-up of that one.", statusCode: StatusCodes.Status409Conflict); + } + if (links.Contains(originalId)) return Invalid("That requirement is already a follow-up of this one."); + if (await db.RequirementFollowUps.CountAsync(f => f.RequirementId == requirementId, cancel) >= MaxFollowUps) + { + return Invalid($"A requirement can follow up at most {MaxFollowUps} requirements."); + } + + var now = Clock.UtcNow(); + db.RequirementFollowUps.Add(new RequirementFollowUp + { + RequirementId = requirementId, + OriginalRequirementId = originalId, + LinkedBy = userId, + LinkedAt = now, + }); + Record(db, requirementId, originalId, "added", userId, now); + await db.SaveChangesAsync(cancel); + await transaction.CommitAsync(cancel); + return TypedResults.Created($"/requirements/{requirementId}/follow-ups", + new FollowUpLink(original.Id, original.Title, original.ArchivedAt, userId, now)); + } + + private static async Task> Remove( + Guid requirementId, Guid originalRequirementId, ClaimsPrincipal user, SpecThreadDbContext db, CancellationToken cancel) + { + var userId = user.CallerId(); + await using var transaction = await db.Database.BeginTransactionAsync(cancel); + var found = await RequirementEndpoints.LockForMember(db, userId, requirementId, cancel); + if (found is null) return TypedResults.NotFound(); + var link = await db.RequirementFollowUps + .SingleOrDefaultAsync(f => f.RequirementId == requirementId && f.OriginalRequirementId == originalRequirementId, cancel); + if (link is null) return TypedResults.NotFound(); + if (found.Archived) return ProjectEndpoints.Archived(); + + db.RequirementFollowUps.Remove(link); + Record(db, requirementId, originalRequirementId, "removed", userId, Clock.UtcNow()); + await db.SaveChangesAsync(cancel); + await transaction.CommitAsync(cancel); + return TypedResults.NoContent(); + } + + private static void Record(SpecThreadDbContext db, Guid requirementId, Guid originalId, string action, string userId, DateTime at) => + db.RequirementFollowUpEvents.Add(new RequirementFollowUpEvent + { + Id = Guid.NewGuid(), + RequirementId = requirementId, + OriginalRequirementId = originalId, + Action = action, + Actor = userId, + OccurredAt = at, + }); + + private static ValidationProblem Invalid(string message) => + TypedResults.ValidationProblem(new Dictionary { ["originalRequirementId"] = [message] }); +} + +internal sealed record FollowUpRequest(Guid? OriginalRequirementId); + +// The other requirement of a follow-up link, with who linked them and when. +internal sealed record FollowUpLink(Guid RequirementId, string Title, DateTime? ArchivedAt, string LinkedBy, DateTime LinkedAt); + +// One added or removed link. RequirementId is the follow-up; OriginalRequirementId the one it follows up. +internal sealed record FollowUpEventResponse( + Guid Id, Guid RequirementId, string RequirementTitle, Guid OriginalRequirementId, string OriginalTitle, string Action, string Actor, DateTime OccurredAt); + +internal sealed record FollowUpsResponse(List FollowUpOf, List FollowedUpBy, List History); diff --git a/app/api/Requirements/MilestoneEndpoints.cs b/app/api/Requirements/MilestoneEndpoints.cs new file mode 100644 index 0000000..f902704 --- /dev/null +++ b/app/api/Requirements/MilestoneEndpoints.cs @@ -0,0 +1,62 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.EntityFrameworkCore; +using SpecThread.Api.Data; +using SpecThread.Api.Projects; + +namespace SpecThread.Api.Requirements; + +// A requirement carries at most one milestone or sprint label, which any project member sets +// (ADR-053). Labels are free text, matched exactly; there is no separate milestone record. +internal static class MilestoneEndpoints +{ + public static void MapMilestoneEndpoints(this IEndpointRouteBuilder app) + { + app.MapGet("/projects/{projectId:guid}/milestones", List).WithName("ListMilestones").WithTags("Requirements") + .WithMetadata(ProjectEndpoints.Unauthorized); + app.MapPut("/requirements/{requirementId:guid}/milestone", Set).WithName("SetRequirementMilestone").WithTags("Requirements") + .WithMetadata(ProjectEndpoints.Unauthorized).ProducesProblem(StatusCodes.Status409Conflict); + } + + // The labels in use on the project's active requirements, in order. + private static async Task>, NotFound>> List( + Guid projectId, ClaimsPrincipal user, SpecThreadDbContext db, CancellationToken cancel) + { + if (!await db.ProjectsFor(user.CallerId()).AnyAsync(p => p.Id == projectId, cancel)) return TypedResults.NotFound(); + var labels = await db.Requirements.AsNoTracking() + .Where(r => r.ProjectId == projectId && r.ArchivedAt == null && r.Milestone != null) + .Select(r => r.Milestone!).Distinct().ToListAsync(cancel); + labels.Sort(StringComparer.Ordinal); + return TypedResults.Ok(labels); + } + + // Setting or clearing the label is not an edit: the version and last editor stay as they + // were, so a decision on the requirement does not become outdated. + private static async Task, ValidationProblem, NotFound, ProblemHttpResult>> Set( + Guid requirementId, MilestoneRequest request, ClaimsPrincipal user, SpecThreadDbContext db, CancellationToken cancel) + { + var errors = new InputErrors(); + var milestone = errors.Milestone("milestone", request.Milestone); + if (errors.Any) return TypedResults.ValidationProblem(errors.ToDictionary()); + + await using var transaction = await db.Database.BeginTransactionAsync(cancel); + var found = await RequirementEndpoints.LockForMember(db, user.CallerId(), requirementId, cancel); + if (found is null) return TypedResults.NotFound(); + if (found.Archived) return ProjectEndpoints.Archived(); + found.Requirement.Milestone = milestone; + try + { + await db.SaveChangesAsync(cancel); + } + catch (DbUpdateConcurrencyException) + { + return RequirementEndpoints.Stale(); + } + await transaction.CommitAsync(cancel); + return TypedResults.Ok(new MilestoneResponse(milestone)); + } +} + +internal sealed record MilestoneRequest(string? Milestone); + +internal sealed record MilestoneResponse(string? Milestone); diff --git a/app/api/Requirements/RequirementEndpoints.cs b/app/api/Requirements/RequirementEndpoints.cs index 9c88eae..f6a0f6b 100644 --- a/app/api/Requirements/RequirementEndpoints.cs +++ b/app/api/Requirements/RequirementEndpoints.cs @@ -28,17 +28,18 @@ public static void MapRequirementEndpoints(this IEndpointRouteBuilder app) .ProducesProblem(StatusCodes.Status409Conflict); } - // Active requirements by default; `archived=true` lists only the archived ones (ADR-050). + // Active requirements by default; `archived=true` lists only the archived ones (ADR-050), and + // `milestone` only those with exactly that label (ADR-053). private static async Task>, NotFound>> List( - Guid projectId, bool? archived, ClaimsPrincipal user, SpecThreadDbContext db, CancellationToken cancel) + Guid projectId, bool? archived, string? milestone, ClaimsPrincipal user, SpecThreadDbContext db, CancellationToken cancel) { if (!await db.ProjectsFor(user.CallerId()).AnyAsync(p => p.Id == projectId, cancel)) return TypedResults.NotFound(); var wantArchived = archived == true; - var requirements = await db.Requirements.AsNoTracking() - .Where(r => r.ProjectId == projectId && (r.ArchivedAt != null) == wantArchived) - .OrderBy(r => r.CreatedAt).ThenBy(r => r.Id) - .ToListAsync(cancel); + var query = db.Requirements.AsNoTracking().Where(r => r.ProjectId == projectId && (r.ArchivedAt != null) == wantArchived); + var label = milestone?.Trim(); + if (!string.IsNullOrEmpty(label)) query = query.Where(r => r.Milestone == label); + var requirements = await query.OrderBy(r => r.CreatedAt).ThenBy(r => r.Id).ToListAsync(cancel); // The latest decision on each requirement, and whether it still describes what is there (ADR-038). var ids = requirements.ConvertAll(r => r.Id); @@ -46,21 +47,29 @@ private static async Task>, NotFound>> List( .GroupBy(v => v.RequirementId) .ToDictionary(group => group.Key, group => group.OrderByDescending(v => v.DecidedAt).ThenByDescending(v => v.Id).First()); var evidence = (await db.RequirementEvidence.AsNoTracking().Where(e => ids.Contains(e.RequirementId)) - .Select(e => new { e.RequirementId, e.Id }).ToListAsync(cancel)) - .ToLookup(e => e.RequirementId, e => e.Id); - - return TypedResults.Ok(requirements.ConvertAll(r => new RequirementSummary( - r.Id, r.ProjectId, r.Title, r.Version, r.CreatedAt, r.UpdatedAt, r.ArchivedAt, evidence[r.Id].Count(), - reviews.TryGetValue(r.Id, out var review) - ? new ReviewSummary(review.Decision, review.DecidedBy, review.DecidedAt, ReviewEndpoints.IsOutdated(review, r.Version, evidence[r.Id])) - : null))); + .Select(e => new { e.RequirementId, e.Id, e.Kind, e.Checks }).ToListAsync(cancel)) + .ToLookup(e => e.RequirementId); + var criteriaVersions = await CriteriaVersionEndpoints.LatestNumbersAsync(db, ids, cancel); + + return TypedResults.Ok(requirements.ConvertAll(r => + { + var links = evidence[r.Id].ToList(); + int? criteriaVersion = criteriaVersions.TryGetValue(r.Id, out var number) ? number : null; + return new RequirementSummary( + r.Id, r.ProjectId, r.Title, r.Version, r.CreatedAt, r.UpdatedAt, r.ArchivedAt, links.Count, + reviews.TryGetValue(r.Id, out var review) + ? ReviewEndpoints.Summarize(review, r.Version, links.Select(e => e.Id), criteriaVersion) + : null, + r.Milestone, criteriaVersion, EvidencePolicy.IsComplete(links.Select(e => new EvidenceChecks(e.Kind, e.Checks)))); + })); } private static async Task, ValidationProblem, NotFound, ProblemHttpResult>> Create( Guid projectId, RequirementRequest request, ClaimsPrincipal user, SpecThreadDbContext db, CancellationToken cancel) { var input = Validate(request.Title, request.Description, request.AcceptanceCriteria, request.DocumentIds, out var errors); - if (input is null) return TypedResults.ValidationProblem(errors.ToDictionary()); + var milestone = errors.Milestone("milestone", request.Milestone); + if (input is null || errors.Any) return TypedResults.ValidationProblem(errors.ToDictionary()); var userId = user.CallerId(); // Serialize with archiving, membership changes, and other writes in this project's team. @@ -83,15 +92,17 @@ private static async Task, ValidationProble UpdatedAt = now, UpdatedBy = userId, Version = 1, + Milestone = milestone, }; db.Requirements.Add(requirement); var criteria = AddCriteria(db, requirement.Id, input.Criteria); + CriteriaVersionEndpoints.Add(db, requirement.Id, 1, input.Criteria, userId, now); LinkDocuments(db, requirement.Id, input.DocumentIds, userId, now); await db.SaveChangesAsync(cancel); var documents = await LoadDocuments(db, requirement.Id, cancel); await transaction.CommitAsync(cancel); - return TypedResults.Created($"/requirements/{requirement.Id}", ToResponse(requirement, criteria, documents)); + return TypedResults.Created($"/requirements/{requirement.Id}", ToResponse(requirement, criteria, documents, 1)); } private static async Task, NotFound>> Get( @@ -104,10 +115,14 @@ private static async Task, NotFound>> Get( .Where(c => c.RequirementId == requirementId) .OrderBy(c => c.Position) .ToListAsync(cancel); - return TypedResults.Ok(ToResponse(requirement.Requirement, criteria, await LoadDocuments(db, requirementId, cancel))); + return TypedResults.Ok(ToResponse(requirement.Requirement, criteria, await LoadDocuments(db, requirementId, cancel), + await CriteriaVersionEndpoints.LatestNumberAsync(db, requirementId, cancel))); } // Replaces the whole criteria and document lists; a stale version means someone else saved first. + // Changed criteria become a new criteria version. When the latest decision accepted the + // current criteria, changing them needs `confirmCriteriaChange`, because that acceptance + // applies to the previous criteria and the requirement will need a new review (ADR-051). private static async Task, ValidationProblem, NotFound, ProblemHttpResult>> Update( Guid requirementId, UpdateRequirementRequest request, ClaimsPrincipal user, SpecThreadDbContext db, CancellationToken cancel) { @@ -123,6 +138,15 @@ private static async Task, ValidationProblem, No if (found.ProjectArchived || requirement.ArchivedAt is not null) return ProjectEndpoints.Archived(); if (requirement.Version != request.Version) return Stale(); + var (criteriaVersion, currentCriteria) = await CriteriaVersionEndpoints.EnsureCurrentAsync(db, requirement, cancel); + var criteriaChanged = !input.Criteria.SequenceEqual(currentCriteria); + if (criteriaChanged && request.ConfirmCriteriaChange != true) + { + var latest = await db.RequirementReviews.AsNoTracking().Where(v => v.RequirementId == requirementId) + .OrderByDescending(v => v.DecidedAt).ThenByDescending(v => v.Id).FirstOrDefaultAsync(cancel); + if (latest is { Decision: "accepted" } && latest.CriteriaVersion == criteriaVersion) return AcceptedCriteriaChange(criteriaVersion); + } + // Kept links keep who linked them and when; only removed ones go and only new ones are added. var links = await db.RequirementDocuments.Where(l => l.RequirementId == requirementId).ToListAsync(cancel); var linked = links.Select(l => l.DocumentId).ToHashSet(); @@ -139,6 +163,7 @@ private static async Task, ValidationProblem, No requirement.UpdatedBy = userId; requirement.Version++; var criteria = AddCriteria(db, requirementId, input.Criteria); + if (criteriaChanged) CriteriaVersionEndpoints.Add(db, requirementId, ++criteriaVersion, input.Criteria, userId, now); LinkDocuments(db, requirementId, input.DocumentIds.Where(id => !linked.Contains(id)), userId, now); try { @@ -151,7 +176,7 @@ private static async Task, ValidationProblem, No var documents = await LoadDocuments(db, requirementId, cancel); await transaction.CommitAsync(cancel); - return TypedResults.Ok(ToResponse(requirement, criteria, documents)); + return TypedResults.Ok(ToResponse(requirement, criteria, documents, criteriaVersion)); } private static Task, NotFound, ProblemHttpResult>> Archive( @@ -188,7 +213,8 @@ private static async Task, NotFound, ProblemHttp .Where(c => c.RequirementId == requirementId) .OrderBy(c => c.Position) .ToListAsync(cancel); - return TypedResults.Ok(ToResponse(found.Requirement, criteria, await LoadDocuments(db, requirementId, cancel))); + return TypedResults.Ok(ToResponse(found.Requirement, criteria, await LoadDocuments(db, requirementId, cancel), + await CriteriaVersionEndpoints.LatestNumberAsync(db, requirementId, cancel))); } // Like FindForMember, but locks the project's team first and then reads the requirement, so @@ -276,13 +302,20 @@ private static List AddCriteria(SpecThreadDbContext db, Gui return criteria; } - private static ProblemHttpResult Stale() => TypedResults.Problem( + internal static ProblemHttpResult Stale() => TypedResults.Problem( "This requirement changed since it was loaded. Reload it and try again.", statusCode: StatusCodes.Status409Conflict); + private static ProblemHttpResult AcceptedCriteriaChange(int criteriaVersion) => TypedResults.Problem( + $"This requirement was accepted against criteria version {criteriaVersion}. That acceptance applies to the previous criteria, " + + "so saving new criteria means it needs a new review. Confirm the change to save it.", + statusCode: StatusCodes.Status409Conflict, + extensions: new Dictionary { ["code"] = "accepted_criteria_change", ["acceptedCriteriaVersion"] = criteriaVersion }); + + // `criteriaVersion` is null only for a requirement the previous API created during a deploy (ADR-044). private static RequirementResponse ToResponse( - Requirement r, IEnumerable criteria, List documents) => new( + Requirement r, IEnumerable criteria, List documents, int? criteriaVersion) => new( r.Id, r.ProjectId, r.Title, r.Description, r.CreatedBy, r.UpdatedBy ?? r.CreatedBy, r.Version, r.CreatedAt, r.UpdatedAt, r.ArchivedAt, - criteria.Select(c => new CriterionResponse(c.Id, c.Text, c.Position)).ToList(), documents); + criteria.Select(c => new CriterionResponse(c.Id, c.Text, c.Position)).ToList(), documents, criteriaVersion, r.Milestone); internal sealed record MemberRequirement(Requirement Requirement, bool ProjectArchived) { @@ -292,18 +325,21 @@ internal sealed record MemberRequirement(Requirement Requirement, bool ProjectAr private sealed record ValidInput(string Title, string Description, List Criteria, List DocumentIds); } -internal sealed record RequirementRequest(string? Title, string? Description, List? AcceptanceCriteria, List? DocumentIds); +internal sealed record RequirementRequest( + string? Title, string? Description, List? AcceptanceCriteria, List? DocumentIds, string? Milestone = null); internal sealed record UpdateRequirementRequest( - string? Title, string? Description, List? AcceptanceCriteria, List? DocumentIds, int? Version); + string? Title, string? Description, List? AcceptanceCriteria, List? DocumentIds, int? Version, + bool? ConfirmCriteriaChange = null); internal sealed record RequirementSummary( - Guid Id, Guid ProjectId, string Title, int Version, DateTime CreatedAt, DateTime UpdatedAt, DateTime? ArchivedAt, int EvidenceCount, ReviewSummary? Review); + Guid Id, Guid ProjectId, string Title, int Version, DateTime CreatedAt, DateTime UpdatedAt, DateTime? ArchivedAt, int EvidenceCount, ReviewSummary? Review, + string? Milestone, int? CriteriaVersion, bool EvidenceComplete); internal sealed record RequirementResponse( Guid Id, Guid ProjectId, string Title, string Description, string CreatedBy, string UpdatedBy, int Version, DateTime CreatedAt, DateTime UpdatedAt, DateTime? ArchivedAt, List AcceptanceCriteria, - List Documents); + List Documents, int? CriteriaVersion, string? Milestone); internal sealed record CriterionResponse(Guid Id, string Text, int Position); diff --git a/app/api/Requirements/ReviewEndpoints.cs b/app/api/Requirements/ReviewEndpoints.cs index 1a27aab..581d4b8 100644 --- a/app/api/Requirements/ReviewEndpoints.cs +++ b/app/api/Requirements/ReviewEndpoints.cs @@ -74,6 +74,7 @@ private static async Task, ValidationProblem, No Decision = decision!, Note = note!, RequirementVersion = requirement.Version, + CriteriaVersion = (await CriteriaVersionEndpoints.EnsureCurrentAsync(db, requirement, cancel)).Number, Evidence = JsonSerializer.Serialize(evidence.ConvertAll(e => new ReviewedEvidence(e.Id, e.Kind, Label(e), e.Title, e.State)), SnapshotJson), DecidedBy = userId, DecidedAt = Clock.UtcNow(), @@ -89,6 +90,12 @@ private static async Task, ValidationProblem, No public static bool IsOutdated(RequirementReview review, int currentVersion, IEnumerable currentEvidence) => review.RequirementVersion != currentVersion || !ReadSnapshot(review).Select(e => e.Id).ToHashSet().SetEquals(currentEvidence); + // The latest decision as lists show it. `CriteriaChanged` says the criteria have been changed + // since the decision, so it applies to an earlier criteria version and needs a new review (ADR-051). + public static ReviewSummary Summarize(RequirementReview review, int currentVersion, IEnumerable currentEvidence, int? currentCriteriaVersion) => + new(review.Decision, review.DecidedBy, review.DecidedAt, IsOutdated(review, currentVersion, currentEvidence), + review.CriteriaVersion is { } reviewed && currentCriteriaVersion is { } current && reviewed != current, review.CriteriaVersion); + private static List ReadSnapshot(RequirementReview review) => JsonSerializer.Deserialize>(review.Evidence, SnapshotJson) ?? []; @@ -106,7 +113,7 @@ private static Task IsMemberAsync(SpecThreadDbContext db, string userId, G .AnyAsync(cancel); private static ReviewResponse ToResponse(RequirementReview r) => - new(r.Id, r.RequirementId, r.Decision, r.Note, r.RequirementVersion, ReadSnapshot(r), r.DecidedBy, r.DecidedAt); + new(r.Id, r.RequirementId, r.Decision, r.Note, r.RequirementVersion, ReadSnapshot(r), r.DecidedBy, r.DecidedAt, r.CriteriaVersion); } internal sealed record RecordReviewRequest(string? Decision, string? Note, int? Version); @@ -115,7 +122,8 @@ internal sealed record RecordReviewRequest(string? Decision, string? Note, int? internal sealed record ReviewedEvidence(Guid Id, string Kind, string Label, string Title, string? State); internal sealed record ReviewResponse( - Guid Id, Guid RequirementId, string Decision, string Note, int RequirementVersion, List Evidence, string DecidedBy, DateTime DecidedAt); + Guid Id, Guid RequirementId, string Decision, string Note, int RequirementVersion, List Evidence, string DecidedBy, DateTime DecidedAt, + int? CriteriaVersion); // The latest decision on a requirement, as shown on lists. -internal sealed record ReviewSummary(string Decision, string DecidedBy, DateTime DecidedAt, bool Outdated); +internal sealed record ReviewSummary(string Decision, string DecidedBy, DateTime DecidedAt, bool Outdated, bool CriteriaChanged, int? CriteriaVersion); diff --git a/app/web/src/app/app-shell.css b/app/web/src/app/app-shell.css index d7a2137..d77178b 100644 --- a/app/web/src/app/app-shell.css +++ b/app/web/src/app/app-shell.css @@ -444,3 +444,36 @@ a.row:focus-visible { outline-offset: -2px; } .document-choice-title { min-width: 0; overflow-wrap: anywhere; } .document-choices .muted { flex: none; font-size: 12.5px; } .criteria-fieldset > p.muted a { color: var(--app-text); text-decoration: underline; text-underline-offset: 3px; } +/* Criteria versions, follow-ups, and milestones (ADR-051 to ADR-053) */ +.review-badge.is-needs-review > span:first-child { border-color: var(--app-warn); background: var(--app-warn); } +.review-badge.is-needs-review { color: var(--app-warn); } +.review-criteria { color: var(--app-text-2); text-decoration: underline; text-underline-offset: 3px; } +.app-main .notice-warning { border-color: #5a4720; background: #1f1a10; color: #f7deaa; } +.criteria-warning { max-width: none; padding: 16px; border: 1px solid var(--app-warn); border-radius: 10px; } +.criteria-warning:focus-visible { outline: 2px solid var(--app-accent); outline-offset: 2px; } +.criteria-warning h2 { font-size: 15px; } +.requirement-meta a { color: var(--app-text-2); text-decoration: underline; text-underline-offset: 3px; } +.milestone-label { flex: none; max-width: 160px; overflow: hidden; color: var(--app-text-2); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } +.row .milestone-label + .row-count { margin-left: 0; } +.row .row-count + .evidence-complete { margin-left: 0; } +.row .evidence-complete + .badge { margin-left: 0; } +.app-main .badge.evidence-complete { border-color: #24533c; color: var(--app-ok); } +.requirement-filter { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-bottom: 12px; font-size: 13px; } +.requirement-filter select { min-width: 0; max-width: 100%; height: 36px; padding: 0 12px; border: 1px solid var(--app-line-strong); border-radius: 8px; background: var(--app-inset); color: var(--app-text); font: inherit; } +.requirement-filter label { color: var(--app-text); font-weight: 500; } +.requirement-counts { margin: 0 0 12px; color: var(--app-text-2); font-size: 13px; } +.requirement-counts strong { color: var(--app-text); font-weight: 500; } +.follow-up-heading { margin: 16px 0 8px; color: var(--app-text-2); font-size: 13px; font-weight: 500; } +.follow-up-heading:first-child { margin-top: 0; } +.follow-up-row { display: flex; align-items: center; gap: 8px; } +.follow-up-row .row { flex: 1; min-width: 0; } +.follow-up-row form { flex: none; padding-right: 8px; } +.row-list + .follow-up-note, .row-list + .evidence-link, .row-list + .notice { margin-top: 12px; } +.evidence-link-row select { flex: 1; min-width: 0; } +.history-list { display: grid; gap: 0; margin: 0; padding: 0; border: 1px solid var(--app-line); border-radius: 10px; list-style: none; } +.history-list > li { display: grid; gap: 6px; padding: 10px 14px; } +.history-list > li + li { border-top: 1px solid var(--app-line); } +.history-list p { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; margin: 0; font-size: 13px; } +.history-list a { color: var(--app-text); text-decoration: underline; text-underline-offset: 3px; } +.history-decisions { margin: 0; padding-left: 18px; color: var(--app-text-2); font-size: 13px; } +.follow-up-history li { display: flex; flex-wrap: wrap; gap: 4px 8px; } diff --git a/app/web/src/app/projects/[projectId]/requirements/[requirementId]/criteria/[version]/page.tsx b/app/web/src/app/projects/[projectId]/requirements/[requirementId]/criteria/[version]/page.tsx new file mode 100644 index 0000000..97b1f24 --- /dev/null +++ b/app/web/src/app/projects/[projectId]/requirements/[requirementId]/criteria/[version]/page.tsx @@ -0,0 +1,76 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { ArrowLeft } from "lucide-react"; +import { listMembers, listReviews, requireCriteriaVersion, requireProject, requireRequirement } from "@/lib/project-data"; +import { formatDate } from "@/lib/projects"; +import { decisionText } from "@/lib/reviews"; + +type Props = PageProps<"/projects/[projectId]/requirements/[requirementId]/criteria/[version]">; + +export async function generateMetadata({ params }: Props): Promise { + const { version } = await params; + return { title: `Acceptance criteria version ${version}` }; +} + +/** + * Shows one saved version of a requirement's acceptance criteria, read-only, with who saved it, + * when, and the decisions made against it, so a past decision can be read against what it + * approved (ADR-051). + */ +export default async function Page({ params }: Props) { + const { projectId, requirementId, version: number } = await params; + const [project, requirement] = await Promise.all([requireProject(projectId), requireRequirement(projectId, requirementId)]); + const [version, reviews, members] = await Promise.all([ + requireCriteriaVersion(requirement.id, number), listReviews(requirement.id), listMembers(projectId), + ]); + const people = Object.fromEntries(members.map(member => [member.userId, member.name])); + const nameOf = (userId: string) => people[userId] ?? "a former member"; + const detail = `/projects/${project.id}/requirements/${requirement.id}`; + const decisions = reviews.filter(review => review.criteriaVersion === version.number); + const current = version.number === requirement.criteriaVersion; + + return ( +
+
+ ); +} diff --git a/app/web/src/app/projects/[projectId]/requirements/[requirementId]/edit/page.tsx b/app/web/src/app/projects/[projectId]/requirements/[requirementId]/edit/page.tsx index 860543e..b76a8c4 100644 --- a/app/web/src/app/projects/[projectId]/requirements/[requirementId]/edit/page.tsx +++ b/app/web/src/app/projects/[projectId]/requirements/[requirementId]/edit/page.tsx @@ -2,7 +2,7 @@ import type { Metadata } from "next"; import Link from "next/link"; import { updateRequirement } from "@/app/projects/requirement-actions"; import { RequirementForm } from "@/components/requirement-form"; -import { listDocuments, requireProject, requireRequirement } from "@/lib/project-data"; +import { listDocuments, listMembers, listReviews, requireProject, requireRequirement } from "@/lib/project-data"; import { documentChoices } from "@/lib/requirements"; export const metadata: Metadata = { title: "Edit requirement" }; @@ -10,13 +10,23 @@ export const metadata: Metadata = { title: "Edit requirement" }; /** * Renders the form that changes a requirement. The save carries the version loaded here, so a * save made by someone else in the meantime is not overwritten. Archived items explain why they cannot change. + * When the latest decision accepted the current criteria, the form warns before changing them (ADR-051). */ export default async function Page({ params }: PageProps<"/projects/[projectId]/requirements/[requirementId]/edit">) { const { projectId, requirementId } = await params; - const [project, requirement, documents] = await Promise.all([ + const [project, requirement, documents, reviews, members] = await Promise.all([ requireProject(projectId), requireRequirement(projectId, requirementId), listDocuments(projectId), + listReviews(requirementId), listMembers(projectId), ]); const detail = `/projects/${project.id}/requirements/${requirement.id}`; + const latest = reviews[0]; + const accepted = latest?.decision === "accepted" && latest.criteriaVersion !== null && latest.criteriaVersion === requirement.criteriaVersion + ? { + criteriaVersion: latest.criteriaVersion, + decidedBy: members.find(member => member.userId === latest.decidedBy)?.name ?? "a former member", + decidedAt: latest.decidedAt, + } + : null; return (
@@ -35,7 +45,7 @@ export default async function Page({ params }: PageProps<"/projects/[projectId]/ documentIds: requirement.documents.map(document => document.id), }} documents={documentChoices(documents, requirement.documents)} documentsHref={`/projects/${project.id}/documents`} - label="Edit requirement" submitLabel="Save changes" pendingLabel="Saving..." cancelHref={detail} />} + label="Edit requirement" submitLabel="Save changes" pendingLabel="Saving..." cancelHref={detail} accepted={accepted} />}
); } diff --git a/app/web/src/app/projects/[projectId]/requirements/[requirementId]/history/page.tsx b/app/web/src/app/projects/[projectId]/requirements/[requirementId]/history/page.tsx index 9cfacf2..d778eb8 100644 --- a/app/web/src/app/projects/[projectId]/requirements/[requirementId]/history/page.tsx +++ b/app/web/src/app/projects/[projectId]/requirements/[requirementId]/history/page.tsx @@ -1,6 +1,76 @@ -import { ScaffoldPage } from "@/components/scaffold-page"; +import type { Metadata } from "next"; +import Link from "next/link"; +import { ArrowLeft } from "lucide-react"; +import { FollowUpHistory } from "@/components/requirement-follow-ups"; +import { listCriteriaVersions, listFollowUps, listMembers, listReviews, requireProject, requireRequirement } from "@/lib/project-data"; +import { formatDate } from "@/lib/projects"; +import { decisionText } from "@/lib/reviews"; -/** Renders the "Requirement history" placeholder with scaffold navigation and no connected product data or actions. */ -export default function Page() { - return ; +export const metadata: Metadata = { title: "Requirement history" }; + +/** + * Shows how a requirement changed: every version of its acceptance criteria with who saved it, + * when, and the decisions made against it (ADR-051), and every follow-up link added or removed + * (ADR-052). Earlier versions open read-only. + */ +export default async function Page({ params }: PageProps<"/projects/[projectId]/requirements/[requirementId]/history">) { + const { projectId, requirementId } = await params; + const [project, requirement, versions, followUps, reviews, members] = await Promise.all([ + requireProject(projectId), requireRequirement(projectId, requirementId), listCriteriaVersions(requirementId), + listFollowUps(requirementId), listReviews(requirementId), listMembers(projectId), + ]); + const people = Object.fromEntries(members.map(member => [member.userId, member.name])); + const nameOf = (userId: string) => people[userId] ?? "a former member"; + const detail = `/projects/${project.id}/requirements/${requirement.id}`; + + return ( +
+
+ ); } diff --git a/app/web/src/app/projects/[projectId]/requirements/[requirementId]/page.tsx b/app/web/src/app/projects/[projectId]/requirements/[requirementId]/page.tsx index edbec70..35fac92 100644 --- a/app/web/src/app/projects/[projectId]/requirements/[requirementId]/page.tsx +++ b/app/web/src/app/projects/[projectId]/requirements/[requirementId]/page.tsx @@ -3,10 +3,14 @@ import Link from "next/link"; import { ArrowLeft, FileText, Pencil } from "lucide-react"; import { EvidencePanel } from "@/components/evidence-panel"; import { RequirementArchive, RequirementRestore } from "@/components/requirement-archive"; +import { RequirementFollowUps } from "@/components/requirement-follow-ups"; +import { RequirementMilestone } from "@/components/requirement-milestone"; import { ReviewPanel } from "@/components/review-panel"; import { documentTypeLabel } from "@/lib/documents"; import { getProjectRepository } from "@/lib/github-data"; -import { listEvidence, listMembers, listReviews, requireProject, requireRequirement, viewerId } from "@/lib/project-data"; +import { + listEvidence, listFollowUps, listMembers, listMilestones, listRequirements, listReviews, requireProject, requireRequirement, viewerId, +} from "@/lib/project-data"; import { formatDate } from "@/lib/projects"; type Props = PageProps<"/projects/[projectId]/requirements/[requirementId]">; @@ -20,21 +24,26 @@ export async function generateMetadata({ params }: Props): Promise { } /** - * Shows who created a requirement and when, its description, ordered acceptance criteria, linked - * documents, evidence, and review decisions, with edit and archive while it can change. + * Shows who created a requirement and when, its description, ordered acceptance criteria and their + * version, milestone, linked documents, follow-up links, evidence, and review decisions, with + * edit and archive while it can change. */ export default async function Page({ params }: Props) { const { projectId, requirementId } = await params; // One round of parallel reads. Each is authorized by the API, and requireRequirement still // shows the not-found page before anything renders when the requirement is in another project. - const [project, requirement, members, repository, evidence, reviews, viewer] = await Promise.all([ + const [project, requirement, members, repository, evidence, reviews, viewer, followUps, requirements, milestones] = await Promise.all([ requireProject(projectId), requireRequirement(projectId, requirementId), listMembers(projectId), getProjectRepository(projectId), listEvidence(requirementId), listReviews(requirementId), viewerId(), + listFollowUps(requirementId), listRequirements(projectId), listMilestones(projectId), ]); const people = Object.fromEntries(members.map(member => [member.userId, member.name])); const nameOf = (userId: string) => people[userId] ?? "a former member"; const editable = !project.archivedAt && !requirement.archivedAt; const base = `/projects/${project.id}/requirements`; + // Requirements this one could follow up: not itself, and not already linked either way. + const linked = new Set([requirement.id, ...[...followUps.followUpOf, ...followUps.followedUpBy].map(link => link.requirementId)]); + const candidates = requirements.filter(other => !linked.has(other.id)).map(({ id, title }) => ({ id, title })); return (
@@ -47,6 +56,9 @@ export default async function Page({ params }: Props) {

Version {requirement.version} · Created by {nameOf(requirement.createdBy)} on {" "}· Updated by {nameOf(requirement.updatedBy)} on + {requirement.criteriaVersion !== null && <> + {" "}· Criteria version {requirement.criteriaVersion} + }

{requirement.archivedAt && (

@@ -69,6 +81,11 @@ export default async function Page({ params }: Props) { )} +

+

Milestone

+ +

Reference documents

{requirement.documents.length === 0 @@ -88,6 +105,11 @@ export default async function Page({ params }: Props) { )}
+
+

Follow-ups

+ +

Evidence

{repository || evidence.length > 0 ? ( @@ -109,7 +131,8 @@ export default async function Page({ params }: Props) {

Review

- link.id)} people={people} canReview={!editable ? "read-only" : viewer === requirement.createdBy ? "author" : "yes"} soloTeam={members.length === 1 ? { name: project.teamName, invitations: `/teams/${project.teamId}/invitations` } : null} /> diff --git a/app/web/src/app/projects/[projectId]/requirements/new/page.tsx b/app/web/src/app/projects/[projectId]/requirements/new/page.tsx index df9b82b..c79c1a5 100644 --- a/app/web/src/app/projects/[projectId]/requirements/new/page.tsx +++ b/app/web/src/app/projects/[projectId]/requirements/new/page.tsx @@ -1,7 +1,7 @@ import type { Metadata } from "next"; import { createRequirement } from "@/app/projects/requirement-actions"; import { RequirementForm } from "@/components/requirement-form"; -import { listDocuments, requireProject } from "@/lib/project-data"; +import { listDocuments, listMilestones, requireProject } from "@/lib/project-data"; import { documentChoices } from "@/lib/requirements"; export const metadata: Metadata = { title: "New requirement" }; @@ -9,7 +9,7 @@ export const metadata: Metadata = { title: "New requirement" }; /** Renders the form that adds a requirement to a project. Archived projects explain why they cannot take one. */ export default async function Page({ params }: PageProps<"/projects/[projectId]/requirements/new">) { const { projectId } = await params; - const [project, documents] = await Promise.all([requireProject(projectId), listDocuments(projectId)]); + const [project, documents, milestones] = await Promise.all([requireProject(projectId), listDocuments(projectId), listMilestones(projectId)]); const list = `/projects/${project.id}/requirements`; return ( @@ -19,7 +19,8 @@ export default async function Page({ params }: PageProps<"/projects/[projectId]/ {project.archivedAt ?

This project is archived, so requirements can't be added.

- : }
diff --git a/app/web/src/app/projects/[projectId]/requirements/page.tsx b/app/web/src/app/projects/[projectId]/requirements/page.tsx index 0b72ef6..e38e5a0 100644 --- a/app/web/src/app/projects/[projectId]/requirements/page.tsx +++ b/app/web/src/app/projects/[projectId]/requirements/page.tsx @@ -2,19 +2,30 @@ import type { Metadata } from "next"; import Link from "next/link"; import { Archive, ArrowLeft, Plus } from "lucide-react"; import { RequirementRows } from "@/components/requirement-rows"; -import { listRequirements, requireProject } from "@/lib/project-data"; +import { listMilestones, listRequirements, requireProject } from "@/lib/project-data"; +import { requirementCounts } from "@/lib/projects"; export const metadata: Metadata = { title: "Requirements" }; /** * Lists a project's active requirements, oldest first, as the API returns them. With * `?archived=true` it lists the archived ones instead, each still opening read-only (ADR-050). + * With `?milestone=` it lists only that milestone or sprint, and the counts above the list say + * how many of them are Evidence complete and how many are Accepted (ADR-053). */ export default async function Page({ params, searchParams }: PageProps<"/projects/[projectId]/requirements">) { const { projectId } = await params; - const archived = (await searchParams).archived === "true"; - const [project, requirements] = await Promise.all([requireProject(projectId), listRequirements(projectId, archived)]); + const query = await searchParams; + const archived = query.archived === "true"; + const milestone = typeof query.milestone === "string" ? query.milestone.trim() : ""; + const [project, requirements, milestones] = await Promise.all([ + requireProject(projectId), listRequirements(projectId, archived, milestone), listMilestones(projectId), + ]); const base = `/projects/${project.id}/requirements`; + const counts = requirementCounts(requirements); + // A label used only on archived requirements is not offered, but stays selected when it is in the address. + const options = milestone && !milestones.includes(milestone) ? [...milestones, milestone] : milestones; + const archivedHref = `${base}?${new URLSearchParams({ archived: "true", ...(milestone ? { milestone } : {}) })}`; return (
@@ -23,14 +34,34 @@ export default async function Page({ params, searchParams }: PageProps<"/project

{archived ? "Archived requirements" : "Requirements"}

{requirements.length} {!archived && ( -
); diff --git a/app/web/src/app/projects/follow-up-actions.ts b/app/web/src/app/projects/follow-up-actions.ts new file mode 100644 index 0000000..9647c79 --- /dev/null +++ b/app/web/src/app/projects/follow-up-actions.ts @@ -0,0 +1,55 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { apiFetch } from "@/lib/api"; +import { problemDetail } from "@/lib/evidence"; +import { fieldError, isUuid } from "@/lib/projects"; + +export type FollowUpState = { error: string | null; changed: number }; + +/** Marks a requirement as a follow-up of another requirement in the same project (ADR-052). */ +export async function addFollowUp( + projectId: string, requirementId: string, previous: FollowUpState, formData: FormData, +): Promise { + const failed = "The follow-up link could not be added. Please try again."; + const originalRequirementId = String(formData.get("originalRequirementId") ?? ""); + const fail = (error: string): FollowUpState => ({ error, changed: previous.changed }); + if (!originalRequirementId) return fail("Choose the requirement this one follows up."); + if (!isUuid(projectId) || !isUuid(requirementId) || !isUuid(originalRequirementId)) return fail(failed); + try { + const response = await apiFetch(`/requirements/${requirementId}/follow-ups`, { + method: "POST", body: JSON.stringify({ originalRequirementId }), + }); + if (response.status === 400) return fail(fieldError(await response.json(), "originalRequirementId") ?? failed); + // 409 explains itself: already linked, or something is archived. + if (response.status === 409) return fail(problemDetail(await response.json()) ?? failed); + if (!response.ok) throw new Error(`Add follow-up returned ${response.status}.`); + } catch (error) { + console.error("Add follow-up failed", error); + return fail(failed); + } + // Both requirements show the link, so every page of the project may change. + revalidatePath(`/projects/${projectId}`, "layout"); + return { error: null, changed: previous.changed + 1 }; +} + +/** Removes a follow-up link. The change is kept in both requirements' history. */ +export async function removeFollowUp( + projectId: string, requirementId: string, previous: FollowUpState, formData: FormData, +): Promise { + const failed = "The follow-up link could not be removed. Please try again."; + const originalRequirementId = String(formData.get("originalRequirementId") ?? ""); + const fail = (error: string): FollowUpState => ({ error, changed: previous.changed }); + if (!isUuid(projectId) || !isUuid(requirementId) || !isUuid(originalRequirementId)) return fail(failed); + try { + const response = await apiFetch(`/requirements/${requirementId}/follow-ups/${originalRequirementId}`, { method: "DELETE" }); + if (response.status === 409) return fail(problemDetail(await response.json()) ?? failed); + // Already removed, for example by someone else: show the list as it is now. + if (!response.ok && response.status !== 404) throw new Error(`Remove follow-up returned ${response.status}.`); + } catch (error) { + console.error("Remove follow-up failed", error); + return fail(failed); + } + revalidatePath(`/projects/${projectId}`, "layout"); + return { error: null, changed: previous.changed + 1 }; +} diff --git a/app/web/src/app/projects/requirement-actions.ts b/app/web/src/app/projects/requirement-actions.ts index bea408f..1b0c4b4 100644 --- a/app/web/src/app/projects/requirement-actions.ts +++ b/app/web/src/app/projects/requirement-actions.ts @@ -3,13 +3,19 @@ import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { apiFetch } from "@/lib/api"; -import { isUuid } from "@/lib/projects"; +import { problemDetail } from "@/lib/evidence"; +import { fieldError, isUuid } from "@/lib/projects"; import { - parseRequirement, readRequirementInput, requirementErrors, requirementProblemErrors, type RequirementErrors, + milestoneError, parseRequirement, readMilestoneInput, readRequirementInput, requirementErrors, requirementProblemErrors, type RequirementErrors, } from "@/lib/requirements"; -export type RequirementFormState = { errors: RequirementErrors | null }; +/** + * `needsConfirmation` is set when the API refused new criteria because the latest decision + * accepted the current ones (ADR-051), so the form asks the user to confirm before saving again. + */ +export type RequirementFormState = { errors: RequirementErrors | null; needsConfirmation?: boolean }; export type RequirementArchiveState = { error: string | null }; +export type MilestoneState = { error: string | null; saved: number }; const failure = (form: string): RequirementFormState => ({ errors: { items: {}, form } }); const PROJECT_ARCHIVED = "This project is archived, so its requirements can't be changed."; @@ -28,6 +34,7 @@ export async function createRequirement(projectId: string, _previous: Requiremen method: "POST", body: JSON.stringify({ title: input.title, description: input.description, acceptanceCriteria: input.criteria, documentIds: input.documentIds, + milestone: input.milestone || null, }), }); if (response.status === 400) return { errors: requirementProblemErrors(await response.json(), failed) }; @@ -45,6 +52,8 @@ export async function createRequirement(projectId: string, _previous: Requiremen /** * Saves a requirement, replacing its criteria, then opens it. `version` is the version the form * was loaded with; if someone else saved first the API refuses and the user is told to reload. + * New criteria on a requirement whose current criteria were accepted are sent only with the + * form's `confirmCriteriaChange` field, which the user sets by confirming the warning (ADR-051). */ export async function updateRequirement( projectId: string, requirementId: string, version: number, _previous: RequirementFormState, formData: FormData, @@ -54,16 +63,22 @@ export async function updateRequirement( const invalid = requirementErrors(input); if (invalid) return { errors: invalid }; if (!isUuid(projectId) || !isUuid(requirementId) || !Number.isInteger(version)) return failure(failed); + const confirmCriteriaChange = formData.get("confirmCriteriaChange") === "true"; try { const response = await apiFetch(`/requirements/${requirementId}`, { method: "PUT", body: JSON.stringify({ title: input.title, description: input.description, acceptanceCriteria: input.criteria, documentIds: input.documentIds, version, + confirmCriteriaChange, }), }); if (response.status === 400) return { errors: requirementProblemErrors(await response.json(), failed) }; - if (response.status === 409) return failure(await conflictMessage(requirementId, version)); + if (response.status === 409) { + const problem: unknown = await response.json().catch(() => null); + if ((problem as { code?: unknown } | null)?.code === "accepted_criteria_change") return { errors: null, needsConfirmation: true }; + return failure(await conflictMessage(requirementId, version)); + } if (!response.ok) throw new Error(`Update requirement returned ${response.status}.`); parseRequirement(await response.json()); } catch (error) { @@ -106,6 +121,35 @@ export async function restoreRequirement(projectId: string, requirementId: strin redirect(`/projects/${projectId}/requirements/${requirementId}`); } +/** + * Sets or clears a requirement's milestone or sprint label (ADR-053). It is not an edit: the + * version stays, so a decision on the requirement does not become outdated. + */ +export async function setMilestone( + projectId: string, requirementId: string, previous: MilestoneState, formData: FormData, +): Promise { + const failed = "The milestone could not be saved. Please try again."; + const fail = (error: string): MilestoneState => ({ error, saved: previous.saved }); + const milestone = readMilestoneInput(formData); + if (milestone === null) return fail(failed); + const invalid = milestoneError(milestone); + if (invalid) return fail(invalid); + if (!isUuid(projectId) || !isUuid(requirementId)) return fail(failed); + try { + const response = await apiFetch(`/requirements/${requirementId}/milestone`, { + method: "PUT", body: JSON.stringify({ milestone: milestone || null }), + }); + if (response.status === 400) return fail(fieldError(await response.json(), "milestone") ?? failed); + if (response.status === 409) return fail(problemDetail(await response.json()) ?? failed); + if (!response.ok) throw new Error(`Set milestone returned ${response.status}.`); + } catch (error) { + console.error("Set milestone failed", error); + return fail(failed); + } + revalidatePath(`/projects/${projectId}`, "layout"); + return { error: null, saved: previous.saved + 1 }; +} + /** Explains a refused save by looking at the requirement as it is now: archived, or changed by someone else. */ async function conflictMessage(requirementId: string, version: number): Promise { const response = await apiFetch(`/requirements/${requirementId}`); diff --git a/app/web/src/components/requirement-follow-ups.tsx b/app/web/src/components/requirement-follow-ups.tsx new file mode 100644 index 0000000..1e21fab --- /dev/null +++ b/app/web/src/components/requirement-follow-ups.tsx @@ -0,0 +1,124 @@ +"use client"; + +import Link from "next/link"; +import { useActionState } from "react"; +import { GitBranch } from "lucide-react"; +import { addFollowUp, removeFollowUp } from "@/app/projects/follow-up-actions"; +import { FOLLOW_UPS_MAX, followUpEventText, type FollowUps } from "@/lib/follow-ups"; +import { formatDate } from "@/lib/projects"; + +/** + * Renders a requirement's follow-up links (ADR-052): the requirements it follows up, which can be + * removed while it is editable, and the requirements that follow it up, each linking to the other + * requirement. `candidates` are the project's other active requirements that are not linked yet. + * Every change is listed with who made it and when. + */ +export function RequirementFollowUps({ projectId, requirementId, followUps, candidates, people, editable }: { + projectId: string; + requirementId: string; + followUps: FollowUps; + candidates: { id: string; title: string }[]; + /** Names of the project's members by user ID. */ + people: Record; + editable: boolean; +}) { + const [added, add, adding] = useActionState(addFollowUp.bind(null, projectId, requirementId), { error: null, changed: 0 }); + const [removed, remove, removing] = useActionState(removeFollowUp.bind(null, projectId, requirementId), { error: null, changed: 0 }); + const href = (id: string) => `/projects/${projectId}/requirements/${id}`; + const nameOf = (userId: string) => people[userId] ?? "a former member"; + const { followUpOf, followedUpBy, history } = followUps; + + return ( + <> +

Follow-up of

+ {followUpOf.length === 0 + ?

This requirement doesn't follow up another requirement.

+ : ( +
    + {followUpOf.map(link => ( +
  • + +
  • + ))} +
+ )} + {removed.error &&

{removed.error}

} + {editable && ( + followUpOf.length >= FOLLOW_UPS_MAX + ?

A requirement can follow up at most {FOLLOW_UPS_MAX} requirements.

+ : candidates.length === 0 + ?

There are no other active requirements in this project to link.

+ : ( +
+
+ +
+ + +
+ {added.error && } +
+
+ ) + )} +

Followed up by

+ {followedUpBy.length === 0 + ?

No requirement follows up this one.

+ : ( +
    + {followedUpBy.map(link => ( +
  • + +
  • + ))} +
+ )} + {history.length > 0 && ( +
+ {history.length === 1 ? "1 link change" : `${history.length} link changes`} + +
+ )} + + ); +} + +/** Lists follow-up link changes, newest first, with who made each and when. */ +export function FollowUpHistory({ requirementId, history, people }: { + requirementId: string; history: FollowUps["history"]; people: Record; +}) { + return ( +
    + {history.map(event => ( +
  1. + {followUpEventText(event, requirementId)}{" "} + + by {people[event.actor] ?? "a former member"} on + +
  2. + ))} +
+ ); +} diff --git a/app/web/src/components/requirement-form.tsx b/app/web/src/components/requirement-form.tsx index d46b5fe..e0c5b35 100644 --- a/app/web/src/components/requirement-form.tsx +++ b/app/web/src/components/requirement-form.tsx @@ -1,24 +1,35 @@ "use client"; -import { useActionState, useRef, useState } from "react"; +import { useActionState, useEffect, useRef, useState } from "react"; import Link from "next/link"; import { ArrowDown, ArrowUp, Plus, X } from "lucide-react"; import type { RequirementFormState } from "@/app/projects/requirement-actions"; import { documentTypeLabel } from "@/lib/documents"; +import { formatDate } from "@/lib/projects"; import { - CRITERIA_MAX, CRITERION_MAX, moveItem, REQUIREMENT_DESCRIPTION_MAX, REQUIREMENT_TITLE_MAX, type DocumentChoice, type RequirementInput, + CRITERIA_MAX, CRITERION_MAX, criteriaChanged, MILESTONE_MAX, moveItem, REQUIREMENT_DESCRIPTION_MAX, REQUIREMENT_TITLE_MAX, + type DocumentChoice, type RequirementInput, } from "@/lib/requirements"; type FormAction = (state: RequirementFormState, formData: FormData) => Promise; +/** The decision that accepted a requirement's current criteria, with the reviewer's name. */ +export type AcceptedCriteria = { criteriaVersion: number; decidedBy: string; decidedAt: string }; + /** * Renders the form for a requirement's title, description, ordered acceptance criteria, and * linked project documents. Criteria can be moved up and down; the order on screen is the order * saved. A requirement needs at least one criterion, so a form without any starts with an empty * one. Fields keep what the user typed when the server rejects the form, and each message * appears next to the field it is about, also after its criterion has been moved. + * + * When `accepted` is set, the latest decision accepted the current criteria, so changing them + * asks for confirmation before anything is sent: that acceptance applies to the previous + * criteria, and the requirement will need a new review (ADR-051). The API asks the same when + * someone accepted it after this form was loaded. `milestones` shows the optional milestone + * field, suggesting the labels already in use; only the create form has it (ADR-053). */ -export function RequirementForm({ action, initial, documents, documentsHref, submitLabel, pendingLabel, cancelHref, label }: { +export function RequirementForm({ action, initial, documents, documentsHref, submitLabel, pendingLabel, cancelHref, label, accepted = null, milestones }: { action: FormAction; initial: RequirementInput; documents: DocumentChoice[]; @@ -27,10 +38,24 @@ export function RequirementForm({ action, initial, documents, documentsHref, sub pendingLabel: string; cancelHref: string; label: string; + accepted?: AcceptedCriteria | null; + milestones?: string[]; }) { const [state, formAction, pending] = useActionState(action, { errors: null }); const [title, setTitle] = useState(initial.title); const [description, setDescription] = useState(initial.description); + const [milestone, setMilestone] = useState(initial.milestone ?? ""); + const [warning, setWarning] = useState(false); + const [answered, setAnswered] = useState(state); + const warningRef = useRef(null); + // A refusal because the criteria were accepted meanwhile opens the warning too. + if (state !== answered) { + setAnswered(state); + if (state.needsConfirmation) setWarning(true); + } + useEffect(() => { + if (warning) warningRef.current?.focus(); + }, [warning]); const [rows, setRows] = useState(() => (initial.criteria.length ? initial.criteria : [""]).map((text, key) => ({ key, text }))); const nextKey = useRef(Math.max(initial.criteria.length, 1)); // The form resets after a server-side rejection. Text fields keep their value because React @@ -40,14 +65,30 @@ export function RequirementForm({ action, initial, documents, documentsHref, sub const [sent, setSent] = useState([]); const [moved, setMoved] = useState(""); const errors = state.errors; - const move = (index: number, to: number) => { + // Compared as the API compares them, ignoring rows left empty, which are refused anyway. + const changed = criteriaChanged(initial.criteria, rows.map(row => row.text).filter(text => text.trim())); + // Criteria put back as they were accepted leave nothing to warn about, and no new version to announce. + if (warning && !changed) setWarning(false); + const move =(index: number, to: number) => { if (to < 0 || to >= rows.length) return; setRows(current => moveItem(current, index, to)); setMoved(`Criterion moved to position ${to + 1} of ${rows.length}.`); }; return ( -
setSent(rows.map(row => row.key))}> + { + const confirmed = (event.nativeEvent as SubmitEvent).submitter?.getAttribute("name") === "confirmCriteriaChange"; + if (!confirmed && accepted && changed) { + event.preventDefault(); + setWarning(true); + return; + } + setSent(rows.map(row => row.key)); + }} onKeyDown={event => { + // While the warning is open its confirm button is the form's only submit button, so Enter + // in a field would confirm. Only an explicit press of that button confirms. + if (warning && event.key === "Enter" && event.target instanceof HTMLInputElement) event.preventDefault(); + }}> {errors?.form &&

{errors.form}

}
@@ -106,6 +147,18 @@ export function RequirementForm({ action, initial, documents, documentsHref, sub