# cloud167 — Arsitektur & Rencana Pengembangan

**Aplikasi:** cloud167
**Institusi:** SMPN 167 Jakarta
**Domain auth:** `@smpn167jakarta.sch.id`
**Penyimpanan:** Google Drive (Workspace for Education)
**Diperbarui:** 12 Agustus 2026

> **Hubungan dengan dokumen lain**
> - **`PLANNING.md` (dokumen ini)** — arsitektur yang **sudah terpasang** hari ini. Acuan saat menyentuh kode.
> - **`cloud167_spesifikasi_pengembangan.md`** — spesifikasi **target** (UI/UX, menu lengkap per peran, modul yang dicita-citakan). Acuan saat merencanakan fitur baru.
>
> Kalau keduanya berbeda, dokumen ini yang menggambarkan kenyataan.

---

## 1. Ringkasan

cloud167 adalah aplikasi web manajemen dokumen sekolah berbasis **CodeIgniter 4**. Guru dan staf login memakai SSO Google Workspace sekolah; berkas fisik disimpan di Google Drive institusi, sedangkan MySQL hanya menyimpan metadata. Selain arsip dokumen umum, aplikasi memuat modul **Administrasi Pembelajaran** (setoran dokumen guru per tahun ajaran) dan **Ijazah Digital**.

---

## 2. Tech Stack (terpasang)

| Layer | Teknologi |
|---|---|
| Framework | CodeIgniter 4.7.x |
| Bahasa | PHP 8.2+ |
| Database | MySQL 8.x (driver MySQLi) |
| Storage | Google Drive API v3 |
| Auth | Google OAuth 2.0 (SSO Workspace) |
| Frontend | Server-side view CI4 + CSS kustom + vanilla JS |
| Ikon / Font | Lucide (CDN), Plus Jakarta Sans + DM Sans (Google Fonts) |
| Dependency | `codeigniter4/framework ^4.7`, `google/apiclient ^2.0` |
| Test | PHPUnit 10 |
| Hosting | cPanel |

> Spesifikasi target menyebut React/Next.js + PostgreSQL. Implementasi memilih CI4 + MySQL karena menyesuaikan hosting cPanel yang tersedia. **Tidak ada rencana migrasi**; abaikan bagian stack di dokumen spesifikasi.

---

## 3. Struktur Folder

```
cloud167/
├── app/
│   ├── Config/
│   │   ├── GoogleCloud.php      # Config kustom: OAuth, root folder, domain, batas upload
│   │   ├── Routes.php           # Seluruh routing
│   │   ├── Filters.php          # csrf + secureheaders aktif global
│   │   ├── Security.php         # Kebijakan token CSRF
│   │   └── Session.php          # FileHandler, kedaluwarsa 8 jam
│   │
│   ├── Controllers/
│   │   ├── Auth.php             # Login / callback OAuth / logout
│   │   ├── Dashboard.php        # Dispatcher dashboard per peran
│   │   ├── Browse.php           # Daftar dokumen + filter/cari/urut/paginasi
│   │   ├── Upload.php           # Upload ke Drive (form & AJAX)
│   │   ├── Teaching.php         # Administrasi Pembelajaran (guru)
│   │   ├── SharedDocs.php       # Baca SK & surat terbitan
│   │   ├── Calendar.php         # Kalender Sekolah
│   │   ├── Messages.php         # Pesan internal & pengumuman
│   │   ├── Notifications.php    # Halaman + API notifikasi
│   │   ├── Profile.php
│   │   ├── Admin/               # Users, Categories, Logs, Monitoring,
│   │   │                        # SharedDocs (terbitkan SK & surat)
│   │   └── Ijazah/              # Students, Diplomas
│   │
│   ├── Models/                  # 10 model, satu per tabel
│   ├── Libraries/GoogleService.php   # Pembungkus OAuth + Drive
│   ├── Filters/                 # AuthFilter, AdminFilter, RoleFilter
│   ├── Helpers/cloud_helper.php # format_size, format_date_id, mime_icon,
│   │                            # current_user, time_ago
│   ├── Database/Migrations/     # 12 migrasi
│   ├── Database/Seeds/          # CategorySeeder, AdminDocTypeSeeder
│   └── Views/
│       ├── layouts/main.php     # Sidebar, topbar, bottom-nav, meta CSRF
│       ├── dashboard/           # guru, kepsek, wakasek, tu, tendik, siswa
│       ├── admin/               # users, categories, logs, monitoring,
│       │                        # periods, doc_types
│       ├── ijazah/              # students, diplomas
│       └── auth/login.php
│
├── public/                      # DOCUMENT ROOT — hanya folder ini yang publik
│   ├── index.php
│   └── assets/{css,js,img}
│
├── tests/feature/CsrfProtectionTest.php
├── writable/                    # cache, logs, session, uploads
├── .env                         # Konfigurasi lokal (git-ignored)
└── .env.production              # Template produksi
```

---

## 4. Skema Database

Database: `cloud167_db` (lokal). Dikelola lewat migrasi — **jangan ubah tabel lewat phpMyAdmin**, buat migrasi baru.

### `users`
Akun hasil SSO. Peran ditetapkan admin setelah login pertama.

| Kolom | Tipe | Catatan |
|---|---|---|
| `id` | INT UNSIGNED PK | |
| `google_id` | VARCHAR(100) UNIQUE, **NULL** | Kosong untuk akun yang disiapkan admin dan belum pernah login; terisi saat OAuth pertama |
| `name`, `email` | VARCHAR(150) | `email` unique |
| `avatar` | VARCHAR(255) | URL foto Google |
| `role` | ENUM | `guru`, `admin`, `kepsek`, `wakasek`, `tu`, `tendik`, `siswa` |
| `sub_role` | VARCHAR(50) | Bidang wakasek: kurikulum/kesiswaan/sarpras/humas |
| `nip` | VARCHAR(30) | |
| `class_group` | VARCHAR(20) | Kelas, untuk peran siswa |
| `drive_folder_id` | VARCHAR(100) | Folder pribadi di Drive |
| `google_refresh_token` | TEXT | Refresh token OAuth (lihat §6) |
| `is_active` | TINYINT(1) | Nonaktifkan tanpa menghapus data |
| `last_login`, `created_at` | TIMESTAMP | |

### `files`
Metadata dokumen umum. Berkas fisik hanya ada di Drive.

`id` · `user_id` FK · `drive_file_id` · `filename` · `original_name` · `category` · `mime_type` · `size` BIGINT · `drive_folder_id` · `is_shared` · `uploaded_at`

### `categories`
`id` · `name` · `slug` UNIQUE · `icon` · `sort_order` — diisi `CategorySeeder` (8 baris).

### `activity_log`
Audit: login, logout, upload, delete.
`id` · `user_id` FK · `action` · `description` · `file_id` · `ip_address` · `created_at`

### `notifications`
`id` · `user_id` FK · `type` · `title` · `message` · `link` · `is_read` · `created_at`

### Modul Administrasi Pembelajaran

- **`academic_periods`** — `tahun_ajaran` · `semester` ENUM('1','2') · `deadline` (deadline **umum**, dipakai dokumen yang tidak punya deadline khusus) · `is_active` (hanya satu aktif) · `created_at`
- **`period_doc_deadlines`** — deadline khusus per dokumen: `period_id` FK · `doc_type_id` FK · `deadline` · unique(`period_id`,`doc_type_id`)
- **`admin_doc_types`** — jenis dokumen wajib: `name` · `description` · `is_required` · `sort_order` (diisi `AdminDocTypeSeeder`, 8 baris). **Tidak menyimpan deadline** — lihat catatan di bawah.
- **`admin_doc_submissions`** — setoran guru: `period_id` · `doc_type_id` · `user_id` · `drive_file_id` · `original_name` · `mime_type` · `size` · `status` ENUM(`submitted`,`revision`,`approved`) · `admin_note` · `submitted_at` · `reviewed_at`

### Modul Ijazah Digital

- **`students`** — `nisn` · `nis` · `nama` · `tempat_lahir` · `tanggal_lahir` · `jenis_kelamin` ENUM('L','P') · `nama_orangtua` · `tahun_lulus` YEAR · `created_at`
- **`diploma_files`** — `student_id` FK · `drive_file_id` · `original_name` · `mime_type` · `size` · `uploaded_by` · `uploaded_at`

### SK & Surat Terbitan

Arahnya kebalikan dari `admin_doc_submissions`: yang itu guru menyetor ke admin,
yang ini admin/TU membagikan satu berkas ke banyak orang.

- **`shared_documents`** — `title` · `doc_number` · `description` · `category` ENUM(`sk`,`surat_tugas`,`edaran`,`lainnya`) · `audience` ENUM(`all`,`selected`) · `target_role` (hanya berarti saat `audience='all'`; NULL = semua peran) · `issued_date` · `drive_file_id` · `original_name` · `mime_type` · `size` · `uploaded_by` FK
- **`shared_document_recipients`** — penerima khusus untuk SK panitia: `document_id` FK · `user_id` FK · unique(`document_id`,`user_id`)

### Kalender Sekolah

**`calendar_events`** — `title` · `description` · `event_type` ENUM(`libur`,`akademik`,`sekolah`,`pimpinan`,`deadline`) · `start_date` · `end_date` (kosong = satu hari) · `start_time` · `end_time` · `all_day` · `location` · `target_role` (kosong = semua peran) · `created_by` FK · `created_at` · `updated_at`

### Pesan Internal

Satu struktur percakapan melayani tiga kebutuhan sekaligus.

- **`conversations`** — `type` ENUM(`direct`,`group`,`broadcast`) · `title` · `created_by` FK · `last_message_at` (didenormalisasi untuk pengurutan) · `created_at`
- **`conversation_participants`** — `conversation_id` FK · `user_id` FK · `last_read_at` (dasar penghitungan belum dibaca) · unique(`conversation_id`,`user_id`)
- **`messages`** — `conversation_id` FK · `sender_id` FK · `body` · `created_at`

---

## 5. Peran & Hak Akses

Tujuh peran di kolom `users.role`. Default pendaftar baru: `guru`.

| Peran | Dashboard | Akses |
|---|---|---|
| `admin` | memakai view `kepsek` | Penuh, termasuk seluruh menu admin |
| `kepsek` | `dashboard/kepsek` | Statistik sekolah, semua dokumen, menu admin |
| `wakasek` | `dashboard/wakasek` | Dokumen sendiri (+`sub_role` per bidang) |
| `tu` | `dashboard/tu` | Dokumen sendiri |
| `guru` | `dashboard/guru` | Dokumen sendiri + Administrasi Pembelajaran |
| `tendik` | `dashboard/tendik` | Dokumen sendiri |
| `siswa` | `dashboard/siswa` | Dokumen sendiri |

**Filter yang tersedia** (`app/Config/Filters.php`):

- `auth` — wajib login, kalau tidak diarahkan ke `/auth/login`
- `admin` — hanya `admin` dan `kepsek`
- `role:a,b,c` — filter generik berbasis daftar peran, mis. `['filter' => 'role:guru,wakasek']`

Di `Browse`, `admin`/`kepsek` melihat **semua** dokumen; peran lain hanya miliknya sendiri.

---

## 6. Alur Autentikasi

```
1. Pengunjung membuka aplikasi           → Auth::login
2. Belum ada sesi                        → tampil tombol "Masuk dengan Google"
3. Redirect ke Google (access_type=offline, prompt=consent select_account)
4. Google kembali ke /auth/callback?code=…
5. Tukar code → access token + refresh token + info user
6. Tolak kalau domain email ≠ smpn167jakarta.sch.id
7. Upsert ke tabel users (berdasarkan google_id)
8. Tolak kalau is_active = 0
9. session()->regenerate(true)           → cegah session fixation
10. Simpan user + token ke sesi
11. Simpan refresh_token ke users.google_refresh_token
12. Buat folder Drive pribadi kalau belum ada
13. Catat ke activity_log → redirect ke /dashboard
```

### Akun yang disiapkan lebih dulu

Admin bisa mendaftarkan akun sebelum orangnya pernah login — beserta perannya —
lewat `UserAccountSeeder`. Barisnya dibuat dengan `google_id = NULL`.

Saat orang tersebut login Google untuk pertama kali, `UserModel::upsertByGoogleId()`
mencari bertingkat: **google_id dulu, lalu email**. Kecocokan email membuat baris
yang sudah ada "diadopsi" dan `google_id`-nya diisi.

Langkah pencocokan email itu wajib ada. Tanpanya, akun pra-daftar memicu INSERT
yang menabrak indeks unique pada email — artinya orang yang akunnya sudah
disiapkan justru **tidak bisa login sama sekali**. Peran yang sudah ditetapkan
admin tidak pernah ditimpa oleh proses login.

### Kenapa `prompt=consent`

Google hanya mengirim `refresh_token` pada **otorisasi pertama** sebuah akun. Tanpa `consent`, login berikutnya tidak membawa refresh token, sehingga akses Drive mati begitu access token kedaluwarsa (~1 jam) — upload dan hapus gagal sampai pengguna login ulang.

Konsekuensinya layar consent Google muncul setiap login. Itu disengaja dan **jangan dihapus** tanpa mengganti mekanisme penyimpanan token.

### Pembaruan token

`GoogleService::getAuthorizedClient()` adalah satu-satunya pintu ke Drive:

1. Ambil token dari sesi.
2. Kalau tidak ada `refresh_token` di sesi, ambil dari `users.google_refresh_token`.
3. Kalau access token masih berlaku → langsung pakai.
4. Kalau kedaluwarsa → perbarui, pertahankan refresh token lama (Google tidak mengirimnya ulang), simpan kembali ke sesi + DB.
5. Kalau tidak ada refresh token sama sekali → lempar `RuntimeException` dengan pesan berbahasa Indonesia yang jelas.

> Refresh token masih disimpan **plaintext**. Kalau dibutuhkan, enkripsi dengan library `Encryption` CI4 (perlu `encryption.key` di `.env`).

---

## 7. Peta Routing

Semua di `app/Config/Routes.php`.

**Publik**
```
GET  /                      Auth::login
GET  /auth/login            Auth::login
GET  /auth/callback         Auth::callback
GET  /auth/logout           Auth::logout
```

**Butuh login** — grup filter `auth`
```
GET  /dashboard             Dashboard::index     (dispatch per peran)
GET  /browse                Browse::index
GET  /upload                Upload::index
POST /upload/process        Upload::process
GET  /profile               Profile::index
GET  /teaching              Teaching::index
POST /teaching/upload       Teaching::upload
GET  /notifications         Notifications::index

GET  /sk                    SharedDocs::index    (semua peran, isi disaring per sasaran)
GET  /sk/download/(:num)    SharedDocs::download (ditolak kalau bukan sasaran)

GET  /calendar              Calendar::index      (semua peran)
POST /calendar/save         Calendar::save       (admin/kepsek/wakasek/tu)
POST /calendar/delete       Calendar::delete     (admin/kepsek/wakasek/tu)

GET  /messages              Messages::index
POST /messages/start        Messages::start      (buka percakapan 1-on-1)
POST /messages/send         Messages::send
POST /messages/broadcast    Messages::broadcast  (admin/kepsek/wakasek/tu)

POST /api/drive/upload            Upload::apiUpload
POST /api/drive/delete            Browse::apiDelete
GET  /api/drive/download/(:seg)   Browse::apiDownload
GET  /api/notifications/recent    Notifications::apiGetRecent
POST /api/notifications/read      Notifications::apiMarkRead
POST /api/notifications/read-all  Notifications::apiMarkAllRead
GET  /api/messages/unread         Messages::apiUnreadCount
GET  /api/messages/thread/(:num)  Messages::apiThread
```

> Hak akses Kalender dan Pengumuman **tidak** dijaga oleh filter route, melainkan
> dicek di dalam controller (`CalendarEventModel::canEdit()`,
> `ConversationModel::canBroadcast()`), karena halamannya boleh dibuka semua peran
> sementara aksi tulisnya terbatas.

**Admin** — grup filter `auth` + `admin`
```
GET/POST  /admin/users…             kelola pengguna, ubah peran, aktif/nonaktif
GET/POST  /admin/categories…        kelola kategori
GET       /admin/logs               log aktivitas
GET       /admin/logs/export        ekspor CSV
GET/POST  /admin/monitoring…        monitoring setoran + review
GET/POST  /admin/monitoring/periods…      tahun ajaran
GET/POST  /admin/monitoring/doc-types…    jenis dokumen
```

**Kelola SK & Surat** — grup filter `auth` + `role:admin,kepsek,tu`
```
GET/POST  /admin/sk…    terbitkan, ubah sasaran, hapus, daftar penerima
```

> Sengaja **bukan** grup `admin`. Tata Usaha adalah pengurus persuratan
> (spesifikasi §3.2), sedangkan `AdminFilter` hanya meloloskan `admin` & `kepsek`.

**Ijazah** — grup filter `auth` + `admin`
```
GET/POST  /ijazah/students…   CRUD siswa + impor CSV
GET/POST  /ijazah/diplomas…   unggah ijazah (satuan & massal), hapus
```

---

## 8. Keamanan

### CSRF (aktif global)

Filter `csrf` terpasang di `globals.before`, jadi **setiap** request non-GET wajib membawa token.

Karena hampir semua aksi berjalan lewat `fetch`/`XMLHttpRequest` yang tersebar di belasan view, token tidak ditempel satu per satu, melainkan **terpusat**:

- `app/Views/layouts/main.php` merender `<meta name="csrf-token" content="…" data-name="…">`
- `public/assets/js/app.js` memasang shim di awal berkas yang:
  - membungkus `window.fetch` → menyisipkan header `X-CSRF-TOKEN` pada request non-GET
  - membungkus `XMLHttpRequest.prototype.open/send` → idem
  - menyuntik hidden input ke setiap `<form method="post">` saat `DOMContentLoaded`

**Konsekuensi untuk pengembang:** kode AJAX baru tidak perlu menangani CSRF sama sekali — cukup pastikan halaman memakai layout `layouts/main`. Kalau menulis form POST yang dirender server, tetap tambahkan `<?= csrf_field() ?>` supaya jalan tanpa JavaScript.

`Security::$regenerate = false` **disengaja**: banyak halaman admin mengirim beberapa aksi AJAX berturut-turut tanpa reload (mis. hapus dua kategori). Kalau token diputar tiap submit, aksi kedua akan ditolak. Token tetap rahasia per-sesi (mode `session`).

### Lain-lain

- Filter `secureheaders` aktif → `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`, `X-Permitted-Cross-Domain-Policies`
- Sesi: `FileHandler` di `writable/session`, kedaluwarsa **8 jam** (spesifikasi §8.2)
- `session()->regenerate(true)` setelah autentikasi berhasil
- Validasi domain email di callback OAuth
- Akun bisa dinonaktifkan (`is_active`) tanpa kehilangan data
- Upload divalidasi: ukuran (`google.maxUploadMB`) dan MIME allow-list di `GoogleService::getAllowedMimeTypes()`
- Audit di `activity_log`

### Utang keamanan yang diketahui

- **`.env.production` belum masuk `.gitignore`** (hanya `.env` yang di-ignore) padahal berisi client secret Google asli. Wajib dibereskan sebelum repo di-commit atau dipublikasikan.
- `google_refresh_token` tersimpan plaintext.
- Filter `honeypot` dan `invalidchars` masih nonaktif.

---

## 9. Konfigurasi Environment

Kunci yang dibaca `app/Config/GoogleCloud.php`:

```env
CI_ENVIRONMENT = development          # production saat deploy
app.baseURL    = 'http://localhost/cloud167/public/'
app.forceGlobalSecureRequests = false # true di produksi

database.default.hostname = localhost
database.default.database = cloud167_db
database.default.username = root
database.default.password =
database.default.DBDriver = MySQLi

session.driver = 'CodeIgniter\Session\Handlers\FileHandler'

google.clientId      = '…apps.googleusercontent.com'
google.clientSecret  = '…'
google.redirectUri   = 'http://localhost/cloud167/public/auth/callback'
google.rootFolderId  = '…'            # ID folder root Drive sekolah
google.allowedDomain = 'smpn167jakarta.sch.id'
google.maxUploadMB   = 50
```

> **Jangan pernah commit nilai `google.clientSecret`.** Simpan hanya di `.env` masing-masing mesin.
>
> `redirectUri` harus **sama persis** dengan yang terdaftar di Google Cloud Console, termasuk `http`/`https` dan ada-tidaknya `/public`.

---

## 10. Setup Google Cloud Console

1. Buka [console.cloud.google.com](https://console.cloud.google.com), pilih/buat project.
2. Aktifkan **Google Drive API** dan **Google People/OAuth2 API**.
3. **OAuth consent screen** → User Type **Internal** (hanya akun organisasi).
   Scope: `email`, `profile`, `https://www.googleapis.com/auth/drive`.
4. **Credentials** → OAuth 2.0 Client ID → tipe **Web application**.
   Authorized redirect URIs (daftarkan keduanya):
   - `http://localhost/cloud167/public/auth/callback`
   - `https://cloud167.ilyasrizalhilmawan.my.id/auth/callback`
5. Salin Client ID & Secret ke `.env`.
6. Di Drive sekolah, buat folder root aplikasi → salin ID-nya dari URL → isi `google.rootFolderId`.

---

## 11. Deploy ke cPanel

**Yang paling sering salah: document root.** CI4 hanya boleh mengekspos `public/`. Kalau root domain diarahkan ke folder proyek, seluruh `app/`, `.env`, dan `writable/` ikut terbuka.

1. Unggah proyek ke luar `public_html`, mis. `~/cloud167`.
2. Arahkan document root domain/subdomain ke `~/cloud167/public`.
3. Buat database MySQL + user di cPanel.
4. Install dependency di Terminal cPanel:
   ```bash
   cd ~/cloud167
   php composer.phar install --no-dev
   ```
5. Salin `.env.production` menjadi `.env`, isi kredensial DB dan Google yang sebenarnya.
6. Jalankan migrasi:
   ```bash
   php spark migrate
   php spark db:seed CategorySeeder
   php spark db:seed AdminDocTypeSeeder
   php spark db:seed UserAccountSeeder    # 31 akun guru & kepala sekolah
   ```
7. Pastikan `writable/` bisa ditulis (755) dan `.env` tidak terbaca publik (640).
8. Aktifkan HTTPS (Let's Encrypt) dan set `app.forceGlobalSecureRequests = true`.
9. Login pertama → akun otomatis berperan `guru`; naikkan ke `admin` lewat DB, lalu peran berikutnya bisa diatur dari `/admin/users`.

### Perintah pengembangan

```bash
/c/xampp/php/php.exe spark migrate        # php tidak ada di PATH XAMPP
/c/xampp/php/php.exe spark routes
/c/xampp/php/php.exe vendor/bin/phpunit tests/ --no-coverage
```

### Menjalankan test

Grup database `tests` di `app/Config/Database.php` diarahkan ke MySQL
(`cloud167_test`), **bukan** SQLite bawaan CodeIgniter — XAMPP di sini tidak
memuat ekstensi `sqlite3`, sehingga seluruh test berbasis database selalu gagal
dengan konfigurasi asli.

Siapkan sekali:

```bash
/c/xampp/mysql/bin/mysql.exe -u root -e "CREATE DATABASE IF NOT EXISTS cloud167_test CHARACTER SET utf8mb4"
```

Skema dibangun otomatis oleh `DatabaseTestTrait` (`$migrate = true`,
`$namespace = null` supaya migrasi `App` ikut jalan). Data pengembangan tidak
pernah tersentuh karena test memakai database terpisah.

Berkas test:

- `tests/feature/CsrfProtectionTest.php` — token ditolak/diterima sebagaimana mestinya
- `tests/feature/CalendarMessagesTest.php` — kalender, pesan, dan batas hak akses tiap peran

---

## 12. Status Implementasi

### Sudah jalan

- [x] Google SSO + validasi domain sekolah
- [x] Pembaruan access token otomatis via refresh token tersimpan
- [x] Dashboard untuk 6 view peran (admin memakai view kepsek)
- [x] Upload ke Drive (form biasa & AJAX dengan progress bar)
- [x] Browse: filter kategori, pencarian, pengurutan, paginasi
- [x] Hapus & unduh dokumen
- [x] Kelola pengguna: ubah peran, aktif/nonaktif
- [x] Kelola kategori
- [x] Log aktivitas + ekspor CSV
- [x] Notifikasi in-app (dropdown + halaman + API)
- [x] Administrasi Pembelajaran: tahun ajaran, jenis dokumen, setoran guru, review admin
- [x] Ijazah Digital: CRUD siswa, impor CSV, unggah satuan & massal
- [x] Proteksi CSRF menyeluruh + secure headers
- [x] Layout responsif: sidebar drawer, bottom navigation
- [x] **Kalender Sekolah**: tampilan bulanan, 5 jenis kegiatan berwarna, kegiatan
      multi-hari, agenda per bulan, target per peran, CRUD terbatas peran
- [x] **Pesan Internal**: percakapan 1-on-1, pengumuman broadcast (masuk ke pusat
      notifikasi), hitungan belum dibaca, polling 15 detik
- [x] **Menu sidebar per peran** — dibangun dari struktur data di `layouts/main.php`
- [x] **SK & Surat terbitan**: admin/kepsek/TU menerbitkan satu berkas untuk
      semua warga, satu peran tertentu, atau sebagian orang terpilih (SK panitia);
      izin Google Drive diberikan otomatis sesuai sasaran; penerima dinotifikasi

### Belum ada

- [ ] Percakapan **grup** — skema `conversations.type = 'group'` sudah mendukung,
      tetapi belum ada antarmuka untuk membuatnya
- [ ] Lampiran berkas di dalam pesan (spesifikasi §6.1)
- [ ] Widget agenda mendatang & kalender mini di dashboard (spesifikasi §4.1).
      `CalendarEventModel::getUpcoming()` sudah tersedia untuk ini
- [ ] Impor/sinkronisasi Google Calendar, ekspor kalender ke PDF (spesifikasi §7.2)
- [ ] Direktori Warga, Laporan & Rekap, Pengaturan
- [ ] Berbagi dokumen: share link, atur kedaluwarsa, cabut akses (spesifikasi §5.2)
- [ ] Notifikasi lewat email
- [ ] e-Signature, bank soal, rapor digital (spesifikasi Fase 3)

### Perbedaan yang disengaja dari spesifikasi

| Hal | Spesifikasi | Terpasang | Alasan |
|---|---|---|---|
| Stack | React/Next + PostgreSQL | CI4 + MySQL | Menyesuaikan hosting cPanel |
| Batas upload | 100 MB | 50 MB (`google.maxUploadMB`) | Batas PHP hosting; ubah bareng `upload_max_filesize` |
| Struktur folder Drive | Pohon per bidang/tahun (§5.1) | Datar: satu folder per pengguna | Belum diimplementasikan — lihat catatan di bawah |

---

## 13. Catatan Teknis

- **Deadline administrasi berlapis dua.** Deadline khusus dokumen ada di
  `period_doc_deadlines`; kalau tidak ada barisnya, dokumen memakai
  `academic_periods.deadline`. Resolusinya terpusat di satu tempat —
  `PeriodDocDeadlineModel::effective($period, $map, $docTypeId)` — jadi jangan
  menyalin logika fallback ini ke view.

  Deadline **sengaja tidak** ditaruh di `admin_doc_types`. Deadline berupa
  tanggal konkret yang hanya berlaku dalam satu periode; menempelkannya di jenis
  dokumen membuat tanggal basi tiap ganti semester sekaligus mengubah riwayat
  periode yang sudah lewat.

  Mengosongkan tanggal di form berarti "ikut deadline umum": barisnya dihapus,
  bukan disimpan sebagai NULL. Di tampilan, tanda `•` menandai deadline khusus.
  Dokumen berstatus `approved` tidak lagi menampilkan hitung mundur.

- **Struktur Drive masih datar.** `Auth`, `Upload`, `Teaching`, dan `Ijazah\Diplomas` semuanya memanggil `createFolder($namaPengguna, rootFolderId)` lalu menaruh berkas di folder pribadi itu — termasuk ijazah dan setoran administrasi. Pohon folder di spesifikasi §5.1 belum ada. Kalau nanti dikerjakan, tambahkan helper resolusi folder di `GoogleService` supaya keempat pemanggil ikut berubah sekaligus.
- **Dokumen bersama wajib diberi izin Drive.** Semua alur unduh lain di aplikasi
  ini hanya melayani berkas milik si pembuka sendiri, jadi tidak pernah butuh
  pemberian izin. SK berbeda: diunggah admin, dibuka orang lain. Tanpa izin,
  penerima hanya melihat "Anda memerlukan akses".

  `Admin\SharedDocs::grantDriveAccess()` mengatur ini sesuai sasaran —
  `all` memakai satu izin tingkat **domain** (`GoogleService::shareWithDomain()`,
  hanya akun `@smpn167jakarta.sch.id`), sedangkan `selected` memakai izin
  **per orang** (`shareWithUser()`) supaya akses tidak lebih luas dari daftar
  penerima. Kalau ada yang gagal, dokumen tetap tersimpan dan penerbit diberi
  peringatan, bukan dibiarkan diam-diam rusak.

  **Izin bersifat menambah, tidak mencabut.** Mempersempit sasaran dokumen yang
  sudah terbit tidak menarik kembali izin Drive yang terlanjur diberikan —
  cabut manual lewat Google Drive kalau memang perlu.

- **Berkas fisik tidak pernah menyentuh server hosting** — hanya metadata yang masuk MySQL. Menghapus baris di `files` tidak menghapus berkas di Drive; gunakan `Browse::apiDelete`.
- **Semua akses Drive lewat `GoogleService`.** Jangan membuat `Google_Client` sendiri di controller, karena logika pembaruan token ada di `getAuthorizedClient()`.
- **`writable/session` adalah lokasi sesi yang benar.** Kalau muncul lagi folder `public/null/`, artinya `session.savePath` salah terisi string `null` — sesi jadi tersimpan di direktori yang bisa diakses publik.
- **Menyempitkan ENUM butuh normalisasi dulu.** `ExpandUserRoles::down()` sempat
  membuat rollback tidak aman: ENUM `role` dikembalikan ke 3 nilai padahal ada
  baris berperan `siswa`/`tu`/`tendik`, sehingga MySQL menolak dan migrasi gagal
  setelah kolom terlanjur dibuang. Kalau menulis `down()` yang menyempitkan ENUM,
  normalkan dulu barisnya lalu jadikan `dropColumn` defensif.

- **Peran baru** cukup ditambahkan ke ENUM `users.role` lewat migrasi, lalu daftarkan di `Dashboard::index`, `UserModel::roleName()`, dan menu `layouts/main.php`.

---

*Dokumen hidup — perbarui setiap ada perubahan arsitektur.*
