Show preset wish names in the current UI language
Preset wishes were created with `name: t(preset.labelKey)`, so the translated string was persisted as the wish's name. A wish created while the UI was Swedish stayed "Jul" in English forever -- the Swedish text was the data, so no display-layer change could fix it. Wish names are user-editable free text, so `name` cannot simply be translated at render time. Migration 007 adds a nullable `leave_wish.name_message_key` recording where a name came from: preset wishes store the i18n key alongside the translated literal, and the UI renders the key when present. The key is cleared server-side the moment `name` actually changes, compared against the stored value rather than trusting the caller, so no client can forget to clear it and silently revert a user's own name to a preset label. An unknown key (a stale row, a renamed key) falls back to the stored literal via an isTranslationKey guard, so a bad key can never crash the page or leak a raw key to the user. Rows predating this column keep displaying their stored name. _FULL_SCHEMA in db/connection.py is updated in step with the migration. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
This commit is contained in:
@@ -81,6 +81,7 @@ CREATE TABLE IF NOT EXISTS leave_wish (
|
||||
workspace_id TEXT NOT NULL,
|
||||
child_id TEXT,
|
||||
name TEXT NOT NULL,
|
||||
name_message_key TEXT,
|
||||
window_start_month INTEGER NOT NULL DEFAULT 1
|
||||
CHECK (window_start_month >= 1 AND window_start_month <= 12),
|
||||
window_start_day INTEGER NOT NULL DEFAULT 1
|
||||
|
||||
@@ -24,7 +24,7 @@ class LeaveWish:
|
||||
id: UUID
|
||||
workspace_id: UUID
|
||||
child_id: UUID | None # None = family-wide; set = tied to that child's pools
|
||||
name: str # "Sommar", "Jullov"
|
||||
name: str # "Sommar", "Jullov" — persisted, possibly-translated display text
|
||||
|
||||
# Recurring anchor stored as month/day ints (SQLite-friendly, no custom type).
|
||||
window_start_month: int # 1-12
|
||||
@@ -55,6 +55,16 @@ class LeaveWish:
|
||||
# completely unmodified.
|
||||
window_mode: str = "fixed" # 'fixed' | 'flexible'
|
||||
|
||||
# Provenance of `name`: when a wish is created from a preset (Sommar,
|
||||
# Jullov, Höstlov, ...) this holds the i18n message key that produced the
|
||||
# translated string stored in `name` (e.g. "wishEditor.presetSommar").
|
||||
# Nullable — free-typed names, and every row created before this field
|
||||
# existed, have no key and just show `name` verbatim. Cleared by
|
||||
# `LeaveWishService.update` the moment the user edits `name`, since a
|
||||
# user-typed name is no longer a preset label and must never silently
|
||||
# revert to one.
|
||||
name_message_key: str | None = None
|
||||
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
@@ -18,6 +18,11 @@ class LeaveWishCreate(BaseModel):
|
||||
workspace_id: UUID
|
||||
child_id: UUID | None = None
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
# i18n message key when `name` came from a preset (e.g.
|
||||
# "wishEditor.presetSommar") — see app/entities/leave_wish.py. Stored
|
||||
# alongside the translated `name` so the frontend can re-translate it;
|
||||
# not validated against a key catalogue here (that's a frontend concern).
|
||||
name_message_key: str | None = Field(default=None, max_length=255)
|
||||
|
||||
window_start_month: int = Field(ge=1, le=12)
|
||||
window_start_day: int = Field(ge=1, le=31)
|
||||
@@ -46,6 +51,11 @@ class LeaveWishUpdate(BaseModel):
|
||||
|
||||
child_id: UUID | None = None
|
||||
name: str | None = Field(None, min_length=1, max_length=255)
|
||||
# Present + null in the payload means "clear it"; absent means "leave
|
||||
# unchanged" (see `model_fields_set` handling in the router). The service
|
||||
# also clears this automatically whenever `name` actually changes, so a
|
||||
# caller can never forget to and leave a stale key pointing at old text.
|
||||
name_message_key: str | None = Field(default=None, max_length=255)
|
||||
|
||||
window_start_month: int | None = Field(None, ge=1, le=12)
|
||||
window_start_day: int | None = Field(None, ge=1, le=31)
|
||||
@@ -71,6 +81,7 @@ class LeaveWishResponse(BaseModel):
|
||||
workspace_id: UUID
|
||||
child_id: UUID | None
|
||||
name: str
|
||||
name_message_key: str | None
|
||||
|
||||
window_start_month: int
|
||||
window_start_day: int
|
||||
@@ -100,6 +111,7 @@ class LeaveWishResponse(BaseModel):
|
||||
workspace_id=entity.workspace_id,
|
||||
child_id=entity.child_id,
|
||||
name=entity.name,
|
||||
name_message_key=entity.name_message_key,
|
||||
window_start_month=entity.window_start_month,
|
||||
window_start_day=entity.window_start_day,
|
||||
window_end_month=entity.window_end_month,
|
||||
|
||||
@@ -26,6 +26,7 @@ class SQLiteLeaveWishRepository(LeaveWishRepository):
|
||||
workspace_id=UUID(row["workspace_id"]),
|
||||
child_id=UUID(row["child_id"]) if row["child_id"] is not None else None,
|
||||
name=row["name"],
|
||||
name_message_key=row["name_message_key"],
|
||||
window_start_month=row["window_start_month"],
|
||||
window_start_day=row["window_start_day"],
|
||||
window_end_month=row["window_end_month"],
|
||||
@@ -54,18 +55,19 @@ class SQLiteLeaveWishRepository(LeaveWishRepository):
|
||||
await self._conn.execute(
|
||||
"""
|
||||
INSERT INTO leave_wish
|
||||
(id, workspace_id, child_id, name,
|
||||
(id, workspace_id, child_id, name, name_message_key,
|
||||
window_start_month, window_start_day, window_end_month, window_end_day,
|
||||
target_weeks, recurrence, first_year, last_year,
|
||||
priority, parent_mode, parent_personal_data_uuid, goal_override,
|
||||
window_mode, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
str(item.id),
|
||||
str(item.workspace_id),
|
||||
str(item.child_id) if item.child_id is not None else None,
|
||||
item.name,
|
||||
item.name_message_key,
|
||||
item.window_start_month,
|
||||
item.window_start_day,
|
||||
item.window_end_month,
|
||||
@@ -109,7 +111,7 @@ class SQLiteLeaveWishRepository(LeaveWishRepository):
|
||||
cursor = await self._conn.execute(
|
||||
"""
|
||||
UPDATE leave_wish
|
||||
SET child_id = ?, name = ?,
|
||||
SET child_id = ?, name = ?, name_message_key = ?,
|
||||
window_start_month = ?, window_start_day = ?,
|
||||
window_end_month = ?, window_end_day = ?,
|
||||
target_weeks = ?, recurrence = ?, first_year = ?, last_year = ?,
|
||||
@@ -120,6 +122,7 @@ class SQLiteLeaveWishRepository(LeaveWishRepository):
|
||||
(
|
||||
str(item.child_id) if item.child_id is not None else None,
|
||||
item.name,
|
||||
item.name_message_key,
|
||||
item.window_start_month,
|
||||
item.window_start_day,
|
||||
item.window_end_month,
|
||||
|
||||
@@ -29,6 +29,7 @@ async def create_leave_wish(
|
||||
workspace_id=payload.workspace_id,
|
||||
child_id=payload.child_id,
|
||||
name=payload.name,
|
||||
name_message_key=payload.name_message_key,
|
||||
window_start_month=payload.window_start_month,
|
||||
window_start_day=payload.window_start_day,
|
||||
window_end_month=payload.window_end_month,
|
||||
@@ -70,6 +71,7 @@ async def update_leave_wish(
|
||||
leave_wish_id=leave_wish_id,
|
||||
child_id=payload.child_id,
|
||||
name=payload.name,
|
||||
name_message_key=payload.name_message_key,
|
||||
window_start_month=payload.window_start_month,
|
||||
window_start_day=payload.window_start_day,
|
||||
window_end_month=payload.window_end_month,
|
||||
@@ -90,6 +92,9 @@ async def update_leave_wish(
|
||||
and payload.parent_personal_data_uuid is None
|
||||
),
|
||||
_clear_goal_override=("goal_override" in fields_set and payload.goal_override is None),
|
||||
_clear_name_message_key=(
|
||||
"name_message_key" in fields_set and payload.name_message_key is None
|
||||
),
|
||||
)
|
||||
except ValidationError as exc:
|
||||
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, str(exc)) from exc
|
||||
|
||||
@@ -37,6 +37,7 @@ class LeaveWishService:
|
||||
target_weeks: int,
|
||||
first_year: int,
|
||||
child_id: UUID | None = None,
|
||||
name_message_key: str | None = None,
|
||||
recurrence: str = "once",
|
||||
last_year: int | None = None,
|
||||
priority: int = 1,
|
||||
@@ -65,6 +66,7 @@ class LeaveWishService:
|
||||
workspace_id=workspace_id,
|
||||
child_id=child_id,
|
||||
name=name,
|
||||
name_message_key=name_message_key,
|
||||
window_start_month=window_start_month,
|
||||
window_start_day=window_start_day,
|
||||
window_end_month=window_end_month,
|
||||
@@ -97,6 +99,7 @@ class LeaveWishService:
|
||||
leave_wish_id: UUID,
|
||||
child_id: UUID | None = None,
|
||||
name: str | None = None,
|
||||
name_message_key: str | None = None,
|
||||
window_start_month: int | None = None,
|
||||
window_start_day: int | None = None,
|
||||
window_end_month: int | None = None,
|
||||
@@ -114,21 +117,35 @@ class LeaveWishService:
|
||||
_clear_last_year: bool = False,
|
||||
_clear_parent_personal_data_uuid: bool = False,
|
||||
_clear_goal_override: bool = False,
|
||||
_clear_name_message_key: bool = False,
|
||||
) -> LeaveWish:
|
||||
"""Partial update — only non-None fields are applied.
|
||||
|
||||
`_clear_*` flags exist because a nullable field's "no change" and
|
||||
"set to None" both look like `None` on the wire; the router passes
|
||||
them based on whether the field was present in the request payload.
|
||||
|
||||
`name_message_key` is additionally enforced here rather than left to
|
||||
callers: whenever `name` actually changes, the key is force-cleared
|
||||
regardless of what was passed for it, since a caller (frontend bug,
|
||||
future API client, ...) forgetting to clear it would silently make a
|
||||
user's edited name revert to a stale preset label.
|
||||
"""
|
||||
entity = await self.get(leave_wish_id)
|
||||
original_name = entity.name
|
||||
|
||||
if _clear_child_id:
|
||||
entity.child_id = None
|
||||
elif child_id is not None:
|
||||
entity.child_id = child_id
|
||||
if _clear_name_message_key:
|
||||
entity.name_message_key = None
|
||||
elif name_message_key is not None:
|
||||
entity.name_message_key = name_message_key
|
||||
if name is not None:
|
||||
entity.name = name
|
||||
if name != original_name:
|
||||
entity.name_message_key = None
|
||||
if window_start_month is not None:
|
||||
entity.window_start_month = window_start_month
|
||||
if window_start_day is not None:
|
||||
|
||||
Reference in New Issue
Block a user