diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..3ecd3e5
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,23 @@
+# ------------------------------------------------------------------
+# Local dev infra (docker-compose.yml) — template เท่านั้น
+# คัดลอกเป็น `.env` แล้วเปลี่ยนค่าก่อนใช้: cp .env.example .env
+# ค่าในไฟล์นี้เป็นค่า dev ที่ใช้เฉพาะเครื่อง local — ห้ามเอาไปใช้ production
+# ------------------------------------------------------------------
+
+# --- PostgreSQL (service: db) ---
+POSTGRES_USER=wordversioning
+POSTGRES_PASSWORD=dev-local-pass
+POSTGRES_DB=wordversioning
+
+# --- MinIO (service: minio) ---
+# password ต้องยาว >= 8 ตัวอักษร
+MINIO_ROOT_USER=wordversioning-minio
+MINIO_ROOT_PASSWORD=dev-local-pass
+
+# --- Keycloak bootstrap admin (service: keycloak) ---
+# ใช้ล็อกอิน admin console ครั้งแรกที่ http://localhost:8080/admin/
+KC_BOOTSTRAP_ADMIN_USERNAME=admin
+KC_BOOTSTRAP_ADMIN_PASSWORD=dev-local-pass
+
+# --- Keycloak test user (define ใน keycloak/realm-export.json ฝั่ง realm) ---
+# username: testuser / password: Test1234! (แก้ที่ realm-export.json แล้ว re-import)
diff --git a/CLAUDE.md b/CLAUDE.md
index c26c866..c675b27 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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`)
-**สถานะปัจจุบัน: pre-implementation — ยังไม่มีโค้ด** งานคือ implement ตาม phase 0–8 ในแผน
+**สถานะปัจจุบัน: Phase 0–2 เสร็จ** (scaffold + infra + backend core FR-5–8) — งานต่อคือ implement phase 3–8 ในแผน
## เอกสารหลัก (อ่านก่อนเริ่มงานใดๆ)
diff --git a/backend/WordVersioning.Api/Controllers/DocumentsController.cs b/backend/WordVersioning.Api/Controllers/DocumentsController.cs
new file mode 100644
index 0000000..91da2f5
--- /dev/null
+++ b/backend/WordVersioning.Api/Controllers/DocumentsController.cs
@@ -0,0 +1,271 @@
+using System.Text.Json;
+using Amazon.Runtime;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Options;
+using Npgsql;
+using WordVersioning.Api.Data;
+using WordVersioning.Api.Models;
+using WordVersioning.Api.Services;
+
+namespace WordVersioning.Api.Controllers;
+
+/// FR-5 (upload version), FR-6 (history), FR-7 (download) + รับ fields สำหรับ FR-11
+[ApiController]
+[Authorize] // FR-8: ทุก endpoint ต้องแนบ token
+[Route("api/documents")]
+public class DocumentsController(
+ AppDbContext db,
+ IObjectStorageService storage,
+ IHashService hasher,
+ ICurrentUser currentUser,
+ IOptions minioOptions,
+ ILogger logger) : ControllerBase
+{
+ public const string DocxContentType =
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
+
+ private const long MaxUploadBytes = 100L * 1024 * 1024; // 100MB ตาม spec
+ private const int MaxVersionInsertRetries = 5;
+
+ /// FR-5 — อัปโหลด version ใหม่ (auto-create Document ถ้ายังไม่มี)
+ [HttpPost("{documentId:guid}/versions")]
+ [RequestSizeLimit(MaxUploadBytes)]
+ [RequestFormLimits(MultipartBodyLengthLimit = MaxUploadBytes)]
+ [ProducesResponseType(typeof(UploadVersionResponse), StatusCodes.Status201Created)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status413PayloadTooLarge)]
+ [ProducesResponseType(StatusCodes.Status502BadGateway)]
+ public async Task UploadVersion(Guid documentId, [FromForm] UploadVersionRequest request, CancellationToken ct)
+ {
+ if (request.File is null || request.File.Length == 0)
+ return BadRequest("ต้องแนบไฟล์ `file` (docx)");
+ if (!Guid.TryParse(request.ClientVersionId, out _))
+ return BadRequest("`clientVersionId` ต้องเป็น GUID");
+ if (request.File.Length > MaxUploadBytes)
+ return StatusCode(StatusCodes.Status413PayloadTooLarge, $"ไฟล์ใหญ่เกิน {MaxUploadBytes / (1024 * 1024)}MB");
+ if (!IsValidDocx(request.File))
+ return BadRequest("รองรับเฉพาะไฟล์ .docx (Word document)");
+
+ // parse fields map (FR-11) — optional, ผิด format คือ 400
+ string? fieldsSnapshotJson = null;
+ if (!string.IsNullOrWhiteSpace(request.Fields))
+ {
+ try
+ {
+ using var fieldsDoc = JsonDocument.Parse(request.Fields);
+ if (fieldsDoc.RootElement.ValueKind != JsonValueKind.Object)
+ return BadRequest("`fields` ต้องเป็น JSON object รูป { tag: text }");
+ foreach (var prop in fieldsDoc.RootElement.EnumerateObject())
+ {
+ if (!IsScalar(prop.Value.ValueKind))
+ return BadRequest($"`fields.{prop.Name}` ต้องเป็นค่า scalar (string/number/bool/null)");
+ }
+ fieldsSnapshotJson = fieldsDoc.RootElement.GetRawText();
+ }
+ catch (JsonException)
+ {
+ return BadRequest("`fields` ไม่ใช่ JSON ที่ถูกต้อง");
+ }
+ }
+
+ var now = DateTimeOffset.UtcNow;
+ var fileName = request.File.FileName;
+
+ // hash ระหว่าง stream ผ่าน (pass แรก) แล้ว rewind ส่ง MinIO (pass สอง)
+ await using var fileStream = request.File.OpenReadStream();
+ Stream uploadStream = fileStream;
+ if (!fileStream.CanSeek)
+ {
+ // IFormFile ปกติ seek ได้ — กันเหลือ: buffer ก่อน
+ var buffered = new MemoryStream();
+ await fileStream.CopyToAsync(buffered, ct);
+ uploadStream = buffered;
+ }
+ var contentHash = await hasher.ComputeSha256HexAsync(uploadStream, ct);
+ uploadStream.Seek(0, SeekOrigin.Begin);
+
+ // สร้าง Document ถ้ายังไม่มี (FR-5)
+ var doc = await db.Documents.FirstOrDefaultAsync(d => d.Id == documentId, ct);
+ var createdDoc = false;
+ if (doc is null)
+ {
+ doc = new Document
+ {
+ Id = documentId,
+ FileName = fileName,
+ AddInSignature = string.Empty, // ฝั่ง add-in stamp ในไฟล์เอง
+ CreatedAt = now,
+ };
+ db.Documents.Add(doc);
+ createdDoc = true;
+ }
+
+ // จอง versionNumber = max+1 — ถ้าชน unique index แปลว่ามี request คู่แข่ง ให้คำนวณใหม่
+ DocumentVersion version = null!;
+ for (var attempt = 1; ; attempt++)
+ {
+ var nextNumber = await db.DocumentVersions
+ .Where(v => v.DocumentId == documentId)
+ .Select(v => (int?)v.VersionNumber)
+ .MaxAsync(ct) ?? 0;
+ nextNumber++;
+
+ version = new DocumentVersion
+ {
+ Id = Guid.NewGuid(),
+ DocumentId = documentId,
+ VersionNumber = nextNumber,
+ ObjectKey = $"{documentId}/v{nextNumber:D4}_{request.ClientVersionId}.docx",
+ ContentHash = contentHash,
+ CreatedByUserId = currentUser.UserId, // จาก claim `sub` เท่านั้น
+ CreatedAt = now,
+ FieldsSnapshotJson = fieldsSnapshotJson,
+ };
+ db.DocumentVersions.Add(version);
+
+ try
+ {
+ await db.SaveChangesAsync(ct);
+ break;
+ }
+ catch (DbUpdateException ex) when (IsUniqueViolation(ex) && attempt < MaxVersionInsertRetries)
+ {
+ logger.LogWarning(
+ "Version number conflict documentId={DocumentId} attempt={Attempt} userId={UserId} — retry",
+ documentId, attempt, currentUser.UserId);
+
+ db.Entry(version).State = EntityState.Detached;
+ if (createdDoc)
+ {
+ // document อาจถูกสร้างโดย request คู่แข่งไปแล้ว — ถอดออกจาก tracker แล้วอ่านใหม่
+ var existing = await db.Documents.AsNoTracking()
+ .FirstOrDefaultAsync(d => d.Id == documentId, ct);
+ if (existing is not null)
+ {
+ db.Entry(doc).State = EntityState.Detached;
+ doc = existing;
+ createdDoc = false;
+ }
+ }
+ }
+ }
+
+ // upload ลง MinIO หลังจองเลข version สำเร็จ (key มี versionNumber อยู่ข้างใน)
+ try
+ {
+ await storage.PutAsync(minioOptions.Value.VersionsBucket, version.ObjectKey, uploadStream, DocxContentType, ct);
+ }
+ catch (AmazonServiceException ex)
+ {
+ logger.LogError(ex, "MinIO upload failed documentId={DocumentId} versionId={VersionId} userId={UserId}",
+ documentId, version.Id, currentUser.UserId);
+
+ // ถอน record ที่เพิ่ง insert — ไม่ทิ้ง row ที่ชี้ไฟล์ที่ไม่มีจริง
+ db.DocumentVersions.Remove(version);
+ if (createdDoc)
+ db.Documents.Remove(doc);
+ await db.SaveChangesAsync(CancellationToken.None);
+ return StatusCode(StatusCodes.Status502BadGateway, "อัปโหลดไฟล์ขึ้น storage ไม่สำเร็จ");
+ }
+
+ 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));
+ }
+
+ /// FR-6 — version history เรียงใหม่ → เก่า (พร้อม snapshot fields ต่อ version)
+ [HttpGet("{documentId:guid}/versions")]
+ [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task>> GetVersions(Guid documentId, CancellationToken ct)
+ {
+ var exists = await db.Documents.AnyAsync(d => d.Id == documentId, ct);
+ if (!exists)
+ return NotFound($"ไม่พบ document {documentId}");
+
+ var rows = await db.DocumentVersions
+ .AsNoTracking()
+ .Where(v => v.DocumentId == documentId)
+ .OrderByDescending(v => v.VersionNumber)
+ .Select(v => new
+ {
+ v.Id,
+ v.VersionNumber,
+ v.CreatedAt,
+ v.CreatedByUserId,
+ v.ContentHash,
+ v.FieldsSnapshotJson,
+ })
+ .ToListAsync(ct);
+
+ return Ok(rows.Select(r => new VersionListItem(
+ r.Id, r.VersionNumber, r.CreatedAt, r.CreatedByUserId, r.ContentHash,
+ ParseSnapshot(r.FieldsSnapshotJson))).ToList());
+ }
+
+ /// FR-7 — ดาวน์โหลด version ที่ระบุ (proxy stream จาก MinIO — ไม่ expose MinIO ตรงๆ)
+ [HttpGet("{documentId:guid}/versions/{versionId:guid}/download")]
+ [ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task DownloadVersion(Guid documentId, Guid versionId, CancellationToken ct)
+ {
+ 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 })
+ .FirstOrDefaultAsync(ct);
+ if (row is null)
+ return NotFound($"ไม่พบ version {versionId} ของ document {documentId}");
+
+ Stream stream;
+ try
+ {
+ stream = await storage.GetStreamAsync(minioOptions.Value.VersionsBucket, row.ObjectKey, ct);
+ }
+ catch (KeyNotFoundException)
+ {
+ logger.LogError("Object missing in storage documentId={DocumentId} versionId={VersionId} key={ObjectKey}",
+ documentId, versionId, row.ObjectKey);
+ return NotFound("ไฟล์ของ version นี้ไม่พบใน storage");
+ }
+
+ logger.LogInformation("Version download documentId={DocumentId} versionId={VersionId} userId={UserId}",
+ documentId, versionId, currentUser.UserId);
+
+ var downloadName = row.FileName.EndsWith(".docx", StringComparison.OrdinalIgnoreCase)
+ ? row.FileName
+ : row.FileName + ".docx";
+ return File(stream, DocxContentType, downloadName);
+ }
+
+ private static bool IsValidDocx(IFormFile file) =>
+ file.FileName.EndsWith(".docx", StringComparison.OrdinalIgnoreCase)
+ && IsAcceptableContentType(file.ContentType);
+
+ // spec: reject content-type ที่ไม่ใช่ docx — ยอมรับ octet-stream/zip ด้วยเพราะ client บางตัว
+ // (เช่น curl -F) ไม่ set ชนิดไฟล์ให้ ส่วนชื่อไฟล์ต้องลง .docx เสมอ (เช็คข้างบน)
+ private static bool IsAcceptableContentType(string contentType) =>
+ string.IsNullOrWhiteSpace(contentType)
+ || contentType.Equals(DocxContentType, StringComparison.OrdinalIgnoreCase)
+ || contentType.Equals("application/octet-stream", StringComparison.OrdinalIgnoreCase)
+ || contentType.Equals("application/zip", StringComparison.OrdinalIgnoreCase);
+
+ private static bool IsScalar(JsonValueKind kind) =>
+ kind is JsonValueKind.String or JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False or JsonValueKind.Null;
+
+ private static object? ParseSnapshot(string? json)
+ {
+ if (json is null)
+ return null;
+ using var doc = JsonDocument.Parse(json);
+ return doc.RootElement.Clone(); // clone ก่อน dispose JsonDocument เพื่อให้ serialize ต่อได้
+ }
+
+ private static bool IsUniqueViolation(DbUpdateException ex) =>
+ ex.InnerException is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation };
+}
diff --git a/backend/WordVersioning.Api/Controllers/WeatherForecastController.cs b/backend/WordVersioning.Api/Controllers/WeatherForecastController.cs
deleted file mode 100644
index 6228dec..0000000
--- a/backend/WordVersioning.Api/Controllers/WeatherForecastController.cs
+++ /dev/null
@@ -1,32 +0,0 @@
-using Microsoft.AspNetCore.Mvc;
-
-namespace WordVersioning.Api.Controllers;
-
-[ApiController]
-[Route("[controller]")]
-public class WeatherForecastController : ControllerBase
-{
- private static readonly string[] Summaries = new[]
- {
- "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
- };
-
- private readonly ILogger _logger;
-
- public WeatherForecastController(ILogger logger)
- {
- _logger = logger;
- }
-
- [HttpGet(Name = "GetWeatherForecast")]
- public IEnumerable Get()
- {
- return Enumerable.Range(1, 5).Select(index => new WeatherForecast
- {
- Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
- TemperatureC = Random.Shared.Next(-20, 55),
- Summary = Summaries[Random.Shared.Next(Summaries.Length)]
- })
- .ToArray();
- }
-}
diff --git a/backend/WordVersioning.Api/Data/AppDbContext.cs b/backend/WordVersioning.Api/Data/AppDbContext.cs
new file mode 100644
index 0000000..71ccda6
--- /dev/null
+++ b/backend/WordVersioning.Api/Data/AppDbContext.cs
@@ -0,0 +1,36 @@
+using Microsoft.EntityFrameworkCore;
+using WordVersioning.Api.Models;
+
+namespace WordVersioning.Api.Data;
+
+public class AppDbContext(DbContextOptions options) : DbContext(options)
+{
+ public DbSet Documents => Set();
+
+ public DbSet DocumentVersions => Set();
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasKey(x => x.Id);
+ entity.Property(x => x.FileName).HasMaxLength(512);
+ entity.Property(x => x.AddInSignature).HasMaxLength(64);
+ });
+
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasKey(x => x.Id);
+
+ // กัน versionNumber ซ้ำตอน upload พร้อมกัน (retry ที่ controller เมื่อชน constraint)
+ entity.HasIndex(x => new { x.DocumentId, x.VersionNumber }).IsUnique();
+
+ entity.Property(x => x.ObjectKey).HasMaxLength(1024);
+ entity.Property(x => x.ContentHash).HasMaxLength(64);
+
+ entity.HasOne(x => x.Document)
+ .WithMany(d => d.Versions)
+ .HasForeignKey(x => x.DocumentId);
+ });
+ }
+}
diff --git a/backend/WordVersioning.Api/Migrations/20260819034805_InitialCreate.Designer.cs b/backend/WordVersioning.Api/Migrations/20260819034805_InitialCreate.Designer.cs
new file mode 100644
index 0000000..5cd9994
--- /dev/null
+++ b/backend/WordVersioning.Api/Migrations/20260819034805_InitialCreate.Designer.cs
@@ -0,0 +1,113 @@
+//
+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("20260819034805_InitialCreate")]
+ partial class InitialCreate
+ {
+ ///
+ 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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AddInSignature")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("FileName")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)");
+
+ b.Property("TemplateId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.ToTable("Documents");
+ });
+
+ modelBuilder.Entity("WordVersioning.Api.Models.DocumentVersion", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("ContentHash")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CreatedByUserId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("DocumentId")
+ .HasColumnType("uuid");
+
+ b.Property("FieldsSnapshotJson")
+ .HasColumnType("text");
+
+ b.Property("ObjectKey")
+ .IsRequired()
+ .HasMaxLength(1024)
+ .HasColumnType("character varying(1024)");
+
+ b.Property("VersionNumber")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DocumentId", "VersionNumber")
+ .IsUnique();
+
+ b.ToTable("DocumentVersions");
+ });
+
+ 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.Document", b =>
+ {
+ b.Navigation("Versions");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/backend/WordVersioning.Api/Migrations/20260819034805_InitialCreate.cs b/backend/WordVersioning.Api/Migrations/20260819034805_InitialCreate.cs
new file mode 100644
index 0000000..2ee2568
--- /dev/null
+++ b/backend/WordVersioning.Api/Migrations/20260819034805_InitialCreate.cs
@@ -0,0 +1,70 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace WordVersioning.Api.Migrations
+{
+ ///
+ public partial class InitialCreate : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "Documents",
+ columns: table => new
+ {
+ Id = table.Column(type: "uuid", nullable: false),
+ FileName = table.Column(type: "character varying(512)", maxLength: 512, nullable: false),
+ AddInSignature = table.Column(type: "character varying(64)", maxLength: 64, nullable: false),
+ TemplateId = table.Column(type: "uuid", nullable: true),
+ CreatedAt = table.Column(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Documents", x => x.Id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "DocumentVersions",
+ columns: table => new
+ {
+ Id = table.Column(type: "uuid", nullable: false),
+ DocumentId = table.Column(type: "uuid", nullable: false),
+ VersionNumber = table.Column(type: "integer", nullable: false),
+ ObjectKey = table.Column(type: "character varying(1024)", maxLength: 1024, nullable: false),
+ ContentHash = table.Column(type: "character varying(64)", maxLength: 64, nullable: false),
+ CreatedByUserId = table.Column(type: "text", nullable: false),
+ CreatedAt = table.Column(type: "timestamp with time zone", nullable: false),
+ FieldsSnapshotJson = table.Column(type: "text", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_DocumentVersions", x => x.Id);
+ table.ForeignKey(
+ name: "FK_DocumentVersions_Documents_DocumentId",
+ column: x => x.DocumentId,
+ principalTable: "Documents",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_DocumentVersions_DocumentId_VersionNumber",
+ table: "DocumentVersions",
+ columns: new[] { "DocumentId", "VersionNumber" },
+ unique: true);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "DocumentVersions");
+
+ migrationBuilder.DropTable(
+ name: "Documents");
+ }
+ }
+}
diff --git a/backend/WordVersioning.Api/Migrations/AppDbContextModelSnapshot.cs b/backend/WordVersioning.Api/Migrations/AppDbContextModelSnapshot.cs
new file mode 100644
index 0000000..f4597dc
--- /dev/null
+++ b/backend/WordVersioning.Api/Migrations/AppDbContextModelSnapshot.cs
@@ -0,0 +1,110 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using WordVersioning.Api.Data;
+
+#nullable disable
+
+namespace WordVersioning.Api.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ partial class AppDbContextModelSnapshot : ModelSnapshot
+ {
+ protected override void BuildModel(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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AddInSignature")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("FileName")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)");
+
+ b.Property("TemplateId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.ToTable("Documents");
+ });
+
+ modelBuilder.Entity("WordVersioning.Api.Models.DocumentVersion", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("ContentHash")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CreatedByUserId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("DocumentId")
+ .HasColumnType("uuid");
+
+ b.Property("FieldsSnapshotJson")
+ .HasColumnType("text");
+
+ b.Property("ObjectKey")
+ .IsRequired()
+ .HasMaxLength(1024)
+ .HasColumnType("character varying(1024)");
+
+ b.Property("VersionNumber")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DocumentId", "VersionNumber")
+ .IsUnique();
+
+ b.ToTable("DocumentVersions");
+ });
+
+ 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.Document", b =>
+ {
+ b.Navigation("Versions");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/backend/WordVersioning.Api/Models/Document.cs b/backend/WordVersioning.Api/Models/Document.cs
new file mode 100644
index 0000000..a28e98b
--- /dev/null
+++ b/backend/WordVersioning.Api/Models/Document.cs
@@ -0,0 +1,20 @@
+namespace WordVersioning.Api.Models;
+
+/// เอกสารหนึ่งฉบับที่ถูก track — 1 document : N versions (spec ส่วน 7)
+public class Document
+{
+ public Guid Id { get; set; }
+
+ public string FileName { get; set; } = string.Empty;
+
+ /// ค่า custom property `SavedByAddIn` ที่ stamp ไว้ในไฟล์ (FR-1)
+ public string AddInSignature { get; set; } = string.Empty;
+
+ /// เอกสารที่สร้างจาก template (FR-10) — null ถ้า track เอกสารเปล่า
+ /// (Phase 3 จะเติม FK ไป Template เมื่อมี entity)
+ public Guid? TemplateId { get; set; }
+
+ public DateTimeOffset CreatedAt { get; set; }
+
+ public List Versions { get; set; } = new();
+}
diff --git a/backend/WordVersioning.Api/Models/DocumentVersion.cs b/backend/WordVersioning.Api/Models/DocumentVersion.cs
new file mode 100644
index 0000000..ed1af3b
--- /dev/null
+++ b/backend/WordVersioning.Api/Models/DocumentVersion.cs
@@ -0,0 +1,28 @@
+namespace WordVersioning.Api.Models;
+
+/// Snapshot ของเอกสารหนึ่ง version (spec ส่วน 7)
+public class DocumentVersion
+{
+ public Guid Id { get; set; }
+
+ public Guid DocumentId { get; set; }
+
+ public Document? Document { get; set; }
+
+ /// เริ่มที่ 1 — auto increment ต่อ document (unique index กัน race)
+ public int VersionNumber { get; set; }
+
+ /// MinIO object key: {documentId}/v{VersionNumber:D4}_{clientVersionId}.docx
+ public string ObjectKey { get; set; } = string.Empty;
+
+ /// SHA256 hex ของไฟล์ (ตรวจ integrity ภายหลัง)
+ public string ContentHash { get; set; } = string.Empty;
+
+ /// จาก claim `sub` เท่านั้น — ห้ามรับจาก client
+ public string CreatedByUserId { get; set; } = string.Empty;
+
+ public DateTimeOffset CreatedAt { get; set; }
+
+ /// Snapshot ค่า fields ของ version นี้ (FR-11) — map tag→text แบบ JSON
+ public string? FieldsSnapshotJson { get; set; }
+}
diff --git a/backend/WordVersioning.Api/Models/VersionDtos.cs b/backend/WordVersioning.Api/Models/VersionDtos.cs
new file mode 100644
index 0000000..31364aa
--- /dev/null
+++ b/backend/WordVersioning.Api/Models/VersionDtos.cs
@@ -0,0 +1,28 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace WordVersioning.Api.Models;
+
+/// body ของ POST /api/documents/{documentId}/versions (multipart/form-data)
+public class UploadVersionRequest
+{
+ [Required]
+ public IFormFile File { get; set; } = null!;
+
+ /// GUID ที่ client generate ต่อ version — ใช้เป็นส่วนหนึ่งของ object key
+ [Required]
+ public string ClientVersionId { get; set; } = string.Empty;
+
+ /// JSON object map tag→text จาก content controls (FR-11 — Phase 3 จะใช้ sync เต็มรูปแบบ)
+ public string? Fields { get; set; }
+}
+
+public record UploadVersionResponse(Guid VersionId, int VersionNumber);
+
+/// item ของ version history — fields คือ snapshot ที่ parse แล้ว (ถ้ามี)
+public record VersionListItem(
+ Guid VersionId,
+ int VersionNumber,
+ DateTimeOffset CreatedAt,
+ string CreatedByUserId,
+ string ContentHash,
+ object? Fields);
diff --git a/backend/WordVersioning.Api/Program.cs b/backend/WordVersioning.Api/Program.cs
index 48863a6..c65cde4 100644
--- a/backend/WordVersioning.Api/Program.cs
+++ b/backend/WordVersioning.Api/Program.cs
@@ -1,25 +1,83 @@
+using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.OpenApi.Models;
+using WordVersioning.Api.Data;
+using WordVersioning.Api.Services;
+
var builder = WebApplication.CreateBuilder(args);
-// Add services to the container.
-
builder.Services.AddControllers();
-// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
-builder.Services.AddSwaggerGen();
+builder.Services.AddSwaggerGen(options =>
+{
+ options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
+ {
+ Name = "Authorization",
+ Type = SecuritySchemeType.Http,
+ Scheme = "bearer",
+ BearerFormat = "JWT",
+ In = ParameterLocation.Header,
+ Description = "วาง access token จาก Keycloak (realm word-versioning)",
+ });
+ options.AddSecurityRequirement(new OpenApiSecurityRequirement
+ {
+ {
+ new OpenApiSecurityScheme
+ {
+ Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "Bearer" },
+ },
+ Array.Empty()
+ },
+ });
+});
+
+// PostgreSQL — connection string จาก ConnectionStrings:Default (override ผ่าน env: ConnectionStrings__Default)
+builder.Services.AddDbContext(options =>
+ options.UseNpgsql(builder.Configuration.GetConnectionString("Default")));
+
+// MinIO / hash / current user
+builder.Services.Configure(builder.Configuration.GetSection(MinioOptions.SectionName));
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+builder.Services.AddHttpContextAccessor();
+builder.Services.AddScoped();
+
+// JWT — validate ผ่าน JWKS ของ Keycloak อัตโนมัติ (FR-8)
+builder.Services
+ .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
+ .AddJwtBearer(options =>
+ {
+ options.Authority = builder.Configuration["Authentication:Authority"];
+ options.RequireHttpsMetadata = false; // ⚠️ dev เท่านั้น — production ต้องเป็น true
+ options.MapInboundClaims = false; // คงชื่อ claim ดิบ: "sub", "preferred_username"
+
+ // รับ token ได้ทั้ง 2 frontend client (add-in + web portal) — แต่ละตัว issue token ที่ aud ต่างกัน
+ var audiences = builder.Configuration.GetSection("Authentication:ValidAudiences").Get();
+ if (audiences is { Length: > 0 })
+ options.TokenValidationParameters.ValidAudiences = audiences;
+ else
+ options.TokenValidationParameters.ValidateAudience = false; // realm-level: ตรวจ issuer + signature อย่างเดียว
+ });
+builder.Services.AddAuthorization();
+
+// CORS — add-in task pane (https://localhost:3000) + web portal (http://localhost:5173) เท่านั้น
+const string corsPolicy = "frontends";
+builder.Services.AddCors(options => options.AddPolicy(corsPolicy, policy => policy
+ .WithOrigins(builder.Configuration.GetSection("Cors:AllowedOrigins").Get() ?? [])
+ .AllowAnyHeader()
+ .AllowAnyMethod()));
var app = builder.Build();
-// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
-app.UseHttpsRedirection();
-
+app.UseCors(corsPolicy);
+app.UseAuthentication();
app.UseAuthorization();
-
app.MapControllers();
app.Run();
diff --git a/backend/WordVersioning.Api/Services/CurrentUser.cs b/backend/WordVersioning.Api/Services/CurrentUser.cs
new file mode 100644
index 0000000..5f70ef5
--- /dev/null
+++ b/backend/WordVersioning.Api/Services/CurrentUser.cs
@@ -0,0 +1,25 @@
+using System.Security.Claims;
+
+namespace WordVersioning.Api.Services;
+
+public interface ICurrentUser
+{
+ /// userId จาก claim `sub` เท่านั้น (FR-8 — ห้ามรับจาก client)
+ string UserId { get; }
+
+ /// username จาก claim `preferred_username` (ถ้ามี)
+ string? UserName { get; }
+}
+
+/// ดึง identity ของผู้เรียกจาก JWT claims — ใช้ตอนบันทึก CreatedByUserId / UpdatedByUserId
+public class CurrentUser(IHttpContextAccessor accessor) : ICurrentUser
+{
+ private readonly ClaimsPrincipal _principal =
+ accessor.HttpContext?.User ?? throw new InvalidOperationException("CurrentUser ต้องเรียกภายใน request ที่มี HttpContext");
+
+ public string UserId =>
+ _principal.FindFirst("sub")?.Value
+ ?? throw new UnauthorizedAccessException("token ไม่มี claim 'sub'");
+
+ public string? UserName => _principal.FindFirst("preferred_username")?.Value;
+}
diff --git a/backend/WordVersioning.Api/Services/HashService.cs b/backend/WordVersioning.Api/Services/HashService.cs
new file mode 100644
index 0000000..4f8ba4d
--- /dev/null
+++ b/backend/WordVersioning.Api/Services/HashService.cs
@@ -0,0 +1,20 @@
+using System.Security.Cryptography;
+
+namespace WordVersioning.Api.Services;
+
+public interface IHashService
+{
+ /// คำนวณ SHA256 ระหว่าง stream ผ่าน — คืน hex ตัวเล็ก
+ /// (อ่านจนจบ stream — caller ต้อง Seek(0) เองถ้าจะใช้ stream ต่อ)
+ Task ComputeSha256HexAsync(Stream stream, CancellationToken ct = default);
+}
+
+public class HashService : IHashService
+{
+ public async Task ComputeSha256HexAsync(Stream stream, CancellationToken ct = default)
+ {
+ using var sha = SHA256.Create();
+ var hash = await sha.ComputeHashAsync(stream, ct);
+ return Convert.ToHexString(hash).ToLowerInvariant();
+ }
+}
diff --git a/backend/WordVersioning.Api/Services/MinioOptions.cs b/backend/WordVersioning.Api/Services/MinioOptions.cs
new file mode 100644
index 0000000..87f7048
--- /dev/null
+++ b/backend/WordVersioning.Api/Services/MinioOptions.cs
@@ -0,0 +1,21 @@
+namespace WordVersioning.Api.Services;
+
+/// Config การเชื่อม MinIO — bind จาก section "MinIO" ใน appsettings/env
+/// (rule: ห้าม hardcode — deploy ได้โดยไม่แก้โค้ด เช่น MinIO__Endpoint)
+public class MinioOptions
+{
+ public const string SectionName = "MinIO";
+
+ /// host:port ไม่รวม scheme (เช่น localhost:9000 หรือ minio:9000)
+ public string Endpoint { get; set; } = "localhost:9000";
+
+ public string AccessKey { get; set; } = string.Empty;
+
+ public string SecretKey { get; set; } = string.Empty;
+
+ public bool UseSSL { get; set; }
+
+ public string VersionsBucket { get; set; } = "docx-versions";
+
+ public string TemplatesBucket { get; set; } = "docx-templates";
+}
diff --git a/backend/WordVersioning.Api/Services/ObjectStorageService.cs b/backend/WordVersioning.Api/Services/ObjectStorageService.cs
new file mode 100644
index 0000000..d2613f7
--- /dev/null
+++ b/backend/WordVersioning.Api/Services/ObjectStorageService.cs
@@ -0,0 +1,97 @@
+using Amazon.Runtime;
+using Amazon.S3;
+using Amazon.S3.Model;
+using Microsoft.Extensions.Options;
+
+namespace WordVersioning.Api.Services;
+
+public interface IObjectStorageService
+{
+ /// อัปโหลด stream ไป bucket (content ต้อง seek ได้ — จะได้กำหนด ContentLength และ retry ได้)
+ Task PutAsync(string bucket, string key, Stream content, string contentType, CancellationToken ct = default);
+
+ /// ดึง object กลับมาเป็น stream (proxy ต่อให้ client) — stream นี้ dispose แล้วจะปิด S3 response ให้ด้วย
+ Task GetStreamAsync(string bucket, string key, CancellationToken ct = default);
+}
+
+/// Wrapper เรียก MinIO ผ่าน AWSSDK.S3 (S3-compatible API)
+public class ObjectStorageService : IObjectStorageService
+{
+ private readonly IAmazonS3 _s3;
+ private readonly ILogger _logger;
+
+ public ObjectStorageService(IOptions options, ILogger logger)
+ {
+ var opt = options.Value;
+ var config = new AmazonS3Config
+ {
+ ServiceURL = (opt.UseSSL ? "https://" : "http://") + opt.Endpoint,
+ ForcePathStyle = true, // จำเป็นสำหรับ MinIO (path-style bucket)
+ };
+ _s3 = new AmazonS3Client(new BasicAWSCredentials(opt.AccessKey, opt.SecretKey), config);
+ _logger = logger;
+ }
+
+ public async Task PutAsync(string bucket, string key, Stream content, string contentType, CancellationToken ct = default)
+ {
+ var request = new PutObjectRequest
+ {
+ BucketName = bucket,
+ Key = key,
+ InputStream = content,
+ ContentType = contentType,
+ };
+ if (content.CanSeek)
+ {
+ // ระบุความยาวชัดๆ ไม่ต้องให้ SDK ไป buffer ทั้ง stream
+ request.Headers.ContentLength = content.Length;
+ }
+
+ await _s3.PutObjectAsync(request, ct);
+ _logger.LogInformation("Uploaded object {Bucket}/{Key} ({Length} bytes)", bucket, key, content.Length);
+ }
+
+ public async Task GetStreamAsync(string bucket, string key, CancellationToken ct = default)
+ {
+ GetObjectResponse response;
+ try
+ {
+ response = await _s3.GetObjectAsync(new GetObjectRequest { BucketName = bucket, Key = key }, ct);
+ }
+ catch (AmazonS3Exception ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
+ {
+ throw new KeyNotFoundException($"Object {bucket}/{key} not found");
+ }
+
+ _logger.LogInformation("Fetching object {Bucket}/{Key} ({Length} bytes)", bucket, key, response.ContentLength);
+ return new S3ResponseStream(response);
+ }
+
+ /// Stream ที่ครอบ GetObjectResponse — dispose stream = dispose response ทั้งอัน
+ /// (ไม่งั้น controller ต้องรับ response มาด้วยแล้วจัดการ lifetime เอง ยุ่ง)
+ private sealed class S3ResponseStream(GetObjectResponse response) : Stream
+ {
+ private readonly Stream _inner = response.ResponseStream;
+
+ public override bool CanRead => _inner.CanRead;
+ public override bool CanSeek => _inner.CanSeek;
+ public override bool CanWrite => _inner.CanWrite;
+ public override long Length => _inner.Length;
+ public override long Position { get => _inner.Position; set => _inner.Position = value; }
+
+ public override int Read(byte[] buffer, int offset, int count) => _inner.Read(buffer, offset, count);
+ public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken ct) =>
+ _inner.ReadAsync(buffer, offset, count, ct);
+ public override ValueTask ReadAsync(Memory buffer, CancellationToken ct = default) => _inner.ReadAsync(buffer, ct);
+ public override long Seek(long offset, SeekOrigin origin) => _inner.Seek(offset, origin);
+ public override void Flush() => _inner.Flush();
+ public override void SetLength(long value) => _inner.SetLength(value);
+ public override void Write(byte[] buffer, int offset, int count) => _inner.Write(buffer, offset, count);
+
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing) response.Dispose();
+ base.Dispose(disposing);
+ }
+ }
+}
diff --git a/backend/WordVersioning.Api/WeatherForecast.cs b/backend/WordVersioning.Api/WeatherForecast.cs
deleted file mode 100644
index 5c892f5..0000000
--- a/backend/WordVersioning.Api/WeatherForecast.cs
+++ /dev/null
@@ -1,12 +0,0 @@
-namespace WordVersioning.Api;
-
-public class WeatherForecast
-{
- public DateOnly Date { get; set; }
-
- public int TemperatureC { get; set; }
-
- public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
-
- public string? Summary { get; set; }
-}
diff --git a/backend/WordVersioning.Api/WordVersioning.Api.http b/backend/WordVersioning.Api/WordVersioning.Api.http
deleted file mode 100644
index 9a85038..0000000
--- a/backend/WordVersioning.Api/WordVersioning.Api.http
+++ /dev/null
@@ -1,6 +0,0 @@
-@WordVersioning.Api_HostAddress = http://localhost:5075
-
-GET {{WordVersioning.Api_HostAddress}}/weatherforecast/
-Accept: application/json
-
-###
diff --git a/backend/WordVersioning.Api/appsettings.Development.json b/backend/WordVersioning.Api/appsettings.Development.json
index 0c208ae..afd7cb6 100644
--- a/backend/WordVersioning.Api/appsettings.Development.json
+++ b/backend/WordVersioning.Api/appsettings.Development.json
@@ -2,7 +2,18 @@
"Logging": {
"LogLevel": {
"Default": "Information",
- "Microsoft.AspNetCore": "Warning"
+ "Microsoft.AspNetCore": "Information"
}
+ },
+
+ "//": "ค่า local dev ตรงกับ .env.example (docker compose) — ถ้าจะ override ด้วย env ใช้ตัวแปร เช่น MinIO__Endpoint, ConnectionStrings__Default, Authentication__Authority",
+
+ "ConnectionStrings": {
+ "Default": "Host=localhost;Port=5432;Database=wordversioning;Username=wordversioning;Password=dev-local-pass"
+ },
+
+ "MinIO": {
+ "AccessKey": "wordversioning-minio",
+ "SecretKey": "dev-local-pass"
}
}
diff --git a/backend/WordVersioning.Api/appsettings.json b/backend/WordVersioning.Api/appsettings.json
index 10f68b8..5fcbc7f 100644
--- a/backend/WordVersioning.Api/appsettings.json
+++ b/backend/WordVersioning.Api/appsettings.json
@@ -5,5 +5,27 @@
"Microsoft.AspNetCore": "Warning"
}
},
- "AllowedHosts": "*"
+ "AllowedHosts": "*",
+
+ "ConnectionStrings": {
+ "Default": "Host=localhost;Port=5432;Database=wordversioning;Username=wordversioning;Password="
+ },
+
+ "MinIO": {
+ "Endpoint": "localhost:9000",
+ "AccessKey": "",
+ "SecretKey": "",
+ "UseSSL": false,
+ "VersionsBucket": "docx-versions",
+ "TemplatesBucket": "docx-templates"
+ },
+
+ "Authentication": {
+ "Authority": "http://localhost:8080/realms/word-versioning",
+ "ValidAudiences": [ "word-addin-officejs", "word-versioning-web" ]
+ },
+
+ "Cors": {
+ "AllowedOrigins": [ "https://localhost:3000", "http://localhost:5173" ]
+ }
}
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..515fa9e
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,105 @@
+# Local dev infrastructure — Word Versioning
+# ใช้ร่วมกับ .env (สร้างจาก .env.example) — ทุก credential อยู่ใน env เท่านั้น ห้าม hardcode
+#
+# docker compose up -d # รัน infra: db + minio (+ init buckets) + keycloak
+# docker compose --profile api up -d # เพิ่ม backend API (หลัง Phase 2 มี Dockerfile)
+
+name: word-versioning
+
+services:
+ # --- PostgreSQL (EF Core / Npgsql) ---------------------------------
+ db:
+ image: postgres:16
+ environment:
+ POSTGRES_USER: ${POSTGRES_USER}
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
+ POSTGRES_DB: ${POSTGRES_DB}
+ ports:
+ - "5432:5432" # เปิดให้ backend ที่รันด้วย dotnet run บน host เชื่อมได้
+ volumes:
+ - postgres_data:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
+ interval: 5s
+ timeout: 3s
+ retries: 12
+
+ # --- MinIO (S3-compatible สำหรับไฟล์ .docx) -----------------------
+ minio:
+ image: minio/minio:latest
+ command: server /data --console-address ":9001"
+ environment:
+ MINIO_ROOT_USER: ${MINIO_ROOT_USER}
+ MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
+ # ให้ healthcheck (mc ready) เรียกผ่าน alias "local" ได้
+ MC_HOST_local: http://${MINIO_ROOT_USER}:${MINIO_ROOT_PASSWORD}@localhost:9000
+ ports:
+ - "9000:9000" # S3 API
+ - "9001:9001" # MinIO console
+ volumes:
+ - minio_data:/data
+ healthcheck:
+ test: ["CMD", "mc", "ready", "local"]
+ interval: 5s
+ timeout: 5s
+ retries: 12
+
+ # สร้าง bucket docx-versions + docx-templates แบบ private (anonymous = none)
+ # idempotent — รันซ้ำได้ด้วย --ignore-existing
+ minio-init:
+ image: minio/mc:latest
+ depends_on:
+ minio:
+ condition: service_healthy
+ restart: "no"
+ environment:
+ MINIO_ROOT_USER: ${MINIO_ROOT_USER}
+ MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
+ entrypoint: >
+ /bin/sh -c "
+ set -e;
+ mc alias set local http://minio:9000 $$MINIO_ROOT_USER $$MINIO_ROOT_PASSWORD;
+ mc mb --ignore-existing local/docx-versions;
+ mc mb --ignore-existing local/docx-templates;
+ mc anonymous set none local/docx-versions;
+ mc anonymous set none local/docx-templates;
+ echo 'MinIO buckets ready: docx-versions, docx-templates (private)';
+ "
+
+ # --- Keycloak (OIDC) -----------------------------------------------
+ # realm word-versioning import อัตโนมัติตอน startup จาก /keycloak/realm-export.json
+ # ไม่ mount volume เก็บข้อมูล realm โดยตั้งใจ: ตั้งแต่ compose down ขึ้นไป realm จะถูก
+ # import ใหม่จาก realm-export.json (ไฟล์นี้คือ source of truth ของ realm config ฝั่ง dev)
+ # — แก้อะไรใน realm ให้แก้ใน realm-export.json แล้ว docker compose down && up
+ keycloak:
+ image: quay.io/keycloak/keycloak:26.2
+ command: start-dev --import-realm
+ environment:
+ KC_BOOTSTRAP_ADMIN_USERNAME: ${KC_BOOTSTRAP_ADMIN_USERNAME}
+ KC_BOOTSTRAP_ADMIN_PASSWORD: ${KC_BOOTSTRAP_ADMIN_PASSWORD}
+ ports:
+ - "8080:8080" # admin console + OIDC endpoints
+ volumes:
+ - ./keycloak/realm-export.json:/opt/keycloak/data/import/realm-export.json:ro
+
+ # --- Backend API (ยังไม่เปิดใช้ — รอ Phase 2 สร้าง Dockerfile) -----
+ # รันฝั่ง host ด้วย `dotnet run` ได้เลย โดยชี้ config ไปที่ localhost:5432 / localhost:9000
+ # หรือรันใน network นี้: docker compose --profile api up -d --build
+ api:
+ build:
+ context: ./backend
+ dockerfile: WordVersioning.Api/Dockerfile
+ profiles: ["api"]
+ depends_on:
+ db:
+ condition: service_healthy
+ minio:
+ condition: service_healthy
+ keycloak:
+ condition: service_started
+ ports:
+ - "8081:8080"
+
+volumes:
+ postgres_data:
+ minio_data:
diff --git a/docs/plans/officejs-implementation-plan.md b/docs/plans/officejs-implementation-plan.md
index 843898c..056fac6 100644
--- a/docs/plans/officejs-implementation-plan.md
+++ b/docs/plans/officejs-implementation-plan.md
@@ -169,18 +169,27 @@ POST /api/documents/generate { templateId, values: { : } }
**เป้า:** รัน infra ทั้งชุดด้วยคำสั่งเดียว — ตรง spec ส่วน 11 (backend ข้อ 6, 8)
-- [ ] `docker-compose.yml` กำหนด services:
+- [x] `docker-compose.yml` กำหนด services:
- `db` — `postgres:16` (volume สำหรับข้อมูล)
- `minio` — `minio/minio` (console เปิดด้วย) + init service สร้าง bucket **`docx-versions`** และ **`docx-templates`** แบบ **private** ทั้งคู่
- `keycloak` — `quay.io/keycloak/keycloak` start-dev + import realm อัตโนมัติ
- `api` — build จาก `/backend` (รอ Phase 2 เสร็จค่อย enable ก็ได้)
-- [ ] `/keycloak/realm-export.json`:
+- [x] `/keycloak/realm-export.json`:
- realm `word-versioning`
- client `word-addin-officejs` — public, Standard Flow, **PKCE S256 บังคับ**, ไม่มี secret, redirect URI `https://localhost:3000/auth-callback.html`
- client `word-versioning-web` — public, Standard Flow, PKCE S256, redirect URI `http://localhost:5173/*` (web ทุก route เพราะใช้ redirect ปกติ)
- client `word-addin-vsto` — public + PKCE (pre-register ตาม spec)
- สร้าง test user แรกไว้ใน realm export เลยก็ได้
-- [ ] กำหนด env defaults (เช่น ใน `.env.example`): MinIO endpoint/keys, Keycloak admin password, DB password
+- [x] กำหนด env defaults (เช่น ใน `.env.example`): MinIO endpoint/keys, Keycloak admin password, DB password
+
+หมายเหตุจากการทำจริง (2026-08-19):
+- Keycloak = `26.2.5` (tag `26.2`); admin bootstrap ใช้ env `KC_BOOTSTRAP_ADMIN_USERNAME/PASSWORD` (ชุดใหม่แทน `KEYCLOAK_ADMIN` ที่ deprecated)
+- Realm import strategy ของ Keycloak = `IGNORE_EXISTING` และ compose **ไม่ mount volume เก็บข้อมูล realm โดยตั้งใจ** → หลัง `docker compose down` realm จะถูก import ใหม่จาก `realm-export.json` เสมอ (ไฟล์นี้คือ source of truth ของ realm ฝั่ง dev — แก้ realm ให้แก้ที่ไฟล์นี้)
+- ทุก client ตั้ง `webOrigins` ด้วย (add-in: `https://localhost:3000`, web: `http://localhost:5173`) — จำเป็นเพื่อให้ browser เรียก token endpoint ผ่าน CORS จาก task pane / เว็บได้ (ไม่ใช่แค่ redirectUris)
+- ปิด `directAccessGrantsEnabled` ทุก client ตาม spec (บังคับ PKCE); ตอน dev อยากได้ token สำหรับทดสอบใช้ client `admin-cli` ของ realm (ROPC) หรือเดิน auth code flow จริง
+- redirect URI ของ `word-addin-vsto` ใช้ loopback `http://localhost/*` + `http://127.0.0.1/*` ตาม RFC 8252 (spec-vsto.md ยังไม่มี — pre-register ไว้ก่อน)
+- `api` service ใส่ไว้ใน compose แบบ `profiles: ["api"]` — ไม่ start ตอน `docker compose up` ปกติ จนกว่าจะมี Dockerfile ใน Phase 2 (`docker compose --profile api up -d --build`)
+- ตรวจ DoD ผ่านหมด: realm + 3 clients (public/standardFlow/PKCE S256/redirect ถูกทุกตัว ผ่าน admin REST), `testuser/Test1234!` ขอ token ได้, bucket 2 ตัวเป็น private (`mc anonymous get` = private, anonymous GET = 403)
**DoD:** `docker compose up` แล้ว (1) เข้า Keycloak admin console เห็น realm `word-versioning` + clients ครบ 3 ตัว (2) เข้า MinIO console เห็น bucket `docx-versions` + `docx-templates` ที่เป็น private
@@ -191,32 +200,44 @@ POST /api/documents/generate { templateId, values: { : } }
**เป้า:** API ครบทุก endpoint ของ spec เดิม พร้อม auth + storage จริง — ตรง spec ส่วน 11 (backend ข้อ 1–5)
### 2.1 Data layer
-- [ ] `Models/Document.cs`, `Models/DocumentVersion.cs` ตาม spec ส่วน 7 + เพิ่ม `TemplateId` (Document) และ `FieldsSnapshotJson` (DocumentVersion) สำหรับ FR-10/11
-- [ ] `Data/AppDbContext.cs` (Npgsql) + unique index `(DocumentId, VersionNumber)`
-- [ ] EF migration แรก
+- [x] `Models/Document.cs`, `Models/DocumentVersion.cs` ตาม spec ส่วน 7 + เพิ่ม `TemplateId` (Document) และ `FieldsSnapshotJson` (DocumentVersion) สำหรับ FR-10/11
+- [x] `Data/AppDbContext.cs` (Npgsql) + unique index `(DocumentId, VersionNumber)`
+- [x] EF migration แรก
### 2.2 Services
-- [ ] `IObjectStorageService` + `ObjectStorageService` — `AmazonS3Client` ตั้ง `ServiceURL` = MinIO endpoint, **`ForcePathStyle = true`** (จำเป็นสำหรับ MinIO), methods: `PutAsync(bucket, key, stream, contentType)`, `GetStreamAsync(bucket, key)`, config (`Endpoint`, `AccessKey`, `SecretKey`, `UseSSL`) จาก `appsettings.json`/env; `BucketName` แยกเป็น `VersionsBucket` + `TemplatesBucket`
-- [ ] `HashService` — SHA256 คำนวณตอน stream ผ่าน (ไม่ต้อง load ทั้งไฟล์ใน memory ถ้าไหว)
+- [x] `IObjectStorageService` + `ObjectStorageService` — `AmazonS3Client` ตั้ง `ServiceURL` = MinIO endpoint, **`ForcePathStyle = true`** (จำเป็นสำหรับ MinIO), methods: `PutAsync(bucket, key, stream, contentType)`, `GetStreamAsync(bucket, key)`, config (`Endpoint`, `AccessKey`, `SecretKey`, `UseSSL`) จาก `appsettings.json`/env; `BucketName` แยกเป็น `VersionsBucket` + `TemplatesBucket`
+- [x] `HashService` — SHA256 คำนวณตอน stream ผ่าน (ไม่ต้อง load ทั้งไฟล์ใน memory ถ้าไหว)
### 2.3 Auth (FR-8 ฝั่ง backend)
-- [ ] `AddAuthentication(JwtBearer)` config `Authority` = Keycloak realm URL, `Audience` ครอบทั้ง client ของ add-in และ web (หรือใช้ realm-level validation ตามที่ config ได้), `RequireHttpsMetadata = false` (dev เท่านั้น — คอมเมนต์ไว้ชัดว่า prod ต้อง true)
-- [ ] `[Authorize]` บน `DocumentsController` และ `TemplatesController` ทั้ง controller
-- [ ] Helper ดึง userId/username จาก claims `sub` / `preferred_username` — **ห้ามรับค่านี้จาก client**
+- [x] `AddAuthentication(JwtBearer)` config `Authority` = Keycloak realm URL, `Audience` ครอบทั้ง client ของ add-in และ web (หรือใช้ realm-level validation ตามที่ config ได้), `RequireHttpsMetadata = false` (dev เท่านั้น — คอมเมนต์ไว้ชัดว่า prod ต้อง true)
+- [x] `[Authorize]` บน `DocumentsController` และ `TemplatesController` ทั้ง controller
+- [x] Helper ดึง userId/username จาก claims `sub` / `preferred_username` — **ห้ามรับค่านี้จาก client**
### 2.4 Endpoints (`DocumentsController`)
-- [ ] `POST /api/documents/{documentId}/versions` (FR-5) — multipart: `file` + `clientVersionId` + **`fields` (JSON map tag→text, เตรียมทางไว้ให้ FR-11)**; auto-create `Document` ถ้ายังไม่มี; versionNumber = max+1 ต่อ document (มี unique index รองรับ concurrency); object key `{documentId}/v{versionNumber:D4}_{clientVersionId}.docx`; คำนวณ + เก็บ SHA256; response `{ versionId, versionNumber }`
-- [ ] `GET /api/documents/{documentId}/versions` (FR-6) — เรียงใหม่ → เก่า (คืน snapshot fields ต่อ version ด้วย)
-- [ ] `GET /api/documents/{documentId}/versions/{versionId}/download` (FR-7) — **proxy stream** จาก MinIO (ไม่ expose MinIO ตรงๆ) พร้อม content-type ที่ถูกต้อง
-- [ ] Validation: reject content-type ที่ไม่ใช่ docx, จำกัด request size **100MB** (`RequestSizeLimit` / Kestrel config)
+- [x] `POST /api/documents/{documentId}/versions` (FR-5) — multipart: `file` + `clientVersionId` + **`fields` (JSON map tag→text, เตรียมทางไว้ให้ FR-11)**; auto-create `Document` ถ้ายังไม่มี; versionNumber = max+1 ต่อ document (มี unique index รองรับ concurrency); object key `{documentId}/v{versionNumber:D4}_{clientVersionId}.docx`; คำนวณ + เก็บ SHA256; response `{ versionId, versionNumber }`
+- [x] `GET /api/documents/{documentId}/versions` (FR-6) — เรียงใหม่ → เก่า (คืน snapshot fields ต่อ version ด้วย)
+- [x] `GET /api/documents/{documentId}/versions/{versionId}/download` (FR-7) — **proxy stream** จาก MinIO (ไม่ expose MinIO ตรงๆ) พร้อม content-type ที่ถูกต้อง
+- [x] Validation: reject content-type ที่ไม่ใช่ docx, จำกัด request size **100MB** (`RequestSizeLimit` / Kestrel config)
### 2.5 Cross-cutting
-- [ ] CORS อนุญาต **`https://localhost:3000` (add-in) และ `http://localhost:5173` (web)**
-- [ ] Logging ทุก upload/download/generate: documentId, versionId, userId
-- [ ] `appsettings.json` + env overrides ครบทุก config (DB, MinIO, Keycloak)
+- [x] CORS อนุญาต **`https://localhost:3000` (add-in) และ `http://localhost:5173` (web)**
+- [x] Logging ทุก upload/download/generate: documentId, versionId, userId
+- [x] `appsettings.json` + env overrides ครบทุก config (DB, MinIO, Keycloak)
+
+หมายเหตุจากการทำจริง (2026-08-19):
+- **Keycloak public client ไม่ใส่ claim `aud` ใน access token อัตโนมัติ** (ทดสอบจริงด้วย PKCE flow) → เพิ่ม `oidc-audience-mapper` ("audience-self") ให้ทุก client ใน `realm-export.json` เพื่อให้ token มี `aud` = clientId ตัวเอง แล้ว backend จึง validate `ValidAudiences = [word-addin-officejs, word-versioning-web]` ได้จริง (realm ต้อง re-import: `docker compose rm -sf keycloak && docker compose up -d`)
+- ตรวจ token ได้โดยไม่ต้องเปิด browser: PKCE auth-code flow ทั้งเส้นด้วย curl (GET login form → POST credentials ตาม cookie → ตาม 302 เอา `code` → exchange กับ verifier) — ใช้ซ้ำได้ตอน dev/test
+- `MapInboundClaims = false` เพื่ออ่าน claim ชื่อดิบ `sub` / `preferred_username` (ไม่โดนแปลงเป็น `http://schemas.xmlsoap.org/...`)
+- download ใช้ stream wrapper ที่ครอบ `GetObjectResponse` (dispose stream = dispose response) — controller คืน `File(stream, ...)` ได้เลยไม่ต้องโหลดทั้งไฟล์ใน memory ไม่ต้องจัดการ lifetime ใน controller
+- upload คำนวณ SHA256 จาก stream pass แรกแล้ว `Seek(0)` ส่ง MinIO pass สอง (IFormFile ของ Kestrel seek ได้เพราะ spool ลง disk แล้ว); ถ้า MinIO ล้มเหลวหลัง insert row จะถอน row กลับ (ไม่ทิ้ง record ชี้ไฟล์ที่ไม่มีจริง)
+- content-type ยอมรับ docx mime ตรงๆ และ `application/octet-stream`/`application/zip` (client อย่าง curl ไม่ set ชนิดให้) แต่ชื่อไฟล์ต้องลงท้าย `.docx` เสมอ
+- เลข version จองผ่าน insert + unique index — ชน constraint (request พร้อมกัน) แล้ว detach แล้วคำนวณใหม่สูงสุด 5 ครั้ง
+- API dev URL = `http://localhost:5075` (run แบบ `dotnet run --no-launch-profile` + `ASPNETCORE_URLS` เพื่อไม่เปิด browser)
**DoD:** `dotnet run` + Swagger: (1) เรียก endpoint ไหนก็ได้โดยไม่แนบ token → **401** (2) ขอ token จาก Keycloak ด้วย test user แล้ว upload ไฟล์ docx จริง → มี record ใน Postgres + object ใน MinIO + download กลับมาเปิดได้ (AC-3 ผ่าน)
+> ✅ ตรวจผ่านจริง (2026-08-19): 401 ทั้ง 3 endpoints; token จาก PKCE flow จริง (client `word-versioning-web`); upload v1 (พร้อม `fields`) + v2 → 201 versionNumber 1, 2; Postgres มี Document + 2 DocumentVersions (ContentHash ตรง sha256 ต้นทาง, CreatedByUserId = `sub`); MinIO มี object 2 ไฟล์ตาม key format; download กลับมาตรง byte-for-byte + content-type ถูก; GET history เรียงใหม่→เก่าพร้อม fields ที่ parse แล้ว; .txt → 400; CORS preflight จาก `https://localhost:3000` → 204 + allow-origin ถูก
+
---
## Phase 3: Backend template & generation — FR-9, FR-10 (ใหม่)
diff --git a/keycloak/realm-export.json b/keycloak/realm-export.json
new file mode 100644
index 0000000..76e496b
--- /dev/null
+++ b/keycloak/realm-export.json
@@ -0,0 +1,183 @@
+{
+ "realm": "word-versioning",
+ "enabled": true,
+ "sslRequired": "none",
+ "registrationAllowed": false,
+ "loginWithEmailAllowed": true,
+ "resetPasswordAllowed": false,
+ "bruteForceProtected": false,
+ "clients": [
+ {
+ "clientId": "word-addin-officejs",
+ "name": "Word Versioning Add-in (Office.js task pane)",
+ "description": "Public client สำหรับ task pane — OIDC Authorization Code + PKCE S256 ผ่าน Office Dialog API (redirect กลับ auth-callback.html)",
+ "enabled": true,
+ "protocol": "openid-connect",
+ "publicClient": true,
+ "bearerOnly": false,
+ "standardFlowEnabled": true,
+ "implicitFlowEnabled": false,
+ "directAccessGrantsEnabled": false,
+ "serviceAccountsEnabled": false,
+ "fullScopeAllowed": true,
+ "surrogateAuthRequired": false,
+ "redirectUris": [
+ "https://localhost:3000/auth-callback.html"
+ ],
+ "webOrigins": [
+ "https://localhost:3000"
+ ],
+ "attributes": {
+ "pkce.code.challenge.method": "S256"
+ },
+ "protocolMappers": [
+ {
+ "name": "audience-self",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-audience-mapper",
+ "consentRequired": false,
+ "config": {
+ "included.client.audience": "word-addin-officejs",
+ "id.token.claim": "false",
+ "access.token.claim": "true",
+ "userinfo.token.claim": "false"
+ }
+ }
+ ],
+ "defaultClientScopes": [
+ "web-origins",
+ "acr",
+ "profile",
+ "roles",
+ "basic",
+ "email"
+ ],
+ "optionalClientScopes": [
+ "address",
+ "phone",
+ "offline_access",
+ "microprofile-jwt"
+ ]
+ },
+ {
+ "clientId": "word-versioning-web",
+ "name": "Word Versioning Web Portal (Nuxt)",
+ "description": "Public client สำหรับเว็บ portal — OIDC browser redirect ปกติ (top-level page) + PKCE S256",
+ "enabled": true,
+ "protocol": "openid-connect",
+ "publicClient": true,
+ "bearerOnly": false,
+ "standardFlowEnabled": true,
+ "implicitFlowEnabled": false,
+ "directAccessGrantsEnabled": false,
+ "serviceAccountsEnabled": false,
+ "fullScopeAllowed": true,
+ "surrogateAuthRequired": false,
+ "redirectUris": [
+ "http://localhost:5173/*"
+ ],
+ "webOrigins": [
+ "http://localhost:5173"
+ ],
+ "attributes": {
+ "pkce.code.challenge.method": "S256"
+ },
+ "protocolMappers": [
+ {
+ "name": "audience-self",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-audience-mapper",
+ "consentRequired": false,
+ "config": {
+ "included.client.audience": "word-versioning-web",
+ "id.token.claim": "false",
+ "access.token.claim": "true",
+ "userinfo.token.claim": "false"
+ }
+ }
+ ],
+ "defaultClientScopes": [
+ "web-origins",
+ "acr",
+ "profile",
+ "roles",
+ "basic",
+ "email"
+ ],
+ "optionalClientScopes": [
+ "address",
+ "phone",
+ "offline_access",
+ "microprofile-jwt"
+ ]
+ },
+ {
+ "clientId": "word-addin-vsto",
+ "name": "Word Versioning Add-in (VSTO desktop)",
+ "description": "Pre-register ไว้ตาม spec (spec-vsto.md ยังไม่มีงาน implement) — public + PKCE, redirect เป็น loopback ตาม RFC 8252",
+ "enabled": true,
+ "protocol": "openid-connect",
+ "publicClient": true,
+ "bearerOnly": false,
+ "standardFlowEnabled": true,
+ "implicitFlowEnabled": false,
+ "directAccessGrantsEnabled": false,
+ "serviceAccountsEnabled": false,
+ "fullScopeAllowed": true,
+ "surrogateAuthRequired": false,
+ "redirectUris": [
+ "http://localhost/*",
+ "http://127.0.0.1/*"
+ ],
+ "webOrigins": [],
+ "attributes": {
+ "pkce.code.challenge.method": "S256"
+ },
+ "protocolMappers": [
+ {
+ "name": "audience-self",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-audience-mapper",
+ "consentRequired": false,
+ "config": {
+ "included.client.audience": "word-addin-vsto",
+ "id.token.claim": "false",
+ "access.token.claim": "true",
+ "userinfo.token.claim": "false"
+ }
+ }
+ ],
+ "defaultClientScopes": [
+ "web-origins",
+ "acr",
+ "profile",
+ "roles",
+ "basic",
+ "email"
+ ],
+ "optionalClientScopes": [
+ "address",
+ "phone",
+ "offline_access",
+ "microprofile-jwt"
+ ]
+ }
+ ],
+ "users": [
+ {
+ "username": "testuser",
+ "enabled": true,
+ "email": "testuser@word-versioning.local",
+ "emailVerified": true,
+ "firstName": "Test",
+ "lastName": "User",
+ "credentials": [
+ {
+ "type": "password",
+ "value": "Test1234!",
+ "temporary": false
+ }
+ ]
+ }
+ ]
+}