diff --git a/app/api/Data/ProductModels.cs b/app/api/Data/ProductModels.cs index 41dcf28..f4087af 100644 --- a/app/api/Data/ProductModels.cs +++ b/app/api/Data/ProductModels.cs @@ -27,6 +27,9 @@ public sealed class Requirement public required string CreatedBy { get; set; } public DateTime CreatedAt { get; set; } public DateTime UpdatedAt { get; set; } + // Who saved the current version. Null only for a requirement created by an API older than + // migration RequirementUpdatedBy after that migration ran; read it as the creator (ADR-050). + public string? UpdatedBy { get; set; } public DateTime? ArchivedAt { get; set; } public int Version { get; set; } = 1; } @@ -129,3 +132,12 @@ public sealed class DocumentContent public Guid DocumentId { get; set; } public required byte[] Content { get; set; } } + +// A project document a requirement cites, saved with the requirement like its criteria (ADR-049). +public sealed class RequirementDocument +{ + public Guid RequirementId { get; set; } + public Guid DocumentId { get; set; } + public required string LinkedBy { get; set; } + public DateTime LinkedAt { get; set; } +} diff --git a/app/api/Data/SpecThreadDbContext.cs b/app/api/Data/SpecThreadDbContext.cs index 3b7eb7f..ba0b880 100644 --- a/app/api/Data/SpecThreadDbContext.cs +++ b/app/api/Data/SpecThreadDbContext.cs @@ -18,6 +18,7 @@ public sealed class SpecThreadDbContext(DbContextOptions op public DbSet TeamInvitations => Set(); public DbSet Documents => Set(); public DbSet DocumentContents => Set(); + public DbSet RequirementDocuments => Set(); protected override void OnModelCreating(ModelBuilder model) { @@ -143,10 +144,12 @@ protected override void OnModelCreating(ModelBuilder model) requirement.Property(x => x.CreatedBy).HasColumnName("created_by"); requirement.Property(x => x.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("now()"); 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.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); + requirement.HasOne().WithMany().HasForeignKey(x => x.UpdatedBy).OnDelete(DeleteBehavior.Restrict); var criterion = model.Entity(); criterion.ToTable("acceptance_criteria", table => @@ -285,5 +288,17 @@ protected override void OnModelCreating(ModelBuilder model) content.Property(x => x.DocumentId).HasColumnName("document_id"); content.Property(x => x.Content).HasColumnName("content"); content.HasOne().WithOne().HasForeignKey(x => x.DocumentId).OnDelete(DeleteBehavior.Restrict); + + var citation = model.Entity(); + citation.ToTable("requirement_documents"); + citation.HasKey(x => new { x.RequirementId, x.DocumentId }); + citation.Property(x => x.RequirementId).HasColumnName("requirement_id"); + citation.Property(x => x.DocumentId).HasColumnName("document_id"); + citation.Property(x => x.LinkedBy).HasColumnName("linked_by"); + citation.Property(x => x.LinkedAt).HasColumnName("linked_at").HasDefaultValueSql("now()"); + citation.HasIndex(x => x.DocumentId); + 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); } } diff --git a/app/api/Migrations/20261024003629_RequirementDocuments.Designer.cs b/app/api/Migrations/20261024003629_RequirementDocuments.Designer.cs new file mode 100644 index 0000000..9f885cb --- /dev/null +++ b/app/api/Migrations/20261024003629_RequirementDocuments.Designer.cs @@ -0,0 +1,1246 @@ +// +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("20261024003629_RequirementDocuments")] + partial class RequirementDocuments + { + /// + 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("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1) + .HasColumnName("version"); + + b.HasKey("Id"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("ProjectId"); + + 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.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("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_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(); + }); + + 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/20261024003629_RequirementDocuments.cs b/app/api/Migrations/20261024003629_RequirementDocuments.cs new file mode 100644 index 0000000..4bbae2a --- /dev/null +++ b/app/api/Migrations/20261024003629_RequirementDocuments.cs @@ -0,0 +1,85 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SpecThread.Api.Migrations +{ + /// + public partial class RequirementDocuments : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "requirement_documents", + schema: "public", + columns: table => new + { + requirement_id = table.Column(type: "uuid", nullable: false), + document_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_documents", x => new { x.requirement_id, x.document_id }); + table.ForeignKey( + name: "FK_requirement_documents_documents_document_id", + column: x => x.document_id, + principalSchema: "public", + principalTable: "documents", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_requirement_documents_requirements_requirement_id", + column: x => x.requirement_id, + principalSchema: "public", + principalTable: "requirements", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_requirement_documents_user_linked_by", + column: x => x.linked_by, + principalSchema: "public", + principalTable: "user", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_requirement_documents_document_id", + schema: "public", + table: "requirement_documents", + column: "document_id"); + + migrationBuilder.CreateIndex( + name: "IX_requirement_documents_linked_by", + schema: "public", + table: "requirement_documents", + column: "linked_by"); + + migrationBuilder.Sql(""" + ALTER TABLE public."requirement_documents" ENABLE ROW LEVEL SECURITY; + REVOKE ALL ON TABLE public."requirement_documents" FROM PUBLIC; + DO $security$ + BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN + REVOKE ALL ON TABLE public."requirement_documents" FROM anon; + END IF; + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN + REVOKE ALL ON TABLE public."requirement_documents" FROM authenticated; + END IF; + END $security$; + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "requirement_documents", + schema: "public"); + } + } +} diff --git a/app/api/Migrations/20261024010934_RequirementUpdatedBy.Designer.cs b/app/api/Migrations/20261024010934_RequirementUpdatedBy.Designer.cs new file mode 100644 index 0000000..795677d --- /dev/null +++ b/app/api/Migrations/20261024010934_RequirementUpdatedBy.Designer.cs @@ -0,0 +1,1257 @@ +// +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("20261024010934_RequirementUpdatedBy")] + partial class RequirementUpdatedBy + { + /// + 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.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("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_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.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/20261024010934_RequirementUpdatedBy.cs b/app/api/Migrations/20261024010934_RequirementUpdatedBy.cs new file mode 100644 index 0000000..f21be3b --- /dev/null +++ b/app/api/Migrations/20261024010934_RequirementUpdatedBy.cs @@ -0,0 +1,60 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SpecThread.Api.Migrations +{ + /// + public partial class RequirementUpdatedBy : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "updated_by", + schema: "public", + table: "requirements", + type: "text", + nullable: true); + + migrationBuilder.Sql(""" + UPDATE public.requirements SET updated_by = created_by WHERE updated_by IS NULL; + """); + + migrationBuilder.CreateIndex( + name: "IX_requirements_updated_by", + schema: "public", + table: "requirements", + column: "updated_by"); + + migrationBuilder.AddForeignKey( + name: "FK_requirements_user_updated_by", + schema: "public", + table: "requirements", + column: "updated_by", + principalSchema: "public", + principalTable: "user", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_requirements_user_updated_by", + schema: "public", + table: "requirements"); + + migrationBuilder.DropIndex( + name: "IX_requirements_updated_by", + schema: "public", + table: "requirements"); + + migrationBuilder.DropColumn( + name: "updated_by", + schema: "public", + table: "requirements"); + } + } +} diff --git a/app/api/Migrations/SpecThreadDbContextModelSnapshot.cs b/app/api/Migrations/SpecThreadDbContextModelSnapshot.cs index 13bf381..42342f6 100644 --- a/app/api/Migrations/SpecThreadDbContextModelSnapshot.cs +++ b/app/api/Migrations/SpecThreadDbContextModelSnapshot.cs @@ -554,6 +554,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnName("updated_at") .HasDefaultValueSql("now()"); + b.Property("UpdatedBy") + .HasColumnType("text") + .HasColumnName("updated_by"); + b.Property("Version") .IsConcurrencyToken() .ValueGeneratedOnAdd() @@ -567,6 +571,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("ProjectId"); + b.HasIndex("UpdatedBy"); + b.ToTable("requirements", "public", t => { t.HasCheckConstraint("ck_requirements_title", "length(btrim(title)) > 0"); @@ -575,6 +581,36 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); + 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") @@ -1102,6 +1138,32 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasForeignKey("ProjectId") .OnDelete(DeleteBehavior.Restrict) .IsRequired(); + + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("UpdatedBy") + .OnDelete(DeleteBehavior.Restrict); + }); + + 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 => diff --git a/app/api/Projects/ProjectAccess.cs b/app/api/Projects/ProjectAccess.cs index df18dec..8ecc22a 100644 --- a/app/api/Projects/ProjectAccess.cs +++ b/app/api/Projects/ProjectAccess.cs @@ -29,6 +29,7 @@ internal sealed class InputErrors public const int MaxDescriptionLength = 10_000; public const int MaxCriteria = 50; public const int MaxCriterionLength = 2_000; + public const int MaxRequirementDocuments = 20; // ADR-049 public const int MaxEmailLength = 254; private readonly Dictionary errors = []; @@ -55,6 +56,12 @@ internal sealed class InputErrors public List? Criteria(string field, IReadOnlyList? values) { values ??= []; + // A requirement says what "done" means before work starts, so it needs at least one. + if (values.Count == 0) + { + Add(field, "Add at least one acceptance criterion."); + return null; + } if (values.Count > MaxCriteria) { Add(field, $"Use at most {MaxCriteria} acceptance criteria."); diff --git a/app/api/Requirements/RequirementEndpoints.cs b/app/api/Requirements/RequirementEndpoints.cs index 22236c0..9c88eae 100644 --- a/app/api/Requirements/RequirementEndpoints.cs +++ b/app/api/Requirements/RequirementEndpoints.cs @@ -24,15 +24,19 @@ public static void MapRequirementEndpoints(this IEndpointRouteBuilder app) .ProducesProblem(StatusCodes.Status409Conflict); requirements.MapPost("/{requirementId:guid}/archive", Archive).WithName("ArchiveRequirement") .ProducesProblem(StatusCodes.Status409Conflict); + requirements.MapPost("/{requirementId:guid}/restore", Restore).WithName("RestoreRequirement") + .ProducesProblem(StatusCodes.Status409Conflict); } + // Active requirements by default; `archived=true` lists only the archived ones (ADR-050). private static async Task>, NotFound>> List( - Guid projectId, ClaimsPrincipal user, SpecThreadDbContext db, CancellationToken cancel) + Guid projectId, bool? archived, 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) + .Where(r => r.ProjectId == projectId && (r.ArchivedAt != null) == wantArchived) .OrderBy(r => r.CreatedAt).ThenBy(r => r.Id) .ToListAsync(cancel); @@ -55,7 +59,7 @@ private static async Task>, NotFound>> List( 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, out var errors); + var input = Validate(request.Title, request.Description, request.AcceptanceCriteria, request.DocumentIds, out var errors); if (input is null) return TypedResults.ValidationProblem(errors.ToDictionary()); var userId = user.CallerId(); @@ -64,6 +68,8 @@ private static async Task, ValidationProble var project = await ProjectEndpoints.LockProjectForCaller(db, projectId, userId, cancel); if (project is null) return TypedResults.NotFound(); if (project.ArchivedAt is not null) return ProjectEndpoints.Archived(); + var refused = await CheckDocuments(db, projectId, input.DocumentIds, new HashSet(), cancel); + if (refused is not null) return refused; var now = Clock.UtcNow(); var requirement = new Requirement @@ -75,14 +81,17 @@ private static async Task, ValidationProble CreatedBy = userId, CreatedAt = now, UpdatedAt = now, + UpdatedBy = userId, Version = 1, }; db.Requirements.Add(requirement); var criteria = AddCriteria(db, requirement.Id, input.Criteria); + 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)); + return TypedResults.Created($"/requirements/{requirement.Id}", ToResponse(requirement, criteria, documents)); } private static async Task, NotFound>> Get( @@ -95,31 +104,42 @@ private static async Task, NotFound>> Get( .Where(c => c.RequirementId == requirementId) .OrderBy(c => c.Position) .ToListAsync(cancel); - return TypedResults.Ok(ToResponse(requirement.Requirement, criteria)); + return TypedResults.Ok(ToResponse(requirement.Requirement, criteria, await LoadDocuments(db, requirementId, cancel))); } - // Replaces the whole criteria list; a stale version means someone else saved first. + // Replaces the whole criteria and document lists; a stale version means someone else saved first. private static async Task, ValidationProblem, NotFound, ProblemHttpResult>> Update( Guid requirementId, UpdateRequirementRequest request, ClaimsPrincipal user, SpecThreadDbContext db, CancellationToken cancel) { - var input = Validate(request.Title, request.Description, request.AcceptanceCriteria, out var errors); + var input = Validate(request.Title, request.Description, request.AcceptanceCriteria, request.DocumentIds, out var errors); if (request.Version is null) errors.Add("version", "Send the version you loaded."); if (input is null || errors.Any) return TypedResults.ValidationProblem(errors.ToDictionary()); + var userId = user.CallerId(); await using var transaction = await db.Database.BeginTransactionAsync(cancel); - var found = await LockForMember(db, user.CallerId(), requirementId, cancel); + var found = await LockForMember(db, userId, requirementId, cancel); if (found is null) return TypedResults.NotFound(); var requirement = found.Requirement; if (found.ProjectArchived || requirement.ArchivedAt is not null) return ProjectEndpoints.Archived(); if (requirement.Version != request.Version) return Stale(); + // 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(); + var refused = await CheckDocuments(db, requirement.ProjectId, input.DocumentIds, linked, cancel); + if (refused is not null) return refused; + db.RequirementDocuments.RemoveRange(links.Where(l => !input.DocumentIds.Contains(l.DocumentId))); + // Delete first so new positions never collide with the unique (requirement_id, position) index. await db.AcceptanceCriteria.Where(c => c.RequirementId == requirementId).ExecuteDeleteAsync(cancel); + var now = Clock.UtcNow(); requirement.Title = input.Title; requirement.Description = input.Description; - requirement.UpdatedAt = Clock.UtcNow(); + requirement.UpdatedAt = now; + requirement.UpdatedBy = userId; requirement.Version++; var criteria = AddCriteria(db, requirementId, input.Criteria); + LinkDocuments(db, requirementId, input.DocumentIds.Where(id => !linked.Contains(id)), userId, now); try { await db.SaveChangesAsync(cancel); @@ -128,21 +148,32 @@ private static async Task, ValidationProblem, No { return Stale(); // Disposing the transaction rolls back the criteria deletion. } + var documents = await LoadDocuments(db, requirementId, cancel); await transaction.CommitAsync(cancel); - return TypedResults.Ok(ToResponse(requirement, criteria)); + return TypedResults.Ok(ToResponse(requirement, criteria, documents)); } - private static async Task, NotFound, ProblemHttpResult>> Archive( - Guid requirementId, ClaimsPrincipal user, SpecThreadDbContext db, CancellationToken cancel) + private static Task, NotFound, ProblemHttpResult>> Archive( + Guid requirementId, ClaimsPrincipal user, SpecThreadDbContext db, CancellationToken cancel) => + SetArchived(requirementId, true, user, db, cancel); + + // Any member restores, as any member archives. Restoring is not an edit: the version, the + // last editor, and the reviews stay as they were, so a decision that was current still is. + private static Task, NotFound, ProblemHttpResult>> Restore( + Guid requirementId, ClaimsPrincipal user, SpecThreadDbContext db, CancellationToken cancel) => + SetArchived(requirementId, false, user, db, cancel); + + private static async Task, NotFound, ProblemHttpResult>> SetArchived( + Guid requirementId, bool archived, ClaimsPrincipal user, SpecThreadDbContext db, CancellationToken cancel) { await using var transaction = await db.Database.BeginTransactionAsync(cancel); var found = await LockForMember(db, user.CallerId(), requirementId, cancel); if (found is null) return TypedResults.NotFound(); if (found.ProjectArchived) return ProjectEndpoints.Archived(); - // Idempotent: archiving again keeps the original timestamp. - found.Requirement.ArchivedAt ??= Clock.UtcNow(); + // Idempotent: archiving again keeps the original timestamp, and restoring an active one changes nothing. + found.Requirement.ArchivedAt = archived ? found.Requirement.ArchivedAt ?? Clock.UtcNow() : null; try { await db.SaveChangesAsync(cancel); @@ -157,7 +188,7 @@ private static async Task, NotFound, ProblemHttp .Where(c => c.RequirementId == requirementId) .OrderBy(c => c.Position) .ToListAsync(cancel); - return TypedResults.Ok(ToResponse(found.Requirement, criteria)); + return TypedResults.Ok(ToResponse(found.Requirement, criteria, await LoadDocuments(db, requirementId, cancel))); } // Like FindForMember, but locks the project's team first and then reads the requirement, so @@ -181,15 +212,57 @@ private static async Task, NotFound, ProblemHttp .Join(db.ProjectsFor(userId), r => r.ProjectId, p => p.Id, (r, p) => new MemberRequirement(r, p.ArchivedAt != null)) .SingleOrDefaultAsync(cancel); - private static ValidInput? Validate(string? title, string? description, IReadOnlyList? criteria, out InputErrors errors) + private static ValidInput? Validate( + string? title, string? description, IReadOnlyList? criteria, IReadOnlyList? documentIds, out InputErrors errors) { errors = new InputErrors(); var validTitle = errors.Required("title", title, InputErrors.MaxNameLength); var validDescription = errors.Optional("description", description, InputErrors.MaxDescriptionLength); var validCriteria = errors.Criteria("acceptanceCriteria", criteria); - return errors.Any ? null : new ValidInput(validTitle!, validDescription!, validCriteria!); + documentIds ??= []; + if (documentIds.Count > InputErrors.MaxRequirementDocuments) + { + errors.Add("documentIds", $"Link at most {InputErrors.MaxRequirementDocuments} documents."); + } + else if (documentIds.Distinct().Count() != documentIds.Count) + { + errors.Add("documentIds", "Link each document once."); + } + return errors.Any ? null : new ValidInput(validTitle!, validDescription!, validCriteria!, [.. documentIds]); + } + + // Documents come from the requirement's project. Archived ones stay linked but can't be newly linked. + // Call after locking the project's team, so a document can't be archived between this check and the save. + private static async Task CheckDocuments( + SpecThreadDbContext db, Guid projectId, List documentIds, IReadOnlySet linked, CancellationToken cancel) + { + if (documentIds.Count == 0) return null; + var found = await db.Documents.Where(d => d.ProjectId == projectId && documentIds.Contains(d.Id)) + .Select(d => new { d.Id, d.ArchivedAt }).ToListAsync(cancel); + string? message = null; + if (found.Count != documentIds.Count) message = "Choose documents from this project's Documentation tab."; + else if (found.Any(d => d.ArchivedAt is not null && !linked.Contains(d.Id))) message = "An archived document can't be newly linked."; + return message is null ? null + : TypedResults.ValidationProblem(new Dictionary { ["documentIds"] = [message] }); } + private static void LinkDocuments(SpecThreadDbContext db, Guid requirementId, IEnumerable documentIds, string userId, DateTime now) => + db.RequirementDocuments.AddRange(documentIds.Select(id => new RequirementDocument + { + RequirementId = requirementId, + DocumentId = id, + LinkedBy = userId, + LinkedAt = now, + })); + + private static Task> LoadDocuments(SpecThreadDbContext db, Guid requirementId, CancellationToken cancel) => + db.RequirementDocuments.AsNoTracking().Where(l => l.RequirementId == requirementId) + .Join(db.Documents, l => l.DocumentId, d => d.Id, (l, d) => new { Link = l, Document = d }) + .OrderBy(x => x.Document.Title).ThenBy(x => x.Document.Id) + .Select(x => new LinkedDocumentResponse(x.Document.Id, x.Document.Title, x.Document.FileName, x.Document.ContentType, + x.Document.ArchivedAt, x.Link.LinkedBy, x.Link.LinkedAt)) + .ToListAsync(cancel); + private static List AddCriteria(SpecThreadDbContext db, Guid requirementId, List texts) { var criteria = texts.Select((text, position) => new AcceptanceCriterion @@ -206,27 +279,33 @@ private static List AddCriteria(SpecThreadDbContext db, Gui private static ProblemHttpResult Stale() => TypedResults.Problem( "This requirement changed since it was loaded. Reload it and try again.", statusCode: StatusCodes.Status409Conflict); - private static RequirementResponse ToResponse(Requirement r, IEnumerable criteria) => new( - r.Id, r.ProjectId, r.Title, r.Description, r.CreatedBy, r.Version, r.CreatedAt, r.UpdatedAt, r.ArchivedAt, - criteria.Select(c => new CriterionResponse(c.Id, c.Text, c.Position)).ToList()); + private static RequirementResponse ToResponse( + Requirement r, IEnumerable criteria, List documents) => 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); internal sealed record MemberRequirement(Requirement Requirement, bool ProjectArchived) { public bool Archived => ProjectArchived || Requirement.ArchivedAt is not null; } - private sealed record ValidInput(string Title, string Description, List Criteria); + private sealed record ValidInput(string Title, string Description, List Criteria, List DocumentIds); } -internal sealed record RequirementRequest(string? Title, string? Description, List? AcceptanceCriteria); +internal sealed record RequirementRequest(string? Title, string? Description, List? AcceptanceCriteria, List? DocumentIds); -internal sealed record UpdateRequirementRequest(string? Title, string? Description, List? AcceptanceCriteria, int? Version); +internal sealed record UpdateRequirementRequest( + string? Title, string? Description, List? AcceptanceCriteria, List? DocumentIds, int? Version); internal sealed record RequirementSummary( Guid Id, Guid ProjectId, string Title, int Version, DateTime CreatedAt, DateTime UpdatedAt, DateTime? ArchivedAt, int EvidenceCount, ReviewSummary? Review); internal sealed record RequirementResponse( - Guid Id, Guid ProjectId, string Title, string Description, string CreatedBy, int Version, - DateTime CreatedAt, DateTime UpdatedAt, DateTime? ArchivedAt, List AcceptanceCriteria); + Guid Id, Guid ProjectId, string Title, string Description, string CreatedBy, string UpdatedBy, int Version, + DateTime CreatedAt, DateTime UpdatedAt, DateTime? ArchivedAt, List AcceptanceCriteria, + List Documents); internal sealed record CriterionResponse(Guid Id, string Text, int Position); + +internal sealed record LinkedDocumentResponse( + Guid Id, string Title, string FileName, string ContentType, DateTime? ArchivedAt, string LinkedBy, DateTime LinkedAt); diff --git a/app/web/src/app/app-shell.css b/app/web/src/app/app-shell.css index 2c350f2..d7a2137 100644 --- a/app/web/src/app/app-shell.css +++ b/app/web/src/app/app-shell.css @@ -438,3 +438,9 @@ a.row:focus-visible { outline-offset: -2px; } .document-details { margin: 4px 0 0; } .row .row-meta + .document-row-date { margin-left: 16px; } @media (max-width: 560px) { .document-row-date { display: none; } } +.document-choices { display: grid; gap: 4px; margin: 0; padding: 0; list-style: none; } +.document-choices label { display: flex; align-items: baseline; gap: 10px; padding: 6px 0; cursor: pointer; } +.document-choices input { flex: none; } +.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; } 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 c89d320..860543e 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 @@ -1,7 +1,9 @@ import type { Metadata } from "next"; +import Link from "next/link"; import { updateRequirement } from "@/app/projects/requirement-actions"; import { RequirementForm } from "@/components/requirement-form"; -import { requireProject, requireRequirement } from "@/lib/project-data"; +import { listDocuments, requireProject, requireRequirement } from "@/lib/project-data"; +import { documentChoices } from "@/lib/requirements"; export const metadata: Metadata = { title: "Edit requirement" }; @@ -11,7 +13,9 @@ export const metadata: Metadata = { title: "Edit requirement" }; */ export default async function Page({ params }: PageProps<"/projects/[projectId]/requirements/[requirementId]/edit">) { const { projectId, requirementId } = await params; - const [project, requirement] = await Promise.all([requireProject(projectId), requireRequirement(projectId, requirementId)]); + const [project, requirement, documents] = await Promise.all([ + requireProject(projectId), requireRequirement(projectId, requirementId), listDocuments(projectId), + ]); const detail = `/projects/${project.id}/requirements/${requirement.id}`; return ( @@ -19,11 +23,18 @@ export default async function Page({ params }: PageProps<"/projects/[projectId]/

Edit requirement

- {project.archivedAt || requirement.archivedAt + {project.archivedAt ?

This requirement is archived and can no longer be changed.

+ : requirement.archivedAt + ?

This requirement is archived. Restore it to edit it again.

: criterion.text) }} + initial={{ + title: requirement.title, description: requirement.description, + criteria: requirement.acceptanceCriteria.map(criterion => criterion.text), + 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} />} ); 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 6910f77..edbec70 100644 --- a/app/web/src/app/projects/[projectId]/requirements/[requirementId]/page.tsx +++ b/app/web/src/app/projects/[projectId]/requirements/[requirementId]/page.tsx @@ -1,9 +1,10 @@ import type { Metadata } from "next"; import Link from "next/link"; -import { ArrowLeft, Pencil } from "lucide-react"; +import { ArrowLeft, FileText, Pencil } from "lucide-react"; import { EvidencePanel } from "@/components/evidence-panel"; -import { RequirementArchive } from "@/components/requirement-archive"; +import { RequirementArchive, RequirementRestore } from "@/components/requirement-archive"; 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 { formatDate } from "@/lib/projects"; @@ -18,7 +19,10 @@ export async function generateMetadata({ params }: Props): Promise { return { title: (await requireRequirement(projectId, requirementId)).title }; } -/** Shows a requirement's description, ordered acceptance criteria, evidence, and review decisions, with edit and archive while it can change. */ +/** + * 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. + */ 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 @@ -28,7 +32,7 @@ export default async function Page({ params }: Props) { getProjectRepository(projectId), listEvidence(requirementId), listReviews(requirementId), viewerId(), ]); const people = Object.fromEntries(members.map(member => [member.userId, member.name])); - const author = members.find(member => member.userId === requirement.createdBy)?.name ?? "a former member"; + const nameOf = (userId: string) => people[userId] ?? "a former member"; const editable = !project.archivedAt && !requirement.archivedAt; const base = `/projects/${project.id}/requirements`; @@ -41,10 +45,13 @@ export default async function Page({ params }: Props) { {editable &&