feat(collections): edit a saved place's provider ids, website, phone and price (#2646)

The update contract takes google_place_id, google_ftid, osm_id, website,
phone, price and currency like saving does, website with the same
normalisation and refusal. REST and update_collection_place share it, so an
agent can correct a place instead of re-saving it and losing its labels,
ratings, position and id. The assistant's change card names the new fields.
This commit is contained in:
Maurice
2026-11-13 09:33:27 +01:00
parent 72e8b1f8d9
commit cae04e83dd
44 changed files with 289 additions and 13 deletions
@@ -32,6 +32,11 @@ export const COLLECTION_CARD_FIELDS = [
'category',
'link',
'photo',
'website',
'phone',
'price',
/** The place's Google or OpenStreetMap entry (its provider ids). */
'mapEntry',
] as const;
/** What a change does to a label of a list; `none` on an answer. */
@@ -75,7 +80,7 @@ export const collectionCardDeltaSchema = z.object({
rating: z.object({ to: z.number().int().min(1).max(5).nullable() }).nullable(),
labelsAdded: z.array(idSchema).max(10),
labelsRemoved: z.array(idSchema).max(10),
fields: z.array(z.enum(COLLECTION_CARD_FIELDS)).max(8),
fields: z.array(z.enum(COLLECTION_CARD_FIELDS)).max(COLLECTION_CARD_FIELDS.length),
movedTo: collectionCardListSchema.nullable(),
removed: z.boolean(),
});
@@ -86,7 +86,7 @@ describe('collection cards', () => {
const delta = deltaOf({ status: { from: 'idea', to: 'visited' }, rating: { to: null }, fields: ['photo'] });
const card = { ...placesCard, places: [{ ...place, delta }] };
expect(collectionPlacesCardSchema.parse(card).places[0]?.delta).toEqual(delta);
const unknownField = { ...placesCard, places: [{ ...place, delta: deltaOf({ fields: ['phone'] }) }] };
const unknownField = { ...placesCard, places: [{ ...place, delta: deltaOf({ fields: ['email'] }) }] };
expect(collectionPlacesCardSchema.safeParse(unknownField).success).toBe(false);
});
@@ -28,6 +28,36 @@ describe('collectionStatusSchema', () => {
});
describe('collectionPlaceUpdateRequestSchema', () => {
// #2646: the fields saving takes, with the same checks, so a place is corrected in place.
it('takes the provider ids, website, phone and price, and null clears each', () => {
const set = {
google_place_id: 'ChIJseptime',
google_ftid: '0x47e6:0x1b2',
osm_id: 'node/42',
website: 'https://septime-charonne.fr',
phone: '+33 1 43 67 38 29',
price: 55,
currency: 'EUR',
};
expect(collectionPlaceUpdateRequestSchema.parse(set)).toEqual(set);
const cleared = Object.fromEntries(Object.keys(set).map((key) => [key, null]));
expect(collectionPlaceUpdateRequestSchema.parse(cleared)).toEqual(cleared);
});
it('normalises a bare domain as website and refuses a script link, as saving does', () => {
expect(collectionPlaceUpdateRequestSchema.parse({ website: 'septime-charonne.fr' }).website).toBe(
'https://septime-charonne.fr',
);
expect(collectionPlaceUpdateRequestSchema.safeParse({ website: 'javascript:alert(1)' }).success).toBe(false);
});
it('leaves the new fields absent when an update does not name them', () => {
const parsed = collectionPlaceUpdateRequestSchema.parse({ name: 'Septime' });
for (const key of ['google_place_id', 'google_ftid', 'osm_id', 'website', 'phone', 'price', 'currency']) {
expect(key in parsed, key).toBe(false);
}
});
// Regression for #1437: an update that omits `status` must NOT inject a default
// of 'idea', otherwise the server overwrites the stored status on every edit-save.
it('does not inject a status when the field is absent', () => {
@@ -223,6 +223,16 @@ export const collectionPlaceUpdateRequestSchema = z.object({
label_ids: z.array(z.number()).optional(),
// Custom thumbnail (#1136): null clears it (falls back to the auto-fetched photo).
image_url: placeImageUrlSchema.nullable().optional(),
// The fields saving accepts but editing did not (#2646), with the same checks, so
// a place can be corrected in place instead of re-saved, which loses its labels,
// ratings, position and id. Each one is left alone when absent; null clears it.
google_place_id: z.string().nullable().optional(),
google_ftid: z.string().nullable().optional(),
osm_id: z.string().nullable().optional(),
website: placeWebsiteSchema.nullable().optional(),
phone: z.string().nullable().optional(),
price: z.number().nullable().optional(),
currency: z.string().nullable().optional(),
});
export type CollectionPlaceUpdateRequest = z.infer<typeof collectionPlaceUpdateRequestSchema>;
@@ -55,6 +55,7 @@ const assistantCollection: TranslationStrings = {
'assistant.card.collection.moveTo': 'نقل إلى {name}',
'assistant.card.collection.removed': 'أُزيل من القائمة',
'assistant.card.collection.photo': 'صورة',
'assistant.card.collection.mapEntry': 'سجل الخريطة',
'assistant.card.collection.places': 'الأماكن المحفوظة في {name}',
'assistant.card.collection.saved': 'الأماكن المحفوظة',
'assistant.card.collection.statusSet': 'تغيّر {count} مكان محفوظ',
@@ -27,6 +27,7 @@ const assistantCollection: TranslationStrings = {
'assistant.card.collection.moveTo': '{name} siyahısına köçür',
'assistant.card.collection.removed': 'Siyahıdan çıxarıldı',
'assistant.card.collection.photo': 'Foto',
'assistant.card.collection.mapEntry': 'Xəritə qeydi',
'assistant.card.collection.places': '{name} siyahısındakı saxlanılmış məkanlar',
'assistant.card.collection.saved': 'Saxlanılmış məkanlar',
'assistant.card.collection.statusSet': '{count} saxlanılmış məkan dəyişdi',
@@ -27,6 +27,7 @@ const assistantCollection: TranslationStrings = {
'assistant.card.collection.moveTo': 'Mover para {name}',
'assistant.card.collection.removed': 'Removido da lista',
'assistant.card.collection.photo': 'Foto',
'assistant.card.collection.mapEntry': 'Registro no mapa',
'assistant.card.collection.places': 'Lugares salvos em {name}',
'assistant.card.collection.saved': 'Lugares salvos',
'assistant.card.collection.statusSet': '{count} lugares salvos alterados',
@@ -27,6 +27,7 @@ const assistantCollection: TranslationStrings = {
'assistant.card.collection.moveTo': 'Mou a {name}',
'assistant.card.collection.removed': 'Tret de la llista',
'assistant.card.collection.photo': 'Foto',
'assistant.card.collection.mapEntry': 'Fitxa del mapa',
'assistant.card.collection.places': 'Llocs desats a {name}',
'assistant.card.collection.saved': 'Llocs desats',
'assistant.card.collection.statusSet': '{count} llocs desats canviats',
@@ -34,6 +34,7 @@ const assistantCollection: TranslationStrings = {
'assistant.card.collection.moveTo': 'Přesunout do {name}',
'assistant.card.collection.removed': 'Odebráno ze seznamu',
'assistant.card.collection.photo': 'Fotka',
'assistant.card.collection.mapEntry': 'Záznam na mapě',
'assistant.card.collection.places': 'Uložená místa v {name}',
'assistant.card.collection.saved': 'Uložená místa',
'assistant.card.collection.statusSet': '{count} uložených míst změněno',
@@ -27,6 +27,7 @@ const assistantCollection: TranslationStrings = {
'assistant.card.collection.moveTo': 'Nach {name} verschieben',
'assistant.card.collection.removed': 'Aus der Liste entfernt',
'assistant.card.collection.photo': 'Foto',
'assistant.card.collection.mapEntry': 'Karteneintrag',
'assistant.card.collection.places': 'Gespeicherte Orte in {name}',
'assistant.card.collection.saved': 'Gespeicherte Orte',
'assistant.card.collection.statusSet': '{count} gespeicherte Orte geändert',
@@ -27,6 +27,7 @@ const assistantCollection: TranslationStrings = {
'assistant.card.collection.moveTo': 'Move to {name}',
'assistant.card.collection.removed': 'Removed from the list',
'assistant.card.collection.photo': 'Photo',
'assistant.card.collection.mapEntry': 'Map entry',
'assistant.card.collection.places': 'Saved places in {name}',
'assistant.card.collection.saved': 'Saved places',
'assistant.card.collection.statusSet': '{count} saved places changed',
@@ -27,6 +27,7 @@ const assistantCollection: TranslationStrings = {
'assistant.card.collection.moveTo': 'Mover a {name}',
'assistant.card.collection.removed': 'Quitado de la lista',
'assistant.card.collection.photo': 'Foto',
'assistant.card.collection.mapEntry': 'Ficha del mapa',
'assistant.card.collection.places': 'Lugares guardados en {name}',
'assistant.card.collection.saved': 'Lugares guardados',
'assistant.card.collection.statusSet': '{count} lugares guardados cambiados',
@@ -27,6 +27,7 @@ const assistantCollection: TranslationStrings = {
'assistant.card.collection.moveTo': 'Teisalda nimekirja {name}',
'assistant.card.collection.removed': 'Nimekirjast eemaldatud',
'assistant.card.collection.photo': 'Foto',
'assistant.card.collection.mapEntry': 'Kaardikirje',
'assistant.card.collection.places': 'Salvestatud kohad nimekirjas {name}',
'assistant.card.collection.saved': 'Salvestatud kohad',
'assistant.card.collection.statusSet': '{count} salvestatud kohta muudetud',
@@ -28,6 +28,7 @@ const assistantCollection: TranslationStrings = {
'assistant.card.collection.removed': 'Retiré de la liste',
'assistant.card.collection.photo': 'Photo', // same-as-en
'assistant.card.collection.places': 'Lieux enregistrés dans {name}',
'assistant.card.collection.mapEntry': 'Fiche sur la carte',
'assistant.card.collection.saved': 'Lieux enregistrés',
'assistant.card.collection.statusSet': '{count} lieux enregistrés modifiés',
'assistant.card.collection.statusSet.one': '{count} lieu enregistré modifié',
@@ -27,6 +27,7 @@ const assistantCollection: TranslationStrings = {
'assistant.card.collection.moveTo': 'Μετακίνηση στη λίστα {name}',
'assistant.card.collection.removed': 'Αφαιρέθηκε από τη λίστα',
'assistant.card.collection.photo': 'Φωτογραφία',
'assistant.card.collection.mapEntry': 'Καταχώριση χάρτη',
'assistant.card.collection.places': 'Αποθηκευμένα μέρη στη λίστα {name}',
'assistant.card.collection.saved': 'Αποθηκευμένα μέρη',
'assistant.card.collection.statusSet': '{count} αποθηκευμένα μέρη άλλαξαν',
@@ -34,6 +34,7 @@ const assistantCollection: TranslationStrings = {
'assistant.card.collection.moveTo': 'העברה ל-{name}',
'assistant.card.collection.removed': 'הוסר מהרשימה',
'assistant.card.collection.photo': 'תמונה',
'assistant.card.collection.mapEntry': 'רשומה במפה',
'assistant.card.collection.places': 'מקומות שמורים ב-{name}',
'assistant.card.collection.saved': 'מקומות שמורים',
'assistant.card.collection.statusSet': '{count} מקומות שמורים עודכנו',
@@ -27,6 +27,7 @@ const assistantCollection: TranslationStrings = {
'assistant.card.collection.moveTo': 'Áthelyezés ide: {name}',
'assistant.card.collection.removed': 'Eltávolítva a listából',
'assistant.card.collection.photo': 'Fotó',
'assistant.card.collection.mapEntry': 'Térképes bejegyzés',
'assistant.card.collection.places': 'Mentett helyek itt: {name}',
'assistant.card.collection.saved': 'Mentett helyek',
'assistant.card.collection.statusSet': '{count} mentett hely módosítva',
@@ -20,6 +20,7 @@ const assistantCollection: TranslationStrings = {
'assistant.card.collection.moveTo': 'Pindahkan ke {name}',
'assistant.card.collection.removed': 'Dihapus dari daftar',
'assistant.card.collection.photo': 'Foto',
'assistant.card.collection.mapEntry': 'Entri peta',
'assistant.card.collection.places': 'Tempat tersimpan di {name}',
'assistant.card.collection.saved': 'Tempat tersimpan',
'assistant.card.collection.statusSet': '{count} tempat tersimpan diubah',
@@ -27,6 +27,7 @@ const assistantCollection: TranslationStrings = {
'assistant.card.collection.moveTo': 'Sposta in {name}',
'assistant.card.collection.removed': 'Rimosso dalla lista',
'assistant.card.collection.photo': 'Foto',
'assistant.card.collection.mapEntry': 'Scheda sulla mappa',
'assistant.card.collection.places': 'Luoghi salvati in {name}',
'assistant.card.collection.saved': 'Luoghi salvati',
'assistant.card.collection.statusSet': '{count} luoghi salvati modificati',
@@ -20,6 +20,7 @@ const assistantCollection: TranslationStrings = {
'assistant.card.collection.moveTo': '{name} に移動',
'assistant.card.collection.removed': 'リストから削除しました',
'assistant.card.collection.photo': '写真',
'assistant.card.collection.mapEntry': '地図の項目',
'assistant.card.collection.places': '{name} の保存した場所',
'assistant.card.collection.saved': '保存した場所',
'assistant.card.collection.statusSet': '保存した場所 {count} 件を変更',
@@ -20,6 +20,7 @@ const assistantCollection: TranslationStrings = {
'assistant.card.collection.moveTo': '{name}(으)로 이동',
'assistant.card.collection.removed': '목록에서 제거됨',
'assistant.card.collection.photo': '사진',
'assistant.card.collection.mapEntry': '지도 항목',
'assistant.card.collection.places': '{name}에 저장한 장소',
'assistant.card.collection.saved': '저장한 장소',
'assistant.card.collection.statusSet': '저장한 장소 {count}곳 변경됨',
@@ -27,6 +27,7 @@ const assistantCollection: TranslationStrings = {
'assistant.card.collection.moveTo': 'Verplaatsen naar {name}',
'assistant.card.collection.removed': 'Uit de lijst verwijderd',
'assistant.card.collection.photo': 'Foto',
'assistant.card.collection.mapEntry': 'Kaartvermelding',
'assistant.card.collection.places': 'Bewaarde plaatsen in {name}',
'assistant.card.collection.saved': 'Bewaarde plaatsen',
'assistant.card.collection.statusSet': '{count} bewaarde plaatsen gewijzigd',
@@ -41,6 +41,7 @@ const assistantCollection: TranslationStrings = {
'assistant.card.collection.moveTo': 'Przenieś do {name}',
'assistant.card.collection.removed': 'Usunięto z listy',
'assistant.card.collection.photo': 'Zdjęcie',
'assistant.card.collection.mapEntry': 'Wpis na mapie',
'assistant.card.collection.places': 'Zapisane miejsca na liście {name}',
'assistant.card.collection.saved': 'Zapisane miejsca',
'assistant.card.collection.statusSet': 'Zmieniono {count} zapisanego miejsca',
@@ -41,6 +41,7 @@ const assistantCollection: TranslationStrings = {
'assistant.card.collection.moveTo': 'Переместить в «{name}»',
'assistant.card.collection.removed': 'Удалено из списка',
'assistant.card.collection.photo': 'Фото',
'assistant.card.collection.mapEntry': 'Запись на карте',
'assistant.card.collection.places': 'Сохранённые места в «{name}»',
'assistant.card.collection.saved': 'Сохранённые места',
'assistant.card.collection.statusSet': 'Изменено сохранённых мест: {count}',
@@ -34,6 +34,7 @@ const assistantCollection: TranslationStrings = {
'assistant.card.collection.moveTo': 'Presunúť do {name}',
'assistant.card.collection.removed': 'Odstránené zo zoznamu',
'assistant.card.collection.photo': 'Fotka',
'assistant.card.collection.mapEntry': 'Záznam na mape',
'assistant.card.collection.places': 'Uložené miesta v {name}',
'assistant.card.collection.saved': 'Uložené miesta',
'assistant.card.collection.statusSet': '{count} uložených miest zmenených',
@@ -27,6 +27,7 @@ const assistantCollection: TranslationStrings = {
'assistant.card.collection.moveTo': 'Flytta till {name}',
'assistant.card.collection.removed': 'Borttagen från listan',
'assistant.card.collection.photo': 'Foto',
'assistant.card.collection.mapEntry': 'Kartpost',
'assistant.card.collection.places': 'Sparade platser i {name}',
'assistant.card.collection.saved': 'Sparade platser',
'assistant.card.collection.statusSet': '{count} sparade platser ändrade',
@@ -20,6 +20,7 @@ const assistantCollection: TranslationStrings = {
'assistant.card.collection.moveTo': 'ย้ายไปที่ {name}',
'assistant.card.collection.removed': 'นำออกจากรายการแล้ว',
'assistant.card.collection.photo': 'รูปภาพ',
'assistant.card.collection.mapEntry': 'รายการบนแผนที่',
'assistant.card.collection.places': 'สถานที่ที่บันทึกไว้ใน {name}',
'assistant.card.collection.saved': 'สถานที่ที่บันทึกไว้',
'assistant.card.collection.statusSet': 'เปลี่ยนสถานที่ที่บันทึกไว้ {count} แห่ง',
@@ -27,6 +27,7 @@ const assistantCollection: TranslationStrings = {
'assistant.card.collection.moveTo': '{name} listesine taşı',
'assistant.card.collection.removed': 'Listeden çıkarıldı',
'assistant.card.collection.photo': 'Fotoğraf',
'assistant.card.collection.mapEntry': 'Harita kaydı',
'assistant.card.collection.places': '{name} listesindeki kayıtlı yerler',
'assistant.card.collection.saved': 'Kayıtlı yerler',
'assistant.card.collection.statusSet': '{count} kayıtlı yer değişti',
@@ -41,6 +41,7 @@ const assistantCollection: TranslationStrings = {
'assistant.card.collection.moveTo': 'Перемістити в «{name}»',
'assistant.card.collection.removed': 'Прибрано зі списку',
'assistant.card.collection.photo': 'Фото',
'assistant.card.collection.mapEntry': 'Запис на мапі',
'assistant.card.collection.places': 'Збережені місця в «{name}»',
'assistant.card.collection.saved': 'Збережені місця',
'assistant.card.collection.statusSet': 'Змінено збережених місць: {count}',
@@ -20,6 +20,7 @@ const assistantCollection: TranslationStrings = {
'assistant.card.collection.moveTo': 'Chuyển sang {name}',
'assistant.card.collection.removed': 'Đã xoá khỏi danh sách',
'assistant.card.collection.photo': 'Ảnh',
'assistant.card.collection.mapEntry': 'Mục trên bản đồ',
'assistant.card.collection.places': 'Địa điểm đã lưu trong {name}',
'assistant.card.collection.saved': 'Địa điểm đã lưu',
'assistant.card.collection.statusSet': 'Đã thay đổi {count} địa điểm đã lưu',
@@ -20,6 +20,7 @@ const assistantCollection: TranslationStrings = {
'assistant.card.collection.moveTo': '移動到 {name}',
'assistant.card.collection.removed': '已從清單中移除',
'assistant.card.collection.photo': '照片',
'assistant.card.collection.mapEntry': '地圖項目',
'assistant.card.collection.places': '{name} 中儲存的地點',
'assistant.card.collection.saved': '儲存的地點',
'assistant.card.collection.statusSet': '已變更 {count} 個儲存的地點',
@@ -20,6 +20,7 @@ const assistantCollection: TranslationStrings = {
'assistant.card.collection.moveTo': '移动到 {name}',
'assistant.card.collection.removed': '已从列表中移除',
'assistant.card.collection.photo': '照片',
'assistant.card.collection.mapEntry': '地图条目',
'assistant.card.collection.places': '{name} 中保存的地点',
'assistant.card.collection.saved': '保存的地点',
'assistant.card.collection.statusSet': '已更改 {count} 个保存的地点',