mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
fix: miscellaneous bug fixes (#1139)
* fix(share): serve place thumbnails in shared trip links (#1100) Google-sourced place photos are stored as image_url pointing at the JWT-guarded /api/maps/place-photo/:placeId/bytes endpoint, so they 401 for an unauthenticated shared-trip viewer and render as broken images. Rewrite place image_url values in the shared payload to a public, token-scoped proxy (/api/shared/:token/place-photo/:placeId/bytes) and add an unguarded SharedController route that validates the token and that the place belongs to its trip before streaming the cached bytes. Mirrors the existing JourneyPublicController precedent. No client changes needed. * fix(atlas): replace Natural Earth with geoBoundaries for up-to-date regions (#1119) Atlas sourced country and sub-national boundaries from Natural Earth's GitHub `master` at runtime. That data is stale (e.g. it still shows Norway's pre-2020 counties such as Oppland/Hordaland) and depicts some contested territory in unwanted ways (nvkelso/natural-earth-vector#391), so Natural Earth is dropped entirely. - Country borders (admin0) now come from the geoBoundaries CGAZ composite; sub-national regions (admin1) from per-country gbOpen, which carries ISO 3166-2 codes. A new script (server/scripts/build-atlas-geo.mjs) normalizes and quantizes them into committed gzipped bundles under server/assets/atlas, read server-side at runtime (no network at boot, no GitHub CSP allowlist entry). - New GET /addons/atlas/countries/geo serves the country layer; the client fetches it from the API instead of GitHub. - A migration reconciles manually-marked visited_regions against the new bundle (valid code -> keep; region name still matches -> re-code; curated merge crosswalk for renamed reforms; else leave intact), with UNIQUE-safe dedup. bucket_list and visited_countries hold only invariant alpha-2 country codes, so they are untouched. - Attribution added (NOTICE.md + README) per geoBoundaries CC BY 4.0. Closes #1119 * fix(packing): make templates admin-only to create, usable by members Creating a packing-list template was gated only by trip access, so any trip member could create one from the Lists feature, while applying a template silently failed for non-admins because the apply dropdown was populated from the AdminGuard-protected /api/admin/packing-templates endpoint. - save-as-template now returns 403 for non-admins; the Save-as-Template button is hidden unless the user is an admin (both the TripPlanner toolbar and the inline packing header). - add member-accessible GET /api/trips/:tripId/packing/templates so the apply dropdown lists templates for any trip member; client fetches from it instead of the admin endpoint. Closes #1120 Closes #1121 * fix(packing): show bag tracking to non-admin members The global Bag Tracking toggle was only readable via the admin-gated GET /api/admin/bag-tracking, so non-admin trip members got 403 and the weight fields, bag circles, and BAGS sidebar never rendered (#1124). Surface the flag through the already-authenticated GET /api/addons (loaded into the client addon store on app start for every user); the packing hook reads it from the store instead of the admin endpoint. The admin write path stays admin-gated and unchanged.
This commit is contained in:
@@ -0,0 +1,33 @@
|
|||||||
|
# Third-party data & attributions
|
||||||
|
|
||||||
|
TREK bundles and uses third-party data that requires attribution.
|
||||||
|
|
||||||
|
## geoBoundaries — country & sub-national boundaries
|
||||||
|
|
||||||
|
The Atlas map's administrative boundaries (admin-0 countries and admin-1
|
||||||
|
provinces/counties), shipped at `server/assets/atlas/admin0.geojson.gz` and
|
||||||
|
`server/assets/atlas/admin1.geojson.gz` and generated by
|
||||||
|
`server/scripts/build-atlas-geo.mjs`, are derived from **geoBoundaries**.
|
||||||
|
|
||||||
|
> Runfola, D. et al. (2020) geoBoundaries: A global database of political
|
||||||
|
> administrative boundaries. PLoS ONE 15(4): e0231866.
|
||||||
|
> https://doi.org/10.1371/journal.pone.0231866
|
||||||
|
|
||||||
|
geoBoundaries is licensed under **CC BY 4.0**
|
||||||
|
(https://creativecommons.org/licenses/by/4.0/). Source: https://www.geoboundaries.org/
|
||||||
|
|
||||||
|
The bundled files are simplified (coordinate-quantized) and re-tagged with the
|
||||||
|
property names TREK consumes. Country borders (`admin0`) derive from the geoBoundaries
|
||||||
|
CGAZ composite; sub-national regions (`admin1`) derive from the per-country open
|
||||||
|
(gbOpen) release.
|
||||||
|
|
||||||
|
## OpenStreetMap — geocoding
|
||||||
|
|
||||||
|
Atlas reverse-geocodes places via the **Nominatim** service. Geocoding data is
|
||||||
|
© OpenStreetMap contributors, licensed under the Open Database License (ODbL).
|
||||||
|
https://www.openstreetmap.org/copyright
|
||||||
|
|
||||||
|
## OurAirports — airport reference data
|
||||||
|
|
||||||
|
`server/assets/airports.json` is built from **OurAirports**
|
||||||
|
(https://ourairports.com/data/), released into the public domain.
|
||||||
@@ -437,6 +437,13 @@ Caddy handles TLS and WebSockets automatically.
|
|||||||
|
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
|
## Data sources
|
||||||
|
|
||||||
|
The Atlas map's country and sub-national (province/county) boundaries come from
|
||||||
|
[**geoBoundaries**](https://www.geoboundaries.org/) (Runfola et al., 2020), licensed
|
||||||
|
[CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). See [NOTICE.md](NOTICE.md)
|
||||||
|
for full third-party attributions.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
TREK is [AGPL v3](LICENSE). Self-host freely for personal or internal company use. If you modify and offer TREK as a network service to third parties, your modifications must be open-sourced under the same licence.
|
TREK is [AGPL v3](LICENSE). Self-host freely for personal or internal company use. If you modify and offer TREK as a network service to third parties, your modifications must be open-sourced under the same licence.
|
||||||
|
|||||||
@@ -394,6 +394,7 @@ export const packingApi = {
|
|||||||
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/packing/reorder`, { orderedIds } satisfies PackingReorderRequest).then(r => r.data),
|
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/packing/reorder`, { orderedIds } satisfies PackingReorderRequest).then(r => r.data),
|
||||||
getCategoryAssignees: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/category-assignees`).then(r => r.data),
|
getCategoryAssignees: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/category-assignees`).then(r => r.data),
|
||||||
setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds } satisfies PackingCategoryAssigneesRequest).then(r => r.data),
|
setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds } satisfies PackingCategoryAssigneesRequest).then(r => r.data),
|
||||||
|
listTemplates: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/templates`).then(r => r.data),
|
||||||
applyTemplate: (tripId: number | string, templateId: number) => apiClient.post(`/trips/${tripId}/packing/apply-template/${templateId}`).then(r => r.data),
|
applyTemplate: (tripId: number | string, templateId: number) => apiClient.post(`/trips/${tripId}/packing/apply-template/${templateId}`).then(r => r.data),
|
||||||
saveAsTemplate: (tripId: number | string, name: string) => apiClient.post(`/trips/${tripId}/packing/save-as-template`, { name }).then(r => r.data),
|
saveAsTemplate: (tripId: number | string, name: string) => apiClient.post(`/trips/${tripId}/packing/save-as-template`, { name }).then(r => r.data),
|
||||||
setBagMembers: (tripId: number | string, bagId: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/bags/${bagId}/members`, { user_ids: userIds } satisfies PackingBagMembersRequest).then(r => r.data),
|
setBagMembers: (tripId: number | string, bagId: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/bags/${bagId}/members`, { user_ids: userIds } satisfies PackingBagMembersRequest).then(r => r.data),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react'
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
import { Package } from 'lucide-react'
|
import { Package } from 'lucide-react'
|
||||||
import { adminApi, packingApi } from '../../api/client'
|
import { packingApi } from '../../api/client'
|
||||||
import { useTripStore } from '../../store/tripStore'
|
import { useTripStore } from '../../store/tripStore'
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
@@ -28,7 +28,7 @@ export default function ApplyTemplateButton({ tripId, style, className }: ApplyT
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
adminApi.packingTemplates().then(d => setTemplates(d.templates || [])).catch(() => {})
|
packingApi.listTemplates(tripId).then(d => setTemplates(d.templates || [])).catch(() => {})
|
||||||
}, [tripId])
|
}, [tripId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { server } from '../../../tests/helpers/msw/server';
|
|||||||
import { useAuthStore } from '../../store/authStore';
|
import { useAuthStore } from '../../store/authStore';
|
||||||
import { useTripStore } from '../../store/tripStore';
|
import { useTripStore } from '../../store/tripStore';
|
||||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||||
import { buildUser, buildTrip, buildPackingItem } from '../../../tests/helpers/factories';
|
import { buildUser, buildAdmin, buildTrip, buildPackingItem } from '../../../tests/helpers/factories';
|
||||||
import PackingListPanel, { itemWeight } from './PackingListPanel';
|
import PackingListPanel, { itemWeight } from './PackingListPanel';
|
||||||
|
|
||||||
describe('itemWeight (bag total weight calc)', () => {
|
describe('itemWeight (bag total weight calc)', () => {
|
||||||
@@ -34,10 +34,10 @@ beforeEach(() => {
|
|||||||
http.get('/api/trips/:id/packing/category-assignees', () =>
|
http.get('/api/trips/:id/packing/category-assignees', () =>
|
||||||
HttpResponse.json({ assignees: {} })
|
HttpResponse.json({ assignees: {} })
|
||||||
),
|
),
|
||||||
http.get('/api/admin/bag-tracking', () =>
|
http.get('/api/addons', () =>
|
||||||
HttpResponse.json({ enabled: false })
|
HttpResponse.json({ bagTracking: false, addons: [] })
|
||||||
),
|
),
|
||||||
http.get('/api/admin/packing-templates', () =>
|
http.get('/api/trips/:id/packing/templates', () =>
|
||||||
HttpResponse.json({ templates: [] })
|
HttpResponse.json({ templates: [] })
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -381,7 +381,7 @@ describe('PackingListPanel', () => {
|
|||||||
|
|
||||||
it('FE-COMP-PACKING-030: packing template button present when templates available', async () => {
|
it('FE-COMP-PACKING-030: packing template button present when templates available', async () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.get('/api/admin/packing-templates', () =>
|
http.get('/api/trips/:id/packing/templates', () =>
|
||||||
HttpResponse.json({ templates: [{ id: 1, name: 'Beach Trip', item_count: 5 }] })
|
HttpResponse.json({ templates: [{ id: 1, name: 'Beach Trip', item_count: 5 }] })
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -457,8 +457,8 @@ describe('PackingListPanel', () => {
|
|||||||
|
|
||||||
it('FE-COMP-PACKING-034: bag tracking enabled shows Bags button and bag sidebar', async () => {
|
it('FE-COMP-PACKING-034: bag tracking enabled shows Bags button and bag sidebar', async () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.get('/api/admin/bag-tracking', () =>
|
http.get('/api/addons', () =>
|
||||||
HttpResponse.json({ enabled: true })
|
HttpResponse.json({ bagTracking: true, addons: [] })
|
||||||
),
|
),
|
||||||
http.get('/api/trips/:id/packing/bags', () =>
|
http.get('/api/trips/:id/packing/bags', () =>
|
||||||
HttpResponse.json({ bags: [{ id: 1, name: 'Carry-on', color: '#6366f1', weight_limit_grams: null, members: [] }] })
|
HttpResponse.json({ bags: [{ id: 1, name: 'Carry-on', color: '#6366f1', weight_limit_grams: null, members: [] }] })
|
||||||
@@ -556,8 +556,8 @@ describe('PackingListPanel', () => {
|
|||||||
it('FE-COMP-PACKING-039: bag modal opens when Bags button clicked with bag tracking enabled', async () => {
|
it('FE-COMP-PACKING-039: bag modal opens when Bags button clicked with bag tracking enabled', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
server.use(
|
server.use(
|
||||||
http.get('/api/admin/bag-tracking', () =>
|
http.get('/api/addons', () =>
|
||||||
HttpResponse.json({ enabled: true })
|
HttpResponse.json({ bagTracking: true, addons: [] })
|
||||||
),
|
),
|
||||||
http.get('/api/trips/:id/packing/bags', () =>
|
http.get('/api/trips/:id/packing/bags', () =>
|
||||||
HttpResponse.json({ bags: [{ id: 1, name: 'Main Bag', color: '#6366f1', weight_limit_grams: null, members: [] }] })
|
HttpResponse.json({ bags: [{ id: 1, name: 'Main Bag', color: '#6366f1', weight_limit_grams: null, members: [] }] })
|
||||||
@@ -585,8 +585,8 @@ describe('PackingListPanel', () => {
|
|||||||
|
|
||||||
it('FE-COMP-PACKING-040: bag sidebar renders BagCard with bag name when enabled and bags exist', async () => {
|
it('FE-COMP-PACKING-040: bag sidebar renders BagCard with bag name when enabled and bags exist', async () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.get('/api/admin/bag-tracking', () =>
|
http.get('/api/addons', () =>
|
||||||
HttpResponse.json({ enabled: true })
|
HttpResponse.json({ bagTracking: true, addons: [] })
|
||||||
),
|
),
|
||||||
http.get('/api/trips/:id/packing/bags', () =>
|
http.get('/api/trips/:id/packing/bags', () =>
|
||||||
HttpResponse.json({ bags: [{ id: 5, name: 'Backpack', color: '#10b981', weight_limit_grams: 10000, members: [] }] })
|
HttpResponse.json({ bags: [{ id: 5, name: 'Backpack', color: '#10b981', weight_limit_grams: 10000, members: [] }] })
|
||||||
@@ -601,26 +601,36 @@ describe('PackingListPanel', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-COMP-PACKING-041: save-as-template button present when items exist', async () => {
|
it('FE-COMP-PACKING-041: save-as-template button present for admins when items exist', async () => {
|
||||||
|
seedStore(useAuthStore, { user: buildAdmin(), isAuthenticated: true });
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const items = [buildPackingItem({ name: 'Sunscreen', category: 'Toiletries' })];
|
const items = [buildPackingItem({ name: 'Sunscreen', category: 'Toiletries' })];
|
||||||
const { container } = render(<PackingListPanel tripId={1} items={items} />);
|
render(<PackingListPanel tripId={1} items={items} />);
|
||||||
|
|
||||||
// Save-as-template button uses FolderPlus icon and "Save as template" text
|
// Save-as-template button shows its label "Save as template"
|
||||||
const folderPlusBtn = container.querySelector('svg.lucide-folder-plus')?.closest('button');
|
const saveBtn = screen.getByText('Save as template').closest('button');
|
||||||
expect(folderPlusBtn).toBeTruthy();
|
expect(saveBtn).toBeTruthy();
|
||||||
|
|
||||||
// Click to show the name input
|
// Click to show the name input
|
||||||
await user.click(folderPlusBtn!);
|
await user.click(saveBtn!);
|
||||||
|
|
||||||
// Template name input appears
|
// Template name input appears
|
||||||
expect(await screen.findByPlaceholderText('Template name')).toBeInTheDocument();
|
expect(await screen.findByPlaceholderText('Template name')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-PACKING-041b: save-as-template button hidden for non-admins', () => {
|
||||||
|
// Default seeded user (beforeEach) is a non-admin trip owner with edit rights.
|
||||||
|
const items = [buildPackingItem({ name: 'Sunscreen', category: 'Toiletries' })];
|
||||||
|
render(<PackingListPanel tripId={1} items={items} />);
|
||||||
|
|
||||||
|
// The "Save as template" action must not be available to normal users.
|
||||||
|
expect(screen.queryByText('Save as template')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it('FE-COMP-PACKING-042: apply template dropdown opens when template button clicked', async () => {
|
it('FE-COMP-PACKING-042: apply template dropdown opens when template button clicked', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
server.use(
|
server.use(
|
||||||
http.get('/api/admin/packing-templates', () =>
|
http.get('/api/trips/:id/packing/templates', () =>
|
||||||
HttpResponse.json({ templates: [{ id: 2, name: 'Summer Packing', item_count: 10 }] })
|
HttpResponse.json({ templates: [{ id: 2, name: 'Summer Packing', item_count: 10 }] })
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -658,8 +668,8 @@ describe('PackingListPanel', () => {
|
|||||||
|
|
||||||
it('FE-COMP-PACKING-044: bag item row shows weight input and bag button when bag tracking enabled', async () => {
|
it('FE-COMP-PACKING-044: bag item row shows weight input and bag button when bag tracking enabled', async () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.get('/api/admin/bag-tracking', () =>
|
http.get('/api/addons', () =>
|
||||||
HttpResponse.json({ enabled: true })
|
HttpResponse.json({ bagTracking: true, addons: [] })
|
||||||
),
|
),
|
||||||
http.get('/api/trips/:id/packing/bags', () =>
|
http.get('/api/trips/:id/packing/bags', () =>
|
||||||
HttpResponse.json({ bags: [] })
|
HttpResponse.json({ bags: [] })
|
||||||
@@ -706,6 +716,7 @@ describe('PackingListPanel', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('FE-COMP-PACKING-046: save-as-template form submission calls saveAsTemplate API', async () => {
|
it('FE-COMP-PACKING-046: save-as-template form submission calls saveAsTemplate API', async () => {
|
||||||
|
seedStore(useAuthStore, { user: buildAdmin(), isAuthenticated: true });
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
let savedTemplateName = '';
|
let savedTemplateName = '';
|
||||||
server.use(
|
server.use(
|
||||||
@@ -714,16 +725,16 @@ describe('PackingListPanel', () => {
|
|||||||
savedTemplateName = String(body.name);
|
savedTemplateName = String(body.name);
|
||||||
return HttpResponse.json({ success: true });
|
return HttpResponse.json({ success: true });
|
||||||
}),
|
}),
|
||||||
http.get('/api/admin/packing-templates', () =>
|
http.get('/api/trips/:id/packing/templates', () =>
|
||||||
HttpResponse.json({ templates: [] })
|
HttpResponse.json({ templates: [] })
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
const items = [buildPackingItem({ name: 'Item', category: 'Test' })];
|
const items = [buildPackingItem({ name: 'Item', category: 'Test' })];
|
||||||
const { container } = render(<PackingListPanel tripId={1} items={items} />);
|
render(<PackingListPanel tripId={1} items={items} />);
|
||||||
|
|
||||||
// Click the FolderPlus "Save as template" button
|
// Click the "Save as template" button
|
||||||
const folderPlusBtn = container.querySelector('svg.lucide-folder-plus')?.closest('button');
|
const saveBtn = screen.getByText('Save as template').closest('button');
|
||||||
await user.click(folderPlusBtn!);
|
await user.click(saveBtn!);
|
||||||
|
|
||||||
// Type template name
|
// Type template name
|
||||||
const nameInput = await screen.findByPlaceholderText('Template name');
|
const nameInput = await screen.findByPlaceholderText('Template name');
|
||||||
@@ -736,8 +747,8 @@ describe('PackingListPanel', () => {
|
|||||||
it('FE-COMP-PACKING-047: bag picker in item row opens when clicked with bag tracking enabled', async () => {
|
it('FE-COMP-PACKING-047: bag picker in item row opens when clicked with bag tracking enabled', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
server.use(
|
server.use(
|
||||||
http.get('/api/admin/bag-tracking', () =>
|
http.get('/api/addons', () =>
|
||||||
HttpResponse.json({ enabled: true })
|
HttpResponse.json({ bagTracking: true, addons: [] })
|
||||||
),
|
),
|
||||||
http.get('/api/trips/:id/packing/bags', () =>
|
http.get('/api/trips/:id/packing/bags', () =>
|
||||||
HttpResponse.json({ bags: [{ id: 3, name: 'Carry-on', color: '#ec4899', weight_limit_grams: null, members: [] }] })
|
HttpResponse.json({ bags: [{ id: 3, name: 'Carry-on', color: '#ec4899', weight_limit_grams: null, members: [] }] })
|
||||||
@@ -765,8 +776,8 @@ describe('PackingListPanel', () => {
|
|||||||
it('FE-COMP-PACKING-048: add bag in bag modal opens form when "Add bag" clicked', async () => {
|
it('FE-COMP-PACKING-048: add bag in bag modal opens form when "Add bag" clicked', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
server.use(
|
server.use(
|
||||||
http.get('/api/admin/bag-tracking', () =>
|
http.get('/api/addons', () =>
|
||||||
HttpResponse.json({ enabled: true })
|
HttpResponse.json({ bagTracking: true, addons: [] })
|
||||||
),
|
),
|
||||||
http.get('/api/trips/:id/packing/bags', () =>
|
http.get('/api/trips/:id/packing/bags', () =>
|
||||||
HttpResponse.json({ bags: [{ id: 1, name: 'Main Bag', color: '#6366f1', weight_limit_grams: null, members: [] }] })
|
HttpResponse.json({ bags: [{ id: 1, name: 'Main Bag', color: '#6366f1', weight_limit_grams: null, members: [] }] })
|
||||||
@@ -805,8 +816,8 @@ describe('PackingListPanel', () => {
|
|||||||
let putBody: Record<string, unknown> | null = null;
|
let putBody: Record<string, unknown> | null = null;
|
||||||
const itemId = 120;
|
const itemId = 120;
|
||||||
server.use(
|
server.use(
|
||||||
http.get('/api/admin/bag-tracking', () =>
|
http.get('/api/addons', () =>
|
||||||
HttpResponse.json({ enabled: true })
|
HttpResponse.json({ bagTracking: true, addons: [] })
|
||||||
),
|
),
|
||||||
http.get('/api/trips/:id/packing/bags', () =>
|
http.get('/api/trips/:id/packing/bags', () =>
|
||||||
HttpResponse.json({ bags: [] })
|
HttpResponse.json({ bags: [] })
|
||||||
@@ -861,8 +872,8 @@ describe('PackingListPanel', () => {
|
|||||||
const itemId = 130;
|
const itemId = 130;
|
||||||
let putBody: Record<string, unknown> | null = null;
|
let putBody: Record<string, unknown> | null = null;
|
||||||
server.use(
|
server.use(
|
||||||
http.get('/api/admin/bag-tracking', () =>
|
http.get('/api/addons', () =>
|
||||||
HttpResponse.json({ enabled: true })
|
HttpResponse.json({ bagTracking: true, addons: [] })
|
||||||
),
|
),
|
||||||
http.get('/api/trips/:id/packing/bags', () =>
|
http.get('/api/trips/:id/packing/bags', () =>
|
||||||
HttpResponse.json({ bags: [{ id: 7, name: 'Trolley', color: '#10b981', weight_limit_grams: null, members: [] }] })
|
HttpResponse.json({ bags: [{ id: 7, name: 'Trolley', color: '#10b981', weight_limit_grams: null, members: [] }] })
|
||||||
@@ -930,8 +941,8 @@ describe('PackingListPanel', () => {
|
|||||||
it('FE-COMP-PACKING-054: item with assigned bag shows "Unassigned" option in bag picker', async () => {
|
it('FE-COMP-PACKING-054: item with assigned bag shows "Unassigned" option in bag picker', async () => {
|
||||||
const itemId = 140;
|
const itemId = 140;
|
||||||
server.use(
|
server.use(
|
||||||
http.get('/api/admin/bag-tracking', () =>
|
http.get('/api/addons', () =>
|
||||||
HttpResponse.json({ enabled: true })
|
HttpResponse.json({ bagTracking: true, addons: [] })
|
||||||
),
|
),
|
||||||
http.get('/api/trips/:id/packing/bags', () =>
|
http.get('/api/trips/:id/packing/bags', () =>
|
||||||
HttpResponse.json({ bags: [{ id: 5, name: 'MyBag', color: '#ec4899', weight_limit_grams: null, members: [] }] })
|
HttpResponse.json({ bags: [{ id: 5, name: 'MyBag', color: '#ec4899', weight_limit_grams: null, members: [] }] })
|
||||||
@@ -957,7 +968,7 @@ describe('PackingListPanel', () => {
|
|||||||
it('FE-COMP-PACKING-055: apply template button click opens template dropdown and shows template', async () => {
|
it('FE-COMP-PACKING-055: apply template button click opens template dropdown and shows template', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
server.use(
|
server.use(
|
||||||
http.get('/api/admin/packing-templates', () =>
|
http.get('/api/trips/:id/packing/templates', () =>
|
||||||
HttpResponse.json({ templates: [{ id: 3, name: 'Weekend Pack', item_count: 8 }] })
|
HttpResponse.json({ templates: [{ id: 3, name: 'Weekend Pack', item_count: 8 }] })
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -1124,7 +1135,7 @@ describe('PackingListPanel', () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
let applyCalled = false;
|
let applyCalled = false;
|
||||||
server.use(
|
server.use(
|
||||||
http.get('/api/admin/packing-templates', () =>
|
http.get('/api/trips/:id/packing/templates', () =>
|
||||||
HttpResponse.json({ templates: [{ id: 5, name: 'Beach Trip', item_count: 12 }] })
|
HttpResponse.json({ templates: [{ id: 5, name: 'Beach Trip', item_count: 12 }] })
|
||||||
),
|
),
|
||||||
http.post('/api/trips/1/packing/apply-template/5', () => {
|
http.post('/api/trips/1/packing/apply-template/5', () => {
|
||||||
@@ -1177,7 +1188,7 @@ describe('PackingListPanel', () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
let createBody: Record<string, unknown> | null = null;
|
let createBody: Record<string, unknown> | null = null;
|
||||||
server.use(
|
server.use(
|
||||||
http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: true })),
|
http.get('/api/addons', () => HttpResponse.json({ bagTracking: true, addons: [] })),
|
||||||
// Start with one bag so the sidebar renders (sidebar requires bags.length > 0)
|
// Start with one bag so the sidebar renders (sidebar requires bags.length > 0)
|
||||||
http.get('/api/trips/:id/packing/bags', () =>
|
http.get('/api/trips/:id/packing/bags', () =>
|
||||||
HttpResponse.json({ bags: [{ id: 1, name: 'Existing Bag', color: '#6366f1', weight_limit_grams: null, members: [] }] })
|
HttpResponse.json({ bags: [{ id: 1, name: 'Existing Bag', color: '#6366f1', weight_limit_grams: null, members: [] }] })
|
||||||
@@ -1207,7 +1218,7 @@ describe('PackingListPanel', () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
let deleteCalled = false;
|
let deleteCalled = false;
|
||||||
server.use(
|
server.use(
|
||||||
http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: true })),
|
http.get('/api/addons', () => HttpResponse.json({ bagTracking: true, addons: [] })),
|
||||||
http.get('/api/trips/:id/packing/bags', () =>
|
http.get('/api/trips/:id/packing/bags', () =>
|
||||||
HttpResponse.json({ bags: [{ id: 9, name: 'Old Bag', color: '#6366f1', weight_limit_grams: null, members: [] }] })
|
HttpResponse.json({ bags: [{ id: 9, name: 'Old Bag', color: '#6366f1', weight_limit_grams: null, members: [] }] })
|
||||||
),
|
),
|
||||||
@@ -1235,7 +1246,7 @@ describe('PackingListPanel', () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
let updateBody: Record<string, unknown> | null = null;
|
let updateBody: Record<string, unknown> | null = null;
|
||||||
server.use(
|
server.use(
|
||||||
http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: true })),
|
http.get('/api/addons', () => HttpResponse.json({ bagTracking: true, addons: [] })),
|
||||||
http.get('/api/trips/:id/packing/bags', () =>
|
http.get('/api/trips/:id/packing/bags', () =>
|
||||||
HttpResponse.json({ bags: [{ id: 11, name: 'Carry-on', color: '#10b981', weight_limit_grams: null, members: [] }] })
|
HttpResponse.json({ bags: [{ id: 11, name: 'Carry-on', color: '#10b981', weight_limit_grams: null, members: [] }] })
|
||||||
),
|
),
|
||||||
@@ -1273,7 +1284,7 @@ describe('PackingListPanel', () => {
|
|||||||
current_user_id: 1,
|
current_user_id: 1,
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: true })),
|
http.get('/api/addons', () => HttpResponse.json({ bagTracking: true, addons: [] })),
|
||||||
http.get('/api/trips/:id/packing/bags', () =>
|
http.get('/api/trips/:id/packing/bags', () =>
|
||||||
HttpResponse.json({ bags: [{ id: 12, name: 'Day Pack', color: '#ec4899', weight_limit_grams: null, members: [] }] })
|
HttpResponse.json({ bags: [{ id: 12, name: 'Day Pack', color: '#ec4899', weight_limit_grams: null, members: [] }] })
|
||||||
)
|
)
|
||||||
@@ -1314,7 +1325,7 @@ describe('PackingListPanel', () => {
|
|||||||
current_user_id: 1,
|
current_user_id: 1,
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: true })),
|
http.get('/api/addons', () => HttpResponse.json({ bagTracking: true, addons: [] })),
|
||||||
http.get('/api/trips/:id/packing/bags', () =>
|
http.get('/api/trips/:id/packing/bags', () =>
|
||||||
HttpResponse.json({ bags: [{ id: 13, name: 'Weekend Bag', color: '#f97316', weight_limit_grams: null, members: [] }] })
|
HttpResponse.json({ bags: [{ id: 13, name: 'Weekend Bag', color: '#f97316', weight_limit_grams: null, members: [] }] })
|
||||||
),
|
),
|
||||||
@@ -1352,7 +1363,7 @@ describe('PackingListPanel', () => {
|
|||||||
it('FE-COMP-PACKING-068: inline bag create in item row picker creates bag and assigns it', async () => {
|
it('FE-COMP-PACKING-068: inline bag create in item row picker creates bag and assigns it', async () => {
|
||||||
let createBody: Record<string, unknown> | null = null;
|
let createBody: Record<string, unknown> | null = null;
|
||||||
server.use(
|
server.use(
|
||||||
http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: true })),
|
http.get('/api/addons', () => HttpResponse.json({ bagTracking: true, addons: [] })),
|
||||||
http.get('/api/trips/:id/packing/bags', () => HttpResponse.json({ bags: [] })),
|
http.get('/api/trips/:id/packing/bags', () => HttpResponse.json({ bags: [] })),
|
||||||
http.post('/api/trips/1/packing/bags', async ({ request }) => {
|
http.post('/api/trips/1/packing/bags', async ({ request }) => {
|
||||||
createBody = await request.json() as Record<string, unknown>;
|
createBody = await request.json() as Record<string, unknown>;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { PackingState } from './usePackingListPanel'
|
|||||||
|
|
||||||
export function PackingHeader(S: PackingState) {
|
export function PackingHeader(S: PackingState) {
|
||||||
const {
|
const {
|
||||||
inlineHeader, t, items, abgehakt, fortschritt, canEdit,
|
inlineHeader, t, items, abgehakt, fortschritt, canEdit, isAdmin,
|
||||||
showSaveTemplate, saveTemplateName, setSaveTemplateName, handleSaveAsTemplate, setShowSaveTemplate,
|
showSaveTemplate, saveTemplateName, setSaveTemplateName, handleSaveAsTemplate, setShowSaveTemplate,
|
||||||
setShowImportModal, handleClearChecked, availableTemplates, templateDropdownRef,
|
setShowImportModal, handleClearChecked, availableTemplates, templateDropdownRef,
|
||||||
showTemplateDropdown, setShowTemplateDropdown, applyingTemplate, handleApplyTemplate,
|
showTemplateDropdown, setShowTemplateDropdown, applyingTemplate, handleApplyTemplate,
|
||||||
@@ -26,7 +26,7 @@ export function PackingHeader(S: PackingState) {
|
|||||||
</div>
|
</div>
|
||||||
) : <span />}
|
) : <span />}
|
||||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
||||||
{canEdit && items.length > 0 && showSaveTemplate && (
|
{canEdit && isAdmin && items.length > 0 && showSaveTemplate && (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
<input
|
<input
|
||||||
type="text" autoFocus
|
type="text" autoFocus
|
||||||
@@ -97,7 +97,7 @@ export function PackingHeader(S: PackingState) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{inlineHeader && canEdit && items.length > 0 && !showSaveTemplate && (
|
{inlineHeader && canEdit && isAdmin && items.length > 0 && !showSaveTemplate && (
|
||||||
<button onClick={() => setShowSaveTemplate(true)} style={{
|
<button onClick={() => setShowSaveTemplate(true)} style={{
|
||||||
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
|
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
|
||||||
border: '1px solid var(--border-primary)', fontSize: 12, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit',
|
border: '1px solid var(--border-primary)', fontSize: 12, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit',
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ import { useState, useMemo, useRef, useEffect } from 'react'
|
|||||||
import type { ChangeEvent } from 'react'
|
import type { ChangeEvent } from 'react'
|
||||||
import { useTripStore } from '../../store/tripStore'
|
import { useTripStore } from '../../store/tripStore'
|
||||||
import { useCanDo } from '../../store/permissionsStore'
|
import { useCanDo } from '../../store/permissionsStore'
|
||||||
|
import { useAuthStore } from '../../store/authStore'
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { packingApi, tripsApi, adminApi } from '../../api/client'
|
import { packingApi, tripsApi } from '../../api/client'
|
||||||
|
import { useAddonStore } from '../../store/addonStore'
|
||||||
import type { PackingItem, PackingBag } from '../../types'
|
import type { PackingItem, PackingBag } from '../../types'
|
||||||
import { BAG_COLORS } from './packingListPanel.constants'
|
import { BAG_COLORS } from './packingListPanel.constants'
|
||||||
import { parseImportLines } from './packingListPanel.helpers'
|
import { parseImportLines } from './packingListPanel.helpers'
|
||||||
@@ -46,6 +48,7 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
|
|||||||
const can = useCanDo()
|
const can = useCanDo()
|
||||||
const trip = useTripStore((s) => s.trip)
|
const trip = useTripStore((s) => s.trip)
|
||||||
const canEdit = can('packing_edit', trip)
|
const canEdit = can('packing_edit', trip)
|
||||||
|
const isAdmin = useAuthStore((s) => s.user?.role === 'admin')
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
@@ -145,19 +148,24 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
|
|||||||
if (failed) toast.error(t('packing.toast.deleteError'))
|
if (failed) toast.error(t('packing.toast.deleteError'))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bag tracking
|
// Bag tracking — the global toggle is a packing sub-flag surfaced to every
|
||||||
const [bagTrackingEnabled, setBagTrackingEnabled] = useState(false)
|
// authenticated user via the addon store (loaded on app start), not the
|
||||||
|
// admin-only endpoint, so non-admin members see weights/bags too.
|
||||||
|
const bagTrackingEnabled = useAddonStore(s => s.bagTracking)
|
||||||
|
const addonsLoaded = useAddonStore(s => s.loaded)
|
||||||
|
const loadAddons = useAddonStore(s => s.loadAddons)
|
||||||
const [bags, setBags] = useState<PackingBag[]>([])
|
const [bags, setBags] = useState<PackingBag[]>([])
|
||||||
const [newBagName, setNewBagName] = useState('')
|
const [newBagName, setNewBagName] = useState('')
|
||||||
const [showAddBag, setShowAddBag] = useState(false)
|
const [showAddBag, setShowAddBag] = useState(false)
|
||||||
const [showBagModal, setShowBagModal] = useState(false)
|
const [showBagModal, setShowBagModal] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
adminApi.getBagTracking().then(d => {
|
if (!addonsLoaded) loadAddons()
|
||||||
setBagTrackingEnabled(d.enabled)
|
}, [addonsLoaded, loadAddons])
|
||||||
if (d.enabled) packingApi.listBags(tripId).then(r => setBags(r.bags || [])).catch(() => {})
|
|
||||||
}).catch(() => {})
|
useEffect(() => {
|
||||||
}, [tripId])
|
if (bagTrackingEnabled) packingApi.listBags(tripId).then(r => setBags(r.bags || [])).catch(() => {})
|
||||||
|
}, [tripId, bagTrackingEnabled])
|
||||||
|
|
||||||
const handleCreateBag = async () => {
|
const handleCreateBag = async () => {
|
||||||
if (!newBagName.trim()) return
|
if (!newBagName.trim()) return
|
||||||
@@ -234,7 +242,7 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
|
|||||||
const templateDropdownRef = useRef<HTMLDivElement>(null)
|
const templateDropdownRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
adminApi.packingTemplates().then(d => setAvailableTemplates(d.templates || [])).catch(() => {})
|
packingApi.listTemplates(tripId).then(d => setAvailableTemplates(d.templates || [])).catch(() => {})
|
||||||
}, [tripId])
|
}, [tripId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -267,7 +275,7 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
|
|||||||
toast.success(t('packing.templateSaved'))
|
toast.success(t('packing.templateSaved'))
|
||||||
setShowSaveTemplate(false)
|
setShowSaveTemplate(false)
|
||||||
setSaveTemplateName('')
|
setSaveTemplateName('')
|
||||||
adminApi.packingTemplates().then(d => setAvailableTemplates(d.templates || [])).catch(() => {})
|
packingApi.listTemplates(tripId).then(d => setAvailableTemplates(d.templates || [])).catch(() => {})
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t('common.error'))
|
toast.error(t('common.error'))
|
||||||
}
|
}
|
||||||
@@ -297,7 +305,7 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
|
|||||||
const font = { fontFamily: "var(--font-system)" }
|
const font = { fontFamily: "var(--font-system)" }
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tripId, items, inlineHeader, t, canEdit, font,
|
tripId, items, inlineHeader, t, canEdit, isAdmin, font,
|
||||||
filter, setFilter, addingCategory, setAddingCategory, newCatName, setNewCatName,
|
filter, setFilter, addingCategory, setAddingCategory, newCatName, setNewCatName,
|
||||||
tripMembers, categoryAssignees, handleSetAssignees, allCategories, gruppiert, abgehakt, fortschritt,
|
tripMembers, categoryAssignees, handleSetAssignees, allCategories, gruppiert, abgehakt, fortschritt,
|
||||||
handleAddItemToCategory, handleAddNewCategory, handleRenameCategory, handleDeleteCategory, handleClearChecked,
|
handleAddItemToCategory, handleAddNewCategory, handleRenameCategory, handleDeleteCategory, handleClearChecked,
|
||||||
|
|||||||
@@ -175,6 +175,9 @@ function useDefaultAtlasHandlers() {
|
|||||||
http.get('/api/addons/atlas/stats', () => HttpResponse.json(atlasStatsResponse)),
|
http.get('/api/addons/atlas/stats', () => HttpResponse.json(atlasStatsResponse)),
|
||||||
http.get('/api/addons/atlas/bucket-list', () => HttpResponse.json({ items: [] })),
|
http.get('/api/addons/atlas/bucket-list', () => HttpResponse.json({ items: [] })),
|
||||||
http.get('/api/addons/atlas/regions', () => HttpResponse.json({ regions: {} })),
|
http.get('/api/addons/atlas/regions', () => HttpResponse.json({ regions: {} })),
|
||||||
|
// Country-border GeoJSON (admin-0) — served by the API now. Tests that need real
|
||||||
|
// country features override this handler via server.use(...).
|
||||||
|
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json({ type: 'FeatureCollection', features: [] })),
|
||||||
// Handler for region GeoJSON fetch (triggered by loadRegionsForViewport when intersects=true)
|
// Handler for region GeoJSON fetch (triggered by loadRegionsForViewport when intersects=true)
|
||||||
http.get('/api/addons/atlas/regions/geo', () => HttpResponse.json({ features: [] })),
|
http.get('/api/addons/atlas/regions/geo', () => HttpResponse.json({ features: [] })),
|
||||||
);
|
);
|
||||||
@@ -187,18 +190,6 @@ beforeEach(() => {
|
|||||||
seedStore(useAuthStore, { isAuthenticated: true, user: buildUser() });
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildUser() });
|
||||||
seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: false }) });
|
seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: false }) });
|
||||||
|
|
||||||
// Stub the external GeoJSON fetch (GitHub raw URL) to avoid real network calls
|
|
||||||
vi.spyOn(global, 'fetch').mockImplementation((url) => {
|
|
||||||
const urlStr = String(url);
|
|
||||||
if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
|
|
||||||
return Promise.resolve({
|
|
||||||
ok: true,
|
|
||||||
json: () => Promise.resolve({ type: 'FeatureCollection', features: [] }),
|
|
||||||
} as Response);
|
|
||||||
}
|
|
||||||
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
useDefaultAtlasHandlers();
|
useDefaultAtlasHandlers();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -469,16 +460,9 @@ describe('AtlasPage', () => {
|
|||||||
describe('FE-PAGE-ATLAS-017: country search filters options from GeoJSON', () => {
|
describe('FE-PAGE-ATLAS-017: country search filters options from GeoJSON', () => {
|
||||||
it('typing in search updates the input value', async () => {
|
it('typing in search updates the input value', async () => {
|
||||||
// Override fetch to return GeoJSON with FR feature
|
// Override fetch to return GeoJSON with FR feature
|
||||||
vi.spyOn(global, 'fetch').mockImplementation((url) => {
|
server.use(
|
||||||
const urlStr = String(url);
|
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)),
|
||||||
if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
|
);
|
||||||
return Promise.resolve({
|
|
||||||
ok: true,
|
|
||||||
json: () => Promise.resolve(geoJsonWithFR),
|
|
||||||
} as Response);
|
|
||||||
}
|
|
||||||
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<AtlasPage />);
|
render(<AtlasPage />);
|
||||||
@@ -519,16 +503,9 @@ describe('AtlasPage', () => {
|
|||||||
|
|
||||||
describe('FE-PAGE-ATLAS-019: confirm popup shows via Enter on search with GeoJSON', () => {
|
describe('FE-PAGE-ATLAS-019: confirm popup shows via Enter on search with GeoJSON', () => {
|
||||||
it('pressing Enter in search with matching GeoJSON result triggers confirm popup', async () => {
|
it('pressing Enter in search with matching GeoJSON result triggers confirm popup', async () => {
|
||||||
vi.spyOn(global, 'fetch').mockImplementation((url) => {
|
server.use(
|
||||||
const urlStr = String(url);
|
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)),
|
||||||
if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
|
);
|
||||||
return Promise.resolve({
|
|
||||||
ok: true,
|
|
||||||
json: () => Promise.resolve(geoJsonWithFR),
|
|
||||||
} as Response);
|
|
||||||
}
|
|
||||||
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
server.use(
|
server.use(
|
||||||
http.post('/api/addons/atlas/country/:code/mark', () => HttpResponse.json({ success: true })),
|
http.post('/api/addons/atlas/country/:code/mark', () => HttpResponse.json({ success: true })),
|
||||||
@@ -600,16 +577,9 @@ describe('AtlasPage', () => {
|
|||||||
|
|
||||||
describe('FE-PAGE-ATLAS-022: confirm popup for bucket type shows month/year selects', () => {
|
describe('FE-PAGE-ATLAS-022: confirm popup for bucket type shows month/year selects', () => {
|
||||||
it('selecting Add to bucket list in confirm popup shows month/year pickers', async () => {
|
it('selecting Add to bucket list in confirm popup shows month/year pickers', async () => {
|
||||||
vi.spyOn(global, 'fetch').mockImplementation((url) => {
|
server.use(
|
||||||
const urlStr = String(url);
|
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)),
|
||||||
if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
|
);
|
||||||
return Promise.resolve({
|
|
||||||
ok: true,
|
|
||||||
json: () => Promise.resolve(geoJsonWithFR),
|
|
||||||
} as Response);
|
|
||||||
}
|
|
||||||
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<AtlasPage />);
|
render(<AtlasPage />);
|
||||||
@@ -642,16 +612,9 @@ describe('AtlasPage', () => {
|
|||||||
|
|
||||||
describe('FE-PAGE-ATLAS-031: confirm popup opens and mark-visited action works', () => {
|
describe('FE-PAGE-ATLAS-031: confirm popup opens and mark-visited action works', () => {
|
||||||
it('opens confirm popup via search and clicking Mark as visited closes it', async () => {
|
it('opens confirm popup via search and clicking Mark as visited closes it', async () => {
|
||||||
vi.spyOn(global, 'fetch').mockImplementation((url) => {
|
server.use(
|
||||||
const urlStr = String(url);
|
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)),
|
||||||
if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
|
);
|
||||||
return Promise.resolve({
|
|
||||||
ok: true,
|
|
||||||
json: () => Promise.resolve(geoJsonWithFR),
|
|
||||||
} as Response);
|
|
||||||
}
|
|
||||||
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
server.use(
|
server.use(
|
||||||
http.post('/api/addons/atlas/country/:code/mark', () => HttpResponse.json({ success: true })),
|
http.post('/api/addons/atlas/country/:code/mark', () => HttpResponse.json({ success: true })),
|
||||||
@@ -710,16 +673,9 @@ describe('AtlasPage', () => {
|
|||||||
|
|
||||||
describe('FE-PAGE-ATLAS-032: confirm popup Add to Bucket opens bucket type', () => {
|
describe('FE-PAGE-ATLAS-032: confirm popup Add to Bucket opens bucket type', () => {
|
||||||
it('clicking Add to bucket list in choose popup switches to bucket type', async () => {
|
it('clicking Add to bucket list in choose popup switches to bucket type', async () => {
|
||||||
vi.spyOn(global, 'fetch').mockImplementation((url) => {
|
server.use(
|
||||||
const urlStr = String(url);
|
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)),
|
||||||
if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
|
);
|
||||||
return Promise.resolve({
|
|
||||||
ok: true,
|
|
||||||
json: () => Promise.resolve(geoJsonWithFR),
|
|
||||||
} as Response);
|
|
||||||
}
|
|
||||||
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<AtlasPage />);
|
render(<AtlasPage />);
|
||||||
@@ -851,16 +807,9 @@ describe('AtlasPage', () => {
|
|||||||
|
|
||||||
describe('FE-PAGE-ATLAS-029: confirm popup opens via search dropdown click', () => {
|
describe('FE-PAGE-ATLAS-029: confirm popup opens via search dropdown click', () => {
|
||||||
it('clicking a country in the search dropdown opens the confirm action popup', async () => {
|
it('clicking a country in the search dropdown opens the confirm action popup', async () => {
|
||||||
vi.spyOn(global, 'fetch').mockImplementation((url) => {
|
server.use(
|
||||||
const urlStr = String(url);
|
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)),
|
||||||
if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
|
);
|
||||||
return Promise.resolve({
|
|
||||||
ok: true,
|
|
||||||
json: () => Promise.resolve(geoJsonWithFR),
|
|
||||||
} as Response);
|
|
||||||
}
|
|
||||||
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
server.use(
|
server.use(
|
||||||
http.post('/api/addons/atlas/country/:code/mark', () => HttpResponse.json({ success: true })),
|
http.post('/api/addons/atlas/country/:code/mark', () => HttpResponse.json({ success: true })),
|
||||||
@@ -914,16 +863,9 @@ describe('AtlasPage', () => {
|
|||||||
|
|
||||||
describe('FE-PAGE-ATLAS-030: confirm popup overlay click closes it', () => {
|
describe('FE-PAGE-ATLAS-030: confirm popup overlay click closes it', () => {
|
||||||
it('clicking the overlay backdrop closes the confirm popup', async () => {
|
it('clicking the overlay backdrop closes the confirm popup', async () => {
|
||||||
vi.spyOn(global, 'fetch').mockImplementation((url) => {
|
server.use(
|
||||||
const urlStr = String(url);
|
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)),
|
||||||
if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
|
);
|
||||||
return Promise.resolve({
|
|
||||||
ok: true,
|
|
||||||
json: () => Promise.resolve(geoJsonWithFR),
|
|
||||||
} as Response);
|
|
||||||
}
|
|
||||||
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<AtlasPage />);
|
render(<AtlasPage />);
|
||||||
@@ -1000,13 +942,9 @@ describe('AtlasPage', () => {
|
|||||||
{ type: 'Feature', properties: { ISO_A2: 'DE', ADM0_A3: 'DEU', ISO_A3: 'DEU', NAME: 'Germany', ADMIN: 'Germany' }, geometry: null },
|
{ type: 'Feature', properties: { ISO_A2: 'DE', ADM0_A3: 'DEU', ISO_A3: 'DEU', NAME: 'Germany', ADMIN: 'Germany' }, geometry: null },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
vi.spyOn(global, 'fetch').mockImplementation((url) => {
|
server.use(
|
||||||
const urlStr = String(url);
|
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonFRandDE)),
|
||||||
if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
|
);
|
||||||
return Promise.resolve({ ok: true, json: () => Promise.resolve(geoJsonFRandDE) } as Response);
|
|
||||||
}
|
|
||||||
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
render(<AtlasPage />);
|
render(<AtlasPage />);
|
||||||
|
|
||||||
@@ -1023,13 +961,9 @@ describe('AtlasPage', () => {
|
|||||||
|
|
||||||
describe('FE-PAGE-ATLAS-034: dropdown button click + mouse events', () => {
|
describe('FE-PAGE-ATLAS-034: dropdown button click + mouse events', () => {
|
||||||
it('clicking France dropdown button covers onClick and mouse event handlers', async () => {
|
it('clicking France dropdown button covers onClick and mouse event handlers', async () => {
|
||||||
vi.spyOn(global, 'fetch').mockImplementation((url) => {
|
server.use(
|
||||||
const urlStr = String(url);
|
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)),
|
||||||
if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
|
);
|
||||||
return Promise.resolve({ ok: true, json: () => Promise.resolve(geoJsonWithFR) } as Response);
|
|
||||||
}
|
|
||||||
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
server.use(
|
server.use(
|
||||||
http.post('/api/addons/atlas/country/:code/mark', () => HttpResponse.json({ success: true })),
|
http.post('/api/addons/atlas/country/:code/mark', () => HttpResponse.json({ success: true })),
|
||||||
@@ -1100,13 +1034,9 @@ describe('AtlasPage', () => {
|
|||||||
http.get('/api/addons/atlas/stats', () => HttpResponse.json(emptyAtlasResponse)),
|
http.get('/api/addons/atlas/stats', () => HttpResponse.json(emptyAtlasResponse)),
|
||||||
http.post('/api/addons/atlas/country/:code/mark', () => HttpResponse.json({ success: true })),
|
http.post('/api/addons/atlas/country/:code/mark', () => HttpResponse.json({ success: true })),
|
||||||
);
|
);
|
||||||
vi.spyOn(global, 'fetch').mockImplementation((url) => {
|
server.use(
|
||||||
const urlStr = String(url);
|
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)),
|
||||||
if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
|
);
|
||||||
return Promise.resolve({ ok: true, json: () => Promise.resolve(geoJsonWithFR) } as Response);
|
|
||||||
}
|
|
||||||
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<AtlasPage />);
|
render(<AtlasPage />);
|
||||||
@@ -1158,13 +1088,9 @@ describe('AtlasPage', () => {
|
|||||||
|
|
||||||
describe('FE-PAGE-ATLAS-036: bucket popup submit action', () => {
|
describe('FE-PAGE-ATLAS-036: bucket popup submit action', () => {
|
||||||
it('submits a bucket list item from the confirm popup', async () => {
|
it('submits a bucket list item from the confirm popup', async () => {
|
||||||
vi.spyOn(global, 'fetch').mockImplementation((url) => {
|
server.use(
|
||||||
const urlStr = String(url);
|
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)),
|
||||||
if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
|
);
|
||||||
return Promise.resolve({ ok: true, json: () => Promise.resolve(geoJsonWithFR) } as Response);
|
|
||||||
}
|
|
||||||
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
server.use(
|
server.use(
|
||||||
http.post('/api/addons/atlas/bucket-list', () =>
|
http.post('/api/addons/atlas/bucket-list', () =>
|
||||||
@@ -1321,13 +1247,9 @@ describe('AtlasPage', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
vi.spyOn(global, 'fetch').mockImplementation((url) => {
|
server.use(
|
||||||
const urlStr = String(url);
|
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithXK)),
|
||||||
if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
|
);
|
||||||
return Promise.resolve({ ok: true, json: () => Promise.resolve(geoJsonWithXK) } as Response);
|
|
||||||
}
|
|
||||||
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
render(<AtlasPage />);
|
render(<AtlasPage />);
|
||||||
|
|
||||||
@@ -1345,13 +1267,9 @@ describe('AtlasPage', () => {
|
|||||||
{ a3: 'FRA', name: 'France', query: 'france' },
|
{ a3: 'FRA', name: 'France', query: 'france' },
|
||||||
{ a3: 'NOR', name: 'Norway', query: 'norway' },
|
{ a3: 'NOR', name: 'Norway', query: 'norway' },
|
||||||
])('returns $name in search results when GeoJSON provides ADM0_A3=$a3 but ISO_A2 is -99', async ({ a3, name, query }) => {
|
])('returns $name in search results when GeoJSON provides ADM0_A3=$a3 but ISO_A2 is -99', async ({ a3, name, query }) => {
|
||||||
vi.spyOn(global, 'fetch').mockImplementation((url) => {
|
server.use(
|
||||||
const urlStr = String(url);
|
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(makeGeoJsonWithA3Fallback(a3, name))),
|
||||||
if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
|
);
|
||||||
return Promise.resolve({ ok: true, json: () => Promise.resolve(makeGeoJsonWithA3Fallback(a3, name)) } as Response);
|
|
||||||
}
|
|
||||||
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<AtlasPage />);
|
render(<AtlasPage />);
|
||||||
@@ -1459,13 +1377,9 @@ describe('AtlasPage', () => {
|
|||||||
|
|
||||||
describe('FE-PAGE-ATLAS-044: direct France dropdown button click', () => {
|
describe('FE-PAGE-ATLAS-044: direct France dropdown button click', () => {
|
||||||
it('directly finds and clicks the France button in the dropdown to cover onClick', async () => {
|
it('directly finds and clicks the France button in the dropdown to cover onClick', async () => {
|
||||||
vi.spyOn(global, 'fetch').mockImplementation((url) => {
|
server.use(
|
||||||
const urlStr = String(url);
|
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)),
|
||||||
if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
|
);
|
||||||
return Promise.resolve({ ok: true, json: () => Promise.resolve(geoJsonWithFR) } as Response);
|
|
||||||
}
|
|
||||||
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
server.use(
|
server.use(
|
||||||
http.post('/api/addons/atlas/country/:code/mark', () => HttpResponse.json({ success: true })),
|
http.post('/api/addons/atlas/country/:code/mark', () => HttpResponse.json({ success: true })),
|
||||||
@@ -1517,13 +1431,9 @@ describe('AtlasPage', () => {
|
|||||||
|
|
||||||
describe('FE-PAGE-ATLAS-045: dark mode toggle covers map re-init + loadRegionsForViewport', () => {
|
describe('FE-PAGE-ATLAS-045: dark mode toggle covers map re-init + loadRegionsForViewport', () => {
|
||||||
it('switching to dark mode re-initializes map and covers region loading code path', async () => {
|
it('switching to dark mode re-initializes map and covers region loading code path', async () => {
|
||||||
vi.spyOn(global, 'fetch').mockImplementation((url) => {
|
server.use(
|
||||||
const urlStr = String(url);
|
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)),
|
||||||
if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
|
);
|
||||||
return Promise.resolve({ ok: true, json: () => Promise.resolve(geoJsonWithFR) } as Response);
|
|
||||||
}
|
|
||||||
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
server.use(
|
server.use(
|
||||||
http.get('/api/addons/atlas/regions/geo', () => HttpResponse.json({ features: [] })),
|
http.get('/api/addons/atlas/regions/geo', () => HttpResponse.json({ features: [] })),
|
||||||
@@ -1636,13 +1546,9 @@ describe('AtlasPage', () => {
|
|||||||
{ type: 'Feature', properties: { ISO_A2: 'IT', ADM0_A3: 'ITA', ISO_A3: 'ITA', NAME: 'Italy', ADMIN: 'Italy' }, geometry: null },
|
{ type: 'Feature', properties: { ISO_A2: 'IT', ADM0_A3: 'ITA', ISO_A3: 'ITA', NAME: 'Italy', ADMIN: 'Italy' }, geometry: null },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
vi.spyOn(global, 'fetch').mockImplementation((url) => {
|
server.use(
|
||||||
const urlStr = String(url);
|
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonFRandIT)),
|
||||||
if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
|
);
|
||||||
return Promise.resolve({ ok: true, json: () => Promise.resolve(geoJsonFRandIT) } as Response);
|
|
||||||
}
|
|
||||||
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
render(<AtlasPage />);
|
render(<AtlasPage />);
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ function ListsContainer({ tripId, packingItems, todoItems }: { tripId: number; p
|
|||||||
const [saveTemplateSignal, setSaveTemplateSignal] = useState(0)
|
const [saveTemplateSignal, setSaveTemplateSignal] = useState(0)
|
||||||
const [addTodoSignal, setAddTodoSignal] = useState(0)
|
const [addTodoSignal, setAddTodoSignal] = useState(0)
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const isAdmin = useAuthStore(s => s.user?.role === 'admin')
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: 'packing' as const, label: t('todo.subtab.packing'), icon: PackageCheck, count: packingItems.length },
|
{ id: 'packing' as const, label: t('todo.subtab.packing'), icon: PackageCheck, count: packingItems.length },
|
||||||
@@ -121,7 +122,7 @@ function ListsContainer({ tripId, packingItems, todoItems }: { tripId: number; p
|
|||||||
className={`${sharedBtnClass} bg-accent text-accent-text`}
|
className={`${sharedBtnClass} bg-accent text-accent-text`}
|
||||||
style={sharedBtnStyle}
|
style={sharedBtnStyle}
|
||||||
/>
|
/>
|
||||||
{packingItems.length > 0 && (
|
{isAdmin && packingItems.length > 0 && (
|
||||||
<button onClick={() => setSaveTemplateSignal(s => s + 1)}
|
<button onClick={() => setSaveTemplateSignal(s => s + 1)}
|
||||||
className={`${sharedBtnClass} bg-accent text-accent-text`}
|
className={`${sharedBtnClass} bg-accent text-accent-text`}
|
||||||
style={sharedBtnStyle}
|
style={sharedBtnStyle}
|
||||||
|
|||||||
@@ -132,18 +132,19 @@ export function useAtlas() {
|
|||||||
}).catch(() => setLoading(false))
|
}).catch(() => setLoading(false))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Load GeoJSON world data (direct GeoJSON, no conversion needed)
|
// Load country-border GeoJSON from our API (geoBoundaries, served server-side —
|
||||||
|
// no third-party fetch from the browser).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_50m_admin_0_countries.geojson')
|
apiClient.get('/addons/atlas/countries/geo')
|
||||||
.then(r => r.json())
|
.then(res => {
|
||||||
.then(geo => {
|
const geo = res.data
|
||||||
// Dynamically build A2→A3 mapping from GeoJSON
|
// Dynamically build A2→A3 mapping from GeoJSON
|
||||||
for (const f of geo.features) {
|
for (const f of geo.features) {
|
||||||
const a2 = f.properties?.ISO_A2
|
const a2 = f.properties?.ISO_A2
|
||||||
const a3 = f.properties?.ADM0_A3 || f.properties?.ISO_A3
|
const a3 = f.properties?.ADM0_A3 || f.properties?.ISO_A3
|
||||||
// Only real 2-letter ISO codes: natural-earth uses subdivision-style
|
// Only accept clean 2-letter ISO codes and never overwrite an existing
|
||||||
// values like "CN-TW" for Taiwan, which would otherwise overwrite the
|
// mapping: some datasets carry subdivision-style values like "CN-TW" for
|
||||||
// legitimate TWN->TW reverse mapping and break the country (#1049).
|
// Taiwan, which would clobber the legitimate TWN->TW entry (#1049).
|
||||||
if (a2 && a3 && a2.length === 2 && a2 !== '-99' && a3 !== '-99' && !A2_TO_A3[a2]) {
|
if (a2 && a3 && a2.length === 2 && a2 !== '-99' && a3 !== '-99' && !A2_TO_A3[a2]) {
|
||||||
A2_TO_A3[a2] = a3
|
A2_TO_A3[a2] = a3
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ interface Addon {
|
|||||||
|
|
||||||
interface AddonState {
|
interface AddonState {
|
||||||
addons: Addon[]
|
addons: Addon[]
|
||||||
|
bagTracking: boolean
|
||||||
loaded: boolean
|
loaded: boolean
|
||||||
loadAddons: () => Promise<void>
|
loadAddons: () => Promise<void>
|
||||||
isEnabled: (id: string) => boolean
|
isEnabled: (id: string) => boolean
|
||||||
@@ -31,12 +32,13 @@ interface AddonState {
|
|||||||
|
|
||||||
export const useAddonStore = create<AddonState>((set, get) => ({
|
export const useAddonStore = create<AddonState>((set, get) => ({
|
||||||
addons: [],
|
addons: [],
|
||||||
|
bagTracking: false,
|
||||||
loaded: false,
|
loaded: false,
|
||||||
|
|
||||||
loadAddons: async () => {
|
loadAddons: async () => {
|
||||||
try {
|
try {
|
||||||
const data = await addonsApi.enabled()
|
const data = await addonsApi.enabled()
|
||||||
set({ addons: data.addons || [], loaded: true })
|
set({ addons: data.addons || [], bagTracking: !!data.bagTracking, loaded: true })
|
||||||
} catch {
|
} catch {
|
||||||
set({ loaded: true })
|
set({ loaded: true })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { http, HttpResponse } from 'msw';
|
|||||||
export const addonHandlers = [
|
export const addonHandlers = [
|
||||||
http.get('/api/addons', () => {
|
http.get('/api/addons', () => {
|
||||||
return HttpResponse.json({
|
return HttpResponse.json({
|
||||||
|
bagTracking: false,
|
||||||
addons: [
|
addons: [
|
||||||
{ id: 'vacay', name: 'Vacay', type: 'feature', icon: 'calendar', enabled: true },
|
{ id: 'vacay', name: 'Vacay', type: 'feature', icon: 'calendar', enabled: true },
|
||||||
{ id: 'atlas', name: 'Atlas', type: 'feature', icon: 'map', enabled: true },
|
{ id: 'atlas', name: 'Atlas', type: 'feature', icon: 'map', enabled: true },
|
||||||
|
|||||||
@@ -18,6 +18,18 @@ describe('addonStore', () => {
|
|||||||
expect(state.addons.length).toBeGreaterThan(0);
|
expect(state.addons.length).toBeGreaterThan(0);
|
||||||
expect(state.addons[0]).toHaveProperty('id');
|
expect(state.addons[0]).toHaveProperty('id');
|
||||||
expect(state.addons[0]).toHaveProperty('enabled', true);
|
expect(state.addons[0]).toHaveProperty('enabled', true);
|
||||||
|
expect(state.bagTracking).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('captures the global bagTracking flag from the response', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/addons', () =>
|
||||||
|
HttpResponse.json({ bagTracking: true, addons: [] })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
await useAddonStore.getState().loadAddons();
|
||||||
|
expect(useAddonStore.getState().bagTracking).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
.atlas-geo-cache/
|
||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,225 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Build server/assets/atlas/{admin0,admin1}.geojson.gz from geoBoundaries (gbOpen).
|
||||||
|
//
|
||||||
|
// Why: Atlas previously fetched country + sub-national boundaries from Natural Earth's
|
||||||
|
// GitHub `master` at runtime. Natural Earth is stale (e.g. it still shows Norway's
|
||||||
|
// pre-2020 counties) and depicts some contested territory in ways the project does not
|
||||||
|
// want (see nvkelso/natural-earth-vector#391). geoBoundaries (CC BY 4.0) is current,
|
||||||
|
// redistributable, and carries ISO 3166-2 codes on its per-country ADM1 files.
|
||||||
|
//
|
||||||
|
// This downloads the *simplified* per-country gbOpen ADM0 (countries) and ADM1
|
||||||
|
// (regions) layers from a pinned geoBoundaries revision, normalizes each feature to
|
||||||
|
// the property names the Atlas client/server already read, and writes two gzipped
|
||||||
|
// FeatureCollections that the server serves at runtime (no network at boot).
|
||||||
|
//
|
||||||
|
// geoBoundaries: CC BY 4.0 — https://www.geoboundaries.org/ (attribution required).
|
||||||
|
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import zlib from 'node:zlib'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const OUT_DIR = path.join(__dirname, '..', 'assets', 'atlas')
|
||||||
|
|
||||||
|
// Pinned geoBoundaries revision (override with GB_REF=<sha|branch|tag>). The LFS media
|
||||||
|
// endpoint resolves a commit SHA, branch, or tag in the <ref> path segment.
|
||||||
|
const GB_REF = process.env.GB_REF || '5c25134028196d43ce97b5071934fd0cfc92f09f'
|
||||||
|
const MEDIA = (a3, level) =>
|
||||||
|
`https://media.githubusercontent.com/media/wmgeolab/geoBoundaries/${GB_REF}` +
|
||||||
|
`/releaseData/gbOpen/${a3}/${level}/geoBoundaries-${a3}-${level}_simplified.geojson`
|
||||||
|
|
||||||
|
// Country borders come from CGAZ (the Comprehensive Global Administrative Zones composite)
|
||||||
|
// rather than per-country gbOpen ADM0: CGAZ is gap-filled, so it includes territories
|
||||||
|
// that gbOpen omits or folds away — notably Svalbard (inside Norway's geometry) and
|
||||||
|
// Greenland. The country layer only needs A3/A2/name, so CGAZ's lack of `shapeISO` is
|
||||||
|
// irrelevant. (gbOpen ADM0 maxes Norway at 71°N and has no Svalbard at all.)
|
||||||
|
const CGAZ_ADM0 =
|
||||||
|
`https://media.githubusercontent.com/media/wmgeolab/geoBoundaries/${GB_REF}` +
|
||||||
|
`/releaseData/CGAZ/geoBoundariesCGAZ_ADM0.geojson`
|
||||||
|
|
||||||
|
const CONCURRENCY = 8
|
||||||
|
const RETRIES = 3
|
||||||
|
|
||||||
|
// Complete ISO-3166-1 alpha-3 → alpha-2 map (source: lukes/ISO-3166-Countries-with-
|
||||||
|
// Regional-Codes). Drives ADM1 enumeration (one gbOpen request per code; missing ones
|
||||||
|
// 404 and are skipped) and stamps `iso_a2`/`ISO_A2` (geoBoundaries keys by alpha-3
|
||||||
|
// `shapeGroup`). A complete map — not the client's curated ~180 — is what restores the
|
||||||
|
// dropped territories (Greenland, Falklands, French Guiana, …).
|
||||||
|
const A3_TO_A2 = {"ABW":"AW", "AFG":"AF", "AGO":"AO", "AIA":"AI", "ALA":"AX", "ALB":"AL", "AND":"AD", "ARE":"AE", "ARG":"AR", "ARM":"AM", "ASM":"AS", "ATA":"AQ", "ATF":"TF", "ATG":"AG", "AUS":"AU", "AUT":"AT", "AZE":"AZ", "BDI":"BI", "BEL":"BE", "BEN":"BJ", "BES":"BQ", "BFA":"BF", "BGD":"BD", "BGR":"BG", "BHR":"BH", "BHS":"BS", "BIH":"BA", "BLM":"BL", "BLR":"BY", "BLZ":"BZ", "BMU":"BM", "BOL":"BO", "BRA":"BR", "BRB":"BB", "BRN":"BN", "BTN":"BT", "BVT":"BV", "BWA":"BW", "CAF":"CF", "CAN":"CA", "CCK":"CC", "CHE":"CH", "CHL":"CL", "CHN":"CN", "CIV":"CI", "CMR":"CM", "COD":"CD", "COG":"CG", "COK":"CK", "COL":"CO", "COM":"KM", "CPV":"CV", "CRI":"CR", "CUB":"CU", "CUW":"CW", "CXR":"CX", "CYM":"KY", "CYP":"CY", "CZE":"CZ", "DEU":"DE", "DJI":"DJ", "DMA":"DM", "DNK":"DK", "DOM":"DO", "DZA":"DZ", "ECU":"EC", "EGY":"EG", "ERI":"ER", "ESH":"EH", "ESP":"ES", "EST":"EE", "ETH":"ET", "FIN":"FI", "FJI":"FJ", "FLK":"FK", "FRA":"FR", "FRO":"FO", "FSM":"FM", "GAB":"GA", "GBR":"GB", "GEO":"GE", "GGY":"GG", "GHA":"GH", "GIB":"GI", "GIN":"GN", "GLP":"GP", "GMB":"GM", "GNB":"GW", "GNQ":"GQ", "GRC":"GR", "GRD":"GD", "GRL":"GL", "GTM":"GT", "GUF":"GF", "GUM":"GU", "GUY":"GY", "HKG":"HK", "HMD":"HM", "HND":"HN", "HRV":"HR", "HTI":"HT", "HUN":"HU", "IDN":"ID", "IMN":"IM", "IND":"IN", "IOT":"IO", "IRL":"IE", "IRN":"IR", "IRQ":"IQ", "ISL":"IS", "ISR":"IL", "ITA":"IT", "JAM":"JM", "JEY":"JE", "JOR":"JO", "JPN":"JP", "KAZ":"KZ", "KEN":"KE", "KGZ":"KG", "KHM":"KH", "KIR":"KI", "KNA":"KN", "KOR":"KR", "KWT":"KW", "LAO":"LA", "LBN":"LB", "LBR":"LR", "LBY":"LY", "LCA":"LC", "LIE":"LI", "LKA":"LK", "LSO":"LS", "LTU":"LT", "LUX":"LU", "LVA":"LV", "MAC":"MO", "MAF":"MF", "MAR":"MA", "MCO":"MC", "MDA":"MD", "MDG":"MG", "MDV":"MV", "MEX":"MX", "MHL":"MH", "MKD":"MK", "MLI":"ML", "MLT":"MT", "MMR":"MM", "MNE":"ME", "MNG":"MN", "MNP":"MP", "MOZ":"MZ", "MRT":"MR", "MSR":"MS", "MTQ":"MQ", "MUS":"MU", "MWI":"MW", "MYS":"MY", "MYT":"YT", "NAM":"NA", "NCL":"NC", "NER":"NE", "NFK":"NF", "NGA":"NG", "NIC":"NI", "NIU":"NU", "NLD":"NL", "NOR":"NO", "NPL":"NP", "NRU":"NR", "NZL":"NZ", "OMN":"OM", "PAK":"PK", "PAN":"PA", "PCN":"PN", "PER":"PE", "PHL":"PH", "PLW":"PW", "PNG":"PG", "POL":"PL", "PRI":"PR", "PRK":"KP", "PRT":"PT", "PRY":"PY", "PSE":"PS", "PYF":"PF", "QAT":"QA", "REU":"RE", "ROU":"RO", "RUS":"RU", "RWA":"RW", "SAU":"SA", "SDN":"SD", "SEN":"SN", "SGP":"SG", "SGS":"GS", "SHN":"SH", "SJM":"SJ", "SLB":"SB", "SLE":"SL", "SLV":"SV", "SMR":"SM", "SOM":"SO", "SPM":"PM", "SRB":"RS", "SSD":"SS", "STP":"ST", "SUR":"SR", "SVK":"SK", "SVN":"SI", "SWE":"SE", "SWZ":"SZ", "SXM":"SX", "SYC":"SC", "SYR":"SY", "TCA":"TC", "TCD":"TD", "TGO":"TG", "THA":"TH", "TJK":"TJ", "TKL":"TK", "TKM":"TM", "TLS":"TL", "TON":"TO", "TTO":"TT", "TUN":"TN", "TUR":"TR", "TUV":"TV", "TWN":"TW", "TZA":"TZ", "UGA":"UG", "UKR":"UA", "UMI":"UM", "URY":"UY", "USA":"US", "UZB":"UZ", "VAT":"VA", "VCT":"VC", "VEN":"VE", "VGB":"VG", "VIR":"VI", "VNM":"VN", "VUT":"VU", "WLF":"WF", "WSM":"WS", "YEM":"YE", "ZAF":"ZA", "ZMB":"ZM", "ZWE":"ZW"}
|
||||||
|
|
||||||
|
const COUNTRIES = Object.keys(A3_TO_A2) // every ISO alpha-3 code (ADM1 fetch list)
|
||||||
|
|
||||||
|
// Cache raw downloads so re-runs (e.g. to tune simplification) don't re-fetch ~360 files.
|
||||||
|
const CACHE_DIR = path.join(__dirname, '..', '.atlas-geo-cache', GB_REF)
|
||||||
|
|
||||||
|
async function fetchGeo(url) {
|
||||||
|
const cacheFile = path.join(CACHE_DIR, url.split('/').slice(-1)[0])
|
||||||
|
if (fs.existsSync(cacheFile)) {
|
||||||
|
const cached = fs.readFileSync(cacheFile, 'utf8')
|
||||||
|
return cached === '' ? null : JSON.parse(cached)
|
||||||
|
}
|
||||||
|
for (let attempt = 1; attempt <= RETRIES; attempt++) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, { headers: { 'User-Agent': 'TREK atlas builder' } })
|
||||||
|
if (res.status === 404) { fs.writeFileSync(cacheFile, ''); return null } // no file — skip
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
const text = await res.text()
|
||||||
|
if (text.startsWith('version https://git-lfs')) throw new Error('got LFS pointer, not content')
|
||||||
|
const parsed = JSON.parse(text)
|
||||||
|
fs.writeFileSync(cacheFile, text)
|
||||||
|
return parsed
|
||||||
|
} catch (err) {
|
||||||
|
if (attempt === RETRIES) {
|
||||||
|
console.warn(` ! ${url.split('/').slice(-1)[0]}: ${err.message}`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
await new Promise(r => setTimeout(r, 500 * attempt))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run async tasks with a fixed concurrency cap.
|
||||||
|
async function pool(items, worker) {
|
||||||
|
const results = []
|
||||||
|
let i = 0
|
||||||
|
const runners = Array.from({ length: CONCURRENCY }, async () => {
|
||||||
|
while (i < items.length) {
|
||||||
|
const idx = i++
|
||||||
|
results[idx] = await worker(items[idx], idx)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await Promise.all(runners)
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
// Geometry size control. geoBoundaries' "_simplified" files still carry ~12-decimal
|
||||||
|
// coordinates, which dominate the JSON size. Quantizing to a fixed grid (rounding
|
||||||
|
// preserves topology — identical input coords map to identical output) and dropping
|
||||||
|
// the now-redundant consecutive duplicate points shrinks the bundles ~5-8x with no
|
||||||
|
// visible effect at the atlas' zoom range (3-10). ADM0 fills are viewed zoomed out, so
|
||||||
|
// they tolerate a coarser grid than ADM1 region borders.
|
||||||
|
const ADM0_DECIMALS = 2 // ~1.1 km
|
||||||
|
const ADM1_DECIMALS = 3 // ~110 m
|
||||||
|
|
||||||
|
function quantizeRing(ring, decimals) {
|
||||||
|
const m = 10 ** decimals
|
||||||
|
const out = []
|
||||||
|
let prevX, prevY
|
||||||
|
for (const pt of ring) {
|
||||||
|
const x = Math.round(pt[0] * m) / m
|
||||||
|
const y = Math.round(pt[1] * m) / m
|
||||||
|
if (x === prevX && y === prevY) continue
|
||||||
|
out.push([x, y])
|
||||||
|
prevX = x; prevY = y
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quantize a (Multi)Polygon, dropping rings that collapse below a valid ring (<4 pts).
|
||||||
|
function quantizeGeometry(geom, decimals) {
|
||||||
|
if (!geom) return null
|
||||||
|
if (geom.type === 'Polygon') {
|
||||||
|
const rings = geom.coordinates.map(r => quantizeRing(r, decimals)).filter(r => r.length >= 4)
|
||||||
|
return rings.length ? { type: 'Polygon', coordinates: rings } : null
|
||||||
|
}
|
||||||
|
if (geom.type === 'MultiPolygon') {
|
||||||
|
const polys = geom.coordinates
|
||||||
|
.map(poly => poly.map(r => quantizeRing(r, decimals)).filter(r => r.length >= 4))
|
||||||
|
.filter(poly => poly.length)
|
||||||
|
return polys.length ? { type: 'MultiPolygon', coordinates: polys } : null
|
||||||
|
}
|
||||||
|
return geom
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize one CGAZ ADM0 feature (keyed by alpha-3 `shapeGroup`) to the property names
|
||||||
|
// the client country layer reads (ISO_A2/ADM0_A3/NAME/ADMIN). Returns null for the CRS
|
||||||
|
// pseudo-entry or anything without a group/geometry.
|
||||||
|
function normalizeAdm0Feature(f) {
|
||||||
|
const a3 = f.properties?.shapeGroup
|
||||||
|
if (!a3) return null
|
||||||
|
const name = f.properties?.shapeName || a3
|
||||||
|
const geometry = quantizeGeometry(f.geometry, ADM0_DECIMALS)
|
||||||
|
if (!geometry) return null
|
||||||
|
return {
|
||||||
|
type: 'Feature',
|
||||||
|
properties: { ISO_A2: A3_TO_A2[a3] || null, ADM0_A3: a3, NAME: name, ADMIN: name },
|
||||||
|
geometry,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAdm1(geo, a3, countryName) {
|
||||||
|
if (!geo?.features) return []
|
||||||
|
return geo.features.map(f => {
|
||||||
|
const name = f.properties?.shapeName || ''
|
||||||
|
const geometry = quantizeGeometry(f.geometry, ADM1_DECIMALS)
|
||||||
|
if (!geometry) return null
|
||||||
|
const a2 = A3_TO_A2[a3] || null
|
||||||
|
// shapeISO is a real ISO 3166-2 code for ~90% of features; geoBoundaries leaves the
|
||||||
|
// rest blank or uses an `XX_YYY` placeholder. Keep real/placeholder codes as-is
|
||||||
|
// (stable per polygon → manual mark/unmark works, real ones match Nominatim). For
|
||||||
|
// blank codes, synthesize a stable id mirroring the server's geocode fallback so
|
||||||
|
// every region is still markable.
|
||||||
|
let code = f.properties?.shapeISO || ''
|
||||||
|
if (!code && a2) code = `${a2}-${name.replace(/[^A-Za-z0-9]/g, '').substring(0, 3).toUpperCase()}`
|
||||||
|
return {
|
||||||
|
type: 'Feature',
|
||||||
|
// Property names the Atlas region layer + server getRegionGeo already read.
|
||||||
|
properties: {
|
||||||
|
iso_a2: a2,
|
||||||
|
iso_3166_2: code,
|
||||||
|
name,
|
||||||
|
name_en: name,
|
||||||
|
admin: countryName,
|
||||||
|
},
|
||||||
|
geometry,
|
||||||
|
}
|
||||||
|
}).filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(`[atlas-geo] geoBoundaries ref ${GB_REF}; ${COUNTRIES.length} countries`)
|
||||||
|
fs.mkdirSync(OUT_DIR, { recursive: true })
|
||||||
|
fs.mkdirSync(CACHE_DIR, { recursive: true })
|
||||||
|
|
||||||
|
// ADM0 (countries) — one comprehensive CGAZ file (large; cached). Also yields the
|
||||||
|
// English country name (shapeGroup → shapeName) used for the ADM1 `admin` field.
|
||||||
|
console.log('[atlas-geo] downloading CGAZ ADM0 (countries)…')
|
||||||
|
const cgaz = await fetchGeo(CGAZ_ADM0)
|
||||||
|
const adm0Features = []
|
||||||
|
const a3ToName = {}
|
||||||
|
for (const f of cgaz?.features || []) {
|
||||||
|
const nf = normalizeAdm0Feature(f)
|
||||||
|
if (nf) { a3ToName[nf.properties.ADM0_A3] = nf.properties.NAME; adm0Features.push(nf) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ADM1 (sub-national regions) — per-country gbOpen (carries ISO 3166-2 `shapeISO`).
|
||||||
|
console.log('[atlas-geo] downloading ADM1 (regions)…')
|
||||||
|
const adm1Raw = await pool(COUNTRIES, a3 => fetchGeo(MEDIA(a3, 'ADM1')))
|
||||||
|
const adm1Features = []
|
||||||
|
let withCodes = 0
|
||||||
|
COUNTRIES.forEach((a3, idx) => {
|
||||||
|
const feats = normalizeAdm1(adm1Raw[idx], a3, a3ToName[a3] || a3)
|
||||||
|
for (const f of feats) if (f.properties.iso_3166_2) withCodes++
|
||||||
|
adm1Features.push(...feats)
|
||||||
|
})
|
||||||
|
|
||||||
|
const write = (name, features) => {
|
||||||
|
const fc = { type: 'FeatureCollection', features }
|
||||||
|
const gz = zlib.gzipSync(Buffer.from(JSON.stringify(fc)), { level: 9 })
|
||||||
|
const file = path.join(OUT_DIR, `${name}.geojson.gz`)
|
||||||
|
fs.writeFileSync(file, gz)
|
||||||
|
console.log(`[atlas-geo] wrote ${path.relative(path.join(__dirname, '..'), file)} — ${features.length} features, ${(gz.length / 1e6).toFixed(1)} MB gz`)
|
||||||
|
}
|
||||||
|
|
||||||
|
write('admin0', adm0Features)
|
||||||
|
write('admin1', adm1Features)
|
||||||
|
|
||||||
|
const missing1 = COUNTRIES.filter((a3, i) => !normalizeAdm1(adm1Raw[i], a3, '').length)
|
||||||
|
console.log(`[atlas-geo] ADM0 country features: ${adm0Features.length}`)
|
||||||
|
console.log(`[atlas-geo] ADM1 countries without regions (skipped/404): ${missing1.length}`)
|
||||||
|
console.log(`[atlas-geo] ADM1 features with ISO 3166-2 code: ${withCodes}/${adm1Features.length}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(err => { console.error(err); process.exit(1) })
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import zlib from 'zlib';
|
||||||
import Database from 'better-sqlite3';
|
import Database from 'better-sqlite3';
|
||||||
import { encrypt_api_key } from '../services/apiKeyCrypto';
|
import { encrypt_api_key } from '../services/apiKeyCrypto';
|
||||||
|
|
||||||
@@ -2369,6 +2372,94 @@ function runMigrations(db: Database.Database): void {
|
|||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_expires ON webauthn_challenges(expires_at);
|
CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_expires ON webauthn_challenges(expires_at);
|
||||||
`),
|
`),
|
||||||
|
// Atlas dropped Natural Earth for geoBoundaries. Manually-marked sub-national
|
||||||
|
// regions (`visited_regions`) stored the OLD Natural Earth ISO-3166-2 codes; some no
|
||||||
|
// longer match any polygon in the new bundle and would stop highlighting. Reconcile
|
||||||
|
// every row against the ACTUAL shipped admin-1 bundle so this covers *all* countries,
|
||||||
|
// not just one hand-listed reform:
|
||||||
|
// 1. code still present in the new bundle → leave it (already correct);
|
||||||
|
// 2. else a region in the same country shares → adopt that region's code+name
|
||||||
|
// the stored region_name (case-insensitive) (handles code re-spellings, e.g.
|
||||||
|
// ES-AN → ES_AND, names unchanged);
|
||||||
|
// 3. else a curated merge crosswalk maps it → adopt the merged region (handles
|
||||||
|
// (region absorbed into a *renamed* one) reforms where the name changed,
|
||||||
|
// which step 2 cannot catch);
|
||||||
|
// 4. else → leave as-is (cannot be resolved; the client's name fallback may still
|
||||||
|
// highlight it, and nothing is destroyed).
|
||||||
|
// Other Atlas tables need NO remap: `visited_countries` / `bucket_list` hold only
|
||||||
|
// ISO-3166-1 alpha-2 codes (invariant across the swap), `bucket_list.name` is free
|
||||||
|
// text we must not auto-rewrite, and `place_regions` is a re-derivable Nominatim cache.
|
||||||
|
() => {
|
||||||
|
type Row = { id: number; region_code: string; region_name: string; country_code: string };
|
||||||
|
const rows = db.prepare(
|
||||||
|
'SELECT id, region_code, region_name, country_code FROM visited_regions'
|
||||||
|
).all() as Row[];
|
||||||
|
if (rows.length === 0) return; // nothing marked → skip the bundle read entirely
|
||||||
|
|
||||||
|
// Index the shipped admin-1 bundle: valid codes, name→code per country, code→name.
|
||||||
|
// __dirname resolves ../../assets under both dist (dist/db) and tests (src/db).
|
||||||
|
let features: { properties?: { iso_a2?: string; iso_3166_2?: string; name?: string } }[] = [];
|
||||||
|
try {
|
||||||
|
const file = path.join(__dirname, '..', '..', 'assets', 'atlas', 'admin1.geojson.gz');
|
||||||
|
features = JSON.parse(zlib.gunzipSync(fs.readFileSync(file)).toString('utf8')).features || [];
|
||||||
|
} catch {
|
||||||
|
features = []; // bundle missing → degrade to the curated crosswalk below
|
||||||
|
}
|
||||||
|
const validCodes = new Set<string>();
|
||||||
|
const nameToCode = new Map<string, string>(); // `${A2}|${nameLower}` → code
|
||||||
|
const codeToName = new Map<string, string>();
|
||||||
|
for (const f of features) {
|
||||||
|
const a2 = (f.properties?.iso_a2 || '').toUpperCase();
|
||||||
|
const code = f.properties?.iso_3166_2 || '';
|
||||||
|
const name = f.properties?.name || '';
|
||||||
|
if (!code) continue;
|
||||||
|
validCodes.add(code);
|
||||||
|
if (!codeToName.has(code)) codeToName.set(code, name);
|
||||||
|
if (a2 && name) nameToCode.set(`${a2}|${name.toLowerCase()}`, code);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Curated crosswalk for regions absorbed into a *renamed* successor (step 2 can't
|
||||||
|
// match these because the name changed). Norway's 2018/2020 reforms; extend as the
|
||||||
|
// pinned geoBoundaries dataset gains further reforms.
|
||||||
|
const MERGE_CROSSWALK: Record<string, string> = {
|
||||||
|
'NO-04': 'NO-34', 'NO-05': 'NO-34', // Hedmark, Oppland → Innlandet
|
||||||
|
'NO-12': 'NO-46', 'NO-14': 'NO-46', // Hordaland, Sogn og Fjordane → Vestland
|
||||||
|
'NO-09': 'NO-42', 'NO-10': 'NO-42', // Aust-/Vest-Agder → Agder
|
||||||
|
'NO-01': 'NO-30', 'NO-02': 'NO-30', 'NO-06': 'NO-30', // Østfold/Akershus/Buskerud → Viken
|
||||||
|
'NO-07': 'NO-38', 'NO-08': 'NO-38', // Vestfold, Telemark → Vestfold og Telemark
|
||||||
|
'NO-19': 'NO-54', 'NO-20': 'NO-54', // Troms, Finnmark → Troms og Finnmark
|
||||||
|
'NO-16': 'NO-50', 'NO-17': 'NO-50', // Sør-/Nord-Trøndelag → Trøndelag
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolve = (row: Row): string | null => {
|
||||||
|
if (validCodes.has(row.region_code)) return null; // already valid
|
||||||
|
const a2 = (row.country_code || '').toUpperCase();
|
||||||
|
const byName = nameToCode.get(`${a2}|${(row.region_name || '').toLowerCase()}`);
|
||||||
|
if (byName) return byName;
|
||||||
|
const merged = MERGE_CROSSWALK[row.region_code];
|
||||||
|
// Only trust the crosswalk target if it actually exists in the bundle (or the
|
||||||
|
// bundle was unreadable, in which case we apply the curated map blindly).
|
||||||
|
if (merged && (validCodes.size === 0 || validCodes.has(merged))) return merged;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const update = db.prepare(
|
||||||
|
'UPDATE OR IGNORE visited_regions SET region_code = ?, region_name = ? WHERE id = ?'
|
||||||
|
);
|
||||||
|
const del = db.prepare('DELETE FROM visited_regions WHERE id = ?');
|
||||||
|
for (const row of rows) {
|
||||||
|
const newCode = resolve(row);
|
||||||
|
if (!newCode || newCode === row.region_code) continue;
|
||||||
|
const newName = codeToName.get(newCode) || row.region_name;
|
||||||
|
update.run(newCode, newName, row.id);
|
||||||
|
// UNIQUE(user_id, region_code): if the user already had the target code the
|
||||||
|
// UPDATE was IGNORED and this row still carries the old code → drop the duplicate.
|
||||||
|
const after = db.prepare('SELECT region_code FROM visited_regions WHERE id = ?').get(row.id) as
|
||||||
|
| { region_code: string }
|
||||||
|
| undefined;
|
||||||
|
if (after && after.region_code === row.region_code) del.run(row.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (currentVersion < migrations.length) {
|
if (currentVersion < migrations.length) {
|
||||||
|
|||||||
@@ -97,7 +97,6 @@ export function applyGlobalMiddleware(
|
|||||||
"https://*.basemaps.cartocdn.com", "https://*.tile.openstreetmap.org",
|
"https://*.basemaps.cartocdn.com", "https://*.tile.openstreetmap.org",
|
||||||
"https://unpkg.com", "https://open-meteo.com", "https://api.open-meteo.com",
|
"https://unpkg.com", "https://open-meteo.com", "https://api.open-meteo.com",
|
||||||
"https://geocoding-api.open-meteo.com", "https://api.exchangerate-api.com",
|
"https://geocoding-api.open-meteo.com", "https://api.exchangerate-api.com",
|
||||||
"https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_50m_admin_0_countries.geojson",
|
|
||||||
"https://router.project-osrm.org/route/v1/", "https://routing.openstreetmap.de/",
|
"https://router.project-osrm.org/route/v1/", "https://routing.openstreetmap.de/",
|
||||||
"https://api.mapbox.com", "https://*.tiles.mapbox.com", "https://events.mapbox.com"
|
"https://api.mapbox.com", "https://*.tiles.mapbox.com", "https://events.mapbox.com"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { db } from '../../db/database';
|
import { db } from '../../db/database';
|
||||||
import type { Addon } from '../../types';
|
import type { Addon } from '../../types';
|
||||||
import { getCollabFeatures } from '../../services/adminService';
|
import { getBagTracking, getCollabFeatures } from '../../services/adminService';
|
||||||
import { getPhotoProviderConfig } from '../../services/memories/helpersService';
|
import { getPhotoProviderConfig } from '../../services/memories/helpersService';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -53,6 +53,7 @@ export class AddonsService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
collabFeatures: getCollabFeatures(),
|
collabFeatures: getCollabFeatures(),
|
||||||
|
bagTracking: getBagTracking().enabled,
|
||||||
addons: [
|
addons: [
|
||||||
...addons.map((a) => ({ ...a, enabled: !!a.enabled })),
|
...addons.map((a) => ({ ...a, enabled: !!a.enabled })),
|
||||||
...providers.map((p) => ({
|
...providers.map((p) => ({
|
||||||
|
|||||||
@@ -62,6 +62,12 @@ export class AtlasController {
|
|||||||
return geo;
|
return geo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('countries/geo')
|
||||||
|
@Header('Cache-Control', 'public, max-age=86400')
|
||||||
|
countryGeo(): RegionGeo {
|
||||||
|
return this.atlas.countryGeo();
|
||||||
|
}
|
||||||
|
|
||||||
@Get('country/:code')
|
@Get('country/:code')
|
||||||
countryPlaces(@CurrentUser() user: User, @Param('code') code: string) {
|
countryPlaces(@CurrentUser() user: User, @Param('code') code: string) {
|
||||||
return this.atlas.countryPlaces(user.id, code.toUpperCase());
|
return this.atlas.countryPlaces(user.id, code.toUpperCase());
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
unmarkRegionVisited,
|
unmarkRegionVisited,
|
||||||
getVisitedRegions,
|
getVisitedRegions,
|
||||||
getRegionGeo,
|
getRegionGeo,
|
||||||
|
getCountryGeo,
|
||||||
listBucketList,
|
listBucketList,
|
||||||
createBucketItem,
|
createBucketItem,
|
||||||
updateBucketItem,
|
updateBucketItem,
|
||||||
@@ -37,6 +38,10 @@ export class AtlasService {
|
|||||||
return getRegionGeo(countries);
|
return getRegionGeo(countries);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
countryGeo() {
|
||||||
|
return getCountryGeo();
|
||||||
|
}
|
||||||
|
|
||||||
countryPlaces(userId: number, code: string) {
|
countryPlaces(userId: number, code: string) {
|
||||||
return getCountryPlaces(userId, code);
|
return getCountryPlaces(userId, code);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -195,6 +195,12 @@ export class PackingController {
|
|||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('templates')
|
||||||
|
listTemplates(@CurrentUser() user: User, @Param('tripId') tripId: string) {
|
||||||
|
this.requireTrip(tripId, user);
|
||||||
|
return { templates: this.packing.listTemplates() };
|
||||||
|
}
|
||||||
|
|
||||||
@Post('apply-template/:templateId')
|
@Post('apply-template/:templateId')
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
applyTemplate(
|
applyTemplate(
|
||||||
@@ -238,6 +244,9 @@ export class PackingController {
|
|||||||
@Body('name') name?: string,
|
@Body('name') name?: string,
|
||||||
) {
|
) {
|
||||||
this.requireTrip(tripId, user);
|
this.requireTrip(tripId, user);
|
||||||
|
if (user.role !== 'admin') {
|
||||||
|
throw new HttpException({ error: 'Admin access required' }, 403);
|
||||||
|
}
|
||||||
if (!name?.trim()) {
|
if (!name?.trim()) {
|
||||||
throw new HttpException({ error: 'Template name is required' }, 400);
|
throw new HttpException({ error: 'Template name is required' }, 400);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,10 @@ export class PackingService {
|
|||||||
return svc.setBagMembers(tripId, bagId, userIds);
|
return svc.setBagMembers(tripId, bagId, userIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
listTemplates() {
|
||||||
|
return svc.listTemplates();
|
||||||
|
}
|
||||||
|
|
||||||
applyTemplate(tripId: string, templateId: string) {
|
applyTemplate(tripId: string, templateId: string) {
|
||||||
return svc.applyTemplate(tripId, templateId);
|
return svc.applyTemplate(tripId, templateId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Body, Controller, Delete, Get, HttpException, Param, Post, Res, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Delete, Get, HttpException, Param, Post, Res, UseGuards } from '@nestjs/common';
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
|
import { createReadStream } from 'node:fs';
|
||||||
import type { User } from '../../types';
|
import type { User } from '../../types';
|
||||||
import { ShareService } from './share.service';
|
import { ShareService } from './share.service';
|
||||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
@@ -72,6 +73,30 @@ export class TripShareController {
|
|||||||
export class SharedController {
|
export class SharedController {
|
||||||
constructor(private readonly share: ShareService) {}
|
constructor(private readonly share: ShareService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public, token-scoped place-photo proxy. The shared payload rewrites place
|
||||||
|
* image URLs to this route so thumbnails load without a session cookie (the
|
||||||
|
* /api/maps bytes endpoint is JwtAuthGuard'd). The service validates the token
|
||||||
|
* and that the place belongs to its trip; a miss streams nothing and answers
|
||||||
|
* 404. Declared before the bare ':token' read route. Streaming mirrors
|
||||||
|
* MapsController.placePhotoBytes (cached photos are always JPEG).
|
||||||
|
*/
|
||||||
|
@Get(':token/place-photo/:placeId/bytes')
|
||||||
|
placePhotoBytes(@Param('token') token: string, @Param('placeId') placeId: string, @Res() res: Response): void {
|
||||||
|
const fp = this.share.getSharedPlacePhotoPath(token, placeId);
|
||||||
|
if (!fp) {
|
||||||
|
res.status(404).json({ error: 'Photo not cached' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.set('Cache-Control', 'public, max-age=2592000, immutable');
|
||||||
|
res.type('image/jpeg');
|
||||||
|
const stream = createReadStream(fp);
|
||||||
|
stream.on('error', () => {
|
||||||
|
if (!res.headersSent) res.status(404).json({ error: 'Photo not cached' });
|
||||||
|
});
|
||||||
|
stream.pipe(res);
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':token')
|
@Get(':token')
|
||||||
read(@Param('token') token: string) {
|
read(@Param('token') token: string) {
|
||||||
const data = this.share.getSharedTripData(token);
|
const data = this.share.getSharedTripData(token);
|
||||||
|
|||||||
@@ -26,4 +26,5 @@ export class ShareService {
|
|||||||
get(tripId: string) { return svc.getShareLink(tripId); }
|
get(tripId: string) { return svc.getShareLink(tripId); }
|
||||||
remove(tripId: string) { return svc.deleteShareLink(tripId); }
|
remove(tripId: string) { return svc.deleteShareLink(tripId); }
|
||||||
getSharedTripData(token: string) { return svc.getSharedTripData(token); }
|
getSharedTripData(token: string) { return svc.getSharedTripData(token); }
|
||||||
|
getSharedPlacePhotoPath(token: string, placeId: string) { return svc.getSharedPlacePhotoPath(token, placeId); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,45 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import zlib from 'zlib';
|
||||||
import { db } from '../db/database';
|
import { db } from '../db/database';
|
||||||
import { Trip, Place } from '../types';
|
import { Trip, Place } from '../types';
|
||||||
|
|
||||||
// ── Admin-1 GeoJSON cache (sub-national regions) ─────────────────────────
|
// ── Bundled boundary GeoJSON (admin-0 countries + admin-1 regions) ─────────
|
||||||
|
//
|
||||||
|
// Sourced from geoBoundaries (CC BY 4.0), normalized + quantized offline by
|
||||||
|
// scripts/build-atlas-geo.mjs into gzipped FeatureCollections under server/assets.
|
||||||
|
// They are read + decompressed once and cached in memory — no network at runtime.
|
||||||
|
// (Replaces the previous runtime fetch of Natural Earth, which was stale for recent
|
||||||
|
// sub-national reforms and depicts some contested borders in unwanted ways.)
|
||||||
|
//
|
||||||
|
// __dirname is server/dist/services at runtime and server/src/services under vitest;
|
||||||
|
// both resolve ../../assets to server/assets.
|
||||||
|
|
||||||
let admin1GeoCache: any = null;
|
const geoBundleCache = new Map<string, any>();
|
||||||
let admin1GeoLoading: Promise<any> | null = null;
|
|
||||||
|
|
||||||
async function loadAdmin1Geo(): Promise<any> {
|
function loadGeoBundle(name: 'admin0' | 'admin1'): any {
|
||||||
if (admin1GeoCache) return admin1GeoCache;
|
const cached = geoBundleCache.get(name);
|
||||||
if (admin1GeoLoading) return admin1GeoLoading;
|
if (cached) return cached;
|
||||||
admin1GeoLoading = fetch(
|
const file = path.join(__dirname, '..', '..', 'assets', 'atlas', `${name}.geojson.gz`);
|
||||||
'https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_10m_admin_1_states_provinces.geojson',
|
if (!fs.existsSync(file)) {
|
||||||
{ headers: { 'User-Agent': 'TREK Travel Planner' } }
|
console.warn(`[Atlas] ${name}.geojson.gz missing — run \`node scripts/build-atlas-geo.mjs\``);
|
||||||
).then(r => r.json()).then((geo: any) => {
|
const empty = { type: 'FeatureCollection', features: [] };
|
||||||
admin1GeoCache = geo;
|
geoBundleCache.set(name, empty);
|
||||||
admin1GeoLoading = null;
|
return empty;
|
||||||
console.log(`[Atlas] Cached admin-1 GeoJSON: ${geo.features?.length || 0} features`);
|
}
|
||||||
return geo;
|
const geo = JSON.parse(zlib.gunzipSync(fs.readFileSync(file)).toString('utf8'));
|
||||||
}).catch(err => {
|
geoBundleCache.set(name, geo);
|
||||||
admin1GeoLoading = null;
|
console.log(`[Atlas] Loaded ${name} GeoJSON: ${geo.features?.length || 0} features`);
|
||||||
console.error('[Atlas] Failed to load admin-1 GeoJSON:', err);
|
return geo;
|
||||||
return null;
|
}
|
||||||
});
|
|
||||||
return admin1GeoLoading;
|
/** Full admin-0 country-border FeatureCollection (for the client map's country layer). */
|
||||||
|
export function getCountryGeo(): any {
|
||||||
|
return loadGeoBundle('admin0');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getRegionGeo(countryCodes: string[]): Promise<any> {
|
export async function getRegionGeo(countryCodes: string[]): Promise<any> {
|
||||||
const geo = await loadAdmin1Geo();
|
const geo = loadGeoBundle('admin1');
|
||||||
if (!geo) return { type: 'FeatureCollection', features: [] };
|
if (!geo) return { type: 'FeatureCollection', features: [] };
|
||||||
const codes = new Set(countryCodes.map(c => c.toUpperCase()));
|
const codes = new Set(countryCodes.map(c => c.toUpperCase()));
|
||||||
const features = geo.features.filter((f: any) => codes.has(f.properties?.iso_a2?.toUpperCase()));
|
const features = geo.features.filter((f: any) => codes.has(f.properties?.iso_a2?.toUpperCase()));
|
||||||
|
|||||||
@@ -191,6 +191,22 @@ export function deleteBag(tripId: string | number, bagId: string | number) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── List Templates ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only template list for trip members (name + item count), so non-admins
|
||||||
|
* can pick a template to apply. Management (create/edit/delete) stays admin-only
|
||||||
|
* under /api/admin/packing-templates.
|
||||||
|
*/
|
||||||
|
export function listTemplates() {
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT pt.id, pt.name,
|
||||||
|
(SELECT COUNT(*) FROM packing_template_items ti JOIN packing_template_categories tc ON ti.category_id = tc.id WHERE tc.template_id = pt.id) as item_count
|
||||||
|
FROM packing_templates pt
|
||||||
|
ORDER BY pt.created_at DESC
|
||||||
|
`).all() as { id: number; name: string; item_count: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
// ── Apply Template ─────────────────────────────────────────────────────────
|
// ── Apply Template ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function applyTemplate(tripId: string | number, templateId: string | number) {
|
export function applyTemplate(tripId: string | number, templateId: string | number) {
|
||||||
|
|||||||
@@ -1,6 +1,24 @@
|
|||||||
import { db, canAccessTrip } from '../db/database';
|
import { db, canAccessTrip } from '../db/database';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import { loadTagsByPlaceIds } from './queryHelpers';
|
import { loadTagsByPlaceIds } from './queryHelpers';
|
||||||
|
import { serveFilePath } from './placePhotoCache';
|
||||||
|
|
||||||
|
const PLACE_PHOTO_PROXY_PREFIX = '/api/maps/place-photo/';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Place photo proxy URLs (`/api/maps/place-photo/<id>/bytes`) are served by the
|
||||||
|
* JWT-guarded MapsController, so they 401 for an unauthenticated shared-trip
|
||||||
|
* viewer. Rewrite them to the public, token-scoped equivalent
|
||||||
|
* (`/api/shared/<token>/place-photo/<id>/bytes`) so thumbnails load in a shared
|
||||||
|
* link. A simple prefix swap keeps the already-encoded placeId segment intact, so
|
||||||
|
* the URL round-trips. Non-proxy URLs (data:, /uploads/, null) pass through.
|
||||||
|
*/
|
||||||
|
function rewritePlacePhotoUrl(url: string | null | undefined, token: string): string | null {
|
||||||
|
if (typeof url === 'string' && url.startsWith(PLACE_PHOTO_PROXY_PREFIX)) {
|
||||||
|
return `/api/shared/${token}/place-photo/${url.slice(PLACE_PHOTO_PROXY_PREFIX.length)}`;
|
||||||
|
}
|
||||||
|
return url ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
interface SharePermissions {
|
interface SharePermissions {
|
||||||
share_map?: boolean;
|
share_map?: boolean;
|
||||||
@@ -129,7 +147,7 @@ export function getSharedTripData(token: string): Record<string, any> | null {
|
|||||||
id: a.place_id, name: a.place_name, description: a.place_description,
|
id: a.place_id, name: a.place_name, description: a.place_description,
|
||||||
lat: a.lat, lng: a.lng, address: a.address, category_id: a.category_id,
|
lat: a.lat, lng: a.lng, address: a.address, category_id: a.category_id,
|
||||||
price: a.price, place_time: a.place_time, end_time: a.end_time,
|
price: a.price, place_time: a.place_time, end_time: a.end_time,
|
||||||
image_url: a.image_url, transport_mode: a.transport_mode,
|
image_url: rewritePlacePhotoUrl(a.image_url, token), transport_mode: a.transport_mode,
|
||||||
category: a.category_id ? { id: a.category_id, name: a.category_name, color: a.category_color, icon: a.category_icon } : null,
|
category: a.category_id ? { id: a.category_id, name: a.category_name, color: a.category_color, icon: a.category_icon } : null,
|
||||||
tags: tagsByPlace[a.place_id] || [],
|
tags: tagsByPlace[a.place_id] || [],
|
||||||
}
|
}
|
||||||
@@ -147,11 +165,11 @@ export function getSharedTripData(token: string): Record<string, any> | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Places
|
// Places
|
||||||
const places = db.prepare(`
|
const places = (db.prepare(`
|
||||||
SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon
|
SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon
|
||||||
FROM places p LEFT JOIN categories c ON p.category_id = c.id
|
FROM places p LEFT JOIN categories c ON p.category_id = c.id
|
||||||
WHERE p.trip_id = ? ORDER BY p.created_at DESC
|
WHERE p.trip_id = ? ORDER BY p.created_at DESC
|
||||||
`).all(tripId);
|
`).all(tripId) as any[]).map((p) => ({ ...p, image_url: rewritePlacePhotoUrl(p.image_url, token) }));
|
||||||
|
|
||||||
// Reservations — include per-day positions so the client can render the same order as the planner
|
// Reservations — include per-day positions so the client can render the same order as the planner
|
||||||
const reservations = db.prepare('SELECT * FROM reservations WHERE trip_id = ? ORDER BY reservation_time ASC').all(tripId) as any[];
|
const reservations = db.prepare('SELECT * FROM reservations WHERE trip_id = ? ORDER BY reservation_time ASC').all(tripId) as any[];
|
||||||
@@ -210,3 +228,26 @@ export function getSharedTripData(token: string): Record<string, any> | null {
|
|||||||
collab: collabMessages,
|
collab: collabMessages,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the on-disk path for a cached place photo requested through a public
|
||||||
|
* share link. Validates that the token is valid + unexpired and that the place
|
||||||
|
* actually belongs to that token's trip (matched via the stored proxy URL, which
|
||||||
|
* covers both Google `placeId` and Wikimedia `coords:` pseudo-IDs without
|
||||||
|
* depending on google_place_id). Returns null — never throws — so the caller
|
||||||
|
* answers a plain 404, mirroring the authenticated bytes endpoint.
|
||||||
|
*/
|
||||||
|
export function getSharedPlacePhotoPath(token: string, placeId: string): string | null {
|
||||||
|
const shareRow = db.prepare(
|
||||||
|
"SELECT trip_id FROM share_tokens WHERE token = ? AND (expires_at IS NULL OR expires_at > datetime('now'))"
|
||||||
|
).get(token) as { trip_id: string } | undefined;
|
||||||
|
if (!shareRow) return null;
|
||||||
|
|
||||||
|
const expectedUrl = `${PLACE_PHOTO_PROXY_PREFIX}${encodeURIComponent(placeId)}/bytes`;
|
||||||
|
const place = db.prepare(
|
||||||
|
'SELECT 1 FROM places WHERE trip_id = ? AND image_url = ?'
|
||||||
|
).get(shareRow.trip_id, expectedUrl);
|
||||||
|
if (!place) return null;
|
||||||
|
|
||||||
|
return serveFilePath(placeId);
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,11 +30,12 @@ vi.mock('../../src/db/database', () => ({
|
|||||||
db, canAccessTrip: vi.fn(), isOwner: vi.fn(), getPlaceWithTags: vi.fn(), closeDb: () => {}, reinitialize: () => {},
|
db, canAccessTrip: vi.fn(), isOwner: vi.fn(), getPlaceWithTags: vi.fn(), closeDb: () => {}, reinitialize: () => {},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { getCollabFeatures, getPhotoProviderConfig } = vi.hoisted(() => ({
|
const { getCollabFeatures, getBagTracking, getPhotoProviderConfig } = vi.hoisted(() => ({
|
||||||
getCollabFeatures: vi.fn(() => ({ chat: true, notes: true, polls: true, whatsnext: true })),
|
getCollabFeatures: vi.fn(() => ({ chat: true, notes: true, polls: true, whatsnext: true })),
|
||||||
|
getBagTracking: vi.fn(() => ({ enabled: true })),
|
||||||
getPhotoProviderConfig: vi.fn(() => ({ url: 'https://immich.example' })),
|
getPhotoProviderConfig: vi.fn(() => ({ url: 'https://immich.example' })),
|
||||||
}));
|
}));
|
||||||
vi.mock('../../src/services/adminService', () => ({ getCollabFeatures }));
|
vi.mock('../../src/services/adminService', () => ({ getCollabFeatures, getBagTracking }));
|
||||||
vi.mock('../../src/services/memories/helpersService', () => ({ getPhotoProviderConfig }));
|
vi.mock('../../src/services/memories/helpersService', () => ({ getPhotoProviderConfig }));
|
||||||
|
|
||||||
import { AddonsModule } from '../../src/nest/addons/addons.module';
|
import { AddonsModule } from '../../src/nest/addons/addons.module';
|
||||||
@@ -72,11 +73,15 @@ describe('GET /api/addons e2e (real auth guard + temp SQLite)', () => {
|
|||||||
expect((await request(server).get('/api/addons')).status).toBe(401);
|
expect((await request(server).get('/api/addons')).status).toBe(401);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Session 1 is a default-role ('user') account — i.e. a non-admin. Asserting the
|
||||||
|
// global bagTracking flag here is present is the #1124 regression guard: reading the
|
||||||
|
// toggle must not require admin.
|
||||||
it('200 returns enabled addons + photo providers (disabled addon excluded)', async () => {
|
it('200 returns enabled addons + photo providers (disabled addon excluded)', async () => {
|
||||||
const res = await request(server).get('/api/addons').set('Cookie', sessionCookie(1));
|
const res = await request(server).get('/api/addons').set('Cookie', sessionCookie(1));
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body).toEqual({
|
expect(res.body).toEqual({
|
||||||
collabFeatures: { chat: true, notes: true, polls: true, whatsnext: true },
|
collabFeatures: { chat: true, notes: true, polls: true, whatsnext: true },
|
||||||
|
bagTracking: true,
|
||||||
addons: [
|
addons: [
|
||||||
{ id: 'packing', name: 'Packing', type: 'trip', icon: 'Backpack', enabled: true },
|
{ id: 'packing', name: 'Packing', type: 'trip', icon: 'Backpack', enabled: true },
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const { mocks } = vi.hoisted(() => ({
|
|||||||
unmarkRegionVisited: vi.fn(),
|
unmarkRegionVisited: vi.fn(),
|
||||||
getVisitedRegions: vi.fn(),
|
getVisitedRegions: vi.fn(),
|
||||||
getRegionGeo: vi.fn(),
|
getRegionGeo: vi.fn(),
|
||||||
|
getCountryGeo: vi.fn(),
|
||||||
listBucketList: vi.fn(),
|
listBucketList: vi.fn(),
|
||||||
createBucketItem: vi.fn(),
|
createBucketItem: vi.fn(),
|
||||||
updateBucketItem: vi.fn(),
|
updateBucketItem: vi.fn(),
|
||||||
@@ -75,6 +76,14 @@ describe('Atlas e2e (real auth guard + temp SQLite)', () => {
|
|||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('200 countries/geo returns the admin-0 FeatureCollection', async () => {
|
||||||
|
mocks.getCountryGeo.mockReturnValue({ type: 'FeatureCollection', features: [{ id: 'NO' }] });
|
||||||
|
const res = await request(server).get('/api/addons/atlas/countries/geo').set('Cookie', sessionCookie(1));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.type).toBe('FeatureCollection');
|
||||||
|
expect(res.headers['cache-control']).toContain('max-age=86400');
|
||||||
|
});
|
||||||
|
|
||||||
it('200 stats for an authenticated user', async () => {
|
it('200 stats for an authenticated user', async () => {
|
||||||
const res = await request(server).get('/api/addons/atlas/stats').set('Cookie', sessionCookie(1));
|
const res = await request(server).get('/api/addons/atlas/stats').set('Cookie', sessionCookie(1));
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
|
|||||||
@@ -8,6 +8,9 @@
|
|||||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
import cookieParser from 'cookie-parser';
|
import cookieParser from 'cookie-parser';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import fs from 'node:fs';
|
||||||
import type { Server } from 'http';
|
import type { Server } from 'http';
|
||||||
import { Test } from '@nestjs/testing';
|
import { Test } from '@nestjs/testing';
|
||||||
import { seedUser, sessionCookie } from './harness';
|
import { seedUser, sessionCookie } from './harness';
|
||||||
@@ -28,7 +31,7 @@ const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn() }));
|
|||||||
vi.mock('../../src/services/permissions', () => ({ checkPermission }));
|
vi.mock('../../src/services/permissions', () => ({ checkPermission }));
|
||||||
|
|
||||||
const { shareSvc } = vi.hoisted(() => ({
|
const { shareSvc } = vi.hoisted(() => ({
|
||||||
shareSvc: { createOrUpdateShareLink: vi.fn(), getShareLink: vi.fn(), deleteShareLink: vi.fn(), getSharedTripData: vi.fn() },
|
shareSvc: { createOrUpdateShareLink: vi.fn(), getShareLink: vi.fn(), deleteShareLink: vi.fn(), getSharedTripData: vi.fn(), getSharedPlacePhotoPath: vi.fn() },
|
||||||
}));
|
}));
|
||||||
vi.mock('../../src/services/shareService', () => shareSvc);
|
vi.mock('../../src/services/shareService', () => shareSvc);
|
||||||
|
|
||||||
@@ -106,4 +109,29 @@ describe('Share-link e2e (real auth guard + temp SQLite)', () => {
|
|||||||
expect(res.status).toBe(404);
|
expect(res.status).toBe(404);
|
||||||
expect(res.body).toEqual({ error: 'Invalid or expired link' });
|
expect(res.body).toEqual({ error: 'Invalid or expired link' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('public place-photo proxy (/api/shared/:token/place-photo/:placeId/bytes)', () => {
|
||||||
|
const photoFile = path.join(os.tmpdir(), 'trek-share-photo.e2e.jpg');
|
||||||
|
const photoBytes = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]); // JPEG-ish header
|
||||||
|
|
||||||
|
beforeAll(() => fs.writeFileSync(photoFile, photoBytes));
|
||||||
|
afterAll(() => { try { fs.unlinkSync(photoFile); } catch { /* ignore */ } });
|
||||||
|
|
||||||
|
it('streams cached bytes with no cookie (unguarded) for a valid token + place', async () => {
|
||||||
|
shareSvc.getSharedPlacePhotoPath.mockReturnValueOnce(photoFile);
|
||||||
|
const res = await request(server).get('/api/shared/tok/place-photo/ChIJabc/bytes');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers['content-type']).toContain('image/jpeg');
|
||||||
|
expect(res.headers['cache-control']).toContain('immutable');
|
||||||
|
expect(Buffer.from(res.body)).toEqual(photoBytes);
|
||||||
|
expect(shareSvc.getSharedPlacePhotoPath).toHaveBeenCalledWith('tok', 'ChIJabc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('404 when the token/place does not resolve to a cached photo', async () => {
|
||||||
|
shareSvc.getSharedPlacePhotoPath.mockReturnValueOnce(null);
|
||||||
|
const res = await request(server).get('/api/shared/bad/place-photo/ChIJabc/bytes');
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect(res.body).toEqual({ error: 'Photo not cached' });
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -448,8 +448,8 @@ describe('Packing — apply-template, bag members, save-as-template', () => {
|
|||||||
expect(res.body.error).toBeDefined();
|
expect(res.body.error).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('PACK-017 — POST /save-as-template saves packing list as a template', async () => {
|
it('PACK-017 — POST /save-as-template saves packing list as a template (admin)', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb, { role: 'admin' });
|
||||||
const trip = createTrip(testDb, user.id);
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
// Add an item so the trip has something to save
|
// Add an item so the trip has something to save
|
||||||
@@ -465,8 +465,8 @@ describe('Packing — apply-template, bag members, save-as-template', () => {
|
|||||||
expect(res.body.template.name).toBe('My Summer Template');
|
expect(res.body.template.name).toBe('My Summer Template');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('PACK-017b — POST /save-as-template without name returns 400', async () => {
|
it('PACK-017b — POST /save-as-template without name returns 400 (admin)', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb, { role: 'admin' });
|
||||||
const trip = createTrip(testDb, user.id);
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
@@ -478,8 +478,8 @@ describe('Packing — apply-template, bag members, save-as-template', () => {
|
|||||||
expect(res.body.error).toBeDefined();
|
expect(res.body.error).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('PACK-017c — POST /save-as-template when trip has no items returns 400', async () => {
|
it('PACK-017c — POST /save-as-template when trip has no items returns 400 (admin)', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb, { role: 'admin' });
|
||||||
const trip = createTrip(testDb, user.id);
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
@@ -490,4 +490,37 @@ describe('Packing — apply-template, bag members, save-as-template', () => {
|
|||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
expect(res.body.error).toBeDefined();
|
expect(res.body.error).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('PACK-017d — POST /save-as-template is forbidden for non-admins (403)', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
createPackingItem(testDb, trip.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/packing/save-as-template`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ name: 'My Summer Template' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(res.body.error).toBe('Admin access required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PACK-017e — GET /packing/templates lists templates for a trip member', async () => {
|
||||||
|
const { user: admin } = createUser(testDb, { role: 'admin' });
|
||||||
|
const trip = createTrip(testDb, admin.id);
|
||||||
|
createPackingItem(testDb, trip.id);
|
||||||
|
await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/packing/save-as-template`)
|
||||||
|
.set('Cookie', authCookie(admin.id))
|
||||||
|
.send({ name: 'Shared Template' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/api/trips/${trip.id}/packing/templates`)
|
||||||
|
.set('Cookie', authCookie(admin.id));
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(Array.isArray(res.body.templates)).toBe(true);
|
||||||
|
expect(res.body.templates.some((t: { name: string }) => t.name === 'Shared Template')).toBe(true);
|
||||||
|
expect(res.body.templates[0]).toHaveProperty('item_count');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ import { runMigrations } from '../../src/db/migrations';
|
|||||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||||
import { createUser, createTrip, addTripMember, createDay, createPlace, createDayAssignment, createDayNote } from '../helpers/factories';
|
import { createUser, createTrip, addTripMember, createDay, createPlace, createDayAssignment, createDayNote } from '../helpers/factories';
|
||||||
import { authCookie } from '../helpers/auth';
|
import { authCookie } from '../helpers/auth';
|
||||||
|
import * as placePhotoCache from '../../src/services/placePhotoCache';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
|
||||||
let nestApp: INestApplication;
|
let nestApp: INestApplication;
|
||||||
let app: Application;
|
let app: Application;
|
||||||
@@ -351,3 +353,78 @@ describe('Shared trip — ordering parity (issue #981)', () => {
|
|||||||
expect(reservation.day_positions[day.id]).toBe(1.5);
|
expect(reservation.day_positions[day.id]).toBe(1.5);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Shared trip — place photos in shared links (issue #1100)', () => {
|
||||||
|
const PLACE_ID = 'ChIJsharedPhoto1100';
|
||||||
|
const PROXY_URL = `/api/maps/place-photo/${encodeURIComponent(PLACE_ID)}/bytes`;
|
||||||
|
const photoBytes = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]);
|
||||||
|
let cachedFilePath: string;
|
||||||
|
|
||||||
|
afterAll(() => { try { if (cachedFilePath) fs.unlinkSync(cachedFilePath); } catch { /* ignore */ } });
|
||||||
|
|
||||||
|
async function setupSharedPlaceWithPhoto() {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
const place = createPlace(testDb, trip.id, { name: 'Photo Place' });
|
||||||
|
testDb.prepare('UPDATE places SET image_url = ?, google_place_id = ? WHERE id = ?').run(PROXY_URL, PLACE_ID, place.id);
|
||||||
|
|
||||||
|
const { body: { token } } = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/share-link`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({});
|
||||||
|
return { token, place };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('SHARE-016 — shared payload rewrites place image_url to the public token-scoped proxy', async () => {
|
||||||
|
const { token } = await setupSharedPlaceWithPhoto();
|
||||||
|
const res = await request(app).get(`/api/shared/${token}`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const place = res.body.places.find((p: any) => p.image_url);
|
||||||
|
expect(place.image_url).toBe(`/api/shared/${token}/place-photo/${encodeURIComponent(PLACE_ID)}/bytes`);
|
||||||
|
expect(place.image_url.startsWith('/api/maps/')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SHARE-017 — shared payload rewrites assignment place image_url too', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
const day = createDay(testDb, trip.id, { date: '2025-10-01' });
|
||||||
|
const place = createPlace(testDb, trip.id, { name: 'Assigned Photo Place' });
|
||||||
|
testDb.prepare('UPDATE places SET image_url = ? WHERE id = ?').run(PROXY_URL, place.id);
|
||||||
|
createDayAssignment(testDb, day.id, place.id, {});
|
||||||
|
|
||||||
|
const { body: { token } } = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/share-link`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({});
|
||||||
|
|
||||||
|
const res = await request(app).get(`/api/shared/${token}`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.assignments[day.id][0].place.image_url)
|
||||||
|
.toBe(`/api/shared/${token}/place-photo/${encodeURIComponent(PLACE_ID)}/bytes`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SHARE-018 — public proxy streams cached bytes for a valid token + place (no cookie)', async () => {
|
||||||
|
const { token } = await setupSharedPlaceWithPhoto();
|
||||||
|
const cached = await placePhotoCache.put(PLACE_ID, photoBytes, null);
|
||||||
|
cachedFilePath = cached.filePath;
|
||||||
|
|
||||||
|
const res = await request(app).get(`/api/shared/${token}/place-photo/${encodeURIComponent(PLACE_ID)}/bytes`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers['content-type']).toContain('image/jpeg');
|
||||||
|
expect(Buffer.from(res.body)).toEqual(photoBytes);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SHARE-019 — public proxy 404s for a placeId not in the shared trip', async () => {
|
||||||
|
const { token } = await setupSharedPlaceWithPhoto();
|
||||||
|
const res = await request(app).get(`/api/shared/${token}/place-photo/ChIJnotInTrip/bytes`);
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect(res.body).toEqual({ error: 'Photo not cached' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SHARE-020 — public proxy 404s for an invalid token', async () => {
|
||||||
|
await setupSharedPlaceWithPhoto();
|
||||||
|
const res = await request(app).get(`/api/shared/bad-token/place-photo/${encodeURIComponent(PLACE_ID)}/bytes`);
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect(res.body).toEqual({ error: 'Photo not cached' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
/**
|
||||||
|
* Unit test for the Atlas region-code reconciliation migration (#1119).
|
||||||
|
*
|
||||||
|
* After Atlas swapped Natural Earth for geoBoundaries, manually-marked regions
|
||||||
|
* (`visited_regions`) held the old Natural Earth ISO-3166-2 codes. The final migration
|
||||||
|
* reconciles each row against the shipped admin-1 bundle: valid codes are kept, codes
|
||||||
|
* whose region NAME still matches are re-coded, renamed-merge cases use a curated
|
||||||
|
* crosswalk, and anything else is left untouched. We exercise the real migration by
|
||||||
|
* running all migrations, seeding rows, rewinding schema_version by one, and re-running
|
||||||
|
* so only the last (reconciliation) migration fires.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import { createTables } from '../../../src/db/schema';
|
||||||
|
import { runMigrations } from '../../../src/db/migrations';
|
||||||
|
import { createUser } from '../../helpers/factories';
|
||||||
|
|
||||||
|
function freshDb() {
|
||||||
|
const db = new Database(':memory:');
|
||||||
|
db.exec('PRAGMA journal_mode = WAL');
|
||||||
|
db.exec('PRAGMA foreign_keys = ON');
|
||||||
|
createTables(db);
|
||||||
|
runMigrations(db);
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mark(db: Database.Database, userId: number, code: string, name: string, country = 'NO') {
|
||||||
|
db.prepare(
|
||||||
|
'INSERT INTO visited_regions (user_id, region_code, region_name, country_code) VALUES (?, ?, ?, ?)'
|
||||||
|
).run(userId, code, name, country);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rewind one migration and re-run so only the reconciliation (the last migration) executes.
|
||||||
|
function rerunLastMigration(db: Database.Database) {
|
||||||
|
const version = (db.prepare('SELECT version FROM schema_version').get() as { version: number }).version;
|
||||||
|
db.prepare('UPDATE schema_version SET version = ?').run(version - 1);
|
||||||
|
runMigrations(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Atlas region-code reconciliation migration', () => {
|
||||||
|
it('CROSSWALK-001: remaps a renamed-merge county via the curated crosswalk', () => {
|
||||||
|
const db = freshDb();
|
||||||
|
const { user } = createUser(db);
|
||||||
|
mark(db, user.id, 'NO-05', 'Oppland'); // merged into Innlandet, name changed
|
||||||
|
|
||||||
|
rerunLastMigration(db);
|
||||||
|
|
||||||
|
const rows = db.prepare('SELECT region_code, region_name FROM visited_regions WHERE user_id = ?').all(user.id);
|
||||||
|
expect(rows).toEqual([{ region_code: 'NO-34', region_name: 'Innlandet' }]);
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CROSSWALK-002: merges two old counties that map to the same new region (no UNIQUE clash)', () => {
|
||||||
|
const db = freshDb();
|
||||||
|
const { user } = createUser(db);
|
||||||
|
mark(db, user.id, 'NO-04', 'Hedmark'); // → Innlandet
|
||||||
|
mark(db, user.id, 'NO-05', 'Oppland'); // → Innlandet
|
||||||
|
|
||||||
|
rerunLastMigration(db);
|
||||||
|
|
||||||
|
const rows = db.prepare('SELECT region_code FROM visited_regions WHERE user_id = ?').all(user.id);
|
||||||
|
expect(rows).toEqual([{ region_code: 'NO-34' }]);
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CROSSWALK-003: leaves a still-valid code untouched', () => {
|
||||||
|
const db = freshDb();
|
||||||
|
const { user } = createUser(db);
|
||||||
|
mark(db, user.id, 'NO-03', 'Oslo'); // present in the new bundle
|
||||||
|
|
||||||
|
rerunLastMigration(db);
|
||||||
|
|
||||||
|
const rows = db.prepare('SELECT region_code, region_name FROM visited_regions WHERE user_id = ?').all(user.id);
|
||||||
|
expect(rows).toEqual([{ region_code: 'NO-03', region_name: 'Oslo' }]);
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CROSSWALK-004: re-codes a stale code whose region NAME still matches the bundle', () => {
|
||||||
|
// Not in any crosswalk: a bogus code but a name ("Oslo") that the bundle still carries
|
||||||
|
// for NO → reconciled to the bundle's code for that name (NO-03) by the name-match path.
|
||||||
|
const db = freshDb();
|
||||||
|
const { user } = createUser(db);
|
||||||
|
mark(db, user.id, 'NO-99', 'Oslo');
|
||||||
|
|
||||||
|
rerunLastMigration(db);
|
||||||
|
|
||||||
|
const rows = db.prepare('SELECT region_code, region_name FROM visited_regions WHERE user_id = ?').all(user.id);
|
||||||
|
expect(rows).toEqual([{ region_code: 'NO-03', region_name: 'Oslo' }]);
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CROSSWALK-005: leaves an unresolvable row as-is (no code, no name, no crosswalk match)', () => {
|
||||||
|
const db = freshDb();
|
||||||
|
const { user } = createUser(db);
|
||||||
|
mark(db, user.id, 'ZZ-99', 'Nowhere', 'ZZ');
|
||||||
|
|
||||||
|
rerunLastMigration(db);
|
||||||
|
|
||||||
|
const rows = db.prepare('SELECT region_code, region_name FROM visited_regions WHERE user_id = ?').all(user.id);
|
||||||
|
expect(rows).toEqual([{ region_code: 'ZZ-99', region_name: 'Nowhere' }]);
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CROSSWALK-006: does not touch bucket_list or visited_countries (no region identifier there)', () => {
|
||||||
|
const db = freshDb();
|
||||||
|
const { user } = createUser(db);
|
||||||
|
db.prepare('INSERT INTO bucket_list (user_id, name, country_code) VALUES (?, ?, ?)').run(user.id, 'Oppland', 'NO');
|
||||||
|
db.prepare('INSERT INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(user.id, 'NO');
|
||||||
|
mark(db, user.id, 'NO-05', 'Oppland'); // ensure the migration actually runs its body
|
||||||
|
|
||||||
|
rerunLastMigration(db);
|
||||||
|
|
||||||
|
const bucket = db.prepare('SELECT name, country_code FROM bucket_list WHERE user_id = ?').all(user.id);
|
||||||
|
expect(bucket).toEqual([{ name: 'Oppland', country_code: 'NO' }]); // free-text name untouched
|
||||||
|
const countries = db.prepare('SELECT country_code FROM visited_countries WHERE user_id = ?').all(user.id);
|
||||||
|
expect(countries).toEqual([{ country_code: 'NO' }]);
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -135,6 +135,8 @@ const ALLOWED_DESTRUCTIVE: Record<string, string> = {
|
|||||||
"Migration 121: DELETE ... WHERE title IN ('Gallery','[Trip Photos]') — remove synthetic wrapper entries replaced by the gallery model.",
|
"Migration 121: DELETE ... WHERE title IN ('Gallery','[Trip Photos]') — remove synthetic wrapper entries replaced by the gallery model.",
|
||||||
'DELETE FROM place_regions':
|
'DELETE FROM place_regions':
|
||||||
'Atlas enclave fix: DELETE ... WHERE place_id IN (places inside specific enclave boxes) — invalidate stale region cache; re-resolved on next request.',
|
'Atlas enclave fix: DELETE ... WHERE place_id IN (places inside specific enclave boxes) — invalidate stale region cache; re-resolved on next request.',
|
||||||
|
'DELETE FROM visited_regions':
|
||||||
|
'Atlas geoBoundaries swap (#1119): DELETE ... WHERE id = ? — after UPDATE OR IGNORE re-codes a manually-marked region to its current code, drop only the single leftover row whose UNIQUE(user_id, region_code) collision caused the update to be skipped (a duplicate of a region the user already has).',
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('migration hygiene — destructive operation guard', () => {
|
describe('migration hygiene — destructive operation guard', () => {
|
||||||
|
|||||||
@@ -53,6 +53,13 @@ describe('AtlasController (parity with the legacy /api/addons/atlas route)', ()
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('GET /countries/geo delegates to the service', () => {
|
||||||
|
const fc = { type: 'FeatureCollection', features: [{ id: 'NO' }] };
|
||||||
|
const countryGeo = vi.fn().mockReturnValue(fc);
|
||||||
|
expect(makeController({ countryGeo }).countryGeo()).toBe(fc);
|
||||||
|
expect(countryGeo).toHaveBeenCalledWith();
|
||||||
|
});
|
||||||
|
|
||||||
describe('country', () => {
|
describe('country', () => {
|
||||||
it('GET /country/:code upper-cases the code', () => {
|
it('GET /country/:code upper-cases the code', () => {
|
||||||
const countryPlaces = vi.fn().mockReturnValue([]);
|
const countryPlaces = vi.fn().mockReturnValue([]);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { PackingService } from '../../../src/nest/packing/packing.service';
|
|||||||
import type { User } from '../../../src/types';
|
import type { User } from '../../../src/types';
|
||||||
|
|
||||||
const user = { id: 1, role: 'user', email: 'u@example.test' } as User;
|
const user = { id: 1, role: 'user', email: 'u@example.test' } as User;
|
||||||
|
const admin = { id: 1, role: 'admin', email: 'a@example.test' } as User;
|
||||||
const trip = { id: 5, user_id: 1 };
|
const trip = { id: 5, user_id: 1 };
|
||||||
|
|
||||||
/** Service mock with trip access granted + edit allowed by default. */
|
/** Service mock with trip access granted + edit allowed by default. */
|
||||||
@@ -119,6 +120,14 @@ describe('PackingController (parity with the legacy /api/trips/:tripId/packing r
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('templates', () => {
|
describe('templates', () => {
|
||||||
|
it('GET /templates returns the template list for an accessible trip', () => {
|
||||||
|
const listTemplates = vi.fn().mockReturnValue([{ id: 1, name: 'Beach', item_count: 4 }]);
|
||||||
|
const svc = makeService({ listTemplates } as Partial<PackingService>);
|
||||||
|
expect(new PackingController(svc).listTemplates(user, '5')).toEqual({
|
||||||
|
templates: [{ id: 1, name: 'Beach', item_count: 4 }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('404 when applying a missing/empty template (POST stays 200 otherwise)', () => {
|
it('404 when applying a missing/empty template (POST stays 200 otherwise)', () => {
|
||||||
const svc = makeService({ applyTemplate: vi.fn().mockReturnValue(null) } as Partial<PackingService>);
|
const svc = makeService({ applyTemplate: vi.fn().mockReturnValue(null) } as Partial<PackingService>);
|
||||||
expect(thrown(() => new PackingController(svc).applyTemplate(user, '5', 't1'))).toEqual({
|
expect(thrown(() => new PackingController(svc).applyTemplate(user, '5', 't1'))).toEqual({
|
||||||
@@ -126,12 +135,30 @@ describe('PackingController (parity with the legacy /api/trips/:tripId/packing r
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('400 saving a template with no items', () => {
|
it('403 when a non-admin tries to save a template', () => {
|
||||||
const svc = makeService({ saveAsTemplate: vi.fn().mockReturnValue(null) } as Partial<PackingService>);
|
const saveAsTemplate = vi.fn();
|
||||||
|
const svc = makeService({ saveAsTemplate } as Partial<PackingService>);
|
||||||
expect(thrown(() => new PackingController(svc).saveAsTemplate(user, '5', 'My template'))).toEqual({
|
expect(thrown(() => new PackingController(svc).saveAsTemplate(user, '5', 'My template'))).toEqual({
|
||||||
|
status: 403, body: { error: 'Admin access required' },
|
||||||
|
});
|
||||||
|
expect(saveAsTemplate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('400 when an admin saves a template with no items', () => {
|
||||||
|
const svc = makeService({ saveAsTemplate: vi.fn().mockReturnValue(null) } as Partial<PackingService>);
|
||||||
|
expect(thrown(() => new PackingController(svc).saveAsTemplate(admin, '5', 'My template'))).toEqual({
|
||||||
status: 400, body: { error: 'No items to save' },
|
status: 400, body: { error: 'No items to save' },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('saves a template for an admin', () => {
|
||||||
|
const saveAsTemplate = vi.fn().mockReturnValue({ id: 7, name: 'My template' });
|
||||||
|
const svc = makeService({ saveAsTemplate } as Partial<PackingService>);
|
||||||
|
expect(new PackingController(svc).saveAsTemplate(admin, '5', 'My template')).toEqual({
|
||||||
|
template: { id: 7, name: 'My template' },
|
||||||
|
});
|
||||||
|
expect(saveAsTemplate).toHaveBeenCalledWith('5', admin.id, 'My template');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('category assignees', () => {
|
describe('category assignees', () => {
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import { createTables } from '../../../src/db/schema';
|
|||||||
import { runMigrations } from '../../../src/db/migrations';
|
import { runMigrations } from '../../../src/db/migrations';
|
||||||
import { resetTestDb } from '../../helpers/test-db';
|
import { resetTestDb } from '../../helpers/test-db';
|
||||||
import { createUser, createTrip } from '../../helpers/factories';
|
import { createUser, createTrip } from '../../helpers/factories';
|
||||||
import { getStats, getCached, setCache, getCountryFromCoords, getCountryFromAddress, reverseGeocodeCountry, getRegionGeo, getCountryPlaces, getVisitedRegions } from '../../../src/services/atlasService';
|
import { getStats, getCached, setCache, getCountryFromCoords, getCountryFromAddress, reverseGeocodeCountry, getRegionGeo, getCountryGeo, getCountryPlaces, getVisitedRegions } from '../../../src/services/atlasService';
|
||||||
|
|
||||||
function insertPlace(db: any, tripId: number, name: string, address: string | null = null) {
|
function insertPlace(db: any, tripId: number, name: string, address: string | null = null) {
|
||||||
const cat = db.prepare('SELECT id FROM categories LIMIT 1').get() as { id: number } | undefined;
|
const cat = db.prepare('SELECT id FROM categories LIMIT 1').get() as { id: number } | undefined;
|
||||||
@@ -243,38 +243,57 @@ describe('reverseGeocodeCountry', () => {
|
|||||||
|
|
||||||
// ── getRegionGeo ────────────────────────────────────────────────────────────
|
// ── getRegionGeo ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// These read the committed geoBoundaries bundle (server/assets/atlas/admin1.geojson.gz),
|
||||||
|
// so they double as a guard that the bundle ships current sub-national data (#1119).
|
||||||
describe('getRegionGeo', () => {
|
describe('getRegionGeo', () => {
|
||||||
it('ATLAS-SVC-017: returns empty FeatureCollection when fetch throws a network error', async () => {
|
it('ATLAS-SVC-017: returns an empty FeatureCollection for a country with no admin-1 features', async () => {
|
||||||
// Override the default stub to throw so loadAdmin1Geo's .catch handler runs,
|
const result = await getRegionGeo(['ZZ']);
|
||||||
// returning null — which causes getRegionGeo to return the empty FeatureCollection.
|
|
||||||
// (The default ok:false stub does NOT trigger the catch; it still resolves json()
|
|
||||||
// to {}, which loadAdmin1Geo caches as a non-null truthy value.)
|
|
||||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network failure')));
|
|
||||||
const result = await getRegionGeo(['DE', 'FR']);
|
|
||||||
expect(result).toEqual({ type: 'FeatureCollection', features: [] });
|
expect(result).toEqual({ type: 'FeatureCollection', features: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ATLAS-SVC-018: returns filtered features for matching country codes when fetch returns mock GeoJSON', async () => {
|
it('ATLAS-SVC-018: returns the current geoBoundaries regions for a country, case-insensitively', async () => {
|
||||||
// ATLAS-SVC-017 ran with a throwing fetch, so admin1GeoCache is null and
|
// Pass lowercase 'no' — getRegionGeo uppercases internally for matching.
|
||||||
// admin1GeoLoading is null — this test's fetch override will be called.
|
const result = await getRegionGeo(['no']);
|
||||||
const mockGeoJson = {
|
|
||||||
type: 'FeatureCollection',
|
|
||||||
features: [
|
|
||||||
{ type: 'Feature', properties: { iso_a2: 'DE' }, geometry: {} },
|
|
||||||
{ type: 'Feature', properties: { iso_a2: 'FR' }, geometry: {} },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
json: async () => mockGeoJson,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Pass lowercase 'de' — getRegionGeo uppercases internally for matching
|
|
||||||
const result = await getRegionGeo(['de']);
|
|
||||||
|
|
||||||
expect(result.type).toBe('FeatureCollection');
|
expect(result.type).toBe('FeatureCollection');
|
||||||
expect(result.features).toHaveLength(1);
|
expect(result.features.length).toBeGreaterThan(0);
|
||||||
expect(result.features[0].properties.iso_a2).toBe('DE');
|
expect(result.features.every((f: any) => f.properties.iso_a2 === 'NO')).toBe(true);
|
||||||
|
|
||||||
|
const names = result.features.map((f: any) => f.properties.name);
|
||||||
|
const codes = result.features.map((f: any) => f.properties.iso_3166_2);
|
||||||
|
// Post-2020 reform is present…
|
||||||
|
expect(codes).toContain('NO-34'); // Innlandet
|
||||||
|
expect(codes).toContain('NO-46'); // Vestland
|
||||||
|
// …and the merged-away pre-2020 counties are gone (the original #1119 bug).
|
||||||
|
expect(names).not.toContain('Oppland');
|
||||||
|
expect(names).not.toContain('Hordaland');
|
||||||
|
expect(names).not.toContain('Sogn og Fjordane');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getCountryGeo', () => {
|
||||||
|
it('ATLAS-SVC-019: returns the admin-0 FeatureCollection with ISO_A2/ADM0_A3 properties', () => {
|
||||||
|
const geo = getCountryGeo();
|
||||||
|
expect(geo.type).toBe('FeatureCollection');
|
||||||
|
expect(geo.features.length).toBeGreaterThan(0);
|
||||||
|
const no = geo.features.find((f: any) => f.properties.ISO_A2 === 'NO');
|
||||||
|
expect(no).toBeDefined();
|
||||||
|
expect(no.properties.ADM0_A3).toBe('NOR');
|
||||||
|
expect(no.properties.NAME).toBe('Norway');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ATLAS-SVC-020: includes territories that the curated list dropped (Greenland + Svalbard)', () => {
|
||||||
|
const geo = getCountryGeo();
|
||||||
|
// Greenland is its own feature.
|
||||||
|
expect(geo.features.some((f: any) => f.properties.ISO_A2 === 'GL')).toBe(true);
|
||||||
|
// Svalbard has no separate ISO entity in geoBoundaries; it sits inside Norway's
|
||||||
|
// geometry (lat ~74-81°N). Guard that the country polygon reaches those latitudes.
|
||||||
|
const no = geo.features.find((f: any) => f.properties.ISO_A2 === 'NO');
|
||||||
|
const maxLat = (function max(coords: any): number {
|
||||||
|
if (typeof coords[0] === 'number') return coords[1];
|
||||||
|
return Math.max(...coords.map(max));
|
||||||
|
})(no.geometry.coordinates);
|
||||||
|
expect(maxLat).toBeGreaterThan(78);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import { createUser, createTrip } from '../../helpers/factories';
|
|||||||
import {
|
import {
|
||||||
saveAsTemplate,
|
saveAsTemplate,
|
||||||
applyTemplate,
|
applyTemplate,
|
||||||
|
listTemplates,
|
||||||
setBagMembers,
|
setBagMembers,
|
||||||
createBag,
|
createBag,
|
||||||
deleteBag,
|
deleteBag,
|
||||||
@@ -92,6 +93,27 @@ describe('saveAsTemplate', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── listTemplates ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('listTemplates', () => {
|
||||||
|
it('PACK-SVC-LIST-001: returns templates with id, name and item_count', () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
|
testDb.prepare('INSERT INTO packing_items (trip_id, name, category, checked, sort_order) VALUES (?, ?, ?, 0, ?)').run(trip.id, 'Shirt', 'Clothes', 0);
|
||||||
|
testDb.prepare('INSERT INTO packing_items (trip_id, name, category, checked, sort_order) VALUES (?, ?, ?, 0, ?)').run(trip.id, 'Toothbrush', 'Toiletries', 1);
|
||||||
|
const saved = saveAsTemplate(trip.id, user.id, 'Weekend');
|
||||||
|
|
||||||
|
const templates = listTemplates();
|
||||||
|
expect(templates).toHaveLength(1);
|
||||||
|
expect(templates[0]).toMatchObject({ id: saved!.id, name: 'Weekend', item_count: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PACK-SVC-LIST-002: returns an empty array when no templates exist', () => {
|
||||||
|
expect(listTemplates()).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ── applyTemplate ─────────────────────────────────────────────────────────────
|
// ── applyTemplate ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('applyTemplate', () => {
|
describe('applyTemplate', () => {
|
||||||
|
|||||||
@@ -126,6 +126,22 @@ export type PackingSaveTemplateRequest = z.infer<
|
|||||||
typeof packingSaveTemplateRequestSchema
|
typeof packingSaveTemplateRequestSchema
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
export const packingTemplateSummarySchema = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
name: z.string(),
|
||||||
|
item_count: z.number(),
|
||||||
|
});
|
||||||
|
export type PackingTemplateSummary = z.infer<
|
||||||
|
typeof packingTemplateSummarySchema
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const packingTemplatesResponseSchema = z.object({
|
||||||
|
templates: z.array(packingTemplateSummarySchema),
|
||||||
|
});
|
||||||
|
export type PackingTemplatesResponse = z.infer<
|
||||||
|
typeof packingTemplatesResponseSchema
|
||||||
|
>;
|
||||||
|
|
||||||
export const packingCategoryAssigneesRequestSchema = z.object({
|
export const packingCategoryAssigneesRequestSchema = z.object({
|
||||||
user_ids: z.array(z.number()),
|
user_ids: z.array(z.number()),
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user