Pahami jalur penyimpanan donasi dari form ke database: validasi server, cek ulang campaign, session user, snapshot donatur, status pending, idempotency key, lookup token, dan risiko dua sumber nomor donasi.
35m bacaanKuis WajibMicro Badge Lesson
Simpan Donasi & Nomor Donasi
Di lesson sebelumnya, kamu sudah melihat bahwa form donasi tidak boleh hanya terlihat benar di browser. Data dari form harus masuk lewat validasi server, karena input browser bisa dimanipulasi.
Sekarang kita maju satu langkah: setelah data lolos validasi, bagaimana donasi benar-benar disimpan?
Ini pertanyaan penting. Di aplikasi donasi, baris data bukan sekadar catatan. Setiap baris punya konsekuensi:
donatur perlu mendapat nomor donasi yang bisa dirujuk;
status awal harus jelas, misalnya pending;
campaign harus masih aktif saat donasi dikirim;
data kontak donatur perlu disimpan sebagai snapshot;
akses guest perlu punya token lookup yang aman;
submit ganda tidak boleh menghasilkan data kacau.
Kalau bagian ini asal jadi, UI bisa terlihat sukses padahal data di belakangnya tidak bisa dipercaya.
Gambaran alur yang ingin kita pahami
Buka file ini di starter:
text
src/app/campaigns/[slug]/donate/actions.ts
Di dalamnya ada server action bernama createDonation.
Secara ringkas, jalurnya seperti ini:
text
FormData ↓validasi Zod ↓cek ulang campaign ke database ↓baca user dari session Supabase ↓buat idempotency key, donation number, lookup token ↓susun snapshot donasi ↓insert ke tabel donations ↓balikan donationNumber + lookupToken ke UI
Diagram itu terlihat sederhana. Tapi di tiap langkah ada keputusan teknis yang menentukan apakah data donasi aman, konsisten, dan bisa diverifikasi.
Pertama, semua nilai dari FormData dianggap mentah. formData.get('amount') tidak otomatis aman hanya karena input di UI memakai komponen nominal. Browser bisa dibuka lewat DevTools, request bisa dimodifikasi, dan field tersembunyi bisa diubah.
Kedua, safeParse dipakai supaya action tidak langsung melempar error saat data invalid. Hasilnya bisa dibaca:
parsed.success === true → lanjut;
parsed.success === false → kembalikan error per field.
Di sini server action berperan sebagai gerbang kontrak data. Ia memastikan bentuk data benar sebelum database disentuh.
Prinsip yang perlu kamu simpan:
UI boleh membantu user mengisi data, tapi server tetap yang memutuskan data itu sah atau tidak.
Bagian 2 — Campaign harus dicek ulang saat submit
Setelah validasi, action membuat Supabase client lalu mengecek campaign:
Mungkin muncul pertanyaan: “Bukannya halaman form tadi sudah menampilkan campaign? Kenapa dicek lagi?”
Karena halaman yang kamu lihat adalah snapshot. Bisa saja halaman dirender pukul 10:00, lalu admin menutup campaign pukul 10:01, dan user baru klik submit pukul 10:02.
Kalau action hanya percaya data dari halaman, donasi masih bisa masuk ke campaign yang sudah tidak menerima donasi.
Karena itu action mengecek ulang status campaign langsung ke database saat submit. Ini bukan duplikasi sia-sia; ini penjagaan boundary.
Prinsipnya:
Data yang menentukan izin operasi harus dicek di server pada saat operasi terjadi, bukan hanya saat halaman ditampilkan.
Bagian 3 — Identitas user tidak boleh diambil dari form
Setelah campaign valid, action membaca user dari session Supabase:
ts
const { data: { user },} = await supabase.auth.getUser();
Nanti nilai ini dipakai saat menyusun snapshot:
ts
user_id: user?.id ?? null,
Kalau user login, user_id berisi ID user dari session. Kalau guest, nilainya null.
Yang penting: user_idtidak diambil dari form.
Kenapa?
Karena form adalah input dari client. Kalau ada field seperti ini:
html
<input type="hidden" name="userId" value="..." />
maka user bisa mengubahnya lewat DevTools. Hidden input bukan mekanisme keamanan. Ia hanya menyembunyikan tampilan, bukan melindungi nilai.
Identitas harus datang dari session yang diverifikasi server, bukan dari data yang dikirim browser.
Prinsipnya:
Form boleh membawa pilihan user, tapi tidak boleh membawa klaim identitas yang dipercaya server.
Idempotency berarti: operasi yang sama boleh terkirim lebih dari sekali, tapi hasil akhirnya tetap dianggap satu operasi.
Dalam konteks form donasi, ini penting karena user bisa:
klik tombol submit dua kali;
refresh saat request sedang berjalan;
koneksi lambat lalu browser mengirim ulang request;
mobile browser mengulang request setelah jaringan berubah.
Kalau tidak ada idempotency, satu niat donasi bisa menghasilkan dua baris donasi.
Di starter ini, action mengecek apakah idempotency_key sudah pernah dipakai. Kalau sudah ada, action tidak membuat donasi baru. Ia mengembalikan nomor donasi yang sudah ada dan menandai respons sebagai reused: true.
Ini belum sempurna untuk semua kondisi race, tapi sudah memperkenalkan prinsip penting:
Submit ulang harus menghasilkan status yang bisa dipahami, bukan membuat data ganda diam-diam.
Bagian 5 — Snapshot donasi menyimpan keadaan saat transaksi dibuat
Setelah semua pengecekan lewat, action membuat nilai pendukung:
Perhatikan penamaan donor_name_snapshot, donor_email_snapshot, dan donor_phone_snapshot.
Kenapa disebut snapshot?
Karena data itu disalin ke baris donasi pada saat donasi dibuat. Kalau suatu hari user mengubah profil, email, atau nomor WhatsApp, data donasi historis tetap utuh.
Ini penting untuk record transaksi. Donasi bukan hanya relasi ke profil terbaru. Donasi adalah kejadian pada waktu tertentu.
Status awal juga dikunci sebagai:
ts
status: 'pending'
Ini masuk akal: donasi baru dibuat, tapi belum diverifikasi. Status seperti verified atau rejected harus lewat jalur verifikasi, bukan dikirim dari form donasi.
Prinsipnya:
Data transaksi harus menyimpan keadaan saat kejadian dibuat, bukan selalu mengikuti data profil terbaru.
Bagian 6 — Lookup token disimpan sebagai hash
Snapshot juga berisi:
ts
lookup_token_hash: hashLookupToken(lookupToken),
Action mengembalikan lookupToken ke UI, tetapi database menyimpan hash-nya.
Ini pola yang mirip dengan token reset password: token mentah hanya diketahui oleh user lewat URL atau respons saat itu; database menyimpan bentuk hash untuk mencocokkan nanti.
Kenapa tidak simpan token mentah?
Karena kalau database bocor, token mentah bisa langsung dipakai untuk melihat halaman konfirmasi donasi guest. Dengan hash, risiko berkurang: sistem masih bisa mencocokkan token yang dikirim user, tanpa menyimpan token asli.
Prinsipnya:
Token akses yang bisa membuka data privat sebaiknya tidak disimpan dalam bentuk mentah.
Di Supabase, .insert().select() akan mencoba membaca ulang row yang baru dibuat. Itu bisa gagal kalau policy SELECT tidak mengizinkan row tersebut dibaca oleh user saat ini, terutama untuk guest.
Starter memilih strategi yang lebih aman: action sudah punya donationNumber dan lookupToken sebelum insert. Jadi setelah insert sukses, action cukup mengembalikan nilai yang sudah ia buat sendiri:
Ini keputusan penting. Kadang kita tergoda membaca ulang data setelah insert, padahal policy read belum tentu mengizinkan. Kalau action hanya butuh nomor dan token untuk redirect, ia tidak harus memaksa read-back.
Prinsipnya:
Jangan melakukan read-back setelah insert kalau policy akses tidak membutuhkannya dan data yang diperlukan sudah tersedia di server action.
Bagian 8 — Jebakan: nomor donasi dibuat di dua tempat
Sekarang kita masuk ke bagian paling penting dari alur penyimpanan donasi.
Di action, nomor donasi dibuat oleh fungsi:
ts
const donationNumber = generateDonationNumber();
Fungsinya ada di:
text
src/lib/donation/number.ts
Isinya:
ts
export function generateDonationNumber(date: Date = new Date()): string { const yyyy = date.getFullYear(); const mm = String(date.getMonth() + 1).padStart(2, '0'); const dd = String(date.getDate()).padStart(2, '0'); const seq = Math.floor(Math.random() * 9000) + 1000; // 4 digit acak return `DON-${yyyy}${mm}${dd}-${seq}`;}
Formatnya:
text
DON-YYYYMMDD-####
Empat digit terakhir acak.
Tapi migration database juga punya mekanisme nomor donasi:
sql
create sequence if not exists public.donation_number_seq;create or replace function public.assign_donation_number()returns triggerlanguage plpgsqlsecurity defineras $$declare seq_val bigint;begin select nextval('public.donation_number_seq') into seq_val; new.donation_number := 'DON-' || to_char(new.created_at, 'YYYYMMDD') || '-' || lpad(seq_val::text, 4, '0'); return new;end;$$;create trigger trg_donations_assign_numberbefore insert on public.donationsfor each rowwhen (new.donation_number is null)execute function public.assign_donation_number();
Database membuat format yang terlihat mirip:
text
DON-YYYYMMDD-NNNN
Tapi empat digit terakhir berasal dari sequence berurutan, bukan angka acak.
Sekarang bandingkan dua sumber ini:
Sumber
Lokasi
Kapan jalan
Strategi angka
Aplikasi
src/lib/donation/number.ts
Saat createDonation dipanggil
4 digit acak
Database
trigger assign_donation_number
Saat insert dan donation_number is null
sequence berurutan
Di jalur createDonation saat ini, action selalu mengisi donation_number sebelum insert. Karena itu trigger database tidak aktif di jalur ini. Trigger hanya menjadi fallback kalau ada insert lain yang tidak mengisi donation_number.
Apakah ini langsung bug?
Belum tentu.
Tapi ini adalah risiko desain. Kalau sebagian insert memakai app dan sebagian insert memakai trigger, tabel donations bisa punya dua gaya nomor:
text
DON-20260910-7319 # dari app, acakDON-20260910-0042 # dari trigger, sequence
Bagi user, keduanya terlihat seperti nomor resmi. Bagi sistem, keduanya valid. Tapi bagi operasional, ini bisa membingungkan: apakah angka terakhir urutan transaksi, angka random, atau sekadar kode unik?
Di sinilah AI coding yang terarah berbeda dari sekadar “yang penting jalan”. Kamu tidak hanya bertanya: “apakah insert berhasil?” Kamu juga bertanya:
siapa sumber kebenaran nomor donasi?
apakah formatnya konsisten?
apakah risiko collision cukup kecil?
apakah nomor harus berurutan untuk kebutuhan operasional?
apakah action perlu tahu nomor sebelum insert?
apakah policy RLS memungkinkan read-back kalau nomor dibuat database?
Pertanyaan-pertanyaan itu adalah inti pembelajaran dari kasus nomor donasi.
Pilihan desain yang masuk akal
Ada dua pilihan yang sama-sama bisa dibela.
Pilihan A — Aplikasi menjadi penulis nomor
Action tetap membuat nomor sebelum insert:
ts
const donationNumber = generateDonationNumber();
Kelebihannya:
action langsung tahu nomor donasi;
tidak perlu .insert().select();
cocok untuk guest flow yang read-back-nya dibatasi RLS;
redirect/confirmation bisa memakai nilai yang sudah ada.
Kekurangannya:
angka acak punya risiko tabrakan, walaupun kecil;
perlu strategi retry saat kena unique violation;
trigger database menjadi fallback dengan format berbeda, kecuali diselaraskan.
Kalau memilih jalur ini, trigger database sebaiknya memakai format yang sama atau dihapus supaya tidak ada dua gaya nomor.
Pilihan B — Database menjadi penulis nomor
Action tidak mengirim donation_number, sehingga trigger mengisinya:
ts
// donation_number tidak diisi di snapshot
Kelebihannya:
nomor berurutan dan konsisten;
database menjadi satu sumber kebenaran;
semua jalur insert memakai mekanisme yang sama.
Kekurangannya:
action perlu cara mendapatkan nomor setelah insert;
.insert().select() bisa terbentur RLS;
perlu desain tambahan, misalnya RPC aman atau policy read terbatas.
Kalau memilih jalur ini, kamu harus merancang cara action mendapatkan nomor tanpa membuka data guest secara sembarangan.
Tidak ada jawaban yang otomatis benar untuk semua aplikasi. Yang salah adalah membiarkan dua sumber hidup tanpa keputusan yang jelas.
Praktik dengan Antigravity
Sekarang minta Antigravity membantu membaca jalur ini. Tapi ingat: tugasnya analisis, bukan edit kode.
Gunakan prompt ini:
text
Project: Platform Donasi Amanah.Saya ingin memahami jalur penyimpanan donasi, bukan mengubah kode dulu.Baca file berikut:- src/app/campaigns/[slug]/donate/actions.ts- src/lib/donation/number.ts- supabase/migrations/20260829_vertical_slice.sqlTolong jelaskan:1. Urutan createDonation dari FormData sampai insert ke tabel donations.2. Data apa saja yang divalidasi oleh Zod.3. Kenapa campaign dicek ulang ke database saat submit.4. Dari mana user_id berasal, dan kenapa tidak boleh dari form.5. Bagaimana idempotency_key dipakai untuk mencegah submit ganda.6. Kenapa lookup token disimpan sebagai hash.7. Siapa yang mengisi donation_number di jalur createDonation saat ini.8. Apa peran trigger assign_donation_number, dan kapan trigger itu tidak berjalan.9. Risiko apa yang muncul kalau app dan database sama-sama punya mekanisme nomor donasi dengan strategi berbeda.10. Rekomendasikan satu pilihan desain: app sebagai penulis nomor atau database sebagai penulis nomor. Jelaskan trade-off-nya.Jangan edit file apa pun. Jawab dengan kutipan file yang relevan dan alasan teknis.
Prompt ini sengaja panjang karena tugasnya bukan “buat fitur”. Tugasnya adalah membaca sistem dan membantu kamu memahami trade-off.
Kalau agent langsung mengubah kode, hentikan dan minta revert. Pada tahap ini, perubahan kode belum dibutuhkan.
Cara menilai jawaban Antigravity
Jawaban yang baik harus menyebut hal-hal ini:
safeParse dipakai sebelum database disentuh.
Campaign dicek ulang dengan .from('campaigns').select('id, status').
user_id berasal dari supabase.auth.getUser().
idempotency_key dicek ke tabel donations sebelum insert baru.
lookupToken dikembalikan mentah ke UI, tetapi database menyimpan hashLookupToken(lookupToken).
donation_number saat ini dibuat oleh generateDonationNumber() di app.
Trigger database hanya berjalan when (new.donation_number is null).
Risiko utama adalah dua strategi nomor: acak dari app vs sequence dari database.
Kalau agent melewatkan trigger database, berarti ia hanya membaca folder app dan belum membaca migration. Perbaiki prompt dengan kalimat:
text
Kamu belum membahas trigger assign_donation_number di migration. Baca bagian trigger dan jelaskan klausa WHEN-nya.
Kalau agent bilang trigger selalu menimpa nomor dari app, itu salah. Tunjukkan bagian ini:
sql
when (new.donation_number is null)
Artinya trigger hanya mengisi saat kolom belum diisi.
Verifikasi manual
Jangan berhenti di jawaban agent. Cek sendiri minimal tiga hal.
Kalau kamu belum menghubungkan Supabase lokal/remote, minimal lakukan verifikasi baca kode. Tapi sebelum fitur ini diklaim selesai, verifikasi database tetap harus dilakukan.
Kesalahan umum
Gejala
Kemungkinan penyebab
Cara berpikir yang benar
Agent bilang “insert sudah benar” tanpa membahas trigger
Agent hanya membaca action, tidak membaca migration
Minta agent membaca semua sumber yang memengaruhi data
Donasi sukses di UI tapi tidak ada di Table Editor
Insert gagal, env salah, atau project Supabase berbeda
UI sukses bukan bukti final; database adalah bukti simpan
Trigger dianggap selalu menimpa nomor app
Klausa WHEN tidak dibaca
Trigger hanya jalan saat donation_number null
User ID dikirim dari hidden input
Identitas dipercaya dari client
Identitas harus dari session server
Nomor donasi acak dan sequence dicampur tanpa sadar
Dua sumber kebenaran dibiarkan hidup
Pilih satu sumber dan samakan format
Prinsip yang perlu dibawa ke project lain
Dari satu fungsi createDonation, kamu belajar beberapa pola penting:
Server action adalah boundary. Ia menerima input mentah, memvalidasi, mengecek izin, lalu baru menyentuh database.
Session adalah sumber identitas. Jangan percaya user_id dari form.
Snapshot menjaga histori. Data transaksi perlu menyimpan keadaan saat kejadian dibuat.
Idempotency mencegah efek ganda. Satu niat submit tidak boleh diam-diam menjadi dua transaksi.
Token akses harus diperlakukan sensitif. Simpan hash, bukan token mentah.
Nomor resmi butuh satu sumber kebenaran. Kalau app dan database sama-sama bisa membuat nomor, pastikan strategi dan formatnya disengaja.
Verifikasi simpan data dilakukan di database. Halaman sukses hanya salah satu sinyal, bukan bukti akhir.
Yang perlu kamu hasilkan
Sebelum lanjut, kamu harus punya tiga output kecil:
Ringkasan 6–8 langkah jalur createDonation dengan bahasamu sendiri.
Pilihan sumber nomor donasi yang kamu anggap paling tepat untuk project ini: app atau database.
Bukti verifikasi: hasil grep, atau screenshot/query Table Editor kalau Supabase sudah terhubung.
Simpan ringkasan keputusan nomor donasi di:
text
docs/decisions/donation-number-source.md
Isi minimalnya:
md
# Sumber Nomor DonasiPilihan: [app/database]Alasan:- ...- ...Risiko yang dihindari:- ...Cara verifikasi:- ...
Ini bukan formalitas. Dokumen kecil seperti ini membuat keputusan teknis tidak hilang saat kamu melanjutkan fitur berikutnya.
Checkpoint
Pastikan semua ini sudah jelas:
Saya bisa menjelaskan kenapa data form harus lewat safeParse dulu.
Saya tahu kenapa campaign dicek ulang saat submit.
Saya tahu kenapa user_id berasal dari session, bukan form.
Saya bisa menjelaskan fungsi idempotency_key.
Saya tahu kenapa lookupToken disimpan sebagai hash.
Saya menemukan dua sumber nomor donasi dan tahu mana yang aktif di jalur createDonation.
Saya punya pilihan sumber nomor donasi yang jelas beserta alasannya.
Saya memverifikasi data donasi langsung di database, atau minimal sudah membaca jalur kode lengkap.
Nah, sekarang kamu tidak cuma tahu “donasi tersimpan”. Kamu tahu apa saja yang terjadi sebelum data masuk, siapa yang boleh dipercaya, kenapa nomor donasi bisa jadi sumber masalah, dan bagaimana membuktikannya. Setelah ini, kita akan masuk ke halaman konfirmasi: bagaimana donatur melihat status donasinya tanpa membuka data privat ke orang yang tidak berhak.
Checkpoint KuisTarget: 70%
Kuis 04.2 — Simpan Donasi & Nomor Donasi
Kuis ini wajib untuk sertifikat. Kamu tetap bisa lanjut ke pelajaran berikutnya dan mengerjakannya nanti.