feat(places): collaborative place ratings + collections MCP (#1435) (#1644)

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.
This commit is contained in:
Maurice
2026-07-21 21:00:26 +02:00
committed by GitHub
parent 88cb7f22dd
commit 399e3573c1
86 changed files with 1441 additions and 46 deletions
+4
View File
@@ -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)
+4
View File
@@ -74,6 +74,10 @@ export const collectionsApi = {
postMultipart(`${base}/places/${pid}/image`, formData),
setStatus: (pid: number, status: CollectionStatus): Promise<CollectionPlace> =>
ax.post(`${base}/places/${pid}/status`, { status }).then((r: AxiosResponse) => r.data),
ratePlace: (pid: number, rating: number | null): Promise<CollectionPlace> =>
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<unknown> =>
ax.delete(`${base}/places/${pid}`).then((r: AxiosResponse) => r.data),
deleteMany: (ids: number[]): Promise<unknown> =>
+4 -2
View File
@@ -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)', () => {
+2
View File
@@ -20,6 +20,8 @@ export const SCOPE_GROUPS: Record<string, ScopeKeys> = {
'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' },