This commit is contained in:
Marek Maslowski
2026-04-24 17:22:18 +00:00
committed by GitHub
10 changed files with 608 additions and 23 deletions
@@ -24,6 +24,10 @@ const mockDayNotesState = vi.hoisted(() => ({
moveNote: vi.fn(), moveNote: vi.fn(),
})) }))
const mockPermissionsState = vi.hoisted(() => ({
canDo: true,
}))
// ── Module mocks ──────────────────────────────────────────────────────────── // ── Module mocks ────────────────────────────────────────────────────────────
vi.mock('../../api/client', async (importOriginal) => { vi.mock('../../api/client', async (importOriginal) => {
@@ -79,7 +83,7 @@ vi.mock('../../store/permissionsStore', async (importOriginal) => {
const actual = await importOriginal() as any const actual = await importOriginal() as any
return { return {
...actual, ...actual,
useCanDo: () => () => true, useCanDo: () => () => mockPermissionsState.canDo,
} }
}) })
@@ -125,6 +129,7 @@ beforeEach(() => {
// Reset mutable day-notes state // Reset mutable day-notes state
mockDayNotesState.noteUi = {} mockDayNotesState.noteUi = {}
mockDayNotesState.dayNotes = {} mockDayNotesState.dayNotes = {}
mockPermissionsState.canDo = true
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true }) seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true })
seedStore(useTripStore, { trip: buildTrip({ id: 1 }) }) seedStore(useTripStore, { trip: buildTrip({ id: 1 }) })
seedStore(useSettingsStore, { settings: { time_format: '24h', temperature_unit: 'celsius' } } as any) seedStore(useSettingsStore, { settings: { time_format: '24h', temperature_unit: 'celsius' } } as any)
@@ -894,23 +899,138 @@ describe('DayPlanSidebar', () => {
// ── ICS export click ───────────────────────────────────────────────── // ── ICS export click ─────────────────────────────────────────────────
it('FE-PLANNER-DAYPLAN-058: clicking ICS button calls fetch for .ics export', async () => { it('FE-PLANNER-DAYPLAN-058: clicking ICS button first asks link or download', async () => {
const user = userEvent.setup() const user = userEvent.setup()
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ if (!navigator.clipboard) {
ok: true, Object.defineProperty(navigator, 'clipboard', {
blob: () => Promise.resolve(new Blob(['BEGIN:VCALENDAR'], { type: 'text/calendar' })), value: { writeText: vi.fn().mockResolvedValue(undefined) },
} as any) configurable: true,
// Mock URL.createObjectURL })
}
const clipboardSpy = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined)
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
const url = String(input)
const method = (init?.method || 'GET').toUpperCase()
if (url === '/api/trips/1/subscribe.ics' && method === 'GET') {
return {
ok: true,
json: () => Promise.resolve({ token: null }),
} as any
}
if (url === '/api/trips/1/subscribe.ics' && method === 'POST') {
return {
ok: true,
json: () => Promise.resolve({
url: 'https://example.com/api/shared/token/calendar.ics',
webcal_url: 'webcal://example.com/api/shared/token/calendar.ics',
}),
} as any
}
if (url === '/api/trips/1/subscribe.ics' && method === 'DELETE') {
return { ok: true } as any
}
if (url === '/api/trips/1/export.ics' && method === 'GET') {
return {
ok: true,
blob: () => Promise.resolve(new Blob(['BEGIN:VCALENDAR'], { type: 'text/calendar' })),
} as any
}
throw new Error(`Unexpected fetch call: ${method} ${url}`)
})
const createObjURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock') const createObjURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock')
const revokeObjURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}) const revokeObjURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
render(<DayPlanSidebar {...makeDefaultProps()} />) render(<DayPlanSidebar {...makeDefaultProps()} />)
await user.click(screen.getByText('ICS').closest('button')!) await user.click(screen.getByText('ICS').closest('button')!)
await waitFor(() => expect(fetchSpy).toHaveBeenCalledWith('/api/trips/1/subscribe.ics', expect.any(Object)))
expect(await screen.findByText('Calendar share')).toBeInTheDocument()
expect(screen.getByText('Create a subscription link for calendar apps, or download the ICS file.')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Create link' }))
await waitFor(() => expect(fetchSpy).toHaveBeenCalledWith('/api/trips/1/subscribe.ics', expect.objectContaining({ method: 'POST' })))
expect(screen.getByDisplayValue('https://example.com/api/shared/token/calendar.ics')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Copy' }))
await waitFor(() => expect(clipboardSpy).toHaveBeenCalledWith('https://example.com/api/shared/token/calendar.ics'))
await user.click(screen.getByRole('button', { name: 'Delete link' }))
await waitFor(() => expect(fetchSpy).toHaveBeenCalledWith('/api/trips/1/subscribe.ics', expect.objectContaining({ method: 'DELETE' })))
expect(screen.getByRole('button', { name: 'Create link' })).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Download ICS file' }))
await waitFor(() => expect(fetchSpy).toHaveBeenCalledWith('/api/trips/1/export.ics', expect.any(Object))) await waitFor(() => expect(fetchSpy).toHaveBeenCalledWith('/api/trips/1/export.ics', expect.any(Object)))
expect(createObjURL).toHaveBeenCalled()
expect(revokeObjURL).toHaveBeenCalledWith('blob:mock')
fetchSpy.mockRestore() fetchSpy.mockRestore()
clipboardSpy.mockRestore()
createObjURL.mockRestore() createObjURL.mockRestore()
revokeObjURL.mockRestore() revokeObjURL.mockRestore()
}) })
it('FE-PLANNER-DAYPLAN-097: opening ICS dialog shows existing generated link when present', async () => {
const user = userEvent.setup()
const expectedUrl = `${window.location.origin}/api/shared/existing-token/calendar.ics`
const clipboardSpy = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined)
const fetchSpy = vi.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ token: 'existing-token' }),
} as any)
.mockResolvedValueOnce({
ok: true,
blob: () => Promise.resolve(new Blob(['BEGIN:VCALENDAR'], { type: 'text/calendar' })),
} as any)
const createObjURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock')
const revokeObjURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
render(<DayPlanSidebar {...makeDefaultProps()} />)
await user.click(screen.getByText('ICS').closest('button')!)
await waitFor(() => expect(fetchSpy).toHaveBeenCalledWith('/api/trips/1/subscribe.ics', expect.any(Object)))
expect(await screen.findByDisplayValue(expectedUrl)).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Create link' })).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Copy' }))
await waitFor(() => expect(clipboardSpy).toHaveBeenCalledWith(expectedUrl))
await user.click(screen.getByRole('button', { name: 'Download ICS file' }))
await waitFor(() => expect(fetchSpy).toHaveBeenCalledWith('/api/trips/1/export.ics', expect.any(Object)))
expect(createObjURL).toHaveBeenCalled()
expect(revokeObjURL).toHaveBeenCalledWith('blob:mock')
expect(fetchSpy).not.toHaveBeenCalledWith('/api/trips/1/subscribe.ics', expect.objectContaining({ method: 'POST' }))
fetchSpy.mockRestore()
clipboardSpy.mockRestore()
createObjURL.mockRestore()
revokeObjURL.mockRestore()
})
it('FE-PLANNER-DAYPLAN-099: ICS dialog hides delete link button without share_manage permission', async () => {
const user = userEvent.setup()
mockPermissionsState.canDo = false
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true,
json: () => Promise.resolve({ token: 'existing-token' }),
} as any)
render(<DayPlanSidebar {...makeDefaultProps()} />)
await user.click(screen.getByText('ICS').closest('button')!)
await waitFor(() => expect(fetchSpy).toHaveBeenCalledWith('/api/trips/1/subscribe.ics', expect.any(Object)))
expect(await screen.findByDisplayValue(`${window.location.origin}/api/shared/existing-token/calendar.ics`)).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Delete link' })).not.toBeInTheDocument()
fetchSpy.mockRestore()
})
// ── openAddNote button click ────────────────────────────────────────── // ── openAddNote button click ──────────────────────────────────────────
it('FE-PLANNER-DAYPLAN-059: clicking Add Note button calls openAddNote', async () => { it('FE-PLANNER-DAYPLAN-059: clicking Add Note button calls openAddNote', async () => {
+198 -16
View File
@@ -4,7 +4,7 @@ declare global { interface Window { __dragData: DragDataPayload | null } }
import React, { useState, useEffect, useRef, useMemo } from 'react' import React, { useState, useEffect, useRef, useMemo } from 'react'
import ReactDOM from 'react-dom' import ReactDOM from 'react-dom'
import { ChevronDown, ChevronRight, ChevronUp, ChevronsDownUp, ChevronsUpDown, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users, Undo2, X, Route as RouteIcon } from 'lucide-react' import { ChevronDown, ChevronRight, ChevronUp, ChevronsDownUp, ChevronsUpDown, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users, Undo2, X, Route as RouteIcon, Link2, Copy } from 'lucide-react'
const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, event: Ticket, tour: Users, other: FileText } const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, event: Ticket, tour: Users, other: FileText }
import { assignmentsApi, reservationsApi } from '../../api/client' import { assignmentsApi, reservationsApi } from '../../api/client'
@@ -225,6 +225,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
const tripActions = useRef(useTripStore.getState()).current const tripActions = useRef(useTripStore.getState()).current
const can = useCanDo() const can = useCanDo()
const canEditDays = can('day_edit', trip) const canEditDays = can('day_edit', trip)
const canManageShare = can('share_manage', trip)
const { noteUi, setNoteUi, noteInputRef, dayNotes, openAddNote: _openAddNote, openEditNote: _openEditNote, cancelNote, saveNote, deleteNote: _deleteNote, moveNote: _moveNote } = useDayNotes(tripId) const { noteUi, setNoteUi, noteInputRef, dayNotes, openAddNote: _openAddNote, openEditNote: _openEditNote, cancelNote, saveNote, deleteNote: _deleteNote, moveNote: _moveNote } = useDayNotes(tripId)
@@ -252,6 +253,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
const setDropTargetKey = (key) => { dropTargetRef.current = key; _setDropTargetKey(key) } const setDropTargetKey = (key) => { dropTargetRef.current = key; _setDropTargetKey(key) }
const [dragOverDayId, setDragOverDayId] = useState(null) const [dragOverDayId, setDragOverDayId] = useState(null)
const [transportDetail, setTransportDetail] = useState(null) const [transportDetail, setTransportDetail] = useState(null)
const [icsDialog, setIcsDialog] = useState<{ url: string; webcal_url: string; creating: boolean } | null>(null)
const [icsCopied, setIcsCopied] = useState(false)
const icsCopyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [transportPosVersion, setTransportPosVersion] = useState(0) const [transportPosVersion, setTransportPosVersion] = useState(0)
useEffect(() => { useEffect(() => {
@@ -284,6 +288,99 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
const currency = trip?.currency || 'EUR' const currency = trip?.currency || 'EUR'
useEffect(() => {
return () => {
if (icsCopyTimerRef.current) clearTimeout(icsCopyTimerRef.current)
}
}, [])
const closeIcsDialog = () => setIcsDialog(null)
const handleIcsOpenDialog = async () => {
setIcsCopied(false)
setIcsDialog({ url: '', webcal_url: '', creating: true })
try {
const res = await fetch(`/api/trips/${tripId}/subscribe.ics`, { credentials: 'include' })
if (!res.ok) throw new Error()
const data = await res.json() as { token?: string | null }
if (data.token) {
const url = `${window.location.origin}/api/shared/${encodeURIComponent(data.token)}/calendar.ics`
const webcal_url = url.replace(/^https?:\/\//, 'webcal://')
setIcsDialog({ url, webcal_url, creating: false })
} else {
setIcsDialog({ url: '', webcal_url: '', creating: false })
}
} catch {
setIcsDialog({ url: '', webcal_url: '', creating: false })
toast.error(t('dayplan.calendarLinkFailed'))
}
}
const handleIcsCreateLink = async () => {
if (icsDialog?.creating) return
setIcsDialog(prev => prev ? { ...prev, creating: true } : { url: '', webcal_url: '', creating: true })
try {
const res = await fetch(`/api/trips/${tripId}/subscribe.ics`, { method: 'POST', credentials: 'include' })
if (!res.ok) throw new Error()
const data = await res.json() as { url?: string; webcal_url?: string }
const shareUrl = data.url
const openUrl = data.webcal_url || data.url
if (!shareUrl || !openUrl) throw new Error()
setIcsDialog({ url: shareUrl, webcal_url: openUrl, creating: false })
} catch {
setIcsDialog(prev => prev ? { ...prev, creating: false } : prev)
toast.error(t('dayplan.calendarLinkFailed'))
}
}
const handleIcsCopyLink = async () => {
if (!icsDialog?.url) return
try {
await navigator.clipboard.writeText(icsDialog.url)
setIcsCopied(true)
if (icsCopyTimerRef.current) clearTimeout(icsCopyTimerRef.current)
icsCopyTimerRef.current = setTimeout(() => setIcsCopied(false), 2000)
} catch {
toast.error(t('dayplan.calendarCopyFailed'))
}
}
const handleIcsDeleteLink = async () => {
if (!icsDialog || icsDialog.creating) return
setIcsDialog(prev => prev ? { ...prev, creating: true } : prev)
try {
const res = await fetch(`/api/trips/${tripId}/subscribe.ics`, {
method: 'DELETE',
credentials: 'include',
})
if (!res.ok) throw new Error()
setIcsCopied(false)
setIcsDialog(prev => prev ? { ...prev, url: '', webcal_url: '', creating: false } : prev)
toast.success(t('dayplan.calendarLinkDeleted'))
} catch {
setIcsDialog(prev => prev ? { ...prev, creating: false } : prev)
toast.error(t('dayplan.calendarDeleteFailed'))
}
}
const handleIcsDownload = async () => {
try {
const res = await fetch(`/api/trips/${tripId}/export.ics`, { credentials: 'include' })
if (!res.ok) throw new Error()
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${trip?.title || 'trip'}.ics`
a.click()
URL.revokeObjectURL(url)
toast.success(t('dayplan.calendarDownloaded'))
closeIcsDialog()
} catch {
toast.error(t('dayplan.calendarExportFailed'))
}
}
// Drag-Daten aus dataTransfer, Ref oder window lesen (dataTransfer geht bei Re-Render verloren) // Drag-Daten aus dataTransfer, Ref oder window lesen (dataTransfer geht bei Re-Render verloren)
const getDragData = (e) => { const getDragData = (e) => {
const dt = e?.dataTransfer const dt = e?.dataTransfer
@@ -993,21 +1090,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
</div> </div>
<div style={{ position: 'relative', flexShrink: 0 }}> <div style={{ position: 'relative', flexShrink: 0 }}>
<button <button
onClick={async () => { onClick={handleIcsOpenDialog}
try {
const res = await fetch(`/api/trips/${tripId}/export.ics`, {
credentials: 'include',
})
if (!res.ok) throw new Error()
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${trip?.title || 'trip'}.ics`
a.click()
URL.revokeObjectURL(url)
} catch { toast.error(t('planner.icsExportFailed')) }
}}
onMouseEnter={() => setIcsHover(true)} onMouseEnter={() => setIcsHover(true)}
onMouseLeave={() => setIcsHover(false)} onMouseLeave={() => setIcsHover(false)}
style={{ style={{
@@ -2128,6 +2211,105 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
document.body document.body
)} )}
{/* ICS subscription dialog */}
{icsDialog && ReactDOM.createPortal(
<div style={{
position: 'fixed', inset: 0, zIndex: 10000,
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'rgba(0,0,0,0.3)', backdropFilter: 'blur(3px)',
}} onClick={closeIcsDialog}>
<div style={{
width: 420, maxWidth: '92vw', background: 'var(--bg-card)', borderRadius: 16,
boxShadow: '0 16px 48px rgba(0,0,0,0.22)', padding: '22px 22px 18px',
display: 'flex', flexDirection: 'column', gap: 12, position: 'relative',
}} onClick={e => e.stopPropagation()}>
<button
onClick={closeIcsDialog}
aria-label="Close"
style={{
position: 'absolute',
top: 10,
right: 10,
width: 30,
height: 30,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 8,
border: '1px solid var(--border-faint)',
background: 'transparent',
color: 'var(--text-muted)',
cursor: 'pointer',
}}
>
<X size={14} />
</button>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 2 }}>
<Link2 size={14} style={{ color: 'var(--text-muted)' }} />
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>
{t('dayplan.calendarShareTitle')}
</span>
</div>
<p style={{ fontSize: 11, color: 'var(--text-faint)', margin: 0, lineHeight: 1.5 }}>
{t('dayplan.calendarShareDescription')}
</p>
{icsDialog.url ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{
display: 'flex', alignItems: 'center', gap: 6, padding: '8px 10px',
background: 'var(--bg-tertiary)', borderRadius: 8, border: '1px solid var(--border-faint)',
}}>
<input type="text" value={icsDialog.url} readOnly style={{
flex: 1, border: 'none', background: 'none', fontSize: 11, color: 'var(--text-primary)',
outline: 'none', fontFamily: 'monospace',
}} />
<button onClick={handleIcsCopyLink} style={{
display: 'flex', alignItems: 'center', gap: 4, padding: '4px 8px', borderRadius: 6,
border: 'none', background: icsCopied ? '#16a34a' : 'var(--accent)', color: icsCopied ? 'white' : 'var(--accent-text)',
fontSize: 10, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', transition: 'background 0.2s',
}}>
{icsCopied ? <><Check size={10} /> {t('common.copied')}</> : <><Copy size={10} /> {t('common.copy')}</>}
</button>
</div>
{canManageShare && (
<button onClick={handleIcsDeleteLink} style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
padding: '6px 0', borderRadius: 8, border: '1px solid rgba(239,68,68,0.3)',
background: 'rgba(239,68,68,0.06)', color: '#ef4444', fontSize: 11, fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit',
}}>
<Trash2 size={11} /> {t('dayplan.calendarDeleteLink')}
</button>
)}
</div>
) : (
<button onClick={handleIcsCreateLink}
disabled={!canManageShare}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
width: '100%', padding: '8px 0', borderRadius: 8, border: '1px dashed var(--border-primary)',
background: 'none', color: 'var(--text-muted)', fontSize: 12, fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit',
}}>
{canManageShare ? <><Link2 size={12} /> {t('dayplan.calendarCreateLink')}</> : <>{t('dayplan.calendarCreateLinkNoPermission')}</>}
</button>
)}
<button
onClick={handleIcsDownload}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
width: '100%', padding: '8px 0', borderRadius: 8, border: 'none',
background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 12, fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit',
}}
>
{t('dayplan.calendarDownloadFile')}
</button>
</div>
</div>,
document.body
)}
{/* Transport-Detail-Modal */} {/* Transport-Detail-Modal */}
{transportDetail && ReactDOM.createPortal( {transportDetail && ReactDOM.createPortal(
<div style={{ <div style={{
+12
View File
@@ -305,6 +305,18 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'settings.notificationsActive': 'Active channel', 'settings.notificationsActive': 'Active channel',
'settings.notificationsManagedByAdmin': 'Notification events are configured by your administrator.', 'settings.notificationsManagedByAdmin': 'Notification events are configured by your administrator.',
'dayplan.icsTooltip': 'Export calendar (ICS)', 'dayplan.icsTooltip': 'Export calendar (ICS)',
'dayplan.calendarShareTitle': 'Calendar share',
'dayplan.calendarShareDescription': 'Create a subscription link for calendar apps, or download the ICS file.',
'dayplan.calendarCreateLink': 'Create link',
'dayplan.calendarCreateLinkNoPermission': 'You do not have permission to create calendar links for this trip.',
'dayplan.calendarDeleteLink': 'Delete link',
'dayplan.calendarDownloadFile': 'Download ICS file',
'dayplan.calendarLinkFailed': 'Calendar link failed',
'dayplan.calendarDeleteFailed': 'Delete link failed',
'dayplan.calendarCopyFailed': 'Copy failed',
'dayplan.calendarDownloaded': 'ICS downloaded',
'dayplan.calendarExportFailed': 'ICS export failed',
'dayplan.calendarLinkDeleted': 'Calendar link deleted',
'share.linkTitle': 'Public Link', 'share.linkTitle': 'Public Link',
'share.linkHint': 'Create a link anyone can use to view this trip without logging in. Read-only — no editing possible.', 'share.linkHint': 'Create a link anyone can use to view this trip without logging in. Read-only — no editing possible.',
'share.createLink': 'Create link', 'share.createLink': 'Create link',
+16
View File
@@ -2130,6 +2130,22 @@ function runMigrations(db: Database.Database): void {
'ON journey_entries(journey_id, entry_date, sort_order)' 'ON journey_entries(journey_id, entry_date, sort_order)'
); );
}, },
() => {
// Dedicated calendar subscription tokens for trips
db.exec(`
CREATE TABLE IF NOT EXISTS calendar_share_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL,
token TEXT NOT NULL UNIQUE,
created_by INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (trip_id) REFERENCES trips(id) ON DELETE CASCADE,
FOREIGN KEY (created_by) REFERENCES users(id),
UNIQUE(trip_id)
)
`);
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_calendar_share_trip ON calendar_share_tokens(trip_id)');
},
]; ];
if (currentVersion < migrations.length) { if (currentVersion < migrations.length) {
+9
View File
@@ -202,6 +202,15 @@ function createTables(db: Database.Database): void {
UNIQUE(trip_id, user_id) UNIQUE(trip_id, user_id)
); );
CREATE TABLE IF NOT EXISTS calendar_share_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
created_by INTEGER NOT NULL REFERENCES users(id),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(trip_id)
);
CREATE TABLE IF NOT EXISTS day_notes ( CREATE TABLE IF NOT EXISTS day_notes (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE, day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE,
+12
View File
@@ -58,4 +58,16 @@ router.get('/shared/:token', (req: Request, res: Response) => {
res.json(data); res.json(data);
}); });
// Public calendar subscription payload (no auth required)
router.get('/shared/:token/calendar.ics', (req: Request, res: Response) => {
const { token } = req.params;
const exported = shareService.getSharedTripICS(token);
if (!exported) return res.status(404).json({ error: 'Invalid or expired link' });
res.setHeader('Content-Type', 'text/calendar; charset=utf-8');
// Inline lets calendar clients subscribe/fetch from URL instead of forced download.
res.setHeader('Content-Disposition', `inline; filename="${exported.filename}"`);
res.send(exported.ics);
});
export default router; export default router;
+61
View File
@@ -36,6 +36,7 @@ import { listItems as listTodoItems } from '../services/todoService';
import { listBudgetItems } from '../services/budgetService'; import { listBudgetItems } from '../services/budgetService';
import { listReservations } from '../services/reservationService'; import { listReservations } from '../services/reservationService';
import { listFiles } from '../services/fileService'; import { listFiles } from '../services/fileService';
import { createOrUpdateCalendarShareLink, getCalendarShareLink, deleteCalendarShareLink } from '../services/shareService';
const router = express.Router(); const router = express.Router();
@@ -355,4 +356,64 @@ router.get('/:id/export.ics', authenticate, (req: Request, res: Response) => {
} }
}); });
// ── ICS calendar subscription link ───────────────────────────────────────
router.get('/:id/subscribe.ics', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!canAccessTrip(req.params.id, authReq.user.id)) {
return res.status(404).json({ error: 'Trip not found' });
}
const existing = getCalendarShareLink(req.params.id);
const token = existing?.token ?? null;
const host = req.get('host');
if (!host) return res.status(500).json({ error: 'Host header missing' });
const forwardedProto = typeof req.headers['x-forwarded-proto'] === 'string'
? req.headers['x-forwarded-proto'].split(',')[0].trim()
: null;
const protocol = forwardedProto || req.protocol;
const url = token ? `${protocol}://${host}/api/shared/${encodeURIComponent(token)}/calendar.ics` : null;
const webcal_url = url ? url.replace(/^https?:\/\//, 'webcal://') : null;
res.json({ url, webcal_url, token, created: false });
});
router.post('/:id/subscribe.ics', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const access = canAccessTrip(req.params.id, authReq.user.id);
if (!access) {
return res.status(404).json({ error: 'Trip not found' });
}
if (!checkPermission('share_manage', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id)) {
return res.status(403).json({ error: 'No permission' });
}
const result = createOrUpdateCalendarShareLink(req.params.id, authReq.user.id);
const host = req.get('host');
if (!host) return res.status(500).json({ error: 'Host header missing' });
const forwardedProto = typeof req.headers['x-forwarded-proto'] === 'string'
? req.headers['x-forwarded-proto'].split(',')[0].trim()
: null;
const protocol = forwardedProto || req.protocol;
const url = `${protocol}://${host}/api/shared/${encodeURIComponent(result.token)}/calendar.ics`;
const webcal_url = url.replace(/^https?:\/\//, 'webcal://');
res.status(result.created ? 201 : 200).json({ url, webcal_url, token: result.token, created: result.created });
});
router.delete('/:id/subscribe.ics', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const access = canAccessTrip(req.params.id, authReq.user.id);
if (!access) return res.status(404).json({ error: 'Trip not found' });
if (!checkPermission('share_manage', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id)) {
return res.status(403).json({ error: 'No permission' });
}
deleteCalendarShareLink(req.params.id);
res.json({ success: true });
});
export default router; export default router;
+57
View File
@@ -1,6 +1,7 @@
import { db, canAccessTrip } from '../db/database'; import { db, canAccessTrip } from '../db/database';
import crypto from 'crypto'; import crypto from 'crypto';
import { loadTagsByPlaceIds } from './queryHelpers'; import { loadTagsByPlaceIds } from './queryHelpers';
import { exportICS } from './tripService';
interface SharePermissions { interface SharePermissions {
share_map?: boolean; share_map?: boolean;
@@ -20,6 +21,11 @@ interface ShareTokenInfo {
share_collab: boolean; share_collab: boolean;
} }
interface CalendarShareTokenInfo {
token: string;
created_at: string;
}
/** /**
* Creates a new share link or updates the permissions on an existing one. * Creates a new share link or updates the permissions on an existing one.
* Returns an object with the token string and whether it was newly created. * Returns an object with the token string and whether it was newly created.
@@ -79,6 +85,57 @@ export function deleteShareLink(tripId: string): void {
db.prepare('DELETE FROM share_tokens WHERE trip_id = ?').run(tripId); db.prepare('DELETE FROM share_tokens WHERE trip_id = ?').run(tripId);
} }
/**
* Creates or returns a dedicated calendar subscription link for a trip.
*/
export function createOrUpdateCalendarShareLink(
tripId: string,
createdBy: number,
): { token: string; created: boolean } {
const existing = db.prepare('SELECT token FROM calendar_share_tokens WHERE trip_id = ?').get(tripId) as { token: string } | undefined;
if (existing) {
return { token: existing.token, created: false };
}
const token = crypto.randomBytes(24).toString('base64url');
db.prepare('INSERT INTO calendar_share_tokens (trip_id, token, created_by) VALUES (?, ?, ?)')
.run(tripId, token, createdBy);
return { token, created: true };
}
/**
* Returns the calendar subscription link for a trip, or null if none exists.
*/
export function getCalendarShareLink(tripId: string): CalendarShareTokenInfo | null {
const row = db.prepare('SELECT * FROM calendar_share_tokens WHERE trip_id = ?').get(tripId) as any;
if (!row) return null;
return {
token: row.token,
created_at: row.created_at,
};
}
/**
* Deletes the calendar subscription link for a trip.
*/
export function deleteCalendarShareLink(tripId: string): void {
db.prepare('DELETE FROM calendar_share_tokens WHERE trip_id = ?').run(tripId);
}
/**
* Resolves a shared token to ICS calendar content.
* Returns null when token or trip is invalid.
*/
export function getSharedTripICS(token: string): { ics: string; filename: string } | null {
const shareRow = db.prepare('SELECT trip_id FROM calendar_share_tokens WHERE token = ?').get(token) as { trip_id: number } | undefined;
if (!shareRow) return null;
try {
return exportICS(shareRow.trip_id);
} catch {
return null;
}
}
/** /**
* Loads the full public trip data for a share token, filtered by the token's * Loads the full public trip data for a share token, filtered by the token's
* permission flags. Returns null if the token is invalid or the trip is gone. * permission flags. Returns null if the token is invalid or the trip is gone.
+18
View File
@@ -204,6 +204,24 @@ describe('Shared trip access', () => {
.send({}); .send({});
expect(res.status).toBe(404); expect(res.status).toBe(404);
}); });
it('SHARE-009 — GET /shared/:token/calendar.ics returns public calendar payload', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Rome Calendar' });
const create = await request(app)
.post(`/api/trips/${trip.id}/subscribe.ics`)
.set('Host', 'trek.example.com')
.set('Cookie', authCookie(user.id))
.send({});
const token = create.body.token;
const res = await request(app).get(`/api/shared/${token}/calendar.ics`);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toMatch(/text\/calendar/);
expect(res.text).toContain('BEGIN:VCALENDAR');
expect(res.text).toContain('END:VCALENDAR');
});
}); });
describe('Shared trip — day assignments and notes', () => { describe('Shared trip — day assignments and notes', () => {
+98
View File
@@ -855,6 +855,104 @@ describe('ICS export', () => {
const res = await request(app).get(`/api/trips/${trip.id}/export.ics`); const res = await request(app).get(`/api/trips/${trip.id}/export.ics`);
expect(res.status).toBe(401); expect(res.status).toBe(401);
}); });
it('TRIP-025 — GET /api/trips/:id/subscribe.ics returns null before a calendar link exists', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Calendar Trip' });
const res = await request(app)
.get(`/api/trips/${trip.id}/subscribe.ics`)
.set('Host', 'trek.example.com')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.url).toBeNull();
expect(res.body.webcal_url).toBeNull();
expect(res.body.token).toBeNull();
});
it('TRIP-025 — POST /api/trips/:id/subscribe.ics creates shareable http+webcal links', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Calendar Trip' });
const res = await request(app)
.post(`/api/trips/${trip.id}/subscribe.ics`)
.set('Host', 'trek.example.com')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(201);
expect(res.body.url).toMatch(/^http:\/\/trek\.example\.com\/api\/shared\/.+\/calendar\.ics$/);
expect(res.body.webcal_url).toMatch(/^webcal:\/\/trek\.example\.com\/api\/shared\/.+\/calendar\.ics$/);
expect(typeof res.body.token).toBe('string');
});
it('TRIP-025 — DELETE /api/trips/:id/subscribe.ics removes calendar token', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Delete Calendar Token' });
await request(app)
.post(`/api/trips/${trip.id}/subscribe.ics`)
.set('Host', 'trek.example.com')
.set('Cookie', authCookie(user.id));
const del = await request(app)
.delete(`/api/trips/${trip.id}/subscribe.ics`)
.set('Cookie', authCookie(user.id));
expect(del.status).toBe(200);
expect(del.body.success).toBe(true);
const status = await request(app)
.get(`/api/trips/${trip.id}/subscribe.ics`)
.set('Host', 'trek.example.com')
.set('Cookie', authCookie(user.id));
expect(status.body.token).toBeNull();
});
it('TRIP-025 — non-member cannot get subscribe link → 404', async () => {
const { user: owner } = createUser(testDb);
const { user: stranger } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: 'Private Trip' });
const res = await request(app)
.get(`/api/trips/${trip.id}/subscribe.ics`)
.set('Cookie', authCookie(stranger.id));
expect(res.status).toBe(404);
});
it('TRIP-025 — member without share_manage cannot create subscribe link → 403', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: 'Shared Trip' });
addTripMember(testDb, trip.id, member.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/subscribe.ics`)
.set('Host', 'trek.example.com')
.set('Cookie', authCookie(member.id));
expect(res.status).toBe(403);
});
it('TRIP-025 — member without share_manage cannot delete subscribe link → 403', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: 'Shared Trip' });
addTripMember(testDb, trip.id, member.id);
await request(app)
.post(`/api/trips/${trip.id}/subscribe.ics`)
.set('Host', 'trek.example.com')
.set('Cookie', authCookie(owner.id));
const res = await request(app)
.delete(`/api/trips/${trip.id}/subscribe.ics`)
.set('Cookie', authCookie(member.id));
expect(res.status).toBe(403);
});
}); });
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────