Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions app/api/Data/ProductModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down Expand Up @@ -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
Expand All @@ -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; }
Expand Down
58 changes: 58 additions & 0 deletions app/api/Data/SpecThreadDbContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ public sealed class SpecThreadDbContext(DbContextOptions<SpecThreadDbContext> op
public DbSet<ProjectMember> ProjectMembers => Set<ProjectMember>();
public DbSet<Requirement> Requirements => Set<Requirement>();
public DbSet<AcceptanceCriterion> AcceptanceCriteria => Set<AcceptanceCriterion>();
public DbSet<RequirementCriteriaVersion> RequirementCriteriaVersions => Set<RequirementCriteriaVersion>();
public DbSet<ProjectRepository> ProjectRepositories => Set<ProjectRepository>();
public DbSet<RequirementEvidence> RequirementEvidence => Set<RequirementEvidence>();
public DbSet<RequirementReview> RequirementReviews => Set<RequirementReview>();
Expand All @@ -19,6 +20,8 @@ public sealed class SpecThreadDbContext(DbContextOptions<SpecThreadDbContext> op
public DbSet<Document> Documents => Set<Document>();
public DbSet<DocumentContent> DocumentContents => Set<DocumentContent>();
public DbSet<RequirementDocument> RequirementDocuments => Set<RequirementDocument>();
public DbSet<RequirementFollowUp> RequirementFollowUps => Set<RequirementFollowUp>();
public DbSet<RequirementFollowUpEvent> RequirementFollowUpEvents => Set<RequirementFollowUpEvent>();

protected override void OnModelCreating(ModelBuilder model)
{
Expand Down Expand Up @@ -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()");
Expand All @@ -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<Project>().WithMany().HasForeignKey(x => x.ProjectId).OnDelete(DeleteBehavior.Restrict);
requirement.HasOne<AuthUser>().WithMany().HasForeignKey(x => x.CreatedBy).OnDelete(DeleteBehavior.Restrict);
Expand All @@ -165,6 +170,25 @@ protected override void OnModelCreating(ModelBuilder model)
criterion.HasIndex(x => new { x.RequirementId, x.Position }).IsUnique();
criterion.HasOne<Requirement>().WithMany().HasForeignKey(x => x.RequirementId).OnDelete(DeleteBehavior.Restrict);

var criteriaVersion = model.Entity<RequirementCriteriaVersion>();
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<Requirement>().WithMany().HasForeignKey(x => x.RequirementId).OnDelete(DeleteBehavior.Restrict);
criteriaVersion.HasOne<AuthUser>().WithMany().HasForeignKey(x => x.CreatedBy).OnDelete(DeleteBehavior.Restrict);

var repository = model.Entity<ProjectRepository>();
repository.ToTable("project_repositories", table =>
{
Expand Down Expand Up @@ -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'");
Expand All @@ -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()");
Expand Down Expand Up @@ -300,5 +326,37 @@ protected override void OnModelCreating(ModelBuilder model)
citation.HasOne<Requirement>().WithMany().HasForeignKey(x => x.RequirementId).OnDelete(DeleteBehavior.Restrict);
citation.HasOne<Document>().WithMany().HasForeignKey(x => x.DocumentId).OnDelete(DeleteBehavior.Restrict);
citation.HasOne<AuthUser>().WithMany().HasForeignKey(x => x.LinkedBy).OnDelete(DeleteBehavior.Restrict);

var followUp = model.Entity<RequirementFollowUp>();
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<Requirement>().WithMany().HasForeignKey(x => x.RequirementId).OnDelete(DeleteBehavior.Restrict);
followUp.HasOne<Requirement>().WithMany().HasForeignKey(x => x.OriginalRequirementId).OnDelete(DeleteBehavior.Restrict);
followUp.HasOne<AuthUser>().WithMany().HasForeignKey(x => x.LinkedBy).OnDelete(DeleteBehavior.Restrict);

var followUpEvent = model.Entity<RequirementFollowUpEvent>();
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<Requirement>().WithMany().HasForeignKey(x => x.RequirementId).OnDelete(DeleteBehavior.Restrict);
followUpEvent.HasOne<Requirement>().WithMany().HasForeignKey(x => x.OriginalRequirementId).OnDelete(DeleteBehavior.Restrict);
followUpEvent.HasOne<AuthUser>().WithMany().HasForeignKey(x => x.Actor).OnDelete(DeleteBehavior.Restrict);
}
}
Loading
Loading