# Handover FE — Daily Activity: `TASK` vs `REMINDER` Status: backend selesai. | Repo | Commit | |---|---| | `module-contracts` | `0863240` feat(notification): add daily-activity reminder event key | | `modules_base` | `862bafd` feat(daily-activities): add reminder type with division notification | | `modules_base` | `8dcaa4f` chore(contracts): bump @module/contracts to 0863240 | | `module-notification` | `0249405` feat(email): add daily-activity reminder template | | `module-notification` | `9bfc4b9` chore(contracts): bump @module/contracts to 0863240 | Migration: `prisma/migrations/20261109101500_v1_daily_activity_reminder` dan `prisma/migrations/20261109120000_v1_daily_activity_assignment_notified`. --- ## 1. Ringkasan Daily activity sekarang punya **dua tipe**: - **`TASK`** — perilaku lama. Punya window `startTime` + `endTime`, boleh link ke action plan. Tidak mengirim notifikasi. - **`REMINDER`** — satu titik waktu (`reminderTime`), tanpa window. Saat waktunya tiba, backend mengirim **notifikasi in-app + push + email** ke tim project dari **division guest** yang dipilih. Keduanya muncul di kalender weekly/monthly yang sama. Bedanya hanya kolom waktu: task = rentang, reminder = satu jam. Default `type` kalau tidak dikirim = `TASK`, jadi payload lama tetap valid apa adanya. --- ## 2. Yang berubah untuk FE | Hal | Dulu | Sekarang | |---|---|---| | `type` | tidak ada | `"TASK"` \| `"REMINDER"`, default `TASK` | | `startTime` / `endTime` | wajib | wajib **hanya** untuk `TASK`; `null` untuk reminder | | `reminderTime` | tidak ada | wajib untuk `REMINDER` | | `reminderSentAt` | tidak ada | kapan reminder terkirim; `null` = masih pending | | `startTimeWib` / `endTimeWib` / `reminderTimeWib` | tidak ada | string ISO dengan offset `+07:00` — **pakai ini untuk print jam** | | `divisionGuestId` | opsional | **wajib** untuk `REMINDER` (audiens notifikasi) | | Grouping hari di kalender | server-local | WIB (`+07:00`) | | Kontrak report PDF | — | tidak berubah | `startTime`/`endTime`/`reminderTime` (Date UTC) tetap dikirim dengan bentuk lama. Field `*Wib` adalah tambahan, bukan pengganti. --- ## 3. Endpoint Tidak ada endpoint baru. Semua di bawah project path: ``` POST /api/projects/:projectId/daily-activities GET /api/projects/:projectId/daily-activities GET /api/projects/:projectId/daily-activities/:dailyActivityId PATCH /api/projects/:projectId/daily-activities/:dailyActivityId DELETE /api/projects/:projectId/daily-activities/:dailyActivityId ``` Authz: membership project dengan role `MEMBER` / `OWNER` / `ADMIN`. `POST` / `PATCH` / `DELETE` memakai `ProjectNotClosedGuard`: `403 Daily activities are read-only: the project is closed` kalau status project `COMPLETED`. Semua response dibungkus envelope standar (`statusCode`, `message`, `data`, `durationMs`, `_reference`) seperti endpoint lain. Contoh di §6 dan §7 menampilkan isi `data` saja. --- ## 4. Create ### 4a. TASK ```http POST /api/projects/:projectId/daily-activities Content-Type: application/json ``` ```json { "title": "Kunjungan site Gedung A", "type": "TASK", "startTime": "2026-11-09T01:00:00.000Z", "endTime": "2026-11-09T03:00:00.000Z", "note": "Cek bekisting lantai 3", "visibility": "PUBLIC", "divisionGuestId": "uuid-division-guest", "actionPlans": [{ "scopeType": "TASK", "itemId": "uuid-item" }] } ``` `type` boleh dihilangkan — hasilnya `TASK`. ### 4b. REMINDER ```json { "title": "Meeting koordinasi MEP", "type": "REMINDER", "reminderTime": "2026-11-09T01:00:00.000Z", "note": "Bawa dokumen shop drawing", "visibility": "PUBLIC", "divisionGuestId": "uuid-division-guest" } ``` ### 4c. Aturan validasi (400) | Kondisi | Pesan | |---|---| | `TASK` tanpa `startTime` | `startTime is required` | | `TASK` tanpa `endTime` | `endTime is required` | | `TASK` dengan `endTime <= startTime` | `endTime must be after startTime` | | `TASK` mengirim `reminderTime` | `A task takes no reminderTime` | | `REMINDER` tanpa `reminderTime` | `reminderTime is required for a reminder` | | `REMINDER` mengirim `startTime`/`endTime` | `A reminder takes no startTime/endTime` | | `REMINDER` tanpa `divisionGuestId` | `A reminder needs a division guest to notify` | | `divisionGuestId` bukan guest di workspace project | `Division is not in this workspace catalog` | `actionPlans` pada reminder tidak dilarang oleh DTO, tapi **tidak berguna** — reminder tidak punya progress dan report menampilkan action plan kosong. Sembunyikan field itu di form reminder. --- ## 5. Update ```http PATCH /api/projects/:projectId/daily-activities/:dailyActivityId ``` Sifatnya **partial**: field yang tidak dikirim tidak berubah. Yang perlu diperhatikan: 1. **Setengah window boleh.** Kirim `startTime` saja sah; validasi final memakai gabungan nilai tersimpan + payload. 2. **Ganti `type` harus lengkap.** `TASK` → `REMINDER` wajib menyertakan `reminderTime` (dan guest sudah ada di row, boleh tidak dikirim ulang). `REMINDER` → `TASK` wajib menyertakan `startTime` + `endTime`. 3. **Kolom bentuk lama dibersihkan otomatis.** Switch ke `REMINDER` mengosongkan `startTime`/`endTime`, dan sebaliknya. 4. **`divisionGuestId: null` menghapus guest**, tapi akan ditolak kalau hasil akhirnya `REMINDER`. 5. **`actionPlans` = full replace.** Kalau dikirim, daftar lama dihapus dan diganti isi payload. Kirim `[]` untuk mengosongkan. 6. **Reschedule hanya saat jam berubah.** Mengubah `reminderTime` (atau switch masuk ke `REMINDER`) membatalkan job lama, menjadwalkan yang baru, dan mereset `reminderSentAt` ke `null` sehingga reminder bisa terkirim lagi. Mengubah `title`/`note` saja **tidak** mengirim ulang reminder yang sudah terkirim. Hanya **creator** atau `OWNER`/`ADMIN` workspace yang boleh edit: `403 Only the creator or a workspace admin can edit a daily activity`. --- ## 6. Response shape `POST` / `GET` / `PATCH` mengembalikan objek yang sama (isi `data`): ```json { "id": "uuid", "projectId": "uuid-project", "title": "Meeting koordinasi MEP", "type": "REMINDER", "startTime": null, "endTime": null, "reminderTime": "2026-11-09T01:00:00.000Z", "reminderSentAt": null, "startTimeWib": null, "endTimeWib": null, "reminderTimeWib": "2026-11-09T08:00:00.000+07:00", "note": "Bawa dokumen shop drawing", "visibility": "PUBLIC", "createdBy": "uuid-user", "updatedBy": "uuid-user", "createdAt": "2026-11-09T00:12:00.000Z", "updatedAt": "2026-11-09T00:12:00.000Z", "createdByUser": { "id": "uuid-user", "name": "Budi", "avatarUrl": null }, "updatedByUser": { "id": "uuid-user", "name": "Budi", "avatarUrl": null }, "divisionGuestId": "uuid-division-guest", "divisionGuest": { "id": "uuid-division-guest", "name": "Struktur" }, "actionPlanLinks": [] } ``` Contoh task: ```json { "type": "TASK", "startTime": "2026-11-09T01:00:00.000Z", "endTime": "2026-11-09T03:00:00.000Z", "reminderTime": null, "startTimeWib": "2026-11-09T08:00:00.000+07:00", "endTimeWib": "2026-11-09T10:00:00.000+07:00", "reminderTimeWib": null } ``` Aturan render: - Print jam dari `*Wib` (`new Date(x).getHours()` di browser lokal user bisa beda timezone — `*Wib` sudah membawa offset, atau ambil substring `11:00`), **bukan** dari `startTime` mentah. - `type === 'REMINDER'` → tampilkan satu jam + ikon/label reminder. `reminderSentAt !== null` = sudah terkirim. - `actionPlanLinks[].itemName` adalah snapshot nama item saat ditulis — tetap ada walau item action plan-nya sudah dihapus. --- ## 7. Kalender ``` GET /api/projects/:projectId/daily-activities?view=weekly&month=2026-11&visibility=PUBLIC&search=meeting ``` | Query | Default | Keterangan | |---|---|---| | `view` | `weekly` | `weekly` \| `monthly` | | `month` | bulan berjalan (WIB) | `YYYY-MM` | | `visibility` | semua | filter `PUBLIC` / `PRIVATE` | | `search` | — | `contains` pada `title` | Response (isi `data`): ```json { "view": "monthly", "start": "2026-10-31T17:00:00.000Z", "end": "2026-12-05T16:59:59.999Z", "days": [ { "date": "2026-11-01", "activities": [] }, { "date": "2026-11-02", "activities": [ /* ... */ ] } ] } ``` Perilaku yang perlu diketahui: - **`date` adalah hari kalender WIB** (`YYYY-MM-DD`), bukan hari lokal server. Aktivitas jam 22:00 WIB tidak akan lompat ke hari berikutnya. - `weekly` mengembalikan **hanya hari milik bulan itu** (FE yang menyusun baris minggu). - `monthly` mengembalikan grid penuh ala Google Calendar: hari dari bulan sebelum/sesudah ikut, minimal 35 sel (42 untuk bulan yang butuh 6 baris). - Hari kosong tetap ada dengan `activities: []` — FE tidak perlu mengisi gap sendiri. - Urutan `activities` dalam satu hari = urutan waktu efektif (`startTime` untuk task, `reminderTime` untuk reminder). Tidak perlu sort ulang. - `start`/`end` adalah window yang benar-benar di-query (WIB midnight s/d 23:59:59.999), dikirim sebagai instant UTC. Baris `PRIVATE` hanya terlihat oleh creator-nya atau `OWNER`/`ADMIN` workspace; row yang tidak boleh dilihat **hilang** dari response (bukan `null`). --- ## 8. Notifikasi Dikirim otomatis oleh backend saat `reminderTime` tiba. FE tidak perlu memanggil apa pun. | Aspek | Perilaku | |---|---| | Audiens | seluruh anggota **project team** (status `active`) dari semua project aktif yang `division_id` = division guest yang dipilih | | Kanal | in-app + push + email, mengikuti preferensi notifikasi masing-masing user (default in-app aktif) | | Event key | `base.daily_activity.reminder` | | Cakupan | satu kali per reminder | | `reminderSentAt` | diisi saat terkirim, juga saat audiensnya kosong | Payload in-app/push **reminder**: ```json { "type": "info", "title": "Daily Activities Reminder", "message": "[Budi]: Please send the MoM document by 5 PM at the latest.", "data": { "action": "daily_activity.reminder", "activityId": "uuid", "projectId": "uuid-project", "reminderTime": "2026-11-09T08:00:00.000+07:00" } } ``` Copy mengikuti desain: **`[Sender Username]: `** - `Sender Username` = display name pembuat daily activity (`createdBy`). - `` = field `note`; kalau kosong dipakai `title` supaya tidak ada pesan kosong. - Kalau auth tidak bisa me-resolve nama pembuat, prefix `[...]:` dihilangkan (isi `note` tetap terkirim), dan email jatuh ke sapaan berbasis email. ### 8b. Assignment Dikirim **sekali, tepat saat reminder dibuat** (bukan saat waktunya tiba), ke **admin project** — yaitu `project_teams.role` = `project-owner` atau `admin` — dari semua project yang `division_id` = division guest. ```json { "type": "info", "title": "Assignment Daily Activities", "message": "[Andi] has assign you to the daily activities. On 09 Nov 2026 08:00.", "data": { "action": "daily_activity.assigned", "activityId": "uuid", "projectId": "uuid-project", "reminderTime": "2026-11-09T08:00:00.000+07:00" } } ``` - `[Date]` = tanggal + jam `reminderTime` pada wall clock WIB, format `09 Nov 2026 08:00`. - Nama pembuat tidak ter-resolve → `[Someone]`. - Kanal: in-app + push + email, mengikuti preferensi tiap user (default in-app saja). - Event key terpisah: `base.daily_activity.assigned` (reminder: `base.daily_activity.reminder`), jadi user bisa mematikan salah satu tanpa mematikan yang lain. - Dikirim **ulang hanya kalau `reminderTime` berubah** (PATCH dengan jam baru). Dedupe per instant lewat kolom `assignment_notified_for`, ditulis dalam transaksi yang sama dengan outbox row — retry/clone tidak menghasilkan notifikasi kedua, dan guest berubah tanpa jam berubah tidak memicu kirim ulang. Catatan penting: 1. **Audiens = divisi, bukan project.** Kalau satu divisi punya beberapa project, anggota project lain di divisi yang sama juga menerima notifikasi. 2. **Tidak ada notifikasi kalau division guest kosong** — karena itu reminder tanpa guest ditolak saat create/update. 3. **Tidak ada repeat / recurring.** Satu reminder = satu kali kirim. 4. **Tidak ada endpoint batal.** Menghapus reminder (`DELETE`) membatalkan job-nya. 5. **Kirim ulang** hanya bisa dengan mengubah `reminderTime` (atau switch type keluar-masuk `REMINDER`) — itu mereset `reminderSentAt`. 6. Anggota tim yang belum punya akun (`user_id` null) tidak menerima notifikasi in-app/push. 7. Email memakai baris yang sama (`bodyText`) sebagai isi pesan, jadi teks di inbox = teks in-app. --- ## 9. Report PDF Kontrak API report **tidak berubah**. Di dalam PDF, reminder muncul di tabel Daily Activities dengan: - kolom **Time**: `08:00 (reminder)` (WIB) - kolom **Action plan**: `—` - reminder ikut terhitung dalam window periode berdasarkan `reminderTime` - urutan dalam satu hari mengikuti jam efektif, jadi reminder berselang-seling dengan task — bukan menumpuk di atas --- ## 10. Ringkasan error | HTTP | Kasus | |---|---| | 400 | semua aturan bentuk di §4c | | 403 | project `COMPLETED` (write), bukan creator/admin (update/delete) | | 404 | daily activity tidak ditemukan di project itu | --- ## 11. Checklist FE - [ ] Form create punya pemilih tipe; pilih `TASK` → field window, pilih `REMINDER` → field satu jam + **division guest wajib**. - [ ] Field `startTime`/`endTime` di form TASK dikirim dalam UTC ISO (atau offset apapun, backend pakai `z.coerce.date`). - [ ] Form reminder menyembunyikan pemilih action plan. - [ ] Kalender membaca jam dari `reminderTimeWib`/`startTimeWib`/`endTimeWib`. - [ ] Kalender merender reminder dengan penanda visual berbeda (ikon lonceng / label) dan tanpa rentang waktu. - [ ] Kalender tidak melakukan grouping/sort ulang — pakai `days[].activities` apa adanya. - [ ] Grid monthly memakai `days` dari response (jangan hitung grid sendiri). - [ ] Detail reminder menampilkan status: `reminderSentAt === null` → "Pending", selain itu → waktu terkirim. - [ ] Handle 403 project closed dengan state read-only (sembunyikan tombol tulis). - [ ] Deep link notifikasi: `data.action === 'daily_activity.reminder'` atau `'daily_activity.assigned'` → buka project `data.projectId` di tanggal `data.reminderTime`. --- ## 12. Skenario QA 1. Create TASK dengan window → muncul di kalender pada hari & jam WIB yang benar, tanpa penanda reminder. 2. Create REMINDER tanpa division guest → 400. 3. Create REMINDER jam 23:30 WIB → masuk ke hari yang sama (bukan hari berikutnya). 4. Patch TASK → REMINDER lengkap dengan `reminderTime` → kolom window jadi `null` di response. 5. Patch REMINDER → TASK dengan `startTime`+`endTime` → `reminderTime` jadi `null`, reminder pending lama dibatalkan. 6. Biarkan reminder lewat waktunya → penerima di project team divisi terkait dapat in-app + push + email; `reminderSentAt` terisi. 7. Replay/duplikat job tidak mengirim notifikasi kedua (`reminderSentAt` sudah terisi). 8. Ubah `reminderTime` ke masa depan sebelum terkirim → hanya satu notifikasi, di jam baru. 9. Kalender `monthly` bulan November 2026 → 35 sel atau lebih, kelipatan 7, hari dari Oktober/Desember ikut. 10. Project `COMPLETED` → POST/PATCH/DELETE dibalas 403.