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:
jubnl
2026-06-09 16:02:37 +02:00
committed by GitHub
parent 49b3af8b0d
commit 3c040fab11
41 changed files with 1061 additions and 277 deletions
+33
View File
@@ -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.
+7
View File
@@ -437,6 +437,13 @@ Caddy handles TLS and WebSockets automatically.
<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
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.
+1
View File
@@ -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),
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),
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),
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),
@@ -1,6 +1,6 @@
import React, { useEffect, useRef, useState } from 'react'
import { Package } from 'lucide-react'
import { adminApi, packingApi } from '../../api/client'
import { packingApi } from '../../api/client'
import { useTripStore } from '../../store/tripStore'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
@@ -28,7 +28,7 @@ export default function ApplyTemplateButton({ tripId, style, className }: ApplyT
const { t } = useTranslation()
useEffect(() => {
adminApi.packingTemplates().then(d => setTemplates(d.templates || [])).catch(() => {})
packingApi.listTemplates(tripId).then(d => setTemplates(d.templates || [])).catch(() => {})
}, [tripId])
useEffect(() => {
@@ -7,7 +7,7 @@ import { server } from '../../../tests/helpers/msw/server';
import { useAuthStore } from '../../store/authStore';
import { useTripStore } from '../../store/tripStore';
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';
describe('itemWeight (bag total weight calc)', () => {
@@ -34,10 +34,10 @@ beforeEach(() => {
http.get('/api/trips/:id/packing/category-assignees', () =>
HttpResponse.json({ assignees: {} })
),
http.get('/api/admin/bag-tracking', () =>
HttpResponse.json({ enabled: false })
http.get('/api/addons', () =>
HttpResponse.json({ bagTracking: false, addons: [] })
),
http.get('/api/admin/packing-templates', () =>
http.get('/api/trips/:id/packing/templates', () =>
HttpResponse.json({ templates: [] })
),
);
@@ -381,7 +381,7 @@ describe('PackingListPanel', () => {
it('FE-COMP-PACKING-030: packing template button present when templates available', async () => {
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 }] })
)
);
@@ -457,8 +457,8 @@ describe('PackingListPanel', () => {
it('FE-COMP-PACKING-034: bag tracking enabled shows Bags button and bag sidebar', async () => {
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: [{ 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 () => {
const user = userEvent.setup();
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: [{ 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 () => {
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: [{ 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 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
const folderPlusBtn = container.querySelector('svg.lucide-folder-plus')?.closest('button');
expect(folderPlusBtn).toBeTruthy();
// Save-as-template button shows its label "Save as template"
const saveBtn = screen.getByText('Save as template').closest('button');
expect(saveBtn).toBeTruthy();
// Click to show the name input
await user.click(folderPlusBtn!);
await user.click(saveBtn!);
// Template name input appears
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 () => {
const user = userEvent.setup();
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 }] })
)
);
@@ -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 () => {
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: [] })
@@ -706,6 +716,7 @@ describe('PackingListPanel', () => {
});
it('FE-COMP-PACKING-046: save-as-template form submission calls saveAsTemplate API', async () => {
seedStore(useAuthStore, { user: buildAdmin(), isAuthenticated: true });
const user = userEvent.setup();
let savedTemplateName = '';
server.use(
@@ -714,16 +725,16 @@ describe('PackingListPanel', () => {
savedTemplateName = String(body.name);
return HttpResponse.json({ success: true });
}),
http.get('/api/admin/packing-templates', () =>
http.get('/api/trips/:id/packing/templates', () =>
HttpResponse.json({ templates: [] })
)
);
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
const folderPlusBtn = container.querySelector('svg.lucide-folder-plus')?.closest('button');
await user.click(folderPlusBtn!);
// Click the "Save as template" button
const saveBtn = screen.getByText('Save as template').closest('button');
await user.click(saveBtn!);
// Type 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 () => {
const user = userEvent.setup();
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: [{ 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 () => {
const user = userEvent.setup();
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: [{ 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;
const itemId = 120;
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: [] })
@@ -861,8 +872,8 @@ describe('PackingListPanel', () => {
const itemId = 130;
let putBody: Record<string, unknown> | null = null;
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: [{ 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 () => {
const itemId = 140;
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: [{ 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 () => {
const user = userEvent.setup();
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 }] })
)
);
@@ -1124,7 +1135,7 @@ describe('PackingListPanel', () => {
const user = userEvent.setup();
let applyCalled = false;
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 }] })
),
http.post('/api/trips/1/packing/apply-template/5', () => {
@@ -1177,7 +1188,7 @@ describe('PackingListPanel', () => {
const user = userEvent.setup();
let createBody: Record<string, unknown> | null = null;
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)
http.get('/api/trips/:id/packing/bags', () =>
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();
let deleteCalled = false;
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: [{ id: 9, name: 'Old Bag', color: '#6366f1', weight_limit_grams: null, members: [] }] })
),
@@ -1235,7 +1246,7 @@ describe('PackingListPanel', () => {
const user = userEvent.setup();
let updateBody: Record<string, unknown> | null = null;
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: [{ id: 11, name: 'Carry-on', color: '#10b981', weight_limit_grams: null, members: [] }] })
),
@@ -1273,7 +1284,7 @@ describe('PackingListPanel', () => {
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', () =>
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,
})
),
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: [{ 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 () => {
let createBody: Record<string, unknown> | null = null;
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.post('/api/trips/1/packing/bags', async ({ request }) => {
createBody = await request.json() as Record<string, unknown>;
@@ -5,7 +5,7 @@ import type { PackingState } from './usePackingListPanel'
export function PackingHeader(S: PackingState) {
const {
inlineHeader, t, items, abgehakt, fortschritt, canEdit,
inlineHeader, t, items, abgehakt, fortschritt, canEdit, isAdmin,
showSaveTemplate, saveTemplateName, setSaveTemplateName, handleSaveAsTemplate, setShowSaveTemplate,
setShowImportModal, handleClearChecked, availableTemplates, templateDropdownRef,
showTemplateDropdown, setShowTemplateDropdown, applyingTemplate, handleApplyTemplate,
@@ -26,7 +26,7 @@ export function PackingHeader(S: PackingState) {
</div>
) : <span />}
<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 }}>
<input
type="text" autoFocus
@@ -97,7 +97,7 @@ export function PackingHeader(S: PackingState) {
)}
</div>
)}
{inlineHeader && canEdit && items.length > 0 && !showSaveTemplate && (
{inlineHeader && canEdit && isAdmin && items.length > 0 && !showSaveTemplate && (
<button onClick={() => setShowSaveTemplate(true)} style={{
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',
@@ -2,9 +2,11 @@ import { useState, useMemo, useRef, useEffect } from 'react'
import type { ChangeEvent } from 'react'
import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore'
import { useAuthStore } from '../../store/authStore'
import { useToast } from '../shared/Toast'
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 { BAG_COLORS } from './packingListPanel.constants'
import { parseImportLines } from './packingListPanel.helpers'
@@ -46,6 +48,7 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
const can = useCanDo()
const trip = useTripStore((s) => s.trip)
const canEdit = can('packing_edit', trip)
const isAdmin = useAuthStore((s) => s.user?.role === 'admin')
const toast = useToast()
const { t } = useTranslation()
@@ -145,19 +148,24 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
if (failed) toast.error(t('packing.toast.deleteError'))
}
// Bag tracking
const [bagTrackingEnabled, setBagTrackingEnabled] = useState(false)
// Bag tracking — the global toggle is a packing sub-flag surfaced to every
// 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 [newBagName, setNewBagName] = useState('')
const [showAddBag, setShowAddBag] = useState(false)
const [showBagModal, setShowBagModal] = useState(false)
useEffect(() => {
adminApi.getBagTracking().then(d => {
setBagTrackingEnabled(d.enabled)
if (d.enabled) packingApi.listBags(tripId).then(r => setBags(r.bags || [])).catch(() => {})
}).catch(() => {})
}, [tripId])
if (!addonsLoaded) loadAddons()
}, [addonsLoaded, loadAddons])
useEffect(() => {
if (bagTrackingEnabled) packingApi.listBags(tripId).then(r => setBags(r.bags || [])).catch(() => {})
}, [tripId, bagTrackingEnabled])
const handleCreateBag = async () => {
if (!newBagName.trim()) return
@@ -234,7 +242,7 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
const templateDropdownRef = useRef<HTMLDivElement>(null)
useEffect(() => {
adminApi.packingTemplates().then(d => setAvailableTemplates(d.templates || [])).catch(() => {})
packingApi.listTemplates(tripId).then(d => setAvailableTemplates(d.templates || [])).catch(() => {})
}, [tripId])
useEffect(() => {
@@ -267,7 +275,7 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
toast.success(t('packing.templateSaved'))
setShowSaveTemplate(false)
setSaveTemplateName('')
adminApi.packingTemplates().then(d => setAvailableTemplates(d.templates || [])).catch(() => {})
packingApi.listTemplates(tripId).then(d => setAvailableTemplates(d.templates || [])).catch(() => {})
} catch {
toast.error(t('common.error'))
}
@@ -297,7 +305,7 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
const font = { fontFamily: "var(--font-system)" }
return {
tripId, items, inlineHeader, t, canEdit, font,
tripId, items, inlineHeader, t, canEdit, isAdmin, font,
filter, setFilter, addingCategory, setAddingCategory, newCatName, setNewCatName,
tripMembers, categoryAssignees, handleSetAssignees, allCategories, gruppiert, abgehakt, fortschritt,
handleAddItemToCategory, handleAddNewCategory, handleRenameCategory, handleDeleteCategory, handleClearChecked,
+51 -145
View File
@@ -175,6 +175,9 @@ function useDefaultAtlasHandlers() {
http.get('/api/addons/atlas/stats', () => HttpResponse.json(atlasStatsResponse)),
http.get('/api/addons/atlas/bucket-list', () => HttpResponse.json({ items: [] })),
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)
http.get('/api/addons/atlas/regions/geo', () => HttpResponse.json({ features: [] })),
);
@@ -187,18 +190,6 @@ beforeEach(() => {
seedStore(useAuthStore, { isAuthenticated: true, user: buildUser() });
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();
});
@@ -469,16 +460,9 @@ describe('AtlasPage', () => {
describe('FE-PAGE-ATLAS-017: country search filters options from GeoJSON', () => {
it('typing in search updates the input value', async () => {
// Override fetch to return GeoJSON with FR feature
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(geoJsonWithFR),
} as Response);
}
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
});
server.use(
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)),
);
const user = userEvent.setup();
render(<AtlasPage />);
@@ -519,16 +503,9 @@ describe('AtlasPage', () => {
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 () => {
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(geoJsonWithFR),
} as Response);
}
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
});
server.use(
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)),
);
server.use(
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', () => {
it('selecting Add to bucket list in confirm popup shows month/year pickers', async () => {
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(geoJsonWithFR),
} as Response);
}
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
});
server.use(
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)),
);
const user = userEvent.setup();
render(<AtlasPage />);
@@ -642,16 +612,9 @@ describe('AtlasPage', () => {
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 () => {
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(geoJsonWithFR),
} as Response);
}
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
});
server.use(
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)),
);
server.use(
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', () => {
it('clicking Add to bucket list in choose popup switches to bucket type', async () => {
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(geoJsonWithFR),
} as Response);
}
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
});
server.use(
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)),
);
const user = userEvent.setup();
render(<AtlasPage />);
@@ -851,16 +807,9 @@ describe('AtlasPage', () => {
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 () => {
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(geoJsonWithFR),
} as Response);
}
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
});
server.use(
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)),
);
server.use(
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', () => {
it('clicking the overlay backdrop closes the confirm popup', async () => {
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(geoJsonWithFR),
} as Response);
}
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
});
server.use(
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)),
);
const user = userEvent.setup();
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 },
],
};
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(geoJsonFRandDE) } as Response);
}
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
});
server.use(
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonFRandDE)),
);
render(<AtlasPage />);
@@ -1023,13 +961,9 @@ describe('AtlasPage', () => {
describe('FE-PAGE-ATLAS-034: dropdown button click + mouse events', () => {
it('clicking France dropdown button covers onClick and mouse event handlers', async () => {
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(geoJsonWithFR) } as Response);
}
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
});
server.use(
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)),
);
server.use(
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.post('/api/addons/atlas/country/:code/mark', () => HttpResponse.json({ success: true })),
);
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(geoJsonWithFR) } as Response);
}
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
});
server.use(
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)),
);
const user = userEvent.setup();
render(<AtlasPage />);
@@ -1158,13 +1088,9 @@ describe('AtlasPage', () => {
describe('FE-PAGE-ATLAS-036: bucket popup submit action', () => {
it('submits a bucket list item from the confirm popup', async () => {
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(geoJsonWithFR) } as Response);
}
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
});
server.use(
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)),
);
server.use(
http.post('/api/addons/atlas/bucket-list', () =>
@@ -1321,13 +1247,9 @@ describe('AtlasPage', () => {
},
],
};
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(geoJsonWithXK) } as Response);
}
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
});
server.use(
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithXK)),
);
render(<AtlasPage />);
@@ -1345,13 +1267,9 @@ describe('AtlasPage', () => {
{ a3: 'FRA', name: 'France', query: 'france' },
{ 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 }) => {
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(makeGeoJsonWithA3Fallback(a3, name)) } as Response);
}
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
});
server.use(
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(makeGeoJsonWithA3Fallback(a3, name))),
);
const user = userEvent.setup();
render(<AtlasPage />);
@@ -1459,13 +1377,9 @@ describe('AtlasPage', () => {
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 () => {
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(geoJsonWithFR) } as Response);
}
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
});
server.use(
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)),
);
server.use(
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', () => {
it('switching to dark mode re-initializes map and covers region loading code path', async () => {
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(geoJsonWithFR) } as Response);
}
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
});
server.use(
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)),
);
server.use(
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 },
],
};
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(geoJsonFRandIT) } as Response);
}
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
});
server.use(
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonFRandIT)),
);
render(<AtlasPage />);
+2 -1
View File
@@ -53,6 +53,7 @@ function ListsContainer({ tripId, packingItems, todoItems }: { tripId: number; p
const [saveTemplateSignal, setSaveTemplateSignal] = useState(0)
const [addTodoSignal, setAddTodoSignal] = useState(0)
const { t } = useTranslation()
const isAdmin = useAuthStore(s => s.user?.role === 'admin')
const tabs = [
{ 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`}
style={sharedBtnStyle}
/>
{packingItems.length > 0 && (
{isAdmin && packingItems.length > 0 && (
<button onClick={() => setSaveTemplateSignal(s => s + 1)}
className={`${sharedBtnClass} bg-accent text-accent-text`}
style={sharedBtnStyle}
+8 -7
View File
@@ -132,18 +132,19 @@ export function useAtlas() {
}).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(() => {
fetch('https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_50m_admin_0_countries.geojson')
.then(r => r.json())
.then(geo => {
apiClient.get('/addons/atlas/countries/geo')
.then(res => {
const geo = res.data
// Dynamically build A2→A3 mapping from GeoJSON
for (const f of geo.features) {
const a2 = f.properties?.ISO_A2
const a3 = f.properties?.ADM0_A3 || f.properties?.ISO_A3
// Only real 2-letter ISO codes: natural-earth uses subdivision-style
// values like "CN-TW" for Taiwan, which would otherwise overwrite the
// legitimate TWN->TW reverse mapping and break the country (#1049).
// Only accept clean 2-letter ISO codes and never overwrite an existing
// mapping: some datasets carry subdivision-style values like "CN-TW" for
// Taiwan, which would clobber the legitimate TWN->TW entry (#1049).
if (a2 && a3 && a2.length === 2 && a2 !== '-99' && a3 !== '-99' && !A2_TO_A3[a2]) {
A2_TO_A3[a2] = a3
}
+3 -1
View File
@@ -24,6 +24,7 @@ interface Addon {
interface AddonState {
addons: Addon[]
bagTracking: boolean
loaded: boolean
loadAddons: () => Promise<void>
isEnabled: (id: string) => boolean
@@ -31,12 +32,13 @@ interface AddonState {
export const useAddonStore = create<AddonState>((set, get) => ({
addons: [],
bagTracking: false,
loaded: false,
loadAddons: async () => {
try {
const data = await addonsApi.enabled()
set({ addons: data.addons || [], loaded: true })
set({ addons: data.addons || [], bagTracking: !!data.bagTracking, loaded: true })
} catch {
set({ loaded: true })
}
@@ -3,6 +3,7 @@ import { http, HttpResponse } from 'msw';
export const addonHandlers = [
http.get('/api/addons', () => {
return HttpResponse.json({
bagTracking: false,
addons: [
{ id: 'vacay', name: 'Vacay', type: 'feature', icon: 'calendar', 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[0]).toHaveProperty('id');
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);
});
});
+1
View File
@@ -0,0 +1 @@
.atlas-geo-cache/
Binary file not shown.
Binary file not shown.
+225
View File
@@ -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) })
+91
View File
@@ -1,3 +1,6 @@
import fs from 'fs';
import path from 'path';
import zlib from 'zlib';
import Database from 'better-sqlite3';
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);
`),
// 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) {
@@ -97,7 +97,6 @@ export function applyGlobalMiddleware(
"https://*.basemaps.cartocdn.com", "https://*.tile.openstreetmap.org",
"https://unpkg.com", "https://open-meteo.com", "https://api.open-meteo.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://api.mapbox.com", "https://*.tiles.mapbox.com", "https://events.mapbox.com"
],
+2 -1
View File
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { db } from '../../db/database';
import type { Addon } from '../../types';
import { getCollabFeatures } from '../../services/adminService';
import { getBagTracking, getCollabFeatures } from '../../services/adminService';
import { getPhotoProviderConfig } from '../../services/memories/helpersService';
/**
@@ -53,6 +53,7 @@ export class AddonsService {
return {
collabFeatures: getCollabFeatures(),
bagTracking: getBagTracking().enabled,
addons: [
...addons.map((a) => ({ ...a, enabled: !!a.enabled })),
...providers.map((p) => ({
@@ -62,6 +62,12 @@ export class AtlasController {
return geo;
}
@Get('countries/geo')
@Header('Cache-Control', 'public, max-age=86400')
countryGeo(): RegionGeo {
return this.atlas.countryGeo();
}
@Get('country/:code')
countryPlaces(@CurrentUser() user: User, @Param('code') code: string) {
return this.atlas.countryPlaces(user.id, code.toUpperCase());
+5
View File
@@ -8,6 +8,7 @@ import {
unmarkRegionVisited,
getVisitedRegions,
getRegionGeo,
getCountryGeo,
listBucketList,
createBucketItem,
updateBucketItem,
@@ -37,6 +38,10 @@ export class AtlasService {
return getRegionGeo(countries);
}
countryGeo() {
return getCountryGeo();
}
countryPlaces(userId: number, code: string) {
return getCountryPlaces(userId, code);
}
@@ -195,6 +195,12 @@ export class PackingController {
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')
@HttpCode(200)
applyTemplate(
@@ -238,6 +244,9 @@ export class PackingController {
@Body('name') name?: string,
) {
this.requireTrip(tripId, user);
if (user.role !== 'admin') {
throw new HttpException({ error: 'Admin access required' }, 403);
}
if (!name?.trim()) {
throw new HttpException({ error: 'Template name is required' }, 400);
}
@@ -71,6 +71,10 @@ export class PackingService {
return svc.setBagMembers(tripId, bagId, userIds);
}
listTemplates() {
return svc.listTemplates();
}
applyTemplate(tripId: string, templateId: string) {
return svc.applyTemplate(tripId, templateId);
}
+25
View File
@@ -1,5 +1,6 @@
import { Body, Controller, Delete, Get, HttpException, Param, Post, Res, UseGuards } from '@nestjs/common';
import type { Response } from 'express';
import { createReadStream } from 'node:fs';
import type { User } from '../../types';
import { ShareService } from './share.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
@@ -72,6 +73,30 @@ export class TripShareController {
export class SharedController {
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')
read(@Param('token') token: string) {
const data = this.share.getSharedTripData(token);
+1
View File
@@ -26,4 +26,5 @@ export class ShareService {
get(tripId: string) { return svc.getShareLink(tripId); }
remove(tripId: string) { return svc.deleteShareLink(tripId); }
getSharedTripData(token: string) { return svc.getSharedTripData(token); }
getSharedPlacePhotoPath(token: string, placeId: string) { return svc.getSharedPlacePhotoPath(token, placeId); }
}
+34 -21
View File
@@ -1,32 +1,45 @@
import fs from 'fs';
import path from 'path';
import zlib from 'zlib';
import { db } from '../db/database';
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;
let admin1GeoLoading: Promise<any> | null = null;
const geoBundleCache = new Map<string, any>();
async function loadAdmin1Geo(): Promise<any> {
if (admin1GeoCache) return admin1GeoCache;
if (admin1GeoLoading) return admin1GeoLoading;
admin1GeoLoading = fetch(
'https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_10m_admin_1_states_provinces.geojson',
{ headers: { 'User-Agent': 'TREK Travel Planner' } }
).then(r => r.json()).then((geo: any) => {
admin1GeoCache = geo;
admin1GeoLoading = null;
console.log(`[Atlas] Cached admin-1 GeoJSON: ${geo.features?.length || 0} features`);
return geo;
}).catch(err => {
admin1GeoLoading = null;
console.error('[Atlas] Failed to load admin-1 GeoJSON:', err);
return null;
});
return admin1GeoLoading;
function loadGeoBundle(name: 'admin0' | 'admin1'): any {
const cached = geoBundleCache.get(name);
if (cached) return cached;
const file = path.join(__dirname, '..', '..', 'assets', 'atlas', `${name}.geojson.gz`);
if (!fs.existsSync(file)) {
console.warn(`[Atlas] ${name}.geojson.gz missing — run \`node scripts/build-atlas-geo.mjs\``);
const empty = { type: 'FeatureCollection', features: [] };
geoBundleCache.set(name, empty);
return empty;
}
const geo = JSON.parse(zlib.gunzipSync(fs.readFileSync(file)).toString('utf8'));
geoBundleCache.set(name, geo);
console.log(`[Atlas] Loaded ${name} GeoJSON: ${geo.features?.length || 0} features`);
return geo;
}
/** 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> {
const geo = await loadAdmin1Geo();
const geo = loadGeoBundle('admin1');
if (!geo) return { type: 'FeatureCollection', features: [] };
const codes = new Set(countryCodes.map(c => c.toUpperCase()));
const features = geo.features.filter((f: any) => codes.has(f.properties?.iso_a2?.toUpperCase()));
+16
View File
@@ -191,6 +191,22 @@ export function deleteBag(tripId: string | number, bagId: string | number) {
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 ─────────────────────────────────────────────────────────
export function applyTemplate(tripId: string | number, templateId: string | number) {
+44 -3
View File
@@ -1,6 +1,24 @@
import { db, canAccessTrip } from '../db/database';
import crypto from 'crypto';
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 {
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,
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,
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,
tags: tagsByPlace[a.place_id] || [],
}
@@ -147,11 +165,11 @@ export function getSharedTripData(token: string): Record<string, any> | null {
}
// 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
FROM places p LEFT JOIN categories c ON p.category_id = c.id
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
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,
};
}
/**
* 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);
}
+7 -2
View File
@@ -30,11 +30,12 @@ vi.mock('../../src/db/database', () => ({
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 })),
getBagTracking: vi.fn(() => ({ enabled: true })),
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 }));
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);
});
// 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 () => {
const res = await request(server).get('/api/addons').set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body).toEqual({
collabFeatures: { chat: true, notes: true, polls: true, whatsnext: true },
bagTracking: true,
addons: [
{ id: 'packing', name: 'Packing', type: 'trip', icon: 'Backpack', enabled: true },
{
+9
View File
@@ -33,6 +33,7 @@ const { mocks } = vi.hoisted(() => ({
unmarkRegionVisited: vi.fn(),
getVisitedRegions: vi.fn(),
getRegionGeo: vi.fn(),
getCountryGeo: vi.fn(),
listBucketList: vi.fn(),
createBucketItem: vi.fn(),
updateBucketItem: vi.fn(),
@@ -75,6 +76,14 @@ describe('Atlas e2e (real auth guard + temp SQLite)', () => {
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 () => {
const res = await request(server).get('/api/addons/atlas/stats').set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
+29 -1
View File
@@ -8,6 +8,9 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import request from 'supertest';
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 { Test } from '@nestjs/testing';
import { seedUser, sessionCookie } from './harness';
@@ -28,7 +31,7 @@ const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn() }));
vi.mock('../../src/services/permissions', () => ({ checkPermission }));
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);
@@ -106,4 +109,29 @@ describe('Share-link e2e (real auth guard + temp SQLite)', () => {
expect(res.status).toBe(404);
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' });
});
});
});
+39 -6
View File
@@ -448,8 +448,8 @@ describe('Packing — apply-template, bag members, save-as-template', () => {
expect(res.body.error).toBeDefined();
});
it('PACK-017 — POST /save-as-template saves packing list as a template', async () => {
const { user } = createUser(testDb);
it('PACK-017 — POST /save-as-template saves packing list as a template (admin)', async () => {
const { user } = createUser(testDb, { role: 'admin' });
const trip = createTrip(testDb, user.id);
// 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');
});
it('PACK-017b — POST /save-as-template without name returns 400', async () => {
const { user } = createUser(testDb);
it('PACK-017b — POST /save-as-template without name returns 400 (admin)', async () => {
const { user } = createUser(testDb, { role: 'admin' });
const trip = createTrip(testDb, user.id);
const res = await request(app)
@@ -478,8 +478,8 @@ describe('Packing — apply-template, bag members, save-as-template', () => {
expect(res.body.error).toBeDefined();
});
it('PACK-017c — POST /save-as-template when trip has no items returns 400', async () => {
const { user } = createUser(testDb);
it('PACK-017c — POST /save-as-template when trip has no items returns 400 (admin)', async () => {
const { user } = createUser(testDb, { role: 'admin' });
const trip = createTrip(testDb, user.id);
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.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');
});
});
+77
View File
@@ -49,6 +49,8 @@ import { runMigrations } from '../../src/db/migrations';
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
import { createUser, createTrip, addTripMember, createDay, createPlace, createDayAssignment, createDayNote } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import * as placePhotoCache from '../../src/services/placePhotoCache';
import fs from 'node:fs';
let nestApp: INestApplication;
let app: Application;
@@ -351,3 +353,78 @@ describe('Shared trip — ordering parity (issue #981)', () => {
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.",
'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.',
'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', () => {
@@ -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', () => {
it('GET /country/:code upper-cases the code', () => {
const countryPlaces = vi.fn().mockReturnValue([]);
@@ -5,6 +5,7 @@ import type { PackingService } from '../../../src/nest/packing/packing.service';
import type { User } from '../../../src/types';
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 };
/** 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', () => {
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)', () => {
const svc = makeService({ applyTemplate: vi.fn().mockReturnValue(null) } as Partial<PackingService>);
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', () => {
const svc = makeService({ saveAsTemplate: vi.fn().mockReturnValue(null) } as Partial<PackingService>);
it('403 when a non-admin tries to save a template', () => {
const saveAsTemplate = vi.fn();
const svc = makeService({ saveAsTemplate } as Partial<PackingService>);
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' },
});
});
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', () => {
+46 -27
View File
@@ -31,7 +31,7 @@ import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
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) {
const cat = db.prepare('SELECT id FROM categories LIMIT 1').get() as { id: number } | undefined;
@@ -243,38 +243,57 @@ describe('reverseGeocodeCountry', () => {
// ── 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', () => {
it('ATLAS-SVC-017: returns empty FeatureCollection when fetch throws a network error', async () => {
// Override the default stub to throw so loadAdmin1Geo's .catch handler runs,
// 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']);
it('ATLAS-SVC-017: returns an empty FeatureCollection for a country with no admin-1 features', async () => {
const result = await getRegionGeo(['ZZ']);
expect(result).toEqual({ type: 'FeatureCollection', features: [] });
});
it('ATLAS-SVC-018: returns filtered features for matching country codes when fetch returns mock GeoJSON', async () => {
// ATLAS-SVC-017 ran with a throwing fetch, so admin1GeoCache is null and
// admin1GeoLoading is null — this test's fetch override will be called.
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']);
it('ATLAS-SVC-018: returns the current geoBoundaries regions for a country, case-insensitively', async () => {
// Pass lowercase 'no' — getRegionGeo uppercases internally for matching.
const result = await getRegionGeo(['no']);
expect(result.type).toBe('FeatureCollection');
expect(result.features).toHaveLength(1);
expect(result.features[0].properties.iso_a2).toBe('DE');
expect(result.features.length).toBeGreaterThan(0);
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 {
saveAsTemplate,
applyTemplate,
listTemplates,
setBagMembers,
createBag,
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 ─────────────────────────────────────────────────────────────
describe('applyTemplate', () => {
+16
View File
@@ -126,6 +126,22 @@ export type PackingSaveTemplateRequest = z.infer<
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({
user_ids: z.array(z.number()),
});