Skip to content

Kontrak Tanggal Polos Bukan Instant pada Form Agenda KotaPortal

Adityo Guni Waluyo

event_date pernah meninggalkan browser sebagai instant T00:00:00Z. Kontraknya kini YYYY-MM-DD polos, dan helper klien yang dulu memperbaikinya dihapus.

Ringkasan

Field tanggal acara tadinya dikirim sebagai instant UTC tengah malam, jadi tanggalnya bisa geser sehari gara-gara zona waktu. Fix-nya: ubah kontrak API jadi format date polos YYYY-MM-DD, validasi di service, dan tipe Go diganti *string biar decoder nggak protes. Klien lama yang masih kirim instant bakal gagal keras, tapi itu lebih sehat daripada salah simpen diam-diam.

Saya sedang menelusuri tab jaringan untuk form agenda di proyek KotaPortal, memastikan tanggal acara tersimpan bersih. Yang berangkat justru begini: event_date: "2026-03-05T00:00:00Z". Saya memilih tanggal 5 Maret 2026 di form, tapi yang meninggalkan browser bukan tanggal itu melainkan sebuah instant di tengah malam UTC. Di sisi klien ada toLocalDateInput yang memotongnya lagi dengan slice(0, 10) supaya tampilan tidak meleset sehari. Menambal kebocoran, bukan memperbaiki pipa.

Perbaikan sebelumnya memang menyarankan cara itu: pertahankan kontrak date-time, jatuhkan nilainya di tengah malam UTC. Saya kira itu cukup.

Yang Salah Tipe Datanya, Bukan Waktunya

Ternyata field ini tidak pernah punya waktu sama sekali. Ia tanggal kalender. Di kontrak OpenAPI, format: date mengikuti full-date dari RFC3339, yaitu tahun, bulan, hari [1][2], sementara format: date-time adalah tanggal yang ditambah waktu dan ofset. Dua wire format berbeda, dipisahkan satu kata di kolom format.

Perubahan yang masuk menulis aturan itu apa adanya: event_date: { type: string, format: date, nullable: true } di spesifikasi, DTO Go EventDate *string yang sebelumnya *time.Time, lalu validasi di tepi service.

if req.EventDate != nil && *req.EventDate != "" {
        t, err := time.Parse("2006-01-02", *req.EventDate)
        if err != nil {
                return nil, validationError("tanggal acara tidak valid (format YYYY-MM-DD)")
        }
        a.EventDate = sql.NullTime{Time: t.UTC(), Valid: true}
}

String yang tidak bisa dibaca layout 2006-01-02 langsung ditolak dengan pesan 400 yang menyebut fieldnya, dan time.Parse memang menuntut nilai bisa diparse dengan layout yang diberikan [7]. Uji regresi baru mengunci dua hal sekaligus: 2026-03-05T00:00:00Z kini gagal dengan error validasi, sementara null dan "" sama-sama membersihkan kolom. Itu semantik PUT yang saya mau. Kunci yang tidak dikirim bukan perintah hapus, kunci kosong iya.

Tipe *string dipilih, bukan sekadar pindah ke parser lain, karena ada satu lapis yang bergerak lebih dulu: decoder JSON. time.Time di Go meminta string RFC3339 ketat saat unmarshal, jadi tanggal polos bahkan tidak akan sampai ke validasi service [8]. Dengan *string, nilai masuk apa adanya, lalu keputusan format jadi milik satu tempat: layout 2006-01-02 di service.

Kenapa Payload Salah Tersimpan Tanpa Suara

Browser sebenarnya sudah berbicara bahasa tanggal polos sejak awal. Nilai <input type="date"> selalu ternormalisasi ke yyyy-mm-dd, dan format tampilannya saja yang mengikuti lokali browser [4]. Yang merusak justru langkah berikutnya di klien: Date.parse pada string tanggal saja menyiratkan UTC [5], lalu toISOString selalu mengembalikan akhiran Z [6]. Tanggalnya benar di layar, payloadnya salah di kabel.

Lapisan basis data tidak ikut melindungi. Kolom DATE MariaDB menampilkan YYYY-MM-DD, tapi menerima input yang jauh lebih longgar, termasuk angka dan string, selama masih masuk akal [3]. Instant RFC3339 masuk tanpa komplain, tersimpan sebagai tanggal, tidak ada log merah. Kelonggaran itulah yang menelan payload bengkok secara diam-diam, dan dari situ saya menyimpulkan bahwa kolom memang tempat terakhir untuk menegakkan kontrak.

Apakah semua konsumen API admin sudah pindah ke format baru? Saya tidak punya buktinya. Yang jelas format lama kini ditolak dengan 400, jadi sisa klien lama akan gagal keras, bukan salah simpan diam-diam. Bentuk kegagalan seperti ini lebih sehat daripada kesepakatan lisan antar tim.

Perubahan ini sekaligus menutup satu kelas bug yang tidak pernah muncul di log: tanggal yang bergeser karena zona waktu klien. Selama fieldnya berupa instant, setiap klien bebas memilih titik tengah malamnya sendiri, dan pilihan itu bocor sampai ke tabel.

Regenerasi Tipe, Bukan Kesepakatan Lisan

Di sisi klien, toLocalDateInput dihapus, bukan diperbaiki. Pembacaan tinggal d.event_date ? d.event_date.slice(0, 10) : "", dan payload kembali mengirim form.event_date || null apa adanya. Tipe frontend hasil regenerasi ikut berubah, jadi konsumen tidak lagi bisa mengirim instant tanpa sengaja, kompilernya yang protes lebih dulu.

Ada satu pelajaran besar dari putaran ini: mematok tengah malam itu solusi sementara, menghapus instant dari kontrak itu perbaikannya. Selama field menyimpan tanggal, tipe kabelnya harus tanggal juga. Cerita bug mundur-satu-hari yang dulu saya tulis di artikel sebelumnya tetap benar sebagai diagnostik, tapi kontrak yang direkomendasikannya sudah pensiun sekarang.

## Sources [1] https://spec.openapis.org/oas/v3.0.4.html [2] https://www.rfc-editor.org/rfc/rfc3339.txt [3] https://mariadb.com/kb/en/date [4] https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/date [5] https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/parse [6] https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString [7] https://github.com/golang/go/blob/master/src/time/format.go [8] https://github.com/golang/go/blob/master/src/time/time.go

Artikel terkait