From 0b24ab4b454215c7ea553e70dd8fb7c880e9d8f7 Mon Sep 17 00:00:00 2001 From: Krispire Admin Date: Thu, 11 Jun 2026 01:11:10 +0530 Subject: [PATCH] fix: migration --- internal/service/reminder_service.go | 6 +- internal/service/sadhna_service.go | 5 ++ internal/service/timezone.go | 89 ++++++++++++++++++++ migrations/0034_notification_prefs.up.sql | 4 +- migrations/0035_normalize_timezones.down.sql | 3 + migrations/0035_normalize_timezones.up.sql | 74 ++++++++++++++++ 6 files changed, 177 insertions(+), 4 deletions(-) create mode 100644 internal/service/timezone.go create mode 100644 migrations/0035_normalize_timezones.down.sql create mode 100644 migrations/0035_normalize_timezones.up.sql diff --git a/internal/service/reminder_service.go b/internal/service/reminder_service.go index 6755358..2887887 100644 --- a/internal/service/reminder_service.go +++ b/internal/service/reminder_service.go @@ -105,9 +105,11 @@ func (s *ReminderService) SetEnabled(ctx context.Context, userID string, enabled } // SetTimezone validates the IANA zone before storing so a bad value can never -// break the (timezone-using) candidate scan. +// break the (timezone-using) candidate scan. Legacy aliases are canonicalized +// first (browsers report e.g. "Asia/Calcutta"): Go resolves them but a +// Postgres without the tzdata backward links rejects them in AT TIME ZONE. func (s *ReminderService) SetTimezone(ctx context.Context, userID, tz string) error { - tz = strings.TrimSpace(tz) + tz = canonicalTZ(strings.TrimSpace(tz)) if tz == "" { tz = "UTC" } diff --git a/internal/service/sadhna_service.go b/internal/service/sadhna_service.go index abff115..ccf7909 100644 --- a/internal/service/sadhna_service.go +++ b/internal/service/sadhna_service.go @@ -94,6 +94,11 @@ const fallbackTZ = "Asia/Kolkata" // genuine UTC user re-reports their real zone on next load. func (s *SadhnaService) resolveTZ(ctx context.Context, userID string) (string, *time.Location) { tz, _ := s.logs.UserTimezone(ctx, userID) + // Canonicalize legacy aliases (e.g. stored "Asia/Calcutta") — the name + // is fed into SQL AT TIME ZONE, which rejects aliases on a Postgres + // without the tzdata backward links. Covers rows written before + // SetTimezone started canonicalizing. + tz = canonicalTZ(tz) if tz == "" || tz == "UTC" { tz = fallbackTZ } diff --git a/internal/service/timezone.go b/internal/service/timezone.go new file mode 100644 index 0000000..c1bbc77 --- /dev/null +++ b/internal/service/timezone.go @@ -0,0 +1,89 @@ +package service + +// Legacy IANA timezone aliases → canonical names. +// +// Browsers report Intl.DateTimeFormat().resolvedOptions().timeZone, and the +// ICU/CLDR data they ship still uses several LEGACY ids as its canonical +// form — Safari and Chrome genuinely say "Asia/Calcutta", not "Asia/Kolkata". +// Go's time.LoadLocation resolves these aliases, so they pass validation and +// get stored; but a Postgres built against tzdata without the "backward" +// links (Debian/Alpine images with the tzdata-legacy split) rejects them in +// AT TIME ZONE with SQLSTATE 22023. Everything we store or hand to SQL goes +// through canonicalTZ first. +var legacyTZ = map[string]string{ + // Ids ICU/CLDR still reports as canonical (the ones browsers send). + "Asia/Calcutta": "Asia/Kolkata", + "Asia/Katmandu": "Asia/Kathmandu", + "Asia/Rangoon": "Asia/Yangon", + "Asia/Saigon": "Asia/Ho_Chi_Minh", + "America/Buenos_Aires": "America/Argentina/Buenos_Aires", + "America/Catamarca": "America/Argentina/Catamarca", + "America/Cordoba": "America/Argentina/Cordoba", + "America/Jujuy": "America/Argentina/Jujuy", + "America/Mendoza": "America/Argentina/Mendoza", + "America/Indianapolis": "America/Indiana/Indianapolis", + "America/Louisville": "America/Kentucky/Louisville", + "America/Godthab": "America/Nuuk", + "Atlantic/Faeroe": "Atlantic/Faroe", + "Pacific/Ponape": "Pacific/Pohnpei", + "Pacific/Truk": "Pacific/Chuuk", + "Europe/Kiev": "Europe/Kyiv", + + // Other common backward links, in case a zone arrives from an older + // client or was set by hand. + "Asia/Ashkhabad": "Asia/Ashgabat", + "Asia/Chongqing": "Asia/Shanghai", + "Asia/Chungking": "Asia/Shanghai", + "Asia/Dacca": "Asia/Dhaka", + "Asia/Harbin": "Asia/Shanghai", + "Asia/Istanbul": "Europe/Istanbul", + "Asia/Kashgar": "Asia/Urumqi", + "Asia/Macao": "Asia/Macau", + "Asia/Tel_Aviv": "Asia/Jerusalem", + "Asia/Thimbu": "Asia/Thimphu", + "Asia/Ujung_Pandang": "Asia/Makassar", + "Asia/Ulan_Bator": "Asia/Ulaanbaatar", + "Africa/Asmera": "Africa/Asmara", + "America/Coral_Harbour": "America/Atikokan", + "Australia/ACT": "Australia/Sydney", + "Australia/Canberra": "Australia/Sydney", + "Australia/NSW": "Australia/Sydney", + "Australia/North": "Australia/Darwin", + "Australia/Queensland": "Australia/Brisbane", + "Australia/South": "Australia/Adelaide", + "Australia/Tasmania": "Australia/Hobart", + "Australia/Victoria": "Australia/Melbourne", + "Australia/West": "Australia/Perth", + "US/Alaska": "America/Anchorage", + "US/Arizona": "America/Phoenix", + "US/Central": "America/Chicago", + "US/Eastern": "America/New_York", + "US/Hawaii": "Pacific/Honolulu", + "US/Mountain": "America/Denver", + "US/Pacific": "America/Los_Angeles", + "GB": "Europe/London", + "GB-Eire": "Europe/London", + "Eire": "Europe/Dublin", + "Egypt": "Africa/Cairo", + "Hongkong": "Asia/Hong_Kong", + "Iceland": "Atlantic/Reykjavik", + "Israel": "Asia/Jerusalem", + "Japan": "Asia/Tokyo", + "NZ": "Pacific/Auckland", + "Poland": "Europe/Warsaw", + "Portugal": "Europe/Lisbon", + "PRC": "Asia/Shanghai", + "ROC": "Asia/Taipei", + "ROK": "Asia/Seoul", + "Singapore": "Asia/Singapore", + "Turkey": "Europe/Istanbul", +} + +// canonicalTZ returns the canonical IANA name for tz, resolving legacy +// aliases; unknown values pass through unchanged. +func canonicalTZ(tz string) string { + if c, ok := legacyTZ[tz]; ok { + return c + } + return tz +} diff --git a/migrations/0034_notification_prefs.up.sql b/migrations/0034_notification_prefs.up.sql index 4bf1b68..1c5b8f4 100644 --- a/migrations/0034_notification_prefs.up.sql +++ b/migrations/0034_notification_prefs.up.sql @@ -9,8 +9,8 @@ -- chant_reminder, …); the Settings UI groups kinds into coarser categories -- but storage stays kind-level so backfilled opt-outs map exactly. CREATE TABLE IF NOT EXISTS notification_prefs ( - user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - kind TEXT NOT NULL, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + kind TEXT NOT NULL, channel TEXT NOT NULL CHECK (channel IN ('in_app', 'email', 'push')), enabled BOOLEAN NOT NULL DEFAULT FALSE, updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), diff --git a/migrations/0035_normalize_timezones.down.sql b/migrations/0035_normalize_timezones.down.sql new file mode 100644 index 0000000..56c0f43 --- /dev/null +++ b/migrations/0035_normalize_timezones.down.sql @@ -0,0 +1,3 @@ +-- No-op: the canonical names are valid everywhere the aliases were, so +-- there's nothing to restore (and the original alias per row isn't known). +SELECT 1; diff --git a/migrations/0035_normalize_timezones.up.sql b/migrations/0035_normalize_timezones.up.sql new file mode 100644 index 0000000..40107fd --- /dev/null +++ b/migrations/0035_normalize_timezones.up.sql @@ -0,0 +1,74 @@ +-- Normalize legacy IANA timezone aliases stored in users.timezone. +-- +-- Browsers (ICU/CLDR) report legacy ids — Safari sends "Asia/Calcutta", not +-- "Asia/Kolkata" — and Go's validation accepts them, so they were stored +-- verbatim. A Postgres built against tzdata without the "backward" links +-- rejects those names in AT TIME ZONE (SQLSTATE 22023), 500-ing the sādhana +-- dashboard. The Go side now canonicalizes on read and write (see +-- internal/service/timezone.go); this backfills the rows written before. +UPDATE users SET timezone = m.canonical +FROM (VALUES + ('Asia/Calcutta', 'Asia/Kolkata'), + ('Asia/Katmandu', 'Asia/Kathmandu'), + ('Asia/Rangoon', 'Asia/Yangon'), + ('Asia/Saigon', 'Asia/Ho_Chi_Minh'), + ('America/Buenos_Aires', 'America/Argentina/Buenos_Aires'), + ('America/Catamarca', 'America/Argentina/Catamarca'), + ('America/Cordoba', 'America/Argentina/Cordoba'), + ('America/Jujuy', 'America/Argentina/Jujuy'), + ('America/Mendoza', 'America/Argentina/Mendoza'), + ('America/Indianapolis', 'America/Indiana/Indianapolis'), + ('America/Louisville', 'America/Kentucky/Louisville'), + ('America/Godthab', 'America/Nuuk'), + ('Atlantic/Faeroe', 'Atlantic/Faroe'), + ('Pacific/Ponape', 'Pacific/Pohnpei'), + ('Pacific/Truk', 'Pacific/Chuuk'), + ('Europe/Kiev', 'Europe/Kyiv'), + ('Asia/Ashkhabad', 'Asia/Ashgabat'), + ('Asia/Chongqing', 'Asia/Shanghai'), + ('Asia/Chungking', 'Asia/Shanghai'), + ('Asia/Dacca', 'Asia/Dhaka'), + ('Asia/Harbin', 'Asia/Shanghai'), + ('Asia/Istanbul', 'Europe/Istanbul'), + ('Asia/Kashgar', 'Asia/Urumqi'), + ('Asia/Macao', 'Asia/Macau'), + ('Asia/Tel_Aviv', 'Asia/Jerusalem'), + ('Asia/Thimbu', 'Asia/Thimphu'), + ('Asia/Ujung_Pandang', 'Asia/Makassar'), + ('Asia/Ulan_Bator', 'Asia/Ulaanbaatar'), + ('Africa/Asmera', 'Africa/Asmara'), + ('America/Coral_Harbour','America/Atikokan'), + ('Australia/ACT', 'Australia/Sydney'), + ('Australia/Canberra', 'Australia/Sydney'), + ('Australia/NSW', 'Australia/Sydney'), + ('Australia/North', 'Australia/Darwin'), + ('Australia/Queensland', 'Australia/Brisbane'), + ('Australia/South', 'Australia/Adelaide'), + ('Australia/Tasmania', 'Australia/Hobart'), + ('Australia/Victoria', 'Australia/Melbourne'), + ('Australia/West', 'Australia/Perth'), + ('US/Alaska', 'America/Anchorage'), + ('US/Arizona', 'America/Phoenix'), + ('US/Central', 'America/Chicago'), + ('US/Eastern', 'America/New_York'), + ('US/Hawaii', 'Pacific/Honolulu'), + ('US/Mountain', 'America/Denver'), + ('US/Pacific', 'America/Los_Angeles'), + ('GB', 'Europe/London'), + ('GB-Eire', 'Europe/London'), + ('Eire', 'Europe/Dublin'), + ('Egypt', 'Africa/Cairo'), + ('Hongkong', 'Asia/Hong_Kong'), + ('Iceland', 'Atlantic/Reykjavik'), + ('Israel', 'Asia/Jerusalem'), + ('Japan', 'Asia/Tokyo'), + ('NZ', 'Pacific/Auckland'), + ('Poland', 'Europe/Warsaw'), + ('Portugal', 'Europe/Lisbon'), + ('PRC', 'Asia/Shanghai'), + ('ROC', 'Asia/Taipei'), + ('ROK', 'Asia/Seoul'), + ('Singapore', 'Asia/Singapore'), + ('Turkey', 'Europe/Istanbul') +) AS m(alias, canonical) +WHERE users.timezone = m.alias;