ข้ามไปที่เนื้อหา

S3 — Activity Report & Course Registration (PRC) — Backend Implementation Blueprint

เอกสารนี้คืออะไร

แผน implement backend สำหรับทีมพัฒนา — ผูก REQ → endpoint → service → ตารางใน schema ใหม่ บน stack ใหม่ (greenfield) · ไม่ใช่โค้ดจริง (repo นี้เป็น KB) · อ้างอิง Traceability · Data Dictionary · ER Diagram

1. Stack

S3 เป็น greenfield — ไม่มี legacy DB ให้ migrate (ต่างจาก S8); สร้าง schema PRC ใหม่บน DB กลาง

Layer เทคโนโลยี หมายเหตุ
Backend NestJS (Node.js / TypeScript) — RESTful API module-per-aggregate, DI, guards
DB MSSQL PSDBPRDDB · schema PRC (Single DB) 27 ตาราง, int IDENTITY PK (log = bigint)
Frontend React 18 + Vite 5 (Responsive, WCAG 2.2 AA) de-facto contract = ar_and_crs/fe/src/data.jsx
Container Docker / Docker Compose + CI/CD VM สเปคเดียวกับ S9
Auth SSO via JWT (AD / Keycloak / ThaiD) thin mirror — ไม่เก็บ password (ดู §2)
Email SMTP Gateway กรมฯ retry worker (REQ-010)
File Network File Storage (≤50MB, 7 ชนิด) polymorphic Attachment (ดู §5)

สถาปัตยกรรมเป้าหมาย: แยก 2 ฝั่ง — (1) Back-office (เจ้าหน้าที่ ADMIN/OFFICER_KSS/OFFICER_OTHER/TRAINER/VIEWER) และ (2) Public Portal (CITIZEN — catalog + สมัคร + เช็คสถานะ + ดาวน์โหลด e-cert) ทั้งคู่อ่าน/เขียน schema PRC เดียวกัน

Layering (แนะนำ)

Controller / API endpoint
   └─ Service (business rule + workflow + validation)
        └─ Repository (1 ตัวต่อ aggregate: Course, Project, Trainer, Member, Registration, Certificate, ...)
             └─ PRC.* (MSSQL)
Cross-cutting: SSO JWT guard (verify + auto-provision AppUser) · RBAC guard (RoleAssignment/nav allowlist)
             · AuditLog interceptor · AttachmentService (validate 50MB/7-ext) · EmailRetryWorker (cron)
             · CounterService (denormalized recompute) · BE-date mapper (พ.ศ. ↔ Gregorian)

ทุก write ผ่าน Service เพื่อบังคับ audit (PRC.AuditLog) + soft-delete (IsDeleted) + denormalized counter recompute

2. Auth & data-scoping (thin mirror)

Authentication = SSO กลางผ่าน JWT — S3 ไม่เก็บ password/lockout (อยู่ที่ IdP). PRC.AppUser = mirror ของ identity เท่านั้น

Flow ต่อ request:

1. verify JWT (signature + exp + issuer) — middleware/guard
2. resolve AppUser by ExternalSubject (= JWT 'sub')
     ↳ ไม่พบ → auto-provision: INSERT PRC.AppUser (ExternalSubject, AuthSource, DisplayName, Email จาก claims)
3. roles จาก JWT claim → mirror ลง PRC.RoleAssignment (upsert; deactivate role ที่หายไป)
4. set request context { appUserId, roles[], orgUnitId, trainerId, memberId }
5. RBAC guard: เช็ค role + nav allowlist ของ endpoint

  • AppUser.ExternalSubject UNIQUE = OIDC sub / Keycloak UUID / AD sAMAccountName / ThaiD pairwise; AuthSource ∈ AD/KEYCLOAK/THAID
  • AppUser.Username UNIQUE เฉพาะเมื่อ not null (UX_PRC_AppUser_Username filtered)
  • RoleAssignment UNIQUE(AppUserId, RoleId) — ไม่ duplicate; runtime อ่าน role จาก JWT (DB เป็น mirror/audit เท่านั้น)

Nav allowlist (6 roles) — ตรง ROLE_NAV ใน demo; bind ที่ RBAC guard:

Role เมนูที่เห็น
ADMIN ทุกเมนู (dashboard, members, courses, trainers, budget, equipment, knowledge-reports, reports-export, registration, alumni-certs, admin-users, admin-audit, admin-approvals)
OFFICER_KSS dashboard, members, courses, trainers, budget, equipment, knowledge-reports, reports-export, registration, alumni-certs
OFFICER_OTHER dashboard, knowledge-reports, trainers (ร่างประวัติ)
TRAINER dashboard, my-courses, my-profile
VIEWER dashboard, reports-export, admin-audit (read-only)
CITIZEN public shell — ไม่มี internal nav (catalog/สมัคร/สถานะ/cert)

Data-scoping (sees-own) — เพิ่ม WHERE จาก request context ที่ repository:

Role กติกา คอลัมน์ที่ filter
OFFICER_OTHER เห็นเฉพาะรายงานของตน KnowledgeReport.SubmittedBy = ctx.appUserId
TRAINER เห็นเฉพาะหลักสูตรที่ตนสอน AppUser.TrainerId → Course.TrainerId = ctx.trainerId
CITIZEN เห็นเฉพาะใบสมัคร/ใบประกาศของตน AppUser.MemberId → Registration.MemberId / Certificate.MemberId = ctx.memberId

ADMIN/OFFICER_KSS/VIEWER = scope กว้าง (ทั้งกรมฯ); VIEWER บังคับ read-only ที่ guard

3. REQ → Endpoint → Service → Tables

ทุก write → AuditLog interceptor (ActorUserId/ActorName/ActorRole/Action/EntityType/EntityId) เป็น default ของทุก REQ — ระบุซ้ำเฉพาะที่เป็น flow สำคัญ

REQ-PRC-001 — Dashboard Real-time + Thailand heatmap + KPI

  • GET /dashboard/kpis?year=&month= — KPI 4 ตัว (โครงการ/หลักสูตร/สมาชิก/ใบประกาศ)
  • GET /dashboard/heatmap?year= — aggregate รายจังหวัด สำหรับ SVG map
  • GET /dashboard/trends?year= — จำแนกรายปี/รายเดือน
  • Service: DashboardService — aggregate queries (no write); heatmap group BY ProvinceId (ดู §6)
  • Tables (read): Province (MapX/MapY), Course, Registration, Certificate, Member, Enrollment

REQ-PRC-002 — Member CRUD + ค้นหา + soft-delete

  • GET /members?name=&orgUnitId=&provinceId=&courseId= · POST /members · PUT /members/:id · DELETE /members/:id (soft)
  • GET /members/:id/courses — ประวัติการอบรม (จาก Enrollment)
  • Service: MemberService — CRUD admin-only; delete = UPDATE IsDeleted=1 (REQ-002 บังคับ soft-delete); ทุก list filter WHERE IsDeleted=0
  • Tables (R/W): Member (IsDeleted), MemberType, Province, OrgUnit, Enrollment (read history)

REQ-PRC-003 — Course / Project / Trainer + แนบไฟล์ + edit-request

  • POST /courses · PUT /courses/:id · POST /projects · PUT /projects/:id
  • POST /trainers · PUT /trainers/:id · POST /trainers/:id/edit-requests (TRAINER/OFFICER_OTHER ร่าง) · PUT /trainers/:id/edit-requests/:reqId/approve|reject (ADMIN)
  • POST /{entity}/:id/attachments · DELETE /attachments/:id (polymorphic — ดู §5)
  • Service: CourseService/ProjectService/TrainerService + AttachmentService; TrainerEditRequestService = ร่าง (PENDING) → ADMIN APPROVED/REJECTED แล้วจึง apply ลง Trainer (+ toggle HasPendingEdit/SelfEditEnabled)
  • Tables (R/W): Course, Project, ProjectCourse (junction N:M), Trainer, TrainerSpecialty, TrainerEditRequest, Attachment

REQ-PRC-004 — งบประมาณ Per-Head + 2-step delete

  • POST /projects/:id/budget/calculate — คำนวณ preview (ไม่บันทึก) → คืน TotalCost/PerHeadCost
  • POST /projects/:id/budget/confirm — ผู้ใช้กดยืนยัน → snapshot ลง BudgetCalculation
  • DELETE /projects/:id (step 1 = soft IsDeleted=1) → DELETE /projects/:id/purge (step 2 = ยืนยันลบจริง/คงไว้)
  • Service: BudgetServiceper-head snapshot, no auto-save: คำนวณที่ app (TotalCost = CourseCost+AdminCost, PerHeadCost = TotalCost/HeadCount, CHECK HeadCount>0), persist เฉพาะตอน confirm พร้อม ConfirmedBy; การลบ 2 ขั้น → AuditLog
  • Tables (R/W): BudgetCalculation, Project (IsDeleted), AuditLog

REQ-PRC-005 — ครุภัณฑ์ 4 สถานะ / อายุ 5 ปี / audit โอนผู้รับผิดชอบ

  • GET /equipment?status=&over5yr=true · POST /equipment · PUT /equipment/:id
  • POST /equipment/:id/transfer — เปลี่ยนผู้รับผิดชอบ
  • GET /equipment/:id/history — ประวัติการโอน
  • Service: EquipmentServiceอายุ 5 ปี = computed runtime DATEDIFF(YEAR, AcquiredAt, now) >= 5 (ไม่เก็บ flag กัน stale); transfer = append EquipmentOwnershipHistory (From/To responsible + ChangedBy) — append-only ownership history, ไม่ overwrite
  • Tables (R/W): Equipment (Status 4 ค่า), EquipmentOwnershipHistory (append-only)

REQ-PRC-006 — รายงานเผยแพร่ความรู้ + Admin flag กลับ

  • GET /knowledge-reports (OFFICER_OTHER เห็นเฉพาะของตน — ดู §2) · POST /knowledge-reports · PUT /knowledge-reports/:id
  • PUT /knowledge-reports/:id/flag (ADMIN → Status=NEEDS_FIX + FlagNote + FlaggedBy) · PUT /knowledge-reports/:id/approve (Status=APPROVED)
  • Service: KnowledgeReportService — OFFICER_OTHER บันทึก (SubmittedBy=ctx.appUserId); ADMIN flag กลับให้แก้ → resubmit
  • Tables (R/W): KnowledgeReport (SubmittedBy/FlaggedBy/FlagNote), KnowledgeType, OrgUnit, Province

REQ-PRC-007 — Dashboard export + drill-down รายจังหวัด

  • GET /dashboard/export?format=pdf|png|jpeg&... — render (≥1920×1080) ฝั่ง frontend; backend คืน data
  • GET /dashboard/provinces/:id — drill-down 1 จังหวัด
  • Service: re-use DashboardService (REQ-001 queries); export action → AuditLog (EntityType='DASHBOARD')
  • Tables: (no new schema) อ่านชุดเดียวกับ REQ-001; export → AuditLog

REQ-PRC-008 — Registration + approval queue + draft/publish + reusable course

  • POST /registrations (ภายใน/portal) · GET /registrations?status=PENDING (approval queue) · PUT /registrations/:id/approve|reject|request-fix
  • PUT /courses/:id/publish (IsDraft 1→0, Status DRAFT→PUBLISHED) · PUT /courses/:id/draft
  • Service: RegistrationService — สร้าง RegistrationNo, Status PENDING→APPROVED/REJECTED/NEEDS_FIX (ReviewedBy/ReviewNote); ตรวจ prerequisite ก่อนรับสมัคร (ผู้สมัครต้องผ่าน prerequisite course ครบตาม CoursePrerequisite — เช็คจาก Certificate(Result=PASS)/Enrollment ของ course นั้น) · ไม่บังคับ capacity (รับไม่จำกัด, Course.TargetCount เป็น soft target) · approve → สร้าง/ผูก Member + Enrollment + trigger email (REQ-010); CourseService.publish คุม draft/publish (reusable course)
  • Tables (R/W): Registration, Course (Status/IsDraft), CoursePrerequisite (เงื่อนไขก่อนสมัคร), Enrollment, Member, EmailNotification

REQ-PRC-009 — Public catalog 2 หมวด

  • GET /public/courses?category=PROJECT|EXECUTIVE — catalog สาธารณะ (ไม่ต้อง login)
  • GET /public/courses/:id — รายละเอียด + รุ่น + วิทยากร + ไฟล์แนบ
  • Service: PublicCatalogService — read-only; filter Status=PUBLISHED AND IsDeleted=0 (index IX_PRC_Course_Category)
  • Tables (read): Course (Category), Batch, Trainer, TrainerSpecialty, Attachment

REQ-PRC-010 — Email แจ้งผล + retry 3× + ผู้สมัครเช็คสถานะ

  • (internal trigger จาก approve REQ-008) → INSERT EmailNotification (Status=PENDING)
  • GET /registrations/status?regNo=&email= — ผู้สมัครเช็คสถานะตนเอง
  • GET /registrations/:id/email-attempts — log การส่ง (admin)
  • Service: EmailService + EmailRetryWorker (cron) — select Status='FAILED' AND RetryCount < MaxRetry AND NextRetryAt <= now; ส่งผ่าน SMTP → append EmailAttempt (per-try) + อัปเดต RetryCount/LastAttemptAt/Status; ล้มเหลว → ตั้ง NextRetryAt แบบ exponential backoff (เช่น 2^RetryCount นาที); สำเร็จ → Status=SENT + SentAt + Registration.EmailSent=1; index IX_PRC_EmailNotification_Status(Status, NextRetryAt)
  • Tables (R/W): EmailNotification (Status/RetryCount/MaxRetry/NextRetryAt), EmailAttempt (append-only), Registration (EmailSent)

REQ-PRC-011 — ค้นหา + ส่งออก Excel/CSV (เจ้าหน้าที่)

  • GET /reports/registrations/export?format=xlsx|csv&... · GET /reports/members/export?...
  • Service: ExportService — query + stream Excel/CSV; export action → AuditLog
  • Tables: (no new schema) อ่าน Registration/Member/Course/Enrollment (index IX_PRC_Registration_*, IX_PRC_Member_Type)

REQ-PRC-012 — Hybrid (Onsite + Online พร้อมกัน)

  • (field ของ REQ-003 course)POST/PUT /courses รับ Mode=HYBRID + OnsiteVenue + OnlineUrl พร้อมกัน
  • Service: CourseService validation — Mode=ONSITE⇒OnsiteVenue, ONLINE⇒OnlineUrl, HYBRID⇒ทั้งคู่ (CHECK Mode)
  • Tables (R/W): Course (Mode + OnsiteVenue + OnlineUrl)

REQ-PRC-013 — ระดับหลักสูตร + Batch + ทำเนียบรุ่น (Alumni)

  • POST /courses/:id/batches · PUT /batches/:id · GET /courses/:id/batches
  • GET /alumni?courseId=&batchId=&level= — ทำเนียบรุ่น (Alumni Directory)
  • Service: BatchService/AlumniServiceCourse.Level (BASIC/INTER/ADV); alumni = join Enrollment×Member×Batch (index IX_PRC_Enrollment_Course(CourseId, BatchId)); Batch.CompletedCount denorm
  • Tables (R/W): Course (Level), Batch, Enrollment, Member, Certificate

REQ-PRC-014 — E-cert + watermark "สำเนา" + audit การพิมพ์

  • POST /certificates (จากผล PASS) · GET /certificates/:id
  • POST /certificates/:id/print — พิมพ์/ดาวน์โหลด (CITIZEN เห็นเฉพาะของตน — §2)
  • Service: CertificateService — พิมพ์ครั้งแรก → PrintType=ORIGINAL, HasWatermark=0, set IsOriginalIssued=1; พิมพ์ซ้ำ → ReprintCount++ + PrintType=COPY + watermark "สำเนา" (HasWatermark=1); ทุกครั้ง append CertificatePrintLog (PrintedBy/PrintedAt) + AuditLog (EntityType='CERTIFICATE'); Result FAIL ⇒ ไม่ออกใบ (IssuedAt NULL)
  • Tables (R/W): Certificate (ReprintCount/IsOriginalIssued/Result), CertificatePrintLog (append-only), AuditLog

Auth (cross-cutting)

  • (ดู §2)AppUser (auto-provision), RoleAssignment (mirror JWT roles), Role (catalog 6 roles)

4. API Surface (สรุป)

Endpoint Method Auth ตารางหลัก REQ
/dashboard/kpis, /dashboard/heatmap, /dashboard/trends GET ADMIN/OFFICER/VIEWER Province, Course, Registration, Certificate, Member, Enrollment 001
/members CRUD ADMIN Member, MemberType, Province, OrgUnit, Enrollment 002
/courses, /projects, /trainers, /{e}/:id/attachments CRUD OFFICER_KSS/ADMIN Course, Project, ProjectCourse, Trainer, TrainerSpecialty, TrainerEditRequest, Attachment 003
/trainers/:id/edit-requests POST/PUT TRAINER/OFFICER_OTHER → ADMIN TrainerEditRequest, Trainer 003
/projects/:id/budget/calculate\|confirm POST OFFICER_KSS BudgetCalculation, Project 004
/equipment, /equipment/:id/transfer\|history CRUD/POST/GET OFFICER_KSS Equipment, EquipmentOwnershipHistory 005
/knowledge-reports, /:id/flag\|approve CRUD/PUT OFFICER_OTHER → ADMIN KnowledgeReport, KnowledgeType, OrgUnit, Province 006
/dashboard/export, /dashboard/provinces/:id GET ADMIN/VIEWER (REQ-001) + AuditLog 007
/registrations, /:id/approve\|reject, /courses/:id/publish POST/PUT OFFICER_KSS/ADMIN Registration, Course, Enrollment, Member, EmailNotification 008
/public/courses GET public Course, Batch, Trainer, Attachment 009
/registrations/status, /:id/email-attempts GET public(self)/admin EmailNotification, EmailAttempt, Registration 010
/reports/*/export GET OFFICER_KSS Registration, Member, Course, Enrollment 011
/courses (Mode=HYBRID) POST/PUT OFFICER_KSS Course 012
/courses/:id/batches, /alumni CRUD/GET OFFICER_KSS Batch, Enrollment, Member, Certificate 013
/certificates, /:id/print POST OFFICER_KSS/CITIZEN(self) Certificate, CertificatePrintLog, AuditLog 014

5. Cross-cutting services

Service หน้าที่ ตาราง
AuthGuard (SSO) verify JWT, auto-provision AppUser, mirror roles, set scope context (§2) AppUser, RoleAssignment, Role
RbacGuard nav allowlist 6 roles + sees-own data-scoping RoleAssignment, (context)
AuditLogInterceptor ทุก write → append (denormalized ActorName/ActorRole) AuditLog
AttachmentService polymorphic upload — validate (§6) + 1 path; integrity ที่ service (ไม่มี DB FK บน owner) Attachment
EmailRetryWorker cron — FAILED + backoff (REQ-010) EmailNotification, EmailAttempt
CounterService recompute denormalized counters (§6) Course/Project/Trainer/Batch
CertificateService watermark + print log (REQ-014) Certificate, CertificatePrintLog

6. Build order

ลำดับ implement 5 stage (เรียงตาม dependency — lookup/auth ก่อน, dashboard ปิดท้าย):

  1. Foundation — Lookups (Province, MemberType, KnowledgeType, OrgUnit, Role) + Auth thin-mirror (AppUser, RoleAssignment, Role) + SSO JWT middleware + RBAC guard + nav allowlist
  2. Catalog coreTrainer (+TrainerSpecialty/TrainerEditRequest), Course (+ProjectCourse), Project (+BudgetCalculation Per-Head), Attachment (polymorphic upload)
  3. Member & RegistrationMember (+Enrollment), Batch, Registration + approval queue, EmailNotification (+EmailAttempt) + email retry worker
  4. Certificate & EquipmentCertificate (+CertificatePrintLog) + watermark service, Equipment (+EquipmentOwnershipHistory)
  5. Knowledge & DashboardKnowledgeReport, AuditLog interceptor (cross-cutting), Dashboard aggregates + export + heatmap

7. Notes

  • Denormalized counters (Course.EnrolledCount, Project.Spent/EnrolledCount, Trainer.TotalHours/CourseCount, Batch.CompletedCount) = display fields (eventually-consistent). Source of truth = แถวสัมพันธ์. Maintain ด้วย CounterService recompute หลัง write (หรือ DB trigger) — ห้ามใช้เป็นค่าตัดสินใจทางธุรกิจ
  • Attachment validation: AttachmentService บังคับ FileSize ≤ 52428800 (50MB) + Extension ∈ {doc, docx, xls, xlsx, ppt, pdf, jpeg} (7 ชนิด) ก่อนเขียน; owner เป็น polymorphic (OwnerEntityType/OwnerEntityId) → ไม่มี DB-level FK บน owner ⇒ บังคับ integrity ที่ service + ใช้ index IX_PRC_Attachment_Owner; demo Course.attached (count) = derived COUNT(*)
  • Thai พ.ศ. conversion ที่ app layer: เก็บ datetime2 UTC / date (Gregorian) ทั้งหมด; แปลง พ.ศ. (CE+543) ตอน render/รับ input. ข้อยกเว้นเดียว = Batch.YearBE (int พ.ศ. — เก็บเป็น label ตาม demo)
  • Dashboard heatmap aggregation: group BY Course.ProvinceId (+ KnowledgeReport.ProvinceId ตามมุมมอง) join Province (MapX/MapY) เพื่อวาด SVG silhouette; ใช้ index IX_PRC_Course_Province; cache ผลรวมรายปีได้ (real-time ไม่ต้องถึงระดับวินาที)
  • Equipment 5-yr = computed runtime (DATEDIFF) — ไม่เก็บ flag กันค่า stale (index IX_PRC_Equipment_Acquired รองรับ scan)
  • Append-only logs (AuditLog, CertificatePrintLog, EmailAttempt, EquipmentOwnershipHistory) ไม่มี Updated*/IsDeleted — insert เท่านั้น · retention = 1 ปี (ยืนยันโดย กสส.) สำหรับ AuditLog/EmailNotification/EmailAttempt → วางแผน archive/purge เมื่ออายุ > 1 ปี
  • Prerequisite check (REQ-008): ก่อน approve/รับสมัคร ตรวจว่าผู้สมัครผ่าน prerequisite course ครบตาม CoursePrerequisite (ผ่าน = มี Certificate Result=PASS หรือ Enrollment ของ course นั้น) ; capacity ไม่บังคับ (TargetCount soft)

ยืนยันจาก กสส. แล้ว (ดู index §การยืนยันจาก กสส.): Per-Head = (CourseCost+AdminCost)/HeadCount · IdP = AD + ThaID + Keycloak realm rlpd (AppUser.AuthSource) · retention log = 1 ปี · OrgUnit มี master (คง FK) · prerequisite มี → เพิ่ม CoursePrerequisite (ไม่จำกัดจำนวน) · Score = กรอกมือ

คงเหลือ (ไม่กระทบ schema): Template E-mail (REQ-010) รอประชุมครั้งที่ 2 · HR push / legacy humanright_file integration = next-phase (ทำที่ API/ETL)