From 8642c5eb380588c3d0c58dcb6f051d7d37f20522 Mon Sep 17 00:00:00 2001 From: sormond319 Date: Sun, 18 Oct 2026 20:06:19 -0400 Subject: [PATCH 1/2] feat: add a Documentation tab for project reference documents Members of a project's team can upload PDF, DOC, and DOCX files (up to 4 MiB), list them, open each one, download it, and archive it. The Documentation tab sits between Requirements and Settings (ADR-047). - API: /projects/{id}/documents and /documents/{id} endpoints with extension and file-signature checks, team-membership access, team locking on writes, and attachment downloads with nosniff. - Database: ProjectDocuments migration adds documents (metadata) and document_contents (bytea) with check constraints and RLS; SQL scripts generated by scripts/generate-project-documents.mjs. - Web: list, upload, and document pages inside the project frame, a download route that 404s for another project's address, response validation, and apiFetch sending JSON headers only for string bodies. - Tests: schema API spec, helper tests, and browser tests against the real API; the test proxy now forwards Content-Disposition, and the migration-history counts in older rollback tests include the new one. - Docs: ADR-047, API, DATABASE, ARCHITECTURE, PROJECT, TESTING, HANDOFF. Co-Authored-By: Claude Opus 5.5 --- app/api/Data/ProductModels.cs | 20 + app/api/Data/SpecThreadDbContext.cs | 31 + app/api/Documents/DocumentEndpoints.cs | 189 +++ ...0261018234943_ProjectDocuments.Designer.cs | 1195 +++++++++++++++++ .../20261018234943_ProjectDocuments.cs | 122 ++ .../SpecThreadDbContextModelSnapshot.cs | 104 ++ app/api/Program.cs | 2 + app/web/next.config.ts | 4 + app/web/src/app/app-shell.css | 8 + .../documents/[documentId]/download/route.ts | 35 + .../documents/[documentId]/page.tsx | 50 + .../[projectId]/documents/new/page.tsx | 27 + .../projects/[projectId]/documents/page.tsx | 42 + app/web/src/app/projects/document-actions.ts | 64 + app/web/src/components/document-archive.tsx | 26 + .../src/components/document-upload-form.tsx | 64 + app/web/src/components/project-tabs.tsx | 1 + app/web/src/lib/api.ts | 5 +- app/web/src/lib/documents.ts | 80 ++ app/web/src/lib/project-data.ts | 20 + docs/API.md | 22 +- docs/ARCHITECTURE.md | 4 +- docs/DATABASE.md | 30 +- docs/DECISIONS.md | 42 + docs/HANDOFF.md | 68 +- docs/PROJECT.md | 2 + docs/TESTING.md | 11 +- docs/schema/project-documents-rollback.sql | 10 + docs/schema/project-documents.sql | 59 + scripts/generate-project-documents.mjs | 38 + scripts/test-api-proxy.mjs | 2 +- scripts/test-database.mjs | 1 + tests/api/document-files.spec.ts | 40 + tests/api/products.spec.ts | 8 + tests/e2e/documents.spec.ts | 137 ++ tests/e2e/projects.spec.ts | 2 +- tests/schema/documents-api.spec.ts | 193 +++ tests/schema/evidence.spec.ts | 8 +- tests/schema/github-repository.spec.ts | 4 +- tests/schema/reviews.spec.ts | 2 +- 40 files changed, 2752 insertions(+), 20 deletions(-) create mode 100644 app/api/Documents/DocumentEndpoints.cs create mode 100644 app/api/Migrations/20261018234943_ProjectDocuments.Designer.cs create mode 100644 app/api/Migrations/20261018234943_ProjectDocuments.cs create mode 100644 app/web/src/app/projects/[projectId]/documents/[documentId]/download/route.ts create mode 100644 app/web/src/app/projects/[projectId]/documents/[documentId]/page.tsx create mode 100644 app/web/src/app/projects/[projectId]/documents/new/page.tsx create mode 100644 app/web/src/app/projects/[projectId]/documents/page.tsx create mode 100644 app/web/src/app/projects/document-actions.ts create mode 100644 app/web/src/components/document-archive.tsx create mode 100644 app/web/src/components/document-upload-form.tsx create mode 100644 app/web/src/lib/documents.ts create mode 100644 docs/schema/project-documents-rollback.sql create mode 100644 docs/schema/project-documents.sql create mode 100644 scripts/generate-project-documents.mjs create mode 100644 tests/api/document-files.spec.ts create mode 100644 tests/e2e/documents.spec.ts create mode 100644 tests/schema/documents-api.spec.ts diff --git a/app/api/Data/ProductModels.cs b/app/api/Data/ProductModels.cs index 2a4218d..41dcf28 100644 --- a/app/api/Data/ProductModels.cs +++ b/app/api/Data/ProductModels.cs @@ -109,3 +109,23 @@ public sealed class RequirementReview public required string DecidedBy { get; set; } public DateTime DecidedAt { get; set; } } + +// Metadata only; the bytes live in DocumentContent so lists never load files (ADR-047). +public sealed class Document +{ + public Guid Id { get; set; } + public Guid ProjectId { get; set; } + public required string Title { get; set; } + public required string FileName { get; set; } + public required string ContentType { get; set; } + public long SizeBytes { get; set; } + public required string UploadedBy { get; set; } + public DateTime UploadedAt { get; set; } + public DateTime? ArchivedAt { get; set; } +} + +public sealed class DocumentContent +{ + public Guid DocumentId { get; set; } + public required byte[] Content { get; set; } +} diff --git a/app/api/Data/SpecThreadDbContext.cs b/app/api/Data/SpecThreadDbContext.cs index e58f53f..3b7eb7f 100644 --- a/app/api/Data/SpecThreadDbContext.cs +++ b/app/api/Data/SpecThreadDbContext.cs @@ -16,6 +16,8 @@ public sealed class SpecThreadDbContext(DbContextOptions op public DbSet TeamMembers => Set(); public DbSet UserOnboardings => Set(); public DbSet TeamInvitations => Set(); + public DbSet Documents => Set(); + public DbSet DocumentContents => Set(); protected override void OnModelCreating(ModelBuilder model) { @@ -254,5 +256,34 @@ protected override void OnModelCreating(ModelBuilder model) review.HasIndex(x => new { x.RequirementId, x.DecidedAt }); review.HasOne().WithMany().HasForeignKey(x => x.RequirementId).OnDelete(DeleteBehavior.Restrict); review.HasOne().WithMany().HasForeignKey(x => x.DecidedBy).OnDelete(DeleteBehavior.Restrict); + + var document = model.Entity(); + document.ToTable("documents", table => + { + table.HasCheckConstraint("ck_documents_title", "length(btrim(title)) > 0"); + table.HasCheckConstraint("ck_documents_file_name", "length(btrim(file_name)) > 0"); + table.HasCheckConstraint("ck_documents_content_type", + "content_type IN ('application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document')"); + table.HasCheckConstraint("ck_documents_size", "size_bytes BETWEEN 1 AND 4194304"); + }); + document.HasKey(x => x.Id); + document.Property(x => x.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()"); + document.Property(x => x.ProjectId).HasColumnName("project_id"); + document.Property(x => x.Title).HasColumnName("title"); + document.Property(x => x.FileName).HasColumnName("file_name"); + document.Property(x => x.ContentType).HasColumnName("content_type"); + document.Property(x => x.SizeBytes).HasColumnName("size_bytes"); + document.Property(x => x.UploadedBy).HasColumnName("uploaded_by"); + document.Property(x => x.UploadedAt).HasColumnName("uploaded_at").HasDefaultValueSql("now()"); + document.Property(x => x.ArchivedAt).HasColumnName("archived_at"); + document.HasOne().WithMany().HasForeignKey(x => x.ProjectId).OnDelete(DeleteBehavior.Restrict); + document.HasOne().WithMany().HasForeignKey(x => x.UploadedBy).OnDelete(DeleteBehavior.Restrict); + + var content = model.Entity(); + content.ToTable("document_contents"); + content.HasKey(x => x.DocumentId); + 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); } } diff --git a/app/api/Documents/DocumentEndpoints.cs b/app/api/Documents/DocumentEndpoints.cs new file mode 100644 index 0000000..d1a3465 --- /dev/null +++ b/app/api/Documents/DocumentEndpoints.cs @@ -0,0 +1,189 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using SpecThread.Api.Data; +using SpecThread.Api.Projects; + +namespace SpecThread.Api.Documents; + +// Any member of the project's team uploads, downloads, and archives reference documents (ADR-047). +internal static class DocumentEndpoints +{ + // Files pass through a Vercel function on the way in and out, whose body limit is 4.5 MB. + public const int MaxDocumentBytes = 4 * 1024 * 1024; + public const int MaxFileNameLength = 255; + private const int MaxUploadRequestBytes = MaxDocumentBytes + 64 * 1024; // Room for multipart framing. + + // The stored type comes from the extension, and the file must start with that format's signature. + private static readonly Dictionary Formats = + new(StringComparer.OrdinalIgnoreCase) + { + [".pdf"] = ("application/pdf", "%PDF-"u8.ToArray()), + [".doc"] = ("application/msword", [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]), + [".docx"] = ("application/vnd.openxmlformats-officedocument.wordprocessingml.document", [0x50, 0x4B, 0x03, 0x04]), + }; + + public static void MapDocumentEndpoints(this IEndpointRouteBuilder app) + { + var byProject = app.MapGroup("/projects/{projectId:guid}/documents").WithTags("Documents") + .WithMetadata(ProjectEndpoints.Unauthorized); + byProject.MapGet("/", List).WithName("ListDocuments"); + // Bearer tokens, not cookies, authenticate the API, so form posts need no antiforgery token. + byProject.MapPost("/", Upload).WithName("UploadDocument") + .DisableAntiforgery() + .WithMetadata(new RequestSizeLimitAttribute(MaxUploadRequestBytes)) + .ProducesProblem(StatusCodes.Status409Conflict); + + var documents = app.MapGroup("/documents").WithTags("Documents") + .WithMetadata(ProjectEndpoints.Unauthorized); + documents.MapGet("/{documentId:guid}", Get).WithName("GetDocument"); + documents.MapGet("/{documentId:guid}/content", Content).WithName("DownloadDocument") + .Produces(StatusCodes.Status200OK, contentType: "application/octet-stream"); + documents.MapPost("/{documentId:guid}/archive", Archive).WithName("ArchiveDocument") + .ProducesProblem(StatusCodes.Status409Conflict); + } + + private static async Task>, NotFound>> List( + Guid projectId, ClaimsPrincipal user, SpecThreadDbContext db, CancellationToken cancel) + { + if (!await db.ProjectsFor(user.CallerId()).AnyAsync(p => p.Id == projectId, cancel)) return TypedResults.NotFound(); + + var items = await db.Documents.Where(d => d.ProjectId == projectId && d.ArchivedAt == null) + .Join(db.Set(), d => d.UploadedBy, u => u.Id, (d, u) => new { Document = d, UploaderName = u.Name }) + .OrderByDescending(x => x.Document.UploadedAt).ThenBy(x => x.Document.Id) + .Select(x => ToResponse(x.Document, x.UploaderName)) + .ToListAsync(cancel); + return TypedResults.Ok(items); + } + + private static async Task, ValidationProblem, NotFound, ProblemHttpResult>> Upload( + Guid projectId, IFormFile? file, [FromForm] string? title, ClaimsPrincipal user, SpecThreadDbContext db, + CancellationToken cancel) + { + var errors = new InputErrors(); + var validTitle = errors.Optional("title", title, InputErrors.MaxNameLength); + var upload = await ReadUpload(file, errors, cancel); + if (upload is null || errors.Any) return TypedResults.ValidationProblem(errors.ToDictionary()); + + var userId = user.CallerId(); + // Serialize with archiving and membership changes in this project's team. + await using var transaction = await db.Database.BeginTransactionAsync(cancel); + 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 document = new Document + { + Id = Guid.NewGuid(), + ProjectId = projectId, + Title = validTitle is "" ? DefaultTitle(upload.FileName) : validTitle!, + FileName = upload.FileName, + ContentType = upload.ContentType, + SizeBytes = upload.Bytes.Length, + UploadedBy = userId, + UploadedAt = Clock.UtcNow(), + }; + db.Documents.Add(document); + db.DocumentContents.Add(new DocumentContent { DocumentId = document.Id, Content = upload.Bytes }); + await db.SaveChangesAsync(cancel); + await transaction.CommitAsync(cancel); + + var response = await Responses(db, db.Documents.Where(d => d.Id == document.Id)).SingleAsync(cancel); + return TypedResults.Created($"/documents/{document.Id}", response); + } + + private static async Task, NotFound>> Get( + Guid documentId, ClaimsPrincipal user, SpecThreadDbContext db, CancellationToken cancel) + { + var document = await Responses(db, MemberDocuments(db, user.CallerId()).Where(d => d.Id == documentId)) + .SingleOrDefaultAsync(cancel); + return document is null ? TypedResults.NotFound() : TypedResults.Ok(document); + } + + // Archived documents and projects stay downloadable; archiving only hides them from the list. + private static async Task> Content( + Guid documentId, ClaimsPrincipal user, HttpResponse response, SpecThreadDbContext db, CancellationToken cancel) + { + var file = await MemberDocuments(db, user.CallerId()).Where(d => d.Id == documentId) + .Join(db.DocumentContents, d => d.Id, c => c.DocumentId, (d, c) => new { d.FileName, d.ContentType, c.Content }) + .SingleOrDefaultAsync(cancel); + if (file is null) return TypedResults.NotFound(); + + response.Headers.XContentTypeOptions = "nosniff"; + response.Headers.CacheControl = "private, no-store"; + // A download name makes this an attachment, so browsers never render the file in the app's origin. + return TypedResults.File(file.Content, file.ContentType, file.FileName); + } + + private static async Task, NotFound, ProblemHttpResult>> Archive( + Guid documentId, ClaimsPrincipal user, SpecThreadDbContext db, CancellationToken cancel) + { + await using var transaction = await db.Database.BeginTransactionAsync(cancel); + var projectId = await db.Documents.Where(d => d.Id == documentId).Select(d => (Guid?)d.ProjectId).SingleOrDefaultAsync(cancel); + var project = projectId is null ? null : await ProjectEndpoints.LockProjectForCaller(db, projectId.Value, user.CallerId(), cancel); + if (project is null) return TypedResults.NotFound(); + if (project.ArchivedAt is not null) return ProjectEndpoints.Archived(); + + // Idempotent: archiving again keeps the original timestamp. + var document = await db.Documents.SingleAsync(d => d.Id == documentId, cancel); + document.ArchivedAt ??= Clock.UtcNow(); + await db.SaveChangesAsync(cancel); + await transaction.CommitAsync(cancel); + + var response = await Responses(db, db.Documents.Where(d => d.Id == documentId)).SingleAsync(cancel); + return TypedResults.Ok(response); + } + + // Documents in projects the user belongs to; others look as if they do not exist. + private static IQueryable MemberDocuments(SpecThreadDbContext db, string userId) => + db.Documents.Where(d => db.ProjectsFor(userId).Any(p => p.Id == d.ProjectId)); + + private static IQueryable Responses(SpecThreadDbContext db, IQueryable documents) => + documents.Join(db.Set(), d => d.UploadedBy, u => u.Id, (d, u) => ToResponse(d, u.Name)); + + private static DocumentResponse ToResponse(Document d, string uploaderName) => new( + d.Id, d.ProjectId, d.Title, d.FileName, d.ContentType, d.SizeBytes, d.UploadedBy, uploaderName, d.UploadedAt, d.ArchivedAt); + + // Returns the cleaned upload, or null after recording an error on "file". + private static async Task ReadUpload(IFormFile? file, InputErrors errors, CancellationToken cancel) + { + if (file is null || file.Length == 0) return Reject(errors, "Choose a file to upload."); + if (file.Length > MaxDocumentBytes) return Reject(errors, $"Use a file of at most {MaxDocumentBytes / (1024 * 1024)} MB."); + + // Browsers send a bare name, but some clients include a path in either style. + var name = file.FileName[(file.FileName.LastIndexOfAny(['/', '\\']) + 1)..]; + name = new string(name.Where(c => !char.IsControl(c)).ToArray()).Trim(); + if (name.Length == 0) return Reject(errors, "The file needs a name."); + if (name.Length > MaxFileNameLength) return Reject(errors, $"Use a file name of at most {MaxFileNameLength} characters."); + if (!Formats.TryGetValue(Path.GetExtension(name), out var format)) return Reject(errors, "Upload a PDF, DOC, or DOCX file."); + + var bytes = new byte[file.Length]; + await using (var stream = file.OpenReadStream()) + { + await stream.ReadExactlyAsync(bytes, cancel); + } + if (!bytes.AsSpan().StartsWith(format.Signature)) return Reject(errors, "The file's contents don't match its type."); + + return new ValidUpload(name, format.ContentType, bytes); + } + + private static ValidUpload? Reject(InputErrors errors, string message) + { + errors.Add("file", message); + return null; + } + + private static string DefaultTitle(string fileName) + { + var withoutExtension = Path.GetFileNameWithoutExtension(fileName).Trim(); + var title = withoutExtension.Length > 0 ? withoutExtension : fileName; + return title.Length > InputErrors.MaxNameLength ? title[..InputErrors.MaxNameLength] : title; + } + + private sealed record ValidUpload(string FileName, string ContentType, byte[] Bytes); +} + +internal sealed record DocumentResponse( + Guid Id, Guid ProjectId, string Title, string FileName, string ContentType, long SizeBytes, + string UploadedBy, string UploadedByName, DateTime UploadedAt, DateTime? ArchivedAt); diff --git a/app/api/Migrations/20261018234943_ProjectDocuments.Designer.cs b/app/api/Migrations/20261018234943_ProjectDocuments.Designer.cs new file mode 100644 index 0000000..0fcd998 --- /dev/null +++ b/app/api/Migrations/20261018234943_ProjectDocuments.Designer.cs @@ -0,0 +1,1195 @@ +// +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("20261018234943_ProjectDocuments")] + partial class ProjectDocuments + { + /// + 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.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.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/20261018234943_ProjectDocuments.cs b/app/api/Migrations/20261018234943_ProjectDocuments.cs new file mode 100644 index 0000000..4e6d7a1 --- /dev/null +++ b/app/api/Migrations/20261018234943_ProjectDocuments.cs @@ -0,0 +1,122 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SpecThread.Api.Migrations +{ + /// + public partial class ProjectDocuments : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "documents", + schema: "public", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"), + project_id = table.Column(type: "uuid", nullable: false), + title = table.Column(type: "text", nullable: false), + file_name = table.Column(type: "text", nullable: false), + content_type = table.Column(type: "text", nullable: false), + size_bytes = table.Column(type: "bigint", nullable: false), + uploaded_by = table.Column(type: "text", nullable: false), + uploaded_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"), + archived_at = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_documents", x => x.id); + table.CheckConstraint("ck_documents_content_type", "content_type IN ('application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document')"); + table.CheckConstraint("ck_documents_file_name", "length(btrim(file_name)) > 0"); + table.CheckConstraint("ck_documents_size", "size_bytes BETWEEN 1 AND 4194304"); + table.CheckConstraint("ck_documents_title", "length(btrim(title)) > 0"); + table.ForeignKey( + name: "FK_documents_projects_project_id", + column: x => x.project_id, + principalSchema: "public", + principalTable: "projects", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_documents_user_uploaded_by", + column: x => x.uploaded_by, + principalSchema: "public", + principalTable: "user", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "document_contents", + schema: "public", + columns: table => new + { + document_id = table.Column(type: "uuid", nullable: false), + content = table.Column(type: "bytea", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_document_contents", x => x.document_id); + table.ForeignKey( + name: "FK_document_contents_documents_document_id", + column: x => x.document_id, + principalSchema: "public", + principalTable: "documents", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_documents_project_id", + schema: "public", + table: "documents", + column: "project_id"); + + migrationBuilder.CreateIndex( + name: "IX_documents_uploaded_by", + schema: "public", + table: "documents", + column: "uploaded_by"); + + migrationBuilder.Sql(""" + ALTER TABLE public."documents" ENABLE ROW LEVEL SECURITY; + REVOKE ALL ON TABLE public."documents" FROM PUBLIC; + DO $security$ + BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN + REVOKE ALL ON TABLE public."documents" FROM anon; + END IF; + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN + REVOKE ALL ON TABLE public."documents" FROM authenticated; + END IF; + END $security$; + ALTER TABLE public."document_contents" ENABLE ROW LEVEL SECURITY; + REVOKE ALL ON TABLE public."document_contents" FROM PUBLIC; + DO $security$ + BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN + REVOKE ALL ON TABLE public."document_contents" FROM anon; + END IF; + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN + REVOKE ALL ON TABLE public."document_contents" FROM authenticated; + END IF; + END $security$; + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "document_contents", + schema: "public"); + + migrationBuilder.DropTable( + name: "documents", + schema: "public"); + } + } +} diff --git a/app/api/Migrations/SpecThreadDbContextModelSnapshot.cs b/app/api/Migrations/SpecThreadDbContextModelSnapshot.cs index 087a089..13bf381 100644 --- a/app/api/Migrations/SpecThreadDbContextModelSnapshot.cs +++ b/app/api/Migrations/SpecThreadDbContextModelSnapshot.cs @@ -313,6 +313,86 @@ protected override void BuildModel(ModelBuilder modelBuilder) 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") @@ -940,6 +1020,30 @@ protected override void BuildModel(ModelBuilder modelBuilder) .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) diff --git a/app/api/Program.cs b/app/api/Program.cs index 920a90f..5b173e6 100644 --- a/app/api/Program.cs +++ b/app/api/Program.cs @@ -3,6 +3,7 @@ using Microsoft.IdentityModel.JsonWebTokens; using SpecThread.Api.Auth; using SpecThread.Api.Data; +using SpecThread.Api.Documents; using SpecThread.Api.GitHub; using SpecThread.Api.Projects; using SpecThread.Api.Requirements; @@ -61,6 +62,7 @@ app.MapProjectEndpoints(); app.MapMemberEndpoints(); app.MapRequirementEndpoints(); +app.MapDocumentEndpoints(); app.MapRepositoryEndpoints(); app.MapEvidenceEndpoints(); app.MapReviewEndpoints(); diff --git a/app/web/next.config.ts b/app/web/next.config.ts index 34c28f2..18ab6c3 100644 --- a/app/web/next.config.ts +++ b/app/web/next.config.ts @@ -1,6 +1,10 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { + experimental: { + /** Document uploads (up to 4 MB, ADR-047) pass through a server action; the default limit is 1 MB. */ + serverActions: { bodySizeLimit: "5mb" }, + }, /** * Baseline protections for every response: no framing by other sites, no MIME sniffing, and * only the origin in cross-site referrers. Pages may still set a stricter referrer policy. diff --git a/app/web/src/app/app-shell.css b/app/web/src/app/app-shell.css index d84e590..2c350f2 100644 --- a/app/web/src/app/app-shell.css +++ b/app/web/src/app/app-shell.css @@ -430,3 +430,11 @@ a.row:focus-visible { outline-offset: -2px; } @media (prefers-reduced-motion: reduce) { .app-shell *, .app-shell *::before, .app-shell *::after { transition: none; } } + +/* Documents (ADR-047) */ +.document-intro { margin: -6px 0 20px; color: var(--app-text-2); } +.document-form > * + * { margin-top: 20px; } +.app-main .field input[type="file"] { height: auto; padding: 7px 12px; color: var(--app-text-2); } +.document-details { margin: 4px 0 0; } +.row .row-meta + .document-row-date { margin-left: 16px; } +@media (max-width: 560px) { .document-row-date { display: none; } } diff --git a/app/web/src/app/projects/[projectId]/documents/[documentId]/download/route.ts b/app/web/src/app/projects/[projectId]/documents/[documentId]/download/route.ts new file mode 100644 index 0000000..6976068 --- /dev/null +++ b/app/web/src/app/projects/[projectId]/documents/[documentId]/download/route.ts @@ -0,0 +1,35 @@ +import { apiFetch } from "@/lib/api"; +import { parseDocument } from "@/lib/documents"; +import { isUuid } from "@/lib/projects"; + +const passedHeaders = ["content-type", "content-disposition", "content-length"]; +const notFound = () => new Response("Document not found.", { status: 404 }); + +/** + * Streams a document from the API to a signed-in member of its team (the page proxy requires a + * session). A document opened under another project's address is not found, like its page. + * The API's attachment disposition is kept, so the file never renders inside the app's origin. + */ +export async function GET(_request: Request, { params }: RouteContext<"/projects/[projectId]/documents/[documentId]/download">) { + const { projectId, documentId } = await params; + if (!isUuid(projectId) || !isUuid(documentId)) return notFound(); + try { + const metadata = await apiFetch(`/documents/${documentId}`); + if (metadata.status === 404) return notFound(); + if (!metadata.ok) throw new Error(`GET /documents returned ${metadata.status}.`); + if (parseDocument(await metadata.json()).projectId !== projectId) return notFound(); + + const file = await apiFetch(`/documents/${documentId}/content`); + if (file.status === 404) return notFound(); + if (!file.ok || !file.body) throw new Error(`GET /documents content returned ${file.status}.`); + const headers = new Headers({ "cache-control": "private, no-store" }); + for (const name of passedHeaders) { + const value = file.headers.get(name); + if (value) headers.set(name, value); + } + return new Response(file.body, { headers }); + } catch (error) { + console.error("Download document failed", error); + return new Response("The document could not be downloaded. Please try again.", { status: 502 }); + } +} diff --git a/app/web/src/app/projects/[projectId]/documents/[documentId]/page.tsx b/app/web/src/app/projects/[projectId]/documents/[documentId]/page.tsx new file mode 100644 index 0000000..92c20e3 --- /dev/null +++ b/app/web/src/app/projects/[projectId]/documents/[documentId]/page.tsx @@ -0,0 +1,50 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { ArrowLeft, Download } from "lucide-react"; +import { DocumentArchive } from "@/components/document-archive"; +import { documentTypeLabel, formatFileSize } from "@/lib/documents"; +import { requireDocument, requireProject } from "@/lib/project-data"; +import { formatDate } from "@/lib/projects"; + +type Props = PageProps<"/projects/[projectId]/documents/[documentId]">; + +export async function generateMetadata({ params }: Props): Promise { + const { projectId, documentId } = await params; + return { title: (await requireDocument(projectId, documentId)).title }; +} + +/** Shows a document's details with its download link, and the archive action while it can change. */ +export default async function Page({ params }: Props) { + const { projectId, documentId } = await params; + const [project, document] = await Promise.all([requireProject(projectId), requireDocument(projectId, documentId)]); + const editable = !project.archivedAt && !document.archivedAt; + const base = `/projects/${project.id}/documents`; + + return ( +
+
+ ); +} diff --git a/app/web/src/app/projects/[projectId]/documents/new/page.tsx b/app/web/src/app/projects/[projectId]/documents/new/page.tsx new file mode 100644 index 0000000..287ed6b --- /dev/null +++ b/app/web/src/app/projects/[projectId]/documents/new/page.tsx @@ -0,0 +1,27 @@ +import type { Metadata } from "next"; +import { uploadDocument } from "@/app/projects/document-actions"; +import { DocumentUploadForm } from "@/components/document-upload-form"; +import { requireProject } from "@/lib/project-data"; + +export const metadata: Metadata = { title: "Upload document" }; + +/** Renders the form that adds a document to a project. Archived projects explain why they cannot take one. */ +export default async function Page({ params }: PageProps<"/projects/[projectId]/documents/new">) { + const project = await requireProject((await params).projectId); + + return ( +
+
+

Upload document

+
+ {project.archivedAt + ?

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

+ : ( + <> +

Everyone in {project.teamName} can open and download the documents in this project.

+ + + )} +
+ ); +} diff --git a/app/web/src/app/projects/[projectId]/documents/page.tsx b/app/web/src/app/projects/[projectId]/documents/page.tsx new file mode 100644 index 0000000..e3e9cd3 --- /dev/null +++ b/app/web/src/app/projects/[projectId]/documents/page.tsx @@ -0,0 +1,42 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { FileText, Upload } from "lucide-react"; +import { documentTypeLabel, formatFileSize } from "@/lib/documents"; +import { listDocuments, requireProject } from "@/lib/project-data"; +import { formatDate } from "@/lib/projects"; + +export const metadata: Metadata = { title: "Documentation" }; + +/** Lists a project's active reference documents, newest first, each linking to its page. */ +export default async function Page({ params }: PageProps<"/projects/[projectId]/documents">) { + const { projectId } = await params; + const [project, documents] = await Promise.all([requireProject(projectId), listDocuments(projectId)]); + + return ( +
+
+

Documentation

+ {documents.length} + {!project.archivedAt && ( +
+ {documents.length === 0 ? ( +

This project has no documents yet. Upload a PDF, DOC, or DOCX file to share it with the team.

+ ) : ( +
    + {documents.map(document => ( +
  • + +
  • + ))} +
+ )} +
+ ); +} diff --git a/app/web/src/app/projects/document-actions.ts b/app/web/src/app/projects/document-actions.ts new file mode 100644 index 0000000..71510a6 --- /dev/null +++ b/app/web/src/app/projects/document-actions.ts @@ -0,0 +1,64 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import { apiFetch } from "@/lib/api"; +import { DOCUMENT_TITLE_MAX, documentFileError, parseDocument } from "@/lib/documents"; +import { fieldError, isUuid } from "@/lib/projects"; + +export type DocumentFormState = { errors: { file?: string; title?: string; form?: string } | null }; +export type DocumentArchiveState = { error: string | null }; + +const PROJECT_ARCHIVED = "This project is archived, so its documents can't be changed."; + +/** + * Uploads the form's file and optional title to a project, then opens the new document. + * Only those two fields are forwarded; the API checks the file's contents (ADR-047). + */ +export async function uploadDocument(projectId: string, _previous: DocumentFormState, formData: FormData): Promise { + const failed = "The document could not be uploaded. Please try again."; + const file = formData.get("file"); + const title = String(formData.get("title") ?? "").trim(); + const fileError = file instanceof File ? documentFileError(file.name, file.size) : "Choose a file to upload."; + const titleError = title.length > DOCUMENT_TITLE_MAX ? `Use ${DOCUMENT_TITLE_MAX} characters or fewer.` : null; + if (fileError || titleError) return { errors: { file: fileError ?? undefined, title: titleError ?? undefined } }; + if (!isUuid(projectId) || !(file instanceof File)) return { errors: { form: failed } }; + + const body = new FormData(); + body.set("file", file, file.name); + if (title) body.set("title", title); + let documentId: string; + try { + const response = await apiFetch(`/projects/${projectId}/documents`, { method: "POST", body }); + if (response.status === 400) { + const problem: unknown = await response.json(); + const errors = { file: fieldError(problem, "file") ?? undefined, title: fieldError(problem, "title") ?? undefined }; + return { errors: errors.file || errors.title ? errors : { form: failed } }; + } + if (response.status === 413) return { errors: { file: documentFileError("", Number.MAX_SAFE_INTEGER) ?? failed } }; + if (response.status === 409) return { errors: { form: PROJECT_ARCHIVED } }; + if (!response.ok) throw new Error(`Upload document returned ${response.status}.`); + documentId = parseDocument(await response.json()).id; + } catch (error) { + console.error("Upload document failed", error); + return { errors: { form: failed } }; + } + revalidatePath(`/projects/${projectId}`, "layout"); + redirect(`/projects/${projectId}/documents/${documentId}`); +} + +/** Archives a document, then returns to the project's document list. Archiving cannot be undone. */ +export async function archiveDocument(projectId: string, documentId: string): Promise { + const failed = "The document could not be archived. Please try again."; + if (!isUuid(projectId) || !isUuid(documentId)) return { error: failed }; + try { + const response = await apiFetch(`/documents/${documentId}/archive`, { method: "POST" }); + if (response.status === 409) return { error: PROJECT_ARCHIVED }; + if (!response.ok) throw new Error(`Archive document returned ${response.status}.`); + } catch (error) { + console.error("Archive document failed", error); + return { error: failed }; + } + revalidatePath(`/projects/${projectId}`, "layout"); + redirect(`/projects/${projectId}/documents`); +} diff --git a/app/web/src/components/document-archive.tsx b/app/web/src/components/document-archive.tsx new file mode 100644 index 0000000..2120879 --- /dev/null +++ b/app/web/src/components/document-archive.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { useActionState, useState } from "react"; +import { archiveDocument } from "@/app/projects/document-actions"; + +/** Renders the archive action for a document, which asks for confirmation first. */ +export function DocumentArchive({ projectId, documentId }: { projectId: string; documentId: string }) { + const [state, action, pending] = useActionState(archiveDocument.bind(null, projectId, documentId), { error: null }); + const [confirming, setConfirming] = useState(false); + + return ( +
+

Archive document

+

An archived document leaves the list but can still be opened and downloaded by its address. Archiving cannot be undone.

+ {state.error &&

{state.error}

} + {confirming ? ( +
+ + +
+ ) : ( + + )} +
+ ); +} diff --git a/app/web/src/components/document-upload-form.tsx b/app/web/src/components/document-upload-form.tsx new file mode 100644 index 0000000..a6883e2 --- /dev/null +++ b/app/web/src/components/document-upload-form.tsx @@ -0,0 +1,64 @@ +"use client"; + +import Link from "next/link"; +import { useActionState, useState, type ChangeEvent, type FormEvent } from "react"; +import type { DocumentFormState } from "@/app/projects/document-actions"; +import { DOCUMENT_ACCEPT, DOCUMENT_TITLE_MAX, documentFileError, MAX_DOCUMENT_BYTES } from "@/lib/documents"; + +type FormAction = (state: DocumentFormState, formData: FormData) => Promise; + +/** + * Uploads one document. The chosen file is checked in the browser first so a wrong type or an + * oversized file never leaves it; the server reports everything else next to the field. + * The title is kept when the server rejects the form; the file must be chosen again. + */ +export function DocumentUploadForm({ action, cancelHref }: { action: FormAction; cancelHref: string }) { + const [state, formAction, pending] = useActionState(action, { errors: null }); + const [localFileError, setLocalFileError] = useState(null); + const [title, setTitle] = useState(""); + const fileError = localFileError ?? state.errors?.file; + const titleError = state.errors?.title; + + /** Checks the newly chosen file, clearing the message when the selection is acceptable. */ + function handleFile(event: ChangeEvent) { + const file = event.currentTarget.files?.[0]; + setLocalFileError(file ? documentFileError(file.name, file.size) : null); + } + + /** Blocks submission while the chosen file is known to be unacceptable. */ + function handleSubmit(event: FormEvent) { + const file = new FormData(event.currentTarget).get("file"); + const error = file instanceof File && file.name ? documentFileError(file.name, file.size) : "Choose a file to upload."; + if (error) { + event.preventDefault(); + setLocalFileError(error); + } + } + + return ( +
+ {state.errors?.form &&

{state.errors.form}

} +
+ + +

PDF, DOC, or DOCX, up to {MAX_DOCUMENT_BYTES / (1024 * 1024)} MB.

+ {fileError && } +
+
+ + setTitle(event.target.value)} + aria-invalid={titleError ? true : undefined} + aria-describedby={titleError ? "document-title-help document-title-error" : "document-title-help"} /> +

Defaults to the file name.

+ {titleError && } +
+
+ + Cancel +
+
+ ); +} diff --git a/app/web/src/components/project-tabs.tsx b/app/web/src/components/project-tabs.tsx index 9363642..026fc45 100644 --- a/app/web/src/components/project-tabs.tsx +++ b/app/web/src/components/project-tabs.tsx @@ -6,6 +6,7 @@ import { usePathname } from "next/navigation"; const tabs = [ { label: "Overview", segment: "" }, { label: "Requirements", segment: "/requirements" }, + { label: "Documentation", segment: "/documents" }, { label: "Settings", segment: "/settings" }, ] as const; diff --git a/app/web/src/lib/api.ts b/app/web/src/lib/api.ts index 6ac8c6e..e87aa67 100644 --- a/app/web/src/lib/api.ts +++ b/app/web/src/lib/api.ts @@ -7,7 +7,7 @@ import { auth } from "./auth"; * Calls the SpecThread API as the signed-in user (ADR-024). Server-only: use it from * server components, server actions, or route handlers, never from client components. * Exchanges the Better Auth session for a short-lived JWT and sends it as a bearer - * token. Returns the raw Response so callers handle 400/403/404/409 themselves; + * token. String bodies are sent as JSON. Returns the raw Response so callers handle 400/403/404/409 themselves; * see docs/API.md. Throws when SPECTHREAD_API_URL is not configured or there is no session. * Within one server render every call shares a single token exchange. * @@ -23,7 +23,8 @@ export async function apiFetch(path: `/${string}`, init: RequestInit = {}, incom const { token } = incomingHeaders ? await auth.api.getToken({ headers: incomingHeaders }) : await requestToken(); const requestHeaders = new Headers(init.headers); requestHeaders.set("authorization", `Bearer ${token}`); - if (init.body !== undefined && !requestHeaders.has("content-type")) { + // FormData bodies need fetch to set the multipart boundary, so only JSON strings get a type. + if (typeof init.body === "string" && !requestHeaders.has("content-type")) { requestHeaders.set("content-type", "application/json"); } const send = () => fetch(url, { ...init, headers: requestHeaders, cache: "no-store", signal: withTimeout(init.signal) }); diff --git a/app/web/src/lib/documents.ts b/app/web/src/lib/documents.ts new file mode 100644 index 0000000..b14b6a1 --- /dev/null +++ b/app/web/src/lib/documents.ts @@ -0,0 +1,80 @@ +/** A project reference document's metadata as the API returns it (docs/API.md, ADR-047). */ +export type ProjectDocument = { + id: string; + projectId: string; + title: string; + fileName: string; + contentType: string; + sizeBytes: number; + uploadedBy: string; + uploadedByName: string; + uploadedAt: string; + archivedAt: string | null; +}; + +/** Matches the API limit, which keeps uploads and downloads under Vercel's 4.5 MB function body limit. */ +export const MAX_DOCUMENT_BYTES = 4 * 1024 * 1024; +export const DOCUMENT_TITLE_MAX = 200; + +const formats = { + ".pdf": { label: "PDF", contentType: "application/pdf" }, + ".doc": { label: "DOC", contentType: "application/msword" }, + ".docx": { label: "DOCX", contentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document" }, +} as const; + +/** The file input's accept list: extensions plus MIME types, so pickers filter on any platform. */ +export const DOCUMENT_ACCEPT = Object.entries(formats).flatMap(([extension, { contentType }]) => [extension, contentType]).join(","); + +/** + * Returns a message when a chosen file can't be uploaded, otherwise null. This is a quick + * check for the form; the API also verifies the file's contents and remains the authority. + */ +export function documentFileError(name: string, size: number): string | null { + if (size <= 0) return "Choose a file to upload."; + if (size > MAX_DOCUMENT_BYTES) return `Use a file of at most ${MAX_DOCUMENT_BYTES / (1024 * 1024)} MB.`; + if (!(extensionOf(name) in formats)) return "Upload a PDF, DOC, or DOCX file."; + return null; +} + +/** Returns the short label for a document's content type, such as "PDF", or "File" when unknown. */ +export function documentTypeLabel(contentType: string) { + return Object.values(formats).find(format => format.contentType === contentType)?.label ?? "File"; +} + +/** Formats a byte count as B, KB, or MB with one decimal place above 1 KB. */ +export function formatFileSize(bytes: number) { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +const isDate = (value: unknown): value is string => typeof value === "string" && !Number.isNaN(Date.parse(value)); + +/** Validates one document from an API response. Throws when the shape is not the documented one. */ +export function parseDocument(value: unknown): ProjectDocument { + const item = value as Record | null; + if (typeof item !== "object" || item === null || typeof item.id !== "string" || typeof item.projectId !== "string" || + typeof item.title !== "string" || typeof item.fileName !== "string" || typeof item.contentType !== "string" || + typeof item.sizeBytes !== "number" || !Number.isInteger(item.sizeBytes) || item.sizeBytes < 1 || + typeof item.uploadedBy !== "string" || typeof item.uploadedByName !== "string" || !isDate(item.uploadedAt) || + (item.archivedAt !== null && !isDate(item.archivedAt))) { + throw new Error("The API returned an unexpected document."); + } + return { + id: item.id, projectId: item.projectId, title: item.title, fileName: item.fileName, contentType: item.contentType, + sizeBytes: item.sizeBytes, uploadedBy: item.uploadedBy, uploadedByName: item.uploadedByName, + uploadedAt: item.uploadedAt, archivedAt: item.archivedAt, + }; +} + +/** Validates a document list from an API response. Throws when it is not an array of documents. */ +export function parseDocuments(value: unknown): ProjectDocument[] { + if (!Array.isArray(value)) throw new Error("The API returned an unexpected document list."); + return value.map(parseDocument); +} + +/** Returns the lowercase extension including the dot, or "" when the name has none. */ +function extensionOf(name: string) { + const dot = name.lastIndexOf("."); + return dot < 0 ? "" : name.slice(dot).toLowerCase(); +} diff --git a/app/web/src/lib/project-data.ts b/app/web/src/lib/project-data.ts index d2e447b..ccd6791 100644 --- a/app/web/src/lib/project-data.ts +++ b/app/web/src/lib/project-data.ts @@ -7,6 +7,7 @@ import { isUuid, parseMembers, parseProject, parseRequirementSummaries, type Project, type ProjectMember, type RequirementSummary, } from "./projects"; +import { parseDocument, parseDocuments, type ProjectDocument } from "./documents"; import { parseEvidenceList, type Evidence } from "./evidence"; import { parseRequirement, type Requirement } from "./requirements"; import { parseReviews, type Review } from "./reviews"; @@ -75,3 +76,22 @@ export const listReviews = cache(async (requirementId: string): Promise => { + const body = isUuid(projectId) ? await read(`/projects/${projectId}/documents`) : null; + if (body === null) notFound(); + return parseDocuments(body); +}); + +/** + * Loads a document of the given project, or shows the not-found page when it does not exist, + * the user is not a member, or it belongs to a different project than the address says. + */ +export const requireDocument = cache(async (projectId: string, documentId: string): Promise => { + const body = isUuid(documentId) ? await read(`/documents/${documentId}`) : null; + if (body === null) notFound(); + const document = parseDocument(body); + if (document.projectId !== projectId) notFound(); + return document; +}); diff --git a/docs/API.md b/docs/API.md index 1e6a488..150f79a 100644 --- a/docs/API.md +++ b/docs/API.md @@ -62,6 +62,18 @@ to the project's team; non-members get 404 as if the project did not exist. | DELETE | `/requirements/{requirementId}/evidence/{evidenceId}` | member | | 204 | | GET | `/requirements/{requirementId}/reviews` | member | | 200 `Review[]` (newest first) | | POST | `/requirements/{requirementId}/reviews` | member, not the requirement's author | `{ decision, note?, version }` | 201 `Review` | +| GET | `/projects/{projectId}/documents` | member | | 200 `Document[]` (active only, newest first) | +| POST | `/projects/{projectId}/documents` | member | multipart: `file`, optional `title` | 201 `Document` | +| GET | `/documents/{documentId}` | member | | 200 `Document` (archived included) | +| GET | `/documents/{documentId}/content` | member | | 200 file bytes, as an attachment | +| POST | `/documents/{documentId}/archive` | member | | 200 `Document` (idempotent) | + +Documents (ADR-047) are PDF, DOC, or DOCX files of at most 4 MiB. The API checks the +file's leading bytes against its extension and stores the content type for that +extension. A missing title becomes the file name without its extension. Upload with +`apiFetch(path, { method: "POST", body: formData })`; `apiFetch` sets a JSON content type +only for string bodies. Downloads stay available for archived documents and projects; the +web streams them through `/projects/{projectId}/documents/{documentId}/download`. `reference` is an issue or pull request number (`42` or `#42`), a commit SHA of 7 to 40 hex digits, a release tag, or the github.com address of any of them, in the @@ -126,6 +138,10 @@ type Requirement = Omit & { description: string; createdBy: string; acceptanceCriteria: { id: string; text: string; position: number }[]; }; +type Document = { + id: string; projectId: string; title: string; fileName: string; contentType: string; sizeBytes: number; + uploadedBy: string; uploadedByName: string; uploadedAt: string; archivedAt: string | null; +}; type ProjectMember = { userId: string; name: string; email: string; joinedAt: string; isOwner: boolean; role: "owner" | "admin" | "member" }; type AvailableRepository = { installationId: number; repositoryId: number; owner: string; name: string; fullName: string; isPrivate: boolean; @@ -179,7 +195,7 @@ Errors are problem details (`application/problem+json`) with `title`, `status`, | Status | Meaning | Suggested UI | |---|---|---| -| 400 | Validation failed; `errors` maps fields such as `title` or `acceptanceCriteria[2]` to messages | Show the messages next to the fields | +| 400 | Validation failed; `errors` maps fields such as `title`, `file`, or `acceptanceCriteria[2]` to messages | Show the messages next to the fields | | 401 | Missing or invalid token | Send the user to sign in | | 403 | Insufficient team role, a decision by the requirement's author, or the account record is missing | Show the `detail` | | 404 | Not found, or not a member | Show a not-found state | @@ -187,7 +203,9 @@ Errors are problem details (`application/problem+json`) with `title`, `status`, | 410 | Individual project membership writes retired | Manage membership through the team | Limits: project names and requirement titles 1-200 characters, descriptions up to 10,000, -up to 50 acceptance criteria of 1-2,000 characters each, and emails up to 254 characters. +up to 50 acceptance criteria of 1-2,000 characters each, emails up to 254 characters, +document titles up to 200 characters, and file names up to 255. An upload body well over +4 MiB returns 413. ## Teams navigation and onboarding (ADR-039/040) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6c5e263..08212c8 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -99,6 +99,8 @@ Requirements: requirement text, acceptance criteria, lifecycle, and ownership. Evidence: normalized links to issues, pull requests, commits, checks, tests, and releases. +Documents: project reference files (PDF, DOC, DOCX) that team members upload and download (ADR-047). + Integrations: GitHub authentication, API calls, webhook validation, synchronization, and rate-limit handling. Verification: configured check definitions and recorded results. @@ -159,7 +161,7 @@ Better Auth runs in Next.js with authentication-only database access. Users sign A deny-by-default Next.js proxy validates the Better Auth session for every non-public page and redirects signed-out visitors to login with the requested page in `next` (ADR-023). Login and signup redirect an existing session to that validated page, defaulting to the dashboard. The route scaffold links placeholder pages with example IDs until product data and API membership authorization are implemented. -The API exposes anonymous GET /health returning { "status": "ok" } and authenticated GET /me returning { "userId": "" }. Projects, requirements, and acceptance criteria are created, read, edited, and archived through /projects and /requirements with inherited team-membership authorization (ADR-040, docs/API.md). Project membership writes are retired; joining a team requires invitation acceptance. Next.js server code calls them through `apiFetch` with the user's JWT. The invitation token preview is also anonymous; all mutations and other product endpoints require a valid Better Auth JWT by default (ADR-015). /health is a liveness check, not database readiness. Development exposes /openapi/v1.json; production does not. EF Core maps six auth tables (user, session, account, verification, jwks, rateLimit) and eight product tables in public, and owns the InitialSchema, AuthRateLimits, Teams, TeamNavigationOnboarding, TeamProjects, and TeamInvitations migrations and future migrations. Startup does not create, migrate, or query the database. Database use fails explicitly if ConnectionStrings:Database is missing. RLS and revoked browser-role grants deny Data API access by default; this does not replace server-side project authorization. Supabase-managed schemas must not be modified. See DATABASE.md. +The API exposes anonymous GET /health returning { "status": "ok" } and authenticated GET /me returning { "userId": "" }. Projects, requirements, and acceptance criteria are created, read, edited, and archived through /projects and /requirements with inherited team-membership authorization (ADR-040, docs/API.md). Project membership writes are retired; joining a team requires invitation acceptance. Members upload, list, download, and archive project documents of up to 4 MiB through /projects/{id}/documents and /documents (ADR-047). Next.js server code calls them through `apiFetch` with the user's JWT. The invitation token preview is also anonymous; all mutations and other product endpoints require a valid Better Auth JWT by default (ADR-015). /health is a liveness check, not database readiness. Development exposes /openapi/v1.json; production does not. EF Core maps six auth tables (user, session, account, verification, jwks, rateLimit) and thirteen product tables in public, and owns every migration in app/api/Migrations (see DATABASE.md) and future migrations. Startup does not create, migrate, or query the database. Database use fails explicitly if ConnectionStrings:Database is missing. RLS and revoked browser-role grants deny Data API access by default; this does not replace server-side project authorization. Supabase-managed schemas must not be modified. See DATABASE.md. Teams (ADR-039/040) adds member-only reads, atomic creation, a real Members table, and personal favorite/expansion preferences. Your teams has expandable groups with diff --git a/docs/DATABASE.md b/docs/DATABASE.md index b095103..9967ba1 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -13,9 +13,9 @@ dotnet tool restore dotnet build app/api --configuration Release ``` -`SpecThreadDbContext` maps six Better Auth tables and eight product tables in -`public`, with the InitialSchema, AuthRateLimits, Teams, TeamNavigationOnboarding, -TeamProjects, and TeamInvitations migrations. Startup never calls `EnsureCreated`, `Migrate`, or a database +`SpecThreadDbContext` maps six Better Auth tables and thirteen product tables in +`public`, with the migrations listed under "Combined migration order" below, ending +with ProjectDocuments. Startup never calls `EnsureCreated`, `Migrate`, or a database query. `/health` checks process liveness only. Resolving the context without `ConnectionStrings:Database` fails explicitly. Playwright verifies this failure and provider initialization with dummy credentials, without a database connection. @@ -325,7 +325,29 @@ evidence and review tables refer only to projects, requirements, and users. Roll back by script (docs/schema/*-rollback.sql) for one migration at a time: `dotnet ef database update ` would also undo the other branch's migrations that come later in time. scripts/test-database.mjs applies the teams scripts first -and then the evidence and review scripts. +and then the evidence and review scripts. ProjectDocuments comes after all of them. + +## Project documents migration + +ProjectDocuments (ADR-047) adds `documents` (metadata, with check constraints on title, +file name, content type, and a 1 to 4,194,304 byte size) and `document_contents` (the +bytea file, keyed by document ID). Both reference their parents with restrict deletes, +have RLS enabled with no allow policies, and revoke PUBLIC, anon, and authenticated +grants. + +```sh +dotnet ef migrations add ProjectDocuments --project app/api --configuration Release +node scripts/generate-project-documents.mjs +``` + +The second command adds the RLS and grant statements and generates +docs/schema/project-documents.sql and project-documents-rollback.sql. + +CI applies it after the merge, before the API that needs it is deployed (ADR-044); it +only adds tables, so the running API is unaffected. Rollback: +`dotnet ef database update RequirementReviews --project app/api`, or the rollback +script. It drops both tables and permanently deletes every uploaded file, so export +them first. ## Supabase tooling diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 2bbc601..4c39ccc 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -1088,6 +1088,48 @@ Consequences: Fewer cross-country round trips per page, and a brief failure cost show its error. Vercel's `DATABASE_URL` must point at port 6543; the region must follow the database and API if they move. +ADR-047: Project reference documents stored in PostgreSQL, in a Documentation tab + +Status: Accepted + +Context: Teams need one place in each project for reference documents (PDF, DOC, DOCX) +that everyone on the project's team can read; requirements will cite them in a later +task. The user chose PostgreSQL storage over Supabase Storage to avoid a new service and +secret. The web runs on Vercel, whose functions accept and return at most 4.5 MB bodies, +and browsers reach the API only through Next.js (ADR-024). + +Decision: Metadata lives in `documents` and the bytes in `document_contents` (bytea, one +row per document), so lists never read file contents. Files are at most 4 MiB +(4,194,304 bytes) and must be .pdf, .doc, or .docx; the API checks that the file starts +with that format's signature (`%PDF-`, the OLE header, or the ZIP header) and stores a +content type derived from the extension, never the client's. File names lose any path +and control characters and are at most 255 characters; titles are optional (default: +the file name without its extension), at most 200. Database check constraints repeat +the type and size rules. Any member of the project's team uploads (multipart +POST /projects/{id}/documents), lists, downloads, and archives, like requirements +(ADR-041); writes lock the project's team as other project writes do. There is no +delete. Archived documents leave the list but stay downloadable, and archived projects +refuse uploads and archiving (409). Downloads are attachments with +`X-Content-Type-Options: nosniff` and `Cache-Control: private, no-store`. The upload +endpoint disables antiforgery because the API authenticates bearer tokens, not cookies. + +In the web, Documentation is a project tab (ADR-028) with a list, an upload page, and a +page per document with its download link and the archive action, which asks for +confirmation. A Next.js route handler streams downloads and answers 404 when the +document belongs to a different project than the address says. Server actions accept +5 MB bodies for the upload form. `apiFetch` sets a JSON content type only for string +bodies, so multipart uploads keep their boundary. + +Consequences: Files count against the database size (500 MB on Supabase's free plan), +and archiving frees nothing; a size report or purge job may be needed later. Larger +files need direct-to-storage uploads, which would supersede this decision. DOC and DOCX +contents beyond the header are not inspected, and files are not virus scanned. A +download reads the whole file into API memory, which the size limit bounds, and costs +two API calls from the web (metadata, then content). Requirement references to +documents, search, renaming, and unarchiving are future work. Migration +ProjectDocuments adds both tables with RLS on and browser grants revoked; CI applies it +(ADR-044). + ADR-NNN: Title Status: Proposed, Accepted, Superseded, or Rejected diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index e88f2ce..23eb73e 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -1,6 +1,72 @@ # Shared handoff -## Current task: Intermittent page errors and slow loads (2026-10-18) +## Current task: Project documentation tab (2026-10-18) + +- Branch: `feature/project-docs`, rebuilt on `origin/main` at `35b407c`. The first version + was written before the teams, tabs, and black-and-white work reached `main`; it is kept + locally as `backup/project-docs-v1` and was ported, not merged. The pre-existing + uncommitted deletion of `app/api/.env.example` is unrelated and was left untouched. +- Completed (ADR-047): + - The API lets any member of a project's team upload PDF, DOC, and DOCX files of up to + 4 MiB, list them newest first, download them, and archive them. Writes lock the team + like other project writes. + - The API checks each file's leading bytes against its extension. Non-members get 404. + Archived projects refuse uploads and archiving with 409. + - Web: a Documentation project tab, between Requirements and Settings. It has a list + (empty state; no upload button on archived projects) and an upload page that checks + files in the browser and shows the server's messages. + - A page per document shows its details, a Download link, and archive with + confirmation. A route streams downloads and answers 404 when the address names + another project. + - The user asked for a project "sub menu"; on `main` project sections are tabs + (ADR-028), so it is a tab. + - Tickets (bug, issue or task, and user story, with states) were also requested. + They are deferred to a separate task. +- Changed files: + - API: `app/api/Documents/DocumentEndpoints.cs` (new), `Data/ProductModels.cs`, + `Data/SpecThreadDbContext.cs`, `Program.cs`, migration `*_ProjectDocuments*`, and + the model snapshot. + - Schema: `scripts/generate-project-documents.mjs` (new), generated + `docs/schema/project-documents.sql` and `project-documents-rollback.sql`, + `scripts/test-database.mjs`. + - Web, new: `lib/documents.ts`, `app/projects/document-actions.ts`, + `components/document-upload-form.tsx`, `components/document-archive.tsx`, and + `app/projects/[projectId]/documents/**`. + - Web, changed: `lib/project-data.ts` (`listDocuments`, `requireDocument`), + `lib/api.ts` (JSON content type only for string bodies), `components/project-tabs.tsx`, + `app-shell.css`, and `next.config.ts` (server action body limit 5 MB). + - Tests: `tests/schema/documents-api.spec.ts`, `tests/api/document-files.spec.ts`, + `tests/e2e/documents.spec.ts` (new), and `tests/api/products.spec.ts`. + - Tests, adjusted: `tests/e2e/projects.spec.ts` (tab list); the migration-history + counts in `tests/schema/evidence.spec.ts`, `github-repository.spec.ts`, and + `reviews.spec.ts`, each one higher; `scripts/test-api-proxy.mjs`, which now forwards + `Content-Disposition`. + - Docs: DECISIONS (ADR-047), API, DATABASE, ARCHITECTURE, PROJECT (MVP scope), TESTING. + DATABASE and ARCHITECTURE said "eight product tables"; the model has thirteen with + this change (eleven before it), so both now say thirteen. +- Decisions and assumptions: + - Files are stored as bytea in PostgreSQL (the user's choice). The 4 MiB limit + follows from Vercel's 4.5 MB function body limit. + - Access follows team membership like requirements, so any team Member may archive. + - Documents are archived, never deleted. There is no unarchive yet. +- Checks: + - Passed: `dotnet format app/api --verify-no-changes`, `npm run lint`, + `npm run typecheck`, `dotnet ef migrations has-pending-model-changes` (none), and + `git diff --check`. + - `npm test`: 341 passed on the last two full runs. + - The first full run after the port also failed `documents-api` "uploads are + checked…" and `auth-jwt-runtime` once each. Their errors were not captured, and + neither recurred in two full runs or in 15 repeats of the documents spec in one worker. +- Known issues and risks: + - CI applies ProjectDocuments on merge (ADR-044); rollback deletes every uploaded file. + - Files use database space (500 MB on the free plan), and archiving frees none. + - There is no virus scanning, and DOC/DOCX contents past the header are not inspected. + - After a server-side rejection the file input is cleared (React's form reset); the + title is kept. +- Next step: review and merge the PR. Follow-ups are citing documents from requirements, + and Tickets. + +## Previous task: Intermittent page errors and slow loads (2026-10-18) - Branch: `fix/onboarding-gate-failure` (PR #19, at the user's request, together with the gate fix below). diff --git a/docs/PROJECT.md b/docs/PROJECT.md index 7a5927e..07922c8 100644 --- a/docs/PROJECT.md +++ b/docs/PROJECT.md @@ -58,6 +58,8 @@ creating, viewing, editing, and archiving requirements; writing testable acceptance criteria; +uploading project reference documents (PDF, DOC, DOCX) that every member of the project's team can download (ADR-047); citing them from requirements comes later; + connecting one GitHub repository; linking GitHub issues and pull requests to a requirement; diff --git a/docs/TESTING.md b/docs/TESTING.md index 9406063..e799844 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -68,7 +68,7 @@ which the release evidence test uses to check that stable answers are not asked web app at 127.0.0.1:3100, a test-only JWKS issuer (scripts/start-test-jwks.mjs) at 127.0.0.1:5101, and the API in Development at 127.0.0.1:5100 trusting that issuer. Auth tests also start extra API instances on 5102-5104, and product API tests on 5105. -The Teams API tests use 5116, team management 5117, and invitations 5118 (moved from +The Teams API tests use 5116, team management 5117, invitations 5118, and documents 5119 (moved from 5107 to 5109 when the branches were merged, because the proxy and GitHub stand-ins below already use those). scripts/start-test-web.mjs also starts the API on 5106 for browser tests. That instance uses @@ -238,6 +238,15 @@ currently require network access during the web build. removal, inactive legacy memberships, retired membership writes, isolated team project/archive lists, and active requirement counts. Ordered criteria/stale-version and archived-item checks remain. OpenAPI and anonymous checks run without a DB. +- Documents (schema suite, API on 5119 against Docker Postgres): PDF, DOC, and DOCX + upload; newest-first lists; byte-identical downloads with attachment and nosniff + headers; idempotent archive; rejection of wrong types, mismatched contents, empty, + oversized (400) and far oversized (413) files and long titles; 404 for non-members; + 409 for archived projects; table constraints and RLS. Browser tests cover the + Documentation tab, upload, download, archive with confirmation, the form's browser + and server messages, a failed upload, archived projects, and a document opened + under another project's address. The test API proxy forwards `Content-Disposition` + so downloads keep their file names. - Teams: real API-backed creation, search, empty/error/retry and member-only access; reference Home, independent saved sidebar groups, personal favorites sorting first, keyboard team menu, and desktop/mobile screenshots. Required onboarding resumes diff --git a/docs/schema/project-documents-rollback.sql b/docs/schema/project-documents-rollback.sql new file mode 100644 index 0000000..e773a65 --- /dev/null +++ b/docs/schema/project-documents-rollback.sql @@ -0,0 +1,10 @@ +START TRANSACTION; +DROP TABLE public.document_contents; + +DROP TABLE public.documents; + +DELETE FROM "__EFMigrationsHistory" +WHERE "MigrationId" = '20261018234943_ProjectDocuments'; + +COMMIT; + diff --git a/docs/schema/project-documents.sql b/docs/schema/project-documents.sql new file mode 100644 index 0000000..e817eab --- /dev/null +++ b/docs/schema/project-documents.sql @@ -0,0 +1,59 @@ +START TRANSACTION; +CREATE TABLE public.documents ( + id uuid NOT NULL DEFAULT (gen_random_uuid()), + project_id uuid NOT NULL, + title text NOT NULL, + file_name text NOT NULL, + content_type text NOT NULL, + size_bytes bigint NOT NULL, + uploaded_by text NOT NULL, + uploaded_at timestamp with time zone NOT NULL DEFAULT (now()), + archived_at timestamp with time zone, + CONSTRAINT "PK_documents" PRIMARY KEY (id), + CONSTRAINT ck_documents_content_type CHECK (content_type IN ('application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document')), + CONSTRAINT ck_documents_file_name CHECK (length(btrim(file_name)) > 0), + CONSTRAINT ck_documents_size CHECK (size_bytes BETWEEN 1 AND 4194304), + CONSTRAINT ck_documents_title CHECK (length(btrim(title)) > 0), + CONSTRAINT "FK_documents_projects_project_id" FOREIGN KEY (project_id) REFERENCES public.projects (id) ON DELETE RESTRICT, + CONSTRAINT "FK_documents_user_uploaded_by" FOREIGN KEY (uploaded_by) REFERENCES public."user" (id) ON DELETE RESTRICT +); + +CREATE TABLE public.document_contents ( + document_id uuid NOT NULL, + content bytea NOT NULL, + CONSTRAINT "PK_document_contents" PRIMARY KEY (document_id), + CONSTRAINT "FK_document_contents_documents_document_id" FOREIGN KEY (document_id) REFERENCES public.documents (id) ON DELETE RESTRICT +); + +CREATE INDEX "IX_documents_project_id" ON public.documents (project_id); + +CREATE INDEX "IX_documents_uploaded_by" ON public.documents (uploaded_by); + +ALTER TABLE public."documents" ENABLE ROW LEVEL SECURITY; +REVOKE ALL ON TABLE public."documents" FROM PUBLIC; +DO $security$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN + REVOKE ALL ON TABLE public."documents" FROM anon; + END IF; + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN + REVOKE ALL ON TABLE public."documents" FROM authenticated; + END IF; +END $security$; +ALTER TABLE public."document_contents" ENABLE ROW LEVEL SECURITY; +REVOKE ALL ON TABLE public."document_contents" FROM PUBLIC; +DO $security$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN + REVOKE ALL ON TABLE public."document_contents" FROM anon; + END IF; + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN + REVOKE ALL ON TABLE public."document_contents" FROM authenticated; + END IF; +END $security$; + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20261018234943_ProjectDocuments', '10.0.12'); + +COMMIT; + diff --git a/scripts/generate-project-documents.mjs b/scripts/generate-project-documents.mjs new file mode 100644 index 0000000..b0d90d6 --- /dev/null +++ b/scripts/generate-project-documents.mjs @@ -0,0 +1,38 @@ +// Run after scaffolding ProjectDocuments with EF. No database connection is opened. +import { execFileSync } from "node:child_process"; +import { readdirSync, readFileSync, writeFileSync } from "node:fs"; + +const directory = "app/api/Migrations"; +const file = `${directory}/${readdirSync(directory).find(name => name.endsWith("_ProjectDocuments.cs"))}`; +const source = readFileSync(file, "utf8"); +if (!source.includes("ENABLE ROW LEVEL SECURITY")) { + const position = source.lastIndexOf(" }", source.indexOf("protected override void Down")); + if (position < 0) throw new Error("Unexpected migration format"); + // RLS is not expressible in EF's relational model, so it is added to the migration here. + const statements = ["documents", "document_contents"].map(table => ` ALTER TABLE public."${table}" ENABLE ROW LEVEL SECURITY; + REVOKE ALL ON TABLE public."${table}" FROM PUBLIC; + DO $security$ + BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN + REVOKE ALL ON TABLE public."${table}" FROM anon; + END IF; + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN + REVOKE ALL ON TABLE public."${table}" FROM authenticated; + END IF; + END $security$;`).join("\n"); + const security = ` + migrationBuilder.Sql(""" +${statements} + """); +`; + writeFileSync(file, source.slice(0, position) + security + source.slice(position)); +} +const env = { ...process.env, ConnectionStrings__Database: "Host=127.0.0.1;Port=1;Database=offline;Username=placeholder;Password=placeholder" }; +for (const [from, to, output] of [ + ["RequirementReviews", "ProjectDocuments", "docs/schema/project-documents.sql"], + ["ProjectDocuments", "RequirementReviews", "docs/schema/project-documents-rollback.sql"], +]) { + execFileSync("dotnet", ["ef", "migrations", "script", from, to, "--project", "app/api", "--configuration", "Release", "--output", output], { env, stdio: "inherit" }); + const content = readFileSync(output, "utf8").replace(/^/, ""); + writeFileSync(output, content, "utf8"); +} diff --git a/scripts/test-api-proxy.mjs b/scripts/test-api-proxy.mjs index ed13af0..61c7b95 100644 --- a/scripts/test-api-proxy.mjs +++ b/scripts/test-api-proxy.mjs @@ -72,7 +72,7 @@ export function startApiProxy({ port, upstream }) { }); const answer = Buffer.from(await forwarded.arrayBuffer()); const answerHeaders = {}; - for (const name of ["content-type", "location", "www-authenticate"]) { + for (const name of ["content-type", "content-disposition", "location", "www-authenticate"]) { const value = forwarded.headers.get(name); if (value) answerHeaders[name] = value; } diff --git a/scripts/test-database.mjs b/scripts/test-database.mjs index 16da5c6..d7a7046 100644 --- a/scripts/test-database.mjs +++ b/scripts/test-database.mjs @@ -53,6 +53,7 @@ export async function startTestDatabase() { await pool.query(await readSql("docs/schema/evidence-checks.sql")); await pool.query(await readSql("docs/schema/evidence-releases.sql")); await pool.query(await readSql("docs/schema/requirement-reviews.sql")); + await pool.query(await readSql("docs/schema/project-documents.sql")); return { name, connectionString, pool, stop }; } catch (error) { await stop(); diff --git a/tests/api/document-files.spec.ts b/tests/api/document-files.spec.ts new file mode 100644 index 0000000..6808076 --- /dev/null +++ b/tests/api/document-files.spec.ts @@ -0,0 +1,40 @@ +import { expect, test } from "@playwright/test"; +import { + DOCUMENT_ACCEPT, documentFileError, documentTypeLabel, formatFileSize, MAX_DOCUMENT_BYTES, parseDocument, parseDocuments, +} from "../../app/web/src/lib/documents"; + +// Browser-independent document helpers (ADR-047); upload behavior is covered in tests/schema/documents-api.spec.ts. +const document = { + id: "d1", projectId: "p1", title: "Brief", fileName: "brief.pdf", contentType: "application/pdf", sizeBytes: 12, + uploadedBy: "u1", uploadedByName: "Ada", uploadedAt: "2026-10-18T23:30:00Z", archivedAt: null, +}; + +test("the upload form accepts PDF, DOC, and DOCX files within the size limit", () => { + for (const name of ["spec.pdf", "Notes.DOC", "plan.final.docx"]) expect(documentFileError(name, 10), name).toBeNull(); + expect(documentFileError("spec.pdf", MAX_DOCUMENT_BYTES)).toBeNull(); + expect(documentFileError("spec.pdf", MAX_DOCUMENT_BYTES + 1)).toBe("Use a file of at most 4 MB."); + expect(documentFileError("spec.pdf", 0)).toBe("Choose a file to upload."); + for (const name of ["notes.txt", "pdf", "archive.pdf.zip", "spec."]) { + expect(documentFileError(name, 10), name).toBe("Upload a PDF, DOC, or DOCX file."); + } + expect(DOCUMENT_ACCEPT.split(",")).toEqual(expect.arrayContaining([".pdf", ".doc", ".docx", "application/pdf"])); +}); + +test("document metadata is formatted for display", () => { + expect(formatFileSize(512)).toBe("512 B"); + expect(formatFileSize(1536)).toBe("1.5 KB"); + expect(formatFileSize(MAX_DOCUMENT_BYTES)).toBe("4.0 MB"); + expect(documentTypeLabel("application/pdf")).toBe("PDF"); + expect(documentTypeLabel("application/vnd.openxmlformats-officedocument.wordprocessingml.document")).toBe("DOCX"); + expect(documentTypeLabel("text/plain")).toBe("File"); +}); + +test("API documents are validated before they are rendered", () => { + expect(parseDocument({ ...document, extra: "ignored" })).toEqual(document); + expect(parseDocuments([document, { ...document, id: "d2", archivedAt: "2026-10-19T00:00:00Z" }])).toHaveLength(2); + for (const broken of [null, "x", { ...document, sizeBytes: 0 }, { ...document, sizeBytes: 1.5 }, { ...document, title: 3 }, + { ...document, uploadedAt: "yesterday" }, { ...document, archivedAt: undefined }]) { + expect(() => parseDocument(broken)).toThrow("The API returned an unexpected document."); + } + expect(() => parseDocuments({})).toThrow("The API returned an unexpected document list."); +}); diff --git a/tests/api/products.spec.ts b/tests/api/products.spec.ts index 80eb668..867e7a1 100644 --- a/tests/api/products.spec.ts +++ b/tests/api/products.spec.ts @@ -8,7 +8,10 @@ test("OpenAPI describes the product endpoints", async ({ request }) => { "/projects/{projectId}/requirements", "/requirements/{requirementId}", "/requirements/{requirementId}/archive", "/projects/{projectId}/members", "/projects/{projectId}/members/{userId}", "/github/repositories", "/projects/{projectId}/repository", "/requirements/{requirementId}/reviews", + "/projects/{projectId}/documents", "/documents/{documentId}", "/documents/{documentId}/content", + "/documents/{documentId}/archive", ])); + expect(Object.keys(paths["/projects/{projectId}/documents"].post.requestBody.content)).toContain("multipart/form-data"); expect(paths["/requirements/{requirementId}"].put.responses).toEqual( expect.objectContaining({ "200": expect.anything(), "400": expect.anything(), "401": expect.anything(), "404": expect.anything(), "409": expect.anything() })); expect(paths["/projects/{projectId}"].patch.responses["403"]).toBeDefined(); @@ -22,5 +25,10 @@ test("product endpoints reject anonymous requests before touching the database", await request.put("/requirements/00000000-0000-0000-0000-000000000000", { data: { title: "x", version: 1 } }), await request.post("/github/repositories", { data: { githubToken: "x" } }), await request.get("/projects/00000000-0000-0000-0000-000000000000/repository"), + await request.get("/projects/00000000-0000-0000-0000-000000000000/documents"), + await request.post("/projects/00000000-0000-0000-0000-000000000000/documents", { + multipart: { file: { name: "a.pdf", mimeType: "application/pdf", buffer: Buffer.from("%PDF-1.4") } }, + }), + await request.get("/documents/00000000-0000-0000-0000-000000000000/content"), ]) expect(response.status()).toBe(401); }); diff --git a/tests/e2e/documents.spec.ts b/tests/e2e/documents.spec.ts new file mode 100644 index 0000000..0f0eee5 --- /dev/null +++ b/tests/e2e/documents.spec.ts @@ -0,0 +1,137 @@ +import type { Page } from "@playwright/test"; +import { readFile } from "node:fs/promises"; +import { createTestSession, createTestTeam, expect, runTestSql, test } from "./fixtures"; +import { clearApiFaults, failApi } from "../support/api-faults"; + +// The project's Documentation tab (ADR-047): uploading, reading, downloading, and archiving +// documents, and how those pages behave when the API refuses or fails. Each test uses its own user. +let owner: Awaited>; +test.use({ signedIn: false }); +test.beforeEach(async ({ context }) => { + owner = await createTestSession("Document tester"); + await createTestTeam(owner.userId); + await context.addCookies([owner.cookie]); +}); +test.afterEach(() => clearApiFaults(owner.userId)); + +const pdf = Buffer.from("%PDF-1.4\n1 0 obj << /Type /Catalog >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n"); + +async function createProject(page: Page, name: string) { + await page.goto("/projects/new"); + await page.getByLabel("Project name").fill(name); + await page.getByRole("button", { name: "Create project" }).click(); + await expect(page).toHaveURL(/\/projects\/[0-9a-f-]{36}$/); + return page.url().split("/").at(-1)!; +} + +/** Uploads a PDF through the form and returns the new document's ID from the page it opens. */ +async function uploadPdf(page: Page, projectId: string, name: string, title = "") { + await page.goto(`/projects/${projectId}/documents/new`); + await page.getByLabel("File", { exact: true }).setInputFiles({ name, mimeType: "application/pdf", buffer: pdf }); + if (title) await page.getByLabel("Title (optional)").fill(title); + await page.getByRole("button", { name: "Upload document" }).click(); + await expect(page).toHaveURL(/\/documents\/[0-9a-f-]{36}$/); + return page.url().split("/").at(-1)!; +} + +test("a team member uploads, downloads, and archives a document from the Documentation tab", async ({ page }, testInfo) => { + const projectId = await createProject(page, "Checkout"); + const tabs = page.getByRole("navigation", { name: "Project sections" }); + await tabs.getByRole("link", { name: "Documentation" }).click(); + await expect(page).toHaveURL(new RegExp(`/projects/${projectId}/documents$`)); + await expect(tabs.getByRole("link", { name: "Documentation" })).toHaveAttribute("aria-current", "page"); + await expect(page.getByText("This project has no documents yet.")).toBeVisible(); + + await page.getByRole("link", { name: "Upload document" }).click(); + await page.getByLabel("File", { exact: true }).setInputFiles({ name: "Product brief.pdf", mimeType: "application/pdf", buffer: pdf }); + await page.getByLabel("Title (optional)").fill("Checkout product brief"); + await page.getByRole("button", { name: "Upload document" }).click(); + + await expect(page.getByRole("heading", { name: "Checkout product brief", level: 1 })).toBeVisible(); + await expect(tabs.getByRole("link", { name: "Documentation" })).toHaveAttribute("aria-current", "page"); + await expect(page.getByText("Product brief.pdf")).toBeVisible(); + await expect(page.getByText(`${pdf.length} B`)).toBeVisible(); + await expect(page.getByText(/by Document tester$/)).toBeVisible(); + await page.screenshot({ path: testInfo.outputPath("document-page.png"), fullPage: true }); + + const [download] = await Promise.all([page.waitForEvent("download"), page.getByRole("link", { name: "Download" }).click()]); + expect(download.suggestedFilename()).toBe("Product brief.pdf"); + expect(Buffer.compare(await readFile((await download.path())!), pdf)).toBe(0); + + await page.getByRole("link", { name: "Documentation" }).first().click(); + await expect(page.getByRole("list", { name: "Documents" }).getByRole("link")).toHaveText([/^Checkout product brief.*PDF/]); + await page.screenshot({ path: testInfo.outputPath("documents-list.png"), fullPage: true }); + + await page.getByRole("list", { name: "Documents" }).getByRole("link").click(); + await page.getByRole("button", { name: "Archive document" }).click(); + await page.getByRole("button", { name: "Yes, archive this document" }).click(); + await expect(page).toHaveURL(new RegExp(`/projects/${projectId}/documents$`)); + await expect(page.getByText("This project has no documents yet.")).toBeVisible(); +}); + +test("the upload form rejects wrong files in the browser and shows the server's reasons", async ({ page }) => { + const projectId = await createProject(page, "Checkout"); + await page.goto(`/projects/${projectId}/documents/new`); + const form = page.getByRole("form", { name: "Upload document" }); + const file = page.getByLabel("File", { exact: true }); + await expect(file).toHaveAttribute("accept", /\.pdf,.*\.docx/); + + await form.getByRole("button", { name: "Upload document" }).click(); + await expect(form.getByRole("alert")).toHaveText("Choose a file to upload."); + + await file.setInputFiles({ name: "notes.txt", mimeType: "text/plain", buffer: Buffer.from("plain text") }); + await expect(form.getByRole("alert")).toHaveText("Upload a PDF, DOC, or DOCX file."); + await expect(file).toHaveAttribute("aria-invalid", "true"); + + await file.setInputFiles({ name: "large.pdf", mimeType: "application/pdf", buffer: Buffer.alloc(4 * 1024 * 1024 + 1) }); + await expect(form.getByRole("alert")).toHaveText("Use a file of at most 4 MB."); + + // Only the API reads the contents: a DOCX that isn't a ZIP passes the browser check. + await file.setInputFiles({ name: "fake.docx", mimeType: "application/octet-stream", buffer: Buffer.from("not a zip file") }); + await expect(form.getByRole("alert")).toHaveCount(0); + await page.getByLabel("Title (optional)").fill("Kept title"); + await form.getByRole("button", { name: "Upload document" }).click(); + await expect(form.getByRole("alert")).toHaveText("The file's contents don't match its type."); + await expect(page.getByLabel("Title (optional)")).toHaveValue("Kept title"); + await expect(page).toHaveURL(/\/documents\/new$/); +}); + +test("a failed upload keeps the user on the form with a general message", async ({ page }) => { + const projectId = await createProject(page, "Checkout"); + await failApi(owner.userId, { method: "POST", path: "/documents$", status: 500 }); + await page.goto(`/projects/${projectId}/documents/new`); + await page.getByLabel("File", { exact: true }).setInputFiles({ name: "brief.pdf", mimeType: "application/pdf", buffer: pdf }); + await page.getByRole("button", { name: "Upload document" }).click(); + await expect(page.getByRole("form", { name: "Upload document" }).getByRole("alert")) + .toHaveText("The document could not be uploaded. Please try again."); + await expect(page).toHaveURL(/\/documents\/new$/); +}); + +test("archived projects keep documents downloadable but take no new ones", async ({ page }) => { + const projectId = await createProject(page, "Checkout"); + const documentId = await uploadPdf(page, projectId, "brief.pdf", "Brief"); + await runTestSql(`UPDATE public.projects SET archived_at = now() WHERE id = '${projectId}'`); + + await page.goto(`/projects/${projectId}/documents`); + await expect(page.getByRole("list", { name: "Documents" }).getByRole("link")).toHaveCount(1); + await expect(page.getByRole("link", { name: "Upload document" })).toHaveCount(0); + await page.goto(`/projects/${projectId}/documents/new`); + await expect(page.getByText("This project is archived, so documents can't be added.")).toBeVisible(); + + await page.goto(`/projects/${projectId}/documents/${documentId}`); + await expect(page.getByRole("button", { name: "Archive document" })).toHaveCount(0); + const response = await page.request.get(`/projects/${projectId}/documents/${documentId}/download`); + expect(response.status()).toBe(200); + expect(response.headers()["content-disposition"]).toMatch(/^attachment; filename=brief\.pdf/); +}); + +test("a document opened under another project's address is not found", async ({ page }) => { + const first = await createProject(page, "Checkout"); + const documentId = await uploadPdf(page, first, "brief.pdf"); + const second = await createProject(page, "Billing"); + + await page.goto(`/projects/${second}/documents/${documentId}`); + await expect(page.getByRole("heading", { name: "Page not found" })).toBeVisible(); + expect((await page.request.get(`/projects/${second}/documents/${documentId}/download`)).status()).toBe(404); + expect((await page.request.get(`/projects/${second}/documents/not-an-id/download`)).status()).toBe(404); +}); diff --git a/tests/e2e/projects.spec.ts b/tests/e2e/projects.spec.ts index 120a5d9..73902c9 100644 --- a/tests/e2e/projects.spec.ts +++ b/tests/e2e/projects.spec.ts @@ -119,7 +119,7 @@ test("project tabs show its requirements, planned sections keep the frame, and t await expect(page.getByRole("list", { name: "Requirements" }).getByRole("link")).toHaveCount(2); // Membership will be managed on the team, so a project has no members page. - await expect(tabs.getByRole("link")).toHaveText(["Overview", "Requirements", "Settings"]); + await expect(tabs.getByRole("link")).toHaveText(["Overview", "Requirements", "Documentation", "Settings"]); await page.goto(`/projects/${projectId}/members`); await expect(page.getByRole("heading", { name: "Page not found" })).toBeVisible(); diff --git a/tests/schema/documents-api.spec.ts b/tests/schema/documents-api.spec.ts new file mode 100644 index 0000000..4647a36 --- /dev/null +++ b/tests/schema/documents-api.spec.ts @@ -0,0 +1,193 @@ +import { expect, test, type APIRequestContext } from "@playwright/test"; +import { startTestDatabase } from "../../scripts/test-database.mjs"; +import { startApi } from "../support/api-process"; + +// Project documents (ADR-047) against a disposable PostgreSQL database, with tokens from +// the test JWKS issuer that Playwright starts for every run (scripts/start-test-jwks.mjs). +const issuer = "http://127.0.0.1:5101"; +const MAX_BYTES = 4 * 1024 * 1024; +const pdf = Buffer.from("%PDF-1.4\n% SpecThread test document\n"); +const doc = Buffer.concat([Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]), Buffer.alloc(32)]); +const docx = Buffer.concat([Buffer.from([0x50, 0x4b, 0x03, 0x04]), Buffer.alloc(32)]); +const types = { + pdf: "application/pdf", + doc: "application/msword", + docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", +}; +let database: Awaited>; +let api: Awaited>; + +test.describe.configure({ mode: "serial" }); + +test.beforeAll(async () => { + test.setTimeout(180_000); + database = await startTestDatabase(); + await database.pool.query(`INSERT INTO public."user" (id,name,email,"emailVerified","createdAt","updatedAt") VALUES + ('owner','Owner','owner@example.invalid',true,now(),now()), + ('member','Member','member@example.invalid',true,now(),now()), + ('outsider','Outsider','outsider@example.invalid',true,now(),now())`); + await database.pool.query(`INSERT INTO user_onboarding (user_id,completed_at) SELECT id,now() FROM public."user"`); + const url = new URL(database.connectionString); + const connection = `Host=${url.hostname};Port=${url.port};Database=postgres;Username=${url.username};Password=${url.password}`; + api = await startApi(5119, { ConnectionStrings__Database: connection, Auth__Issuer: issuer }); +}); +test.afterAll(async () => { + api?.stop(); + await database?.stop(); +}); + +type Upload = { name: string; buffer: Buffer; mimeType?: string; title?: string }; + +async function as(request: APIRequestContext, sub: string) { + const minted = await request.post(`${issuer}/sign`, { data: { claims: { sub } } }); + expect(minted.status()).toBe(200); + const headers = { authorization: `Bearer ${(await minted.json()).token}` }; + const url = (path: string) => `${api.baseURL}${path}`; + return { + get: (path: string) => request.get(url(path), { headers }), + post: (path: string, data?: object) => request.post(url(path), { headers, data }), + upload: (projectId: string, { name, buffer, mimeType = "application/octet-stream", title }: Upload) => + request.post(url(`/projects/${projectId}/documents`), { + headers, + multipart: { file: { name, mimeType, buffer }, ...(title === undefined ? {} : { title }) }, + }), + }; +} + +// A project in a new team owned by "owner", where "member" is a plain Member. +async function createProject(request: APIRequestContext, name: string) { + const owner = await as(request, "owner"); + const team = await owner.post("/teams", { name: `${name} team` }); + expect(team.status()).toBe(201); + const teamId: string = (await team.json()).id; + // Fixture-only membership. Product joins require invitation acceptance. + await database.pool.query("INSERT INTO team_members(team_id,user_id,role) VALUES ($1,'member','member')", [teamId]); + const response = await owner.post("/projects", { name, teamId }); + expect(response.status()).toBe(201); + return await response.json() as { id: string }; +} + +test("members upload, list, download, and archive PDF, DOC, and DOCX documents", async ({ request }) => { + const project = await createProject(request, "Documents"); + const owner = await as(request, "owner"); + const member = await as(request, "member"); + + const uploaded = await member.upload(project.id, { name: "brief.pdf", buffer: pdf, title: " Product brief " }); + expect(uploaded.status()).toBe(201); + const brief = await uploaded.json(); + expect(brief).toMatchObject({ + projectId: project.id, title: "Product brief", fileName: "brief.pdf", contentType: types.pdf, + sizeBytes: pdf.length, uploadedBy: "member", uploadedByName: "Member", archivedAt: null, + }); + expect(uploaded.headers().location).toBe(`/documents/${brief.id}`); + + // The stored type follows the validated extension, not the client's header; the title defaults to the name. + const plan = await (await owner.upload(project.id, { name: "plan.docx", buffer: docx, mimeType: "text/html" })).json(); + expect(plan).toMatchObject({ title: "plan", contentType: types.docx, uploadedByName: "Owner" }); + const notes = await (await owner.upload(project.id, { name: "C:\\fakepath\\Notes.DOC", buffer: doc })).json(); + expect(notes).toMatchObject({ title: "Notes", fileName: "Notes.DOC", contentType: types.doc }); + + const listed = await (await owner.get(`/projects/${project.id}/documents`)).json(); + expect(listed.map((d: { id: string }) => d.id)).toEqual([notes.id, plan.id, brief.id]); + + const download = await owner.get(`/documents/${brief.id}/content`); + expect(download.status()).toBe(200); + expect(Buffer.compare(await download.body(), pdf)).toBe(0); + expect(download.headers()["content-type"]).toBe(types.pdf); + expect(download.headers()["content-disposition"]).toMatch(/^attachment; filename=brief\.pdf/); + expect(download.headers()["x-content-type-options"]).toBe("nosniff"); + expect(download.headers()["cache-control"]).toContain("no-store"); + expect(await (await member.get(`/documents/${plan.id}`)).json()).toEqual(plan); + + const archived = await (await member.post(`/documents/${brief.id}/archive`)).json(); + expect(archived.archivedAt).not.toBeNull(); + const again = await (await member.post(`/documents/${brief.id}/archive`)).json(); + expect(again.archivedAt).toBe(archived.archivedAt); + const remaining = await (await owner.get(`/projects/${project.id}/documents`)).json(); + expect(remaining.map((d: { id: string }) => d.id)).toEqual([notes.id, plan.id]); + expect((await owner.get(`/documents/${brief.id}`)).status()).toBe(200); + expect((await owner.get(`/documents/${brief.id}/content`)).status()).toBe(200); +}); + +test("uploads are checked for type, contents, size, and title", async ({ request }) => { + const project = await createProject(request, "Validation"); + const member = await as(request, "member"); + const fileError = async (upload: Upload) => { + const response = await member.upload(project.id, upload); + expect(response.status(), upload.name).toBe(400); + return (await response.json()).errors.file?.[0]; + }; + + expect(await fileError({ name: "notes.txt", buffer: Buffer.from("plain text") })).toBe("Upload a PDF, DOC, or DOCX file."); + expect(await fileError({ name: "fake.pdf", buffer: Buffer.from("not a pdf") })) + .toBe("The file's contents don't match its type."); + expect(await fileError({ name: "fake.docx", buffer: pdf })).toBe("The file's contents don't match its type."); + expect(await fileError({ name: "empty.pdf", buffer: Buffer.alloc(0) })).toBe("Choose a file to upload."); + const tooLarge = Buffer.concat([pdf, Buffer.alloc(MAX_BYTES + 1 - pdf.length)]); + expect(await fileError({ name: "large.pdf", buffer: tooLarge })).toBe("Use a file of at most 4 MB."); + // Bodies well past the limit are refused by the request size limit before validation. + const farTooLarge = Buffer.concat([pdf, Buffer.alloc(MAX_BYTES + 256 * 1024)]); + expect((await member.upload(project.id, { name: "huge.pdf", buffer: farTooLarge })).status()).toBe(413); + + const longTitle = await member.upload(project.id, { name: "brief.pdf", buffer: pdf, title: "x".repeat(201) }); + expect(longTitle.status()).toBe(400); + expect((await longTitle.json()).errors.title).toEqual(["Use at most 200 characters."]); + + const atLimit = Buffer.concat([pdf, Buffer.alloc(MAX_BYTES - pdf.length)]); + expect((await member.upload(project.id, { name: "limit.pdf", buffer: atLimit })).status()).toBe(201); + const { rows } = await database.pool.query("SELECT count(*)::int AS count FROM public.documents WHERE project_id = $1", [project.id]); + expect(rows).toEqual([{ count: 1 }]); +}); + +test("non-members see nothing, and archived projects keep documents readable but frozen", async ({ request }) => { + const project = await createProject(request, "Access"); + const owner = await as(request, "owner"); + const member = await as(request, "member"); + const outsider = await as(request, "outsider"); + const brief = await (await member.upload(project.id, { name: "brief.pdf", buffer: pdf })).json(); + + for (const response of [ + await outsider.get(`/projects/${project.id}/documents`), + await outsider.upload(project.id, { name: "brief.pdf", buffer: pdf }), + await outsider.get(`/documents/${brief.id}`), + await outsider.get(`/documents/${brief.id}/content`), + await outsider.post(`/documents/${brief.id}/archive`), + ]) expect(response.status()).toBe(404); + + expect((await owner.post(`/projects/${project.id}/archive`)).status()).toBe(200); + expect((await member.upload(project.id, { name: "late.pdf", buffer: pdf })).status()).toBe(409); + expect((await member.post(`/documents/${brief.id}/archive`)).status()).toBe(409); + expect((await member.get(`/projects/${project.id}/documents`)).status()).toBe(200); + expect((await member.get(`/documents/${brief.id}/content`)).status()).toBe(200); +}); + +test("the database constrains document metadata and protects both tables", async ({ request }) => { + const project = await createProject(request, "Constraints"); + const sql = (text: string) => database.pool.query(text); + const insert = (contentType: string, size: number) => sql(`INSERT INTO public.documents + (project_id,title,file_name,content_type,size_bytes,uploaded_by) + VALUES ('${project.id}','Title','file.pdf','${contentType}',${size},'owner')`); + + await expect(insert("text/html", 10)).rejects.toThrow(/check constraint/); + await expect(insert(types.pdf, 0)).rejects.toThrow(/check constraint/); + await expect(insert(types.pdf, MAX_BYTES + 1)).rejects.toThrow(/check constraint/); + await expect(sql(`INSERT INTO public.document_contents (document_id,content) + VALUES ('00000000-0000-0000-0000-000000000000','\\x00')`)).rejects.toThrow(/foreign key/); + + const { rows } = await sql("SELECT tablename, rowsecurity FROM pg_tables WHERE schemaname='public' AND tablename IN ('documents','document_contents') ORDER BY tablename"); + expect(rows).toEqual([{ tablename: "document_contents", rowsecurity: true }, { tablename: "documents", rowsecurity: true }]); + // A dedicated connection, so the role change never leaks into the pool. + const client = await database.pool.connect(); + try { + for (const role of ["anon", "authenticated"]) { + await client.query(`SET ROLE ${role}`); + for (const table of ["documents", "document_contents"]) { + await expect(client.query(`SELECT * FROM public.${table}`)).rejects.toThrow(/permission denied/); + } + await client.query("RESET ROLE"); + } + } finally { + await client.query("RESET ROLE"); + client.release(); + } +}); diff --git a/tests/schema/evidence.spec.ts b/tests/schema/evidence.spec.ts index a1ba853..8e4a513 100644 --- a/tests/schema/evidence.spec.ts +++ b/tests/schema/evidence.spec.ts @@ -521,7 +521,7 @@ test("the release migration constrains what a release row can be, and its rollba expect(await count("SELECT count(*)::int AS count FROM public.requirement_evidence WHERE kind = 'release'")).toBe(0); expect(await count("SELECT count(*)::int AS count FROM public.requirement_evidence")).toBe(others); expect(await count(columns)).toBe(0); - expect(await count(`SELECT count(*)::int AS count FROM "__EFMigrationsHistory"`)).toBe(11); + expect(await count(`SELECT count(*)::int AS count FROM "__EFMigrationsHistory"`)).toBe(12); }); test("the check migration defaults every link to manual, constrains the source, and rolls back without losing links", async () => { @@ -545,7 +545,7 @@ test("the check migration defaults every link to manual, constrains the source, await database.pool.query(await readFile("docs/schema/evidence-checks-rollback.sql", "utf8")); expect(await count(columns)).toBe(0); expect(await count("SELECT count(*)::int AS count FROM public.requirement_evidence")).toBe(before); - expect(await count(`SELECT count(*)::int AS count FROM "__EFMigrationsHistory"`)).toBe(10); + expect(await count(`SELECT count(*)::int AS count FROM "__EFMigrationsHistory"`)).toBe(11); }); test("the commit migration constrains what a row can be, and its rollback removes commit links only", async () => { @@ -571,7 +571,7 @@ test("the commit migration constrains what a row can be, and its rollback remove expect(await count("SELECT count(*)::int AS count FROM public.requirement_evidence WHERE kind = 'commit'")).toBe(0); expect(await count("SELECT count(*)::int AS count FROM public.requirement_evidence WHERE repository_id = 515151")).toBe(2); expect(await count("SELECT count(*)::int AS count FROM information_schema.columns WHERE table_name='requirement_evidence' AND column_name IN ('sha','commits','additions')")).toBe(0); - expect(await count(`SELECT count(*)::int AS count FROM "__EFMigrationsHistory"`)).toBe(9); + expect(await count(`SELECT count(*)::int AS count FROM "__EFMigrationsHistory"`)).toBe(10); }); test("the migration protects the new table and its rollback removes only that table", async () => { @@ -593,5 +593,5 @@ test("the migration protects the new table and its rollback removes only that ta await database.pool.query(await readFile("docs/schema/requirement-evidence-rollback.sql", "utf8")); expect(await count("SELECT count(*)::int AS count FROM pg_tables WHERE schemaname='public' AND tablename='requirement_evidence'")).toBe(0); expect(await count("SELECT count(*)::int AS count FROM pg_tables WHERE schemaname='public' AND tablename IN ('project_repositories','requirements')")).toBe(2); - expect(await count(`SELECT count(*)::int AS count FROM "__EFMigrationsHistory"`)).toBe(8); + expect(await count(`SELECT count(*)::int AS count FROM "__EFMigrationsHistory"`)).toBe(9); }); diff --git a/tests/schema/github-repository.spec.ts b/tests/schema/github-repository.spec.ts index 0a14a85..8d0922b 100644 --- a/tests/schema/github-repository.spec.ts +++ b/tests/schema/github-repository.spec.ts @@ -255,6 +255,6 @@ test("the migration protects the new table and its rollback removes only that ta await database.pool.query(await readFile("docs/schema/project-repositories-rollback.sql", "utf8")); expect(await count("SELECT count(*)::int AS count FROM pg_tables WHERE schemaname='public' AND tablename='project_repositories'")).toBe(0); expect(await count("SELECT count(*)::int AS count FROM pg_tables WHERE schemaname='public' AND tablename IN ('projects','requirements','rateLimit')")).toBe(3); - // Everything else remains: the two initial migrations, four for teams, four later ones for evidence, and RequirementReviews. - expect(await count(`SELECT count(*)::int AS count FROM "__EFMigrationsHistory"`)).toBe(11); + // Everything else remains: the two initial migrations, four for teams, four later ones for evidence, RequirementReviews, and ProjectDocuments. + expect(await count(`SELECT count(*)::int AS count FROM "__EFMigrationsHistory"`)).toBe(12); }); diff --git a/tests/schema/reviews.spec.ts b/tests/schema/reviews.spec.ts index 5ba3070..ec54196 100644 --- a/tests/schema/reviews.spec.ts +++ b/tests/schema/reviews.spec.ts @@ -270,5 +270,5 @@ test("the migration protects the new table, constrains its rows, and its rollbac expect(await count("SELECT count(*)::int AS count FROM pg_tables WHERE schemaname='public' AND tablename='requirement_reviews'")).toBe(0); expect(await count("SELECT count(*)::int AS count FROM pg_tables WHERE schemaname='public' AND tablename IN ('requirements','requirement_evidence','project_repositories')")).toBe(3); expect(await count(`SELECT count(*)::int AS count FROM "__EFMigrationsHistory"`)).toBe(history - 1); - expect(history).toBe(12); + expect(history).toBe(13); // Every migration through ProjectDocuments. }); From 56a9d8f7869ca537e8cc233611ed7fb59836a08b Mon Sep 17 00:00:00 2001 From: sormond319 Date: Sun, 18 Oct 2026 20:22:32 -0400 Subject: [PATCH 2/2] fix(web): read document downloads in full before responding apiFetch's timeout also covers reading the response body, so streaming the API body through let a stalled or aborted transfer truncate the download after the route had returned, outside its error handling. The route now buffers the file (at most 4 MiB), sets Content-Length from it, and reports a failed transfer as 502. Co-Authored-By: Claude Opus 5.5 --- .../documents/[documentId]/download/route.ts | 13 ++++++++----- docs/API.md | 2 +- docs/DECISIONS.md | 5 +++-- docs/HANDOFF.md | 2 +- tests/e2e/documents.spec.ts | 5 +++++ 5 files changed, 18 insertions(+), 9 deletions(-) diff --git a/app/web/src/app/projects/[projectId]/documents/[documentId]/download/route.ts b/app/web/src/app/projects/[projectId]/documents/[documentId]/download/route.ts index 6976068..4313441 100644 --- a/app/web/src/app/projects/[projectId]/documents/[documentId]/download/route.ts +++ b/app/web/src/app/projects/[projectId]/documents/[documentId]/download/route.ts @@ -2,11 +2,11 @@ import { apiFetch } from "@/lib/api"; import { parseDocument } from "@/lib/documents"; import { isUuid } from "@/lib/projects"; -const passedHeaders = ["content-type", "content-disposition", "content-length"]; +const passedHeaders = ["content-type", "content-disposition"]; const notFound = () => new Response("Document not found.", { status: 404 }); /** - * Streams a document from the API to a signed-in member of its team (the page proxy requires a + * Sends a document from the API to a signed-in member of its team (the page proxy requires a * session). A document opened under another project's address is not found, like its page. * The API's attachment disposition is kept, so the file never renders inside the app's origin. */ @@ -21,13 +21,16 @@ export async function GET(_request: Request, { params }: RouteContext<"/projects const file = await apiFetch(`/documents/${documentId}/content`); if (file.status === 404) return notFound(); - if (!file.ok || !file.body) throw new Error(`GET /documents content returned ${file.status}.`); - const headers = new Headers({ "cache-control": "private, no-store" }); + if (!file.ok) throw new Error(`GET /documents content returned ${file.status}.`); + // Read the whole file (at most 4 MiB) here, while apiFetch's timeout still applies, so a + // stalled or broken transfer becomes the 502 below instead of a truncated download. + const bytes = await file.arrayBuffer(); + const headers = new Headers({ "cache-control": "private, no-store", "content-length": String(bytes.byteLength) }); for (const name of passedHeaders) { const value = file.headers.get(name); if (value) headers.set(name, value); } - return new Response(file.body, { headers }); + return new Response(bytes, { headers }); } catch (error) { console.error("Download document failed", error); return new Response("The document could not be downloaded. Please try again.", { status: 502 }); diff --git a/docs/API.md b/docs/API.md index 150f79a..7e4fd38 100644 --- a/docs/API.md +++ b/docs/API.md @@ -73,7 +73,7 @@ file's leading bytes against its extension and stores the content type for that extension. A missing title becomes the file name without its extension. Upload with `apiFetch(path, { method: "POST", body: formData })`; `apiFetch` sets a JSON content type only for string bodies. Downloads stay available for archived documents and projects; the -web streams them through `/projects/{projectId}/documents/{documentId}/download`. +web serves them, read in full, through `/projects/{projectId}/documents/{documentId}/download`. `reference` is an issue or pull request number (`42` or `#42`), a commit SHA of 7 to 40 hex digits, a release tag, or the github.com address of any of them, in the diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 4c39ccc..1953b2a 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -1115,7 +1115,8 @@ endpoint disables antiforgery because the API authenticates bearer tokens, not c In the web, Documentation is a project tab (ADR-028) with a list, an upload page, and a page per document with its download link and the archive action, which asks for -confirmation. A Next.js route handler streams downloads and answers 404 when the +confirmation. A Next.js route handler reads each download in full within the API call's +timeout, so a failed transfer returns 502 rather than a truncated file, and answers 404 when the document belongs to a different project than the address says. Server actions accept 5 MB bodies for the upload form. `apiFetch` sets a JSON content type only for string bodies, so multipart uploads keep their boundary. @@ -1124,7 +1125,7 @@ Consequences: Files count against the database size (500 MB on Supabase's free p and archiving frees nothing; a size report or purge job may be needed later. Larger files need direct-to-storage uploads, which would supersede this decision. DOC and DOCX contents beyond the header are not inspected, and files are not virus scanned. A -download reads the whole file into API memory, which the size limit bounds, and costs +download reads the whole file into API and web memory, which the size limit bounds, and costs two API calls from the web (metadata, then content). Requirement references to documents, search, renaming, and unarchiving are future work. Migration ProjectDocuments adds both tables with RLS on and browser grants revoked; CI applies it diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 23eb73e..c7aaca0 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -16,7 +16,7 @@ (empty state; no upload button on archived projects) and an upload page that checks files in the browser and shows the server's messages. - A page per document shows its details, a Download link, and archive with - confirmation. A route streams downloads and answers 404 when the address names + confirmation. A route serves downloads (read in full, so a failed transfer is a 502) and answers 404 when the address names another project. - The user asked for a project "sub menu"; on `main` project sections are tabs (ADR-028), so it is a tab. diff --git a/tests/e2e/documents.spec.ts b/tests/e2e/documents.spec.ts index 0f0eee5..3179615 100644 --- a/tests/e2e/documents.spec.ts +++ b/tests/e2e/documents.spec.ts @@ -123,6 +123,11 @@ test("archived projects keep documents downloadable but take no new ones", async const response = await page.request.get(`/projects/${projectId}/documents/${documentId}/download`); expect(response.status()).toBe(200); expect(response.headers()["content-disposition"]).toMatch(/^attachment; filename=brief\.pdf/); + expect(response.headers()["content-length"]).toBe(String(pdf.length)); + + // A failed transfer from the API is reported, not passed on as a truncated file. + await failApi(owner.userId, { method: "GET", path: "/content$", status: 500 }); + expect((await page.request.get(`/projects/${projectId}/documents/${documentId}/download`)).status()).toBe(502); }); test("a document opened under another project's address is not found", async ({ page }) => {