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:
2026-09-14 22:32:54 +02:00
parent b03462066e
commit dc4c0a5edd
20 changed files with 668 additions and 17 deletions
+1
View File
@@ -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
+11 -1
View File
@@ -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
+12
View File
@@ -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,
+5
View File
@@ -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:
@@ -0,0 +1,51 @@
"""Add leave_wish.name_message_key (nullable i18n key for preset names).
Bug: `WishEditor.tsx` created preset wishes (Sommar/Jullov/Höstlov/...) with
`name` set to the already-translated string (e.g. "Jul"), persisted as-is.
Switching the UI language afterwards could never fix a wish's name, because
the Swedish (or English) text was the data, not a rendering choice.
Fix: `name_message_key` stores the i18n key that produced `name` at creation
time (e.g. "wishEditor.seasonJul"), alongside the translated `name` so
anything reading `name` directly (backend, exports, ...) still gets a sane
value. The frontend re-translates via the key when present and falls back to
the literal `name` otherwise. Nullable and no default: every existing row
(and any row created by a free-typed custom name) has no key and must keep
showing its stored `name` unchanged — see `LeaveWishService.update`, which
also force-clears the key the moment a user edits `name`.
Revision ID: 007
Revises: 006
Create Date: 2026-09-14
"""
from __future__ import annotations
from alembic import op
revision: str = "007"
down_revision: str | None = "006"
branch_labels: str | None = None
depends_on: str | None = None
_UPGRADE_SQL = """
ALTER TABLE leave_wish ADD COLUMN name_message_key TEXT;
"""
_DOWNGRADE_SQL = """
ALTER TABLE leave_wish DROP COLUMN name_message_key;
"""
def upgrade() -> None:
from sqlalchemy import text
for stmt in [s.strip() for s in _UPGRADE_SQL.split(";") if s.strip()]:
op.execute(text(stmt))
def downgrade() -> None:
from sqlalchemy import text
for stmt in [s.strip() for s in _DOWNGRADE_SQL.split(";") if s.strip()]:
op.execute(text(stmt))
@@ -823,3 +823,81 @@ class TestWorkspaceParentsRenameAndRemove:
# The blocked placeholder and the caller are both still members.
assert placeholder_uuid in member_uuids
assert str(pd_uuid) in member_uuids
class TestLeaveWishNameMessageKeyRoundTrip:
"""`name_message_key` (added to fix the "Jul"/"Höstlov" always-Swedish
bug — see `backend/app/entities/leave_wish.py`) must actually appear on
the wire. The hand-written entity<->response mapping in this codebase
has silently dropped new fields twice before, so this asserts the
response *body* directly rather than only the service/repository layer.
All four scenarios share a single registered user/workspace (one
`_register` call) rather than one each: `/api/auth/register` is rate
limited to 10/minute (see `app/routers/auth.py`), and this file already
registers a user per test class — four more registrations pushed the
whole module over that limit when run together.
"""
async def test_name_message_key_round_trip(
self, client: AsyncClient, connection: aiosqlite.Connection
) -> None:
headers, ws_id, _ = await _register(client, "wish-key-roundtrip@example.com", "Alex")
# --- create returns the key it was given -----------------------
wish = await _create_wish(
client,
headers,
workspace_id=str(ws_id),
name="Jul",
name_message_key="wishEditor.seasonJul",
window_start_month=12,
window_start_day=1,
window_end_month=1,
window_end_day=31,
target_weeks=4,
recurrence="yearly",
first_year=date.today().year,
)
assert wish["name_message_key"] == "wishEditor.seasonJul"
assert wish["name"] == "Jul"
# --- a wish created without a key round-trips null, not a crash -
# (pre-existing rows, or a free-typed custom name, have no key.)
keyless_wish = await _create_wish(
client,
headers,
workspace_id=str(ws_id),
name="Custom trip",
window_start_month=6,
window_start_day=1,
window_end_month=6,
window_end_day=7,
target_weeks=1,
recurrence="once",
first_year=date.today().year,
)
assert keyless_wish["name_message_key"] is None
# --- updating an unrelated field preserves the key --------------
resp = await client.put(
f"/api/leave-wishes/{wish['id']}", headers=headers, json={"target_weeks": 5}
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["target_weeks"] == 5
assert body["name"] == "Jul"
assert body["name_message_key"] == "wishEditor.seasonJul"
# --- renaming clears the key even if the caller never mentions --
# it: the backend enforces this, not just the frontend. Getting
# this backwards would mean a user's custom name silently reverts
# to the old preset label the next time the UI translates it.
resp = await client.put(
f"/api/leave-wishes/{wish['id']}", headers=headers, json={"name": "Vinterresa"}
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["name"] == "Vinterresa"
assert body["name_message_key"] is None
@@ -133,6 +133,57 @@ class TestSQLiteLeaveWishRepository:
result = await leave_wish_repo.get_by_id(uuid4())
assert result is None
async def test_create_and_get_round_trip_name_message_key(
self,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
entity = _make(
workspace_id=seeded_ids.workspace_id,
name="Jul",
name_message_key="wishEditor.seasonJul",
)
created = await leave_wish_repo.create(entity)
assert created.name_message_key == "wishEditor.seasonJul"
fetched = await leave_wish_repo.get_by_id(created.id)
assert fetched is not None
assert fetched.name_message_key == "wishEditor.seasonJul"
async def test_create_without_name_message_key_defaults_to_none(
self,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
"""Existing-row simulation: a wish created before this field existed
(or a free-typed custom name) has no key and must read back `None`,
not a synthesized or empty-string value.
"""
entity = _make(workspace_id=seeded_ids.workspace_id, name="Custom trip")
created = await leave_wish_repo.create(entity)
assert created.name_message_key is None
fetched = await leave_wish_repo.get_by_id(created.id)
assert fetched is not None
assert fetched.name_message_key is None
async def test_update_round_trips_name_message_key(
self,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
entity = _make(workspace_id=seeded_ids.workspace_id, name="Jul", name_message_key=None)
created = await leave_wish_repo.create(entity)
created.name_message_key = "wishEditor.seasonJul"
updated = await leave_wish_repo.update(created.id, created)
assert updated.name_message_key == "wishEditor.seasonJul"
fetched = await leave_wish_repo.get_by_id(created.id)
assert fetched is not None
assert fetched.name_message_key == "wishEditor.seasonJul"
async def test_create_preserves_all_fields(
self,
leave_wish_repo: SQLiteLeaveWishRepository,
@@ -902,6 +953,105 @@ class TestLeaveWishService:
assert result.parent_mode == "either"
assert result.parent_personal_data_uuid is None
# -- name_message_key (fixes the "Jul"/"Höstlov" always-Swedish bug) ---
async def test_create_passes_through_name_message_key(
self,
leave_wish_service: LeaveWishService,
seeded_ids: SeededIds,
) -> None:
result = await leave_wish_service.create(
workspace_id=seeded_ids.workspace_id,
name="Jul",
name_message_key="wishEditor.seasonJul",
window_start_month=12,
window_start_day=1,
window_end_month=1,
window_end_day=31,
target_weeks=4,
first_year=2027,
)
assert result.name_message_key == "wishEditor.seasonJul"
async def test_update_without_changing_name_preserves_name_message_key(
self,
leave_wish_service: LeaveWishService,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
entity = _make(
workspace_id=seeded_ids.workspace_id,
name="Jul",
name_message_key="wishEditor.seasonJul",
)
created = await leave_wish_repo.create(entity)
result = await leave_wish_service.update(leave_wish_id=created.id, target_weeks=5)
assert result.name_message_key == "wishEditor.seasonJul"
assert result.name == "Jul"
async def test_update_that_resends_the_same_name_preserves_name_message_key(
self,
leave_wish_service: LeaveWishService,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
"""The auto-save flow resends the unchanged `name` on every edit —
only an *actual* change to the text may clear the key.
"""
entity = _make(
workspace_id=seeded_ids.workspace_id,
name="Jul",
name_message_key="wishEditor.seasonJul",
)
created = await leave_wish_repo.create(entity)
result = await leave_wish_service.update(leave_wish_id=created.id, name="Jul")
assert result.name_message_key == "wishEditor.seasonJul"
async def test_update_renaming_clears_name_message_key_even_without_being_told_to(
self,
leave_wish_service: LeaveWishService,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
"""Backend enforcement, not caller-trust: renaming clears the key
even if the caller never mentions `name_message_key` at all — this
is the critical guard against a user's custom name silently
reverting to a stale preset label.
"""
entity = _make(
workspace_id=seeded_ids.workspace_id,
name="Jul",
name_message_key="wishEditor.seasonJul",
)
created = await leave_wish_repo.create(entity)
result = await leave_wish_service.update(leave_wish_id=created.id, name="Vinterresa")
assert result.name == "Vinterresa"
assert result.name_message_key is None
async def test_update_can_explicitly_clear_name_message_key(
self,
leave_wish_service: LeaveWishService,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
entity = _make(
workspace_id=seeded_ids.workspace_id,
name="Jul",
name_message_key="wishEditor.seasonJul",
)
created = await leave_wish_repo.create(entity)
result = await leave_wish_service.update(
leave_wish_id=created.id, _clear_name_message_key=True
)
assert result.name_message_key is None
assert result.name == "Jul" # unrelated to the clear
async def test_update_missing_raises(
self,
leave_wish_service: LeaveWishService,