From 68750da1038a818fb1ac5b22fa8235be06884a39 Mon Sep 17 00:00:00 2001 From: sormond319 Date: Fri, 23 Oct 2026 21:04:56 -0400 Subject: [PATCH 1/3] feat: require an acceptance criterion and link documents from requirements A requirement can no longer be created or saved without at least one acceptance criterion; the form starts with one empty row. The requirement page now shows when it was created as well as by whom. Requirements link up to 20 of their project's documents, ticked in the requirement form and saved with the requirement (ADR-049). Kept links keep who linked them and when; archived documents can't be newly linked but stay linked until unticked. Migration RequirementDocuments adds the link table with RLS on and browser grants revoked; CI applies it on merge (ADR-044). Co-Authored-By: Claude Opus 5.5 --- app/api/Data/ProductModels.cs | 9 + app/api/Data/SpecThreadDbContext.cs | 13 + ...024003629_RequirementDocuments.Designer.cs | 1246 +++++++++++++++++ .../20261024003629_RequirementDocuments.cs | 85 ++ .../SpecThreadDbContextModelSnapshot.cs | 51 + app/api/Projects/ProjectAccess.cs | 7 + app/api/Requirements/RequirementEndpoints.cs | 97 +- app/web/src/app/app-shell.css | 6 + .../[requirementId]/edit/page.tsx | 14 +- .../requirements/[requirementId]/page.tsx | 30 +- .../[projectId]/requirements/new/page.tsx | 9 +- .../src/app/projects/requirement-actions.ts | 8 +- app/web/src/components/requirement-form.tsx | 48 +- app/web/src/lib/requirements.ts | 60 +- docs/API.md | 19 +- docs/ARCHITECTURE.md | 2 +- docs/DATABASE.md | 33 +- docs/DECISIONS.md | 37 +- docs/HANDOFF.md | 61 +- docs/PROJECT.md | 18 +- docs/TESTING.md | 15 +- .../schema/requirement-documents-rollback.sql | 8 + docs/schema/requirement-documents.sql | 33 + scripts/generate-requirement-documents.mjs | 38 + scripts/test-database.mjs | 1 + tests/api/project-data.spec.ts | 53 +- tests/e2e/evidence.spec.ts | 1 + tests/e2e/requirements.spec.ts | 143 +- tests/e2e/reviews.spec.ts | 2 + tests/schema/evidence.spec.ts | 10 +- tests/schema/github-repository.spec.ts | 4 +- tests/schema/product-api.spec.ts | 51 +- tests/schema/requirement-documents.spec.ts | 206 +++ tests/schema/reviews.spec.ts | 14 +- tests/schema/team-management.spec.ts | 2 +- 35 files changed, 2312 insertions(+), 122 deletions(-) create mode 100644 app/api/Migrations/20261024003629_RequirementDocuments.Designer.cs create mode 100644 app/api/Migrations/20261024003629_RequirementDocuments.cs create mode 100644 docs/schema/requirement-documents-rollback.sql create mode 100644 docs/schema/requirement-documents.sql create mode 100644 scripts/generate-requirement-documents.mjs create mode 100644 tests/schema/requirement-documents.spec.ts diff --git a/app/api/Data/ProductModels.cs b/app/api/Data/ProductModels.cs index 41dcf28..13ded7a 100644 --- a/app/api/Data/ProductModels.cs +++ b/app/api/Data/ProductModels.cs @@ -129,3 +129,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..5d0b611 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) { @@ -285,5 +286,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/SpecThreadDbContextModelSnapshot.cs b/app/api/Migrations/SpecThreadDbContextModelSnapshot.cs index 13bf381..689fb93 100644 --- a/app/api/Migrations/SpecThreadDbContextModelSnapshot.cs +++ b/app/api/Migrations/SpecThreadDbContextModelSnapshot.cs @@ -575,6 +575,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") @@ -1104,6 +1134,27 @@ protected override void BuildModel(ModelBuilder modelBuilder) .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) 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..68923f3 100644 --- a/app/api/Requirements/RequirementEndpoints.cs +++ b/app/api/Requirements/RequirementEndpoints.cs @@ -55,7 +55,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 +64,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 @@ -79,10 +81,12 @@ private static async Task, ValidationProble }; 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 +99,41 @@ 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.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,9 +142,10 @@ 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( @@ -157,7 +172,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 +196,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 +263,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( + private static RequirementResponse ToResponse( + Requirement r, IEnumerable criteria, List documents) => 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()); + 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); + 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..9b9322a 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,8 @@ import type { Metadata } from "next"; 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 +12,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 ( @@ -23,7 +26,12 @@ export default async function Page({ params }: PageProps<"/projects/[projectId]/ ?

This requirement is archived and can no longer be changed.

: 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..a57acba 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 { 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 @@ -41,7 +45,8 @@ export default async function Page({ params }: Props) { {editable &&