Phase 11: version PDF (FR-15)
- เลข version ใหม่: เว็บ-generate = v0 ฉบับตั้งต้น (ไม่มี PDF), add-in save (Start Versioning + Save & Version) = v1+ มี PDF เสมอ - PDF ผลิตจาก Word จริงผ่าน task pane เท่านั้น (getFileAsync Pdf + runtime guard, manifest ไม่เพิ่ม requirement); capture/upload เป็น best-effort — docx ผ่านแม้ PDF ล้ม + MessageBar เตือนตาม hasPdf - Backend: DocumentVersion.PdfObjectKey (nullable, migration AddVersionPdf) + sibling .pdf ใน bucket docx-versions · multipart part pdf (ผิด type → 400) · /download + /download-link รับ ?format=pdf (ไม่มี → 404) · presigned ฝังชื่อไฟล์ไทย + _vN (RFC 5987) - Web: เปิดไฟล์แทน blob-download — PDF แท็บใหม่, docx ผ่าน ms-word:ofe|u| พร้อม heuristic fallback ลิงก์สำรอง (useVersionOpen, versionDownload) · ปุ่ม "เปิดใน Word" ที่หน้า success ของฟอร์มสร้างเอกสาร - Spec v7 + CONTEXT.md + ADR 0001 + แผน docs/plans/version-pdf.md - Automated QA: backend 68 / add-in 63 / web 55 ผ่าน · manual acceptance ผ่านบน Word for Mac (2026-09-08) รวม fidelity + track changes — ค้างเฉพาะ Windows / Word on the web Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
618328ffa7
commit
39d610a781
39 changed files with 1387 additions and 212 deletions
|
|
@ -10,8 +10,8 @@ namespace WordVersioning.Api.IntegrationTests;
|
|||
[Collection(IntegrationTestCollection.Name)]
|
||||
public sealed class FieldSyncTests(IntegrationTestFixture fixture)
|
||||
{
|
||||
/// <summary>สร้างเอกสารจาก template ที่ตั้งค่าแล้ว (v1 ค่า DefaultValues) — จุดตั้งต้นของทุก test ในคลาสนี้</summary>
|
||||
private async Task<(HttpClient Client, Guid DocumentId, Guid V1VersionId)> CreateTrackedDocumentAsync()
|
||||
/// <summary>สร้างเอกสารจาก template ที่ตั้งค่าแล้ว (v0 ค่า DefaultValues) — จุดตั้งต้นของทุก test ในคลาสนี้</summary>
|
||||
private async Task<(HttpClient Client, Guid DocumentId, Guid V0VersionId)> CreateTrackedDocumentAsync()
|
||||
{
|
||||
var client = fixture.CreateClient();
|
||||
var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, "field sync");
|
||||
|
|
@ -20,13 +20,13 @@ public sealed class FieldSyncTests(IntegrationTestFixture fixture)
|
|||
}
|
||||
|
||||
/// <summary>AC-13 — แก้ค่าใน content control แล้ว Save & Version → latest values เปลี่ยนตาม
|
||||
/// + snapshot ของ version นั้นเก็บค่าใหม่ และ v1 snapshot ยังเก็บค่าเก่าไว้ (diff ได้)</summary>
|
||||
/// + snapshot ของ version นั้นเก็บค่าใหม่ และ v0 snapshot ยังเก็บค่าเก่าไว้ (diff ได้)</summary>
|
||||
[Fact]
|
||||
public async Task UploadWithChangedFields_UpdatesLatestValues_AndSnapshotsPerVersion()
|
||||
{
|
||||
var (client, documentId, v1VersionId) = await CreateTrackedDocumentAsync();
|
||||
var (client, documentId, v0VersionId) = await CreateTrackedDocumentAsync();
|
||||
|
||||
var v2Fields = new Dictionary<string, string>
|
||||
var v1Fields = new Dictionary<string, string>
|
||||
{
|
||||
["employee_name"] = "สมหญิง เปลี่ยนชื่อแล้ว",
|
||||
["position"] = "วิศวกรซอฟต์แวร์", // ไม่เปลี่ยน
|
||||
|
|
@ -37,37 +37,37 @@ public sealed class FieldSyncTests(IntegrationTestFixture fixture)
|
|||
$"api/documents/{documentId}/versions",
|
||||
ApiExtensions.VersionForm(
|
||||
TestDocx.BuildPlainDocx("แก้ไขใน Word"), "doc.docx", Guid.NewGuid().ToString(),
|
||||
JsonSerializer.Serialize(v2Fields)));
|
||||
JsonSerializer.Serialize(v1Fields)));
|
||||
Assert.Equal(HttpStatusCode.Created, upload.StatusCode);
|
||||
var v2 = await upload.Content.ReadFromJsonAsync<UploadVersionResult>(IntegrationTestFixture.Json);
|
||||
var v1 = await upload.Content.ReadFromJsonAsync<UploadVersionResult>(IntegrationTestFixture.Json);
|
||||
|
||||
// latest values: field ที่ "ส่งมา" ถูก update ทุกตัว (แม้ค่าเดิมไม่เปลี่ยน — ไม่มี dirty check)
|
||||
// จึงชี้ SourceVersionId = v2 ทั้งหมด; ตัวที่ "ไม่ได้ส่ง" เท่านั้นที่คง v1 (ดู test AC-14)
|
||||
// จึงชี้ SourceVersionId = v1 ทั้งหมด; ตัวที่ "ไม่ได้ส่ง" เท่านั้นที่คง v0 (ดู test AC-14)
|
||||
await using var db = fixture.CreateDb();
|
||||
var values = await db.DocumentFieldValues.AsNoTracking()
|
||||
.Where(v => v.DocumentId == documentId)
|
||||
.ToDictionaryAsync(v => v.FieldKey);
|
||||
Assert.Equal("สมหญิง เปลี่ยนชื่อแล้ว", values["employee_name"].Value);
|
||||
Assert.Equal(v2!.VersionId, values["employee_name"].SourceVersionId);
|
||||
Assert.Equal(v1!.VersionId, values["employee_name"].SourceVersionId);
|
||||
Assert.Equal("99000", values["salary"].Value);
|
||||
Assert.Equal(v2.VersionId, values["salary"].SourceVersionId);
|
||||
Assert.Equal("วิศวกรซอฟต์แวร์", values["position"].Value); // ค่าเดิมส่งมาเท่าเดิม → คงเดิมแต่ source ขยับตาม v2
|
||||
Assert.Equal(v2.VersionId, values["position"].SourceVersionId);
|
||||
Assert.Equal(v1.VersionId, values["salary"].SourceVersionId);
|
||||
Assert.Equal("วิศวกรซอฟต์แวร์", values["position"].Value); // ค่าเดิมส่งมาเท่าเดิม → คงเดิมแต่ source ขยับตาม v1
|
||||
Assert.Equal(v1.VersionId, values["position"].SourceVersionId);
|
||||
Assert.Equal(fixture.TestUserId, values["employee_name"].UpdatedByUserId);
|
||||
|
||||
// snapshot ต่อ version ผ่าน history API: v2 = ค่าใหม่, v1 = ค่าเดิม (missing=false ทั้งคู่)
|
||||
// snapshot ต่อ version ผ่าน history API: v1 = ค่าใหม่, v0 = ค่าเดิม (missing=false ทั้งคู่)
|
||||
using var history = await client.GetAsync($"api/documents/{documentId}/versions");
|
||||
var items = (await history.Content.ReadFromJsonAsync<List<VersionItem>>(IntegrationTestFixture.Json))!
|
||||
.OrderBy(i => i.VersionNumber)
|
||||
.ToList();
|
||||
|
||||
var v1Snapshot = Snapshot(items[0].Fields!.Value);
|
||||
var v2Snapshot = Snapshot(items[1].Fields!.Value);
|
||||
Assert.Equal("สมหญิง รักงาน", v1Snapshot["employee_name"].Value);
|
||||
Assert.Equal("สมหญิง เปลี่ยนชื่อแล้ว", v2Snapshot["employee_name"].Value);
|
||||
Assert.Equal("99000", v2Snapshot["salary"].Value);
|
||||
var v0Snapshot = Snapshot(items[0].Fields!.Value);
|
||||
var v1Snapshot = Snapshot(items[1].Fields!.Value);
|
||||
Assert.Equal("สมหญิง รักงาน", v0Snapshot["employee_name"].Value);
|
||||
Assert.Equal("สมหญิง เปลี่ยนชื่อแล้ว", v1Snapshot["employee_name"].Value);
|
||||
Assert.Equal("99000", v1Snapshot["salary"].Value);
|
||||
Assert.All(v0Snapshot.Values, e => Assert.False(e.Missing));
|
||||
Assert.All(v1Snapshot.Values, e => Assert.False(e.Missing));
|
||||
Assert.All(v2Snapshot.Values, e => Assert.False(e.Missing));
|
||||
}
|
||||
|
||||
/// <summary>AC-14 — ลบ content control แล้ว Save & Version: request สำเร็จ, ค่าเดิมคงอยู่ + mark missing
|
||||
|
|
@ -75,7 +75,7 @@ public sealed class FieldSyncTests(IntegrationTestFixture fixture)
|
|||
[Fact]
|
||||
public async Task UploadWithMissingFields_KeepsOldValues_MarksMissing_DoesNotFail()
|
||||
{
|
||||
var (client, documentId, v1VersionId) = await CreateTrackedDocumentAsync();
|
||||
var (client, documentId, v0VersionId) = await CreateTrackedDocumentAsync();
|
||||
|
||||
// ผู้ใช้ลบ control ของ position/salary/start_date — เหลือส่ง employee_name เท่านั้น + ส่งเกิน 1 field
|
||||
using var upload = await client.PostAsync(
|
||||
|
|
@ -87,22 +87,22 @@ public sealed class FieldSyncTests(IntegrationTestFixture fixture)
|
|||
// policy FR-11: ไม่ fail request
|
||||
Assert.Equal(HttpStatusCode.Created, upload.StatusCode);
|
||||
|
||||
// latest values ของ field ที่หาย: คงค่า v1 เดิมไว้ทั้งหมด (รวม SourceVersionId ที่ยังชี้ v1)
|
||||
// latest values ของ field ที่หาย: คงค่า v0 เดิมไว้ทั้งหมด (รวม SourceVersionId ที่ยังชี้ v0)
|
||||
await using var db = fixture.CreateDb();
|
||||
var values = await db.DocumentFieldValues.AsNoTracking()
|
||||
.Where(v => v.DocumentId == documentId)
|
||||
.ToDictionaryAsync(v => v.FieldKey);
|
||||
Assert.Equal("สมหญิง ยังอยู่", values["employee_name"].Value);
|
||||
Assert.Equal("วิศวกรซอฟต์แวร์", values["position"].Value);
|
||||
Assert.Equal(v1VersionId, values["position"].SourceVersionId); // ไม่ถูกแตะเพราะไม่ได้ส่งมา
|
||||
Assert.Equal(v0VersionId, values["position"].SourceVersionId); // ไม่ถูกแตะเพราะไม่ได้ส่งมา
|
||||
Assert.Equal("75000", values["salary"].Value);
|
||||
Assert.Equal("2026-08-01", values["start_date"].Value);
|
||||
Assert.Equal(4, values.Count); // field เกินไม่ถูกเก็บ
|
||||
|
||||
// snapshot v2: field ที่หาย missing=true พร้อมค่าเดิม, ไม่มี key ของ field เกิน
|
||||
// snapshot v1: field ที่หาย missing=true พร้อมค่าเดิม, ไม่มี key ของ field เกิน
|
||||
using var history = await client.GetAsync($"api/documents/{documentId}/versions");
|
||||
var items = await history.Content.ReadFromJsonAsync<List<VersionItem>>(IntegrationTestFixture.Json);
|
||||
var snapshot = Snapshot(items!.Single(i => i.VersionNumber == 2).Fields!.Value);
|
||||
var snapshot = Snapshot(items!.Single(i => i.VersionNumber == 1).Fields!.Value);
|
||||
|
||||
Assert.False(snapshot["employee_name"].Missing);
|
||||
Assert.True(snapshot["position"].Missing);
|
||||
|
|
@ -112,7 +112,7 @@ public sealed class FieldSyncTests(IntegrationTestFixture fixture)
|
|||
Assert.Equal(4, snapshot.Count); // ไม่มี "อะไรที่ไม่มีในนิยาม"
|
||||
}
|
||||
|
||||
/// <summary>FR-12 ผ่าน detail — หลัง sync แล้วหน้า detail ต้องเห็นค่าล่าสุด (ไม่ใช่ของ v1)</summary>
|
||||
/// <summary>FR-12 ผ่าน detail — หลัง sync แล้วหน้า detail ต้องเห็นค่าล่าสุด (ไม่ใช่ของ v0)</summary>
|
||||
[Fact]
|
||||
public async Task DetailReflectsLatestSyncedValues()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -6,12 +6,12 @@ using WordVersioning.Api.Services;
|
|||
namespace WordVersioning.Api.IntegrationTests;
|
||||
|
||||
/// <summary>FR-10 — generate เอกสารจาก template: ค่าครบ + formatting รักษาไว้ + stamp custom properties
|
||||
/// + Document/v1/field values ใน flow เดียว (AC-12 ส่วน backend) + FR-12 (detail / รายการเอกสารต่อ template)</summary>
|
||||
/// + Document/v0/field values ใน flow เดียว (AC-12 ส่วน backend) + FR-12 (detail / รายการเอกสารต่อ template)</summary>
|
||||
[Collection(IntegrationTestCollection.Name)]
|
||||
public sealed class GenerateTests(IntegrationTestFixture fixture)
|
||||
{
|
||||
[Fact]
|
||||
public async Task Generate_FillsAllFields_PreservesFormatting_StampsProperties_AndCreatesV1()
|
||||
public async Task Generate_FillsAllFields_PreservesFormatting_StampsProperties_AndCreatesV0()
|
||||
{
|
||||
using var client = fixture.CreateClient();
|
||||
var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, "generate หลัก");
|
||||
|
|
@ -19,7 +19,7 @@ public sealed class GenerateTests(IntegrationTestFixture fixture)
|
|||
var doc = await TestFlow.GenerateDocumentAsync(client, templateId);
|
||||
|
||||
// response ครบตาม FR-10 + downloadUrl ใช้ได้ทันที
|
||||
Assert.Equal(1, doc.VersionNumber);
|
||||
Assert.Equal(0, doc.VersionNumber);
|
||||
Assert.Equal($"/api/documents/{doc.DocumentId}/versions/{doc.VersionId}/download", doc.DownloadUrl);
|
||||
using var download = await client.GetAsync(doc.DownloadUrl);
|
||||
Assert.Equal(HttpStatusCode.OK, download.StatusCode);
|
||||
|
|
@ -43,7 +43,7 @@ public sealed class GenerateTests(IntegrationTestFixture fixture)
|
|||
Assert.Equal(AddInMetadata.AddInSignatureGuid, TestDocx.ReadCustomProperty(fileBytes, "SavedByAddIn"));
|
||||
Assert.Equal(doc.DocumentId.ToString(), TestDocx.ReadCustomProperty(fileBytes, "DocumentTrackingId"));
|
||||
|
||||
// (4) สถานะใน Postgres: Document ผูก template + v1 + field values ครบ
|
||||
// (4) สถานะใน Postgres: Document ผูก template + v0 + field values ครบ
|
||||
await using var db = fixture.CreateDb();
|
||||
var document = await db.Documents.AsNoTracking()
|
||||
.Include(d => d.Versions)
|
||||
|
|
@ -53,7 +53,7 @@ public sealed class GenerateTests(IntegrationTestFixture fixture)
|
|||
Assert.EndsWith(".docx", document.FileName);
|
||||
|
||||
var version = Assert.Single(document.Versions);
|
||||
Assert.Equal(1, version.VersionNumber);
|
||||
Assert.Equal(0, version.VersionNumber);
|
||||
Assert.Equal(fixture.TestUserId, version.CreatedByUserId); // จาก claim sub เท่านั้น
|
||||
|
||||
var fieldValues = await db.DocumentFieldValues.AsNoTracking()
|
||||
|
|
@ -109,7 +109,7 @@ public sealed class GenerateTests(IntegrationTestFixture fixture)
|
|||
};
|
||||
|
||||
var doc = await TestFlow.GenerateDocumentAsync(client, templateId, values);
|
||||
Assert.Equal(1, doc.VersionNumber);
|
||||
Assert.Equal(0, doc.VersionNumber);
|
||||
|
||||
await using var db = fixture.CreateDb();
|
||||
Assert.False(await db.DocumentFieldValues.AsNoTracking()
|
||||
|
|
@ -158,7 +158,7 @@ public sealed class GenerateTests(IntegrationTestFixture fixture)
|
|||
Assert.Null(byKey["note"].SourceVersionId);
|
||||
Assert.Equal("note", byKey["note"].Label);
|
||||
|
||||
// def เดิมที่ยังอยู่ (employee_name) — ค่า v1 ยังอยู่ครบ
|
||||
// def เดิมที่ยังอยู่ (employee_name) — ค่า v0 ยังอยู่ครบ
|
||||
Assert.Equal("สมหญิง รักงาน", byKey["employee_name"].Value);
|
||||
Assert.Equal(doc.VersionId, byKey["employee_name"].SourceVersionId);
|
||||
|
||||
|
|
@ -192,7 +192,7 @@ public sealed class GenerateTests(IntegrationTestFixture fixture)
|
|||
Assert.Equal(2, items!.Count);
|
||||
Assert.Equal(firstDoc.DocumentId, items[0].DocumentId); // ล่าสุดก่อน
|
||||
Assert.Equal(2, items[0].VersionCount);
|
||||
Assert.Equal(2, items[0].LatestVersionNumber);
|
||||
Assert.Equal(1, items[0].LatestVersionNumber);
|
||||
Assert.Equal(secondDoc.DocumentId, items[1].DocumentId);
|
||||
Assert.Equal(1, items[1].VersionCount);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,11 +5,13 @@ using System.Text.Json;
|
|||
namespace WordVersioning.Api.IntegrationTests;
|
||||
|
||||
/// <summary>DTO ของ response ฝั่ง test — mirror ของ API (deserialize ด้วย camelCase เหมือนที่ API คืน)</summary>
|
||||
public record UploadVersionResult(Guid VersionId, int VersionNumber);
|
||||
public record UploadVersionResult(Guid VersionId, int VersionNumber, bool HasPdf);
|
||||
|
||||
public record DownloadLinkResult(string Url, DateTimeOffset ExpiresAt);
|
||||
|
||||
public record GenerateResult(Guid DocumentId, Guid VersionId, int VersionNumber, string DownloadUrl);
|
||||
|
||||
public record VersionItem(Guid VersionId, int VersionNumber, DateTimeOffset CreatedAt, string CreatedByUserId, string ContentHash, JsonElement? Fields, string? Message);
|
||||
public record VersionItem(Guid VersionId, int VersionNumber, DateTimeOffset CreatedAt, string CreatedByUserId, string ContentHash, JsonElement? Fields, string? Message, bool HasPdf);
|
||||
|
||||
public record TemplateFieldItem(Guid Id, string Key, string Label, string Type, string Editor, bool IsRequired, int SortOrder);
|
||||
|
||||
|
|
@ -31,7 +33,7 @@ public static class ApiExtensions
|
|||
{
|
||||
/// <summary>multipart form ของ POST versions: file + clientVersionId + fields (option) + message (option)</summary>
|
||||
public static MultipartFormDataContent VersionForm(
|
||||
byte[] docx, string fileName, string clientVersionId, string? fieldsJson = null, string? message = null)
|
||||
byte[] docx, string fileName, string clientVersionId, string? fieldsJson = null, string? message = null, byte[]? pdf = null)
|
||||
{
|
||||
var form = new MultipartFormDataContent();
|
||||
var file = new ByteArrayContent(docx);
|
||||
|
|
@ -42,6 +44,12 @@ public static class ApiExtensions
|
|||
form.Add(new StringContent(fieldsJson), "fields");
|
||||
if (message is not null)
|
||||
form.Add(new StringContent(message), "message");
|
||||
if (pdf is not null)
|
||||
{
|
||||
var part = new ByteArrayContent(pdf);
|
||||
part.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
|
||||
form.Add(part, "pdf", "document.pdf");
|
||||
}
|
||||
return form;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -146,7 +146,7 @@ public sealed class VersionMessageTests(IntegrationTestFixture fixture)
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Generate_WithMessage_CreatesV1WithMessage()
|
||||
public async Task Generate_WithMessage_CreatesV0WithMessage()
|
||||
{
|
||||
using var client = fixture.CreateClient();
|
||||
var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, "msg-gen");
|
||||
|
|
@ -157,7 +157,7 @@ public sealed class VersionMessageTests(IntegrationTestFixture fixture)
|
|||
await using var db = fixture.CreateDb();
|
||||
var version = await db.DocumentVersions.AsNoTracking()
|
||||
.SingleAsync(v => v.DocumentId == generated.DocumentId);
|
||||
Assert.Equal(1, version.VersionNumber);
|
||||
Assert.Equal(0, version.VersionNumber);
|
||||
Assert.Equal("ฉบับเสนอลงนามครั้งแรก", version.Message);
|
||||
|
||||
using var history = await client.GetAsync($"api/documents/{generated.DocumentId}/versions");
|
||||
|
|
|
|||
171
backend/WordVersioning.Api.IntegrationTests/VersionPdfTests.cs
Normal file
171
backend/WordVersioning.Api.IntegrationTests/VersionPdfTests.cs
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace WordVersioning.Api.IntegrationTests;
|
||||
|
||||
[Collection(IntegrationTestCollection.Name)]
|
||||
public sealed class VersionPdfTests(IntegrationTestFixture fixture)
|
||||
{
|
||||
private static readonly byte[] Pdf = "%PDF-1.7\nversion snapshot\n%%EOF"u8.ToArray();
|
||||
|
||||
[Fact]
|
||||
public async Task Upload_WithPdf_PersistsSiblingKey_AndDownloadsExactBytes()
|
||||
{
|
||||
using var client = fixture.CreateClient();
|
||||
var id = Guid.NewGuid();
|
||||
var clientId = Guid.NewGuid().ToString();
|
||||
using var response = await client.PostAsync($"api/documents/{id}/versions",
|
||||
ApiExtensions.VersionForm(TestDocx.BuildPlainDocx(), "คำร้อง.docx", clientId, pdf: Pdf));
|
||||
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||
var result = (await response.ReadJsonAsync<UploadVersionResult>())!;
|
||||
Assert.True(result.HasPdf);
|
||||
Assert.Equal(1, result.VersionNumber);
|
||||
await using var db = fixture.CreateDb();
|
||||
var row = await db.DocumentVersions.AsNoTracking().SingleAsync(v => v.Id == result.VersionId);
|
||||
Assert.Equal($"{id}/v0001_{clientId}.pdf", row.PdfObjectKey);
|
||||
var history = await client.GetFromJsonAsync<List<VersionItem>>($"api/documents/{id}/versions");
|
||||
Assert.True(history!.Single().HasPdf);
|
||||
using var download = await client.GetAsync($"api/documents/{id}/versions/{result.VersionId}/download?format=PDF");
|
||||
Assert.Equal(HttpStatusCode.OK, download.StatusCode);
|
||||
Assert.Equal(Pdf, await download.Content.ReadAsByteArrayAsync());
|
||||
Assert.Equal("application/pdf", download.Content.Headers.ContentType!.MediaType);
|
||||
Assert.Equal("คำร้อง.pdf", download.Content.Headers.ContentDisposition!.FileNameStar);
|
||||
var link = await client.GetFromJsonAsync<DownloadLinkResult>($"api/documents/{id}/versions/{result.VersionId}/download-link?format=pdf");
|
||||
using var browser = new HttpClient();
|
||||
using var signedDownload = await browser.GetAsync(link!.Url);
|
||||
Assert.Equal(HttpStatusCode.OK, signedDownload.StatusCode);
|
||||
Assert.Equal(Pdf, await signedDownload.Content.ReadAsByteArrayAsync());
|
||||
Assert.Equal("inline", signedDownload.Content.Headers.ContentDisposition!.DispositionType);
|
||||
Assert.Equal("คำร้อง_v1.pdf", signedDownload.Content.Headers.ContentDisposition.FileNameStar);
|
||||
var docxLink = await client.GetFromJsonAsync<DownloadLinkResult>($"api/documents/{id}/versions/{result.VersionId}/download-link");
|
||||
using var signedDocx = await browser.GetAsync(docxLink!.Url);
|
||||
Assert.Equal(HttpStatusCode.OK, signedDocx.StatusCode);
|
||||
Assert.Equal("attachment", signedDocx.Content.Headers.ContentDisposition!.DispositionType);
|
||||
Assert.Equal("คำร้อง_v1.docx", signedDocx.Content.Headers.ContentDisposition.FileNameStar);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upload_WithoutPdf_HasFalse_AndBothPdfDownloadsReturn404()
|
||||
{
|
||||
using var client = fixture.CreateClient();
|
||||
var id = Guid.NewGuid();
|
||||
using var response = await client.PostAsync($"api/documents/{id}/versions",
|
||||
ApiExtensions.VersionForm(TestDocx.BuildPlainDocx(), "doc.docx", Guid.NewGuid().ToString()));
|
||||
response.EnsureSuccessStatusCode();
|
||||
var result = (await response.ReadJsonAsync<UploadVersionResult>())!;
|
||||
Assert.False(result.HasPdf);
|
||||
await using var db = fixture.CreateDb();
|
||||
Assert.Null((await db.DocumentVersions.SingleAsync(v => v.Id == result.VersionId)).PdfObjectKey);
|
||||
foreach (var endpoint in new[] { "download", "download-link" })
|
||||
{
|
||||
using var download = await client.GetAsync($"api/documents/{id}/versions/{result.VersionId}/{endpoint}?format=pdf");
|
||||
Assert.Equal(HttpStatusCode.NotFound, download.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("bad.txt", "application/pdf", false)]
|
||||
[InlineData("doc.pdf", "text/plain", false)]
|
||||
[InlineData("doc.pdf", "application/pdf", true)]
|
||||
public async Task InvalidPdf_Returns400_BeforeCreatingDocument(string name, string type, bool empty)
|
||||
{
|
||||
using var client = fixture.CreateClient();
|
||||
var id = Guid.NewGuid();
|
||||
using var form = ApiExtensions.VersionForm(TestDocx.BuildPlainDocx(), "doc.docx", Guid.NewGuid().ToString());
|
||||
var pdf = new ByteArrayContent(empty ? [] : Pdf);
|
||||
pdf.Headers.ContentType = new MediaTypeHeaderValue(type);
|
||||
form.Add(pdf, "pdf", name);
|
||||
using var response = await client.PostAsync($"api/documents/{id}/versions", form);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
await using var db = fixture.CreateDb();
|
||||
Assert.False(await db.Documents.AnyAsync(d => d.Id == id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Generate_V0WithoutPdf_ThenUpload_V1WithPdf()
|
||||
{
|
||||
using var client = fixture.CreateClient();
|
||||
var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, "pdf-gen");
|
||||
var generated = await TestFlow.GenerateDocumentAsync(client, templateId);
|
||||
Assert.Equal(0, generated.VersionNumber);
|
||||
await using var db = fixture.CreateDb();
|
||||
var row = await db.DocumentVersions.AsNoTracking().SingleAsync(v => v.Id == generated.VersionId);
|
||||
Assert.Null(row.PdfObjectKey);
|
||||
Assert.StartsWith($"{generated.DocumentId}/v0000_", row.ObjectKey);
|
||||
using var response = await client.PostAsync($"api/documents/{generated.DocumentId}/versions",
|
||||
ApiExtensions.VersionForm(TestDocx.BuildPlainDocx(), "doc.docx", Guid.NewGuid().ToString(), pdf: Pdf));
|
||||
response.EnsureSuccessStatusCode();
|
||||
var result = (await response.ReadJsonAsync<UploadVersionResult>())!;
|
||||
Assert.Equal(1, result.VersionNumber);
|
||||
Assert.True(result.HasPdf);
|
||||
var history = await client.GetFromJsonAsync<List<VersionItem>>($"api/documents/{generated.DocumentId}/versions");
|
||||
Assert.Equal(new[] { 1, 0 }, history!.Select(v => v.VersionNumber));
|
||||
Assert.False(history[1].HasPdf);
|
||||
}
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task StorageFailure_PdfIsBestEffort_DocxStillRollsBack(bool failDocx)
|
||||
{
|
||||
await using var db = fixture.CreateDb();
|
||||
var storage = new FailingStorage(failDocx);
|
||||
var user = new TestUser();
|
||||
var controller = new WordVersioning.Api.Controllers.DocumentsController(db, storage,
|
||||
new WordVersioning.Api.Services.HashService(), null!,
|
||||
new WordVersioning.Api.Services.FieldSyncService(db, user), user,
|
||||
Microsoft.Extensions.Options.Options.Create(new WordVersioning.Api.Services.MinioOptions()),
|
||||
Microsoft.Extensions.Logging.Abstractions.NullLogger<WordVersioning.Api.Controllers.DocumentsController>.Instance);
|
||||
var id = Guid.NewGuid();
|
||||
using var docxStream = new MemoryStream(TestDocx.BuildPlainDocx());
|
||||
using var pdfStream = new MemoryStream(Pdf);
|
||||
var request = new WordVersioning.Api.Models.UploadVersionRequest
|
||||
{
|
||||
File = new Microsoft.AspNetCore.Http.FormFile(docxStream, 0, docxStream.Length, "file", "doc.docx")
|
||||
{ Headers = new Microsoft.AspNetCore.Http.HeaderDictionary(), ContentType = TestDocx.DocxContentType },
|
||||
Pdf = new Microsoft.AspNetCore.Http.FormFile(pdfStream, 0, pdfStream.Length, "pdf", "doc.pdf")
|
||||
{ Headers = new Microsoft.AspNetCore.Http.HeaderDictionary(), ContentType = "application/pdf" },
|
||||
ClientVersionId = Guid.NewGuid().ToString(),
|
||||
};
|
||||
var response = await controller.UploadVersion(id, request, CancellationToken.None);
|
||||
if (failDocx)
|
||||
{
|
||||
Assert.Equal(502, Assert.IsType<Microsoft.AspNetCore.Mvc.ObjectResult>(response).StatusCode);
|
||||
Assert.False(await db.Documents.AnyAsync(d => d.Id == id));
|
||||
Assert.False(await db.DocumentVersions.AnyAsync(v => v.DocumentId == id));
|
||||
Assert.Single(storage.Keys);
|
||||
}
|
||||
else
|
||||
{
|
||||
var created = Assert.IsType<Microsoft.AspNetCore.Mvc.CreatedAtActionResult>(response);
|
||||
Assert.False(Assert.IsType<WordVersioning.Api.Models.UploadVersionResponse>(created.Value).HasPdf);
|
||||
await using var persisted = fixture.CreateDb();
|
||||
var row = await persisted.DocumentVersions.SingleAsync(v => v.DocumentId == id);
|
||||
Assert.Null(row.PdfObjectKey);
|
||||
Assert.Equal(2, storage.Keys.Count);
|
||||
Assert.EndsWith(".docx", storage.Keys[0]);
|
||||
Assert.EndsWith(".pdf", storage.Keys[1]);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestUser : WordVersioning.Api.Services.ICurrentUser
|
||||
{
|
||||
public string UserId => "test";
|
||||
public string? UserName => "test";
|
||||
}
|
||||
|
||||
private sealed class FailingStorage(bool failDocx) : WordVersioning.Api.Services.IObjectStorageService
|
||||
{
|
||||
public List<string> Keys { get; } = [];
|
||||
public Task PutAsync(string bucket, string key, Stream content, string contentType, CancellationToken ct = default)
|
||||
{
|
||||
Keys.Add(key);
|
||||
if (failDocx || key.EndsWith(".pdf")) throw new Amazon.Runtime.AmazonServiceException("Simulated storage failure");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
public Task<Stream> GetStreamAsync(string bucket, string key, CancellationToken ct = default) => throw new NotSupportedException();
|
||||
public string GetPresignedGetUrl(string bucket, string key, TimeSpan expiry, string? contentDisposition = null) => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -37,6 +37,7 @@ public class DocumentsController(
|
|||
private const int MaxVersionMessageLength = 2048; // FR-13 — ตรงกับ HasMaxLength ใน AppDbContext
|
||||
|
||||
/// <summary>FR-5 — อัปโหลด version ใหม่ (auto-create Document ถ้ายังไม่มี)
|
||||
/// + FR-15: optional PDF; PDF storage failure still returns 201 with HasPdf=false
|
||||
/// + FR-11: แนบ `fields` (map tag→text) → sync latest values + snapshot ต่อ version</summary>
|
||||
[HttpPost("{documentId:guid}/versions")]
|
||||
[RequestSizeLimit(MaxUploadBytes)]
|
||||
|
|
@ -65,6 +66,21 @@ public class DocumentsController(
|
|||
if (message is { Length: > MaxVersionMessageLength })
|
||||
return BadRequest($"`message` ยาวเกิน {MaxVersionMessageLength} อักขระ");
|
||||
|
||||
if (request.Pdf is not null)
|
||||
{
|
||||
if (request.Pdf.Length > MaxUploadBytes)
|
||||
return StatusCode(StatusCodes.Status413PayloadTooLarge, "ไฟล์ PDF ใหญ่เกิน 100MB");
|
||||
if (request.Pdf.Length == 0 || !PdfUploadValidation.IsValidPdf(request.Pdf))
|
||||
return BadRequest("ต้องแนบไฟล์ .pdf ที่ไม่ว่าง (application/pdf)");
|
||||
}
|
||||
|
||||
await using var pdfStream = new MemoryStream();
|
||||
if (request.Pdf is not null)
|
||||
{
|
||||
await request.Pdf.CopyToAsync(pdfStream, ct);
|
||||
pdfStream.Position = 0;
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var fileName = request.File.FileName;
|
||||
|
||||
|
|
@ -133,6 +149,7 @@ public class DocumentsController(
|
|||
DocumentId = documentId,
|
||||
VersionNumber = nextNumber,
|
||||
ObjectKey = $"{documentId}/v{nextNumber:D4}_{request.ClientVersionId}.docx",
|
||||
PdfObjectKey = request.Pdf is null ? null : $"{documentId}/v{nextNumber:D4}_{request.ClientVersionId}.pdf",
|
||||
ContentHash = contentHash,
|
||||
CreatedByUserId = currentUser.UserId, // จาก claim `sub` เท่านั้น
|
||||
CreatedAt = now,
|
||||
|
|
@ -187,15 +204,31 @@ public class DocumentsController(
|
|||
return StatusCode(StatusCodes.Status502BadGateway, "อัปโหลดไฟล์ขึ้น storage ไม่สำเร็จ");
|
||||
}
|
||||
|
||||
// FR-15: PDF is derived; its storage failure must not undo the saved docx.
|
||||
if (version.PdfObjectKey is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await storage.PutAsync(minioOptions.Value.VersionsBucket, version.PdfObjectKey,
|
||||
pdfStream, PdfUploadValidation.PdfContentType, ct);
|
||||
}
|
||||
catch (Exception ex) when (ex is AmazonServiceException or IOException or HttpRequestException or OperationCanceledException)
|
||||
{
|
||||
logger.LogWarning(ex, "PDF upload failed documentId={DocumentId} versionId={VersionId}", documentId, version.Id);
|
||||
version.PdfObjectKey = null;
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"Version uploaded documentId={DocumentId} versionId={VersionId} versionNumber={VersionNumber} userId={UserId} bytes={Size} hash={Hash}",
|
||||
documentId, version.Id, version.VersionNumber, currentUser.UserId, request.File.Length, contentHash);
|
||||
|
||||
return CreatedAtAction(nameof(GetVersions), new { documentId },
|
||||
new UploadVersionResponse(version.Id, version.VersionNumber));
|
||||
new UploadVersionResponse(version.Id, version.VersionNumber, version.PdfObjectKey is not null));
|
||||
}
|
||||
|
||||
/// <summary>FR-10 — สร้างเอกสารจาก template: generate docx + stamp metadata + upload version 1
|
||||
/// <summary>FR-10 — สร้างเอกสารจาก template: generate docx + stamp metadata + upload version 0
|
||||
/// + field values ทั้งหมดใน flow เดียว — เปิดใน Word แล้ว add-in ตรวจพบ tracked document ทันที (AC-12)</summary>
|
||||
[HttpPost("generate")]
|
||||
[ProducesResponseType(typeof(GenerateDocumentResponse), StatusCodes.Status201Created)]
|
||||
|
|
@ -304,8 +337,8 @@ public class DocumentsController(
|
|||
{
|
||||
Id = Guid.NewGuid(),
|
||||
DocumentId = documentId,
|
||||
VersionNumber = 1,
|
||||
ObjectKey = $"{documentId}/v0001_{Guid.NewGuid()}.docx", // clientVersionId generate ฝั่ง server (flow นี้ไม่มี client)
|
||||
VersionNumber = 0,
|
||||
ObjectKey = $"{documentId}/v0000_{Guid.NewGuid()}.docx", // clientVersionId generate ฝั่ง server (flow นี้ไม่มี client)
|
||||
ContentHash = contentHash,
|
||||
CreatedByUserId = currentUser.UserId,
|
||||
CreatedAt = now,
|
||||
|
|
@ -313,7 +346,7 @@ public class DocumentsController(
|
|||
};
|
||||
db.DocumentVersions.Add(version);
|
||||
|
||||
// snapshot + field values ของ v1 (ทุก field มีค่า = missing=false ทั้งหมด)
|
||||
// snapshot + field values ของ v0 (ทุก field มีค่า = missing=false ทั้งหมด)
|
||||
var sync = await fieldSync.StageFieldSyncAsync(doc, version.Id, values, now, ct);
|
||||
version.FieldsSnapshotJson = sync.SnapshotJson;
|
||||
|
||||
|
|
@ -434,37 +467,43 @@ public class DocumentsController(
|
|||
v.ContentHash,
|
||||
v.FieldsSnapshotJson,
|
||||
v.Message,
|
||||
v.PdfObjectKey,
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
|
||||
return Ok(rows.Select(r => new VersionListItem(
|
||||
r.Id, r.VersionNumber, r.CreatedAt, r.CreatedByUserId, r.ContentHash,
|
||||
ParseSnapshot(r.FieldsSnapshotJson), r.Message)).ToList());
|
||||
ParseSnapshot(r.FieldsSnapshotJson), r.Message, r.PdfObjectKey is not null)).ToList());
|
||||
}
|
||||
|
||||
/// <summary>FR-7 — ดาวน์โหลด version ที่ระบุ (proxy stream จาก MinIO — ไม่ expose MinIO ตรงๆ)</summary>
|
||||
/// <summary>FR-7/FR-15 — proxy download; format=pdf selects PDF (404 when absent), default is docx.</summary>
|
||||
[HttpGet("{documentId:guid}/versions/{versionId:guid}/download")]
|
||||
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> DownloadVersion(Guid documentId, Guid versionId, CancellationToken ct)
|
||||
public async Task<IActionResult> DownloadVersion(Guid documentId, Guid versionId, CancellationToken ct, [FromQuery] string? format = null)
|
||||
{
|
||||
var row = await db.DocumentVersions
|
||||
.AsNoTracking()
|
||||
.Where(v => v.DocumentId == documentId && v.Id == versionId)
|
||||
.Join(db.Documents, v => v.DocumentId, d => d.Id, (v, d) => new { v.ObjectKey, d.FileName })
|
||||
.Join(db.Documents, v => v.DocumentId, d => d.Id, (v, d) => new { v.ObjectKey, v.PdfObjectKey, d.FileName })
|
||||
.FirstOrDefaultAsync(ct);
|
||||
if (row is null)
|
||||
return NotFound($"ไม่พบ version {versionId} ของ document {documentId}");
|
||||
|
||||
var isPdf = string.Equals(format, "pdf", StringComparison.OrdinalIgnoreCase);
|
||||
var objectKey = isPdf ? row.PdfObjectKey : row.ObjectKey;
|
||||
if (objectKey is null)
|
||||
return NotFound("version นี้ไม่มีสำเนา PDF");
|
||||
|
||||
Stream stream;
|
||||
try
|
||||
{
|
||||
stream = await storage.GetStreamAsync(minioOptions.Value.VersionsBucket, row.ObjectKey, ct);
|
||||
stream = await storage.GetStreamAsync(minioOptions.Value.VersionsBucket, objectKey, ct);
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
logger.LogError("Object missing in storage documentId={DocumentId} versionId={VersionId} key={ObjectKey}",
|
||||
documentId, versionId, row.ObjectKey);
|
||||
documentId, versionId, objectKey);
|
||||
return NotFound("ไฟล์ของ version นี้ไม่พบใน storage");
|
||||
}
|
||||
|
||||
|
|
@ -474,28 +513,38 @@ public class DocumentsController(
|
|||
var downloadName = row.FileName.EndsWith(".docx", StringComparison.OrdinalIgnoreCase)
|
||||
? row.FileName
|
||||
: row.FileName + ".docx";
|
||||
return File(stream, DocxContentType, downloadName);
|
||||
return File(stream, isPdf ? PdfUploadValidation.PdfContentType : DocxContentType,
|
||||
isPdf ? Path.ChangeExtension(downloadName, ".pdf") : downloadName);
|
||||
}
|
||||
|
||||
/// <summary>FR-7 (ทางเลือก presigned URL อายุสั้น — spec ส่วน 9 อนุญาต)
|
||||
/// <summary>FR-7/FR-15 — presigned URL with filename; format=pdf selects inline PDF (404 when absent).
|
||||
/// ออก URL ให้ add-in เปิดในเบราว์เซอร์ของผู้ใช้โดยตรง — webview ของ Word (โดยเฉพาะ WKWebView บน Mac)
|
||||
/// ทิ้ง blob download แบบเงียบๆ จึงต้องให้เบราว์เซอร์จริงจัดการการดาวน์โหลดแทน</summary>
|
||||
[HttpGet("{documentId:guid}/versions/{versionId:guid}/download-link")]
|
||||
[ProducesResponseType(typeof(VersionDownloadLinkResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<VersionDownloadLinkResponse>> GetVersionDownloadLink(
|
||||
Guid documentId, Guid versionId, CancellationToken ct)
|
||||
Guid documentId, Guid versionId, CancellationToken ct, [FromQuery] string? format = null)
|
||||
{
|
||||
var objectKey = await db.DocumentVersions
|
||||
var row = await db.DocumentVersions
|
||||
.AsNoTracking()
|
||||
.Where(v => v.DocumentId == documentId && v.Id == versionId)
|
||||
.Select(v => v.ObjectKey)
|
||||
.Join(db.Documents, v => v.DocumentId, d => d.Id,
|
||||
(v, d) => new { v.ObjectKey, v.PdfObjectKey, v.VersionNumber, d.FileName })
|
||||
.FirstOrDefaultAsync(ct);
|
||||
if (objectKey is null)
|
||||
if (row is null)
|
||||
return NotFound($"ไม่พบ version {versionId} ของ document {documentId}");
|
||||
|
||||
var isPdf = string.Equals(format, "pdf", StringComparison.OrdinalIgnoreCase);
|
||||
var objectKey = isPdf ? row.PdfObjectKey : row.ObjectKey;
|
||||
if (objectKey is null)
|
||||
return NotFound("version นี้ไม่มีสำเนา PDF");
|
||||
var baseName = row.FileName.EndsWith(".docx", StringComparison.OrdinalIgnoreCase)
|
||||
? row.FileName[..^5] : row.FileName;
|
||||
var fileName = $"{baseName}_v{row.VersionNumber}.{(isPdf ? "pdf" : "docx")}";
|
||||
var disposition = $"{(isPdf ? "inline" : "attachment")}; filename*=UTF-8''{Uri.EscapeDataString(fileName)}";
|
||||
var expirySeconds = Math.Clamp(minioOptions.Value.PresignedExpirySeconds, 30, 600);
|
||||
var url = storage.GetPresignedGetUrl(minioOptions.Value.VersionsBucket, objectKey, TimeSpan.FromSeconds(expirySeconds));
|
||||
var url = storage.GetPresignedGetUrl(minioOptions.Value.VersionsBucket, objectKey, TimeSpan.FromSeconds(expirySeconds), disposition);
|
||||
|
||||
logger.LogInformation(
|
||||
"Version download link issued documentId={DocumentId} versionId={VersionId} userId={UserId} expirySeconds={ExpirySeconds}",
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
|
|||
entity.HasIndex(x => new { x.DocumentId, x.VersionNumber }).IsUnique();
|
||||
|
||||
entity.Property(x => x.ObjectKey).HasMaxLength(1024);
|
||||
entity.Property(x => x.PdfObjectKey).HasMaxLength(1024);
|
||||
entity.Property(x => x.ContentHash).HasMaxLength(64);
|
||||
entity.Property(x => x.Message).HasMaxLength(2048); // FR-13 commit message — นโยบายเดียวกับ Template.Description
|
||||
|
||||
|
|
|
|||
264
backend/WordVersioning.Api/Migrations/20260908053250_AddVersionPdf.Designer.cs
generated
Normal file
264
backend/WordVersioning.Api/Migrations/20260908053250_AddVersionPdf.Designer.cs
generated
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using WordVersioning.Api.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace WordVersioning.Api.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260908053250_AddVersionPdf")]
|
||||
partial class AddVersionPdf
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "8.0.11")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("WordVersioning.Api.Models.Document", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("AddInSignature")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("FileName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)");
|
||||
|
||||
b.Property<Guid?>("TemplateId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TemplateId");
|
||||
|
||||
b.ToTable("Documents");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("WordVersioning.Api.Models.DocumentFieldValue", b =>
|
||||
{
|
||||
b.Property<Guid>("DocumentId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("FieldKey")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<Guid>("SourceVersionId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("UpdatedByUserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("DocumentId", "FieldKey");
|
||||
|
||||
b.ToTable("DocumentFieldValues");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("WordVersioning.Api.Models.DocumentVersion", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ContentHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("CreatedByUserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("DocumentId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("FieldsSnapshotJson")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<string>("ObjectKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)");
|
||||
|
||||
b.Property<string>("PdfObjectKey")
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)");
|
||||
|
||||
b.Property<int>("VersionNumber")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("DocumentId", "VersionNumber")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("DocumentVersions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("WordVersioning.Api.Models.Template", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("ObjectKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Templates");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("WordVersioning.Api.Models.TemplateField", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Editor")
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)");
|
||||
|
||||
b.Property<bool>("IsRequired")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("Label")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid>("TemplateId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TemplateId", "Key")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("TemplateFields");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("WordVersioning.Api.Models.Document", b =>
|
||||
{
|
||||
b.HasOne("WordVersioning.Api.Models.Template", "Template")
|
||||
.WithMany("Documents")
|
||||
.HasForeignKey("TemplateId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.Navigation("Template");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("WordVersioning.Api.Models.DocumentFieldValue", b =>
|
||||
{
|
||||
b.HasOne("WordVersioning.Api.Models.Document", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("DocumentId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("WordVersioning.Api.Models.DocumentVersion", b =>
|
||||
{
|
||||
b.HasOne("WordVersioning.Api.Models.Document", "Document")
|
||||
.WithMany("Versions")
|
||||
.HasForeignKey("DocumentId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Document");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("WordVersioning.Api.Models.TemplateField", b =>
|
||||
{
|
||||
b.HasOne("WordVersioning.Api.Models.Template", "Template")
|
||||
.WithMany("Fields")
|
||||
.HasForeignKey("TemplateId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Template");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("WordVersioning.Api.Models.Document", b =>
|
||||
{
|
||||
b.Navigation("Versions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("WordVersioning.Api.Models.Template", b =>
|
||||
{
|
||||
b.Navigation("Documents");
|
||||
|
||||
b.Navigation("Fields");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace WordVersioning.Api.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddVersionPdf : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "PdfObjectKey",
|
||||
table: "DocumentVersions",
|
||||
type: "character varying(1024)",
|
||||
maxLength: 1024,
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PdfObjectKey",
|
||||
table: "DocumentVersions");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -112,6 +112,10 @@ namespace WordVersioning.Api.Migrations
|
|||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)");
|
||||
|
||||
b.Property<string>("PdfObjectKey")
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)");
|
||||
|
||||
b.Property<int>("VersionNumber")
|
||||
.HasColumnType("integer");
|
||||
|
||||
|
|
|
|||
|
|
@ -9,12 +9,15 @@ public class DocumentVersion
|
|||
|
||||
public Document? Document { get; set; }
|
||||
|
||||
/// <summary>เริ่มที่ 1 — auto increment ต่อ document (unique index กัน race)</summary>
|
||||
/// <summary>generate เริ่ม 0; add-in เริ่ม 1 — auto increment ต่อ document (unique index กัน race)</summary>
|
||||
public int VersionNumber { get; set; }
|
||||
|
||||
/// <summary>MinIO object key: {documentId}/v{VersionNumber:D4}_{clientVersionId}.docx</summary>
|
||||
public string ObjectKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>FR-15: null เมื่อไม่มี PDF (v0 หรือ capture/upload ล้ม)</summary>
|
||||
public string? PdfObjectKey { get; set; }
|
||||
|
||||
/// <summary>SHA256 hex ของไฟล์ (ตรวจ integrity ภายหลัง)</summary>
|
||||
public string ContentHash { get; set; } = string.Empty;
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ public class UploadVersionRequest
|
|||
[Required]
|
||||
public IFormFile File { get; set; } = null!;
|
||||
|
||||
public IFormFile? Pdf { get; set; }
|
||||
|
||||
/// <summary>GUID ที่ client generate ต่อ version — ใช้เป็นส่วนหนึ่งของ object key</summary>
|
||||
[Required]
|
||||
public string ClientVersionId { get; set; } = string.Empty;
|
||||
|
|
@ -20,7 +22,7 @@ public class UploadVersionRequest
|
|||
public string? Message { get; set; }
|
||||
}
|
||||
|
||||
public record UploadVersionResponse(Guid VersionId, int VersionNumber);
|
||||
public record UploadVersionResponse(Guid VersionId, int VersionNumber, bool HasPdf);
|
||||
|
||||
/// <summary>body ของ POST /api/documents/generate (FR-10)</summary>
|
||||
public class GenerateDocumentRequest
|
||||
|
|
@ -45,7 +47,8 @@ public record VersionListItem(
|
|||
string CreatedByUserId,
|
||||
string ContentHash,
|
||||
object? Fields,
|
||||
string? Message);
|
||||
string? Message,
|
||||
bool HasPdf);
|
||||
|
||||
/// <summary>response ของ GET .../download-link — presigned URL อายุสั้นให้เปิดในเบราว์เซอร์ของผู้ใช้</summary>
|
||||
public record VersionDownloadLinkResponse(string Url, DateTimeOffset ExpiresAt);
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ public interface IObjectStorageService
|
|||
|
||||
/// <summary>สร้าง presigned GET URL อายุสั้น — ให้เปิดในเบราว์เซอร์ของผู้ใช้โดยตรง
|
||||
/// (webview ของ Word ทิ้ง blob download แบบเงียบๆ — spec อนุญาต presigned URL อายุสั้นสำหรับดาวน์โหลด)</summary>
|
||||
string GetPresignedGetUrl(string bucket, string key, TimeSpan expiry);
|
||||
string GetPresignedGetUrl(string bucket, string key, TimeSpan expiry, string? contentDisposition = null);
|
||||
}
|
||||
|
||||
/// <summary>Wrapper เรียก MinIO ผ่าน AWSSDK.S3 (S3-compatible API)</summary>
|
||||
|
|
@ -76,7 +76,7 @@ public class ObjectStorageService : IObjectStorageService
|
|||
return new S3ResponseStream(response);
|
||||
}
|
||||
|
||||
public string GetPresignedGetUrl(string bucket, string key, TimeSpan expiry)
|
||||
public string GetPresignedGetUrl(string bucket, string key, TimeSpan expiry, string? contentDisposition = null)
|
||||
{
|
||||
var request = new GetPreSignedUrlRequest
|
||||
{
|
||||
|
|
@ -85,6 +85,8 @@ public class ObjectStorageService : IObjectStorageService
|
|||
Verb = HttpVerb.GET,
|
||||
Expires = DateTime.UtcNow + expiry,
|
||||
};
|
||||
if (contentDisposition is not null)
|
||||
request.ResponseHeaderOverrides.ContentDisposition = contentDisposition;
|
||||
// GetPreSignedURL เป็น sync (เซ็น SigV4 ฝั่ง client ไม่ยิง network)
|
||||
var url = _s3.GetPreSignedURL(request);
|
||||
// AWSSDK v4 บังคับ https ใน presigned URL เสมอ (ไม่สน UseHttp) — สลับ scheme เองตาม config ได้
|
||||
|
|
|
|||
12
backend/WordVersioning.Api/Services/PdfUploadValidation.cs
Normal file
12
backend/WordVersioning.Api/Services/PdfUploadValidation.cs
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
namespace WordVersioning.Api.Services;
|
||||
|
||||
public static class PdfUploadValidation
|
||||
{
|
||||
public const string PdfContentType = "application/pdf";
|
||||
|
||||
public static bool IsValidPdf(IFormFile file) =>
|
||||
file.FileName.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase)
|
||||
&& (string.IsNullOrWhiteSpace(file.ContentType)
|
||||
|| file.ContentType.Equals(PdfContentType, StringComparison.OrdinalIgnoreCase)
|
||||
|| file.ContentType.Equals("application/octet-stream", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue