# eCert MBIP — Database Design (Final) ## Entity Relationship Summary ``` users ← admin accounts only programs ← program data ├── program_qr_codes ← QR token per program ├── program_participants ← enrollment (pre-reg + walk-in) ├── attendances ← actual check-in records ├── certificate_templates ← uploaded template image + config ├── certificates ← generated certificates per participant └── program_questionnaires ← link program ke questionnaire set participants ← master data peserta (no_kp unique) ├── program_participants ├── attendances ├── certificates └── questionnaire_responses questionnaire_sets ← reusable question sets ├── questionnaire_questions ├── program_questionnaires └── questionnaire_responses └── questionnaire_answers email_logs ← semua email attempt audit_logs ← admin action tracking ``` --- ## Table Definitions ### 1. `users` Laravel default. Tambah kolum berikut: ``` is_admin boolean default true ← semua user = admin buat masa ini ``` --- ### 2. `programs` | Kolum | Type | Keterangan | |-------|------|-----------| | id | bigint PK | — | | uuid | char(36) UNIQUE | public identifier, pakai dalam URL | | title | varchar(255) | nama program | | description | text nullable | deskripsi program | | organizer | varchar(255) | penganjur | | location | varchar(500) | lokasi program | | start_date | date | tarikh mula | | end_date | date | tarikh tamat | | checkin_start_at | datetime nullable | mula boleh check-in | | checkin_end_at | datetime nullable | tamat check-in | | ecert_download_start_at | datetime nullable | mula boleh download sijil | | ecert_download_end_at | datetime nullable | tamat download sijil | | status | enum(draft,published,closed) | default: draft | | allow_walk_in | boolean | default: true | | default_staff_session | enum(pagi,petang,full_day) nullable | sesi default untuk staff | | default_external_session | enum(pagi,petang,full_day) nullable | sesi default untuk orang luar | | created_by | bigint FK users.id | — | | timestamps | — | created_at, updated_at | Index: `status`, `uuid` --- ### 3. `program_qr_codes` | Kolum | Type | Keterangan | |-------|------|-----------| | id | bigint PK | — | | program_id | bigint FK | — | | token | varchar(64) UNIQUE | random token, bukan UUID biasa | | qr_image_path | varchar(500) nullable | path dalam storage | | is_active | boolean | default: true | | timestamps | — | — | Index: `token`, `program_id` --- ### 4. `participants` | Kolum | Type | Keterangan | |-------|------|-----------| | id | bigint PK | — | | uuid | char(36) UNIQUE | public identifier | | name | varchar(255) | nama penuh | | no_kp | varchar(20) UNIQUE | no kad pengenalan (tanpa sempang) | | email | varchar(255) nullable | emel | | phone | varchar(20) nullable | no telefon | | agency | varchar(255) nullable | jabatan / agensi | | participant_type | enum(staff,external) | default: external | | timestamps | — | — | Index: `no_kp`, `email`, `uuid` > **Nota PDPA**: `no_kp` disimpan dalam DB tetapi TIDAK dipaparkan dalam URL. Akses public guna `uuid` atau `certificate.token`. --- ### 5. `program_participants` | Kolum | Type | Keterangan | |-------|------|-----------| | id | bigint PK | — | | program_id | bigint FK | — | | participant_id | bigint FK | — | | registration_source | enum(pre_registered,walk_in,admin_manual,import) | — | | is_pre_registered | boolean | default: false | | pre_registered_session | enum(pagi,petang,full_day) nullable | sesi yang ditetapkan semasa pre-reg | | status | enum(registered,checked_in,cancelled) | default: registered | | registered_at | timestamp nullable | — | | timestamps | — | — | Unique: `(program_id, participant_id)` Index: `program_id`, `participant_id`, `status` --- ### 6. `attendances` | Kolum | Type | Keterangan | |-------|------|-----------| | id | bigint PK | — | | program_id | bigint FK | — | | participant_id | bigint FK | — | | program_participant_id | bigint FK nullable | link ke enrollment | | attendance_source | enum(pre_registered_staff,walk_in_external,admin_manual) | — | | attendance_session | enum(pagi,petang,full_day) | sesi hadir | | checked_in_at | timestamp | masa check-in | | checked_in_ip | varchar(45) nullable | IP address | | user_agent | varchar(500) nullable | browser/device | | notes | varchar(500) nullable | nota tambahan | | timestamps | — | — | Unique: `(program_id, participant_id)` Index: `program_id`, `participant_id`, `attendance_source`, `attendance_session` --- ### 7. `certificate_templates` | Kolum | Type | Keterangan | |-------|------|-----------| | id | bigint PK | — | | program_id | bigint FK | — | | original_filename | varchar(255) | nama fail asal | | image_path | varchar(500) | path dalam storage | | config_json | json | koordinat dan style teks | | is_active | boolean | default: true | | uploaded_by | bigint FK users.id | — | | timestamps | — | — | **Contoh `config_json`:** ```json { "fields": { "name": { "x": 1240, "y": 850, "font_size": 72, "font_size_min": 36, "font_family": "NotoSans-Bold", "color": "#1a1a1a", "align": "center", "max_width": 1600 }, "no_kp": { "x": 1240, "y": 960, "font_size": 48, "font_size_min": 36, "font_family": "NotoSans-Regular", "color": "#333333", "align": "center", "max_width": 1200 }, "program_title": { "x": 1240, "y": 620, "font_size": 52, "font_size_min": 28, "font_family": "NotoSans-Bold", "color": "#1a1a1a", "align": "center", "max_width": 1800, "enabled": false }, "program_date": { "x": 1240, "y": 700, "font_size": 36, "font_family": "NotoSans-Regular", "color": "#555555", "align": "center", "enabled": false } }, "canvas_dpi": 150 } ``` --- ### 8. `certificates` | Kolum | Type | Keterangan | |-------|------|-----------| | id | bigint PK | — | | uuid | char(36) UNIQUE | — | | program_id | bigint FK | — | | participant_id | bigint FK | — | | certificate_template_id | bigint FK | — | | certificate_no | varchar(100) UNIQUE nullable | no sijil rasmi (auto-generate) | | file_path | varchar(500) nullable | path PDF dalam storage | | token | varchar(64) UNIQUE | token untuk download link | | status | enum(pending,generating,generated,emailed,failed) | default: pending | | error_message | text nullable | jika gagal | | generated_at | timestamp nullable | — | | emailed_at | timestamp nullable | — | | downloaded_at | timestamp nullable | tarikh pertama download | | download_count | int | default: 0 | | timestamps | — | — | Unique: `(program_id, participant_id)` Index: `token`, `status`, `program_id`, `participant_id` **Format `certificate_no`**: `MBIP/{YEAR}/{PROGRAM_ID}/{SEQUENCE}` — contoh: `MBIP/2025/001/0042` --- ### 9. `questionnaire_sets` | Kolum | Type | Keterangan | |-------|------|-----------| | id | bigint PK | — | | title | varchar(255) | tajuk set | | description | text nullable | — | | status | enum(draft,published,archived) | default: draft | | created_by | bigint FK users.id | — | | timestamps | — | — | --- ### 10. `questionnaire_questions` | Kolum | Type | Keterangan | |-------|------|-----------| | id | bigint PK | — | | questionnaire_set_id | bigint FK | — | | question_text | text | teks soalan | | question_type | enum(rating,single_choice,multiple_choice,short_text,long_text) | — | | options_json | json nullable | untuk single/multiple choice dan rating | | is_required | boolean | default: true | | sort_order | int | default: 0 | | timestamps | — | — | **Contoh `options_json`:** ```json // single_choice / multiple_choice: { "options": ["Sangat Berpuas Hati", "Berpuas Hati", "Sederhana", "Tidak Berpuas Hati"] } // rating: { "min": 1, "max": 5, "labels": { "1": "Sangat Lemah", "5": "Cemerlang" } } ``` --- ### 11. `program_questionnaires` | Kolum | Type | Keterangan | |-------|------|-----------| | id | bigint PK | — | | program_id | bigint FK | — | | questionnaire_set_id | bigint FK | — | | is_confirmed | boolean | default: false | | confirmed_at | timestamp nullable | — | | confirmed_by | bigint FK users.id nullable | — | | timestamps | — | — | Unique: `(program_id, questionnaire_set_id)` --- ### 12. `questionnaire_responses` | Kolum | Type | Keterangan | |-------|------|-----------| | id | bigint PK | — | | program_id | bigint FK | — | | participant_id | bigint FK | — | | questionnaire_set_id | bigint FK | — | | submitted_at | timestamp | — | | ip_address | varchar(45) nullable | — | | user_agent | varchar(500) nullable | — | | timestamps | — | — | Unique: `(program_id, participant_id, questionnaire_set_id)` Index: `program_id`, `participant_id` --- ### 13. `questionnaire_answers` | Kolum | Type | Keterangan | |-------|------|-----------| | id | bigint PK | — | | questionnaire_response_id | bigint FK | — | | questionnaire_question_id | bigint FK | — | | answer_value | json nullable | nilai jawapan (flexibel untuk semua jenis) | | timestamps | — | — | **Contoh `answer_value`:** ```json // rating: { "value": 4 } // single: { "value": "Berpuas Hati" } // multiple: { "value": ["A", "B"] } // short_text: { "value": "Teks ringkas" } // long_text: { "value": "Teks panjang..." } ``` --- ### 14. `email_logs` | Kolum | Type | Keterangan | |-------|------|-----------| | id | bigint PK | — | | program_id | bigint FK nullable | — | | participant_id | bigint FK nullable | — | | certificate_id | bigint FK nullable | — | | recipient_email | varchar(255) | alamat emel penerima | | subject | varchar(500) | subjek emel | | email_type | enum(certificate_ready,reminder,test) | jenis emel | | status | enum(pending,sent,failed) | default: pending | | error_message | text nullable | ralat SMTP/queue | | sent_at | timestamp nullable | — | | timestamps | — | — | Index: `status`, `program_id`, `participant_id` --- ### 15. `audit_logs` | Kolum | Type | Keterangan | |-------|------|-----------| | id | bigint PK | — | | user_id | bigint FK nullable | admin yang buat action | | action | varchar(100) | nama action: program.created, template.uploaded, dll | | auditable_type | varchar(255) nullable | model class | | auditable_id | bigint nullable | ID rekod berkaitan | | old_values | json nullable | nilai sebelum (REDACT data sensitif) | | new_values | json nullable | nilai selepas (REDACT data sensitif) | | ip_address | varchar(45) nullable | — | | user_agent | varchar(500) nullable | — | | timestamps | — | — | Index: `user_id`, `action`, `auditable_type + auditable_id` **Actions yang perlu diaudit:** - `program.created`, `program.updated`, `program.deleted` - `template.uploaded`, `template.updated` - `questionnaire.confirmed`, `questionnaire.attached` - `certificate.generated`, `certificate.downloaded` - `participant.imported`, `participant.added` --- ## Migration Order (Dependency-safe) ``` 1. users (no dependency) 2. programs (FK: users) 3. program_qr_codes (FK: programs) 4. participants (no dependency) 5. program_participants (FK: programs, participants) 6. attendances (FK: programs, participants, program_participants) 7. certificate_templates (FK: programs, users) 8. questionnaire_sets (FK: users) 9. questionnaire_questions (FK: questionnaire_sets) 10. program_questionnaires (FK: programs, questionnaire_sets, users) 11. certificates (FK: programs, participants, certificate_templates) 12. questionnaire_responses (FK: programs, participants, questionnaire_sets) 13. questionnaire_answers (FK: questionnaire_responses, questionnaire_questions) 14. email_logs (FK: programs, participants, certificates) 15. audit_logs (FK: users) ``` --- ## Data Integrity Rules 1. `participants.no_kp` — UNIQUE global (satu orang = satu rekod). 2. `attendances (program_id, participant_id)` — UNIQUE (tidak boleh check-in dua kali program sama). 3. `certificates (program_id, participant_id)` — UNIQUE (satu sijil per peserta per program). 4. `questionnaire_responses (program_id, participant_id, questionnaire_set_id)` — UNIQUE. 5. `program_participants (program_id, participant_id)` — UNIQUE. 6. Jika program `status = closed` atau `published`, rekod tidak boleh padam (soft delete sahaja jika perlu). 7. `no_kp` dalam participants tidak dipaparkan dalam URL atau log — pakai `uuid` atau `token`.