fix: follow-ups from the review of the price, Immich, moving day and import fixes

Saved place price (#2471)
- A price above zero that would be left without a currency is refused
  with a 400, over REST and through update_collection_place alike. A list
  has no base currency, so such an amount was read in each viewer's own
  default currency and went into a trip as the trip's. A price sent alone
  keeps the stored currency, and a zero price or clearing both fields
  still goes through. The check sits in the service because the MCP tool
  spreads the contract's shape, where a refine would never arrive.
- An older row that holds an amount without a currency shows it as a
  bare number instead of in the viewer's own currency.
- The Hungarian hint uses the informal register of its file, which is
  now prettier clean.

Immich certificate switch (#2475)
- The switch trusts one server. Saving a different URL without sending
  it turns it off; saving the same URL keeps it as before. The wiki says
  so.

Moving day (#2476)
- Google Maps and CoMaps are left out only when there is nothing to hand
  over. A day with a single stop opens it as a pin again, on the desktop
  and on the phone, as it did before.
- Desktop: a moving day whose flight or train has no stations shows no
  route tools, since there is nothing left to draw. With stations they
  stay.
- Phone: the drive between two stays on a day without stops or bookings
  shows once at the top instead of again at the bottom.
- The export helpers take the carrier flags as one object instead of two
  booleans in a row.

Import card (#2477)
- The success and error marks use the theme tokens.
- The e2e test names its upload with a made-up booking number.

Tests: COLLECTIONS-SVC-132 and 133, COLLECTIONS-E2E-088,
update_collection_place with a price and no currency, FE-COMP-COLEXTRAS-013
(011 adjusted), IMMICH-TLS-010, IMMICH-103 extended,
FE-PLANNER-DAYPLAN-222 adjusted and 224, FE-MOB-PLTL-051, FE-MOB-PTLM-047,
FE-W4BGT-023 and 024 check the mark colours.
This commit is contained in:
Maurice
2026-09-23 17:39:25 +02:00
parent 0df82185bd
commit a910c2a837
22 changed files with 269 additions and 84 deletions
@@ -995,6 +995,7 @@ export class CollectionsService {
async updatePlace(userId: number, placeId: number, body: CollectionPlaceUpdateRequest, socketId?: string): Promise<CollectionPlace> {
const currentCollection = this.collectionIdOfPlace(placeId);
this.assertCanEdit(userId, currentCollection);
if (body.price !== undefined || body.currency !== undefined) this.assertPriceHasCurrency(placeId, body);
// Capture the previous thumbnail so a replaced/cleared custom upload (#1136)
// can be reclaimed once nothing references it any more.
@@ -1056,6 +1057,23 @@ export class CollectionsService {
return this.getPlaceById(placeId);
}
/**
* A list has no base currency, so a price that names none would be read in
* each viewer's own default currency and carried into a trip as the trip's
* (#2471). Checked here rather than in the contract so REST and the MCP tool,
* which spreads the contract's shape, refuse it alike. Only the row as it
* would look after this update counts, so a zero price or clearing both
* fields still goes through.
*/
private assertPriceHasCurrency(placeId: number, body: Pick<CollectionPlaceUpdateRequest, 'price' | 'currency'>): void {
const stored = this.db.get<{ price: number | null; currency: string | null }>(
'SELECT price, currency FROM collection_places WHERE id = ?', placeId,
);
const price = body.price === undefined ? stored?.price ?? null : body.price;
const currency = body.currency === undefined ? stored?.currency ?? null : body.currency;
if (price != null && price > 0 && !currency) httpError(400, 'A price needs a currency: send its three-letter code along');
}
setStatus(userId: number, placeId: number, status: CollectionStatus, socketId?: string): CollectionPlace {
const collectionId = this.collectionIdOfPlace(placeId);
this.assertCanEdit(userId, collectionId);
+12 -10
View File
@@ -115,9 +115,10 @@ export class ImmichService {
}
/**
* `allowInsecureTls` left undefined keeps the stored choice, so a client that
* does not know the switch cannot clear it by saving. Disconnecting (no URL)
* always turns it off again.
* `allowInsecureTls` left undefined keeps the stored choice while the URL
* stays the same, so a client that does not know the switch cannot clear it
* by saving. The switch trusts one server, so a new URL without it starts
* off, and disconnecting (no URL) always turns it off again.
*/
async saveImmichSettings(
userId: number,
@@ -134,16 +135,17 @@ export class ImmichService {
if (!ssrf.allowed) {
return { success: false, error: `Invalid Immich URL: ${ssrf.error}` };
}
const url = immichUrl.trim();
const insecure = allowInsecureTls === undefined ? null : Number(allowInsecureTls);
// SET expressions read the row as it was, so `immich_url IS ?` compares the
// stored URL with the new one.
this.db
.prepare(
'UPDATE users SET immich_url = ?, immich_api_key = ?, immich_allow_insecure_tls = COALESCE(?, immich_allow_insecure_tls) WHERE id = ?',
`UPDATE users SET immich_url = ?, immich_api_key = ?,
immich_allow_insecure_tls = CASE WHEN immich_url IS ? THEN COALESCE(?, immich_allow_insecure_tls) ELSE COALESCE(?, 0) END
WHERE id = ?`,
)
.run(
immichUrl.trim(),
maybe_encrypt_api_key(immichApiKey),
allowInsecureTls === undefined ? null : Number(allowInsecureTls),
userId,
);
.run(url, maybe_encrypt_api_key(immichApiKey), url, insecure, insecure, userId);
if (ssrf.isPrivate) {
this.audit.writeAudit({
userId,
+11
View File
@@ -214,6 +214,17 @@ describe('Collections e2e (real auth guard + real service + temp SQLite)', () =>
expect(db.prepare('SELECT price, currency, website FROM collection_places WHERE id = ?').get(placeId)).toEqual({ price: 10, currency: 'EUR', website: null });
});
it('COLLECTIONS-E2E-088: a price without a currency on a place that has none is a 400', async () => {
const col = (await request(server).post('/api/addons/collections').set('Cookie', sessionCookie(ownerId)).send({ name: 'Bare prices' })).body;
const placeId = (await request(server).post('/api/addons/collections/places')
.set('Cookie', sessionCookie(ownerId)).send({ collection_id: col.id, name: 'Kunsthaus' })).body.place.id;
const res = await request(server).patch(`/api/addons/collections/places/${placeId}`).set('Cookie', sessionCookie(ownerId)).send({ price: 14 });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/currency/);
expect(db.prepare('SELECT price, currency FROM collection_places WHERE id = ?').get(placeId)).toEqual({ price: null, currency: null });
});
it('COLLECTIONS-E2E-087: a member who may only read cannot set a price (403)', async () => {
const col = (await request(server).post('/api/addons/collections').set('Cookie', sessionCookie(ownerId)).send({ name: 'Read only prices' })).body;
await request(server).post('/api/addons/collections/invite').set('Cookie', sessionCookie(ownerId)).send({ collection_id: col.id, user_id: otherId, role: 'viewer' });
@@ -1164,7 +1164,7 @@ describe('Immich self-signed certificate switch (#2475)', () => {
expect(get.body.allow_insecure_tls).toBe(true);
});
it('IMMICH-103: a save without the switch keeps it, and disconnecting clears it', async () => {
it('IMMICH-103: a save without the switch keeps it for the same server, a new server or disconnecting clears it', async () => {
const { user } = createUser(testDb);
const put = (body: Record<string, unknown>) =>
request(app).put(`${IMMICH}/settings`).set('Cookie', authCookie(user.id)).send(body);
@@ -1176,6 +1176,12 @@ describe('Immich self-signed certificate switch (#2475)', () => {
await put({ immich_url: 'https://immich.example.com', immich_api_key: 'k' });
expect(allowInsecureTls(user.id)).toBe(1);
// A different server is a different trust decision: without the switch it starts off.
await put({ immich_url: 'https://photos.example.com', immich_api_key: 'k' });
expect(allowInsecureTls(user.id)).toBe(0);
await put({ immich_url: 'https://photos.example.com', immich_api_key: 'k', allow_insecure_tls: true });
expect(allowInsecureTls(user.id)).toBe(1);
await put({ immich_url: '', immich_api_key: 'k', allow_insecure_tls: true });
expect(allowInsecureTls(user.id)).toBe(0);
});
@@ -406,6 +406,25 @@ describe('Tool: update_collection_place / set_collection_place_status / rate_col
});
});
// The contract takes price and currency as separate fields; the service holds
// them together, so an agent cannot leave a bare amount on a place without one.
it('refuses a price without a currency on a place that has none', async () => {
const { user } = createUser(testDb);
const col = seedCollection(user.id, 'P');
const pid = seedPlace(col, user.id, 'Pergamon');
await withHarness(user.id, async (h) => {
const bare = await h.client.callTool({ name: 'update_collection_place', arguments: { placeId: pid, price: 14 } });
expect(bare.isError).toBe(true);
expect(errorText(bare)).toMatch(/currency/);
expect(testDb.prepare('SELECT price, currency FROM collection_places WHERE id = ?').get(pid)).toEqual({ price: null, currency: null });
const paired = parseToolResult(await h.client.callTool({
name: 'update_collection_place', arguments: { placeId: pid, price: 14, currency: 'EUR' },
})) as { place: { price: number; currency: string } };
expect(paired.place).toMatchObject({ price: 14, currency: 'EUR' });
});
});
it('describes the price as a rough cost that needs its currency', async () => {
const { user } = createUser(testDb);
const h = await createMcpHarness({ userId: user.id, withResources: false });
@@ -379,6 +379,31 @@ describe('status + updatePlace move', () => {
expect(renamed).toMatchObject({ name: 'Kunsthaus Zürich', price: 24.5, currency: 'CHF', website: 'https://kunsthaus.example', phone: '+41 44' });
});
// A list has no base currency, so a bare amount would be read in every
// viewer's own default currency. The service refuses it for REST and MCP alike.
it('COLLECTIONS-SVC-132: a price without a currency on a place that has none is a 400 and changes nothing', async () => {
const u = createUser(testDb).user;
const col = svc.createCollection(u.id, { name: 'Zurich' });
const p = svc.savePlace(u.id, { collection_id: col.id, name: 'Kunsthaus' }).place!;
await expect(svc.updatePlace(u.id, p.id, { price: 14 })).rejects.toMatchObject({ status: 400, message: expect.stringMatching(/currency/) });
await expect(svc.updatePlace(u.id, p.id, { price: 14, currency: null })).rejects.toMatchObject({ status: 400 });
expect(testDb.prepare('SELECT price, currency FROM collection_places WHERE id = ?').get(p.id)).toEqual({ price: null, currency: null });
});
it('COLLECTIONS-SVC-133: a price alone keeps the stored currency, a zero price needs none, and a priced place cannot lose its currency', async () => {
const u = createUser(testDb).user;
const col = svc.createCollection(u.id, { name: 'Zurich' });
const priced = svc.savePlace(u.id, { collection_id: col.id, name: 'Kunsthaus', price: 10, currency: 'CHF' }).place!;
const free = svc.savePlace(u.id, { collection_id: col.id, name: 'Lake shore' }).place!;
expect(await svc.updatePlace(u.id, priced.id, { price: 24.5 })).toMatchObject({ price: 24.5, currency: 'CHF' });
expect(await svc.updatePlace(u.id, free.id, { price: 0 })).toMatchObject({ price: 0, currency: null });
await expect(svc.updatePlace(u.id, priced.id, { currency: null })).rejects.toMatchObject({ status: 400 });
expect(await svc.updatePlace(u.id, priced.id, { price: null, currency: null })).toMatchObject({ price: null, currency: null });
});
it('COLLECTIONS-SVC-131: copyToTrip carries the edited price, currency, website and phone into the trip place', async () => {
const u = createUser(testDb).user;
const trip = createTrip(testDb, u.id);
@@ -844,6 +844,23 @@ describe('self-signed certificates', () => {
expect(row).toEqual({ immich_url: null, immich_allow_insecure_tls: 0 });
});
it('IMMICH-TLS-010: the switch trusts one server, so a new URL saved without it starts off', async () => {
seedUser(USER, 'https://nas.local', 'key-1', 0, 1);
// Same server with a trailing slash: still the same connection, the switch stays.
await svc.saveImmichSettings(USER, 'https://nas.local/', 'key-1', null);
expect(svc.getConnectionSettings(USER).allow_insecure_tls).toBe(true);
await svc.saveImmichSettings(USER, 'https://photos.example.com', 'key-2', null);
expect(testDb.prepare('SELECT immich_url, immich_allow_insecure_tls FROM users WHERE id = ?').get(USER)).toEqual({
immich_url: 'https://photos.example.com', immich_allow_insecure_tls: 0,
});
// Sent along with the new URL, it holds for that server.
await svc.saveImmichSettings(USER, 'https://other.example.com', 'key-3', null, true);
expect(svc.getConnectionSettings(USER).allow_insecure_tls).toBe(true);
});
it('IMMICH-TLS-006: a URL the guard refuses leaves the stored switch alone', async () => {
seedUser(USER, 'https://immich.test', 'key-1', 0, 1);
checkSsrf.mockResolvedValue({ allowed: false, error: 'blocked host' });