Skip to content

[refactor] the customer owns the recording, not the folder - #212

Merged
Lanznx merged 1 commit into
mainfrom
refactor/customer-owns-recording
Aug 8, 2026
Merged

[refactor] the customer owns the recording, not the folder#212
Lanznx merged 1 commit into
mainfrom
refactor/customer-owns-recording

Conversation

@Lanznx

@Lanznx Lanznx commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

What this changes

A recording's place in the app is now decided by its customer. folderId stops being a user-facing concept and becomes the file's location on disk (and what the cloud folder mirror syncs). Folders can no longer be created; one exists only because a company owns it.

Closes #211

Why

companyId and folderId both claimed to answer "whose call is this", and different screens read different ones — the sidebar counted folders, the company page filtered companyId. Five entry points wrote only one of the two, so they disagreed the moment you used any of them:

  • Link a recording to 和運租車 from the study report → it never appears under 和運租車 in the tree. It stays in 未歸戶. (persistEntryLink wrote the company and left the folder alone.)
  • Drag a card into 和運租車's folder → the tree counts it, but 和運租車's own page doesn't list it and the post-meeting review can't file its intel.

#195 put the company and its recordings in one tree, which fixed how this looks. It didn't fix the split underneath.

The one rule

lib/library/scope.ts now owns membership, and everything reads it: the sidebar counts, the library grid, the company page's meeting list, pre-flight's "第 N 次".

companyId → that customer's node
  ↓ (no link, or the company is gone)
folderId owned by a company → that customer's node   ← legacy fallback
  ↓
folderId owned by nobody → that folder's node        ← pre-#211 filing
  ↓
unassigned

The fallback is what keeps recordings saved before links existed from appearing to lose their customer. It is total by construction — anything matching nothing lands on unassigned rather than dropping out of the tree, and a test asserts every recording is counted exactly once across all nodes.

One write, both fields

assignEntryCompany is now the only way a saved recording changes hands, and it moves companyId and folderId together. Every affordance routes through it: the library card menu, the titlebar chip, the MCP move_recording_to_folder (which now recognises that moving into a company's folder is a change of customer, not a filing tweak).

The titlebar's folder chip is gone. In its place is a customer chip that opens the same picker the study report's link bar opens, so one screen has one control deciding ownership.

Asking on the way in

Every import door now asks who the recording belongs to — audio (ingest wizard), transcripts (import dialog, whose folder <Select> is replaced by the customer picker), and MCP import_transcript (a folder name that names a customer now resolves to that customer). Importing while a customer's node is open pre-fills them.

A customer that doesn't exist yet is created from the same keystrokes: Combobox grows a create row, so typing a new name gives you + 建立「和運租車」 instead of a dead-end 找不到符合項目. Shared by the customer and thread pickers.

Migration

Existing installs need no action, but two things were required to make the upgrade seamless:

  1. migrateEntryOwners runs once at startup (right after the company↔folder pairing, which it depends on) and writes companyId onto recordings that only the fallback can place. The read fallback already puts them in the right node — but anything reading the record directly (the cloud summary row, an MCP client, the post-meeting review) would go on seeing an unowned recording.
    • Idempotent and deliberately unflagged. With no candidates it costs one list_history, and a localStorage flag would be per-webview-origin — the exact trap documented at the top of history/folders.ts.
    • Deliberately not pushed to the cloud. pushLocalEntryNow re-uploads the entry's audio blob, so a library with hundreds of old recordings would re-upload all of them at launch. The field buys the cloud nothing today (see below); the next real edit carries it up.
  2. The company page and pre-flight's "第 N 次" now use the shared rule instead of a raw m.companyId === id match, so they're correct before the backfill runs and for cloud-only cards whose summary carries no link.

Not affected: librarySelection isn't persisted (only settings + cloudAuth are), folders.json is untouched, Company.folderId is untouched. Downgrade is safe — new writes always set both fields, and old code reads folderId.

Known limitation, tracked separately

accounts.json (companies, threads, people) doesn't sync to the cloud, while folders do. On a second device a synced recording's companyId points at a company that isn't there, so it falls back to the folder and shows under 其他資料夾(舊資料)with the company's name. Pre-existing, but more visible now that folders are demoted. Also: CloudRecordingSummary has no companyId, so cloud-only cards rely on the folder fallback until opened.

How it was verified

  • bunx tsc --noEmit passes
  • bunx vitest run passes (317 tests, +7 for the ownership rule and the backfill)
  • Ran the app and exercised the change — created a customer by typing a new name, confirmed 存到 followed it, confirmed the tree shows 客戶 → 還沒歸戶 → 語音輸入 with no folder-creation entry point left
  • Added or updated tests
  • Added new user-facing strings to both zh-TW and en

New tests cover the regression directly ({ companyId: "acme", folderId: null } must land on acme), that the backfill changes what's written down without changing where anything lands, and that a second pass finds nothing.

Also fixed

Found while clicking through this: Combobox closed from its own handlers with setOpen(false), which skips Radix's onOpenChange — so the search text survived the close and the next open appended to it. Typing two customer names in a row produced "和運租車裕隆汽車". Every pick now goes through one clearing close.

Screenshots

Not attached — the UI changes are structural (tree sections, menu contents, one new picker) and described above. Happy to add them if useful.

A recording carried two fields that both answered "whose call is this":
companyId and folderId. The sidebar counted folders; the company page
filtered companyId. Five entry points wrote only one of the two, so the
two halves disagreed the moment any of them was used — link a recording
to a customer from the study report and the customer's own tree node
could never count it.

The customer is now the only answer. folderId becomes the file's place
on disk (and what the cloud folder mirror syncs), never a second claim:

- One ownership rule (lib/library/scope) behind the tree counts, the
  grid, the company page and pre-flight's "第 N 次" alike. folderId
  survives as a read fallback so recordings filed under a company before
  links existed don't appear to lose their customer.
- assignEntryCompany is the one write that changes hands; companyId and
  folderId can no longer move apart. persistEntryLink files too.
- Folders stop being creatable — one exists only because a company owns
  it. Pre-#211 company-less folders stay reachable and renameable.
- The titlebar folder chip becomes a customer chip opening the same
  picker the report page opens; the card menu assigns a customer.
- Every import door (audio, transcript, MCP) asks who it belongs to, and
  a customer that doesn't exist yet is created from the same keystrokes
  via the combobox's new create row.
- A one-shot startup backfill writes companyId onto recordings only the
  fallback can place, so anything reading the record directly sees the
  owner. Idempotent, unflagged, and deliberately not pushed to the cloud
  (a push re-uploads the audio blob, and companies don't sync anyway).

Also fixes a combobox bug found while exercising this: closing from our
own handlers skipped Radix's onOpenChange, so the search text survived
and the next open appended to it.

Closes #211

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Lanznx
Lanznx merged commit ab33f46 into main Aug 8, 2026
@Lanznx
Lanznx deleted the refactor/customer-owns-recording branch August 8, 2026 17:48
@sonarqubecloud

sonarqubecloud Bot commented Aug 8, 2026

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[refactor] the customer owns the recording — one answer to "whose call is this", not two

1 participant