Files
multica/server/internal/util/pgx.go
Bohan Jiang 5900d8b637 fix(issues): make start_date/due_date timezone-stable calendar days (#3618) (#3692)
* fix(issues): store start_date/due_date as DATE, not timestamp (MUL-2925)

These fields are calendar days (the pickers offer no time-of-day), but were
stored as TIMESTAMPTZ. A client serializing local midnight via toISOString()
folded its timezone into the instant, so the day shifted by the local offset
(GH #3618). Migrate the columns to DATE and parse/serialize date-only
"YYYY-MM-DD". ParseCalendarDate still accepts legacy RFC3339 (truncated to the
UTC day) so older clients keep working.

Co-authored-by: multica-agent <github@multica.ai>

* fix(issues): render start_date/due_date as timezone-stable calendar days (MUL-2925)

Pickers now emit date-only "YYYY-MM-DD" (local calendar day) instead of
toISOString(), and every read formats via the shared @multica/core/issues/date
helpers with timeZone:"UTC" so the day never shifts with the viewer's offset.
The Gantt's existing UTC bucketing is now correct. Covers web/desktop pickers,
quick-set menu, list/board/detail/activity, and the mobile due-date picker.

Co-authored-by: multica-agent <github@multica.ai>

* fix(issues): address date-only review — loud-fail ambiguous dates, finish display sweep (MUL-2925)

Review follow-ups on #3692:
- ParseCalendarDate no longer silently truncates a legacy non-midnight RFC3339
  to the wrong UTC day; it accepts only YYYY-MM-DD or an exact UTC-midnight
  instant and rejects ambiguous ones loudly. Adds util unit tests.
- migration 112 pins the TIMESTAMPTZ->DATE conversion to UTC explicitly via
  AT TIME ZONE 'UTC' (was session-timezone dependent); down migration too.
- Convert remaining date-change display sites to formatDateOnly: inbox detail
  label (web) and mobile activity + inbox labels (were new Date()+local format).
- CLI --start-date/--due-date help now says YYYY-MM-DD, not RFC3339.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-03 14:34:01 +08:00

147 lines
3.9 KiB
Go

package util
import (
"encoding/hex"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgtype"
)
// ParseUUID parses s into a pgtype.UUID. Invalid input returns an error
// instead of a zero-valued UUID — silently dropping bad input has caused
// data-loss bugs (e.g. DELETE matching no rows, returning 204 success).
//
// Use this at any boundary where s comes from user input (URL params,
// request bodies, headers) and pair it with a 4xx response on error.
// For trusted, already-validated UUID strings (sqlc round-trips, fixtures),
// use MustParseUUID instead.
func ParseUUID(s string) (pgtype.UUID, error) {
var u pgtype.UUID
if err := u.Scan(s); err != nil {
return u, fmt.Errorf("invalid UUID %q: %w", s, err)
}
if !u.Valid {
return u, fmt.Errorf("invalid UUID: %q", s)
}
return u, nil
}
// MustParseUUID parses s into a pgtype.UUID and panics on invalid input.
// Reserve for trusted callers (already-validated round-trips, test fixtures).
// At a request boundary, use ParseUUID and surface a 4xx instead.
func MustParseUUID(s string) pgtype.UUID {
u, err := ParseUUID(s)
if err != nil {
panic(err)
}
return u
}
func UUIDToString(u pgtype.UUID) string {
if !u.Valid {
return ""
}
b := u.Bytes
dst := make([]byte, 36)
hex.Encode(dst[0:8], b[0:4])
dst[8] = '-'
hex.Encode(dst[9:13], b[4:6])
dst[13] = '-'
hex.Encode(dst[14:18], b[6:8])
dst[18] = '-'
hex.Encode(dst[19:23], b[8:10])
dst[23] = '-'
hex.Encode(dst[24:36], b[10:16])
return string(dst)
}
func TextToPtr(t pgtype.Text) *string {
if !t.Valid {
return nil
}
return &t.String
}
func PtrToText(s *string) pgtype.Text {
if s == nil {
return pgtype.Text{}
}
return pgtype.Text{String: *s, Valid: true}
}
func StrToText(s string) pgtype.Text {
if s == "" {
return pgtype.Text{}
}
return pgtype.Text{String: s, Valid: true}
}
func TimestampToString(t pgtype.Timestamptz) string {
if !t.Valid {
return ""
}
return t.Time.Format(time.RFC3339)
}
func TimestampToPtr(t pgtype.Timestamptz) *string {
if !t.Valid {
return nil
}
s := t.Time.Format(time.RFC3339)
return &s
}
// DateToPtr formats a pgtype.Date as a date-only "YYYY-MM-DD" string, or nil
// when unset. Issue start_date/due_date are calendar days with no time-of-day
// or timezone, so they must never be rendered through an instant.
func DateToPtr(d pgtype.Date) *string {
if !d.Valid {
return nil
}
s := d.Time.Format(time.DateOnly)
return &s
}
// ParseCalendarDate parses a calendar day from a "YYYY-MM-DD" string into a
// pgtype.Date carrying no time-of-day or timezone.
//
// For backward compatibility it ALSO accepts an RFC3339 timestamp, but ONLY
// when it lands exactly on a UTC day boundary (e.g. "2026-03-01T00:00:00Z"),
// which unambiguously denotes that calendar day. A non-midnight instant is a
// legacy local-midnight-as-UTC value (e.g. UTC+8 sends "2026-02-28T16:00:00Z"
// for the picked day 2026-03-01) whose intended calendar day is unrecoverable —
// it is rejected loudly rather than silently stored as the wrong day. New
// clients always send "YYYY-MM-DD".
func ParseCalendarDate(s string) (pgtype.Date, error) {
if t, err := time.Parse(time.DateOnly, s); err == nil {
return pgtype.Date{Time: t, Valid: true}, nil
}
if t, err := time.Parse(time.RFC3339, s); err == nil {
u := t.UTC()
if u.Hour() == 0 && u.Minute() == 0 && u.Second() == 0 && u.Nanosecond() == 0 {
return pgtype.Date{
Time: time.Date(u.Year(), u.Month(), u.Day(), 0, 0, 0, 0, time.UTC),
Valid: true,
}, nil
}
return pgtype.Date{}, fmt.Errorf("invalid date %q: timestamps must be a UTC midnight boundary (e.g. 2026-03-01T00:00:00Z); use YYYY-MM-DD", s)
}
return pgtype.Date{}, fmt.Errorf("invalid date %q: expected YYYY-MM-DD", s)
}
func UUIDToPtr(u pgtype.UUID) *string {
if !u.Valid {
return nil
}
s := UUIDToString(u)
return &s
}
func Int8ToPtr(v pgtype.Int8) *int64 {
if !v.Valid {
return nil
}
return &v.Int64
}