refactor: onboarding form — drop Praxisgruppe, free-text role, add Dezernat (t-paliad-020)
- Drop the Praxisgruppe field from the onboarding form. Every Paliad user is in patent practice, so the field carried no signal. The DB column is retained for future use (set to NULL on insert). - Switch role from a 4-value enum (partner/associate/pa/admin) to free text with a <datalist> of suggestions (Partner, Associate, PA, Of Counsel, Referendar/in, Trainee, wiss. Mitarbeiter/in, Sekretariat). German firms have many roles beyond the original four. - Add an optional Dezernat field — the team led by a specific partner. Free text, no FK (the partner may not be registered yet). Backend: - Migration 015: drop the role enum CHECK, replace with non-empty CHECK; ADD COLUMN dezernat text. - UserService.Create: drop validRoles map, require non-empty role string, trim and persist Dezernat. Admin bootstrap gate unchanged. - models.User gains Dezernat *string; userColumns SELECT updated so /api/me returns it. Frontend: - onboarding.tsx: replace role <select> with <input list=...>; add dezernat input; remove practice_group. - onboarding.ts: send dezernat (if non-empty), require role. - i18n: add onboarding.role.placeholder, onboarding.dezernat[.placeholder], onboarding.error.role; remove the role.* enum and practice_group keys.
This commit is contained in:
@@ -671,16 +671,14 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"onboarding.office": "B\u00fcro",
|
||||
"onboarding.office.placeholder": "Bitte ausw\u00e4hlen",
|
||||
"onboarding.role": "Rolle",
|
||||
"onboarding.role.associate": "Associate",
|
||||
"onboarding.role.partner": "Partner",
|
||||
"onboarding.role.pa": "PA",
|
||||
"onboarding.role.admin": "Admin",
|
||||
"onboarding.practice_group": "Praxisgruppe",
|
||||
"onboarding.practice_group.placeholder": "z.B. Patent Litigation",
|
||||
"onboarding.role.placeholder": "z.B. Associate, Partner, PA",
|
||||
"onboarding.dezernat": "Dezernat / Partner",
|
||||
"onboarding.dezernat.placeholder": "z.B. Dr. M\u00fcller, Team Schmidt",
|
||||
"onboarding.optional": "(optional)",
|
||||
"onboarding.submit": "Profil anlegen",
|
||||
"onboarding.error.display_name": "Bitte Anzeigename eingeben.",
|
||||
"onboarding.error.office": "Bitte B\u00fcro ausw\u00e4hlen.",
|
||||
"onboarding.error.role": "Bitte Rolle eingeben.",
|
||||
"onboarding.error.generic": "Profil konnte nicht angelegt werden.",
|
||||
"onboarding.error.connection": "Verbindungsfehler. Bitte versuchen Sie es erneut.",
|
||||
|
||||
@@ -1471,16 +1469,14 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"onboarding.office": "Office",
|
||||
"onboarding.office.placeholder": "Please select",
|
||||
"onboarding.role": "Role",
|
||||
"onboarding.role.associate": "Associate",
|
||||
"onboarding.role.partner": "Partner",
|
||||
"onboarding.role.pa": "PA",
|
||||
"onboarding.role.admin": "Admin",
|
||||
"onboarding.practice_group": "Practice group",
|
||||
"onboarding.practice_group.placeholder": "e.g. Patent Litigation",
|
||||
"onboarding.role.placeholder": "e.g. Associate, Partner, PA",
|
||||
"onboarding.dezernat": "Department / Partner",
|
||||
"onboarding.dezernat.placeholder": "e.g. Dr. M\u00fcller, Team Schmidt",
|
||||
"onboarding.optional": "(optional)",
|
||||
"onboarding.submit": "Create profile",
|
||||
"onboarding.error.display_name": "Please enter a display name.",
|
||||
"onboarding.error.office": "Please select an office.",
|
||||
"onboarding.error.role": "Please enter a role.",
|
||||
"onboarding.error.generic": "Could not create profile.",
|
||||
"onboarding.error.connection": "Connection error. Please try again.",
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ async function submitForm(e: Event): Promise<void> {
|
||||
const displayName = (data.get("display_name") as string || "").trim();
|
||||
const office = (data.get("office") as string || "").trim();
|
||||
const role = (data.get("role") as string || "").trim();
|
||||
const practiceGroup = (data.get("practice_group") as string || "").trim();
|
||||
const dezernat = (data.get("dezernat") as string || "").trim();
|
||||
|
||||
if (!displayName) {
|
||||
showMessage(t("onboarding.error.display_name"), "login-error");
|
||||
@@ -96,13 +96,17 @@ async function submitForm(e: Event): Promise<void> {
|
||||
showMessage(t("onboarding.error.office"), "login-error");
|
||||
return;
|
||||
}
|
||||
if (!role) {
|
||||
showMessage(t("onboarding.error.role"), "login-error");
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
display_name: displayName,
|
||||
office,
|
||||
role,
|
||||
};
|
||||
if (practiceGroup) payload.practice_group = practiceGroup;
|
||||
if (dezernat) payload.dezernat = dezernat;
|
||||
|
||||
submitBtn.disabled = true;
|
||||
try {
|
||||
|
||||
@@ -41,24 +41,39 @@ export function renderOnboarding(): string {
|
||||
</select>
|
||||
|
||||
<label htmlFor="onb-role" className="login-label" data-i18n="onboarding.role">Rolle</label>
|
||||
<select id="onb-role" name="role" required className="login-input">
|
||||
<option value="associate" data-i18n="onboarding.role.associate">Associate</option>
|
||||
<option value="partner" data-i18n="onboarding.role.partner">Partner</option>
|
||||
<option value="pa" data-i18n="onboarding.role.pa">PA</option>
|
||||
<option value="admin" data-i18n="onboarding.role.admin">Admin</option>
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
id="onb-role"
|
||||
name="role"
|
||||
list="onb-role-suggestions"
|
||||
required
|
||||
autocomplete="off"
|
||||
className="login-input"
|
||||
data-i18n-placeholder="onboarding.role.placeholder"
|
||||
placeholder="z.B. Associate, Partner, PA"
|
||||
/>
|
||||
<datalist id="onb-role-suggestions">
|
||||
<option value="Partner"></option>
|
||||
<option value="Associate"></option>
|
||||
<option value="PA"></option>
|
||||
<option value="Of Counsel"></option>
|
||||
<option value="Referendar/in"></option>
|
||||
<option value="Trainee"></option>
|
||||
<option value="wiss. Mitarbeiter/in"></option>
|
||||
<option value="Sekretariat"></option>
|
||||
</datalist>
|
||||
|
||||
<label htmlFor="onb-practice-group" className="login-label" data-i18n="onboarding.practice_group">
|
||||
Praxisgruppe <span className="login-label-optional" data-i18n="onboarding.optional">(optional)</span>
|
||||
<label htmlFor="onb-dezernat" className="login-label" data-i18n="onboarding.dezernat">
|
||||
Dezernat / Partner <span className="login-label-optional" data-i18n="onboarding.optional">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="onb-practice-group"
|
||||
name="practice_group"
|
||||
id="onb-dezernat"
|
||||
name="dezernat"
|
||||
autocomplete="off"
|
||||
className="login-input"
|
||||
data-i18n-placeholder="onboarding.practice_group.placeholder"
|
||||
placeholder="z.B. Patent Litigation"
|
||||
data-i18n-placeholder="onboarding.dezernat.placeholder"
|
||||
placeholder="z.B. Dr. Müller, Team Schmidt"
|
||||
/>
|
||||
|
||||
<button type="submit" className="login-button" data-i18n="onboarding.submit">Profil anlegen</button>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
-- Reverse Phase L. Best-effort: any rows with role values outside the
|
||||
-- original enum will fail the restored CHECK constraint and the migration
|
||||
-- will abort — that is intentional, since blindly losing data is worse.
|
||||
|
||||
ALTER TABLE paliad.users
|
||||
DROP COLUMN IF EXISTS dezernat;
|
||||
|
||||
ALTER TABLE paliad.users
|
||||
DROP CONSTRAINT IF EXISTS users_role_check;
|
||||
|
||||
ALTER TABLE paliad.users
|
||||
ADD CONSTRAINT users_role_check CHECK (role IN (
|
||||
'partner', 'associate', 'pa', 'admin'
|
||||
));
|
||||
@@ -0,0 +1,20 @@
|
||||
-- Phase L: open up paliad.users.role from a fixed enum to free-text and add
|
||||
-- the dezernat (partner team) column.
|
||||
--
|
||||
-- Rationale (m, 2026-04-18): German law firms have many roles beyond the
|
||||
-- four originally enumerated (Of Counsel, Referendar/in, Trainee, wiss.
|
||||
-- Mitarbeiter/in, Sekretariat, ...). The CHECK constraint is replaced by a
|
||||
-- non-empty guard; the bootstrap admin gate stays in the service layer.
|
||||
--
|
||||
-- "Dezernat" is the team led by a specific partner. It is intentionally
|
||||
-- free text — we cannot FK to paliad.users because the partner may not
|
||||
-- have registered yet.
|
||||
|
||||
ALTER TABLE paliad.users
|
||||
DROP CONSTRAINT IF EXISTS users_role_check;
|
||||
|
||||
ALTER TABLE paliad.users
|
||||
ADD CONSTRAINT users_role_check CHECK (role <> '');
|
||||
|
||||
ALTER TABLE paliad.users
|
||||
ADD COLUMN IF NOT EXISTS dezernat text;
|
||||
@@ -60,7 +60,7 @@ func handleCreateOnboarding(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
case errors.Is(err, services.ErrAdminBootstrapOnly):
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{
|
||||
"error": "admin role cannot be self-assigned — choose associate, partner, or pa",
|
||||
"error": "admin role cannot be self-assigned — please choose another role",
|
||||
})
|
||||
default:
|
||||
// Validation errors from the service (bad office, bad role, empty
|
||||
|
||||
@@ -20,6 +20,7 @@ type User struct {
|
||||
Office string `db:"office" json:"office"`
|
||||
PracticeGroup *string `db:"practice_group" json:"practice_group,omitempty"`
|
||||
Role string `db:"role" json:"role"`
|
||||
Dezernat *string `db:"dezernat" json:"dezernat,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
@@ -25,15 +25,6 @@ var (
|
||||
ErrAdminBootstrapOnly = errors.New("admin role reserved for the first user")
|
||||
)
|
||||
|
||||
// Valid role values — mirrors the CHECK constraint on paliad.users.role
|
||||
// (migration 002). Keep in sync.
|
||||
var validRoles = map[string]bool{
|
||||
"partner": true,
|
||||
"associate": true,
|
||||
"pa": true,
|
||||
"admin": true,
|
||||
}
|
||||
|
||||
// UserService reads paliad.users. Writes happen via the Phase D onboarding
|
||||
// endpoint and are not exposed here yet.
|
||||
type UserService struct {
|
||||
@@ -45,7 +36,7 @@ func NewUserService(db *sqlx.DB) *UserService {
|
||||
return &UserService{db: db}
|
||||
}
|
||||
|
||||
const userColumns = `id, email, display_name, office, practice_group, role,
|
||||
const userColumns = `id, email, display_name, office, practice_group, role, dezernat,
|
||||
created_at, updated_at`
|
||||
|
||||
// GetByID returns the user row, or (nil, nil) if the user hasn't completed
|
||||
@@ -65,10 +56,10 @@ func (s *UserService) GetByID(ctx context.Context, id uuid.UUID) (*models.User,
|
||||
|
||||
// CreateUserInput is the payload for the onboarding flow (POST /api/onboarding).
|
||||
type CreateUserInput struct {
|
||||
DisplayName string `json:"display_name"`
|
||||
Office string `json:"office"`
|
||||
Role string `json:"role"`
|
||||
PracticeGroup *string `json:"practice_group,omitempty"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Office string `json:"office"`
|
||||
Role string `json:"role"`
|
||||
Dezernat *string `json:"dezernat,omitempty"`
|
||||
}
|
||||
|
||||
// Create inserts the paliad.users row for the authenticated user. The caller
|
||||
@@ -76,11 +67,11 @@ type CreateUserInput struct {
|
||||
// from the request body, which prevents a user from creating a row for a
|
||||
// different auth.uid().
|
||||
//
|
||||
// Role validation:
|
||||
// - Must be one of partner/associate/pa/admin (CHECK constraint backup).
|
||||
// - 'admin' is reserved: only allowed when the paliad.users table is empty
|
||||
// (bootstrap admin). Subsequent users who ask for 'admin' are rejected —
|
||||
// an existing admin must promote them via SQL / future admin UI.
|
||||
// Role is free-form text (German firms have many titles beyond the original
|
||||
// four-value enum). The DB CHECK only requires non-empty. The one exception
|
||||
// is 'admin', which is reserved: only allowed when the paliad.users table is
|
||||
// empty (bootstrap admin). Subsequent users who ask for 'admin' are rejected
|
||||
// — an existing admin must promote them via SQL / future admin UI.
|
||||
//
|
||||
// Returns ErrUserAlreadyOnboarded if the row exists (callers map to 409).
|
||||
func (s *UserService) Create(ctx context.Context, id uuid.UUID, email string, input CreateUserInput) (*models.User, error) {
|
||||
@@ -91,19 +82,16 @@ func (s *UserService) Create(ctx context.Context, id uuid.UUID, email string, in
|
||||
if !offices.IsValid(input.Office) {
|
||||
return nil, fmt.Errorf("invalid office %q", input.Office)
|
||||
}
|
||||
role := input.Role
|
||||
role := strings.TrimSpace(input.Role)
|
||||
if role == "" {
|
||||
role = "associate"
|
||||
}
|
||||
if !validRoles[role] {
|
||||
return nil, fmt.Errorf("invalid role %q", role)
|
||||
return nil, fmt.Errorf("role is required")
|
||||
}
|
||||
|
||||
var practiceGroup *string
|
||||
if input.PracticeGroup != nil {
|
||||
trimmed := strings.TrimSpace(*input.PracticeGroup)
|
||||
var dezernat *string
|
||||
if input.Dezernat != nil {
|
||||
trimmed := strings.TrimSpace(*input.Dezernat)
|
||||
if trimmed != "" {
|
||||
practiceGroup = &trimmed
|
||||
dezernat = &trimmed
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,10 +134,13 @@ func (s *UserService) Create(ctx context.Context, id uuid.UUID, email string, in
|
||||
}
|
||||
}
|
||||
|
||||
// practice_group is intentionally left NULL — the column is retained for
|
||||
// future use but no longer collected at onboarding (m, 2026-04-18: every
|
||||
// Paliad user is in patent practice, so the field carried no signal).
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO paliad.users (id, email, display_name, office, practice_group, role)
|
||||
`INSERT INTO paliad.users (id, email, display_name, office, role, dezernat)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
id, email, displayName, input.Office, practiceGroup, role,
|
||||
id, email, displayName, input.Office, role, dezernat,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("insert user: %w", err)
|
||||
}
|
||||
|
||||
@@ -63,10 +63,12 @@ func TestUserService_Create_Valid(t *testing.T) {
|
||||
seedAuthUser(t, pool, id, "first@hlc.com")
|
||||
defer cleanupUsers(t, pool, id)
|
||||
|
||||
dezernat := " Team M\u00fcller "
|
||||
u, err := users.Create(context.Background(), id, "first@hlc.com", CreateUserInput{
|
||||
DisplayName: " First User ",
|
||||
Office: "munich",
|
||||
Role: "associate",
|
||||
Role: "Trainee",
|
||||
Dezernat: &dezernat,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
@@ -77,9 +79,12 @@ func TestUserService_Create_Valid(t *testing.T) {
|
||||
if u.DisplayName != "First User" {
|
||||
t.Errorf("display_name not trimmed: %q", u.DisplayName)
|
||||
}
|
||||
if u.Office != "munich" || u.Role != "associate" || u.Email != "first@hlc.com" {
|
||||
if u.Office != "munich" || u.Role != "Trainee" || u.Email != "first@hlc.com" {
|
||||
t.Errorf("field mismatch: %+v", u)
|
||||
}
|
||||
if u.Dezernat == nil || *u.Dezernat != "Team M\u00fcller" {
|
||||
t.Errorf("dezernat not trimmed/persisted: %+v", u.Dezernat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserService_Create_InvalidInput(t *testing.T) {
|
||||
@@ -96,7 +101,7 @@ func TestUserService_Create_InvalidInput(t *testing.T) {
|
||||
}{
|
||||
{"missing display_name", CreateUserInput{Office: "munich", Role: "associate"}},
|
||||
{"invalid office", CreateUserInput{DisplayName: "X", Office: "tokyo", Role: "associate"}},
|
||||
{"invalid role", CreateUserInput{DisplayName: "X", Office: "munich", Role: "wizard"}},
|
||||
{"missing role", CreateUserInput{DisplayName: "X", Office: "munich", Role: " "}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user