feat(docver): add document versioning API (docx+pdf in MinIO)
Stand-alone API group at /api/documents/{docId}/versions — one document
per docId (unique), each version stores .docx + optional .pdf as MinIO
objects with uploader userId/userName/createdAt (no auth, like
api/template; callers identify themselves per docs/PLAN-DOCVER.md).
- POST versions (multipart): validates fields/magic bytes/100MB cap,
uploads to MinIO first, cleans up objects if the DB insert fails;
returns {hasPdf, versionId, versionNumber} with server-authoritative
numbering (tx: upsert + SELECT FOR UPDATE, safe under parallel calls)
- GET versions: DocumentVersionDTO[] newest-first (404 for unknown docId)
- GET versions/{versionId}/download-link?format=docx|pdf: presigned URL
(docId+versionId matched together; pdf-missing = 404)
- tables documents/document_versions + migration drizzle/0005
- docker-compose.yml: judes-note-db + judes-note-minio (quay.io image,
host ports 19000/19001, POSTGRES_DB ecourt-note, pinned volume names)
- lib/openapi.ts: documents tag + paths for Swagger UI
- middleware: exempt api/documents from login redirect
- unit tests tests/lib/document-versioning.test.ts (23 cases)
wordaddin untouched — swap the mock later via NEXT_PUBLIC_ADDIN_API_BASE_URL.
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
5917c0532c
commit
765946f0d5
19 changed files with 2711 additions and 185 deletions
94
db/schema.ts
94
db/schema.ts
|
|
@ -253,12 +253,76 @@ export const annotationVersions = pgTable(
|
|||
(t) => [index("annotation_versions_file_idx").on(t.fileId)],
|
||||
);
|
||||
|
||||
// ─── e-Court Documents Versioning (docs/PLAN-DOCVER.md) ──────────────────────
|
||||
|
||||
/** เอกสารหนึ่งชิ้นต่อ docId ที่ผู้เรียกกำหนด — สร้าง implicitly ตอน upload version แรก
|
||||
* (ไม่มี endpoint/หน้าจอบริหารแยก) · **ไม่เกี่ยวกับตาราง files ของแอป note** —
|
||||
* ไม่มี FK ไม่มี field ร่วม ไม่มีหน้า note ใดอ่าน (ของกลุ่ม /api/documents ล้วน ๆ) */
|
||||
export const documents = pgTable(
|
||||
"documents",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
/** รหัสเอกสารจากผู้เรียก (add-in ใช้ GUID DocumentTrackingId / e-Court ใช้รหัสของตัวเอง)
|
||||
* — unique ทั้งระบบ: เอกสารเดียวมี row เดียว (ห้ามซ้ำ) */
|
||||
docId: text("doc_id").notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
},
|
||||
(t) => [uniqueIndex("documents_doc_id_key").on(t.docId)],
|
||||
);
|
||||
|
||||
/** version ของเอกสาร — ไฟล์จริงอยู่ใน MinIO ตาม object key (docx + pdf ต่อ version)
|
||||
* · pdf เป็น optional ตาม contract ของ add-in (best-effort — NULL = version นี้ไม่มี pdf)
|
||||
* · object key เป็น uuid ล้วน `docver/<versionId>/document.{docx,pdf}` ไม่มี input ผู้ใช้ */
|
||||
export const documentVersions = pgTable(
|
||||
"document_versions",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
documentId: uuid("document_id")
|
||||
.notNull()
|
||||
.references(() => documents.id, { onDelete: "cascade" }),
|
||||
versionNumber: integer("version_number").notNull(),
|
||||
/** GUID ที่ client generate ก่อน upload — เก็บไว้เทียบ/สืบสวน ไม่ใช้เป็น identity
|
||||
* (ไม่มี idempotency — retry ของผู้เรียกเกิด version ใหม่ ตามแผน §4) */
|
||||
clientVersionId: text("client_version_id"),
|
||||
/** SHA-256 hex ของไบต์ docx — คำนวณฝั่ง server เสมอ (NOT NULL เพราะ buffer ก่อน insert ทุกครั้ง) */
|
||||
contentHash: text("content_hash").notNull(),
|
||||
/** commit message (FR-13) — trim แล้วว่าง = NULL */
|
||||
message: text("message"),
|
||||
/** snapshot ตอน upload: map ดิบ `{ [sdtTag]: text }` จาก content controls
|
||||
* (รูปแบบ {value,missing} ของเอกสารผูก template ยังไม่มีผู้ส่ง — วันหน้ามีค่อยขยาย $type) */
|
||||
fields: jsonb("fields").$type<Record<string, string>>(),
|
||||
templateId: text("template_id"),
|
||||
/** ชื่อไฟล์ดั้งเดิมที่ browser ส่ง (ผ่าน decodeUploadFilename + strip path แล้ว) —
|
||||
* ใช้ตั้ง Content-Disposition ของ object ไม่ใช่ชื่อ object */
|
||||
fileName: text("file_name"),
|
||||
docxObjectKey: text("docx_object_key").notNull(),
|
||||
pdfObjectKey: text("pdf_object_key"),
|
||||
docxSize: integer("docx_size").notNull(),
|
||||
pdfSize: integer("pdf_size"),
|
||||
/** ผู้อัปโหลด — endpoint รับมาแบบ plain field เพราะระบบ stand-alone ไม่มี session */
|
||||
userId: text("user_id").notNull(),
|
||||
userName: text("user_name").notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
},
|
||||
(t) => [
|
||||
// safety net ของการจ่ายเลข max+1 ภายใต้ FOR UPDATE (PLAN-DOCVER.md §3)
|
||||
uniqueIndex("document_versions_document_number_key").on(t.documentId, t.versionNumber),
|
||||
index("document_versions_document_created_idx").on(t.documentId, t.createdAt),
|
||||
],
|
||||
);
|
||||
|
||||
// ─── DTO (timestamp เป็น ISO string เสมอ — ห้ามส่ง Date ข้าม server→client) ──
|
||||
|
||||
export type AnnotationRow = typeof annotations.$inferSelect;
|
||||
export type BookmarkRow = typeof bookmarks.$inferSelect;
|
||||
export type ReplyRow = typeof annotationReplies.$inferSelect;
|
||||
export type AnnotationVersionRow = typeof annotationVersions.$inferSelect;
|
||||
export type DocumentRow = typeof documents.$inferSelect;
|
||||
export type DocumentVersionRow = typeof documentVersions.$inferSelect;
|
||||
|
||||
export type AnnotationDTO = {
|
||||
id: string;
|
||||
|
|
@ -371,3 +435,33 @@ export function toVersionDTO(row: AnnotationVersionRow): AnnotationVersionDTO {
|
|||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** item ของ GET /api/documents/{docId}/versions — surface ตรง VersionRecord ของ
|
||||
* wordaddin/models/versionRecord.ts (คัดลอกมา define เอง — ห้าม import จาก wordaddin/
|
||||
* ซึ่งเป็น client-only zone) + createdByUserName เป็นส่วนเสริม (field เพิ่มไม่ทำ
|
||||
* UI ฝั่ง client พัง) · hasPdf derive จาก pdfObjectKey !== null ตัวเดียว (PLAN-DOCVER.md §1) */
|
||||
export type DocumentVersionDTO = {
|
||||
versionId: string;
|
||||
versionNumber: number;
|
||||
createdAt: string;
|
||||
createdByUserId: string;
|
||||
createdByUserName: string;
|
||||
contentHash: string;
|
||||
fields: Record<string, string> | null;
|
||||
message: string | null;
|
||||
hasPdf: boolean;
|
||||
};
|
||||
|
||||
export function toDocumentVersionDTO(row: DocumentVersionRow): DocumentVersionDTO {
|
||||
return {
|
||||
versionId: row.id,
|
||||
versionNumber: row.versionNumber,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
createdByUserId: row.userId,
|
||||
createdByUserName: row.userName,
|
||||
contentHash: row.contentHash,
|
||||
fields: row.fields,
|
||||
message: row.message,
|
||||
hasPdf: row.pdfObjectKey !== null,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue