[refactor] the customer owns the recording, not the folder - #212
Merged
Conversation
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>
|
This was referenced Aug 9, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



What this changes
A recording's place in the app is now decided by its customer.
folderIdstops 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
companyIdandfolderIdboth claimed to answer "whose call is this", and different screens read different ones — the sidebar counted folders, the company page filteredcompanyId. Five entry points wrote only one of the two, so they disagreed the moment you used any of them:persistEntryLinkwrote the company and left the folder alone.)#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.tsnow owns membership, and everything reads it: the sidebar counts, the library grid, the company page's meeting list, pre-flight's "第 N 次".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
unassignedrather than dropping out of the tree, and a test asserts every recording is counted exactly once across all nodes.One write, both fields
assignEntryCompanyis now the only way a saved recording changes hands, and it movescompanyIdandfolderIdtogether. Every affordance routes through it: the library card menu, the titlebar chip, the MCPmove_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 MCPimport_transcript(afoldername 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:
Comboboxgrows 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:
migrateEntryOwnersruns once at startup (right after the company↔folder pairing, which it depends on) and writescompanyIdonto 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.list_history, and a localStorage flag would be per-webview-origin — the exact trap documented at the top ofhistory/folders.ts.pushLocalEntryNowre-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.m.companyId === idmatch, so they're correct before the backfill runs and for cloud-only cards whose summary carries no link.Not affected:
librarySelectionisn't persisted (onlysettings+cloudAuthare),folders.jsonis untouched,Company.folderIdis untouched. Downgrade is safe — new writes always set both fields, and old code readsfolderId.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'scompanyIdpoints 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:CloudRecordingSummaryhas nocompanyId, so cloud-only cards rely on the folder fallback until opened.How it was verified
bunx tsc --noEmitpassesbunx vitest runpasses (317 tests, +7 for the ownership rule and the backfill)zh-TWandenNew 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:
Comboboxclosed from its own handlers withsetOpen(false), which skips Radix'sonOpenChange— 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.