mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 13:29:44 +02:00
* 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>
147 lines
3.9 KiB
Go
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
|
|
}
|