From 2137d0649898644fc7395b20509b8c56614a5fc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sun, 23 Aug 2026 02:50:23 +0000 Subject: [PATCH] the folder name is served, not derived three times MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mobile asked for `/targets` to carry each person's display name as the folder will be named, so a picker can honestly say where files will appear. Right request: the writer already computes that exact string to create a directory, and any client computing its own would eventually disagree with it — which means telling somebody the wrong place to look. So `displayNameFor` is the one definition and the writer calls it too. `/targets` gains `displayName` per person, and `myFolder` for the whole response: files land in the RECIPIENT's tree under the SENDER's name, so that is one value rather than a field on each row. The two are easy to confuse and the distinction matters — a picker showing `displayName` as the destination folder would name the wrong one. The web panel now uses both instead of its own copy of the fallback. Co-Authored-By: Claude Opus 5 --- COMMS/sendoff/platform-2.md | 40 +++++++++++++++++++ src/servers/api/sendoff/paths.ts | 21 ++++++++++ src/servers/api/sendoff/router.ts | 18 +++++++-- .../src/apps/SendOff/SendOffPanel.tsx | 13 ++++-- .../officerdev/src/apps/SendOff/useSendOff.ts | 6 ++- 5 files changed, 90 insertions(+), 8 deletions(-) create mode 100644 COMMS/sendoff/platform-2.md diff --git a/COMMS/sendoff/platform-2.md b/COMMS/sendoff/platform-2.md new file mode 100644 index 00000000..bbddf000 --- /dev/null +++ b/COMMS/sendoff/platform-2.md @@ -0,0 +1,40 @@ +# SendOff — `/targets` now serves the folder name + +**From:** platform (officer-dev) · **Date:** 2026-08-23 · **Re:** `COMMS/sendoff/mobile.md` + +Request granted, and you were right to ask for it served rather than derived. Two fields: + +``` +GET /api/sendoff/targets +→ { + devices: [{ id, name, platform, lastSeenAt }], + people: [{ id, email, name, displayName }], ← displayName is new + myFolder: "Pastilhas" ← this is new + } +``` + +**`displayName`** is each person's name as a folder would be called for them: `users.name` if set, else the +local part of their email, else `user-`. Computed by `displayNameFor` in +`src/servers/api/sendoff/paths.ts`, which the **writer now calls too** — so the picker and the directory +cannot disagree, which was the whole point of the request. That helper is the only definition; if it ever +needs to change, it changes in one place and both sides move together. + +**`myFolder`** is the answer to the question a send picker actually asks. Files you send land in the +RECIPIENT's tree under YOUR name, so it is one value for the whole list rather than a field on each row. +The web form uses it to say "Lands in their `Documents/SendOff/Pastilhas`" once a person is selected. + +Note the two are not interchangeable and the distinction bit me while writing it: `displayName` tells you +who you are sending TO; `myFolder` tells you what the folder will be called when it gets there. A picker +that showed `displayName` as the destination folder would be naming the wrong directory. + +Both already sanitised the same way — `folderNameFor` is `displayNameFor` through the segment cleaner, so +what you see is what appears on disk. + +## Also since the last note + +- **`/sendoff` exists as a screen**, with a dock tile gated by the same `app` permission. Pick files, pick a + machine or a person, send. No received view, deliberately: the file browser already is the inbox. +- Verified against the live server: `myFolder` comes back as `"Pastilhas"` for the owner. + +Nothing here needs anything from mobile. Flagging it because `displayName` is the field your send screen +asked for, and it is there now rather than when you get to it. diff --git a/src/servers/api/sendoff/paths.ts b/src/servers/api/sendoff/paths.ts index 882dcf07..ecb177b9 100644 --- a/src/servers/api/sendoff/paths.ts +++ b/src/servers/api/sendoff/paths.ts @@ -49,3 +49,24 @@ export function safeFilename(name: string): string { .slice(0, 200); return cleaned || 'file'; } + +/** + * The name an account appears as, and therefore the name of the folder its files land in. + * + * One definition, used by the writer AND by `/targets`, because the two must agree: a picker that says + * "Fedra" while the writer creates `fedra.almeida` has told somebody the wrong place to look. Mobile asked + * for this to be served rather than derived, which is right — three clients reimplementing a fallback is + * how three clients disagree. + * + * `users.name` is nullable, so the fallback chain ends at the account id, which is always there. + */ +export function displayNameFor(account: { id: number; name: string | null; email: string }): string { + const chosen = account.name?.trim(); + if (chosen) return chosen; + const local = account.email.split('@')[0]?.trim(); + return local || `user-${account.id}`; +} + +/** What that name becomes on disk — the same value, through the segment sanitiser. */ +export const folderNameFor = (account: { id: number; name: string | null; email: string }): string => + safeSegment(displayNameFor(account)) || `user-${account.id}`; diff --git a/src/servers/api/sendoff/router.ts b/src/servers/api/sendoff/router.ts index 56239902..b29f046b 100644 --- a/src/servers/api/sendoff/router.ts +++ b/src/servers/api/sendoff/router.ts @@ -17,7 +17,7 @@ import { upsertSendoffDevice, } from 'officerdb'; import { getNotifyServerUrl } from '../notify/router'; -import { safeFilename, safeSegment, sendoffDirFor } from './paths'; +import { displayNameFor, folderNameFor, safeFilename, safeSegment, sendoffDirFor } from './paths'; /* SendOff — files land in a real directory in the recipient's own tree. @@ -140,7 +140,7 @@ sendoffRouter.post('/send', async (ctx) => { targetUserId = toUser; targetAccount = { email: recipient.email, isOwner: full?.id === 1 }; // From the TOKEN. This is the whole reason the folder name can be trusted. - folder = safeSegment(me.name || me.email.split('@')[0] || `user-${me.id}`); + folder = folderNameFor({ id: me.id, name: me.name ?? null, email: me.email }); } if (!folder) throw errors.BAD_REQUEST('that name has no usable characters'); @@ -172,7 +172,19 @@ sendoffRouter.get('/targets', async (ctx) => { listSendoffDevices(me.id), isOwnerRole(me.role) ? listOtherAccounts(me.id) : listMyRecipients(me.id), ]); - return ctx.json({ devices, people }); + /* + `displayName` is served rather than derived, at mobile's request and for the right reason: the writer + already computes this exact string to name a folder, and a picker that computes its own would sooner or + later disagree with it — which means telling somebody the wrong place to look. + + `myFolder` answers the question a picker actually wants: files I send land in the RECIPIENT's tree under + MY name, so it is one value for the whole list, not a field on each row. + */ + return ctx.json({ + devices, + people: people.map((p) => ({ ...p, displayName: displayNameFor(p) })), + myFolder: folderNameFor({ id: me.id, name: me.name ?? null, email: me.email }), + }); }); /** GET /sendoff/senders — who I have allowed to send to me. */ diff --git a/src/workspaces/officerdev/src/apps/SendOff/SendOffPanel.tsx b/src/workspaces/officerdev/src/apps/SendOff/SendOffPanel.tsx index ce9aab49..387def99 100644 --- a/src/workspaces/officerdev/src/apps/SendOff/SendOffPanel.tsx +++ b/src/workspaces/officerdev/src/apps/SendOff/SendOffPanel.tsx @@ -17,7 +17,7 @@ type Target = { kind: 'machine'; name: string } | { kind: 'user'; id: number; na * needed keeping in sync. */ export const SendOffPanel = () => { - const { devices, people, isLoading } = useSendOffTargets(); + const { devices, people, myFolder, isLoading } = useSendOffTargets(); const send = useSendOff(); const [files, setFiles] = useState([]); const [target, setTarget] = useState(null); @@ -107,10 +107,12 @@ export const SendOffPanel = () => { icon={User} heading="People" empty="Nobody has allowed you to send to them yet." + // `displayName` from the server, never computed here: it is the folder the writer will + // create, and a picker that guessed it would sooner or later name the wrong directory. options={people.map((p) => ({ key: `u:${p.id}`, - name: label(p), - target: { kind: 'user' as const, id: p.id, name: label(p) }, + name: p.displayName, + target: { kind: 'user' as const, id: p.id, name: p.displayName }, }))} selected={target} onSelect={setTarget} @@ -126,6 +128,11 @@ export const SendOffPanel = () => { {/* The server's own message, not a generic one: a file over the limit and a recipient who has revoked you are different problems with different answers. */} + {target?.kind === 'user' && myFolder && ( +

+ Lands in their Documents/SendOff/{myFolder}. +

+ )} {send.error &&

{send.error.message}

} {send.data && (

diff --git a/src/workspaces/officerdev/src/apps/SendOff/useSendOff.ts b/src/workspaces/officerdev/src/apps/SendOff/useSendOff.ts index bb909215..002049bc 100644 --- a/src/workspaces/officerdev/src/apps/SendOff/useSendOff.ts +++ b/src/workspaces/officerdev/src/apps/SendOff/useSendOff.ts @@ -3,7 +3,9 @@ import { useClient } from 'hooks/useClient'; export type SendOffDevice = { id: string; name: string; platform: string | null; lastSeenAt: string }; export type SendOffPerson = { id: number; email: string; name: string | null }; -type Targets = { devices: SendOffDevice[]; people: SendOffPerson[] }; +/** As served by `/targets` — `displayName` is the folder name, computed once on the server. */ +export type SendOffTargetPerson = SendOffPerson & { displayName: string }; +type Targets = { devices: SendOffDevice[]; people: SendOffTargetPerson[]; myFolder: string }; type Senders = { allowed: SendOffPerson[]; candidates: (SendOffPerson & { allowed: boolean })[] }; const TARGETS = ['sendoff', 'targets']; @@ -19,7 +21,7 @@ export function useSendOffTargets() { // two-row query — the panel is the only reader and it is open for seconds at a time. staleTime: 0, }); - return { devices: data?.devices ?? [], people: data?.people ?? [], isLoading, error }; + return { devices: data?.devices ?? [], people: data?.people ?? [], myFolder: data?.myFolder ?? '', isLoading, error }; } /** Who I have allowed to send to me — the consent list, which only I can change. */