implement Phase 1-2

This commit is contained in:
Suphonchai Phoonsawat 2026-08-19 10:54:55 +07:00
parent 41573b3c4f
commit b976cf3124
23 changed files with 1291 additions and 79 deletions

23
.env.example Normal file
View file

@ -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)

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`) 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 ในแผน
## เอกสารหลัก (อ่านก่อนเริ่มงานใดๆ) ## เอกสารหลัก (อ่านก่อนเริ่มงานใดๆ)

View file

@ -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;
/// <summary>FR-5 (upload version), FR-6 (history), FR-7 (download) + รับ fields สำหรับ FR-11</summary>
[ApiController]
[Authorize] // FR-8: ทุก endpoint ต้องแนบ token
[Route("api/documents")]
public class DocumentsController(
AppDbContext db,
IObjectStorageService storage,
IHashService hasher,
ICurrentUser currentUser,
IOptions<MinioOptions> minioOptions,
ILogger<DocumentsController> 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;
/// <summary>FR-5 — อัปโหลด version ใหม่ (auto-create Document ถ้ายังไม่มี)</summary>
[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<IActionResult> 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));
}
/// <summary>FR-6 — version history เรียงใหม่ → เก่า (พร้อม snapshot fields ต่อ version)</summary>
[HttpGet("{documentId:guid}/versions")]
[ProducesResponseType(typeof(IReadOnlyList<VersionListItem>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<IReadOnlyList<VersionListItem>>> 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());
}
/// <summary>FR-7 — ดาวน์โหลด version ที่ระบุ (proxy stream จาก MinIO — ไม่ expose MinIO ตรงๆ)</summary>
[HttpGet("{documentId:guid}/versions/{versionId:guid}/download")]
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DownloadVersion(Guid documentId, Guid versionId, CancellationToken ct)
{
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 };
}

View file

@ -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<WeatherForecastController> _logger;
public WeatherForecastController(ILogger<WeatherForecastController> logger)
{
_logger = logger;
}
[HttpGet(Name = "GetWeatherForecast")]
public IEnumerable<WeatherForecast> 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();
}
}

View file

@ -0,0 +1,36 @@
using Microsoft.EntityFrameworkCore;
using WordVersioning.Api.Models;
namespace WordVersioning.Api.Data;
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
public DbSet<Document> Documents => Set<Document>();
public DbSet<DocumentVersion> DocumentVersions => Set<DocumentVersion>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Document>(entity =>
{
entity.HasKey(x => x.Id);
entity.Property(x => x.FileName).HasMaxLength(512);
entity.Property(x => x.AddInSignature).HasMaxLength(64);
});
modelBuilder.Entity<DocumentVersion>(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);
});
}
}

View file

@ -0,0 +1,113 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using WordVersioning.Api.Data;
#nullable disable
namespace WordVersioning.Api.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260819034805_InitialCreate")]
partial class InitialCreate
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.11")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("WordVersioning.Api.Models.Document", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AddInSignature")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("FileName")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<Guid?>("TemplateId")
.HasColumnType("uuid");
b.HasKey("Id");
b.ToTable("Documents");
});
modelBuilder.Entity("WordVersioning.Api.Models.DocumentVersion", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ContentHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CreatedByUserId")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("DocumentId")
.HasColumnType("uuid");
b.Property<string>("FieldsSnapshotJson")
.HasColumnType("text");
b.Property<string>("ObjectKey")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<int>("VersionNumber")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("DocumentId", "VersionNumber")
.IsUnique();
b.ToTable("DocumentVersions");
});
modelBuilder.Entity("WordVersioning.Api.Models.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
}
}
}

View file

@ -0,0 +1,70 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace WordVersioning.Api.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Documents",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
FileName = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: false),
AddInSignature = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
TemplateId = table.Column<Guid>(type: "uuid", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(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<Guid>(type: "uuid", nullable: false),
DocumentId = table.Column<Guid>(type: "uuid", nullable: false),
VersionNumber = table.Column<int>(type: "integer", nullable: false),
ObjectKey = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
ContentHash = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
CreatedByUserId = table.Column<string>(type: "text", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
FieldsSnapshotJson = table.Column<string>(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);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "DocumentVersions");
migrationBuilder.DropTable(
name: "Documents");
}
}
}

View file

@ -0,0 +1,110 @@
// <auto-generated />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AddInSignature")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("FileName")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<Guid?>("TemplateId")
.HasColumnType("uuid");
b.HasKey("Id");
b.ToTable("Documents");
});
modelBuilder.Entity("WordVersioning.Api.Models.DocumentVersion", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ContentHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CreatedByUserId")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("DocumentId")
.HasColumnType("uuid");
b.Property<string>("FieldsSnapshotJson")
.HasColumnType("text");
b.Property<string>("ObjectKey")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<int>("VersionNumber")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("DocumentId", "VersionNumber")
.IsUnique();
b.ToTable("DocumentVersions");
});
modelBuilder.Entity("WordVersioning.Api.Models.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
}
}
}

View file

@ -0,0 +1,20 @@
namespace WordVersioning.Api.Models;
/// <summary>เอกสารหนึ่งฉบับที่ถูก track — 1 document : N versions (spec ส่วน 7)</summary>
public class Document
{
public Guid Id { get; set; }
public string FileName { get; set; } = string.Empty;
/// <summary>ค่า custom property `SavedByAddIn` ที่ stamp ไว้ในไฟล์ (FR-1)</summary>
public string AddInSignature { get; set; } = string.Empty;
/// <summary>เอกสารที่สร้างจาก template (FR-10) — null ถ้า track เอกสารเปล่า
/// (Phase 3 จะเติม FK ไป Template เมื่อมี entity)</summary>
public Guid? TemplateId { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public List<DocumentVersion> Versions { get; set; } = new();
}

View file

@ -0,0 +1,28 @@
namespace WordVersioning.Api.Models;
/// <summary>Snapshot ของเอกสารหนึ่ง version (spec ส่วน 7)</summary>
public class DocumentVersion
{
public Guid Id { get; set; }
public Guid DocumentId { get; set; }
public Document? Document { get; set; }
/// <summary>เริ่มที่ 1 — auto increment ต่อ document (unique index กัน race)</summary>
public int VersionNumber { get; set; }
/// <summary>MinIO object key: {documentId}/v{VersionNumber:D4}_{clientVersionId}.docx</summary>
public string ObjectKey { get; set; } = string.Empty;
/// <summary>SHA256 hex ของไฟล์ (ตรวจ integrity ภายหลัง)</summary>
public string ContentHash { get; set; } = string.Empty;
/// <summary>จาก claim `sub` เท่านั้น — ห้ามรับจาก client</summary>
public string CreatedByUserId { get; set; } = string.Empty;
public DateTimeOffset CreatedAt { get; set; }
/// <summary>Snapshot ค่า fields ของ version นี้ (FR-11) — map tag→text แบบ JSON</summary>
public string? FieldsSnapshotJson { get; set; }
}

View file

@ -0,0 +1,28 @@
using System.ComponentModel.DataAnnotations;
namespace WordVersioning.Api.Models;
/// <summary>body ของ POST /api/documents/{documentId}/versions (multipart/form-data)</summary>
public class UploadVersionRequest
{
[Required]
public IFormFile File { get; set; } = null!;
/// <summary>GUID ที่ client generate ต่อ version — ใช้เป็นส่วนหนึ่งของ object key</summary>
[Required]
public string ClientVersionId { get; set; } = string.Empty;
/// <summary>JSON object map tag→text จาก content controls (FR-11 — Phase 3 จะใช้ sync เต็มรูปแบบ)</summary>
public string? Fields { get; set; }
}
public record UploadVersionResponse(Guid VersionId, int VersionNumber);
/// <summary>item ของ version history — fields คือ snapshot ที่ parse แล้ว (ถ้ามี)</summary>
public record VersionListItem(
Guid VersionId,
int VersionNumber,
DateTimeOffset CreatedAt,
string CreatedByUserId,
string ContentHash,
object? Fields);

View file

@ -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); var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddControllers(); builder.Services.AddControllers();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer(); 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<string>()
},
});
});
// PostgreSQL — connection string จาก ConnectionStrings:Default (override ผ่าน env: ConnectionStrings__Default)
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("Default")));
// MinIO / hash / current user
builder.Services.Configure<MinioOptions>(builder.Configuration.GetSection(MinioOptions.SectionName));
builder.Services.AddSingleton<IObjectStorageService, ObjectStorageService>();
builder.Services.AddSingleton<IHashService, HashService>();
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ICurrentUser, CurrentUser>();
// 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<string[]>();
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<string[]>() ?? [])
.AllowAnyHeader()
.AllowAnyMethod()));
var app = builder.Build(); var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
{ {
app.UseSwagger(); app.UseSwagger();
app.UseSwaggerUI(); app.UseSwaggerUI();
} }
app.UseHttpsRedirection(); app.UseCors(corsPolicy);
app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
app.MapControllers(); app.MapControllers();
app.Run(); app.Run();

View file

@ -0,0 +1,25 @@
using System.Security.Claims;
namespace WordVersioning.Api.Services;
public interface ICurrentUser
{
/// <summary>userId จาก claim `sub` เท่านั้น (FR-8 — ห้ามรับจาก client)</summary>
string UserId { get; }
/// <summary>username จาก claim `preferred_username` (ถ้ามี)</summary>
string? UserName { get; }
}
/// <summary>ดึง identity ของผู้เรียกจาก JWT claims — ใช้ตอนบันทึก CreatedByUserId / UpdatedByUserId</summary>
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;
}

View file

@ -0,0 +1,20 @@
using System.Security.Cryptography;
namespace WordVersioning.Api.Services;
public interface IHashService
{
/// <summary>คำนวณ SHA256 ระหว่าง stream ผ่าน — คืน hex ตัวเล็ก
/// (อ่านจนจบ stream — caller ต้อง Seek(0) เองถ้าจะใช้ stream ต่อ)</summary>
Task<string> ComputeSha256HexAsync(Stream stream, CancellationToken ct = default);
}
public class HashService : IHashService
{
public async Task<string> ComputeSha256HexAsync(Stream stream, CancellationToken ct = default)
{
using var sha = SHA256.Create();
var hash = await sha.ComputeHashAsync(stream, ct);
return Convert.ToHexString(hash).ToLowerInvariant();
}
}

View file

@ -0,0 +1,21 @@
namespace WordVersioning.Api.Services;
/// <summary>Config การเชื่อม MinIO — bind จาก section "MinIO" ใน appsettings/env
/// (rule: ห้าม hardcode — deploy ได้โดยไม่แก้โค้ด เช่น MinIO__Endpoint)</summary>
public class MinioOptions
{
public const string SectionName = "MinIO";
/// <summary>host:port ไม่รวม scheme (เช่น localhost:9000 หรือ minio:9000)</summary>
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";
}

View file

@ -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
{
/// <summary>อัปโหลด stream ไป bucket (content ต้อง seek ได้ — จะได้กำหนด ContentLength และ retry ได้)</summary>
Task PutAsync(string bucket, string key, Stream content, string contentType, CancellationToken ct = default);
/// <summary>ดึง object กลับมาเป็น stream (proxy ต่อให้ client) — stream นี้ dispose แล้วจะปิด S3 response ให้ด้วย</summary>
Task<Stream> GetStreamAsync(string bucket, string key, CancellationToken ct = default);
}
/// <summary>Wrapper เรียก MinIO ผ่าน AWSSDK.S3 (S3-compatible API)</summary>
public class ObjectStorageService : IObjectStorageService
{
private readonly IAmazonS3 _s3;
private readonly ILogger<ObjectStorageService> _logger;
public ObjectStorageService(IOptions<MinioOptions> options, ILogger<ObjectStorageService> 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<Stream> 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);
}
/// <summary>Stream ที่ครอบ GetObjectResponse — dispose stream = dispose response ทั้งอัน
/// (ไม่งั้น controller ต้องรับ response มาด้วยแล้วจัดการ lifetime เอง ยุ่ง)</summary>
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<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken ct) =>
_inner.ReadAsync(buffer, offset, count, ct);
public override ValueTask<int> ReadAsync(Memory<byte> 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);
}
}
}

View file

@ -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; }
}

View file

@ -1,6 +0,0 @@
@WordVersioning.Api_HostAddress = http://localhost:5075
GET {{WordVersioning.Api_HostAddress}}/weatherforecast/
Accept: application/json
###

View file

@ -2,7 +2,18 @@
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Information", "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"
} }
} }

View file

@ -5,5 +5,27 @@
"Microsoft.AspNetCore": "Warning" "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" ]
}
} }

105
docker-compose.yml Normal file
View file

@ -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:

View file

@ -169,18 +169,27 @@ POST /api/documents/generate { templateId, values: { <fieldKey>: <value> } }
**เป้า:** รัน infra ทั้งชุดด้วยคำสั่งเดียว — ตรง spec ส่วน 11 (backend ข้อ 6, 8) **เป้า:** รัน infra ทั้งชุดด้วยคำสั่งเดียว — ตรง spec ส่วน 11 (backend ข้อ 6, 8)
- [ ] `docker-compose.yml` กำหนด services: - [x] `docker-compose.yml` กำหนด services:
- `db` — `postgres:16` (volume สำหรับข้อมูล) - `db` — `postgres:16` (volume สำหรับข้อมูล)
- `minio` — `minio/minio` (console เปิดด้วย) + init service สร้าง bucket **`docx-versions`** และ **`docx-templates`** แบบ **private** ทั้งคู่ - `minio` — `minio/minio` (console เปิดด้วย) + init service สร้าง bucket **`docx-versions`** และ **`docx-templates`** แบบ **private** ทั้งคู่
- `keycloak` — `quay.io/keycloak/keycloak` start-dev + import realm อัตโนมัติ - `keycloak` — `quay.io/keycloak/keycloak` start-dev + import realm อัตโนมัติ
- `api` — build จาก `/backend` (รอ Phase 2 เสร็จค่อย enable ก็ได้) - `api` — build จาก `/backend` (รอ Phase 2 เสร็จค่อย enable ก็ได้)
- [ ] `/keycloak/realm-export.json`: - [x] `/keycloak/realm-export.json`:
- realm `word-versioning` - realm `word-versioning`
- client `word-addin-officejs` — public, Standard Flow, **PKCE S256 บังคับ**, ไม่มี secret, redirect URI `https://localhost:3000/auth-callback.html` - 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-versioning-web` — public, Standard Flow, PKCE S256, redirect URI `http://localhost:5173/*` (web ทุก route เพราะใช้ redirect ปกติ)
- client `word-addin-vsto` — public + PKCE (pre-register ตาม spec) - client `word-addin-vsto` — public + PKCE (pre-register ตาม spec)
- สร้าง test user แรกไว้ใน realm export เลยก็ได้ - สร้าง 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 **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: { <fieldKey>: <value> } }
**เป้า:** API ครบทุก endpoint ของ spec เดิม พร้อม auth + storage จริง — ตรง spec ส่วน 11 (backend ข้อ 1–5) **เป้า:** API ครบทุก endpoint ของ spec เดิม พร้อม auth + storage จริง — ตรง spec ส่วน 11 (backend ข้อ 1–5)
### 2.1 Data layer ### 2.1 Data layer
- [ ] `Models/Document.cs`, `Models/DocumentVersion.cs` ตาม spec ส่วน 7 + เพิ่ม `TemplateId` (Document) และ `FieldsSnapshotJson` (DocumentVersion) สำหรับ FR-10/11 - [x] `Models/Document.cs`, `Models/DocumentVersion.cs` ตาม spec ส่วน 7 + เพิ่ม `TemplateId` (Document) และ `FieldsSnapshotJson` (DocumentVersion) สำหรับ FR-10/11
- [ ] `Data/AppDbContext.cs` (Npgsql) + unique index `(DocumentId, VersionNumber)` - [x] `Data/AppDbContext.cs` (Npgsql) + unique index `(DocumentId, VersionNumber)`
- [ ] EF migration แรก - [x] EF migration แรก
### 2.2 Services ### 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` - [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`
- [ ] `HashService` — SHA256 คำนวณตอน stream ผ่าน (ไม่ต้อง load ทั้งไฟล์ใน memory ถ้าไหว) - [x] `HashService` — SHA256 คำนวณตอน stream ผ่าน (ไม่ต้อง load ทั้งไฟล์ใน memory ถ้าไหว)
### 2.3 Auth (FR-8 ฝั่ง backend) ### 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) - [x] `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 - [x] `[Authorize]` บน `DocumentsController` และ `TemplatesController` ทั้ง controller
- [ ] Helper ดึง userId/username จาก claims `sub` / `preferred_username` — **ห้ามรับค่านี้จาก client** - [x] Helper ดึง userId/username จาก claims `sub` / `preferred_username` — **ห้ามรับค่านี้จาก client**
### 2.4 Endpoints (`DocumentsController`) ### 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 }` - [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 }`
- [ ] `GET /api/documents/{documentId}/versions` (FR-6) — เรียงใหม่ → เก่า (คืน snapshot fields ต่อ version ด้วย) - [x] `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 ที่ถูกต้อง - [x] `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] Validation: reject content-type ที่ไม่ใช่ docx, จำกัด request size **100MB** (`RequestSizeLimit` / Kestrel config)
### 2.5 Cross-cutting ### 2.5 Cross-cutting
- [ ] CORS อนุญาต **`https://localhost:3000` (add-in) และ `http://localhost:5173` (web)** - [x] CORS อนุญาต **`https://localhost:3000` (add-in) และ `http://localhost:5173` (web)**
- [ ] Logging ทุก upload/download/generate: documentId, versionId, userId - [x] Logging ทุก upload/download/generate: documentId, versionId, userId
- [ ] `appsettings.json` + env overrides ครบทุก config (DB, MinIO, Keycloak) - [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 ผ่าน) **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 (ใหม่) ## Phase 3: Backend template & generation — FR-9, FR-10 (ใหม่)

183
keycloak/realm-export.json Normal file
View file

@ -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
}
]
}
]
}