Phase 12 v9: draft opens in Word + Thai word-breaking fix (FR-16)

แก้ตามผลใช้งานจริง 2 ประเด็น:
1. เปิดใน Word ทันที — generate-draft เปลี่ยนจากคืนไฟล์ตรงๆ เป็นเก็บ
   ชั่วคราวใต้ drafts/ (lifecycle ลบรายวัน, config Minio__DraftRetentionDays)
   + คืน presigned URL ให้เว็บเปิดผ่าน ms-word:ofe พร้อมลิงก์สำรอง
   (useVersionOpen.openDocxUrl แยกแกนเปิด docx URL ใช้ร่วมกับ flow v0)
2. ตัดคำไทย — T14 แตก placeholder เป็น [ / ข้อความ / ] โดย run ข้อความ
   ถือ w:hint="cs" + w:cs เท่านั้น; เดิมเขียนค่าลง run ปีกกาทำให้ Word
   จัดหน้าไทยผิด — แก้เป็นเขียนที่ run ข้อความยาวสุด + SDT เปล่าสืบทอด
   rPr จาก sdtPr (ใช้ร่วมทั้ง generate และ generate-draft)

- ถอดของที่เลิกใช้: CORS expose Content-Disposition, downloadFile init,
  useApi.spec · Spec v9 + ADR 0002 Amendment + acceptance-review AC-19
- Tests: backend 84 / web 56 + build; QA reviewer PASS (2 rounds, 10 findings
  แก้ครบ — รวม bug จริงที่ test ใหม่จับได้: lifecycle Rules null ทำ startup
  ตั้ง rule ล้มแบบเงียบ) · ค้าง manual บน Word จริง

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
Suphonchai Phoonsawat 2026-09-10 11:49:16 +07:00
parent b3c1c667f7
commit 70f71f4c5c
23 changed files with 408 additions and 241 deletions

View file

@ -14,7 +14,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
Infra ทั้งหมดรันผ่าน `docker-compose.yml`: PostgreSQL + MinIO + Keycloak (OIDC, 3 public clients พร้อม PKCE: `word-addin-officejs`, `word-versioning-web`, `word-addin-vsto`)
**สถานะปัจจุบัน: Phase 0–12 — FR-16 ไฟล์ร่าง: automated QA PASS (backend 82 / add-in 66 / web 59 + build ครบ), ค้าง manual บน Word จริง · Phase 0–11 เสร็จครบ (FR-15 ผ่าน QA + manual บน Word for Mac 2026-09-08)** — ค้างยืนยันบน Word for Windows / Word on the web ตามรายการใน `docs/acceptance-review.md`
**สถานะปัจจุบัน: Phase 0–12 — FR-16 ไฟล์ร่าง: automated QA PASS (backend 84 / add-in 66 / web 56 + build ครบ · v9: เปิดใน Word ทันที + แก้ตัดคำไทย), ค้าง manual บน Word จริง · Phase 0–11 เสร็จครบ (FR-15 ผ่าน QA + manual บน Word for Mac 2026-09-08)** — ค้างยืนยันบน Word for Windows / Word on the web ตามรายการใน `docs/acceptance-review.md`
## เอกสารหลัก (อ่านก่อนเริ่มงานใดๆ)
@ -97,6 +97,6 @@ npx vitest run test/useTheme.spec.ts # รัน test เดี
`DocumentVersion.PdfObjectKey` nullable เก็บ sibling `.pdf` ใน bucket เดิม; history มี `hasPdf`. `/download` และ `/download-link` รับ `?format=pdf`, ไม่มี PDF → 404. Presigned URLs กำหนดชื่อไฟล์ไทย + `_vN`; PDF inline, docx attachment. เว็บเปิด Word ผ่าน `ms-word:ofe|u|<url>` พร้อมดาวน์โหลดสำรอง; PDF เปิดแท็บใหม่. Add-in ใช้ `openBrowserWindow` เท่านั้น. ข้อมูลเก่าไม่ backfill หรือเปลี่ยนเลข.
## ไฟล์ร่าง (FR-16 / spec v8)
## ไฟล์ร่าง (FR-16 / spec v8 · ปรับ v9)
ปุ่ม "สร้างไฟล์ร่าง" บนฟอร์มเดิม → `POST /api/templates/{id}/generate-draft` **stateless** (คืน docx ทันที ไม่สร้าง Document/version ไม่แตะ MinIO; ไม่มีปุ่มเปิดใน Word เพราะไม่มี URL) — validate ชุดเดียวกับ generate, ไม่มี `message`. ไฟล์ถูก stamp custom property **`TemplateId` ตัวเดียว** (ไม่มี SavedByAddIn/DocumentTrackingId) จึงเป็น untracked ในสายตา add-in. เมื่อกด Start Versioning, add-in อ่าน TemplateId แล้วแนบ form field `templateId` (ใช้เฉพาะตอน auto-create Document; validate ทุกครั้งที่ส่ง: ไม่ใช่ GUID/ไม่พบ → 400, deactivated → ผูกได้) → **Document ผูก template + v1** (ไม่มี v0) + field sync เต็มระบบ. CORS ต้อง expose `Content-Disposition` เพื่อให้เว็บอ่านชื่อไฟล์ไทย. การตัดสินใจ: `docs/adr/0002-draft-stateless.md`.
ปุ่ม "สร้างไฟล์ร่าง" บนฟอร์มเดิม → `POST /api/templates/{id}/generate-draft` — ไม่สร้าง Document/version ใน DB; generate แล้ว**เก็บชั่วคราว**ใน bucket `docx-versions` ใต้ prefix `drafts/` (lifecycle ลบรายวัน — `Minio__DraftRetentionDays`, backend ตั้ง rule ตอน startup) แล้วคืน **presigned URL อายุสั้น** ให้เว็บเปิดใน Word ทันทีผ่าน `ms-word:ofe` + ลิงก์สำรอง (`useVersionOpen.openDocxUrl`) — validate ชุดเดียวกับ generate, ไม่มี `message`. ไฟล์ถูก stamp custom property **`TemplateId` ตัวเดียว** (ไม่มี SavedByAddIn/DocumentTrackingId) จึงเป็น untracked ในสายตา add-in. เมื่อกด Start Versioning, add-in อ่าน TemplateId แล้วแนบ form field `templateId` (ใช้เฉพาะตอน auto-create Document; validate ทุกครั้งที่ส่ง: ไม่ใช่ GUID/ไม่พบ → 400, deactivated → ผูกได้) → **Document ผูก template + v1** (ไม่มี v0) + field sync เต็มระบบ. การแทนที่ค่าเขียนที่ **run ข้อความ placeholder จริง** (run ที่ถือ `w:hint="cs"`+`w:cs` — template ไทยแตก placeholder เป็น `[`/ข้อความ/`]` หลาย run) และ SDT เปล่าสืบทอด rPr จาก sdtPr ไม่งั้น Word ตัดคำไทยผิด. การตัดสินใจ: `docs/adr/0002-draft-stateless.md`.

View file

@ -2,6 +2,6 @@
- **Version PDF**: สำเนา PDF ที่ Word ผลิตขณะเปิดเอกสาร; ขาดได้แบบ best-effort
- **ฉบับตั้งต้น (v0)**: version ที่เว็บสร้างจากฟอร์ม ยังไม่เคยผ่าน Word
- **ไฟล์ร่าง (draft)**: ไฟล์ .docx ที่สร้างจาก template แบบ stateless — ยังไม่มีเอกสาร/version ใดๆ ในระบบ; ระบุ template ต้นทางผ่าน custom property และเริ่ม track จริงตอนกด Start Versioning ใน Word (ได้เป็น version 1)
- **ไฟล์ร่าง (draft)**: ไฟล์ .docx ที่สร้างจาก template — ยังไม่มีเอกสาร/version ใดๆ ในระบบ; ระบุ template ต้นทางผ่าน custom property และเริ่ม track จริงตอนกด Start Versioning ใน Word (ได้เป็น version 1)
- **เลข version**: จำนวนรอบการบันทึก version ผ่าน Word หลังฉบับตั้งต้น
- **ต้นฉบับ (docx)**: ตัว version จริง; PDF เป็น derived artifact

View file

@ -5,8 +5,8 @@ using WordVersioning.Api.Services;
namespace WordVersioning.Api.IntegrationTests;
/// <summary>FR-16 — ไฟล์ร่าง (draft): generate docx จาก template แบบ stateless
/// (ค่าครบ + stamp เฉพาะ TemplateId + ไม่เกิด Document/version ใดๆ ในระบบ)</summary>
/// <summary>FR-16 — ไฟล์ร่าง (draft): generate docx จาก template โดยไม่เกิด Document/version ใน DB
/// (v9: เก็บไฟล์ชั่วคราวใต้ drafts/ + คืน presigned URL ให้เว็บเปิดใน Word + ค่าที่แทนที่ได้ formatting ไทยถูกต้อง)</summary>
[Collection(IntegrationTestCollection.Name)]
public sealed class DraftTests(IntegrationTestFixture fixture)
{
@ -19,13 +19,16 @@ public sealed class DraftTests(IntegrationTestFixture fixture)
using var response = await client.PostAsJsonAsync(
$"api/templates/{templateId}/generate-draft",
new { values = TestFlow.DefaultValues }, IntegrationTestFixture.Json);
Assert.Equal(HttpStatusCode.OK, response.StatusCode); // ไม่ใช่ 201 — ไม่มีอะไรถูกสร้าง
Assert.Equal(HttpStatusCode.OK, response.StatusCode); // ไม่ใช่ 201 — ไม่มีอะไรถูกสร้างใน DB
// คืนไฟล์ docx พร้อมชื่อไฟล์จากชื่อ template (Content-Disposition attachment)
Assert.Equal(TestDocx.DocxContentType, response.Content.Headers.ContentType?.MediaType);
Assert.NotNull(response.Content.Headers.ContentDisposition?.FileNameStar);
var fileBytes = await response.Content.ReadAsByteArrayAsync();
// คืน presigned URL อายุสั้น (เปิดใน Word ผ่าน ms-word:ofe / ดาวน์โหลดสำรอง) — ดึงไฟล์จริงได้ทันที
var link = (await response.Content.ReadFromJsonAsync<DraftLinkResult>(IntegrationTestFixture.Json))!;
Assert.False(string.IsNullOrWhiteSpace(link.Url));
Assert.True(link.ExpiresAt > DateTimeOffset.UtcNow.AddSeconds(30));
using var browser = new HttpClient();
using var file = await browser.GetAsync(link.Url);
Assert.Equal(HttpStatusCode.OK, file.StatusCode);
var fileBytes = await file.Content.ReadAsByteArrayAsync();
// (1) ค่าทุก field ถูกแทนที่ใน SDT — เหมือน generate ปกติ
Assert.Equal("สมหญิง รักงาน", TestDocx.ReadSdtText(fileBytes, "employee_name"));
@ -33,7 +36,7 @@ public sealed class DraftTests(IntegrationTestFixture fixture)
Assert.Equal("75000", TestDocx.ReadSdtText(fileBytes, "salary"));
Assert.Equal("2026-08-01", TestDocx.ReadSdtText(fileBytes, "start_date"));
// (2) multi-run SDT ยังรักษา formatting ของ run แรก (bold)
// (2) multi-run SDT ยังรักษา formatting ของ run ผู้บริจาคค่า (bold — ชนะด้วย tie แบบเจตนา, ดู comment ใน TestDocx)
var positionRuns = TestDocx.ReadSdtRuns(fileBytes, "position");
var runWithText = Assert.Single(positionRuns.Where(r => r.Text.Length > 0));
Assert.True(runWithText.Bold);
@ -43,7 +46,7 @@ public sealed class DraftTests(IntegrationTestFixture fixture)
Assert.Null(TestDocx.ReadCustomProperty(fileBytes, "SavedByAddIn"));
Assert.Null(TestDocx.ReadCustomProperty(fileBytes, "DocumentTrackingId"));
// (4) stateless จริง: ไม่เกิด Document/version ใดๆ จาก template นี้
// (4) ไม่เกิด Document/version ใดๆ ใน DB จาก template นี้ (ไฟล์เก็บชั่วคราวใน MinIO เท่านั้น ไม่ใช่ record)
await using var db = fixture.CreateDb();
Assert.False(await db.Documents.AsNoTracking().AnyAsync(d => d.TemplateId == templateId));
Assert.False(await db.DocumentVersions.AsNoTracking()
@ -52,6 +55,63 @@ public sealed class DraftTests(IntegrationTestFixture fixture)
.AnyAsync(v => db.Documents.Any(d => d.Id == v.DocumentId && d.TemplateId == templateId)));
}
[Fact]
public async Task Draft_ThaiPlaceholder_UsesComplexScriptRun_ForWordBreaking()
{
// template ไทยจริง (แบบ T14) แตก placeholder เป็น [ / ข้อความ / ] — ค่าต้องลง run ข้อความ
// ที่ถือ w:hint="cs" + w:cs ไม่ใช่ run ปีกกา ไม่งั้น Word ตัดคำ/กระจายไทยผิดทั้งบรรทัด (v9)
var templateId = await CreateThaiTemplateAsync();
using var client = fixture.CreateClient();
const string detail = "โจทก์ฟ้องร้องให้เพิกถอนนิติกรรมสัญญาซื้อขายที่ดินโฉนดเลขที่ 12345 เนื่องจากถูกปลอมลายมือชื่อ";
using var response = await client.PostAsJsonAsync(
$"api/templates/{templateId}/generate-draft",
new { values = new Dictionary<string, string> { ["detail1"] = detail, ["remark1"] = "หมายเหตุผู้พิพากษา" } },
IntegrationTestFixture.Json);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var link = (await response.Content.ReadFromJsonAsync<DraftLinkResult>(IntegrationTestFixture.Json))!;
using var browser = new HttpClient();
using var file = await browser.GetAsync(link.Url);
Assert.Equal(HttpStatusCode.OK, file.StatusCode);
var fileBytes = await file.Content.ReadAsByteArrayAsync();
// ค่ายาวอยู่ใน run เดียวที่ถือ complex script flags ตาม run ข้อความไทยของ template
// (run ปีกกาถูกลบ text แต่คง rPr ไว้ — โล่ง มองไม่เห็นใน Word เหมือนพฤติกรรมเดิมของ generate)
var run = Assert.Single(TestDocx.ReadSdtRunInfo(fileBytes, "detail1").Where(r => r.Text.Length > 0));
Assert.Equal(detail, run.Text);
Assert.True(run.CsHint);
Assert.True(run.ComplexScript);
// SDT เปล่า: run ใหม่สืบทอด rPr จาก sdtPr (ฟอนต์ไทยของผู้แต่ง template) ไม่ใช่ default font
var emptyRun = Assert.Single(TestDocx.ReadSdtRunInfo(fileBytes, "remark1").Where(r => r.Text.Length > 0));
Assert.Equal("หมายเหตุผู้พิพากษา", emptyRun.Text);
Assert.Equal("TH SarabunPSK", emptyRun.AsciiFont);
}
/// <summary>สร้าง template จาก BuildThaiTemplateDocx พร้อมตั้ง field defs (detail1 required, remark1 ทางเลือก)</summary>
private async Task<Guid> CreateThaiTemplateAsync() {
using var client = fixture.CreateClient();
using var create = await client.PostAsync(
"api/templates",
ApiExtensions.TemplateForm(TestDocx.BuildThaiTemplateDocx(), $"thai {Guid.NewGuid():N}"));
create.EnsureSuccessStatusCode();
var created = (await create.Content.ReadFromJsonAsync<CreateTemplateResult>(IntegrationTestFixture.Json))!;
using var put = await client.PutAsJsonAsync($"api/templates/{created.Id}", new
{
fields = created.Fields.Select(f => new
{
key = f.Key,
label = f.Key,
type = "text",
isRequired = f.Key == "detail1",
sortOrder = f.SortOrder,
}),
}, IntegrationTestFixture.Json);
put.EnsureSuccessStatusCode();
return created.Id;
}
[Fact]
public async Task Draft_MissingRequiredField_Returns400()
{
@ -123,7 +183,28 @@ public sealed class DraftTests(IntegrationTestFixture fixture)
using var response = await client.PostAsJsonAsync(
$"api/templates/{templateId}/generate-draft", new { values }, IntegrationTestFixture.Json);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var fileBytes = await response.Content.ReadAsByteArrayAsync();
var link = (await response.Content.ReadFromJsonAsync<DraftLinkResult>(IntegrationTestFixture.Json))!;
using var browser = new HttpClient();
using var file = await browser.GetAsync(link.Url);
Assert.Equal(HttpStatusCode.OK, file.StatusCode);
var fileBytes = await file.Content.ReadAsByteArrayAsync();
Assert.Equal(templateId.ToString(), TestDocx.ReadCustomProperty(fileBytes, AddInMetadata.TemplateIdPropertyName));
}
[Fact]
public async Task Draft_LifecycleRule_Idempotent_AndSyncsRetentionDays()
{
// Program.cs ตั้ง rule `expire-drafts` ให้ bucket ตั้งแต่ startup แล้ว — ตรวจ path สร้างใหม่/ซ้ำ/เปลี่ยนค่า
// ผ่าน rule id อื่นกับ prefix ทดสอบ (ทั้งหมดยิง MinIO จริง) — ค่าคืน true = เขียน config จริง, false = ถูกต้องอยู่แล้ว
var storage = fixture.CreateStorage();
var bucket = fixture.MinioConfig.VersionsBucket;
Assert.True(await storage.EnsurePrefixExpiryAsync(bucket, "test-drafts-probe/", 1, "test-expire-probe")); // สร้าง
Assert.False(await storage.EnsurePrefixExpiryAsync(bucket, "test-drafts-probe/", 1, "test-expire-probe")); // idempotent
Assert.True(await storage.EnsurePrefixExpiryAsync(bucket, "test-drafts-probe/", 2, "test-expire-probe")); // sync ค่าใหม่
Assert.False(await storage.EnsurePrefixExpiryAsync(bucket, "test-drafts-probe/", 2, "test-expire-probe")); // ตั้งแล้วเช่นกัน
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() =>
storage.EnsurePrefixExpiryAsync(bucket, "test-drafts-probe/", 0, "test-expire-probe"));
}
}

View file

@ -31,8 +31,8 @@ public sealed class GenerateTests(IntegrationTestFixture fixture)
Assert.Equal("75000", TestDocx.ReadSdtText(fileBytes, "salary"));
Assert.Equal("2026-08-01", TestDocx.ReadSdtText(fileBytes, "start_date"));
// (2) multi-run SDT: ค่าใหม่เขียนที่ Text แรก (ใน Run แรก — สืบทอด rPr bold ของ template)
// และ Text ของ run หลังถูกลบ (เหลือ run โล่งที่มีแต่ rPr — มองไม่เห็น ไม่มีผลกับเนื้อหา)
// (2) multi-run SDT: ค่าใหม่เขียนที่ run ข้อความยาวสุด (v9 — ที่นี่คือ run bold ซึ่งชนะด้วย tie แบบเจตนา,
// ดู comment ใน TestDocx) และ Text ของ run หลังถูกลบ (เหลือ run โล่งที่มีแต่ rPr — มองไม่เห็น ไม่มีผลกับเนื้อหา)
var positionRuns = TestDocx.ReadSdtRuns(fileBytes, "position");
var runWithText = Assert.Single(positionRuns.Where(r => r.Text.Length > 0));
Assert.Equal("วิศวกรซอฟต์แวร์", runWithText.Text);

View file

@ -9,6 +9,7 @@ using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Testcontainers.Minio;
using Testcontainers.PostgreSql;
using WordVersioning.Api.Data;
@ -152,6 +153,14 @@ public sealed class IntegrationTestFixture : IAsyncLifetime
.UseNpgsql(_postgres.GetConnectionString())
.Options);
/// <summary>บริการ storage จริงจาก host ของ API — ใช้ทดสอบกับ MinIO container ตรงๆ (เช่น lifecycle rule)</summary>
public WordVersioning.Api.Services.IObjectStorageService CreateStorage() =>
_factory.Services.GetRequiredService<WordVersioning.Api.Services.IObjectStorageService>();
/// <summary>config MinIO ที่ host ใช้จริง (ชื่อ bucket ฯลฯ)</summary>
public WordVersioning.Api.Services.MinioOptions MinioConfig =>
_factory.Services.GetRequiredService<Microsoft.Extensions.Options.IOptions<WordVersioning.Api.Services.MinioOptions>>().Value;
/// <summary>user token จริงผ่าน password grant (client word-versioning-web ของ realm test เปิดให้ตอน test เท่านั้น)
/// — token มี aud = word-versioning-web จาก audience mapper ของ realm ตามที่ backend validate
/// (เลี่ยง PKCE dance ของ production flow — ตัว validate ฝั่ง backend คือตัวเดิมทดสอบเต็มรูปแบบ)</summary>

View file

@ -32,7 +32,9 @@ public static class TestDocx
new RunProperties(new Bold()),
new Text("[ชื่อพนักงาน]")))),
// SDT multi-run คนละ formatting — generate ต้องเขียนค่าที่ run แรก (รักษา bold) และลบ text ของ run หลัง
// SDT multi-run คนละ formatting — generate เขียนค่าที่ run ข้อความยาวสุด (v9) และลบ text ของอีก run
// ⚠️ สอง run ยาวเท่ากัน (8/8 อักขระ) โดยเจตนา: tie → run แรก (bold) ชนะเพราะ OrderByDescending
// เสถียร — GenerateTests/DraftTests ใช้จุดนี้เป็น regression guard ของพฤติกรรม tie ห้ามแก้ความยาว
new Paragraph(SdtRun("position",
new Run(
new RunProperties(new Bold()),
@ -83,6 +85,49 @@ public static class TestDocx
return buffer.ToArray();
}
/// <summary>template แบบที่พบใน template ไทยจริง (เช่น T14): placeholder แตกเป็น [ / ข้อความไทย / ]
/// หลาย run โดย run ข้อความไทยถือ complex script flags (w:hint="cs" + w:cs) ต่างจาก run ปีกกา
/// — และมี SDT เปล่าที่มี rPr อยู่ใน sdtPr เท่านั้น (formatting ที่ผู้แต่งกำหนดให้เนื้อหา)</summary>
public static byte[] BuildThaiTemplateDocx()
{
RunFonts ThaiFonts(bool hintCs) => new()
{
Ascii = "TH SarabunPSK",
HighAnsi = "TH SarabunPSK",
ComplexScript = "TH SarabunPSK",
Hint = hintCs ? FontTypeHintValues.ComplexScript : null,
};
// แบบ T14 หน้ารับส่ง: [ / รายละเอียด / ] — ค่าที่แทนที่ต้องได้ formatting ของ run ข้อความไทย
var detail = new Paragraph(new SdtRun(
new SdtProperties(new Tag { Val = "detail1" }),
new SdtContentRun(
new Run(
new RunProperties(ThaiFonts(false), new FontSize { Val = "36" }),
new Text("[")),
new Run(
new RunProperties(ThaiFonts(true), new FontSize { Val = "36" }, new ComplexScript()),
new Text("รายละเอียด")),
new Run(
new RunProperties(ThaiFonts(false), new FontSize { Val = "36" }),
new Text("]")))));
// SDT เปล่า: formatting อยู่ที่ sdtPr/rPr เท่านั้น — run ใหม่ต้องสืบทอดมา ไม่ใช่ default font
var remark = new Paragraph(new SdtRun(
new SdtProperties(
new RunProperties(ThaiFonts(true), new FontSize { Val = "36" }),
new Tag { Val = "remark1" }),
new SdtContentRun()));
var buffer = new MemoryStream();
using (var doc = WordprocessingDocument.Create(buffer, WordprocessingDocumentType.Document))
{
var main = doc.AddMainDocumentPart();
main.Document = new Document(new Body(detail, remark));
}
return buffer.ToArray();
}
/// <summary>อ่านข้อความรวมของ SDT ตาม tag (คืน "" เมื่อไม่มี tag นั้นในไฟล์) — ตรวจผลลัพธ์ generate</summary>
public static string ReadSdtText(byte[] docx, string tag)
{
@ -109,7 +154,7 @@ public static class TestDocx
}
/// <summary>อ่านข้อความ+formatting ของแต่ละ run ใน SDT (ตาม tag) — ใช้ตรวจว่า generate
/// รวม multi-run เป็น run เดียวแล้วยังรักษา run properties (bold/italic) ของ run แรกไว้</summary>
/// รวม multi-run เหลือ run ที่ถือค่าแล้วยังรักษา run properties (bold/italic) ของ run นั้นไว้ (v9: run ข้อความยาวสุด)</summary>
public static List<(string Text, bool Bold, bool Italic)> ReadSdtRuns(byte[] docx, string tag)
{
using var doc = WordprocessingDocument.Open(new MemoryStream(docx), false);
@ -140,6 +185,36 @@ public static class TestDocx
?.VTLPWSTR?.Text;
}
/// <summary>ข้อมูล run หนึ่งตัวใน SDT (ตาม tag): ข้อความรวม + hint=cs ของ rFonts + flag complex script (w:cs)
/// + ฟอนต์ ascii — ใช้ตรวจว่าค่าที่แทนที่ได้ formatting ของ run ข้อความไทยจริง (FR-16 v9)</summary>
public record SdtRunInfo(string Text, bool CsHint, bool ComplexScript, string? AsciiFont);
public static List<SdtRunInfo> ReadSdtRunInfo(byte[] docx, string tag)
{
using var doc = WordprocessingDocument.Open(new MemoryStream(docx), false);
var main = doc.MainDocumentPart!;
foreach (var root in new OpenXmlPartRootElement?[] { main.Document }
.Concat(main.HeaderParts.Select(h => (OpenXmlPartRootElement?)h.Header))
.Concat(main.FooterParts.Select(f => (OpenXmlPartRootElement?)f.Footer)))
{
if (root is null) continue;
foreach (var sdtTag in root.Descendants<Tag>())
{
if (sdtTag.Val?.Value != tag) continue;
var sdt = sdtTag.Parent?.Parent;
var content = sdt?.ChildElements.FirstOrDefault(c => c.LocalName == "sdtContent");
return content?.Descendants<Run>()
.Select(run => new SdtRunInfo(
string.Concat(run.Descendants<Text>().Select(t => t.Text)),
run.RunProperties?.RunFonts?.Hint?.Value == FontTypeHintValues.ComplexScript,
run.RunProperties?.ComplexScript is not null,
run.RunProperties?.RunFonts?.Ascii?.Value))
.ToList() ?? [];
}
}
return [];
}
private static SdtRun SdtRun(string tag, params Run[] runs) => new(
new SdtProperties(new Tag { Val = tag }),
new SdtContentRun(runs));

View file

@ -9,6 +9,9 @@ public record UploadVersionResult(Guid VersionId, int VersionNumber, bool HasPdf
public record DownloadLinkResult(string Url, DateTimeOffset ExpiresAt);
/// <summary>response ของ POST generate-draft (FR-16) — presigned URL อายุสั้นของไฟล์ร่าง</summary>
public record DraftLinkResult(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, bool HasPdf);

View file

@ -166,6 +166,7 @@ public sealed class VersionPdfTests(IntegrationTestFixture fixture)
}
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();
public Task<bool> EnsurePrefixExpiryAsync(string bucket, string prefix, int days, string ruleId, CancellationToken ct = default) => Task.FromResult(false);
}
}

View file

@ -305,11 +305,12 @@ public class TemplatesController(
return Ok(new ReplaceTemplateFileResponse(template.Id, FieldDtos(template), addedTags, removedTags));
}
/// <summary>FR-16 — ไฟล์ร่าง (draft): generate docx จาก template แบบ stateless — ไม่สร้าง
/// Document/DocumentVersion/DocumentFieldValue และไม่เขียน MinIO; คืนไฟล์ทันที
/// (ผู้ใช้เปิดใน Word แล้วกด Start Versioning เพื่อเริ่ม track เป็น v1 พร้อมผูกกลับมาที่ template นี้)</summary>
/// <summary>FR-16 — ไฟล์ร่าง (draft): generate docx จาก template โดยไม่สร้าง
/// Document/DocumentVersion/DocumentFieldValue ใน DB — เก็บไฟล์ชั่วคราวใน bucket versions ใต้ prefix
/// `drafts/` (หมดอายุรายวันตาม lifecycle) แล้วคืน presigned URL อายุสั้นให้เว็บเปิดใน Word
/// ผ่าน ms-word:ofe พร้อมลิงก์ดาวน์โหลดสำรอง (v9 — ตอนแรกคืนไฟล์ตรงๆ แต่ผู้ใช้ต้องเปิด Word เอง)</summary>
[HttpPost("{id:guid}/generate-draft")]
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(GenerateDraftResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status502BadGateway)]
@ -366,11 +367,32 @@ public class TemplatesController(
return StatusCode(StatusCodes.Status502BadGateway, "ไฟล์ template เสียหาย (เปิดด้วย OpenXML ไม่ได้)");
}
logger.LogInformation("Draft generated templateId={TemplateId} userId={UserId} bytes={Size}",
template.Id, currentUser.UserId, fileBytes.Length);
// เก็บชั่วคราวใต้ drafts/ — lifecycle ลบให้เอง (Program.cs ตั้ง rule ตอน startup)
var now = DateTimeOffset.UtcNow;
var objectKey = $"drafts/{template.Id}/{now:yyyyMMddHHmmss}_{Guid.NewGuid():N}.docx";
try
{
await storage.PutAsync(minioOptions.Value.VersionsBucket, objectKey,
new MemoryStream(fileBytes), DocxUploadValidation.DocxContentType, ct);
}
catch (AmazonServiceException ex)
{
logger.LogError(ex, "Draft upload failed templateId={TemplateId} userId={UserId}",
template.Id, currentUser.UserId);
return StatusCode(StatusCodes.Status502BadGateway, "อัปโหลดไฟล์ขึ้น storage ไม่สำเร็จ");
}
return File(fileBytes, DocxUploadValidation.DocxContentType,
DocxGenerationService.BuildGeneratedFileName(template.Name, DateTimeOffset.UtcNow));
// presigned อายุสั้น + ชื่อไฟล์ไทยตาม RFC 5987 (นโยบายเดียวกับ download-link ของ version)
var expirySeconds = Math.Clamp(minioOptions.Value.PresignedExpirySeconds, 30, 600);
var fileName = DocxGenerationService.BuildGeneratedFileName(template.Name, now);
var url = storage.GetPresignedGetUrl(
minioOptions.Value.VersionsBucket, objectKey, TimeSpan.FromSeconds(expirySeconds),
$"attachment; filename*=UTF-8''{Uri.EscapeDataString(fileName)}");
logger.LogInformation("Draft generated templateId={TemplateId} userId={UserId} bytes={Size} key={ObjectKey}",
template.Id, currentUser.UserId, fileBytes.Length, objectKey);
return Ok(new GenerateDraftResponse(url, now.AddSeconds(expirySeconds)));
}
/// <summary>deactivate (soft-delete) — document เก่าที่อ้างอยู่ยังใช้ได้ต่อ แต่สร้างเอกสารใหม่ไม่ได้</summary>

View file

@ -61,6 +61,10 @@ public class GenerateDraftRequest
public JsonElement Values { get; set; }
}
/// <summary>response ของ POST /api/templates/{id}/generate-draft (FR-16) — presigned URL อายุสั้น
/// ให้เว็บเปิดใน Word ผ่าน ms-word:ofe พร้อมลิงก์ดาวน์โหลดสำรอง (object ใต้ drafts/ หมดอายุรายวันตาม lifecycle)</summary>
public record GenerateDraftResponse(string Url, DateTimeOffset ExpiresAt);
public record TemplateFieldDto(Guid Id, string Key, string Label, string Type, string Editor, bool IsRequired, int SortOrder);
public record TemplateListItem(

View file

@ -2,6 +2,8 @@ using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Microsoft.OpenApi.Models;
using WordVersioning.Api.Data;
using WordVersioning.Api.Services;
@ -65,14 +67,11 @@ builder.Services
builder.Services.AddAuthorization();
// CORS — add-in task pane (https://localhost:3000) + web portal (http://localhost:5173) เท่านั้น
// expose Content-Disposition ให้ fetch ฝั่งเว็บอ่านชื่อไฟล์ (ไทย) ของไฟล์ร่างที่ดาวน์โหลดได้ (FR-16)
// — ไม่ expose แล้ว header จะเป็น null บน cross-origin และชื่อไฟล์หายเงียบๆ
const string corsPolicy = "frontends";
builder.Services.AddCors(options => options.AddPolicy(corsPolicy, policy => policy
.WithOrigins(builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>() ?? [])
.AllowAnyHeader()
.AllowAnyMethod()
.WithExposedHeaders("Content-Disposition")));
.AllowAnyMethod()));
// --- dev HTTPS -------------------------------------------------------------
// Word webview (WKWebView บน Mac) บล็อก fetch จากหน้า https ไป http แบบเงียบๆ ("Load failed")
@ -102,6 +101,21 @@ if (devHttpsEnabled)
var app = builder.Build();
// FR-16 — ไฟล์ร่างอยู่ใต้ prefix `drafts/` ของ bucket versions: ตั้ง lifecycle ให้หมดอายุเอง (idempotent)
// best-effort — MinIO ยังไม่พร้อมตอนสตาร์ทก็แค่ log เตือน ไม่บล็อก (ไฟล์ร่างจะค้างนานขึ้นเท่านั้น)
try
{
await using var scope = app.Services.CreateAsyncScope();
var storageInit = scope.ServiceProvider.GetRequiredService<IObjectStorageService>();
var minioConfig = scope.ServiceProvider.GetRequiredService<IOptions<MinioOptions>>().Value;
await storageInit.EnsurePrefixExpiryAsync(
minioConfig.VersionsBucket, "drafts/", minioConfig.DraftRetentionDays, "expire-drafts");
}
catch (Exception ex)
{
app.Logger.LogWarning(ex, "ตั้ง lifecycle ของไฟล์ร่าง (drafts/) ไม่สำเร็จ — object จะไม่หมดอายุอัตโนมัติ");
}
if (app.Environment.IsDevelopment())
{
app.UseSwagger();

View file

@ -108,8 +108,11 @@ public class DocxGenerationService : IDocxGenerationService
}
}
/// <summary>แทนที่ข้อความใน SDT ที่ <b>run level</b> — เก็บ Run เดิมไว้ตัวแรก (formatting ตาม template)
/// แล้วลบ Text ที่เหลือ ไม่ทับทั้ง paragraph (spec ส่วน 9 เพิ่มเติม v2)</summary>
/// <summary>แทนที่ข้อความใน SDT ที่ <b>run level</b> — เขียนค่าที่ Run ของ "ข้อความ placeholder จริง"
/// (run ที่มีข้อความยาวสุด) เพื่อสืบทอด rPr ของมัน แล้วลบ Text ที่เหลือ ไม่ทับทั้ง paragraph
/// (spec ส่วน 9 เพิ่มเติม v2 · v9: template ไทยมักแตก placeholder เป็น [ / ข้อความ / ] หลาย run —
/// ตัวข้อความคือ run ที่ถือ complex script flags (w:hint="cs" + w:cs) ที่ทำให้ Word ตัดคำ/กระจายไทยถูก
/// ถ้าเขียนลง run วงเล็บที่ไม่มี flags นี้ ค่ายาวๆ จะจัดหน้าผิดทั้งบรรทัด)</summary>
private static void ReplaceSdtContent(Tag tag, string value)
{
var sdtPr = tag.Parent; // w:sdtPr
@ -122,7 +125,11 @@ public class DocxGenerationService : IDocxGenerationService
if (texts.Count == 0)
{
// SDT ไม่มีข้อความเลย (เช่น ที่ว่างมา) — สร้าง Run ใหม่ใน paragraph แรก (block) หรือใน content ตรงๆ (run)
// โดยสืบทอด rPr จาก sdtPr ถ้ามี (คือ formatting ที่ผู้แต่ง template กำหนดให้เนื้อหาของ control นี้ —
// เช่นเดียวกับที่ Word ใช้ตอนผู้ใช้พิมพ์ลง control ไม่ใช่ปล่อยเป็น default font)
var run = new Run(new Text(value) { Space = SpaceProcessingModeValues.Preserve });
if (sdtPr?.Elements<RunProperties>().FirstOrDefault() is { } sdtRunProperties)
run.RunProperties = (RunProperties)sdtRunProperties.CloneNode(true);
if (content is SdtContentBlock)
{
var paragraph = content.ChildElements.OfType<Paragraph>().FirstOrDefault();
@ -140,12 +147,16 @@ public class DocxGenerationService : IDocxGenerationService
return;
}
// เขียนที่ Text แรก (อยู่ใน Run แรก — สืบทอด rPr ของ run นั้น) แล้วเก็บ space ไว้กรณีค่ามีช่องว่างหัว/ท้าย
texts[0].Text = value;
texts[0].Space = SpaceProcessingModeValues.Preserve;
// เลือก Run ผู้บริจาค formatting = run ที่มีข้อความยาวสุด (tie → run แรก) — หลีกเลี่ยง run ปีกกา/สัญลักษณ์
// ที่ formatting มักไม่ครบ (เช่น ไม่มี complex script flags)
var donorText = texts.OrderByDescending(t => t.Text?.Length ?? 0).First();
// Text ที่เหลือ (จากการ split run เดิม เช่น rsid/formatting ปนกัน) ลบทิ้ง — field = ค่าเดียว
foreach (var text in texts.Skip(1))
// เขียนที่ Text ของ donor แล้วเก็บ space ไว้กรณีค่ามีช่องว่างหัว/ท้าย
donorText.Text = value;
donorText.Space = SpaceProcessingModeValues.Preserve;
// Text อื่นทั้งหมด (รวม donor ถ้ามีหลาย Text ใน run เดียวกัน ไม่เกิด) ลบทิ้ง — field = ค่าเดียว
foreach (var text in texts.Where(t => !ReferenceEquals(t, donorText)))
{
var run = text.Parent;
text.Remove();

View file

@ -21,4 +21,8 @@ public class MinioOptions
/// <summary>อายุของ presigned download URL ที่ออกให้เบราว์เซอร์ (วินาที) — สั้นๆ ตาม spec</summary>
public int PresignedExpirySeconds { get; set; } = 120;
/// <summary>FR-16 — จำนวนวันที่ไฟล์ร่าง (object ใต้ prefix `drafts/` ของ VersionsBucket) ถูกลบโดย
/// lifecycle rule (ผู้ใช้เปิดผ่าน presigned URL ในไม่กี่นาทีแรก หลังจากนั้นเป็นขยะ)</summary>
public int DraftRetentionDays { get; set; } = 1;
}

View file

@ -16,6 +16,12 @@ public interface IObjectStorageService
/// <summary>สร้าง presigned GET URL อายุสั้น — ให้เปิดในเบราว์เซอร์ของผู้ใช้โดยตรง
/// (webview ของ Word ทิ้ง blob download แบบเงียบๆ — spec อนุญาต presigned URL อายุสั้นสำหรับดาวน์โหลด)</summary>
string GetPresignedGetUrl(string bucket, string key, TimeSpan expiry, string? contentDisposition = null);
/// <summary>FR-16 — ตั้ง lifecycle rule ให้ object ใต้ prefix หนึ่งหมดอายุเองตามจำนวนวัน
/// (idempotent ตาม rule id และ sync ค่า days ใหม่ถ้า config เปลี่ยนหลัง boot แรก — days &lt; 1 คือ throw)
/// ใช้กับไฟล์ร่างที่เก็บชั่วคราวใน bucket versions ใต้ `drafts/`
/// คืน true เมื่อเขียน config ใหม่, false เมื่อ rule ถูกต้องอยู่แล้ว (ใช้ assert ใน test)</summary>
Task<bool> EnsurePrefixExpiryAsync(string bucket, string prefix, int days, string ruleId, CancellationToken ct = default);
}
/// <summary>Wrapper เรียก MinIO ผ่าน AWSSDK.S3 (S3-compatible API)</summary>
@ -99,6 +105,54 @@ public class ObjectStorageService : IObjectStorageService
return url;
}
public async Task<bool> EnsurePrefixExpiryAsync(string bucket, string prefix, int days, string ruleId, CancellationToken ct = default)
{
ArgumentOutOfRangeException.ThrowIfLessThan(days, 1);
var rules = new List<LifecycleRule>();
try
{
var existing = await _s3.GetLifecycleConfigurationAsync(
new GetLifecycleConfigurationRequest { BucketName = bucket }, ct);
// Rules เป็น null ได้เมื่อ bucket มี lifecycle config แต่ยังไม่มี rule เลย
rules = existing.Configuration.Rules ?? [];
}
catch (AmazonS3Exception ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
{
// ยังไม่มี lifecycle เลย — เริ่มชุดใหม่
}
var rule = rules.FirstOrDefault(r => r.Id == ruleId);
if (rule is null)
{
rules.Add(new LifecycleRule
{
Id = ruleId,
Filter = new LifecycleFilter { Prefix = prefix },
Status = LifecycleRuleStatus.Enabled,
Expiration = new LifecycleRuleExpiration { Days = days },
});
}
else if (rule.Expiration?.Days == days)
{
_logger.LogDebug("Lifecycle rule {RuleId} มีอยู่แล้วที่ {Bucket} (expire={Days}d)", ruleId, bucket, days);
return false;
}
else
{
// config เปลี่ยนหลัง boot แรก — sync ค่าใหม่ ไม่ใช่ปล่อยให้เป็น no-op เงียบๆ
rule.Expiration = new LifecycleRuleExpiration { Days = days };
}
await _s3.PutLifecycleConfigurationAsync(new PutLifecycleConfigurationRequest
{
BucketName = bucket,
Configuration = new LifecycleConfiguration { Rules = rules },
}, ct);
_logger.LogInformation("Lifecycle rule {RuleId} ตั้งแล้ว: {Bucket}/{Prefix} expire={Days}d", ruleId, bucket, prefix, days);
return true;
}
/// <summary>Stream ที่ครอบ GetObjectResponse — dispose stream = dispose response ทั้งอัน
/// (ไม่งั้น controller ต้องรับ response มาด้วยแล้วจัดการ lifetime เอง ยุ่ง)</summary>
private sealed class S3ResponseStream(GetObjectResponse response) : Stream

View file

@ -136,14 +136,15 @@
QA reviewer: **PASS**, 2 rounds, 3 findings fixed (build compatibility, pending PDF tab cleanup, PDF success copy); 2026-09-08. Manual acceptance ผ่านบน Word for Mac 2026-09-08 — **Phase 11 ปิดแล้ว**; ค้างเฉพาะ Word for Windows / Word on the web ตามรายการด้านบน.
## AC-19 — ไฟล์ร่าง (v8)
## AC-19 — ไฟล์ร่าง (v8 · ปรับ v9)
**ผล: 🔶 ผ่านด้วย automated tests — ค้างยืนยันด้วยมือบน Word จริง** (ตาม pattern เดิม: unit/integration tests ยืนยัน contract และข้อมูลใน DB ได้ แต่ไม่ยืนยันพฤติกรรม add-in ใน Word จริงได้)
**ผล: 🔶 ผ่านด้วย automated tests — ค้างยืนยันด้วยมือบน Word จริง** (ตาม pattern เดิม: unit/integration tests ยืนยัน contract และข้อมูลใน DB ได้ แต่ไม่ยืนยันพฤติกรรม add-in/การเรนเดอร์ Word จริงได้)
| เกณฑ์ | หลักฐาน |
|---|---|
| สร้างไฟล์ร่างแล้ว stateless จริง (ไม่เกิด Document/version/field values) + ค่าครบ + formatting รักษาไว้ | `DraftTests.Draft_FillsAllFields_PreservesFormatting_StampsOnlyTemplateId_CreatesNothing` |
| สร้างไฟล์ร่างแล้วไม่เกิด Document/version/field values ใน DB + ค่าครบ + formatting รักษาไว้ + **presigned URL ดึงไฟล์จริงได้ทันที** (v9) | `DraftTests.Draft_FillsAllFields_PreservesFormatting_StampsOnlyTemplateId_CreatesNothing` |
| stamp เฉพาะ `TemplateId` — ไม่มี SavedByAddIn/DocumentTrackingId | test เดียวกัน (อ่าน custom properties จากไฟล์จริง) |
| **ค่าที่แทนที่ได้ complex script ของ run ข้อความไทย (w:hint="cs"+w:cs) · SDT เปล่าสืบทอด sdtPr rPr** (v9 — แก้การตัดคำไทย) | `DraftTests.Draft_ThaiPlaceholder_UsesComplexScriptRun_ForWordBreaking` (template จำลองโครงสร้าง T14) |
| validate เหมือน FR-10: required ขาด/type ผิด → 400, unknown → 404, deactivate → 400, ค่าเกินนิยาม ignore | `DraftTests` ที่เหลือ (5 tests) |
| Start Versioning ของไฟล์ร่าง: upload + templateId → **v1** + Document ผูก template + field sync ครบ + snapshot แบบมีนิยาม | `TemplateLinkingTests.Upload_WithTemplateId_OnNewDocument_CreatesLinkedDocV1WithFieldSync` |
| upload ถัดไปไม่ส่ง templateId → v2, TemplateId คงเดิม | test เดียวกัน (upload รอบสอง) |
@ -152,15 +153,17 @@ QA reviewer: **PASS**, 2 rounds, 3 findings fixed (build compatibility, pending
| document เดิม (standalone) ส่ง templateId มา → เฉย | `Upload_ExistingDocument_IgnoresTemplateId` |
| endpoint ใหม่ต้อง [Authorize] | `AuthTests` เพิ่ม `POST api/templates/{0}/generate-draft` |
| add-in: อ่าน TemplateId / ส่งเฉพาะ startVersioning / append form field | `metadataService.test.ts` (ไฟล์ร่างอ่าน TemplateId), `versionFlow.test.ts` (แนบ/ไม่แนบ templateId), `versioningService.test.ts` (form field) |
| เว็บ: ปุ่มไฟล์ร่าง POST → blob, ชื่อไทยจาก Content-Disposition, 401 retry, !ok → error | `useApi.spec.ts` (4 tests) + CORS expose `Content-Disposition` ที่ `Program.cs` |
| เว็บ: เปิด docx URL ที่ถืออยู่เอง (ไฟล์ร่าง) ผ่าน ms-word + fallback + notice | `useVersionOpen.spec.ts` `openDocxUrl` test |
ชุดเต็ม: backend **82 ผ่าน**, add-in **66 ผ่าน**, web **59 ผ่าน** + production build ผ่าน.
ชุดเต็ม (v9): backend **84 ผ่าน**, add-in **66 ผ่าน**, web **56 ผ่าน** + production build ผ่าน (backend 82 / web 59 ของ v8 + แก้ v9)
### ค้างยืนยันด้วยมือ (ต้องมี Word จริง)
- [ ] Mac: เว็บสร้างไฟล์ร่าง → ได้ .docx ชื่อไทยถูกต้อง → เปิดใน Word → task pane แสดง untracked + hint ไฟล์ร่าง → Start Versioning → v1 + หน้า document detail ของเว็บแสดง template และค่า fields
- [ ] Mac: เว็บสร้างไฟล์ร่าง → **Word เปิดไฟล์ทันที** พร้อมลิงก์สำรอง → ตรวจการตัดคำ/จัดหน้าภาษาไทยของ field ที่ค่ายาว (เช่น Detail ของ T14) ตรงตาม template → task pane แสดง untracked + hint ไฟล์ร่าง → Start Versioning → v1 + หน้า document detail ของเว็บแสดง template และค่า fields
- [ ] Word for Windows / Word on the web — ตามข้อค้างเดิมตั้งแต่ AC-1
รายการเต็ม: [แผน FR-16](plans/fr16-draft-generation-plan.md).
QA reviewer: **PASS**, 2 rounds, 8 findings แก้ครบไม่มีที่ถูกปฏิเสธ (2026-09-10) — round 1: blocker `.at(-1)` ใน test ใหม่ตัดกับ tsconfig `lib: es2020` ทำ `addin npm run build` พัง (jest ไม่ type-check จึงรอดมา) + minors 4 (นับจำนวน test ผิดในตารางนี้, stale notices ระหว่างสองปุ่ม submit, AlertBox สำเร็จไม่ใช่ `type="success"`, `isTrackedDocument` ไม่มี production caller → เก็บไว้พร้อมหมายเหตุเหตุผล) · round 2: minors 2 + pre-existing 1 (หัวเอกสาร/สถานะเก่าใน acceptance-review, CLAUDE.md, implementation-plan header). ค้าง manual บน Word จริงตามรายการด้านบน.
QA reviewer รอบ v9 (เปิดใน Word + ตัดคำไทย): **PASS**, 2 rounds, 10 findings แก้ครบไม่มีที่ถูกปฏิเสธ (2026-09-10) — round 1: minors 6 (dead `downloadFile`/`filenameFrom`, glossary "stateless" เก่า, lifecycle config เปลี่ยนแล้วเป็น no-op เงียบๆ + days ไม่ validate, ไม่มี test คุม lifecycle, comment เก่าใน TestDocx, typo ใน spec) · round 2: minors 2 + nits 2 (comment เก่าอีก 2 จุด, "(stateless)" ตกค้าง 2 บรรทัด, lifecycle test เพิ่ม assertion ผ่านค่าคืน `true/false`, ตัว verdict นี้เอง) — และ test ใหม่จับ bug จริงได้: `Configuration.Rules` เป็น null ได้ทำให้ startup ตั้ง rule ล้มแบบเงียบมาตั้งแต่แรก (โดน catch กลืน) แก้ด้วย `?? []`.

View file

@ -1,14 +1,18 @@
# ADR 0002: ไฟล์ร่าง stateless — stamp TemplateId ตัวเดียว ผูก template ทีหลังตอน Start Versioning
# ADR 0002: ไฟล์ร่าง — stamp TemplateId ตัวเดียว ผูก template ทีหลังตอน Start Versioning
วันที่: 2026-09-10 · สถานะ: Accepted
วันที่: 2026-09-10 · สถานะ: Accepted (แก้ไข v9 วันเดียวกัน — ด้วย Amendment ด้านล่าง)
## Decision
ไฟล์ร่าง (FR-16) สร้างแบบ stateless: `POST /api/templates/{id}/generate-draft` คืนไฟล์ทันที ไม่สร้าง Document/version ไม่เขียน MinIO. ไฟล์ถูก stamp custom property `TemplateId` ตัวเดียว (ไม่มี SavedByAddIn/DocumentTrackingId) จึงเป็น untracked ในสายตา add-in. การผูกกลับเข้า template เกิดทีหลังผ่าน form field `templateId` ทางเลือกของ upload ซึ่งใช้เฉพาะตอน auto-create Document — เอกสารเกิดเป็น version 1 โดยไม่มี v0.
ไฟล์ร่าง (FR-16) สร้างโดยไม่มี record ใน DB: `POST /api/templates/{id}/generate-draft` ไม่สร้าง Document/version/field values. ไฟล์ถูก stamp custom property `TemplateId` ตัวเดียว (ไม่มี SavedByAddIn/DocumentTrackingId) จึงเป็น untracked ในสายตา add-in. การผูกกลับเข้า template เกิดทีหลังผ่าน form field `templateId` ทางเลือกของ upload ซึ่งใช้เฉพาะตอน auto-create Document — เอกสารเกิดเป็น version 1 โดยไม่มี v0.
## Amendment (v9, 2026-09-10 — หลังใช้งานจริง)
เดิม endpoint คืนไฟล์เป็น stream ตรงๆ (stateless เต็มที่) ทำให้ผู้ใช้ต้องเปิด Word เองจาก Downloads. ผู้ใช้ต้องการเปิดใน Word ทันทีแบบ flow v0 จึงแก้เป็น: generate แล้วเก็บ object ชั่วคราวใต้ prefix `drafts/` ของ bucket docx-versions พร้อม **lifecycle rule ลบรายวัน** (config ได้) แล้วคืน presigned URL อายุสั้นให้เว็บเปิดผ่าน `ms-word:ofe`. สาระสำคัญของ decision เดิมยังอยู่: ไม่มี Document สถานะร่างใน DB ไม่ต้องจัดการ row ที่ไม่ถูกเปิด — ขยะจำกัดอยู่แค่ object ใต้ prefix เดียวที่ลบตัวเอง
นอกจากนี้พบจาก template จริง (T14): ค่าที่แทนที่ต้องเขียนที่ run ของข้อความ placeholder จริง (run ที่ถือ `w:hint="cs"` + `w:cs`) ไม่ใช่ run แรก (ปีกกา) ไม่งั้น Word ตัดคำ/กระจายภาษาไทยผิดทั้งบรรทัด และ SDT เปล่าต้องสืบทอด rPr จาก sdtPr
## Alternatives rejected
- สร้าง Document สถานะ "ร่าง" ค้างไว้ใน DB + ไฟล์ใน MinIO รอผู้ใช้เปิด: ต้องมี lifecycle/cleanup ของเอกสารที่ไม่ถูกเปิด และเอกสารขยะในหน้ารายการ
- สร้าง Document สถานะ "ร่าง" ค้างไว้ใน DB รอผู้ใช้เปิด: ต้องมี lifecycle/cleanup ของ row ที่ไม่ถูกเปิด และเอกสารขยะในหน้ารายการ
- stamp SavedByAddIn + DocumentTrackingId ตั้งแต่ตอนสร้างไฟล์ร่าง: add-in จะมองเป็น tracked document แต่ backend ไม่มี record → history ติด 404 ถาวร (ไม่มี 404-handling ในปัจจุบัน)
- ปุ่ม "เปิดใน Word" สำหรับไฟล์ร่างแบบ flow v0 (`ms-word:ofe|u|<url>`): ไม่มี URL เพราะไม่มี object ใน storage — ต้องเสีย statelessness ไปเก็บไฟล์ชั่วคราวพร้อม TTL
## Consequences
ไฟล์ร่างหาย/ไม่ถูกเปิด = ไม่มีอะไรค้างในระบบ (ต้องกรอกฟอร์มใหม่). เอกสารจากไฟล์ร่างเริ่มที่ v1 ไม่มี v0 — เลข version ยังกำหนดเป็น "รอบบันทึกผ่าน Word หลังฉบับตั้งต้น" โดย v0 เป็นทางเลือกของ flow เดิม. Field sync เริ่มทำงานที่ v1 จากค่าที่ extract ใน Word. CORS ต้อง expose `Content-Disposition` เพื่อให้เว็บอ่านชื่อไฟล์ไทยได้.
เอกสารจากไฟล์ร่างเริ่มที่ v1 ไม่มี v0 — เลข version ยังกำหนดเป็น "รอบบันทึกผ่าน Word หลังฉบับตั้งต้น" โดย v0 เป็นทางเลือกของ flow เดิม. Field sync เริ่มทำงานที่ v1 จากค่าที่ extract ใน Word. Object ไฟล์ร่างที่ไม่ถูกเปิดถูก lifecycle ลบในรอบวัน (ลิงก์ presigned อายุสั้นอยู่แล้ว การเปิดซ้ำหลังผ่านไปนานต้องสร้างไฟล์ร่างใหม่)

View file

@ -640,9 +640,10 @@ npm test # vitest (46 ตัว)
รายละเอียดเต็ม: [fr16-draft-generation-plan.md](fr16-draft-generation-plan.md) — decisions จาก grill session (stateless, stamp TemplateId ตัวเดียว, ผูก template ผ่าน form field `templateId` ตอน Start Versioning, deactivated ผูกได้, คง flow v0, ศัพท์ "ไฟล์ร่าง")
- [x] Spec v8 + glossary + ADR 0002
- [x] Backend: `POST /api/templates/{id}/generate-draft` (stateless) + `DocxGenerationService.GenerateDraft` + แยก `TemplateFieldValidation` + form field `templateId` ของ upload + CORS expose `Content-Disposition`
- [x] Backend: `POST /api/templates/{id}/generate-draft` (ตอน v8 คืนไฟล์ตรงๆ — ปรับใน v9) + `DocxGenerationService.GenerateDraft` + แยก `TemplateFieldValidation` + form field `templateId` ของ upload
- [x] Add-in: อ่าน `TemplateId` + ส่ง `templateId` เฉพาะ Start Versioning + hint ไฟล์ร่างใน task pane
- [x] Web: ปุ่ม "สร้างไฟล์ร่าง" + `downloadFile` รับ `init` (POST → blob)
- [x] Web: ปุ่ม "สร้างไฟล์ร่าง"
- [x] Tests/build (backend 82 / add-in 66 / web 59 + build ทั้ง add-in/web)
- [x] QA reviewer PASS (2 rounds, 2026-09-10)
- [ ] ตรวจ Word จริง (manual): เว็บสร้างไฟล์ร่าง → เปิดใน Word → untracked + hint → Start Versioning → v1 ผูก template + fields sync · รวมในรายการค้างเดิมของ Word for Windows / Word on the web
- [x] **v9 (feedback จากการใช้จริง): เปิดใน Word ทันที** — เก็บ object ใต้ `drafts/` + lifecycle รายวัน + คืน presigned URL + `useVersionOpen.openDocxUrl` · **แก้ตัดคำไทย** — ค่าเขียนที่ run ข้อความ placeholder จริง (complex script) + SDT เปล่าสืบทอด sdtPr rPr (backend 84 / web 56) + QA reviewer PASS (2 rounds, 2026-09-10)
- [ ] ตรวจ Word จริง (manual): เว็บสร้างไฟล์ร่าง → **Word เปิดทันที + ตัดคำไทยถูก** → untracked + hint → Start Versioning → v1 ผูก template + fields sync · รวมในรายการค้างเดิมของ Word for Windows / Word on the web

View file

@ -1,5 +1,6 @@
# Spec: Word Versioning Add-in (Office.js Cross-Platform + .NET Backend)
> ฉบับแก้ไข v9 (2026-09-10): ปรับ FR-16 ตามผลใช้งานจริง — ไฟล์ร่างเก็บชั่วคราวใน MinIO (prefix `drafts/` + lifecycle ลบรายวัน) เพื่อคืน presigned URL ให้เว็บ**เปิดใน Word ทันที** (เดิมคืนไฟล์ตรงๆ ผู้ใช้ต้องเปิดเอง) + ค่าที่แทนที่เขียนที่ run ข้อความ placeholder จริง (complex script ไทย) แก้การตัดคำภาษาไทย
> ฉบับแก้ไข v8 (2026-09-10): เพิ่ม FR-16 — ไฟล์ร่าง (draft): สร้าง .docx จาก template แบบ stateless (ไม่สร้าง Document/version, stamp property `TemplateId` ตัวเดียว) + ผูก template ตอน Start Versioning ผ่าน form field `templateId` ทางเลือก — ไฟล์ร่างเริ่ม versioning ที่ v1 โดยไม่มี v0
> ฉบับแก้ไข v7 (2026-09-08): FR-15 — PDF จาก Word แบบ best-effort; generate เป็น v0, add-in เริ่ม v1; เว็บเปิด Word/PDF พร้อมดาวน์โหลดสำรอง
> ฉบับแก้ไข v6 (2026-08-22): เพิ่ม FR-14 — editor ต่อ field (รูปแบบช่องกรอกในฟอร์มสร้างเอกสาร): `text` เลือก `textbox`/`textarea` ได้, `number`/`date` ใช้ control ตาม type — presentation metadata เท่านั้น ค่า field ยังเป็น string ทั้ง flow
@ -293,13 +294,14 @@ POST /api/documents/generate { templateId, values: { <fieldKey>: <value> } }
- เว็บ history ทั้ง desktop/mobile เปิด docx ผ่าน `ms-word:ofe|u|<presigned-url>` (ไม่ encode URL ทั้งก้อนซ้ำตาม Microsoft Office URI Schemes); PDF แท็บใหม่; มีลิงก์ดาวน์โหลดสำรองและ heuristic blur/visibilitychange ~1.5s ซึ่งไม่ใช่หลักฐานว่า Word เปิดสำเร็จ
- หน้า generate success เปิด v0 ใน Word พร้อมลิงก์ดาวน์โหลดสำรอง; PDF fidelity และ tracked changes ต้องยืนยันบน Word จริง
### FR-16: ไฟล์ร่าง (draft) — สร้าง .docx จาก template โดยไม่สร้าง Document (เพิ่มเติม v8)
- **`POST /api/templates/{templateId}/generate-draft`** body `{ values }` — **stateless**: ไม่สร้าง `Document`/`DocumentVersion`/`DocumentFieldValue` และไม่เขียน MinIO; คืนไฟล์ .docx ทันที (200 + attachment, ชื่อ `ชื่อtemplate_yyyyMMdd-HHmmss.docx` — ไม่ใช่ 201 เพราะไม่มีอะไรถูกสร้าง)
### FR-16: ไฟล์ร่าง (draft) — สร้าง .docx จาก template โดยไม่สร้าง Document (เพิ่มเติม v8 · ปรับ v9)
- **`POST /api/templates/{templateId}/generate-draft`** body `{ values }` — ไม่สร้าง `Document`/`DocumentVersion`/`DocumentFieldValue` ใน DB; generate ไฟล์แล้ว**เก็บชั่วคราว**ใน bucket `docx-versions` ใต้ prefix `drafts/{templateId}/{yyyyMMddHHmmss}_{guid}.docx` ซึ่ง lifecycle rule ลบให้เอง (default 1 วัน — config `Minio__DraftRetentionDays`, backend ตั้ง rule ตอน startup แบบ idempotent/best-effort) แล้วคืน **200 `{ url, expiresAt }`** เป็น presigned GET URL อายุสั้น (attachment + ชื่อไฟล์ไทย RFC 5987) ให้เว็บเปิดใน Word ทันที (v8 เดิมคืนไฟล์เป็น stream ตรงๆ — ผู้ใช้ต้องเปิดจาก Downloads เอง)
- validate เหมือน FR-10 (required + type + format; ส่งเกินนิยาม ignore) · template ไม่พบ → 404, deactivate → 400 ตามนโยบาย FR-10 · ไม่มี `message` (ไม่มี version จึงไม่มี commit message)
- stamp custom property **`TemplateId` = GUID ของ template ตัวเดียว** — ไม่ stamp `SavedByAddIn`/`DocumentTrackingId` ไฟล์ร่างจึงเป็น untracked ในสายตา add-in (เลี่ยงสถานะ "tracked แต่ history 404")
- **ค่าที่แทนที่เขียนที่ run ของ "ข้อความ placeholder จริง"** (v9 — ใช้ร่วมทั้ง generate และ generate-draft): template ไทยมักแตก placeholder เป็น `[` / ข้อความ / `]` หลาย run โดย run ข้อความถือ `w:hint="cs"` + `w:cs` (complex script) ที่ Word ใช้ตัดคำ/กระจายภาษาไทย — เขียนค่าที่ run ที่มีข้อความยาวสุด (ไม่ใช่ run แรกที่เป็นปีกกา) · SDT เปล่าให้ run ใหม่สืบทอด `rPr` จาก `sdtPr` (เหมือนพฤติกรรม Word ตอนพิมพ์ลง control)
- **ผูก template ตอน Start Versioning:** `POST /api/documents/{documentId}/versions` รับ `templateId` (multipart form field ทางเลือก) — ใช้เฉพาะตอน auto-create `Document` (field sync ทำงานเต็มระบบที่ v1); document มีอยู่แล้ว → เฉย (แต่ validate ทุกครั้งที่ส่งมา) · ไม่ใช่ GUID / ไม่พบใน DB → 400 · template ที่ deactivate → **ผูกได้** (ไฟล์ร่างถูกสร้างตอน template ยัง active — นโยบายเดียวกับ FR-9/FR-12) · upload แรกของ Document ใหม่ = **version 1** (max+1 — ไม่มี v0)
- Add-in: อ่าน property `TemplateId` ตอน untracked แล้วแนบ `templateId` **เฉพาะตอน Start Versioning** (Save & Version ไม่ส่ง) + แสดง hint ว่าไฟล์นี้เป็นไฟล์ร่างจาก template
- Web Portal: ปุ่ม "สร้างไฟล์ร่าง" ที่สองบนฟอร์มสร้างเอกสารเดิม → ดาวน์โหลดไฟล์ทันที (browser download ผ่าน Bearer — ชื่อไฟล์อ่านจาก `Content-Disposition` ที่ CORS ต้อง expose); ฟอร์มอยู่ต่อ ไม่มี documentId/หน้าผลลัพธ์/ปุ่มเปิดใน Word (ไม่มี URL ให้ `ms-word:ofe`) · ช่องคำอธิบาย version ใช้กับ flow สร้างเอกสาร v0 เท่านั้น · flow v0 (FR-10) และ re-generate (FR-12) คงเดิม
- Web Portal: ปุ่ม "สร้างไฟล์ร่าง" ที่สองบนฟอร์มสร้างเอกสารเดิม → **เปิดใน Word ทันที** ผ่าน `ms-word:ofe|u|<presigned-url>` พร้อมลิงก์ดาวน์โหลดสำรองอายุสั้น + heuristic เตือนเมื่อไม่เห็นการสลับแอป (กลไกเดียวกับ flow เปิด v0 — `useVersionOpen`); ฟอร์มอยู่ต่อ ไม่มี documentId/หน้าผลลัพธ์ · ช่องคำอธิบาย version ใช้กับ flow สร้างเอกสาร v0 เท่านั้น · flow v0 (FR-10) และ re-generate (FR-12) คงเดิม
## 7. Data Model (Backend)
@ -429,7 +431,7 @@ Custom XML Part (namespace `urn:myaddin:versioning`) — เก็บ compact hi
---
18. (v7, AC-18) generate ได้ v0 ไม่มี PDF → Save & Version ได้ v1 พร้อม PDF; Start Versioning ได้ v1 พร้อม PDF; PDF capture/storage ล้มยังบันทึก docx สำเร็จพร้อมคำเตือน; history แสดง PDF เฉพาะที่มี; download/proxy/presigned PDF ถูก bytes/type/ชื่อ และไม่มี → 404; เว็บเปิด Word/PDF พร้อม fallback; ตรวจ fidelity/markup บน Word จริง
19. (v8, AC-19) สร้างไฟล์ร่างจากฟอร์มเว็บ → ได้ .docx ค่าครบ ชื่อไทยถูกต้อง โดย**ไม่เกิด Document/version ใดๆ ในระบบ** → เปิดใน Word แล้ว add-in แสดงสถานะ untracked พร้อม hint ไฟล์ร่าง → กด Start Versioning → Document ถูกสร้าง ผูก template ต้นทาง + **version 1** + field values sync ครบ → templateId ไม่ใช่ GUID/ไม่พบ → 400 · template deactivate ผูกได้ · Save & Version ถัดไปได้ v2, v3… โดยไม่ส่ง templateId · flow สร้างเอกสาร v0 (FR-10) ยังทำงานได้คู่กัน
19. (v8, AC-19 · ปรับ v9) สร้างไฟล์ร่างจากฟอร์มเว็บ → **เปิดใน Word ทันที** (ms-word:ofe + ลิงก์สำรองอายุสั้น) ได้ .docx ค่าครบ **จัดหน้า/ตัดคำไทยถูกต้องตาม template** โดยไม่เกิด Document/version ใน DB (object ใต้ `drafts/` หมดอายุรายวัน) → add-in แสดง untracked + hint ไฟล์ร่าง → Start Versioning → Document + template + **v1** + fields sync ครบ → templateId ไม่ใช่ GUID/ไม่พบ → 400 · deactivate ผูกได้ · Save & Version ถัดไป v2, v3… · flow v0 (FR-10) ยังทำงานคู่กัน
## 11. งานที่ Claude Code ควรทำ
@ -476,6 +478,10 @@ Custom XML Part (namespace `urn:myaddin:versioning`) — เก็บ compact hi
- Web: เปิด Word/PDF + fallback + history v0/v1+
### เพิ่มเติม v8 (FR-16)
- Backend: `POST /api/templates/{id}/generate-draft` (stateless) + `DocxGenerationService.GenerateDraft` (stamp เฉพาะ `TemplateId`) + แยก `TemplateFieldValidation` ใช้ร่วมกับ generate + form field `templateId` ทางเลือกของ upload (ใช้ตอน auto-create) + CORS expose `Content-Disposition`
- Backend: `POST /api/templates/{id}/generate-draft` (ตอน v8 คืนไฟล์ตรงๆ — ปรับเป็นเก็บชั่วคราว + presigned URL ใน v9) + `DocxGenerationService.GenerateDraft` (stamp เฉพาะ `TemplateId`) + แยก `TemplateFieldValidation` ใช้ร่วมกับ generate + form field `templateId` ทางเลือกของ upload (ใช้ตอน auto-create)
- Add-in: อ่าน `TemplateId` ใน `readTrackedProperties` + ส่ง `templateId` เฉพาะ `startVersioning` + hint ไฟล์ร่างใน task pane
- Web: ปุ่ม "สร้างไฟล์ร่าง" บนฟอร์มเดิม + `downloadFile` รับ `init` (POST + JSON → blob)
- Web: ปุ่ม "สร้างไฟล์ร่าง" บนฟอร์มเดิม
### เพิ่มเติม v9 (ปรับ FR-16 ตามผลใช้งานจริง)
- Backend: generate-draft เก็บ object ใต้ `drafts/` + คืน `{ url, expiresAt }` (presigned) + `EnsurePrefixExpiryAsync` ตั้ง lifecycle ตอน startup (idempotent/best-effort) + `ReplaceSdtContent` เขียนค่าที่ run ข้อความยาวสุด (complex script ไทย) + SDT เปล่าสืบทอด `sdtPr` rPr
- Web: `useVersionOpen.openDocxUrl` — แยกแกนเปิด docx URL ออกจาก `openVersion` ให้ไฟล์ร่างใช้ร่วม (ถอด CORS expose และ `downloadFile` init ที่เลิกใช้)

View file

@ -69,41 +69,5 @@ export function useApi() {
return (await apiJson<T>(path, { method, body: form })) as T
}
/**
* ดาวน์โหลดไฟล์ผ่าน Bearer แล้วเซฟด้วย anchor download
* (เว็บรันใน browser จริง — ไม่ใช่ webview ของ Word จึงใช้ blob download ได้ ต่างจาก add-in)
* init ใช้ override method/body ได้ — ไฟล์ร่าง (FR-16) เป็น POST + JSON body ที่คืนไฟล์
*/
async function downloadFile(path: string, fallbackName: string, init: RequestInit = {}): Promise<void> {
const res = await request(path, init)
if (!res.ok) throw await toApiError(res)
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filenameFrom(res.headers.get('Content-Disposition'), fallbackName)
document.body.append(a)
a.click()
a.remove()
setTimeout(() => URL.revokeObjectURL(url), 10_000)
}
return { request, apiJson, apiUpload, downloadFile }
}
/** เอาชื่อไฟล์จาก Content-Disposition (รองรับ filename*=UTF-8 ที่ .NET ใส่มาสำหรับชื่อไทย) */
function filenameFrom(header: string | null, fallback: string): string {
if (!header) return fallback
const utf8 = header.match(/filename\*=(?:UTF-8'')?([^;]+)/i)
if (utf8) {
try {
return decodeURIComponent(utf8[1].replace(/^"|"$/g, ''))
} catch {
/* ผ่านต่อ */
}
}
const plain = header.match(/filename=(?:"([^"]+)"|([^;]+))/i)
if (plain) return (plain[1] ?? plain[2]).trim()
return fallback
return { request, apiJson, apiUpload }
}

View file

@ -23,6 +23,28 @@ export function useVersionOpen() {
openNotice.value = null
}
/** เปิด presigned docx URL ใน Word ผ่าน ms-word:ofe พร้อม heuristic เตือนเมื่อไม่เห็นการสลับแอป —
* ใช้กับลิงก์ที่ถืออยู่เองได้เลย (ไฟล์ร่าง FR-16: response ของ generate-draft คือ URL แล้ว) */
function openDocxUrl(url: string) {
resetOpen()
fallbackUrl.value = url
let switched = false
const onBlur = () => { switched = true }
const onVisibility = () => { if (document.hidden) switched = true }
window.addEventListener('blur', onBlur)
document.addEventListener('visibilitychange', onVisibility)
const timer = window.setTimeout(() => {
if (!switched) openNotice.value = 'หาก Word ไม่เปิด กรุณาดาวน์โหลดไฟล์แล้วเปิดใน Word'
cleanup()
}, 1500)
cleanup = () => {
window.clearTimeout(timer)
window.removeEventListener('blur', onBlur)
window.removeEventListener('visibilitychange', onVisibility)
}
window.location.assign(wordOpenUrl(url))
}
async function openVersion(documentId: string, versionId: string, format: VersionFormat = 'docx') {
if (openingVersionId.value) return
resetOpen()
@ -45,21 +67,7 @@ export function useVersionOpen() {
}
else openNotice.value = 'เบราว์เซอร์บล็อกแท็บใหม่ กรุณากดลิงก์เปิดไฟล์ด้านล่าง'
} else {
let switched = false
const onBlur = () => { switched = true }
const onVisibility = () => { if (document.hidden) switched = true }
window.addEventListener('blur', onBlur)
document.addEventListener('visibilitychange', onVisibility)
const timer = window.setTimeout(() => {
if (!switched) openNotice.value = 'หาก Word ไม่เปิด กรุณาดาวน์โหลดไฟล์แล้วเปิดใน Word'
cleanup()
}, 1500)
cleanup = () => {
window.clearTimeout(timer)
window.removeEventListener('blur', onBlur)
document.removeEventListener('visibilitychange', onVisibility)
}
window.location.assign(wordOpenUrl(link.url))
openDocxUrl(link.url)
}
} catch (err) {
tab?.close()
@ -71,5 +79,5 @@ export function useVersionOpen() {
}
onBeforeUnmount(resetOpen)
return { openingVersionId, downloadError, fallbackUrl, openNotice, openVersion, resetOpen }
return { openingVersionId, downloadError, fallbackUrl, openNotice, openVersion, openDocxUrl, resetOpen }
}

View file

@ -5,8 +5,8 @@
import type { DocumentDetail, GenerateDocumentResponse, TemplateDetail } from '~/types/api'
const route = useRoute()
const { apiJson, downloadFile } = useApi()
const { openingVersionId, downloadError, fallbackUrl, openNotice, openVersion, resetOpen } = useVersionOpen()
const { apiJson } = useApi()
const { openingVersionId, downloadError, fallbackUrl, openNotice, openVersion, openDocxUrl, resetOpen } = useVersionOpen()
const templateId = computed(() => String(route.params.templateId))
const detail = ref<TemplateDetail | null>(null)
@ -82,7 +82,7 @@ async function load() {
/** validate ฝั่ง client (required/type) ก่อนยิง backend — backend ตรวจซ้ำอีกชั้นเสมอ */
async function submit() {
submitError.value = null
downloadError.value = null
resetOpen() // เคลียร์ state ของการเปิดไฟล์ครั้งก่อน (รวม fallbackUrl/openNotice ของไฟล์ร่างที่อาจค้าง)
draftSuccess.value = null
draftError.value = null
issues.value = Object.fromEntries(
@ -105,7 +105,8 @@ async function submit() {
}
}
/** FR-16 — สร้างไฟล์ร่าง (draft): validate ชุดเดียวกันแล้ว POST ค่าไปขอไฟล์กลับมาดาวน์โหลดทันที
/** FR-16 — สร้างไฟล์ร่าง (draft): validate ชุดเดียวกันแล้ว POST ค่าไปขอ presigned URL
* แล้วเปิดใน Word ทันทีผ่าน ms-word:ofe พร้อมลิงก์ดาวน์โหลดสำรอง (เหมือน flow เปิด v0)
* backend ไม่สร้าง Document/version — เอกสารจะเกิดตอนกด Start Versioning ใน Word (เป็น v1 พร้อมผูก template นี้) */
async function submitDraft() {
draftSuccess.value = null
@ -118,13 +119,17 @@ async function submitDraft() {
draftSubmitting.value = true
try {
await downloadFile(`/api/templates/${templateId.value}/generate-draft`, `${detail.value?.name ?? 'เอกสาร'}.docx`, {
const link = (await apiJson<{ url: string; expiresAt: string }>(
`/api/templates/${templateId.value}/generate-draft`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ values: values.value }),
})
},
))!
openDocxUrl(link.url)
draftSuccess.value =
'ดาวน์โหลดไฟล์ร่างแล้ว — เปิดไฟล์ใน Word แล้วกด Start Versioning This Document เพื่อเริ่ม track (เอกสารจะถูกผูกกับ template นี้และบันทึกเป็น version 1)'
'กำลังเปิดไฟล์ร่างใน Word — เปิด task pane ของ add-in แล้วกด Start Versioning This Document เพื่อเริ่ม track (เอกสารจะถูกผูกกับ template นี้และบันทึกเป็น version 1)'
} catch (err) {
draftError.value = err instanceof Error ? err.message : 'สร้างไฟล์ร่างไม่สำเร็จ'
} finally {
@ -301,8 +306,13 @@ onMounted(load)
<AlertBox v-if="submitError" type="error">{{ submitError }}</AlertBox>
<!-- FR-16 — ไฟล์ร่าง: ดาวน์โหลดสำเร็จแล้วฟอร์มอยู่ต่อ ผู้ใช้เปิดไฟล์ใน Word แล้วค่อย Start Versioning -->
<!-- FR-16 — ไฟล์ร่าง: เปิดใน Word แล้วฟอร์มอยู่ต่อ พร้อมลิงก์สำรอง (อายุสั้นตาม presigned URL) -->
<AlertBox v-if="draftSuccess" type="success">{{ draftSuccess }}</AlertBox>
<AlertBox v-if="fallbackUrl && (draftSuccess || openNotice)">
<p v-if="openNotice">{{ openNotice }}</p>
<a :href="fallbackUrl" target="_blank" rel="noopener noreferrer" class="link">ดาวน์โหลดไฟล์ร่างสำรอง (.docx)</a>
<p class="text-xs text-muted">ลิงก์มีอายุสั้น หากหมดอายุให้กดสร้างไฟล์ร่างอีกครั้ง</p>
</AlertBox>
<AlertBox v-if="draftError" type="error">{{ draftError }}</AlertBox>
<div class="flex flex-col-reverse gap-2 pt-2 sm:flex-row sm:justify-end">
@ -323,7 +333,7 @@ onMounted(load)
</button>
</div>
<p class="text-xs text-muted">
ไฟล์ร่าง = ได้ .docx ทันทีโดยยังไม่เกิดเอกสารในระบบ — เริ่ม versioning ตอนเปิดใน Word (version 1)
ไฟล์ร่าง = เปิดใน Word ทันทีโดยยังไม่เกิดเอกสารในระบบ — เริ่ม versioning ตอนกด Start Versioning ใน Word (version 1)
</p>
</form>
</template>

View file

@ -1,117 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiError, useApi } from '../app/composables/useApi'
/**
* useApi — fetch wrapper ของ web portal: ไฟล์ร่าง (FR-16) ใช้ downloadFile แบบ
* POST + JSON body ที่คืนไฟล์ (ชื่อไทยอ่านจาก Content-Disposition filename* ที่ CORS expose ไว้)
*/
const getAccessToken = vi.fn()
const fetchMock = vi.fn()
/** Response ปลอม — ไม่พึ่ง global Response ของ environment (happy-dom) */
function fakeResponse(options: {
ok?: boolean
status?: number
headers?: Record<string, string>
body?: string
}): Response {
const lower = Object.fromEntries(
Object.entries(options.headers ?? {}).map(([k, v]) => [k.toLowerCase(), v]),
)
return {
ok: options.ok ?? true,
status: options.status ?? 200,
headers: { get: (name: string) => lower[name.toLowerCase()] ?? null },
blob: async () => new Blob([options.body ?? 'docx-bytes']),
text: async () => options.body ?? '',
} as unknown as Response
}
let clicked: HTMLAnchorElement | null = null
const originalCreateObjectURL = URL.createObjectURL
const originalRevokeObjectURL = URL.revokeObjectURL
beforeEach(() => {
vi.stubGlobal('useRuntimeConfig', () => ({ public: { apiBaseUrl: 'https://api.test/' } }))
vi.stubGlobal('useAuth', () => ({ getAccessToken }))
vi.stubGlobal('fetch', fetchMock)
getAccessToken.mockReset().mockResolvedValue('token-1')
fetchMock.mockReset()
clicked = null
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function click(this: HTMLAnchorElement) {
clicked = this
})
URL.createObjectURL = vi.fn(() => 'blob:mock')
URL.revokeObjectURL = vi.fn()
})
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
URL.createObjectURL = originalCreateObjectURL
URL.revokeObjectURL = originalRevokeObjectURL
})
describe('downloadFile (FR-16 ไฟล์ร่าง — POST + JSON body คืนไฟล์)', () => {
it('forwards method/body to fetch with Bearer and saves the Thai filename from Content-Disposition', async () => {
fetchMock.mockResolvedValue(
fakeResponse({
headers: {
// .NET ใส่ทั้ง filename และ filename* (RFC 5987) สำหรับชื่อไทย
'Content-Disposition':
"attachment; filename=\"draft.docx\"; filename*=UTF-8''%E0%B9%84%E0%B8%9F%E0%B8%A5%E0%B9%8C%E0%B8%A3%E0%B9%88%E0%B8%B2%E0%B8%87.docx",
},
}),
)
await useApi().downloadFile('/api/templates/abc/generate-draft', 'fallback.docx', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ values: { employee_name: 'สมหญิง' } }),
})
expect(fetchMock).toHaveBeenCalledWith('https://api.test/api/templates/abc/generate-draft', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer token-1' },
body: JSON.stringify({ values: { employee_name: 'สมหญิง' } }),
})
expect(clicked?.download).toBe('ไฟล์ร่าง.docx')
})
it('falls back to the given name when Content-Disposition is missing (CORS ไม่ expose)', async () => {
fetchMock.mockResolvedValue(fakeResponse({}))
await useApi().downloadFile('/api/templates/abc/generate-draft', 'fallback.docx', { method: 'POST' })
expect(clicked?.download).toBe('fallback.docx')
})
it('retries once with a refreshed token on 401 before saving', async () => {
getAccessToken.mockImplementation(async (forceRefresh?: boolean) => (forceRefresh ? 'token-2' : 'token-1'))
fetchMock
.mockResolvedValueOnce(fakeResponse({ ok: false, status: 401 }))
.mockResolvedValueOnce(fakeResponse({ headers: { 'Content-Disposition': 'attachment; filename="draft.docx"' } }))
await useApi().downloadFile('/api/templates/abc/generate-draft', 'fallback.docx', { method: 'POST' })
expect(fetchMock).toHaveBeenCalledTimes(2)
expect(fetchMock.mock.calls[1][1].headers.Authorization).toBe('Bearer token-2')
expect(clicked?.download).toBe('draft.docx')
})
it('throws ApiError with the backend message when the response is not ok', async () => {
fetchMock.mockResolvedValue(
fakeResponse({ ok: false, status: 400, body: 'กรอก required fields ให้ครบ: employee_name' }),
)
await expect(
useApi().downloadFile('/api/templates/abc/generate-draft', 'fallback.docx', { method: 'POST' }),
).rejects.toMatchObject({
constructor: ApiError,
status: 400,
message: 'กรอก required fields ให้ครบ: employee_name',
})
expect(clicked).toBeNull() // ไม่มีการดาวน์โหลดเกิดขึ้น
})
})

View file

@ -97,4 +97,14 @@ describe('version open lifecycle', () => {
app.unmount()
expect(remove).toHaveBeenCalledWith('blur', expect.any(Function))
})
it('openDocxUrl opens a URL the caller already holds (ไฟล์ร่าง FR-16) without extra request', async () => {
const assign = vi.spyOn(window.location, 'assign').mockImplementation(() => {})
const state = mount()
state.openDocxUrl('https://files.example/draft.docx?sig=a%2Fb')
expect(apiJson).not.toHaveBeenCalled()
expect(assign).toHaveBeenCalledWith('ms-word:ofe|u|https://files.example/draft.docx?sig=a%2Fb')
expect(state.fallbackUrl.value).toBe('https://files.example/draft.docx?sig=a%2Fb')
vi.advanceTimersByTime(1500)
expect(state.openNotice.value).toBeTruthy()
})
})