the folder name is served, not derived three times

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 02:50:23 +00:00
co-authored by Claude Opus 5
parent ca7d3c657b
commit 2137d06498
5 changed files with 90 additions and 8 deletions
+40
View File
@@ -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-<id>`. 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.
+21
View File
@@ -49,3 +49,24 @@ export function safeFilename(name: string): string {
.slice(0, 200); .slice(0, 200);
return cleaned || 'file'; 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}`;
+15 -3
View File
@@ -17,7 +17,7 @@ import {
upsertSendoffDevice, upsertSendoffDevice,
} from 'officerdb'; } from 'officerdb';
import { getNotifyServerUrl } from '../notify/router'; 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. SendOff — files land in a real directory in the recipient's own tree.
@@ -140,7 +140,7 @@ sendoffRouter.post('/send', async (ctx) => {
targetUserId = toUser; targetUserId = toUser;
targetAccount = { email: recipient.email, isOwner: full?.id === 1 }; targetAccount = { email: recipient.email, isOwner: full?.id === 1 };
// From the TOKEN. This is the whole reason the folder name can be trusted. // 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'); if (!folder) throw errors.BAD_REQUEST('that name has no usable characters');
@@ -172,7 +172,19 @@ sendoffRouter.get('/targets', async (ctx) => {
listSendoffDevices(me.id), listSendoffDevices(me.id),
isOwnerRole(me.role) ? listOtherAccounts(me.id) : listMyRecipients(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. */ /** GET /sendoff/senders — who I have allowed to send to me. */
@@ -17,7 +17,7 @@ type Target = { kind: 'machine'; name: string } | { kind: 'user'; id: number; na
* needed keeping in sync. * needed keeping in sync.
*/ */
export const SendOffPanel = () => { export const SendOffPanel = () => {
const { devices, people, isLoading } = useSendOffTargets(); const { devices, people, myFolder, isLoading } = useSendOffTargets();
const send = useSendOff(); const send = useSendOff();
const [files, setFiles] = useState<File[]>([]); const [files, setFiles] = useState<File[]>([]);
const [target, setTarget] = useState<Target | null>(null); const [target, setTarget] = useState<Target | null>(null);
@@ -107,10 +107,12 @@ export const SendOffPanel = () => {
icon={User} icon={User}
heading="People" heading="People"
empty="Nobody has allowed you to send to them yet." 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) => ({ options={people.map((p) => ({
key: `u:${p.id}`, key: `u:${p.id}`,
name: label(p), name: p.displayName,
target: { kind: 'user' as const, id: p.id, name: label(p) }, target: { kind: 'user' as const, id: p.id, name: p.displayName },
}))} }))}
selected={target} selected={target}
onSelect={setTarget} onSelect={setTarget}
@@ -126,6 +128,11 @@ export const SendOffPanel = () => {
</Button> </Button>
{/* The server's own message, not a generic one: a file over the limit and a recipient who has {/* 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. */} revoked you are different problems with different answers. */}
{target?.kind === 'user' && myFolder && (
<p className="text-xs text-muted-foreground">
Lands in their <span className="text-foreground">Documents/SendOff/{myFolder}</span>.
</p>
)}
{send.error && <p className="text-sm text-destructive">{send.error.message}</p>} {send.error && <p className="text-sm text-destructive">{send.error.message}</p>}
{send.data && ( {send.data && (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
@@ -3,7 +3,9 @@ import { useClient } from 'hooks/useClient';
export type SendOffDevice = { id: string; name: string; platform: string | null; lastSeenAt: string }; export type SendOffDevice = { id: string; name: string; platform: string | null; lastSeenAt: string };
export type SendOffPerson = { id: number; email: string; name: string | null }; 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 })[] }; type Senders = { allowed: SendOffPerson[]; candidates: (SendOffPerson & { allowed: boolean })[] };
const TARGETS = ['sendoff', 'targets']; 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. // two-row query — the panel is the only reader and it is open for seconds at a time.
staleTime: 0, 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. */ /** Who I have allowed to send to me — the consent list, which only I can change. */