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
+21
View File
@@ -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}`;
+15 -3
View File
@@ -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. */