MotirBuilding in public
MOTIR · moooon
onMotir
You’re viewing a public project. Anyone can view it — no account needed. Sign in to submit, upvote, or comment on requests.View-only — you can’t edit work items
MOTIR-376

6.8.1 Schema (project_key_alias + avatar columns) + the details/rename service core (locked atomic key-change tx, alias reservation, release) + admin-gated PATCH

Done
Description

Estimate: 33m

The data model and the whole write path — no UI.

Migration: project_key_alias (id, workspaceId, projectId, identifier, createdAt; @@unique([workspaceId, identifier]), indexed by projectId) modelled as a Prisma @relation on BOTH sides with onDelete: Cascade from Project (deletion frees the keys — the verified mirror rule; the FK-drift rule applies). Project.avatarIcon + Project.avatarColor (nullable Strings — null = the shipped mono-identifier rendering, so existing rows need no backfill).

Service (projectsService): updateDetails — name (trimmed, non-empty; slug NOT regenerated — recorded decision) + avatar (icon key validated against the preset registry; colour against the swatch set). changeKey — ONE $transaction: FOR-UPDATE lock on the project row (the lock-before-read-derived-update rule); normalizeIdentifier format guard (3–5 uppercase A–Z/0–9 — rung 2); no-op guard (new == current → typed error); collision guard against live identifiers AND other projects' aliases (workspace-scoped); reclaiming the OWN previous key deletes that alias row; a SINGLE bulk UPDATE work_item SET identifier = <new> || '-' || key WHERE "projectId" = … via a repository $queryRaw method (one statement, index-maintained, NO per-row loop and NO revision rows — identifier is derived data, the key number is untouched); insert the alias for the old key; update project.identifier. releaseAlias — admin-gated delete of one alias row (the Jira Cloud "Previous project keys" remove). Create-path guard: the create-project identifier suffix loop ALSO checks the alias table inside its tx (a new project must not take a reserved key).

Route: PATCH /api/projects/[key] accepting { name?, avatarIcon?, avatarColor?, identifier? } + DELETE /api/projects/[key]/aliases/[alias], both project-admin-gated (the 6.4.3 capability, the /api/projects/[key]/access PATCH pattern); typed errors → 400/403/409. DTO growth: avatarIcon/avatarColor + previousKeys on the project DTO (the details card + switcher consumers).

Acceptance criteria

  • Migration applies cleanly; re-run reports "No difference detected" (no FK drift); existing rows read back with null avatar.
  • changeKey is atomic: after PROD→NIF on the large seed, EVERY work_item identifier is NIF- with numbers preserved, the alias row exists, and a mid-tx failure (fault-injected) leaves NO partial state. The rewrite is one SQL statement (asserted via query log), bounded on the 10k-issue seed.
  • Collision matrix: live identifier of another project → 409; alias of another project → 409; own alias → reclaim (alias row swapped); format violations → 400; unchanged key → typed no-op error. Create-project can no longer mint a reserved key (suffix loop skips aliases).
  • Concurrency: a rename racing allocateWorkItemNumber-backed issue creation never produces a stale-prefix identifier (both interleavings asserted against the FOR-UPDATE lock).
  • Non-admin PATCH/DELETE → typed 403; releaseAlias deletes exactly one row; pnpm test:coverage ≥90% on the touched files.

Context refs

  • lib/services/projectsService.ts (normalizeIdentifier, the create suffix loop, allocateWorkItemNumber) + lib/repositories/projectRepository.ts (findByIdentifier)
  • prisma/schema.prismaProject (@@unique([workspaceId, identifier])), WorkItem.identifier (denormalized "PROD-42", @@unique([projectId, identifier]))
  • The 6.4.3 project-admin gate + the /api/projects/[key]/access route pattern
  • motir-core/CLAUDE.md (4-layer, required-tx, FK-as-@relation); the lock-before-read-derived-update rule; the verified mirror behaviour in the Story 6.8 description