diff --git a/internal/db/migrations/064_users_forum_pref.down.sql b/internal/db/migrations/064_users_forum_pref.down.sql new file mode 100644 index 0000000..4e32db7 --- /dev/null +++ b/internal/db/migrations/064_users_forum_pref.down.sql @@ -0,0 +1,5 @@ +-- Reverse t-paliad-157 / m/paliad#15: drops the persisted +-- Fristenrechner inbox-channel preference column. + +ALTER TABLE paliad.users DROP CONSTRAINT IF EXISTS users_forum_pref_check; +ALTER TABLE paliad.users DROP COLUMN IF EXISTS forum_pref; diff --git a/internal/db/migrations/064_users_forum_pref.up.sql b/internal/db/migrations/064_users_forum_pref.up.sql new file mode 100644 index 0000000..6b1b094 --- /dev/null +++ b/internal/db/migrations/064_users_forum_pref.up.sql @@ -0,0 +1,32 @@ +-- t-paliad-157 / m/paliad#15: persisted Fristenrechner inbox-channel +-- preference. +-- +-- Stores the user's typical inbox channel (cms = UPC, bea = national-DE, +-- posteingang = national-DE — slower channel, same set of forums) so +-- /tools/fristenrechner can pre-narrow the proceeding picker without +-- re-asking on every visit. The chip on the page persists changes here +-- via the existing PATCH /api/me endpoint. URL ?inbox= overrides for +-- the current visit so a colleague can share a CMS-narrowed link +-- without flipping anyone's saved preference. +-- +-- The 3-value CHECK keeps the schema honest while leaving room to add +-- epa / dpma channels later (the Fristenrechner already supports those +-- forums via the fine-grained B2 chips; the inbox-channel chip starts +-- with the channels m named explicitly in t-paliad-157). +-- +-- NULL = no preference, picker shows everything. Default for existing +-- rows. + +ALTER TABLE paliad.users + ADD COLUMN forum_pref text; + +ALTER TABLE paliad.users + ADD CONSTRAINT users_forum_pref_check + CHECK (forum_pref IS NULL OR forum_pref IN ('cms', 'bea', 'posteingang')); + +COMMENT ON COLUMN paliad.users.forum_pref IS + 'Persisted Fristenrechner inbox-channel preference (#15). ' + 'cms = UPC; bea = national-DE; posteingang = national-DE (slower ' + 'channel, same forums). NULL = no preference (picker shows ' + 'everything). URL ?inbox= overrides for the current visit. Set via ' + 'PATCH /api/me.'; diff --git a/internal/models/models.go b/internal/models/models.go index 4f0d81a..6053f39 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -56,8 +56,13 @@ type User struct { // for overdue / DRINGEND mail. NULL means "fall back to global_admins". // Settings UI dropdown shipped 2026-04-29 (t-paliad-066). EscalationContactID *uuid.UUID `db:"escalation_contact_id" json:"escalation_contact_id,omitempty"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + // ForumPref is the user's persisted Fristenrechner inbox-channel + // preference (#15): "cms" → UPC; "bea" → national-DE; + // "posteingang" → national-DE (slower channel, same forums). NULL = + // no preference. URL ?inbox= overrides per-visit. + ForumPref *string `db:"forum_pref" json:"forum_pref,omitempty"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` } // Project is one node in the paliad.projects tree. Visibility is team-based diff --git a/internal/services/user_service.go b/internal/services/user_service.go index b35ee4b..57858a9 100644 --- a/internal/services/user_service.go +++ b/internal/services/user_service.go @@ -73,6 +73,7 @@ const userColumns = `id, email, display_name, office, additional_offices, practi reminder_timezone, reminder_warning_offset_days, escalation_contact_id, + forum_pref, created_at, updated_at` // GetByID returns the user row, or (nil, nil) if the user hasn't completed @@ -283,6 +284,10 @@ type UpdateProfileInput struct { // encode the clear signal as "" rather than juggling JSON null / missing // semantics). EscalationContactID *string `json:"escalation_contact_id,omitempty"` + // ForumPref is the persisted Fristenrechner inbox-channel preference + // (#15). Allowed values: "cms" | "bea" | "posteingang". Empty string + // clears the preference (NULL in the DB). nil = don't touch. + ForumPref *string `json:"forum_pref,omitempty"` } // UpdateProfile mutates the paliad.users row for the authenticated user. @@ -409,6 +414,21 @@ func (s *UserService) UpdateProfile(ctx context.Context, id uuid.UUID, input Upd args = append(args, val) i++ } + if input.ForumPref != nil { + raw := strings.TrimSpace(*input.ForumPref) + var val any + if raw == "" { + val = nil + } else { + if !isValidForumPref(raw) { + return nil, fmt.Errorf("invalid forum_pref %q (expected cms, bea, or posteingang)", raw) + } + val = raw + } + sets = append(sets, fmt.Sprintf("forum_pref = $%d", i)) + args = append(args, val) + i++ + } if len(sets) == 0 { // No-op PATCH is legal — just return the current row. @@ -832,6 +852,27 @@ func (s *UserService) AdminDeleteUser(ctx context.Context, id uuid.UUID) error { return nil } +// ForumPref* are the allowed values of paliad.users.forum_pref (#15). +// CMS = UPC; beA / Posteingang = national-DE (Posteingang is the slower +// channel for the same forums). Empty string clears the preference. +const ( + ForumPrefCMS = "cms" + ForumPrefBeA = "bea" + ForumPrefPosteingang = "posteingang" +) + +// isValidForumPref returns true when v is one of the allowed channel +// slugs. The DB CHECK constraint mirrors this list; we validate in the +// service layer so callers see a typed error instead of a raw pq +// constraint violation. +func isValidForumPref(v string) bool { + switch v { + case ForumPrefCMS, ForumPrefBeA, ForumPrefPosteingang: + return true + } + return false +} + // UnonboardedAuthUser is one row in auth.users that has no matching // paliad.users entry — i.e. the user logged in once via Supabase but never // completed onboarding. Surfaced to admins so they can bulk-add real