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) |
| 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.ExternalSubjectUNIQUE = OIDCsub/ Keycloak UUID / ADsAMAccountName/ ThaiD pairwise;AuthSource∈ AD/KEYCLOAK/THAIDAppUser.UsernameUNIQUE เฉพาะเมื่อ not null (UX_PRC_AppUser_Usernamefiltered)RoleAssignmentUNIQUE(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 mapGET /dashboard/trends?year=— จำแนกรายปี/รายเดือน- Service:
DashboardService— aggregate queries (no write); heatmap group BYProvinceId(ดู §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 filterWHERE 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/:idPOST /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(+ toggleHasPendingEdit/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/PerHeadCostPOST /projects/:id/budget/confirm— ผู้ใช้กดยืนยัน → snapshot ลงBudgetCalculationDELETE /projects/:id(step 1 = softIsDeleted=1) →DELETE /projects/:id/purge(step 2 = ยืนยันลบจริง/คงไว้)- Service:
BudgetService— per-head snapshot, no auto-save: คำนวณที่ app (TotalCost = CourseCost+AdminCost,PerHeadCost = TotalCost/HeadCount, CHECKHeadCount>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/:idPOST /equipment/:id/transfer— เปลี่ยนผู้รับผิดชอบGET /equipment/:id/history— ประวัติการโอน- Service:
EquipmentService— อายุ 5 ปี = computed runtimeDATEDIFF(YEAR, AcquiredAt, now) >= 5(ไม่เก็บ flag กัน stale); transfer = appendEquipmentOwnershipHistory(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/:idPUT /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 คืน dataGET /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-fixPUT /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; filterStatus=PUBLISHED AND IsDeleted=0(indexIX_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) — selectStatus='FAILED' AND RetryCount < MaxRetry AND NextRetryAt <= now; ส่งผ่าน SMTP → appendEmailAttempt(per-try) + อัปเดตRetryCount/LastAttemptAt/Status; ล้มเหลว → ตั้งNextRetryAtแบบ exponential backoff (เช่น 2^RetryCount นาที); สำเร็จ → Status=SENT +SentAt+Registration.EmailSent=1; indexIX_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(indexIX_PRC_Registration_*,IX_PRC_Member_Type)
REQ-PRC-012 — Hybrid (Onsite + Online พร้อมกัน)¶
- (field ของ REQ-003 course) —
POST/PUT /coursesรับMode=HYBRID+OnsiteVenue+OnlineUrlพร้อมกัน - Service:
CourseServicevalidation —Mode=ONSITE⇒OnsiteVenue,ONLINE⇒OnlineUrl,HYBRID⇒ทั้งคู่ (CHECKMode) - Tables (R/W):
Course(Mode + OnsiteVenue + OnlineUrl)
REQ-PRC-013 — ระดับหลักสูตร + Batch + ทำเนียบรุ่น (Alumni)¶
POST /courses/:id/batches·PUT /batches/:id·GET /courses/:id/batchesGET /alumni?courseId=&batchId=&level=— ทำเนียบรุ่น (Alumni Directory)- Service:
BatchService/AlumniService—Course.Level(BASIC/INTER/ADV); alumni = joinEnrollment×Member×Batch(indexIX_PRC_Enrollment_Course(CourseId, BatchId));Batch.CompletedCountdenorm - Tables (R/W):
Course(Level),Batch,Enrollment,Member,Certificate
REQ-PRC-014 — E-cert + watermark "สำเนา" + audit การพิมพ์¶
POST /certificates(จากผล PASS) ·GET /certificates/:idPOST /certificates/:id/print— พิมพ์/ดาวน์โหลด (CITIZEN เห็นเฉพาะของตน — §2)- Service:
CertificateService— พิมพ์ครั้งแรก →PrintType=ORIGINAL,HasWatermark=0, setIsOriginalIssued=1; พิมพ์ซ้ำ →ReprintCount+++PrintType=COPY+ watermark "สำเนา" (HasWatermark=1); ทุกครั้ง appendCertificatePrintLog(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 ปิดท้าย):
- Foundation — Lookups (
Province,MemberType,KnowledgeType,OrgUnit,Role) + Auth thin-mirror (AppUser,RoleAssignment,Role) + SSO JWT middleware + RBAC guard + nav allowlist - Catalog core —
Trainer(+TrainerSpecialty/TrainerEditRequest),Course(+ProjectCourse),Project(+BudgetCalculationPer-Head),Attachment(polymorphic upload) - Member & Registration —
Member(+Enrollment),Batch,Registration+ approval queue,EmailNotification(+EmailAttempt) + email retry worker - Certificate & Equipment —
Certificate(+CertificatePrintLog) + watermark service,Equipment(+EquipmentOwnershipHistory) - Knowledge & Dashboard —
KnowledgeReport,AuditLoginterceptor (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 ด้วยCounterServicerecompute หลัง 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 + ใช้ indexIX_PRC_Attachment_Owner; demoCourse.attached(count) = derivedCOUNT(*) - Thai พ.ศ. conversion ที่ app layer: เก็บ
datetime2UTC /date(Gregorian) ทั้งหมด; แปลง พ.ศ. (CE+543) ตอน render/รับ input. ข้อยกเว้นเดียว =Batch.YearBE(int พ.ศ. — เก็บเป็น label ตาม demo) - Dashboard heatmap aggregation: group BY
Course.ProvinceId(+KnowledgeReport.ProvinceIdตามมุมมอง) joinProvince(MapX/MapY) เพื่อวาด SVG silhouette; ใช้ indexIX_PRC_Course_Province; cache ผลรวมรายปีได้ (real-time ไม่ต้องถึงระดับวินาที) - Equipment 5-yr = computed runtime (
DATEDIFF) — ไม่เก็บ flag กันค่า stale (indexIX_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(ผ่าน = มีCertificateResult=PASS หรือEnrollmentของ course นั้น) ; capacity ไม่บังคับ (TargetCountsoft)
ยืนยันจาก กสส. แล้ว (ดู index §การยืนยันจาก กสส.): Per-Head =
(CourseCost+AdminCost)/HeadCount· IdP = AD + ThaID + Keycloak realmrlpd(AppUser.AuthSource) · retention log = 1 ปี · OrgUnit มี master (คง FK) · prerequisite มี → เพิ่มCoursePrerequisite(ไม่จำกัดจำนวน) · Score = กรอกมือคงเหลือ (ไม่กระทบ schema): Template E-mail (REQ-010) รอประชุมครั้งที่ 2 · HR push / legacy
humanright_fileintegration = next-phase (ทำที่ API/ETL)