feat(users/forum-pref): persisted Fristenrechner inbox-channel column

Adds paliad.users.forum_pref so /tools/fristenrechner can pre-narrow
the proceeding picker to the user's typical inbox channel without
re-asking on every visit. The new column threads through the User
model, the userColumns SELECT, and UpdateProfileInput so the existing
PATCH /api/me handler accepts it without a new endpoint.

Allowed values mirror the channel chips m named in t-paliad-157:

  - cms          → UPC
  - bea          → national-DE
  - posteingang  → national-DE (slower channel, same forums)

NULL means "no preference, picker shows everything"; URL ?inbox=
overrides per-visit (frontend lands in the next commit). The CHECK
constraint enforces the 3-value enum at the DB layer; isValidForumPref
mirrors it in the service so callers see a typed error instead of a
raw pq violation. Empty string in the PATCH body clears the
preference, consistent with the EscalationContactID convention.

Migration 064 applied to the live Supabase pool; tracker bumped to
v64 so the boot-time runner skips re-applying.

Refs m/paliad#15.
This commit is contained in:
m
2026-05-08 16:23:12 +02:00
parent 7c751617e5
commit 06bd276a9c
4 changed files with 85 additions and 2 deletions

View File

@@ -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;

View File

@@ -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.';

View File

@@ -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

View File

@@ -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