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

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);
// 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<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();
// 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();

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": {
"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"
}
}

View file

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