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 windowstartTime+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
POST /api/projects/:projectId/daily-activities
Content-Type: application/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
{
"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
PATCH /api/projects/:projectId/daily-activities/:dailyActivityId
Sifatnya partial: field yang tidak dikirim tidak berubah. Yang perlu diperhatikan:
- Setengah window boleh. Kirim
startTimesaja sah; validasi final memakai gabungan nilai tersimpan + payload. - Ganti
typeharus lengkap.TASK→REMINDERwajib menyertakanreminderTime(dan guest sudah ada di row, boleh tidak dikirim ulang).REMINDER→TASKwajib menyertakanstartTime+endTime. - Kolom bentuk lama dibersihkan otomatis. Switch ke
REMINDERmengosongkanstartTime/endTime, dan sebaliknya. divisionGuestId: nullmenghapus guest, tapi akan ditolak kalau hasil akhirnyaREMINDER.actionPlans= full replace. Kalau dikirim, daftar lama dihapus dan diganti isi payload. Kirim[]untuk mengosongkan.- Reschedule hanya saat jam berubah. Mengubah
reminderTime(atau switch masuk keREMINDER) membatalkan job lama, menjadwalkan yang baru, dan meresetreminderSentAtkenullsehingga reminder bisa terkirim lagi. Mengubahtitle/notesaja 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):
{
"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:
{
"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 —*Wibsudah membawa offset, atau ambil substring11:00), bukan daristartTimementah. type === 'REMINDER'→ tampilkan satu jam + ikon/label reminder.reminderSentAt !== null= sudah terkirim.actionPlanLinks[].itemNameadalah 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):
{
"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:
dateadalah hari kalender WIB (YYYY-MM-DD), bukan hari lokal server. Aktivitas jam 22:00 WIB tidak akan lompat ke hari berikutnya.weeklymengembalikan hanya hari milik bulan itu (FE yang menyusun baris minggu).monthlymengembalikan 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
activitiesdalam satu hari = urutan waktu efektif (startTimeuntuk task,reminderTimeuntuk reminder). Tidak perlu sort ulang. start/endadalah 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:
{
"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]: <note>
Sender Username= display name pembuat daily activity (createdBy).<note>= fieldnote; kalau kosong dipakaititlesupaya tidak ada pesan kosong.- Kalau auth tidak bisa me-resolve nama pembuat, prefix
[...]:dihilangkan (isinotetetap 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.
{
"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 + jamreminderTimepada wall clock WIB, format09 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
reminderTimeberubah (PATCH dengan jam baru). Dedupe per instant lewat kolomassignment_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:
- Audiens = divisi, bukan project. Kalau satu divisi punya beberapa project, anggota project lain di divisi yang sama juga menerima notifikasi.
- Tidak ada notifikasi kalau division guest kosong — karena itu reminder tanpa guest ditolak saat create/update.
- Tidak ada repeat / recurring. Satu reminder = satu kali kirim.
- Tidak ada endpoint batal. Menghapus reminder (
DELETE) membatalkan job-nya. - Kirim ulang hanya bisa dengan mengubah
reminderTime(atau switch type keluar-masukREMINDER) — itu meresetreminderSentAt. - Anggota tim yang belum punya akun (
user_idnull) tidak menerima notifikasi in-app/push. - 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, pilihREMINDER→ field satu jam + division guest wajib. - Field
startTime/endTimedi form TASK dikirim dalam UTC ISO (atau offset apapun, backend pakaiz.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[].activitiesapa adanya. - Grid monthly memakai
daysdari 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 projectdata.projectIddi tanggaldata.reminderTime.
12. Skenario QA
- Create TASK dengan window → muncul di kalender pada hari & jam WIB yang benar, tanpa penanda reminder.
- Create REMINDER tanpa division guest → 400.
- Create REMINDER jam 23:30 WIB → masuk ke hari yang sama (bukan hari berikutnya).
- Patch TASK → REMINDER lengkap dengan
reminderTime→ kolom window jadinulldi response. - Patch REMINDER → TASK dengan
startTime+endTime→reminderTimejadinull, reminder pending lama dibatalkan. - Biarkan reminder lewat waktunya → penerima di project team divisi terkait dapat in-app + push + email;
reminderSentAtterisi. - Replay/duplikat job tidak mengirim notifikasi kedua (
reminderSentAtsudah terisi). - Ubah
reminderTimeke masa depan sebelum terkirim → hanya satu notifikasi, di jam baru. - Kalender
monthlybulan November 2026 → 35 sel atau lebih, kelipatan 7, hari dari Oktober/Desember ikut. - Project
COMPLETED→ POST/PATCH/DELETE dibalas 403.