From 399e3573c18466f17dfa0b4ce5f60c31d08bbf91 Mon Sep 17 00:00:00 2001 From: Maurice <61554723+mauriceboe@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:00:26 +0200 Subject: [PATCH] feat(places): collaborative place ratings + collections MCP (#1435) (#1644) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trip members can rate a place 1-5 stars; the place shows the average, with a who-voted custom tooltip and a star-sort toggle in the places sidebar. Saved collection places are ratable too (plus a min-rating filter), and ratings travel with a place copied between a trip and a collection — carrying only the votes of users who belong to both sides. Adds a full Collections MCP surface (list/get, CRUD lists + places, status, rate_collection_place, labels, sharing, copy-to-trip) under a new collections:read/collections:write scope, plus rate_place for trip places; rating aggregates flow through the existing place outputs. Mobile trip + collection place sheets gain the rating row. New UI strings translated across all 22 locales. --- client/src/api/client.ts | 4 + client/src/api/collections.ts | 4 + client/src/api/oauthScopes.test.ts | 6 +- client/src/api/oauthScopes.ts | 2 + .../Collections/CollectionFilterBar.test.tsx | 16 +- .../Collections/CollectionFilterBar.tsx | 17 +- .../Collections/CollectionPlaceDetail.tsx | 12 +- .../src/components/Planner/PlaceInspector.tsx | 16 +- .../Planner/PlacesSidebarHeader.tsx | 22 +- .../components/Planner/PlacesSidebarRow.tsx | 9 +- .../components/Planner/usePlacesSidebar.ts | 33 +- client/src/components/shared/StarRating.tsx | 152 +++++++ .../screens/collections/MCollPlaceSheet.tsx | 12 +- .../screens/collections/MCollections.tsx | 12 +- .../screens/trip/sheets/MPlaceSheet.tsx | 16 + client/src/pages/CollectionsPage.tsx | 3 + client/src/pages/TripPlannerPage.tsx | 2 + .../collections/collectionsModel.test.ts | 20 + .../src/pages/collections/collectionsModel.ts | 3 + .../src/pages/collections/useCollections.ts | 24 +- client/src/store/collectionStore.ts | 10 + client/src/store/slices/placesSlice.ts | 13 + server/src/db/database.ts | 14 + server/src/db/migrations.ts | 27 ++ server/src/db/schema.ts | 18 + server/src/mcp/scopes.ts | 4 + server/src/mcp/tools.ts | 3 + server/src/mcp/tools/collections.ts | 398 ++++++++++++++++++ server/src/mcp/tools/places.ts | 24 +- .../collections/collections.controller.ts | 18 + .../nest/collections/collections.service.ts | 1 + server/src/nest/places/places.controller.ts | 32 ++ server/src/nest/places/places.service.ts | 4 + server/src/services/collectionsService.ts | 94 ++++- server/src/services/placeService.ts | 29 +- server/src/services/queryHelpers.ts | 36 +- .../unit/services/collectionsService.test.ts | 135 ++++++ .../tests/unit/services/placeService.test.ts | 32 +- shared/src/collection/collection.schema.ts | 6 +- shared/src/i18n/ar/oauth.ts | 5 + shared/src/i18n/ar/places.ts | 3 + shared/src/i18n/br/oauth.ts | 5 + shared/src/i18n/br/places.ts | 3 + shared/src/i18n/ca/oauth.ts | 5 + shared/src/i18n/ca/places.ts | 3 + shared/src/i18n/cs/oauth.ts | 5 + shared/src/i18n/cs/places.ts | 3 + shared/src/i18n/de/oauth.ts | 5 + shared/src/i18n/de/places.ts | 3 + shared/src/i18n/en/oauth.ts | 5 + shared/src/i18n/en/places.ts | 3 + shared/src/i18n/es/oauth.ts | 5 + shared/src/i18n/es/places.ts | 3 + shared/src/i18n/fr/oauth.ts | 5 + shared/src/i18n/fr/places.ts | 3 + shared/src/i18n/gr/oauth.ts | 5 + shared/src/i18n/gr/places.ts | 3 + shared/src/i18n/hu/oauth.ts | 5 + shared/src/i18n/hu/places.ts | 3 + shared/src/i18n/id/oauth.ts | 5 + shared/src/i18n/id/places.ts | 3 + shared/src/i18n/it/oauth.ts | 5 + shared/src/i18n/it/places.ts | 3 + shared/src/i18n/ja/oauth.ts | 5 + shared/src/i18n/ja/places.ts | 3 + shared/src/i18n/ko/oauth.ts | 5 + shared/src/i18n/ko/places.ts | 3 + shared/src/i18n/nl/oauth.ts | 5 + shared/src/i18n/nl/places.ts | 3 + shared/src/i18n/pl/oauth.ts | 5 + shared/src/i18n/pl/places.ts | 3 + shared/src/i18n/ru/oauth.ts | 5 + shared/src/i18n/ru/places.ts | 3 + shared/src/i18n/sv/oauth.ts | 5 + shared/src/i18n/sv/places.ts | 3 + shared/src/i18n/tr/oauth.ts | 5 + shared/src/i18n/tr/places.ts | 3 + shared/src/i18n/uk/oauth.ts | 5 + shared/src/i18n/uk/places.ts | 3 + shared/src/i18n/vi/oauth.ts | 5 + shared/src/i18n/vi/places.ts | 3 + shared/src/i18n/zh-TW/oauth.ts | 5 + shared/src/i18n/zh-TW/places.ts | 3 + shared/src/i18n/zh/oauth.ts | 5 + shared/src/i18n/zh/places.ts | 3 + shared/src/place/place.schema.ts | 20 + 86 files changed, 1441 insertions(+), 46 deletions(-) create mode 100644 client/src/components/shared/StarRating.tsx create mode 100644 server/src/mcp/tools/collections.ts diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 4f9f70951..188da4106 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -404,6 +404,10 @@ export const placesApi = { fd.append('image', file) return postMultipart<{ place: Place }>(`/trips/${tripId}/places/${id}/image`, fd) }, + rate: (tripId: number | string, id: number | string, rating: number | null): Promise<{ place: Place }> => + rating === null + ? apiClient.delete(`/trips/${tripId}/places/${id}/rating`).then(r => r.data) + : apiClient.put(`/trips/${tripId}/places/${id}/rating`, { rating }).then(r => r.data), importGpx: (tripId: number | string, file: File, opts?: { waypoints?: boolean; routes?: boolean; tracks?: boolean }) => { const fd = new FormData() fd.append('file', file) diff --git a/client/src/api/collections.ts b/client/src/api/collections.ts index 5758d3a65..0b8f54274 100644 --- a/client/src/api/collections.ts +++ b/client/src/api/collections.ts @@ -74,6 +74,10 @@ export const collectionsApi = { postMultipart(`${base}/places/${pid}/image`, formData), setStatus: (pid: number, status: CollectionStatus): Promise => ax.post(`${base}/places/${pid}/status`, { status }).then((r: AxiosResponse) => r.data), + ratePlace: (pid: number, rating: number | null): Promise => + rating === null + ? ax.delete(`${base}/places/${pid}/rating`).then((r: AxiosResponse) => r.data) + : ax.put(`${base}/places/${pid}/rating`, { rating }).then((r: AxiosResponse) => r.data), deletePlace: (pid: number): Promise => ax.delete(`${base}/places/${pid}`).then((r: AxiosResponse) => r.data), deleteMany: (ids: number[]): Promise => diff --git a/client/src/api/oauthScopes.test.ts b/client/src/api/oauthScopes.test.ts index 9e5b538f6..443c30b64 100644 --- a/client/src/api/oauthScopes.test.ts +++ b/client/src/api/oauthScopes.test.ts @@ -7,6 +7,7 @@ describe('SCOPE_GROUPS', () => { const expected = [ 'trips:read', 'trips:write', 'trips:delete', 'trips:share', 'places:read', 'places:write', + 'collections:read', 'collections:write', 'atlas:read', 'atlas:write', 'packing:read', 'packing:write', 'todos:read', 'todos:write', @@ -16,6 +17,7 @@ describe('SCOPE_GROUPS', () => { 'notifications:read', 'notifications:write', 'vacay:read', 'vacay:write', 'geo:read', 'weather:read', + 'journey:read', 'journey:write', 'journey:share', ] for (const scope of expected) { expect(SCOPE_GROUPS).toHaveProperty(scope) @@ -32,8 +34,8 @@ describe('SCOPE_GROUPS', () => { }) describe('ALL_SCOPES', () => { - it('FE-OAUTH-SCOPES-003: contains exactly 27 scopes', () => { - expect(ALL_SCOPES).toHaveLength(27) + it('FE-OAUTH-SCOPES-003: contains exactly 29 scopes', () => { + expect(ALL_SCOPES).toHaveLength(29) }) it('FE-OAUTH-SCOPES-004: matches Object.keys(SCOPE_GROUPS)', () => { diff --git a/client/src/api/oauthScopes.ts b/client/src/api/oauthScopes.ts index 22504e1a6..d11bf853e 100644 --- a/client/src/api/oauthScopes.ts +++ b/client/src/api/oauthScopes.ts @@ -20,6 +20,8 @@ export const SCOPE_GROUPS: Record = { 'trips:share': { labelKey: 'oauth.scope.trips:share.label', descriptionKey: 'oauth.scope.trips:share.description', groupKey: 'oauth.scope.group.trips' }, 'places:read': { labelKey: 'oauth.scope.places:read.label', descriptionKey: 'oauth.scope.places:read.description', groupKey: 'oauth.scope.group.places' }, 'places:write': { labelKey: 'oauth.scope.places:write.label', descriptionKey: 'oauth.scope.places:write.description', groupKey: 'oauth.scope.group.places' }, + 'collections:read': { labelKey: 'oauth.scope.collections:read.label', descriptionKey: 'oauth.scope.collections:read.description', groupKey: 'oauth.scope.group.collections' }, + 'collections:write': { labelKey: 'oauth.scope.collections:write.label', descriptionKey: 'oauth.scope.collections:write.description', groupKey: 'oauth.scope.group.collections' }, 'atlas:read': { labelKey: 'oauth.scope.atlas:read.label', descriptionKey: 'oauth.scope.atlas:read.description', groupKey: 'oauth.scope.group.atlas' }, 'atlas:write': { labelKey: 'oauth.scope.atlas:write.label', descriptionKey: 'oauth.scope.atlas:write.description', groupKey: 'oauth.scope.group.atlas' }, 'packing:read': { labelKey: 'oauth.scope.packing:read.label', descriptionKey: 'oauth.scope.packing:read.description', groupKey: 'oauth.scope.group.packing' }, diff --git a/client/src/components/Collections/CollectionFilterBar.test.tsx b/client/src/components/Collections/CollectionFilterBar.test.tsx index 87f5b769a..7d6e99b99 100644 --- a/client/src/components/Collections/CollectionFilterBar.test.tsx +++ b/client/src/components/Collections/CollectionFilterBar.test.tsx @@ -27,8 +27,10 @@ function makeProps(overrides: Partial = {}): HarnessProps { counts: { all: 3, idea: 1, want: 1, visited: 1 }, categoryFilter: 'all', categoryOptions: CATEGORY_OPTIONS, + ratingFilter: 'all', onStatusFilter: vi.fn(), onCategoryFilter: vi.fn(), + onRatingFilter: vi.fn(), showLabels: false, labelOptions: [], labelFilter: [], @@ -49,9 +51,9 @@ beforeEach(() => { describe('CollectionFilterBar', () => { it('FE-COMP-COLFILTERBAR-001: renders the status dropdown showing the current "All" filter', () => { render(); - // Both dropdown triggers currently read "All" (status=all, category=all). - // With a category present there are exactly two "All" triggers: status + category. - expect(screen.getAllByRole('button', { name: 'All' })).toHaveLength(2); + // Three dropdown triggers read "All" (status=all, category=all, rating=all): + // status + category + the #1435 rating filter. + expect(screen.getAllByRole('button', { name: 'All' })).toHaveLength(3); }); it('FE-COMP-COLFILTERBAR-002: opening the status dropdown reveals the status options', async () => { @@ -84,15 +86,15 @@ describe('CollectionFilterBar', () => { it('FE-COMP-COLFILTERBAR-004: the category dropdown is present when categoryOptions is non-empty', () => { render(); - // Two dropdown triggers = status + category. + // Three dropdown triggers = status + category + rating. const triggers = screen.getAllByRole('button', { name: 'All' }); - expect(triggers).toHaveLength(2); + expect(triggers).toHaveLength(3); }); it('FE-COMP-COLFILTERBAR-005: the category dropdown is hidden when categoryOptions is empty', () => { render(); - // Only the status dropdown remains. - expect(screen.getAllByRole('button', { name: 'All' })).toHaveLength(1); + // The status + rating dropdowns remain (the rating filter always shows). + expect(screen.getAllByRole('button', { name: 'All' })).toHaveLength(2); }); it('FE-COMP-COLFILTERBAR-006: clicking a category option calls onCategoryFilter with the category id', async () => { diff --git a/client/src/components/Collections/CollectionFilterBar.tsx b/client/src/components/Collections/CollectionFilterBar.tsx index e0a093caf..43ce00b89 100644 --- a/client/src/components/Collections/CollectionFilterBar.tsx +++ b/client/src/components/Collections/CollectionFilterBar.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useRef, useState } from 'react' -import { ChevronDown, Check, Layers, Tag, Tags, CheckSquare } from 'lucide-react' +import { ChevronDown, Check, Layers, Tag, Tags, CheckSquare, Star } from 'lucide-react' import type { StatusFilter } from '../../store/collectionStore' import type { TranslationFn } from '../../types' import { getCategoryIcon } from '../shared/categoryIcons' @@ -67,8 +67,10 @@ interface CollectionFilterBarProps { counts: Record categoryFilter: number | 'all' categoryOptions: CategoryOption[] + ratingFilter: number | 'all' onStatusFilter: (f: StatusFilter) => void onCategoryFilter: (f: number | 'all') => void + onRatingFilter: (f: number | 'all') => void // Per-collection labels (hidden on the "All saved" union). showLabels: boolean labelOptions: LabelOption[] @@ -88,7 +90,7 @@ interface CollectionFilterBarProps { * Custom compact dropdowns so they barely take any space. */ export default function CollectionFilterBar({ - statusFilter, counts, categoryFilter, categoryOptions, onStatusFilter, onCategoryFilter, + statusFilter, counts, categoryFilter, categoryOptions, ratingFilter, onStatusFilter, onCategoryFilter, onRatingFilter, showLabels, labelOptions, labelFilter, onLabelFilter, canManageLabels, onManageLabels, showSelect, selectMode, onToggleSelect, t, }: CollectionFilterBarProps): React.ReactElement { @@ -109,12 +111,23 @@ export default function CollectionFilterBar({ }), ] + // Minimum-average-rating filter (#1435): All, then ≥5…≥1 stars. + const ratingOpts: Opt[] = [ + { key: 'all', label: t('common.all') }, + ...[5, 4, 3, 2, 1].map(n => ({ + key: n, + label: `${n}+`, + icon: , + })), + ] + return (
onStatusFilter(k as StatusFilter)} lead={} /> {categoryOptions.length > 0 && ( onCategoryFilter(k as number | 'all')} lead={} /> )} + onRatingFilter(k as number | 'all')} lead={} /> {showSelect && ( + {/* Star sort (#1435): order the list by average rating, best first. */} + + + {canEditPlaces && (
{(place.description || place.address || cat?.name) && (
diff --git a/client/src/components/Planner/usePlacesSidebar.ts b/client/src/components/Planner/usePlacesSidebar.ts index 3457866d8..12a1c8cfa 100644 --- a/client/src/components/Planner/usePlacesSidebar.ts +++ b/client/src/components/Planner/usePlacesSidebar.ts @@ -155,6 +155,8 @@ export function usePlacesSidebar(props: PlacesSidebarProps) { const categoryFilters = useTripStore((s) => s.placesCategoryFilter) const setCategoryFilters = useTripStore((s) => s.setPlacesCategoryFilter) const [selectMode, setSelectMode] = useState(false) + // Star sort (#1435): list-only toggle, so it stays local (the map keeps its order). + const [ratingSort, setRatingSort] = useState(false) const [selectedIds, setSelectedIds] = useState>(new Set()) const [pendingDeleteIds, setPendingDeleteIds] = useState(null) const [categoryPickerOpen, setCategoryPickerOpen] = useState(false) @@ -196,18 +198,26 @@ export function usePlacesSidebar(props: PlacesSidebarProps) { Object.values(assignments).flatMap(da => da.map(a => a.place?.id).filter(Boolean)) ), [assignments]) - const filtered = useMemo(() => places.filter(p => { - if (filter === 'unplanned' && plannedIds.has(p.id)) return false - if (filter === 'tracks' && !p.route_geometry) return false - if (categoryFilters.size > 0) { - if (p.category_id == null) { - if (!categoryFilters.has('uncategorized')) return false - } else if (!categoryFilters.has(String(p.category_id))) return false + const filtered = useMemo(() => { + const list = places.filter(p => { + if (filter === 'unplanned' && plannedIds.has(p.id)) return false + if (filter === 'tracks' && !p.route_geometry) return false + if (categoryFilters.size > 0) { + if (p.category_id == null) { + if (!categoryFilters.has('uncategorized')) return false + } else if (!categoryFilters.has(String(p.category_id))) return false + } + if (search && !p.name.toLowerCase().includes(search.toLowerCase()) && + !(p.address || '').toLowerCase().includes(search.toLowerCase())) return false + return true + }) + // Star sort (#1435): highest average first, unrated places at the end; + // ties keep the list's original order (stable sort). + if (ratingSort) { + return [...list].sort((a, b) => (b.rating_avg ?? -1) - (a.rating_avg ?? -1)) } - if (search && !p.name.toLowerCase().includes(search.toLowerCase()) && - !(p.address || '').toLowerCase().includes(search.toLowerCase())) return false - return true - }), [places, filter, categoryFilters, search, plannedIds]) + return list + }, [places, filter, categoryFilters, search, plannedIds, ratingSort]) const registerPlaceRow = useCallback((placeId: number, element: HTMLDivElement | null) => { if (element) { @@ -267,6 +277,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) { listImportEnrich, setListImportEnrich, canEnrichImport, availableListImportProviders, hasMultipleListImportProviders, handleListImport, search, setSearch, filter, setFilter, categoryFilters, setCategoryFilters, + ratingSort, setRatingSort, selectMode, setSelectMode, selectedIds, setSelectedIds, pendingDeleteIds, setPendingDeleteIds, categoryPickerOpen, setCategoryPickerOpen, saveToListOpen, setSaveToListOpen, collectionsEnabled, tripId, diff --git a/client/src/components/shared/StarRating.tsx b/client/src/components/shared/StarRating.tsx new file mode 100644 index 000000000..760a81562 --- /dev/null +++ b/client/src/components/shared/StarRating.tsx @@ -0,0 +1,152 @@ +import React, { useState } from 'react' +import ReactDOM from 'react-dom' +import { Star } from 'lucide-react' +import { avatarSrc } from '../../utils/avatarSrc' +import { useAuthStore } from '../../store/authStore' +import { useTranslation } from '../../i18n' +import type { PlaceRatingVote } from '@trek/shared' + +const STAR_FILL = '#facc15' + +interface StarsProps { + /** Value the stars visualize (average for display, own vote while hovering). */ + value: number + size?: number + /** Interactive mode: hover previews, click casts/clears the own vote. */ + onRate?: (rating: number | null) => void + myRating?: number | null + ariaLabel?: string +} + +/** Five stars with fractional fill; interactive when onRate is set (#1435). + * Clicking the star matching the own vote clears it. */ +export function Stars({ value, size = 15, onRate, myRating = null, ariaLabel }: StarsProps) { + const [hover, setHover] = useState(null) + const shown = hover ?? value + + return ( +
setHover(null)} + > + {[1, 2, 3, 4, 5].map(i => { + // Fractional fill for averages: full below, empty above, clipped between. + const fill = Math.max(0, Math.min(1, shown - (i - 1))) + const star = ( + + + {fill > 0 && ( + + + + )} + + ) + if (!onRate) return star + return ( + + ) + })} +
+ ) +} + +interface PlaceRatingProps { + ratings: PlaceRatingVote[] + ratingAvg: number | null | undefined + onRate?: (rating: number | null) => void + size?: number + /** Hide the voter avatar strip (compact mobile rows). */ + compact?: boolean +} + +/** + * The collaborative rating row (#1435): interactive stars (hover previews the + * own vote, the average fill shows otherwise), the numeric average, and the + * voter avatars. Hovering reveals a custom tooltip listing every voter with + * their stars. + */ +export default function PlaceRating({ ratings, ratingAvg, onRate, size = 16, compact = false }: PlaceRatingProps) { + const { t } = useTranslation() + const currentUserId = useAuthStore(s => s.user?.id) + const [tip, setTip] = useState<{ top: number; left: number } | null>(null) + const myRating = ratings.find(r => r.user_id === currentUserId)?.rating ?? null + const avg = ratingAvg ?? null + + const showTip = (e: React.MouseEvent) => { + if (ratings.length === 0) return + const r = e.currentTarget.getBoundingClientRect() + setTip({ top: r.top, left: r.left + r.width / 2 }) + } + + return ( +
setTip(null)} + > + + {avg !== null ? ( + + {(Math.round(avg * 10) / 10).toLocaleString()} ({ratings.length}) + + ) : ( + {t('places.notRated')} + )} + + {/* Who voted — avatar strip. */} + {!compact && ratings.length > 0 && ( + + {ratings.slice(0, 6).map((r, i) => ( + + {r.avatar ? : r.username?.[0]?.toUpperCase()} + + ))} + + )} + + {/* Custom voter tooltip — fixed at the root so nothing clips it. */} + {tip && ratings.length > 0 && ReactDOM.createPortal( +
+ {ratings.map(r => ( +
+ + {r.username}{r.user_id === currentUserId ? ` (${t('vacay.you')})` : ''} + + +
+ ))} +
, + document.body, + )} +
+ ) +} diff --git a/client/src/mobile/screens/collections/MCollPlaceSheet.tsx b/client/src/mobile/screens/collections/MCollPlaceSheet.tsx index 9624d51ea..9533d7708 100644 --- a/client/src/mobile/screens/collections/MCollPlaceSheet.tsx +++ b/client/src/mobile/screens/collections/MCollPlaceSheet.tsx @@ -11,6 +11,7 @@ import { normalizeImageFile } from '../../../utils/convertHeic' import { getApiErrorMessage } from '../../../types' import { normalizeLinkUrl, STATUS_ORDER } from '../../../pages/collections/collectionsModel' import MSheet from '../../components/MSheet' +import PlaceRating from '../../../components/shared/StarRating' import MCollCategoryPicker from './MCollCategoryPicker' import MCollLinksEditor from './MCollLinksEditor' import { STATUS_SPEC } from './collectionsMobileModel' @@ -38,6 +39,8 @@ interface MCollPlaceSheetProps { onUploadImage?: (file: File) => Promise onCopyToTrip: () => void onRemove: () => void + /** Cast/clear the current user's star vote (#1435); every member may vote. */ + onRate?: (rating: number | null) => Promise | void t: TranslationFn } @@ -48,7 +51,7 @@ interface MCollPlaceSheetProps { * description / links. */ export default function MCollPlaceSheet({ - place, canEdit, canDelete, categories, labels, onClose, onSetStatus, onSave, onUploadImage, onCopyToTrip, onRemove, t, + place, canEdit, canDelete, categories, labels, onClose, onSetStatus, onSave, onUploadImage, onCopyToTrip, onRemove, onRate, t, }: MCollPlaceSheetProps) { const toast = useToast() const imageInputRef = useRef(null) @@ -231,6 +234,13 @@ export default function MCollPlaceSheet({ })}
+ {/* Collaborative rating (#1435) — tap a star to cast/clear your vote. */} + {onRate && ( +
+ +
+ )} + {editing ? ( <> {t('common.name').toUpperCase()} diff --git a/client/src/mobile/screens/collections/MCollections.tsx b/client/src/mobile/screens/collections/MCollections.tsx index b282866fd..1b15c24b5 100644 --- a/client/src/mobile/screens/collections/MCollections.tsx +++ b/client/src/mobile/screens/collections/MCollections.tsx @@ -84,13 +84,14 @@ export default function MCollections() { setLabelDrop(false) }, [c.activeId]) - // The mobile toolbar has no category filter control, but the shared hook - // still applies one set on desktop — keep it cleared so resizing to phone - // doesn't silently filter the list and map. - const { categoryFilter, setCategoryFilter } = c + // The mobile toolbar has no category or rating filter control, but the shared + // hook still applies whatever was set on desktop — keep both cleared so + // resizing to phone doesn't silently filter the list and map (#1435). + const { categoryFilter, setCategoryFilter, ratingFilter, setRatingFilter } = c useEffect(() => { if (categoryFilter !== 'all') setCategoryFilter('all') - }, [categoryFilter, setCategoryFilter]) + if (ratingFilter !== 'all') setRatingFilter('all') + }, [categoryFilter, setCategoryFilter, ratingFilter, setRatingFilter]) const title = c.isAllSaved ? t('collections.allSaved') : (c.activeCollection?.name ?? t('collections.title')) const isRealList = !c.isAllSaved && typeof c.activeId === 'number' @@ -456,6 +457,7 @@ export default function MCollections() { onUploadImage={file => c.uploadPlaceImage(c.selectedPlace!.id, file)} onCopyToTrip={c.openCopyForSelectedPlace} onRemove={c.handleDetailRemove} + onRate={r => c.handleRatePlace(c.selectedPlace!.id, r)} t={t} /> diff --git a/client/src/mobile/screens/trip/sheets/MPlaceSheet.tsx b/client/src/mobile/screens/trip/sheets/MPlaceSheet.tsx index d10db7b4a..f0053d8bf 100644 --- a/client/src/mobile/screens/trip/sheets/MPlaceSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MPlaceSheet.tsx @@ -13,6 +13,7 @@ import { useAddonStore } from '../../../../store/addonStore' import { useSaveToCollectionStore } from '../../../../store/saveToCollectionStore' import { collectionTargetFromPlace } from '../lib/collectionTarget' import { getCategoryIcon } from '../../../../components/shared/categoryIcons' +import PlaceRating from '../../../../components/shared/StarRating' import { avatarSrc } from '../../../../utils/avatarSrc' import { openFile } from '../../../../utils/fileDownload' import { getGoogleMapsUrlForPlace } from '../../../../components/Planner/placeGoogleMaps' @@ -168,6 +169,16 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) { openSavePicker(collectionTargetFromPlace(place)) } + // Collaborative rating (#1435): every trip member casts their own star vote. + const handleRate = async (rating: number | null) => { + if (!place) return + try { + await planner.tripActions.ratePlace(planner.tripId, place.id, rating) + } catch (err: unknown) { + planner.toast.error(err instanceof Error ? err.message : t('common.unknownError')) + } + } + const showOnMap = () => { close() if (shell.trTab !== 'plan') shell.setTrTab('plan') @@ -259,6 +270,11 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) { )} + {/* Collaborative rating (#1435) — tap a star to cast/clear your vote. */} +
+ +
+ {place.description && (
{place.description}
diff --git a/client/src/pages/CollectionsPage.tsx b/client/src/pages/CollectionsPage.tsx index 50e497acb..cde250c5e 100644 --- a/client/src/pages/CollectionsPage.tsx +++ b/client/src/pages/CollectionsPage.tsx @@ -102,8 +102,10 @@ function CollectionsPageDesktop(): React.ReactElement { counts={c.counts} categoryFilter={c.categoryFilter} categoryOptions={c.categoryOptions} + ratingFilter={c.ratingFilter} onStatusFilter={c.setStatusFilter} onCategoryFilter={c.setCategoryFilter} + onRatingFilter={c.setRatingFilter} showLabels={isRealList} labelOptions={c.labelOptions} labelFilter={c.labelFilter} @@ -298,6 +300,7 @@ function CollectionsPageDesktop(): React.ReactElement { onUploadImage={file => c.uploadPlaceImage(c.selectedPlace!.id, file)} onCopyToTrip={c.openCopyForSelectedPlace} onRemove={c.handleDetailRemove} + onRate={r => c.handleRatePlace(c.selectedPlace!.id, r)} t={t} /> )} diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index c8063ea09..e1d4917ca 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -573,6 +573,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null { }} onUpdatePlace={async (placeId, data) => { try { await tripActions.updatePlace(tripId, placeId, data) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } }} onUploadImage={async (placeId, file) => { await tripActions.uploadPlaceImage(tripId, placeId, file) }} + onRate={async (placeId, rating) => { try { await tripActions.ratePlace(tripId, placeId, rating) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } }} leftWidth={(isMobile || window.innerWidth < 900) ? 0 : (leftCollapsed ? 0 : leftWidth)} rightWidth={(isMobile || window.innerWidth < 900) ? 0 : (rightCollapsed ? 0 : rightWidth)} /> @@ -612,6 +613,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null { }} onUpdatePlace={async (placeId, data) => { try { await tripActions.updatePlace(tripId, placeId, data) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } }} onUploadImage={async (placeId, file) => { await tripActions.uploadPlaceImage(tripId, placeId, file) }} + onRate={async (placeId, rating) => { try { await tripActions.ratePlace(tripId, placeId, rating) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } }} leftWidth={0} rightWidth={0} /> diff --git a/client/src/pages/collections/collectionsModel.test.ts b/client/src/pages/collections/collectionsModel.test.ts index eed0788ac..5d1593173 100644 --- a/client/src/pages/collections/collectionsModel.test.ts +++ b/client/src/pages/collections/collectionsModel.test.ts @@ -35,6 +35,7 @@ interface PlaceLike { sort_order?: number; created_at?: string; label_ids?: number[]; + rating_avg?: number | null; } function cp(overrides: PlaceLike): CollectionPlace { return { @@ -80,6 +81,25 @@ describe('collectionsModel', () => { expect(out.map(p => p.id)).toEqual([1, 3]); }); + it('FE-COLLECTIONS-MODEL-036: rating filter keeps places whose average is >= the floor (#1435)', () => { + const places = [ + cp({ id: 1, name: 'A', status: 'idea', rating_avg: 4.5 }), + cp({ id: 2, name: 'B', status: 'idea', rating_avg: 3 }), + cp({ id: 3, name: 'C', status: 'idea', rating_avg: null }), // unrated → excluded by a floor + cp({ id: 4, name: 'D', status: 'idea' }), // unrated (undefined) → excluded + ]; + const out = filterPlaces(places, 'all', '', 'all', [], 4); + expect(out.map(p => p.id)).toEqual([1]); + }); + + it("FE-COLLECTIONS-MODEL-037: rating filter 'all' keeps unrated places too", () => { + const places = [ + cp({ id: 1, name: 'A', status: 'idea', rating_avg: 5 }), + cp({ id: 2, name: 'B', status: 'idea', rating_avg: null }), + ]; + expect(filterPlaces(places, 'all', '', 'all', [], 'all')).toHaveLength(2); + }); + it('FE-COLLECTIONS-MODEL-004: search matches place name (case-insensitive)', () => { const places = [ cp({ id: 1, name: 'Eiffel Tower', status: 'idea' }), diff --git a/client/src/pages/collections/collectionsModel.ts b/client/src/pages/collections/collectionsModel.ts index d7b572287..894bc5ec6 100644 --- a/client/src/pages/collections/collectionsModel.ts +++ b/client/src/pages/collections/collectionsModel.ts @@ -54,12 +54,15 @@ export function filterPlaces( search: string, categoryFilter: number | 'all' = 'all', labelFilter: number[] = [], + ratingFilter: number | 'all' = 'all', ): CollectionPlace[] { const q = search.trim().toLowerCase() return places.filter(p => { if (!p) return false if (statusFilter !== 'all' && p.status !== statusFilter) return false if (categoryFilter !== 'all' && (p.category_id ?? null) !== categoryFilter) return false + // Minimum-average-stars filter (#1435): unrated places drop out once a floor is set. + if (ratingFilter !== 'all' && (p.rating_avg == null || p.rating_avg < ratingFilter)) return false if (labelFilter.length && !labelFilter.some(id => (p.label_ids ?? []).includes(id))) return false if (!q) return true return ( diff --git a/client/src/pages/collections/useCollections.ts b/client/src/pages/collections/useCollections.ts index a848c1e90..8b167b000 100644 --- a/client/src/pages/collections/useCollections.ts +++ b/client/src/pages/collections/useCollections.ts @@ -50,15 +50,15 @@ export function useCollections() { const store = useCollectionStore() const { collections, activeId, places, members, labels, incomingInvites, - view, statusFilter, categoryFilter, labelFilter, search, selectedPlaceId, selectMode, selectedIds, + view, statusFilter, categoryFilter, ratingFilter, labelFilter, search, selectedPlaceId, selectMode, selectedIds, loading, placesLoading, loadAll, setActive, refreshActive, loadCollection, deleteCollection, - setStatus, updatePlace, uploadPlaceImage, deletePlace, deleteMany, copyToTrip, clearSelection, + setStatus, updatePlace, uploadPlaceImage, ratePlace, deletePlace, deleteMany, copyToTrip, clearSelection, moveToList, duplicateToList, setSelectedIds, createLabel, updateLabel, deleteLabel, assignLabels, acceptInvite, declineInvite, - setView, setStatusFilter, setCategoryFilter, setLabelFilter, setSearch, setSelectedPlaceId, setSelectMode, toggleSelect, + setView, setStatusFilter, setCategoryFilter, setRatingFilter, setLabelFilter, setSearch, setSelectedPlaceId, setSelectMode, toggleSelect, } = store // ── Page-local UI state ───────────────────────────────────────────── @@ -167,8 +167,8 @@ export function useCollections() { // Labels are per-collection, so never apply them on the "All saved" union. const visiblePlaces = useMemo( - () => sortPlaces(filterPlaces(places, statusFilter, search, categoryFilter, isAllSaved ? [] : labelFilter)), - [places, statusFilter, search, categoryFilter, isAllSaved, labelFilter], + () => sortPlaces(filterPlaces(places, statusFilter, search, categoryFilter, isAllSaved ? [] : labelFilter, ratingFilter)), + [places, statusFilter, search, categoryFilter, isAllSaved, labelFilter, ratingFilter], ) // Categories actually present in this list, for the category filter dropdown. const categoryOptions = useMemo(() => presentCategories(places), [places]) @@ -221,6 +221,14 @@ export function useCollections() { } }, [deletePlace, toast, t]) + const handleRatePlace = useCallback(async (placeId: number, rating: number | null) => { + try { + await ratePlace(placeId, rating) + } catch (err) { + toast.error(getApiErrorMessage(err, t('common.error'))) + } + }, [ratePlace, toast, t]) + const handleDeleteSelected = useCallback(async () => { if (selectedIds.length === 0) return try { @@ -378,11 +386,11 @@ export function useCollections() { myRole, canEdit, canDelete, canShare, shareMemberCount, activeId, places, visiblePlaces, mappable, members, incomingInvites, counts, - view, statusFilter, categoryFilter, categoryOptions, search, selectedPlaceId, selectMode, selectedIds, + view, statusFilter, categoryFilter, categoryOptions, ratingFilter, search, selectedPlaceId, selectMode, selectedIds, labels, labelFilter, labelOptions, loading, placesLoading, // store setters - setView, setStatusFilter, setCategoryFilter, setLabelFilter, setSearch, setSelectedPlaceId, setSelectMode, toggleSelect, + setView, setStatusFilter, setCategoryFilter, setRatingFilter, setLabelFilter, setSearch, setSelectedPlaceId, setSelectMode, toggleSelect, updatePlace, uploadPlaceImage, // labels showLabelManager, setShowLabelManager, labelPickerOpen, setLabelPickerOpen, @@ -399,7 +407,7 @@ export function useCollections() { copyIds, openCopyForSelectedPlace, openCopyForSelection, closeCopy, handleCopyToTrip, // handlers handleSelectList, handleDeleteList, - handleStatusChange, handleDeletePlace, handleDeleteSelected, + handleStatusChange, handleRatePlace, handleDeletePlace, handleDeleteSelected, handleAcceptInvite, handleDeclineInvite, allVisibleSelected, handleSelectAll, listPickerMode, setListPickerMode, handleMoveToList, handleDuplicateToList, diff --git a/client/src/store/collectionStore.ts b/client/src/store/collectionStore.ts index 4471b702e..7d52876d9 100644 --- a/client/src/store/collectionStore.ts +++ b/client/src/store/collectionStore.ts @@ -34,6 +34,7 @@ interface CollectionState { view: CollectionView statusFilter: StatusFilter categoryFilter: number | 'all' + ratingFilter: number | 'all' labelFilter: number[] search: string selectedPlaceId: number | null @@ -56,6 +57,7 @@ interface CollectionState { setStatus: (placeId: number, status: CollectionStatus) => Promise updatePlace: (placeId: number, body: CollectionPlaceUpdateRequest) => Promise uploadPlaceImage: (placeId: number, file: File) => Promise + ratePlace: (placeId: number, rating: number | null) => Promise deletePlace: (placeId: number) => Promise deleteMany: (ids: number[]) => Promise copyToTrip: (tripId: number, placeIds: number[], force?: boolean) => Promise<{ copied: number; skipped: { id: number; name: string }[] }> @@ -78,6 +80,7 @@ interface CollectionState { setView: (view: CollectionView) => void setStatusFilter: (filter: StatusFilter) => void setCategoryFilter: (filter: number | 'all') => void + setRatingFilter: (filter: number | 'all') => void setLabelFilter: (labelIds: number[]) => void setSearch: (search: string) => void setSelectedPlaceId: (id: number | null) => void @@ -97,6 +100,7 @@ export const useCollectionStore = create((set, get) => ({ view: 'list', statusFilter: 'all', categoryFilter: 'all', + ratingFilter: 'all', labelFilter: [], search: '', selectedPlaceId: null, @@ -238,6 +242,11 @@ export const useCollectionStore = create((set, get) => ({ if (updated) set({ places: get().places.map(p => (p.id === placeId ? updated : p)) }) }, + ratePlace: async (placeId: number, rating: number | null) => { + const updated = await collectionsApi.ratePlace(placeId, rating) + if (updated) set({ places: get().places.map(p => (p.id === placeId ? updated : p)) }) + }, + deletePlace: async (placeId: number) => { set({ places: get().places.filter(p => p.id !== placeId) }) await collectionsApi.deletePlace(placeId) @@ -383,6 +392,7 @@ export const useCollectionStore = create((set, get) => ({ setView: (view: CollectionView) => set({ view }), setStatusFilter: (filter: StatusFilter) => set({ statusFilter: filter }), setCategoryFilter: (filter: number | 'all') => set({ categoryFilter: filter }), + setRatingFilter: (filter: number | 'all') => set({ ratingFilter: filter }), setLabelFilter: (labelIds: number[]) => set({ labelFilter: labelIds }), setSearch: (search: string) => set({ search }), setSelectedPlaceId: (id: number | null) => set({ selectedPlaceId: id }), diff --git a/client/src/store/slices/placesSlice.ts b/client/src/store/slices/placesSlice.ts index da1a27a79..e87e08648 100644 --- a/client/src/store/slices/placesSlice.ts +++ b/client/src/store/slices/placesSlice.ts @@ -13,6 +13,7 @@ export interface PlacesSlice { addPlace: (tripId: number | string, placeData: Partial & { name: string }) => Promise updatePlace: (tripId: number | string, placeId: number, placeData: Partial) => Promise uploadPlaceImage: (tripId: number | string, placeId: number, file: File) => Promise + ratePlace: (tripId: number | string, placeId: number, rating: number | null) => Promise deletePlace: (tripId: number | string, placeId: number) => Promise deletePlacesMany: (tripId: number | string, placeIds: number[]) => Promise updatePlacesMany: (tripId: number | string, placeIds: number[], patch: Partial) => Promise @@ -81,6 +82,18 @@ export const createPlacesSlice = (set: SetState, get: GetState): PlacesSlice => } }, + ratePlace: async (tripId, placeId, rating) => { + // Casts (or clears, rating null) the current user's own star vote (#1435) + // and applies the returned place with the fresh average. + try { + const data = await placesApi.rate(tripId, placeId, rating) + applyUpdatedPlace(set, placeId, data.place) + return data.place + } catch (err: unknown) { + throw new Error(getApiErrorMessage(err, 'Error rating place')) + } + }, + deletePlace: async (tripId, placeId) => { try { await placeRepo.delete(tripId, placeId) diff --git a/server/src/db/database.ts b/server/src/db/database.ts index 09d0cdb3e..84f66700a 100644 --- a/server/src/db/database.ts +++ b/server/src/db/database.ts @@ -96,6 +96,9 @@ interface PlaceWithCategory extends Place { interface PlaceWithTags extends Place { category: { id: number; name: string; color: string; icon: string } | null; tags: Tag[]; + ratings: { user_id: number; username: string; avatar: string | null; rating: number }[]; + rating_avg: number | null; + rating_count: number; } function getPlaceWithTags(placeId: number | string): PlaceWithTags | null { @@ -114,6 +117,14 @@ function getPlaceWithTags(placeId: number | string): PlaceWithTags | null { WHERE pt.place_id = ? `).all(placeId) as Tag[]; + // Collaborative ratings (#1435): every voter with username/avatar for the + // who-voted tooltip; the displayed value is the average. + const ratings = db.prepare(` + SELECT pr.user_id, u.username, u.avatar, pr.rating FROM place_ratings pr + JOIN users u ON pr.user_id = u.id + WHERE pr.place_id = ? ORDER BY pr.created_at + `).all(placeId) as { user_id: number; username: string; avatar: string | null; rating: number }[]; + return { ...place, category: place.category_id ? { @@ -123,6 +134,9 @@ function getPlaceWithTags(placeId: number | string): PlaceWithTags | null { icon: place.category_icon!, } : null, tags, + ratings, + rating_avg: ratings.length > 0 ? ratings.reduce((s, r) => s + r.rating, 0) / ratings.length : null, + rating_count: ratings.length, }; } diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 59124359b..f01d904aa 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -3727,6 +3727,33 @@ function runMigrations(db: Database.Database): void { `); db.exec('CREATE INDEX IF NOT EXISTS idx_vacay_shares_user ON vacay_shares (user_id);'); }, + // Collaborative place ratings (#1435): every trip member can rate a trip + // place 1-5, every collection member a saved place; the displayed value is + // the average. One row per user and place, mirroring collab_poll_votes. + () => { + db.exec(` + CREATE TABLE IF NOT EXISTS place_ratings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + place_id INTEGER NOT NULL REFERENCES places(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + rating INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(place_id, user_id) + ); + `); + db.exec('CREATE INDEX IF NOT EXISTS idx_place_ratings_place ON place_ratings (place_id);'); + db.exec(` + CREATE TABLE IF NOT EXISTS collection_place_ratings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + collection_place_id INTEGER NOT NULL REFERENCES collection_places(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + rating INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(collection_place_id, user_id) + ); + `); + db.exec('CREATE INDEX IF NOT EXISTS idx_collection_place_ratings_place ON collection_place_ratings (collection_place_id);'); + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 43955b77b..96fa363ed 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -155,6 +155,15 @@ function createTables(db: Database.Database): void { PRIMARY KEY (place_id, tag_id) ); + CREATE TABLE IF NOT EXISTS place_ratings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + place_id INTEGER NOT NULL REFERENCES places(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + rating INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(place_id, user_id) + ); + CREATE TABLE IF NOT EXISTS day_assignments ( id INTEGER PRIMARY KEY AUTOINCREMENT, day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE, @@ -425,6 +434,15 @@ function createTables(db: Database.Database): void { updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); + CREATE TABLE IF NOT EXISTS collection_place_ratings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + collection_place_id INTEGER NOT NULL REFERENCES collection_places(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + rating INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(collection_place_id, user_id) + ); + CREATE TABLE IF NOT EXISTS collection_place_tags ( collection_place_id INTEGER NOT NULL REFERENCES collection_places(id) ON DELETE CASCADE, tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE, diff --git a/server/src/mcp/scopes.ts b/server/src/mcp/scopes.ts index 3def32e59..5473a245b 100644 --- a/server/src/mcp/scopes.ts +++ b/server/src/mcp/scopes.ts @@ -9,6 +9,8 @@ export const SCOPES = { TRIPS_SHARE: 'trips:share', PLACES_READ: 'places:read', PLACES_WRITE: 'places:write', + COLLECTIONS_READ: 'collections:read', + COLLECTIONS_WRITE: 'collections:write', ATLAS_READ: 'atlas:read', ATLAS_WRITE: 'atlas:write', PACKING_READ: 'packing:read', @@ -49,6 +51,8 @@ export const SCOPE_INFO: Record = { 'trips:share': { label: 'Manage share links', description: 'Create, update, and revoke public share links for trips', group: 'Trips' }, 'places:read': { label: 'View places & map data', description: 'Read places, day assignments, tags, and categories', group: 'Places' }, 'places:write': { label: 'Manage places', description: 'Create, update, and delete places, assignments, and tags', group: 'Places' }, + 'collections:read': { label: 'View collections', description: 'Read saved-place collections, their places, ratings, labels, and members', group: 'Collections' }, + 'collections:write': { label: 'Manage collections', description: 'Create/edit collections, save, rate, label and copy places, and share lists', group: 'Collections' }, 'atlas:read': { label: 'View Atlas', description: 'Read visited countries, regions, and bucket list', group: 'Atlas' }, 'atlas:write': { label: 'Manage Atlas', description: 'Mark countries and regions visited, manage bucket list', group: 'Atlas' }, 'packing:read': { label: 'View packing lists', description: 'Read packing items, bags, and category assignees', group: 'Packing' }, diff --git a/server/src/mcp/tools.ts b/server/src/mcp/tools.ts index bb9e9a2ba..8a5cda142 100644 --- a/server/src/mcp/tools.ts +++ b/server/src/mcp/tools.ts @@ -8,6 +8,7 @@ import { registerMapsWeatherTools } from './tools/mapsWeather'; import { registerNotificationTools } from './tools/notifications'; import { registerAtlasTools } from './tools/atlas'; import { registerPlaceTools } from './tools/places'; +import { registerCollectionTools } from './tools/collections'; import { registerDayTools } from './tools/days'; import { registerBudgetTools } from './tools/budget'; import { registerPackingTools } from './tools/packing'; @@ -23,6 +24,8 @@ export function registerTools(server: McpServer, userId: number, scopes: string[ registerPlaceTools(server, userId, scopes); + registerCollectionTools(server, userId, scopes); + registerBudgetTools(server, userId, scopes); registerPackingTools(server, userId, scopes); diff --git a/server/src/mcp/tools/collections.ts b/server/src/mcp/tools/collections.ts new file mode 100644 index 000000000..f837c5c36 --- /dev/null +++ b/server/src/mcp/tools/collections.ts @@ -0,0 +1,398 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; +import { z } from 'zod'; +import { db } from '../../db/database'; +import { isDemoUser } from '../../services/authService'; +import * as collections from '../../services/collectionsService'; +import { + TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE, + TOOL_ANNOTATIONS_NON_IDEMPOTENT, demoDenied, ok, +} from './_shared'; +import { canRead, canWrite } from '../scopes'; +import { + collectionCreateRequestSchema, collectionUpdateRequestSchema, + collectionSavePlaceRequestSchema, collectionPlaceUpdateRequestSchema, + collectionCopyToTripRequestSchema, collectionLabelCreateRequestSchema, + collectionLabelUpdateRequestSchema, collectionLabelAssignRequestSchema, + collectionInviteRequestSchema, COLLECTION_STATUSES, COLLECTION_ROLES, +} from '@trek/shared'; + +/** Convert a thrown service error (httpError carries `.message`) into MCP error text. */ +function errText(err: unknown): string { + if (err instanceof Error) return err.message; + if (typeof err === 'string') return err; + return 'Operation failed'; +} +function fail(err: unknown) { + return { content: [{ type: 'text' as const, text: errText(err) }], isError: true }; +} + +/** + * Collections MCP surface (#1435) — the full set of actions a member can take on + * a saved-places list: browse, CRUD lists + places, status, collaborative star + * ratings, per-list labels, sharing, and copy-into-a-trip. Every service call + * enforces the caller's membership/role, so the tools stay thin wrappers. Place + * outputs carry the `ratings`/`rating_avg`/`rating_count` aggregate. + */ +export function registerCollectionTools(server: McpServer, userId: number, scopes: string[] | null): void { + const R = canRead(scopes, 'collections'); + const W = canWrite(scopes, 'collections'); + + const denyDemo = () => (isDemoUser(userId) ? demoDenied() : null); + + // ── Read ────────────────────────────────────────────────────────────── + + if (R) server.registerTool( + 'list_collections', + { + description: 'List all saved-place collections (lists) the user owns or has accepted a share for, plus any pending incoming invites. Use get_collection for a list\'s places.', + inputSchema: {}, + annotations: TOOL_ANNOTATIONS_READONLY, + }, + async () => { + try { return ok(collections.listCollections(userId)); } catch (err) { return fail(err); } + } + ); + + if (R) server.registerTool( + 'get_collection', + { + description: 'Get one collection with its members, labels, and all saved places. Each place includes rating_avg / rating_count and the per-member ratings (#1435) so you can plan around highly-rated spots.', + inputSchema: { collectionId: z.number().int().positive() }, + annotations: TOOL_ANNOTATIONS_READONLY, + }, + async ({ collectionId }) => { + try { return ok(collections.getCollection(userId, collectionId)); } catch (err) { return fail(err); } + } + ); + + if (R) server.registerTool( + 'available_collection_users', + { + description: 'List users who can still be invited to a collection (excludes current members and guests). Use the returned ids with invite_to_collection.', + inputSchema: { collectionId: z.number().int().positive() }, + annotations: TOOL_ANNOTATIONS_READONLY, + }, + async ({ collectionId }) => { + try { + // Owner-only, mirroring the REST gate — availableUsers() itself does no + // access check, so without this any token could enumerate the user list. + collections.assertAccess(userId, collectionId); + if (!collections.isOwner(userId, collectionId)) { + return { content: [{ type: 'text' as const, text: 'Only the collection owner can view invitable users.' }], isError: true }; + } + return ok({ users: collections.availableUsers(userId, collectionId) }); + } catch (err) { return fail(err); } + } + ); + + // ── Collections CRUD ───────────────────────────────────────────────── + + if (W) server.registerTool( + 'create_collection', + { + description: 'Create a new saved-place collection (list) owned by the user.', + inputSchema: collectionCreateRequestSchema.shape, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, + }, + async (body) => { + const demo = denyDemo(); if (demo) return demo; + try { return ok({ collection: collections.createCollection(userId, body) }); } catch (err) { return fail(err); } + } + ); + + if (W) server.registerTool( + 'update_collection', + { + description: 'Update a collection\'s name, description, colour, icon, cover, links, or sort order. Owner/admin only.', + inputSchema: { collectionId: z.number().int().positive(), ...collectionUpdateRequestSchema.shape }, + annotations: TOOL_ANNOTATIONS_WRITE, + }, + async ({ collectionId, ...body }) => { + const demo = denyDemo(); if (demo) return demo; + try { return ok({ collection: collections.updateCollection(userId, collectionId, body) }); } catch (err) { return fail(err); } + } + ); + + if (W) server.registerTool( + 'delete_collection', + { + description: 'Permanently delete a collection and all its saved places. Owner only. This cannot be undone.', + inputSchema: { collectionId: z.number().int().positive() }, + annotations: TOOL_ANNOTATIONS_DELETE, + }, + async ({ collectionId }) => { + const demo = denyDemo(); if (demo) return demo; + try { collections.deleteCollection(userId, collectionId); return ok({ success: true }); } catch (err) { return fail(err); } + } + ); + + if (W) server.registerTool( + 'reorder_collections', + { + description: 'Reorder the user\'s collections. Pass every collection id in the desired order.', + inputSchema: { orderedIds: z.array(z.number().int().positive()).min(1) }, + annotations: TOOL_ANNOTATIONS_WRITE, + }, + async ({ orderedIds }) => { + const demo = denyDemo(); if (demo) return demo; + try { collections.reorderCollections(userId, orderedIds); return ok({ success: true }); } catch (err) { return fail(err); } + } + ); + + // ── Places ──────────────────────────────────────────────────────────── + + if (W) server.registerTool( + 'save_place_to_collection', + { + description: 'Save a place into a collection from a raw payload (name required; set google_place_id/osm_id from search_place for rich details). Returns a duplicate marker instead of saving when a similar place already exists, unless force is true.', + inputSchema: collectionSavePlaceRequestSchema.shape, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, + }, + async (body) => { + const demo = denyDemo(); if (demo) return demo; + try { return ok(collections.savePlace(userId, body)); } catch (err) { return fail(err); } + } + ); + + if (W) server.registerTool( + 'save_trip_places_to_collection', + { + description: 'Copy one or more existing trip places into a collection (the server reads each place). Their star ratings (#1435) travel along for members shared on both. Duplicates are skipped unless force is true.', + inputSchema: { + collectionId: z.number().int().positive(), + tripId: z.number().int().positive(), + placeIds: z.array(z.number().int().positive()).min(1).max(1000), + force: z.boolean().optional(), + }, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, + }, + async ({ collectionId, tripId, placeIds, force }) => { + const demo = denyDemo(); if (demo) return demo; + try { return ok(collections.saveFromTripPlaces(userId, collectionId, tripId, placeIds, force)); } catch (err) { return fail(err); } + } + ); + + if (W) server.registerTool( + 'update_collection_place', + { + description: 'Update a saved place\'s name, description, notes, status, category, links, tags, labels, image, or move it to another collection (set collection_id).', + inputSchema: { placeId: z.number().int().positive(), ...collectionPlaceUpdateRequestSchema.shape }, + annotations: TOOL_ANNOTATIONS_WRITE, + }, + async ({ placeId, ...body }) => { + const demo = denyDemo(); if (demo) return demo; + try { return ok({ place: collections.updatePlace(userId, placeId, body) }); } catch (err) { return fail(err); } + } + ); + + if (W) server.registerTool( + 'set_collection_place_status', + { + description: 'Set a saved place\'s status: idea, want, or visited.', + inputSchema: { placeId: z.number().int().positive(), status: z.enum(COLLECTION_STATUSES) }, + annotations: TOOL_ANNOTATIONS_WRITE, + }, + async ({ placeId, status }) => { + const demo = denyDemo(); if (demo) return demo; + try { return ok({ place: collections.setStatus(userId, placeId, status) }); } catch (err) { return fail(err); } + } + ); + + if (W) server.registerTool( + 'rate_collection_place', + { + description: "Set or clear the current user's 1-5 star rating on a saved collection place (#1435). Every member rates independently; the place shows the average. Pass null (or omit rating) to remove the user's vote. Ratings a member casts here follow the place into any trip it is later copied to. Use the ratings to capture the user's preferences.", + inputSchema: { + placeId: z.number().int().positive(), + rating: z.number().int().min(1).max(5).nullable().optional().describe('1-5 stars; null/omitted clears the vote'), + }, + annotations: TOOL_ANNOTATIONS_WRITE, + }, + async ({ placeId, rating }) => { + const demo = denyDemo(); if (demo) return demo; + try { return ok({ place: collections.setRating(userId, placeId, rating ?? null) }); } catch (err) { return fail(err); } + } + ); + + if (W) server.registerTool( + 'delete_collection_place', + { + description: 'Remove a saved place from its collection. Requires delete permission on the list.', + inputSchema: { placeId: z.number().int().positive() }, + annotations: TOOL_ANNOTATIONS_DELETE, + }, + async ({ placeId }) => { + const demo = denyDemo(); if (demo) return demo; + try { collections.deletePlace(userId, placeId); return ok({ success: true }); } catch (err) { return fail(err); } + } + ); + + if (W) server.registerTool( + 'copy_collection_places_to_trip', + { + description: 'Copy one or more saved collection places into a trip (dedup precheck on the server). Ratings (#1435) travel into the trip; trip members keep voting there. Requires edit access to the target trip.', + inputSchema: collectionCopyToTripRequestSchema.shape, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, + }, + async (body) => { + const demo = denyDemo(); if (demo) return demo; + try { return ok(collections.copyToTrip(userId, body)); } catch (err) { return fail(err); } + } + ); + + // ── Labels ──────────────────────────────────────────────────────────── + + if (W) server.registerTool( + 'create_collection_label', + { + description: 'Create a custom per-collection label (name + optional hex colour) for grouping/filtering places.', + inputSchema: collectionLabelCreateRequestSchema.shape, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, + }, + async ({ collection_id, name, color }) => { + const demo = denyDemo(); if (demo) return demo; + try { return ok({ label: collections.createLabel(userId, collection_id, name, color) }); } catch (err) { return fail(err); } + } + ); + + if (W) server.registerTool( + 'update_collection_label', + { + description: 'Rename or recolour a collection label, or change its sort order.', + inputSchema: { labelId: z.number().int().positive(), ...collectionLabelUpdateRequestSchema.shape }, + annotations: TOOL_ANNOTATIONS_WRITE, + }, + async ({ labelId, ...body }) => { + const demo = denyDemo(); if (demo) return demo; + try { return ok({ label: collections.updateLabel(userId, labelId, body) }); } catch (err) { return fail(err); } + } + ); + + if (W) server.registerTool( + 'delete_collection_label', + { + description: 'Delete a collection label; its assignments on places are cleared.', + inputSchema: { labelId: z.number().int().positive() }, + annotations: TOOL_ANNOTATIONS_DELETE, + }, + async ({ labelId }) => { + const demo = denyDemo(); if (demo) return demo; + try { collections.deleteLabel(userId, labelId); return ok({ success: true }); } catch (err) { return fail(err); } + } + ); + + if (W) server.registerTool( + 'assign_collection_labels', + { + description: 'Add (or with remove=true, take away) one or more labels across a set of saved places. Only labels belonging to each place\'s own list are applied.', + inputSchema: { ...collectionLabelAssignRequestSchema.shape, remove: z.boolean().optional() }, + annotations: TOOL_ANNOTATIONS_WRITE, + }, + async ({ label_ids, place_ids, remove }) => { + const demo = denyDemo(); if (demo) return demo; + try { return ok(collections.assignLabels(userId, label_ids, place_ids, remove ?? false)); } catch (err) { return fail(err); } + } + ); + + // ── Sharing ─────────────────────────────────────────────────────────── + + if (W) server.registerTool( + 'invite_to_collection', + { + description: 'Invite a user (by id, from available_collection_users) to collaborate on a collection, with a role of viewer, editor, or admin (default editor). Owner only.', + inputSchema: collectionInviteRequestSchema.shape, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, + }, + async ({ collection_id, user_id, role }) => { + const demo = denyDemo(); if (demo) return demo; + const me = db.prepare('SELECT username, email FROM users WHERE id = ?').get(userId) as { username: string; email: string } | undefined; + const res = collections.sendInvite(collection_id, userId, me?.username ?? '', me?.email ?? '', user_id, role); + if (res.error) return { content: [{ type: 'text' as const, text: res.error }], isError: true }; + return ok({ success: true }); + } + ); + + if (W) server.registerTool( + 'set_collection_member_role', + { + description: 'Change an accepted member\'s permission role (viewer, editor, or admin). Owner only.', + inputSchema: { + collectionId: z.number().int().positive(), + userId: z.number().int().positive(), + role: z.enum(COLLECTION_ROLES), + }, + annotations: TOOL_ANNOTATIONS_WRITE, + }, + async ({ collectionId, userId: targetUserId, role }) => { + const demo = denyDemo(); if (demo) return demo; + try { collections.setMemberRole(userId, collectionId, targetUserId, role); return ok({ success: true }); } catch (err) { return fail(err); } + } + ); + + if (W) server.registerTool( + 'remove_collection_member', + { + description: 'Remove an accepted member from a shared collection (a kick). Owner only.', + inputSchema: { collectionId: z.number().int().positive(), userId: z.number().int().positive() }, + annotations: TOOL_ANNOTATIONS_DELETE, + }, + async ({ collectionId, userId: targetUserId }) => { + const demo = denyDemo(); if (demo) return demo; + try { collections.removeMember(userId, collectionId, targetUserId); return ok({ success: true }); } catch (err) { return fail(err); } + } + ); + + if (W) server.registerTool( + 'cancel_collection_invite', + { + description: 'Cancel a pending invite you sent to a user for a collection. Owner only.', + inputSchema: { collectionId: z.number().int().positive(), userId: z.number().int().positive() }, + annotations: TOOL_ANNOTATIONS_WRITE, + }, + async ({ collectionId, userId: targetUserId }) => { + const demo = denyDemo(); if (demo) return demo; + try { collections.cancelInvite(collectionId, userId, targetUserId); return ok({ success: true }); } catch (err) { return fail(err); } + } + ); + + if (W) server.registerTool( + 'accept_collection_invite', + { + description: 'Accept a pending invite to join a shared collection.', + inputSchema: { collectionId: z.number().int().positive() }, + annotations: TOOL_ANNOTATIONS_WRITE, + }, + async ({ collectionId }) => { + const demo = denyDemo(); if (demo) return demo; + const res = collections.acceptInvite(userId, collectionId, undefined); + if (res.error) return { content: [{ type: 'text' as const, text: res.error }], isError: true }; + return ok({ success: true }); + } + ); + + if (W) server.registerTool( + 'decline_collection_invite', + { + description: 'Decline a pending invite to a shared collection.', + inputSchema: { collectionId: z.number().int().positive() }, + annotations: TOOL_ANNOTATIONS_WRITE, + }, + async ({ collectionId }) => { + const demo = denyDemo(); if (demo) return demo; + try { collections.declineInvite(userId, collectionId, undefined); return ok({ success: true }); } catch (err) { return fail(err); } + } + ); + + if (W) server.registerTool( + 'leave_collection', + { + description: 'Leave a shared collection you are a member of. The owner cannot leave (delete the list instead).', + inputSchema: { collectionId: z.number().int().positive() }, + annotations: TOOL_ANNOTATIONS_WRITE, + }, + async ({ collectionId }) => { + const demo = denyDemo(); if (demo) return demo; + try { collections.leaveCollection(userId, collectionId, undefined); return ok({ success: true }); } catch (err) { return fail(err); } + } + ); +} diff --git a/server/src/mcp/tools/places.ts b/server/src/mcp/tools/places.ts index 76660ed85..c76ff93f2 100644 --- a/server/src/mcp/tools/places.ts +++ b/server/src/mcp/tools/places.ts @@ -2,7 +2,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; import { z } from 'zod'; import { canAccessTrip, db } from '../../db/database'; import { isDemoUser } from '../../services/authService'; -import { deletePlacesMany, updatePlacesMany, importGoogleList, importNaverList, listPlaces, createPlace, updatePlace, deletePlace } from '../../services/placeService'; +import { deletePlacesMany, updatePlacesMany, importGoogleList, importNaverList, listPlaces, createPlace, updatePlace, deletePlace, ratePlace } from '../../services/placeService'; import { createAssignment, dayExists } from '../../services/assignmentService'; import { onPlaceDeleted, reconcileTripSkeletons } from '../../services/journeyService'; import { listCategories } from '../../services/categoryService'; @@ -139,6 +139,28 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st } ); + if (W) server.registerTool( + 'rate_place', + { + description: "Set or clear the current user's 1-5 star rating on a trip place (#1435). Every trip member rates independently; the place shows the average. Omit rating (or pass null) to remove the user's vote. Use the ratings to capture the user's preferences and shape the itinerary around highly-rated places.", + inputSchema: { + tripId: z.number().int().positive(), + placeId: z.number().int().positive(), + rating: z.number().int().min(1).max(5).nullable().optional().describe('1-5 stars; null/omitted clears the vote'), + }, + annotations: TOOL_ANNOTATIONS_WRITE, + }, + async ({ tripId, placeId, rating }) => { + if (isDemoUser(userId)) return demoDenied(); + // Rating is a personal vote — any trip member may cast one, place_edit not required. + if (!canAccessTrip(tripId, userId)) return noAccess(); + const place = ratePlace(String(tripId), String(placeId), userId, rating ?? null); + if (!place) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true }; + safeBroadcast(tripId, 'place:updated', { place }); + return ok({ place }); + } + ); + if (W) server.registerTool( 'delete_place', { diff --git a/server/src/nest/collections/collections.controller.ts b/server/src/nest/collections/collections.controller.ts index 014134dab..a2bbc070c 100644 --- a/server/src/nest/collections/collections.controller.ts +++ b/server/src/nest/collections/collections.controller.ts @@ -9,6 +9,7 @@ import { Param, Patch, Post, + Put, Query, UploadedFile, UseGuards, @@ -37,6 +38,7 @@ import { collectionSaveFromTripManyRequestSchema, collectionPlaceUpdateRequestSchema, collectionSetStatusRequestSchema, + placeRatingRequestSchema, collectionCopyToTripRequestSchema, collectionInviteRequestSchema, collectionInviteActionRequestSchema, @@ -56,6 +58,7 @@ import { type CollectionSaveFromTripManyRequest, type CollectionPlaceUpdateRequest, type CollectionSetStatusRequest, + type PlaceRatingRequest, type CollectionCopyToTripRequest, type CollectionInviteRequest, type CollectionInviteActionRequest, @@ -168,6 +171,21 @@ export class CollectionsController { return this.collections.setStatus(user.id, Number(pid), body.status, socketId); } + @Put('places/:pid/rating') + setRating( + @CurrentUser() user: User, + @Param('pid') pid: string, + @Body(new ZodValidationPipe(placeRatingRequestSchema)) body: PlaceRatingRequest, + @Headers('x-socket-id') socketId?: string, + ) { + return this.collections.setRating(user.id, Number(pid), body.rating, socketId); + } + + @Delete('places/:pid/rating') + clearRating(@CurrentUser() user: User, @Param('pid') pid: string, @Headers('x-socket-id') socketId?: string) { + return this.collections.setRating(user.id, Number(pid), null, socketId); + } + @Post('places/:pid/image') @HttpCode(200) @UseInterceptors(FileInterceptor('image', PLACE_IMAGE_UPLOAD)) diff --git a/server/src/nest/collections/collections.service.ts b/server/src/nest/collections/collections.service.ts index 62be190cc..d0a93f0f5 100644 --- a/server/src/nest/collections/collections.service.ts +++ b/server/src/nest/collections/collections.service.ts @@ -33,6 +33,7 @@ export class CollectionsService { } updatePlace(userId: number, placeId: number, body: CollectionPlaceUpdateRequest, socketId?: string) { return svc.updatePlace(userId, placeId, body, socketId); } setStatus(userId: number, placeId: number, status: CollectionStatus, socketId?: string) { return svc.setStatus(userId, placeId, status, socketId); } + setRating(userId: number, placeId: number, rating: number | null, socketId?: string) { return svc.setRating(userId, placeId, rating, socketId); } setPlaceImage(userId: number, placeId: number, imageUrl: string | null, socketId?: string) { return svc.setPlaceImage(userId, placeId, imageUrl, socketId); } deletePlace(userId: number, placeId: number, socketId?: string) { return svc.deletePlace(userId, placeId, socketId); } deletePlacesMany(userId: number, ids: number[], socketId?: string) { return svc.deletePlacesMany(userId, ids, socketId); } diff --git a/server/src/nest/places/places.controller.ts b/server/src/nest/places/places.controller.ts index 8f2598daf..f15891c26 100644 --- a/server/src/nest/places/places.controller.ts +++ b/server/src/nest/places/places.controller.ts @@ -24,6 +24,8 @@ import { CurrentUser } from '../auth/current-user.decorator'; import { PLACE_IMAGE_UPLOAD } from '../common/place-image-upload'; import { placeImageUrl } from '../../services/placeImage'; import { isDemoEmail } from '../../services/demo'; +import { ZodValidationPipe } from '../common/zod-validation.pipe'; +import { placeRatingRequestSchema, type PlaceRatingRequest } from '@trek/shared'; const STRING_LIMITS: Record = { name: 200, description: 2000, address: 500, notes: 2000 }; const UPLOAD = { storage: memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } }; @@ -299,6 +301,36 @@ export class PlacesController { return { place }; } + @Put(':id/rating') + rate( + @CurrentUser() user: User, + @Param('tripId') tripId: string, + @Param('id') id: string, + @Body(new ZodValidationPipe(placeRatingRequestSchema)) body: PlaceRatingRequest, + @Headers('x-socket-id') socketId?: string, + ) { + // Deliberately no place_edit check: every trip member may cast their own + // vote (#1435), even when an admin restricts place editing. + this.requireTrip(tripId, user); + const place = this.places.rate(tripId, id, user.id, body.rating); + if (!place) { + throw new HttpException({ error: 'Place not found' }, 404); + } + this.places.broadcast(tripId, 'place:updated', { place }, socketId); + return { place }; + } + + @Delete(':id/rating') + unrate(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) { + this.requireTrip(tripId, user); + const place = this.places.rate(tripId, id, user.id, null); + if (!place) { + throw new HttpException({ error: 'Place not found' }, 404); + } + this.places.broadcast(tripId, 'place:updated', { place }, socketId); + return { place }; + } + @Get(':id/image') async image(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string) { this.requireTrip(tripId, user); diff --git a/server/src/nest/places/places.service.ts b/server/src/nest/places/places.service.ts index a0312a081..c8001ff6f 100644 --- a/server/src/nest/places/places.service.ts +++ b/server/src/nest/places/places.service.ts @@ -80,6 +80,10 @@ export class PlacesService { return svc.searchPlaceImage(tripId, id, userId); } + rate(tripId: string, id: string, userId: number, rating: number | null) { + return svc.ratePlace(tripId, id, userId, rating); + } + // Journey hooks — non-fatal, mirroring the route's try/catch wrappers. onCreated(tripId: string, placeId: number): void { try { onPlaceCreated(Number(tripId), placeId); } catch { /* non-fatal */ } } onUpdated(placeId: number): void { try { onPlaceUpdated(placeId); } catch { /* non-fatal */ } } diff --git a/server/src/services/collectionsService.ts b/server/src/services/collectionsService.ts index be274b85e..14b1613a3 100644 --- a/server/src/services/collectionsService.ts +++ b/server/src/services/collectionsService.ts @@ -171,12 +171,32 @@ function loadLabelIdsByPlaceIds(placeIds: number[]): Record { return out; } +/** Per-voter rating rows (#1435), batched (mirrors loadTagsByCollectionPlaceIds). */ +function loadRatingsByCollectionPlaceIds(placeIds: number[]): Record { + const out: Record = {}; + if (placeIds.length === 0) return out; + const rows = db.prepare(` + SELECT cpr.collection_place_id AS pid, cpr.user_id, u.username, u.avatar, cpr.rating + FROM collection_place_ratings cpr + JOIN users u ON u.id = cpr.user_id + WHERE cpr.collection_place_id IN (${placeIds.map(() => '?').join(',')}) + ORDER BY cpr.created_at + `).all(...placeIds) as { pid: number; user_id: number; username: string; avatar: string | null; rating: number }[]; + for (const { pid, ...rest } of rows) { + if (!out[pid]) out[pid] = []; + out[pid].push(rest); + } + return out; +} + function hydratePlaces(rows: PlaceRow[]): CollectionPlace[] { const ids = rows.map(r => r.id); const tagsByPlace = loadTagsByCollectionPlaceIds(ids); const labelsByPlace = loadLabelIdsByPlaceIds(ids); + const ratingsByPlace = loadRatingsByCollectionPlaceIds(ids); return rows.map(r => { const { category_name, category_color, category_icon, ...rest } = r; + const ratings = ratingsByPlace[r.id] || []; return { ...rest, links: parseLinks((r as { links?: unknown }).links), @@ -185,6 +205,9 @@ function hydratePlaces(rows: PlaceRow[]): CollectionPlace[] { : undefined, tags: tagsByPlace[r.id] || [], label_ids: labelsByPlace[r.id] || [], + ratings, + rating_avg: ratings.length > 0 ? ratings.reduce((s, x) => s + x.rating, 0) / ratings.length : null, + rating_count: ratings.length, } as CollectionPlace; }); } @@ -419,6 +442,28 @@ function attachTags(collectionPlaceId: number, tagIds: number[] | undefined): vo for (const tid of tagIds) stmt.run(collectionPlaceId, tid); } +/** Owner + accepted members — the users whose votes may live in this list. */ +function collectionMemberIds(collectionId: number): Set { + const ids = new Set([ownerOf(collectionId)]); + const rows = db.prepare("SELECT user_id FROM collection_members WHERE collection_id = ? AND status = 'accepted'").all(collectionId) as { user_id: number }[]; + rows.forEach(r => ids.add(r.user_id)); + return ids; +} + +/** + * Carry star votes (#1435) from a trip place into a freshly saved collection + * place. Only votes by members of the target collection come along (the saver + * is always a member) — other trip members' opinions stay in the trip. + */ +function copyTripRatings(sourcePlaceId: number, collectionPlaceId: number, collectionId: number): void { + const eligible = collectionMemberIds(collectionId); + const rows = db.prepare('SELECT user_id, rating FROM place_ratings WHERE place_id = ?').all(sourcePlaceId) as { user_id: number; rating: number }[]; + const ins = db.prepare('INSERT OR IGNORE INTO collection_place_ratings (collection_place_id, user_id, rating) VALUES (?, ?, ?)'); + for (const r of rows) { + if (eligible.has(r.user_id)) ins.run(collectionPlaceId, r.user_id, r.rating); + } +} + export function savePlace(userId: number, body: CollectionSavePlaceRequest, socketId?: string): CollectionSaveResult { assertCanEdit(userId, body.collection_id); @@ -446,6 +491,18 @@ export function savePlace(userId: number, body: CollectionSavePlaceRequest, sock const placeId = Number(result.lastInsertRowid); attachTags(placeId, body.tag_ids); + // Carry trip ratings ONLY when the caller can actually see the source place. + // source_place_id/source_trip_id are raw client input, so verify trip access + + // that the place lives in that trip before reading place_ratings — otherwise a + // member could harvest co-members' votes on places in trips they cannot access + // (mirrors the canAccessTrip gate in saveFromTripPlace). + if ( + body.source_place_id && body.source_trip_id && + canAccessTrip(body.source_trip_id, userId) && + db.prepare('SELECT 1 FROM places WHERE id = ? AND trip_id = ?').get(body.source_place_id, body.source_trip_id) + ) { + copyTripRatings(body.source_place_id, placeId, body.collection_id); + } notifyCollectionUsers(body.collection_id, socketId, 'collections:updated'); return { place: getPlaceById(placeId) }; } @@ -511,7 +568,7 @@ export function saveFromTripPlaces( skipped.push({ id: placeId, name }); continue; } - insert.run( + const res = insert.run( collectionId, ownerId, userId, name, (p.description as string | null) ?? null, lat, lng, (p.address as string | null) ?? null, (p.category_id as number | null) ?? null, (p.price as number | null) ?? null, (p.currency as string | null) ?? null, (p.notes as string | null) ?? null, @@ -519,6 +576,7 @@ export function saveFromTripPlaces( (p.osm_id as string | null) ?? null, (p.website as string | null) ?? null, (p.phone as string | null) ?? null, tripId, placeId, ); + copyTripRatings(placeId, Number(res.lastInsertRowid), collectionId); copied++; } if (copied > 0) notifyCollectionUsers(collectionId, undefined, 'collections:updated'); @@ -586,6 +644,26 @@ export function setStatus(userId: number, placeId: number, status: CollectionSta return getPlaceById(placeId); } +/** + * Set (1-5) or clear (null) the user's own star vote on a saved place (#1435). + * Gated on assertAccess, not assertCanEdit — a vote is the member's personal + * opinion, so read-only viewers get to cast one too. + */ +export function setRating(userId: number, placeId: number, rating: number | null, socketId?: string): CollectionPlace { + const collectionId = collectionIdOfPlace(placeId); + assertAccess(userId, collectionId); + if (rating === null) { + db.prepare('DELETE FROM collection_place_ratings WHERE collection_place_id = ? AND user_id = ?').run(placeId, userId); + } else { + db.prepare(` + INSERT INTO collection_place_ratings (collection_place_id, user_id, rating) VALUES (?, ?, ?) + ON CONFLICT(collection_place_id, user_id) DO UPDATE SET rating = excluded.rating + `).run(placeId, userId, rating); + } + notifyCollectionUsers(collectionId, socketId, 'collections:updated'); + return getPlaceById(placeId); +} + export function deletePlace(userId: number, placeId: number, socketId?: string): void { const collectionId = collectionIdOfPlace(placeId); assertCanDelete(userId, collectionId); @@ -635,6 +713,14 @@ export function copyToTrip(userId: number, body: CollectionCopyToTripRequest): { httpError(403, 'Not allowed to edit this trip'); } + // Votes only travel for users who are members of THIS trip (owner + members), + // so copying never surfaces a collection member with no tie to the trip. + // Symmetric with copyTripRatings' collection-member filter (#1435). + const tripMemberIds = new Set([trip.user_id]); + for (const r of db.prepare('SELECT user_id FROM trip_members WHERE trip_id = ?').all(body.trip_id) as { user_id: number }[]) { + tripMemberIds.add(r.user_id); + } + // Visibility on every SOURCE place — no cross-user exfiltration via copy. const sources: Array<{ id: number; name: string; description: string | null; lat: number | null; lng: number | null; address: string | null; category_id: number | null; price: number | null; currency: string | null; @@ -665,6 +751,7 @@ export function copyToTrip(userId: number, body: CollectionCopyToTripRequest): { VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); const insertTag = db.prepare('INSERT OR IGNORE INTO place_tags (place_id, tag_id) VALUES (?, ?)'); + const insertRating = db.prepare('INSERT OR IGNORE INTO place_ratings (place_id, user_id, rating) VALUES (?, ?, ?)'); let copied = 0; const skipped: { id: number; name: string }[] = []; @@ -680,6 +767,11 @@ export function copyToTrip(userId: number, body: CollectionCopyToTripRequest): { const newPlaceId = Number(res.lastInsertRowid); const tagIds = db.prepare('SELECT tag_id FROM collection_place_tags WHERE collection_place_id = ?').all(s.id) as { tag_id: number }[]; for (const t of tagIds) insertTag.run(newPlaceId, t.tag_id); + // Ratings travel into the trip too (#1435), but only for trip members — a + // collection voter who isn't on the trip stays out of it. Trip members keep + // voting there; nothing is mirrored back. + const votes = db.prepare('SELECT user_id, rating FROM collection_place_ratings WHERE collection_place_id = ?').all(s.id) as { user_id: number; rating: number }[]; + for (const v of votes) if (tripMemberIds.has(v.user_id)) insertRating.run(newPlaceId, v.user_id, v.rating); if (s.name) dedup.names.add(s.name.trim().toLowerCase()); else if (s.lat != null && s.lng != null) dedup.coords.push({ lat: s.lat, lng: s.lng }); diff --git a/server/src/services/placeService.ts b/server/src/services/placeService.ts index 241174d1a..596e44783 100644 --- a/server/src/services/placeService.ts +++ b/server/src/services/placeService.ts @@ -1,7 +1,7 @@ import { XMLParser, XMLValidator } from 'fast-xml-parser'; import unzipper from 'unzipper'; import { db, getPlaceWithTags } from '../db/database'; -import { loadTagsByPlaceIds } from './queryHelpers'; +import { loadTagsByPlaceIds, loadRatingsByPlaceIds, ratingAggregate } from './queryHelpers'; import { checkSsrf, safeFetchFollow, SsrfBlockedError } from '../utils/ssrfGuard'; import { Place } from '../types'; import { @@ -97,6 +97,7 @@ export function listPlaces( const placeIds = places.map(p => p.id); const tagsByPlaceId = loadTagsByPlaceIds(placeIds); + const ratingsByPlaceId = loadRatingsByPlaceIds(placeIds); return places.map(p => ({ ...p, @@ -107,6 +108,8 @@ export function listPlaces( icon: p.category_icon, } : null, tags: tagsByPlaceId[p.id] || [], + ratings: ratingsByPlaceId[p.id] || [], + ...ratingAggregate(ratingsByPlaceId[p.id]), })); } @@ -994,3 +997,27 @@ export async function searchPlaceImage(tripId: string, placeId: string, userId: return searchUnsplashPhotos(place.name + (place.address ? ' ' + place.address : ''), 5, getUnsplashKey(userId)); } + +// --------------------------------------------------------------------------- +// Collaborative ratings (#1435) +// --------------------------------------------------------------------------- + +/** + * Set (rating 1-5) or clear (rating null) the user's own vote on a trip place. + * Ratings live in their own table so voting never bumps places.updated_at — + * a vote must not 409 another member's offline edit. Returns the refreshed + * place (with the new aggregate) or null when the place isn't in the trip. + */ +export function ratePlace(tripId: string, placeId: string, userId: number, rating: number | null): ReturnType | null { + const place = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(placeId, tripId); + if (!place) return null; + if (rating === null) { + db.prepare('DELETE FROM place_ratings WHERE place_id = ? AND user_id = ?').run(placeId, userId); + } else { + db.prepare(` + INSERT INTO place_ratings (place_id, user_id, rating) VALUES (?, ?, ?) + ON CONFLICT(place_id, user_id) DO UPDATE SET rating = excluded.rating + `).run(placeId, userId, rating); + } + return getPlaceWithTags(placeId); +} diff --git a/server/src/services/queryHelpers.ts b/server/src/services/queryHelpers.ts index f2c4ebb0f..1c6cdb9a4 100644 --- a/server/src/services/queryHelpers.ts +++ b/server/src/services/queryHelpers.ts @@ -37,6 +37,40 @@ function loadTagsByPlaceIds(placeIds: number[], { compact }: { compact?: boolean return tagsByPlaceId; } +export interface PlaceRatingRow { + user_id: number; + username: string; + avatar: string | null; + rating: number; +} + +/** Batch-load collaborative ratings (#1435) for multiple places in one query, indexed by place ID. */ +function loadRatingsByPlaceIds(placeIds: number[]): Record { + const ratingsByPlaceId: Record = {}; + if (placeIds.length > 0) { + const rows = db.prepare(` + SELECT pr.place_id, pr.user_id, u.username, u.avatar, pr.rating FROM place_ratings pr + JOIN users u ON pr.user_id = u.id + WHERE pr.place_id IN (${placeIds.map(() => '?').join(',')}) + ORDER BY pr.created_at + `).all(...placeIds) as (PlaceRatingRow & { place_id: number })[]; + for (const { place_id, ...rest } of rows) { + if (!ratingsByPlaceId[place_id]) ratingsByPlaceId[place_id] = []; + ratingsByPlaceId[place_id].push(rest); + } + } + return ratingsByPlaceId; +} + +/** avg/count aggregate for a place's rating rows. */ +function ratingAggregate(ratings: PlaceRatingRow[] | undefined) { + const rows = ratings || []; + return { + rating_avg: rows.length > 0 ? rows.reduce((s, r) => s + r.rating, 0) / rows.length : null, + rating_count: rows.length, + }; +} + /** Batch-load participants for multiple day-assignments in a single query, indexed by assignment ID. */ function loadParticipantsByAssignmentIds(assignmentIds: number[]): Record { const participantsByAssignment: Record = {}; @@ -95,4 +129,4 @@ function formatAssignmentWithPlace(a: AssignmentRow, tags: Partial[], parti }; } -export { loadTagsByPlaceIds, loadParticipantsByAssignmentIds, formatAssignmentWithPlace }; +export { loadTagsByPlaceIds, loadParticipantsByAssignmentIds, formatAssignmentWithPlace, loadRatingsByPlaceIds, ratingAggregate }; diff --git a/server/tests/unit/services/collectionsService.test.ts b/server/tests/unit/services/collectionsService.test.ts index d897ccccc..cf95e8a6d 100644 --- a/server/tests/unit/services/collectionsService.test.ts +++ b/server/tests/unit/services/collectionsService.test.ts @@ -614,3 +614,138 @@ describe('custom saved-place image', () => { expect(fs.existsSync(fileA)).toBe(false); }); }); + +// ── Collaborative ratings (#1435) ──────────────────────────────────────────── + +describe('collaborative ratings (#1435)', () => { + it('COLLECTIONS-SVC-070: setRating stores a vote, updates it, and clears with null', () => { + const u = createUser(testDb).user; + const col = svc.createCollection(u.id, { name: 'Rate' }); + const p = svc.savePlace(u.id, { collection_id: col.id, name: 'Louvre' }).place!; + + let updated = svc.setRating(u.id, p.id, 5); + expect(updated.rating_avg).toBe(5); + expect(updated.rating_count).toBe(1); + expect(updated.ratings?.find(r => r.user_id === u.id)?.rating).toBe(5); + + updated = svc.setRating(u.id, p.id, 3); // same user re-votes → replaces, not appends + expect(updated.rating_avg).toBe(3); + expect(updated.rating_count).toBe(1); + + updated = svc.setRating(u.id, p.id, null); // clear + expect(updated.rating_avg).toBeNull(); + expect(updated.rating_count).toBe(0); + }); + + it('COLLECTIONS-SVC-071: every accepted member may vote; the value is the average', () => { + const owner = createUser(testDb).user; + const member = createUser(testDb).user; + const col = svc.createCollection(owner.id, { name: 'Shared rate' }); + // A viewer (read-only) member — still allowed to cast a personal vote. + testDb.prepare("INSERT INTO collection_members (collection_id, user_id, status, role) VALUES (?, ?, 'accepted', 'viewer')").run(col.id, member.id); + const p = svc.savePlace(owner.id, { collection_id: col.id, name: 'Notre-Dame' }).place!; + + svc.setRating(owner.id, p.id, 5); + const updated = svc.setRating(member.id, p.id, 2); + expect(updated.rating_count).toBe(2); + expect(updated.rating_avg).toBe(3.5); + }); + + it('COLLECTIONS-SVC-072: a non-member cannot rate', () => { + const owner = createUser(testDb).user; + const outsider = createUser(testDb).user; + const col = svc.createCollection(owner.id, { name: 'Private rate' }); + const p = svc.savePlace(owner.id, { collection_id: col.id, name: 'Secret' }).place!; + expect(() => svc.setRating(outsider.id, p.id, 4)).toThrow(); + }); + + it('COLLECTIONS-SVC-073: saving a trip place copies only the saver + shared-member votes', () => { + const owner = createUser(testDb).user; + const shared = createUser(testDb).user; // member of BOTH the trip and the collection + const tripOnly = createUser(testDb).user; // member of the trip only + const col = svc.createCollection(owner.id, { name: 'From trip rated' }); + testDb.prepare("INSERT INTO collection_members (collection_id, user_id, status, role) VALUES (?, ?, 'accepted', 'editor')").run(col.id, shared.id); + + const trip = createTrip(testDb, owner.id); + addTripMember(testDb, trip.id, shared.id); + addTripMember(testDb, trip.id, tripOnly.id); + const place = createPlace(testDb, trip.id, { name: 'Colosseum' }); + const ins = testDb.prepare('INSERT INTO place_ratings (place_id, user_id, rating) VALUES (?, ?, ?)'); + ins.run(place.id, owner.id, 5); + ins.run(place.id, shared.id, 4); + ins.run(place.id, tripOnly.id, 1); + + const saved = svc.savePlace(owner.id, { + collection_id: col.id, name: 'Colosseum', source_trip_id: trip.id, source_place_id: place.id, + }).place!; + + const votes = testDb.prepare('SELECT user_id, rating FROM collection_place_ratings WHERE collection_place_id = ?').all(saved.id) as { user_id: number; rating: number }[]; + const voterIds = votes.map(v => v.user_id).sort((a, b) => a - b); + expect(voterIds).toEqual([owner.id, shared.id].sort((a, b) => a - b)); + expect(votes.find(v => v.user_id === tripOnly.id)).toBeUndefined(); + }); + + it('COLLECTIONS-SVC-074: copying a saved place into a trip carries its ratings along', () => { + const owner = createUser(testDb).user; + const member = createUser(testDb).user; + const col = svc.createCollection(owner.id, { name: 'Copyable' }); + testDb.prepare("INSERT INTO collection_members (collection_id, user_id, status, role) VALUES (?, ?, 'accepted', 'editor')").run(col.id, member.id); + const cp = svc.savePlace(owner.id, { collection_id: col.id, name: 'Trevi' }).place!; + svc.setRating(owner.id, cp.id, 5); + svc.setRating(member.id, cp.id, 3); + + const trip = createTrip(testDb, owner.id); + addTripMember(testDb, trip.id, member.id); // member is on the trip, so their vote carries + const res = svc.copyToTrip(owner.id, { trip_id: trip.id, place_ids: [cp.id] }); + expect(res.copied).toBe(1); + + const newPlace = testDb.prepare('SELECT id FROM places WHERE trip_id = ? ORDER BY id DESC LIMIT 1').get(trip.id) as { id: number }; + const votes = testDb.prepare('SELECT user_id, rating FROM place_ratings WHERE place_id = ?').all(newPlace.id) as { user_id: number; rating: number }[]; + expect(votes).toHaveLength(2); + expect(votes.find(v => v.user_id === owner.id)?.rating).toBe(5); + expect(votes.find(v => v.user_id === member.id)?.rating).toBe(3); + }); + + it('COLLECTIONS-SVC-075: savePlace does NOT harvest ratings from a source place the caller cannot access', () => { + const attacker = createUser(testDb).user; + const victim = createUser(testDb).user; + const col = svc.createCollection(attacker.id, { name: 'Harvest attempt' }); + // The victim is a member of the attacker's collection (so they'd be "eligible"). + testDb.prepare("INSERT INTO collection_members (collection_id, user_id, status, role) VALUES (?, ?, 'accepted', 'editor')").run(col.id, victim.id); + // A PRIVATE trip the attacker is not on, with the victim's vote on a place. + const privateTrip = createTrip(testDb, victim.id); + const secret = createPlace(testDb, privateTrip.id, { name: 'Secret spot' }); + testDb.prepare('INSERT INTO place_ratings (place_id, user_id, rating) VALUES (?, ?, ?)').run(secret.id, victim.id, 5); + + const saved = svc.savePlace(attacker.id, { + collection_id: col.id, name: 'x', source_trip_id: privateTrip.id, source_place_id: secret.id, + }).place!; + + const stolen = testDb.prepare('SELECT * FROM collection_place_ratings WHERE collection_place_id = ?').all(saved.id); + expect(stolen).toHaveLength(0); // no access to the source trip → nothing copied + }); + + it('COLLECTIONS-SVC-076: copyToTrip carries only votes from members of the target trip', () => { + const owner = createUser(testDb).user; + const inTrip = createUser(testDb).user; // collection member AND trip member + const notInTrip = createUser(testDb).user; // collection member only + const col = svc.createCollection(owner.id, { name: 'Mixed membership' }); + for (const u of [inTrip, notInTrip]) { + testDb.prepare("INSERT INTO collection_members (collection_id, user_id, status, role) VALUES (?, ?, 'accepted', 'editor')").run(col.id, u.id); + } + const cp = svc.savePlace(owner.id, { collection_id: col.id, name: 'Pantheon' }).place!; + svc.setRating(owner.id, cp.id, 5); + svc.setRating(inTrip.id, cp.id, 4); + svc.setRating(notInTrip.id, cp.id, 1); + + const trip = createTrip(testDb, owner.id); + addTripMember(testDb, trip.id, inTrip.id); + svc.copyToTrip(owner.id, { trip_id: trip.id, place_ids: [cp.id] }); + + const newPlace = testDb.prepare('SELECT id FROM places WHERE trip_id = ? ORDER BY id DESC LIMIT 1').get(trip.id) as { id: number }; + const ids = (testDb.prepare('SELECT user_id FROM place_ratings WHERE place_id = ?').all(newPlace.id) as { user_id: number }[]) + .map(v => v.user_id).sort((a, b) => a - b); + expect(ids).toEqual([owner.id, inTrip.id].sort((a, b) => a - b)); + expect(ids).not.toContain(notInTrip.id); + }); +}); diff --git a/server/tests/unit/services/placeService.test.ts b/server/tests/unit/services/placeService.test.ts index 7a8a59cf0..845ed4aa3 100644 --- a/server/tests/unit/services/placeService.test.ts +++ b/server/tests/unit/services/placeService.test.ts @@ -55,7 +55,7 @@ import { resetTestDb } from '../../helpers/test-db'; import { createUser, createTrip, createPlace, createCategory, createTag } from '../../helpers/factories'; import path from 'path'; import fs from 'fs'; -import { listPlaces, createPlace as svcCreatePlace, getPlace, updatePlace, updatePlacesMany, deletePlace, importGpx, importKmlPlaces, importGoogleList, searchPlaceImage } from '../../../src/services/placeService'; +import { listPlaces, createPlace as svcCreatePlace, getPlace, updatePlace, updatePlacesMany, deletePlace, importGpx, importKmlPlaces, importGoogleList, searchPlaceImage, ratePlace } from '../../../src/services/placeService'; import { PLACE_IMAGES_DIR } from '../../../src/services/placeImage'; const GPX_FIXTURE = path.join(__dirname, '../../fixtures/test.gpx'); @@ -764,4 +764,34 @@ describe('custom place image reclaim', () => { testDb.exec('DELETE FROM collection_places; DELETE FROM collections;'); fs.unlinkSync(fileA); }); + + // ── Collaborative ratings (#1435) ────────────────────────────────────────── + it('PLACE-SVC-050 — ratePlace stores one vote per user, replaces on re-vote, clears with null', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const place = createPlace(testDb, trip.id, { name: 'Rated' }) as { id: number }; + + ratePlace(String(trip.id), String(place.id), user.id, 5); + let rows = testDb.prepare('SELECT rating FROM place_ratings WHERE place_id = ? AND user_id = ?').all(place.id, user.id) as { rating: number }[]; + expect(rows).toEqual([{ rating: 5 }]); + + ratePlace(String(trip.id), String(place.id), user.id, 2); // re-vote replaces via the UNIQUE upsert + rows = testDb.prepare('SELECT rating FROM place_ratings WHERE place_id = ? AND user_id = ?').all(place.id, user.id) as { rating: number }[]; + expect(rows).toEqual([{ rating: 2 }]); + + ratePlace(String(trip.id), String(place.id), user.id, null); // clear + const count = testDb.prepare('SELECT COUNT(*) AS n FROM place_ratings WHERE place_id = ?').get(place.id) as { n: number }; + expect(count.n).toBe(0); + }); + + it('PLACE-SVC-051 — ratePlace returns null and writes nothing when the place is not in the trip', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const otherTrip = createTrip(testDb, user.id); + const place = createPlace(testDb, otherTrip.id, { name: 'Elsewhere' }) as { id: number }; + + expect(ratePlace(String(trip.id), String(place.id), user.id, 4)).toBeNull(); + const count = testDb.prepare('SELECT COUNT(*) AS n FROM place_ratings WHERE place_id = ?').get(place.id) as { n: number }; + expect(count.n).toBe(0); + }); }); diff --git a/shared/src/collection/collection.schema.ts b/shared/src/collection/collection.schema.ts index 28e6ed8e4..6a1dc9c26 100644 --- a/shared/src/collection/collection.schema.ts +++ b/shared/src/collection/collection.schema.ts @@ -1,4 +1,4 @@ -import { placeCategorySchema } from '../place/place.schema'; +import { placeCategorySchema, placeRatingVoteSchema } from '../place/place.schema'; import { tagSchema } from '../tag/tag.schema'; import { z } from 'zod'; @@ -70,6 +70,10 @@ export const collectionPlaceSchema = z.object({ tags: z.array(tagSchema.partial()).optional(), /** Ids of the per-collection labels assigned to this place. */ label_ids: z.array(z.number()).optional(), + // Collaborative ratings (#1435): every member's vote + the displayed aggregate. + ratings: z.array(placeRatingVoteSchema).optional(), + rating_avg: z.number().nullable().optional(), + rating_count: z.number().optional(), }); export type CollectionPlace = z.infer; diff --git a/shared/src/i18n/ar/oauth.ts b/shared/src/i18n/ar/oauth.ts index d6ae0b8a7..3bc3eb7c1 100644 --- a/shared/src/i18n/ar/oauth.ts +++ b/shared/src/i18n/ar/oauth.ts @@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types'; const oauth: TranslationStrings = { 'oauth.scope.group.trips': 'الرحلات', 'oauth.scope.group.places': 'الأماكن', + 'oauth.scope.group.collections': 'المجموعات', 'oauth.scope.group.packing': 'الأمتعة', 'oauth.scope.group.todos': 'المهام', 'oauth.scope.group.budget': 'الميزانية', @@ -24,6 +25,10 @@ const oauth: TranslationStrings = { 'oauth.scope.places:read.description': 'قراءة الأماكن وتعيينات الأيام والعلامات والفئات', 'oauth.scope.places:write.label': 'إدارة الأماكن', 'oauth.scope.places:write.description': 'إنشاء وتحديث وحذف الأماكن والتعيينات والعلامات', + 'oauth.scope.collections:read.label': 'عرض المجموعات', + 'oauth.scope.collections:read.description': 'قراءة مجموعات الأماكن المحفوظة وأماكنها وتقييماتها وتسمياتها وأعضائها', + 'oauth.scope.collections:write.label': 'إدارة المجموعات', + 'oauth.scope.collections:write.description': 'إنشاء وتعديل المجموعات، وحفظ الأماكن وتقييمها وتسميتها ونسخها، ومشاركة القوائم', 'oauth.scope.atlas:read.label': 'عرض Atlas', 'oauth.scope.atlas:read.description': 'قراءة الدول والمناطق المزارة وقائمة الأمنيات', 'oauth.scope.atlas:write.label': 'إدارة Atlas', diff --git a/shared/src/i18n/ar/places.ts b/shared/src/i18n/ar/places.ts index 36418c16a..108a16728 100644 --- a/shared/src/i18n/ar/places.ts +++ b/shared/src/i18n/ar/places.ts @@ -43,6 +43,9 @@ const places: TranslationStrings = { 'places.all': 'الكل', 'places.unplanned': 'غير مخطط', 'places.filterTracks': 'المسارات', + 'places.sortByRating': 'الترتيب حسب التقييم', + 'places.yourRating': 'تقييمك', + 'places.notRated': 'لم يُقيَّم بعد', 'places.search': 'ابحث عن أماكن...', 'places.allCategories': 'كل الفئات', 'places.categoriesSelected': 'فئات', diff --git a/shared/src/i18n/br/oauth.ts b/shared/src/i18n/br/oauth.ts index ffce828f2..cbe0437b3 100644 --- a/shared/src/i18n/br/oauth.ts +++ b/shared/src/i18n/br/oauth.ts @@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types'; const oauth: TranslationStrings = { 'oauth.scope.group.trips': 'Viagens', 'oauth.scope.group.places': 'Locais', + 'oauth.scope.group.collections': 'Coleções', 'oauth.scope.group.atlas': 'Atlas', 'oauth.scope.group.packing': 'Bagagem', 'oauth.scope.group.todos': 'Tarefas', @@ -26,6 +27,10 @@ const oauth: TranslationStrings = { 'oauth.scope.places:read.description': 'Ler locais, atribuições de dias, tags e categorias', 'oauth.scope.places:write.label': 'Gerenciar locais', 'oauth.scope.places:write.description': 'Criar, atualizar e excluir locais, atribuições e tags', + 'oauth.scope.collections:read.label': 'Ver coleções', + 'oauth.scope.collections:read.description': 'Ler coleções de lugares salvos, seus lugares, avaliações, etiquetas e membros', + 'oauth.scope.collections:write.label': 'Gerenciar coleções', + 'oauth.scope.collections:write.description': 'Criar/editar coleções, salvar, avaliar, etiquetar e copiar lugares, e compartilhar listas', 'oauth.scope.atlas:read.label': 'Ver Atlas', 'oauth.scope.atlas:read.description': 'Ler países visitados, regiões e lista de desejos', 'oauth.scope.atlas:write.label': 'Gerenciar Atlas', diff --git a/shared/src/i18n/br/places.ts b/shared/src/i18n/br/places.ts index 7866e17c8..829dc38ab 100644 --- a/shared/src/i18n/br/places.ts +++ b/shared/src/i18n/br/places.ts @@ -43,6 +43,9 @@ const places: TranslationStrings = { 'places.all': 'Todos', 'places.unplanned': 'Não planejados', 'places.filterTracks': 'Trilhas', + 'places.sortByRating': 'Ordenar por avaliação', + 'places.yourRating': 'Sua avaliação', + 'places.notRated': 'Ainda sem avaliações', 'places.search': 'Buscar lugares...', 'places.allCategories': 'Todas as categorias', 'places.categoriesSelected': 'categorias', diff --git a/shared/src/i18n/ca/oauth.ts b/shared/src/i18n/ca/oauth.ts index 5d922ba66..081180c96 100644 --- a/shared/src/i18n/ca/oauth.ts +++ b/shared/src/i18n/ca/oauth.ts @@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types'; const oauth: TranslationStrings = { 'oauth.scope.group.trips': 'Viatges', 'oauth.scope.group.places': 'Llocs', + 'oauth.scope.group.collections': 'Col·leccions', 'oauth.scope.group.atlas': 'Atles', 'oauth.scope.group.packing': 'Equipatge', 'oauth.scope.group.todos': 'Tasques', @@ -26,6 +27,10 @@ const oauth: TranslationStrings = { 'oauth.scope.places:read.description': 'Llegeix llocs, assignacions de dies, etiquetes i categories', 'oauth.scope.places:write.label': 'Gestiona els llocs', 'oauth.scope.places:write.description': 'Crea, actualitza i elimina llocs, assignacions i etiquetes', + 'oauth.scope.collections:read.label': 'Mostra les col·leccions', + 'oauth.scope.collections:read.description': 'Llegeix les col·leccions de llocs desats, els seus llocs, valoracions, etiquetes i membres', + 'oauth.scope.collections:write.label': 'Gestiona les col·leccions', + 'oauth.scope.collections:write.description': 'Crea i edita col·leccions; desa, valora, etiqueta i copia llocs, i comparteix llistes', 'oauth.scope.atlas:read.label': "Mostra l'Atles", 'oauth.scope.atlas:read.description': 'Llegeix països visitats, regions i llista de desitjos', 'oauth.scope.atlas:write.label': "Gestiona l'Atles", diff --git a/shared/src/i18n/ca/places.ts b/shared/src/i18n/ca/places.ts index 9f305d6b9..81b9f42b2 100644 --- a/shared/src/i18n/ca/places.ts +++ b/shared/src/i18n/ca/places.ts @@ -41,6 +41,9 @@ const places: TranslationStrings = { 'places.all': 'Tot', 'places.unplanned': 'Sense planificar', 'places.filterTracks': 'Rutes', + 'places.sortByRating': 'Ordena per valoració', + 'places.yourRating': 'La teva valoració', + 'places.notRated': 'Encara sense valoració', 'places.search': 'Cerca llocs...', 'places.allCategories': 'Totes les categories', 'places.categoriesSelected': 'categories', diff --git a/shared/src/i18n/cs/oauth.ts b/shared/src/i18n/cs/oauth.ts index f816c7885..6699066d6 100644 --- a/shared/src/i18n/cs/oauth.ts +++ b/shared/src/i18n/cs/oauth.ts @@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types'; const oauth: TranslationStrings = { 'oauth.scope.group.trips': 'Výlety', 'oauth.scope.group.places': 'Místa', + 'oauth.scope.group.collections': 'Kolekce', 'oauth.scope.group.atlas': 'Atlas', 'oauth.scope.group.packing': 'Balení', 'oauth.scope.group.todos': 'Úkoly', @@ -26,6 +27,10 @@ const oauth: TranslationStrings = { 'oauth.scope.places:read.description': 'Číst místa, denní přiřazení, štítky a kategorie', 'oauth.scope.places:write.label': 'Spravovat místa', 'oauth.scope.places:write.description': 'Vytvářet, aktualizovat a mazat místa, přiřazení a štítky', + 'oauth.scope.collections:read.label': 'Zobrazit kolekce', + 'oauth.scope.collections:read.description': 'Číst kolekce uložených míst, jejich místa, hodnocení, štítky a členy', + 'oauth.scope.collections:write.label': 'Spravovat kolekce', + 'oauth.scope.collections:write.description': 'Vytvářet a upravovat kolekce, ukládat, hodnotit, označovat a kopírovat místa a sdílet seznamy', 'oauth.scope.atlas:read.label': 'Zobrazit Atlas', 'oauth.scope.atlas:read.description': 'Číst navštívené země, regiony a seznam přání', 'oauth.scope.atlas:write.label': 'Spravovat Atlas', diff --git a/shared/src/i18n/cs/places.ts b/shared/src/i18n/cs/places.ts index a09c692eb..633394d97 100644 --- a/shared/src/i18n/cs/places.ts +++ b/shared/src/i18n/cs/places.ts @@ -43,6 +43,9 @@ const places: TranslationStrings = { 'places.all': 'Vše', 'places.unplanned': 'Nezařazené', 'places.filterTracks': 'Trasy', + 'places.sortByRating': 'Seřadit podle hodnocení', + 'places.yourRating': 'Tvé hodnocení', + 'places.notRated': 'Zatím bez hodnocení', 'places.search': 'Hledat místa...', 'places.allCategories': 'Všechny kategorie', 'places.categoriesSelected': 'kategorií', diff --git a/shared/src/i18n/de/oauth.ts b/shared/src/i18n/de/oauth.ts index cbd18d304..ce176f82f 100644 --- a/shared/src/i18n/de/oauth.ts +++ b/shared/src/i18n/de/oauth.ts @@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types'; const oauth: TranslationStrings = { 'oauth.scope.group.trips': 'Reisen', 'oauth.scope.group.places': 'Orte', + 'oauth.scope.group.collections': 'Sammlungen', 'oauth.scope.group.atlas': 'Atlas', 'oauth.scope.group.packing': 'Packliste', 'oauth.scope.group.todos': 'Aufgaben', @@ -26,6 +27,10 @@ const oauth: TranslationStrings = { 'oauth.scope.places:read.description': 'Orte, Tageszuweisungen, Tags und Kategorien lesen', 'oauth.scope.places:write.label': 'Orte verwalten', 'oauth.scope.places:write.description': 'Orte, Zuweisungen und Tags erstellen, aktualisieren und löschen', + 'oauth.scope.collections:read.label': 'Sammlungen ansehen', + 'oauth.scope.collections:read.description': 'Sammlungen gespeicherter Orte, ihre Orte, Bewertungen, Labels und Mitglieder lesen', + 'oauth.scope.collections:write.label': 'Sammlungen verwalten', + 'oauth.scope.collections:write.description': 'Sammlungen erstellen/bearbeiten, Orte speichern, bewerten, labeln und kopieren, Listen teilen', 'oauth.scope.atlas:read.label': 'Atlas anzeigen', 'oauth.scope.atlas:read.description': 'Besuchte Länder, Regionen und Wunschliste lesen', 'oauth.scope.atlas:write.label': 'Atlas verwalten', diff --git a/shared/src/i18n/de/places.ts b/shared/src/i18n/de/places.ts index 8daea2627..53ec831e5 100644 --- a/shared/src/i18n/de/places.ts +++ b/shared/src/i18n/de/places.ts @@ -43,6 +43,9 @@ const places: TranslationStrings = { 'places.all': 'Alle', 'places.unplanned': 'Ungeplant', 'places.filterTracks': 'Tracks', + 'places.sortByRating': 'Nach Bewertung sortieren', + 'places.yourRating': 'Deine Bewertung', + 'places.notRated': 'Noch nicht bewertet', 'places.search': 'Orte suchen...', 'places.allCategories': 'Alle Kategorien', 'places.categoriesSelected': 'Kategorien', diff --git a/shared/src/i18n/en/oauth.ts b/shared/src/i18n/en/oauth.ts index 3559ea120..38a9631b2 100644 --- a/shared/src/i18n/en/oauth.ts +++ b/shared/src/i18n/en/oauth.ts @@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types'; const oauth: TranslationStrings = { 'oauth.scope.group.trips': 'Trips', 'oauth.scope.group.places': 'Places', + 'oauth.scope.group.collections': 'Collections', 'oauth.scope.group.atlas': 'Atlas', 'oauth.scope.group.packing': 'Packing', 'oauth.scope.group.todos': 'To-dos', @@ -26,6 +27,10 @@ const oauth: TranslationStrings = { 'oauth.scope.places:read.description': 'Read places, day assignments, tags, and categories', 'oauth.scope.places:write.label': 'Manage places', 'oauth.scope.places:write.description': 'Create, update, and delete places, assignments, and tags', + 'oauth.scope.collections:read.label': 'View collections', + 'oauth.scope.collections:read.description': 'Read saved-place collections, their places, ratings, labels, and members', + 'oauth.scope.collections:write.label': 'Manage collections', + 'oauth.scope.collections:write.description': 'Create/edit collections, save, rate, label and copy places, and share lists', 'oauth.scope.atlas:read.label': 'View Atlas', 'oauth.scope.atlas:read.description': 'Read visited countries, regions, and bucket list', 'oauth.scope.atlas:write.label': 'Manage Atlas', diff --git a/shared/src/i18n/en/places.ts b/shared/src/i18n/en/places.ts index a06544af3..aac098b37 100644 --- a/shared/src/i18n/en/places.ts +++ b/shared/src/i18n/en/places.ts @@ -43,6 +43,9 @@ const places: TranslationStrings = { 'places.all': 'All', 'places.unplanned': 'Unplanned', 'places.filterTracks': 'Tracks', + 'places.sortByRating': 'Sort by rating', + 'places.yourRating': 'Your rating', + 'places.notRated': 'Not rated yet', 'places.search': 'Search places...', 'places.allCategories': 'All Categories', 'places.categoriesSelected': 'categories', diff --git a/shared/src/i18n/es/oauth.ts b/shared/src/i18n/es/oauth.ts index c235c68e8..adcf51521 100644 --- a/shared/src/i18n/es/oauth.ts +++ b/shared/src/i18n/es/oauth.ts @@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types'; const oauth: TranslationStrings = { 'oauth.scope.group.trips': 'Viajes', 'oauth.scope.group.places': 'Lugares', + 'oauth.scope.group.collections': 'Colecciones', 'oauth.scope.group.atlas': 'Atlas', 'oauth.scope.group.packing': 'Equipaje', 'oauth.scope.group.todos': 'Tareas', @@ -26,6 +27,10 @@ const oauth: TranslationStrings = { 'oauth.scope.places:read.description': 'Leer lugares, asignaciones de días, etiquetas y categorías', 'oauth.scope.places:write.label': 'Gestionar lugares', 'oauth.scope.places:write.description': 'Crear, actualizar y eliminar lugares, asignaciones y etiquetas', + 'oauth.scope.collections:read.label': 'Ver colecciones', + 'oauth.scope.collections:read.description': 'Leer las colecciones de lugares guardados, sus lugares, valoraciones, etiquetas y miembros', + 'oauth.scope.collections:write.label': 'Gestionar colecciones', + 'oauth.scope.collections:write.description': 'Crear/editar colecciones, guardar, valorar, etiquetar y copiar lugares, y compartir listas', 'oauth.scope.atlas:read.label': 'Ver Atlas', 'oauth.scope.atlas:read.description': 'Leer países visitados, regiones y lista de deseos', 'oauth.scope.atlas:write.label': 'Gestionar Atlas', diff --git a/shared/src/i18n/es/places.ts b/shared/src/i18n/es/places.ts index 29bbaec46..5ddbcc7f8 100644 --- a/shared/src/i18n/es/places.ts +++ b/shared/src/i18n/es/places.ts @@ -43,6 +43,9 @@ const places: TranslationStrings = { 'places.all': 'Todo', 'places.unplanned': 'Sin planificar', 'places.filterTracks': 'Rutas', + 'places.sortByRating': 'Ordenar por valoración', + 'places.yourRating': 'Tu valoración', + 'places.notRated': 'Sin valorar todavía', 'places.search': 'Buscar lugares...', 'places.allCategories': 'Todas las categorías', 'places.categoriesSelected': 'categorías', diff --git a/shared/src/i18n/fr/oauth.ts b/shared/src/i18n/fr/oauth.ts index aedd0d2d1..084e462aa 100644 --- a/shared/src/i18n/fr/oauth.ts +++ b/shared/src/i18n/fr/oauth.ts @@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types'; const oauth: TranslationStrings = { 'oauth.scope.group.trips': 'Voyages', 'oauth.scope.group.places': 'Lieux', + 'oauth.scope.group.collections': 'Collections', 'oauth.scope.group.atlas': 'Atlas', 'oauth.scope.group.packing': 'Bagages', 'oauth.scope.group.todos': 'Tâches', @@ -27,6 +28,10 @@ const oauth: TranslationStrings = { 'oauth.scope.places:read.description': 'Lire les lieux, affectations de jours, étiquettes et catégories', 'oauth.scope.places:write.label': 'Gérer les lieux', 'oauth.scope.places:write.description': 'Créer, modifier et supprimer des lieux, affectations et étiquettes', + 'oauth.scope.collections:read.label': 'Voir les collections', + 'oauth.scope.collections:read.description': 'Consulter les collections de lieux enregistrés, ainsi que leurs lieux, notes, étiquettes et membres', + 'oauth.scope.collections:write.label': 'Gérer les collections', + 'oauth.scope.collections:write.description': 'Créer/modifier des collections, enregistrer, noter, étiqueter et copier des lieux, et partager des listes', 'oauth.scope.atlas:read.label': "Voir l'Atlas", 'oauth.scope.atlas:read.description': 'Lire les pays visités, régions et liste de souhaits', 'oauth.scope.atlas:write.label': "Gérer l'Atlas", diff --git a/shared/src/i18n/fr/places.ts b/shared/src/i18n/fr/places.ts index f32d99fa2..0ac58d8ef 100644 --- a/shared/src/i18n/fr/places.ts +++ b/shared/src/i18n/fr/places.ts @@ -43,6 +43,9 @@ const places: TranslationStrings = { 'places.all': 'Tous', 'places.unplanned': 'Non planifiés', 'places.filterTracks': 'Traces', + 'places.sortByRating': 'Trier par note', + 'places.yourRating': 'Ta note', + 'places.notRated': 'Pas encore noté', 'places.search': 'Rechercher des lieux…', 'places.allCategories': 'Toutes les catégories', 'places.categoriesSelected': 'catégories', diff --git a/shared/src/i18n/gr/oauth.ts b/shared/src/i18n/gr/oauth.ts index e6f8de8b9..f19f5eb85 100644 --- a/shared/src/i18n/gr/oauth.ts +++ b/shared/src/i18n/gr/oauth.ts @@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types'; const oauth: TranslationStrings = { 'oauth.scope.group.trips': 'Ταξίδια', 'oauth.scope.group.places': 'Τοποθεσίες', + 'oauth.scope.group.collections': 'Συλλογές', 'oauth.scope.group.atlas': 'Atlas', 'oauth.scope.group.packing': 'Αποσκευές', 'oauth.scope.group.todos': 'Εργασίες', @@ -27,6 +28,10 @@ const oauth: TranslationStrings = { 'oauth.scope.places:read.description': 'Ανάγνωση τοποθεσιών, αναθέσεων ημερών, ετικετών και κατηγοριών', 'oauth.scope.places:write.label': 'Διαχείριση τοποθεσιών', 'oauth.scope.places:write.description': 'Δημιουργία, ενημέρωση και διαγραφή τοποθεσιών, αναθέσεων και ετικετών', + 'oauth.scope.collections:read.label': 'Προβολή συλλογών', + 'oauth.scope.collections:read.description': 'Ανάγνωση συλλογών αποθηκευμένων τοποθεσιών, των τοποθεσιών τους, των βαθμολογιών, των ετικετών και των μελών τους', + 'oauth.scope.collections:write.label': 'Διαχείριση συλλογών', + 'oauth.scope.collections:write.description': 'Δημιουργία/επεξεργασία συλλογών, αποθήκευση, βαθμολόγηση, προσθήκη ετικετών και αντιγραφή τοποθεσιών και κοινή χρήση λιστών', 'oauth.scope.atlas:read.label': 'Προβολή Atlas', 'oauth.scope.atlas:read.description': 'Ανάγνωση περιοχών που έχουν επισκεφθεί, χωρών και λίστας επιθυμιών', 'oauth.scope.atlas:write.label': 'Διαχείριση Atlas', diff --git a/shared/src/i18n/gr/places.ts b/shared/src/i18n/gr/places.ts index 752839e79..8f330409f 100644 --- a/shared/src/i18n/gr/places.ts +++ b/shared/src/i18n/gr/places.ts @@ -43,6 +43,9 @@ const places: TranslationStrings = { 'places.all': 'Όλα', 'places.unplanned': 'Μη προγραμματισμένα', 'places.filterTracks': 'Ίχνη', + 'places.sortByRating': 'Ταξινόμηση κατά βαθμολογία', + 'places.yourRating': 'Η βαθμολογία σου', + 'places.notRated': 'Χωρίς βαθμολογία ακόμη', 'places.search': 'Αναζήτηση μερών...', 'places.allCategories': 'Όλες οι Κατηγορίες', 'places.categoriesSelected': 'κατηγορίες', diff --git a/shared/src/i18n/hu/oauth.ts b/shared/src/i18n/hu/oauth.ts index f74518d4c..1002f2e26 100644 --- a/shared/src/i18n/hu/oauth.ts +++ b/shared/src/i18n/hu/oauth.ts @@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types'; const oauth: TranslationStrings = { 'oauth.scope.group.trips': 'Utazások', 'oauth.scope.group.places': 'Helyek', + 'oauth.scope.group.collections': 'Gyűjtemények', 'oauth.scope.group.atlas': 'Atlas', 'oauth.scope.group.packing': 'Csomagolás', 'oauth.scope.group.todos': 'Feladatok', @@ -26,6 +27,10 @@ const oauth: TranslationStrings = { 'oauth.scope.places:read.description': 'Helyek, napi hozzárendelések, címkék és kategóriák olvasása', 'oauth.scope.places:write.label': 'Helyek kezelése', 'oauth.scope.places:write.description': 'Helyek, hozzárendelések és címkék létrehozása, frissítése és törlése', + 'oauth.scope.collections:read.label': 'Gyűjtemények megtekintése', + 'oauth.scope.collections:read.description': 'Helygyűjtemények, azok helyeinek, értékeléseinek, címkéinek és tagjainak olvasása', + 'oauth.scope.collections:write.label': 'Gyűjtemények kezelése', + 'oauth.scope.collections:write.description': 'Gyűjtemények létrehozása/szerkesztése, helyek mentése, értékelése, címkézése és másolása, valamint listák megosztása', 'oauth.scope.atlas:read.label': 'Atlas megtekintése', 'oauth.scope.atlas:read.description': 'Meglátogatott országok, régiók és bakancslisták olvasása', 'oauth.scope.atlas:write.label': 'Atlas kezelése', diff --git a/shared/src/i18n/hu/places.ts b/shared/src/i18n/hu/places.ts index 464bf70b6..3123ddb3f 100644 --- a/shared/src/i18n/hu/places.ts +++ b/shared/src/i18n/hu/places.ts @@ -43,6 +43,9 @@ const places: TranslationStrings = { 'places.all': 'Összes', 'places.unplanned': 'Nem tervezett', 'places.filterTracks': 'Nyomvonalak', + 'places.sortByRating': 'Rendezés értékelés szerint', + 'places.yourRating': 'Az értékelésed', + 'places.notRated': 'Még nincs értékelve', 'places.search': 'Helyek keresése...', 'places.allCategories': 'Összes kategória', 'places.categoriesSelected': 'kategória', diff --git a/shared/src/i18n/id/oauth.ts b/shared/src/i18n/id/oauth.ts index e2b7adeb5..b8bd23f7b 100644 --- a/shared/src/i18n/id/oauth.ts +++ b/shared/src/i18n/id/oauth.ts @@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types'; const oauth: TranslationStrings = { 'oauth.scope.group.trips': 'Perjalanan', 'oauth.scope.group.places': 'Tempat', + 'oauth.scope.group.collections': 'Koleksi', 'oauth.scope.group.atlas': 'Atlas', 'oauth.scope.group.packing': 'Perlengkapan', 'oauth.scope.group.todos': 'To-do', @@ -26,6 +27,10 @@ const oauth: TranslationStrings = { 'oauth.scope.places:read.description': 'Baca tempat, penugasan hari, tag, dan kategori', 'oauth.scope.places:write.label': 'Kelola tempat', 'oauth.scope.places:write.description': 'Buat, perbarui, dan hapus tempat, penugasan, dan tag', + 'oauth.scope.collections:read.label': 'Lihat koleksi', + 'oauth.scope.collections:read.description': 'Baca koleksi tempat tersimpan beserta tempat, rating, label, dan anggotanya', + 'oauth.scope.collections:write.label': 'Kelola koleksi', + 'oauth.scope.collections:write.description': 'Buat/edit koleksi, simpan, beri rating, beri label, dan salin tempat, serta bagikan daftar', 'oauth.scope.atlas:read.label': 'Lihat Atlas', 'oauth.scope.atlas:read.description': 'Baca negara yang dikunjungi, wilayah, dan daftar impian', 'oauth.scope.atlas:write.label': 'Kelola Atlas', diff --git a/shared/src/i18n/id/places.ts b/shared/src/i18n/id/places.ts index e74f88030..dfc3e2cfc 100644 --- a/shared/src/i18n/id/places.ts +++ b/shared/src/i18n/id/places.ts @@ -42,6 +42,9 @@ const places: TranslationStrings = { 'places.all': 'Semua', 'places.unplanned': 'Belum direncanakan', 'places.filterTracks': 'Trek', + 'places.sortByRating': 'Urutkan berdasarkan rating', + 'places.yourRating': 'Rating kamu', + 'places.notRated': 'Belum dinilai', 'places.search': 'Cari tempat...', 'places.allCategories': 'Semua Kategori', 'places.categoriesSelected': 'kategori', diff --git a/shared/src/i18n/it/oauth.ts b/shared/src/i18n/it/oauth.ts index 0e7718867..58ea29e0a 100644 --- a/shared/src/i18n/it/oauth.ts +++ b/shared/src/i18n/it/oauth.ts @@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types'; const oauth: TranslationStrings = { 'oauth.scope.group.trips': 'Viaggi', 'oauth.scope.group.places': 'Luoghi', + 'oauth.scope.group.collections': 'Raccolte', 'oauth.scope.group.atlas': 'Atlas', 'oauth.scope.group.packing': 'Bagagli', 'oauth.scope.group.todos': 'Attività', @@ -26,6 +27,10 @@ const oauth: TranslationStrings = { 'oauth.scope.places:read.description': 'Leggi luoghi, assegnazioni giornaliere, tag e categorie', 'oauth.scope.places:write.label': 'Gestisci luoghi', 'oauth.scope.places:write.description': 'Crea, aggiorna ed elimina luoghi, assegnazioni e tag', + 'oauth.scope.collections:read.label': 'Visualizza raccolte', + 'oauth.scope.collections:read.description': 'Leggi le raccolte di luoghi salvati, i relativi luoghi, valutazioni, etichette e membri', + 'oauth.scope.collections:write.label': 'Gestisci raccolte', + 'oauth.scope.collections:write.description': 'Crea/modifica raccolte, salva, valuta, etichetta e copia luoghi e condividi elenchi', 'oauth.scope.atlas:read.label': 'Visualizza Atlas', 'oauth.scope.atlas:read.description': 'Leggi paesi visitati, regioni e lista dei desideri', 'oauth.scope.atlas:write.label': 'Gestisci Atlas', diff --git a/shared/src/i18n/it/places.ts b/shared/src/i18n/it/places.ts index 1e0b07dae..7d965e7c6 100644 --- a/shared/src/i18n/it/places.ts +++ b/shared/src/i18n/it/places.ts @@ -43,6 +43,9 @@ const places: TranslationStrings = { 'places.all': 'Tutti', 'places.unplanned': 'Non pianificati', 'places.filterTracks': 'Tracce', + 'places.sortByRating': 'Ordina per valutazione', + 'places.yourRating': 'La tua valutazione', + 'places.notRated': 'Non ancora valutato', 'places.search': 'Cerca luoghi...', 'places.allCategories': 'Tutte le categorie', 'places.categoriesSelected': 'categorie', diff --git a/shared/src/i18n/ja/oauth.ts b/shared/src/i18n/ja/oauth.ts index 99baad19b..ec3fdb7cb 100644 --- a/shared/src/i18n/ja/oauth.ts +++ b/shared/src/i18n/ja/oauth.ts @@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types'; const oauth: TranslationStrings = { 'oauth.scope.group.trips': '旅行', 'oauth.scope.group.places': '場所', + 'oauth.scope.group.collections': 'コレクション', 'oauth.scope.group.atlas': '地図', 'oauth.scope.group.packing': '持ち物', 'oauth.scope.group.todos': 'ToDo', @@ -26,6 +27,10 @@ const oauth: TranslationStrings = { 'oauth.scope.places:read.description': '場所、日への割り当て、タグ、カテゴリを閲覧', 'oauth.scope.places:write.label': '場所を管理', 'oauth.scope.places:write.description': '場所、割り当て、タグの作成・更新・削除', + 'oauth.scope.collections:read.label': 'コレクションを表示', + 'oauth.scope.collections:read.description': '保存した場所のコレクションと、その中の場所、評価、ラベル、メンバーを読み取る', + 'oauth.scope.collections:write.label': 'コレクションを管理', + 'oauth.scope.collections:write.description': 'コレクションの作成・編集、場所の保存・評価・ラベル付け・コピー、リストの共有', 'oauth.scope.atlas:read.label': '地図を表示', 'oauth.scope.atlas:read.description': '訪問した国・地域、バケットリストを閲覧', 'oauth.scope.atlas:write.label': '地図を管理', diff --git a/shared/src/i18n/ja/places.ts b/shared/src/i18n/ja/places.ts index 892dd57c7..556f2b92a 100644 --- a/shared/src/i18n/ja/places.ts +++ b/shared/src/i18n/ja/places.ts @@ -43,6 +43,9 @@ const places: TranslationStrings = { 'places.all': 'すべて', 'places.unplanned': '未計画', 'places.filterTracks': 'トラック', + 'places.sortByRating': '評価順に並べ替え', + 'places.yourRating': 'あなたの評価', + 'places.notRated': '未評価', 'places.search': '場所を検索…', 'places.allCategories': 'すべてのカテゴリ', 'places.categoriesSelected': 'カテゴリ', diff --git a/shared/src/i18n/ko/oauth.ts b/shared/src/i18n/ko/oauth.ts index 40df24bb4..356810d38 100644 --- a/shared/src/i18n/ko/oauth.ts +++ b/shared/src/i18n/ko/oauth.ts @@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types'; const oauth: TranslationStrings = { 'oauth.scope.group.trips': '여행', 'oauth.scope.group.places': '장소', + 'oauth.scope.group.collections': '컬렉션', 'oauth.scope.group.atlas': 'Atlas', 'oauth.scope.group.packing': '짐 목록', 'oauth.scope.group.todos': '할 일', @@ -26,6 +27,10 @@ const oauth: TranslationStrings = { 'oauth.scope.places:read.description': '장소, 날 배정, 태그, 카테고리 읽기', 'oauth.scope.places:write.label': '장소 관리', 'oauth.scope.places:write.description': '장소, 배정, 태그 만들기, 업데이트, 삭제', + 'oauth.scope.collections:read.label': '컬렉션 보기', + 'oauth.scope.collections:read.description': '저장된 장소 컬렉션과 장소, 평점, 라벨, 멤버 읽기', + 'oauth.scope.collections:write.label': '컬렉션 관리', + 'oauth.scope.collections:write.description': '컬렉션 생성·편집, 장소 저장·평가·라벨 지정·복사, 목록 공유', 'oauth.scope.atlas:read.label': 'Atlas 보기', 'oauth.scope.atlas:read.description': '방문한 나라, 지역, 버킷 리스트 읽기', 'oauth.scope.atlas:write.label': 'Atlas 관리', diff --git a/shared/src/i18n/ko/places.ts b/shared/src/i18n/ko/places.ts index ef40c55de..3a78826c6 100644 --- a/shared/src/i18n/ko/places.ts +++ b/shared/src/i18n/ko/places.ts @@ -42,6 +42,9 @@ const places: TranslationStrings = { 'places.all': '전체', 'places.unplanned': '미계획', 'places.filterTracks': '트랙', + 'places.sortByRating': '평점순 정렬', + 'places.yourRating': '내 평점', + 'places.notRated': '아직 평점 없음', 'places.search': '장소 검색...', 'places.allCategories': '모든 카테고리', 'places.categoriesSelected': '카테고리', diff --git a/shared/src/i18n/nl/oauth.ts b/shared/src/i18n/nl/oauth.ts index fdde9198f..f9eb1e4a8 100644 --- a/shared/src/i18n/nl/oauth.ts +++ b/shared/src/i18n/nl/oauth.ts @@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types'; const oauth: TranslationStrings = { 'oauth.scope.group.trips': 'Reizen', 'oauth.scope.group.places': 'Plaatsen', + 'oauth.scope.group.collections': 'Collecties', 'oauth.scope.group.atlas': 'Atlas', 'oauth.scope.group.packing': 'Paklijst', 'oauth.scope.group.todos': 'Taken', @@ -26,6 +27,10 @@ const oauth: TranslationStrings = { 'oauth.scope.places:read.description': 'Plaatsen, dagtoewijzingen, tags en categorieën lezen', 'oauth.scope.places:write.label': 'Plaatsen beheren', 'oauth.scope.places:write.description': 'Plaatsen, toewijzingen en tags aanmaken, bijwerken en verwijderen', + 'oauth.scope.collections:read.label': 'Collecties bekijken', + 'oauth.scope.collections:read.description': 'Collecties met opgeslagen plekken lezen, inclusief hun plekken, beoordelingen, labels en leden', + 'oauth.scope.collections:write.label': 'Collecties beheren', + 'oauth.scope.collections:write.description': 'Collecties maken/bewerken, plekken opslaan, beoordelen, labelen en kopiëren, en lijsten delen', 'oauth.scope.atlas:read.label': 'Atlas bekijken', 'oauth.scope.atlas:read.description': "Bezochte landen, regio's en bucketlist lezen", 'oauth.scope.atlas:write.label': 'Atlas beheren', diff --git a/shared/src/i18n/nl/places.ts b/shared/src/i18n/nl/places.ts index d7e3b04e1..275aa1155 100644 --- a/shared/src/i18n/nl/places.ts +++ b/shared/src/i18n/nl/places.ts @@ -43,6 +43,9 @@ const places: TranslationStrings = { 'places.all': 'Alle', 'places.unplanned': 'Ongepland', 'places.filterTracks': 'Tracks', + 'places.sortByRating': 'Sorteren op beoordeling', + 'places.yourRating': 'Jouw beoordeling', + 'places.notRated': 'Nog niet beoordeeld', 'places.search': 'Plaatsen zoeken...', 'places.allCategories': 'Alle categorieën', 'places.categoriesSelected': 'categorieën', diff --git a/shared/src/i18n/pl/oauth.ts b/shared/src/i18n/pl/oauth.ts index 4c485b2b3..aafdd165b 100644 --- a/shared/src/i18n/pl/oauth.ts +++ b/shared/src/i18n/pl/oauth.ts @@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types'; const oauth: TranslationStrings = { 'oauth.scope.group.trips': 'Podróże', 'oauth.scope.group.places': 'Miejsca', + 'oauth.scope.group.collections': 'Kolekcje', 'oauth.scope.group.atlas': 'Atlas', 'oauth.scope.group.packing': 'Pakowanie', 'oauth.scope.group.todos': 'Zadania', @@ -26,6 +27,10 @@ const oauth: TranslationStrings = { 'oauth.scope.places:read.description': 'Odczytuj miejsca, przypisania dni, tagi i kategorie', 'oauth.scope.places:write.label': 'Zarządzaj miejscami', 'oauth.scope.places:write.description': 'Twórz, aktualizuj i usuń miejsca, przypisania i tagi', + 'oauth.scope.collections:read.label': 'Wyświetlanie kolekcji', + 'oauth.scope.collections:read.description': 'Odczyt kolekcji zapisanych miejsc, zawartych w nich miejsc, ocen, etykiet i członków', + 'oauth.scope.collections:write.label': 'Zarządzanie kolekcjami', + 'oauth.scope.collections:write.description': 'Tworzenie/edytowanie kolekcji, zapisywanie, ocenianie, etykietowanie i kopiowanie miejsc oraz udostępnianie list', 'oauth.scope.atlas:read.label': 'Przeglądaj Atlas', 'oauth.scope.atlas:read.description': 'Odczytuj odwiedzone kraje, regiony i listę marzeń', 'oauth.scope.atlas:write.label': 'Zarządzaj Atlasem', diff --git a/shared/src/i18n/pl/places.ts b/shared/src/i18n/pl/places.ts index 30c8f45b5..08d8a531b 100644 --- a/shared/src/i18n/pl/places.ts +++ b/shared/src/i18n/pl/places.ts @@ -34,6 +34,9 @@ const places: TranslationStrings = { 'places.all': 'Wszystkie', 'places.unplanned': 'Niezaplanowane', 'places.filterTracks': 'Trasy', + 'places.sortByRating': 'Sortuj według oceny', + 'places.yourRating': 'Twoja ocena', + 'places.notRated': 'Jeszcze nie oceniono', 'places.search': 'Szukaj miejsc...', 'places.allCategories': 'Wszystkie kategorie', 'places.categoriesSelected': 'kategorii', diff --git a/shared/src/i18n/ru/oauth.ts b/shared/src/i18n/ru/oauth.ts index 159d9e435..3c76ad39f 100644 --- a/shared/src/i18n/ru/oauth.ts +++ b/shared/src/i18n/ru/oauth.ts @@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types'; const oauth: TranslationStrings = { 'oauth.scope.group.trips': 'Поездки', 'oauth.scope.group.places': 'Места', + 'oauth.scope.group.collections': 'Коллекции', 'oauth.scope.group.atlas': 'Atlas', 'oauth.scope.group.packing': 'Вещи', 'oauth.scope.group.todos': 'Задачи', @@ -26,6 +27,10 @@ const oauth: TranslationStrings = { 'oauth.scope.places:read.description': 'Чтение мест, назначений по дням, тегов и категорий', 'oauth.scope.places:write.label': 'Управление местами', 'oauth.scope.places:write.description': 'Создание, обновление и удаление мест, назначений и тегов', + 'oauth.scope.collections:read.label': 'Просмотр коллекций', + 'oauth.scope.collections:read.description': 'Чтение коллекций сохранённых мест, входящих в них мест, оценок, меток и участников', + 'oauth.scope.collections:write.label': 'Управление коллекциями', + 'oauth.scope.collections:write.description': 'Создание и изменение коллекций, сохранение, оценка, добавление меток и копирование мест, а также предоставление доступа к спискам', 'oauth.scope.atlas:read.label': 'Просмотр Atlas', 'oauth.scope.atlas:read.description': 'Чтение посещённых стран, регионов и списка желаний', 'oauth.scope.atlas:write.label': 'Управление Atlas', diff --git a/shared/src/i18n/ru/places.ts b/shared/src/i18n/ru/places.ts index 22a7865dc..5eae4926d 100644 --- a/shared/src/i18n/ru/places.ts +++ b/shared/src/i18n/ru/places.ts @@ -43,6 +43,9 @@ const places: TranslationStrings = { 'places.all': 'Все', 'places.unplanned': 'Незапланированные', 'places.filterTracks': 'Треки', + 'places.sortByRating': 'Сортировать по рейтингу', + 'places.yourRating': 'Твоя оценка', + 'places.notRated': 'Ещё нет оценок', 'places.search': 'Поиск мест...', 'places.allCategories': 'Все категории', 'places.categoriesSelected': 'категорий', diff --git a/shared/src/i18n/sv/oauth.ts b/shared/src/i18n/sv/oauth.ts index a38e46d4e..827833773 100644 --- a/shared/src/i18n/sv/oauth.ts +++ b/shared/src/i18n/sv/oauth.ts @@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types'; const oauth: TranslationStrings = { 'oauth.scope.group.trips': 'Resor', 'oauth.scope.group.places': 'Platser', + 'oauth.scope.group.collections': 'Samlingar', 'oauth.scope.group.atlas': 'Atlas', 'oauth.scope.group.packing': 'Packning', 'oauth.scope.group.todos': 'Att göra', @@ -26,6 +27,10 @@ const oauth: TranslationStrings = { 'oauth.scope.places:read.description': 'Läsa platser, dagsuppdrag, taggar och kategorier', 'oauth.scope.places:write.label': 'Hantera platser', 'oauth.scope.places:write.description': 'Skapa, uppdatera och ta bort platser, uppdrag och taggar', + 'oauth.scope.collections:read.label': 'Visa samlingar', + 'oauth.scope.collections:read.description': 'Läs samlingar med sparade platser, deras platser, betyg, etiketter och medlemmar', + 'oauth.scope.collections:write.label': 'Hantera samlingar', + 'oauth.scope.collections:write.description': 'Skapa/redigera samlingar, spara, betygsätt, etikettera och kopiera platser samt dela listor', 'oauth.scope.atlas:read.label': 'Visa Atlas', 'oauth.scope.atlas:read.description': 'Läs om besökta länder, regioner och bucketlist', 'oauth.scope.atlas:write.label': 'Hantera Atlas', diff --git a/shared/src/i18n/sv/places.ts b/shared/src/i18n/sv/places.ts index 05287a4fe..7573c11f7 100644 --- a/shared/src/i18n/sv/places.ts +++ b/shared/src/i18n/sv/places.ts @@ -43,6 +43,9 @@ const places: TranslationStrings = { 'places.all': 'Alla', 'places.unplanned': 'Oplanerat', 'places.filterTracks': 'Spår', + 'places.sortByRating': 'Sortera efter betyg', + 'places.yourRating': 'Ditt betyg', + 'places.notRated': 'Inte betygsatt ännu', 'places.search': 'Sök efter platser...', 'places.allCategories': 'Alla kategorier', 'places.categoriesSelected': 'kategorier', diff --git a/shared/src/i18n/tr/oauth.ts b/shared/src/i18n/tr/oauth.ts index 63547e621..d2b2986b0 100644 --- a/shared/src/i18n/tr/oauth.ts +++ b/shared/src/i18n/tr/oauth.ts @@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types'; const oauth: TranslationStrings = { 'oauth.scope.group.trips': 'Geziler', 'oauth.scope.group.places': 'Yer', + 'oauth.scope.group.collections': 'Koleksiyonlar', 'oauth.scope.group.atlas': 'Atlas', 'oauth.scope.group.packing': 'Ambalaj', 'oauth.scope.group.todos': 'Yapılacaklar', @@ -27,6 +28,10 @@ const oauth: TranslationStrings = { 'oauth.scope.places:read.description': 'Yerleri, gün atamalarını, etiketleri ve kategorileri oku', 'oauth.scope.places:write.label': 'Yerleri yönet', 'oauth.scope.places:write.description': 'Yerleri, atamaları ve etiketleri oluştur, güncelle ve sil', + 'oauth.scope.collections:read.label': 'Koleksiyonları görüntüle', + 'oauth.scope.collections:read.description': 'Kayıtlı yer koleksiyonlarını, içindeki yerleri, puanları, etiketleri ve üyeleri okuma', + 'oauth.scope.collections:write.label': 'Koleksiyonları yönet', + 'oauth.scope.collections:write.description': 'Koleksiyon oluşturma/düzenleme, yerleri kaydetme, puanlama, etiketleme ve kopyalama, listeleri paylaşma', 'oauth.scope.atlas:read.label': "Atlas'ı Görüntüle", 'oauth.scope.atlas:read.description': 'Ziyaret edilen ülkeleri, bölgeleri ve yapılacaklar listesini oku', 'oauth.scope.atlas:write.label': "Atlas'ı Yönet", diff --git a/shared/src/i18n/tr/places.ts b/shared/src/i18n/tr/places.ts index 929ce2d6e..ef7e20ae4 100644 --- a/shared/src/i18n/tr/places.ts +++ b/shared/src/i18n/tr/places.ts @@ -45,6 +45,9 @@ const places: TranslationStrings = { 'places.all': 'Tüm', 'places.unplanned': 'Planlanmamış', 'places.filterTracks': 'Parçalar', + 'places.sortByRating': 'Puana göre sırala', + 'places.yourRating': 'Senin puanın', + 'places.notRated': 'Henüz puanlanmadı', 'places.search': 'Yer ara...', 'places.allCategories': 'Tüm Kategoriler', 'places.categoriesSelected': 'kategoriler', diff --git a/shared/src/i18n/uk/oauth.ts b/shared/src/i18n/uk/oauth.ts index d65b3737b..617f87e40 100644 --- a/shared/src/i18n/uk/oauth.ts +++ b/shared/src/i18n/uk/oauth.ts @@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types'; const oauth: TranslationStrings = { 'oauth.scope.group.trips': 'Поїздки', 'oauth.scope.group.places': 'Місця', + 'oauth.scope.group.collections': 'Колекції', 'oauth.scope.group.atlas': 'Atlas', 'oauth.scope.group.packing': 'Речі', 'oauth.scope.group.todos': 'Задачі', @@ -26,6 +27,10 @@ const oauth: TranslationStrings = { 'oauth.scope.places:read.description': 'Читання місць, призначень за днями, тегів і категорій', 'oauth.scope.places:write.label': 'Керування місцями', 'oauth.scope.places:write.description': 'Створення, оновлення та видалення місць, призначень і тегів', + 'oauth.scope.collections:read.label': 'Перегляд колекцій', + 'oauth.scope.collections:read.description': 'Читання колекцій збережених місць, їхніх місць, оцінок, міток і учасників', + 'oauth.scope.collections:write.label': 'Керування колекціями', + 'oauth.scope.collections:write.description': 'Створення й редагування колекцій, збереження, оцінювання, додавання міток і копіювання місць, а також надання доступу до списків', 'oauth.scope.atlas:read.label': 'Перегляд Atlas', 'oauth.scope.atlas:read.description': 'Читання відвіданих країн, регіонів і списку бажань', 'oauth.scope.atlas:write.label': 'Керування Atlas', diff --git a/shared/src/i18n/uk/places.ts b/shared/src/i18n/uk/places.ts index acdddad83..73c776180 100644 --- a/shared/src/i18n/uk/places.ts +++ b/shared/src/i18n/uk/places.ts @@ -43,6 +43,9 @@ const places: TranslationStrings = { 'places.all': 'Усі', 'places.unplanned': 'Незаплановані', 'places.filterTracks': 'Треки', + 'places.sortByRating': 'Сортувати за рейтингом', + 'places.yourRating': 'Твоя оцінка', + 'places.notRated': 'Ще не оцінено', 'places.search': 'Пошук місць...', 'places.allCategories': 'Всі категорії', 'places.categoriesSelected': 'категорій', diff --git a/shared/src/i18n/vi/oauth.ts b/shared/src/i18n/vi/oauth.ts index 157af1601..cf35ad8d1 100644 --- a/shared/src/i18n/vi/oauth.ts +++ b/shared/src/i18n/vi/oauth.ts @@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types'; const oauth: TranslationStrings = { 'oauth.scope.group.trips': 'Chuyến đi', 'oauth.scope.group.places': 'Địa điểm', + 'oauth.scope.group.collections': 'Bộ sưu tập', 'oauth.scope.group.atlas': 'Bản đồ', 'oauth.scope.group.packing': 'Đóng gói', 'oauth.scope.group.todos': 'Việc cần làm', @@ -26,6 +27,10 @@ const oauth: TranslationStrings = { 'oauth.scope.places:read.description': 'Đọc địa điểm, bài tập trong ngày, thẻ và danh mục', 'oauth.scope.places:write.label': 'Quản lý địa điểm', 'oauth.scope.places:write.description': 'Tạo, cập nhật và xóa địa điểm, bài tập và thẻ', + 'oauth.scope.collections:read.label': 'Xem bộ sưu tập', + 'oauth.scope.collections:read.description': 'Đọc bộ sưu tập địa điểm đã lưu cùng các địa điểm, đánh giá, nhãn và thành viên trong đó', + 'oauth.scope.collections:write.label': 'Quản lý bộ sưu tập', + 'oauth.scope.collections:write.description': 'Tạo/chỉnh sửa bộ sưu tập, lưu, đánh giá, gắn nhãn và sao chép địa điểm, chia sẻ danh sách', 'oauth.scope.atlas:read.label': 'Xem bản đồ', 'oauth.scope.atlas:read.description': 'Đọc các quốc gia, khu vực và danh sách nhóm đã ghé thăm', 'oauth.scope.atlas:write.label': 'Quản lý bản đồ', diff --git a/shared/src/i18n/vi/places.ts b/shared/src/i18n/vi/places.ts index edc68ca62..78ba11fd2 100644 --- a/shared/src/i18n/vi/places.ts +++ b/shared/src/i18n/vi/places.ts @@ -43,6 +43,9 @@ const places: TranslationStrings = { 'places.all': 'Tất cả', 'places.unplanned': 'Không có kế hoạch', 'places.filterTracks': 'Bài hát', + 'places.sortByRating': 'Sắp xếp theo đánh giá', + 'places.yourRating': 'Đánh giá của bạn', + 'places.notRated': 'Chưa có đánh giá', 'places.search': 'Tìm kiếm địa điểm...', 'places.allCategories': 'Tất cả danh mục', 'places.categoriesSelected': 'Thể loại', diff --git a/shared/src/i18n/zh-TW/oauth.ts b/shared/src/i18n/zh-TW/oauth.ts index db0baf0ca..81b2eebd1 100644 --- a/shared/src/i18n/zh-TW/oauth.ts +++ b/shared/src/i18n/zh-TW/oauth.ts @@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types'; const oauth: TranslationStrings = { 'oauth.scope.group.trips': '行程', 'oauth.scope.group.places': '地點', + 'oauth.scope.group.collections': '收藏集', 'oauth.scope.group.atlas': 'Atlas', 'oauth.scope.group.packing': '行李', 'oauth.scope.group.todos': '待辦事項', @@ -26,6 +27,10 @@ const oauth: TranslationStrings = { 'oauth.scope.places:read.description': '讀取地點、每日指派、標籤及類別', 'oauth.scope.places:write.label': '管理地點', 'oauth.scope.places:write.description': '建立、更新及刪除地點、指派及標籤', + 'oauth.scope.collections:read.label': '檢視收藏集', + 'oauth.scope.collections:read.description': '讀取地點收藏集及其中的地點、評分、標籤與成員', + 'oauth.scope.collections:write.label': '管理收藏集', + 'oauth.scope.collections:write.description': '建立/編輯收藏集,儲存、評分、標記及複製地點,並分享清單', 'oauth.scope.atlas:read.label': '檢視 Atlas', 'oauth.scope.atlas:read.description': '讀取已造訪的國家、地區及願望清單', 'oauth.scope.atlas:write.label': '管理 Atlas', diff --git a/shared/src/i18n/zh-TW/places.ts b/shared/src/i18n/zh-TW/places.ts index c83c97768..ccad0b5f1 100644 --- a/shared/src/i18n/zh-TW/places.ts +++ b/shared/src/i18n/zh-TW/places.ts @@ -42,6 +42,9 @@ const places: TranslationStrings = { 'places.all': '全部', 'places.unplanned': '未規劃', 'places.filterTracks': '路線', + 'places.sortByRating': '依評分排序', + 'places.yourRating': '你的評分', + 'places.notRated': '尚未評分', 'places.search': '搜尋地點...', 'places.allCategories': '所有分類', 'places.categoriesSelected': '個分類', diff --git a/shared/src/i18n/zh/oauth.ts b/shared/src/i18n/zh/oauth.ts index 9cb25f8eb..e1338aeec 100644 --- a/shared/src/i18n/zh/oauth.ts +++ b/shared/src/i18n/zh/oauth.ts @@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types'; const oauth: TranslationStrings = { 'oauth.scope.group.trips': '行程', 'oauth.scope.group.places': '地点', + 'oauth.scope.group.collections': '收藏夹', 'oauth.scope.group.atlas': '足迹', 'oauth.scope.group.packing': '行李', 'oauth.scope.group.todos': '待办事项', @@ -26,6 +27,10 @@ const oauth: TranslationStrings = { 'oauth.scope.places:read.description': '读取地点、每日分配、标签和分类', 'oauth.scope.places:write.label': '管理地点', 'oauth.scope.places:write.description': '创建、更新和删除地点、分配和标签', + 'oauth.scope.collections:read.label': '查看收藏夹', + 'oauth.scope.collections:read.description': '读取收藏夹及其中的地点、评分、标签和成员', + 'oauth.scope.collections:write.label': '管理收藏夹', + 'oauth.scope.collections:write.description': '创建/编辑收藏夹,保存、评分、标注、复制地点,以及分享列表', 'oauth.scope.atlas:read.label': '查看足迹', 'oauth.scope.atlas:read.description': '读取已访问国家、地区和心愿清单', 'oauth.scope.atlas:write.label': '管理足迹', diff --git a/shared/src/i18n/zh/places.ts b/shared/src/i18n/zh/places.ts index 0bab897ef..76e21bab4 100644 --- a/shared/src/i18n/zh/places.ts +++ b/shared/src/i18n/zh/places.ts @@ -42,6 +42,9 @@ const places: TranslationStrings = { 'places.all': '全部', 'places.unplanned': '未规划', 'places.filterTracks': '路线', + 'places.sortByRating': '按评分排序', + 'places.yourRating': '你的评分', + 'places.notRated': '暂无评分', 'places.search': '搜索地点...', 'places.allCategories': '所有分类', 'places.categoriesSelected': '个分类', diff --git a/shared/src/place/place.schema.ts b/shared/src/place/place.schema.ts index 73d5db4da..d9d485911 100644 --- a/shared/src/place/place.schema.ts +++ b/shared/src/place/place.schema.ts @@ -38,6 +38,15 @@ export type PlaceCategory = z.infer; * projection and `tags` array. Numbers (lat/lng/price) are SQLite REAL, ids are * INTEGER; provider-derived columns are nullable. */ +// One member's star vote on a place (#1435), with display info for the tooltip. +export const placeRatingVoteSchema = z.object({ + user_id: z.number(), + username: z.string(), + avatar: z.string().nullable().optional(), + rating: z.number(), +}); +export type PlaceRatingVote = z.infer; + export const placeSchema = z.object({ id: z.number(), trip_id: z.number(), @@ -68,6 +77,11 @@ export const placeSchema = z.object({ updated_at: z.string().optional(), category: placeCategorySchema.optional(), tags: z.array(tagSchema.partial()).optional(), + // Collaborative ratings (#1435): every member's vote (for the who-voted + // tooltip) plus the aggregate the UI displays. + ratings: z.array(placeRatingVoteSchema).optional(), + rating_avg: z.number().nullable().optional(), + rating_count: z.number().optional(), }); export type Place = z.infer; @@ -111,6 +125,12 @@ export type PlaceCreateRequest = z.infer; export const placeUpdateRequestSchema = open; export type PlaceUpdateRequest = z.infer; +// Collaborative ratings (#1435): one 1-5 star vote per user and place. +export const placeRatingRequestSchema = z.object({ + rating: z.number().int().min(1).max(5), +}); +export type PlaceRatingRequest = z.infer; + export const placeBulkDeleteRequestSchema = z.object({ ids: z.array(z.number()), });