mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
feat(feeds): subscribable ICS calendar feeds for trips
Adds TripIt-style live calendar subscriptions alongside the existing one-time .ics download. A trip (or all of a user's trips) exposes a secret, revocable feed URL that Google/Apple/Outlook poll to stay in sync. - Public read endpoints GET /api/feed/trip/:token.ics and /api/feed/user/:token.ics (no auth — the secret token is the credential), reusing the existing exportICS() generator and adding REFRESH-INTERVAL / X-PUBLISHED-TTL hints. - JWT-guarded token endpoints to generate (lazy, idempotent) and regenerate/revoke per-trip and per-user feed tokens; tokens stored in nullable feed_token columns. - All-trips feed excludes archived trips and trips ended >90 days ago. - UI: ICS toolbar button becomes a Download/Subscribe menu; modal offers one-click "Add to Google Calendar" (render?cid=webcal://) and a webcal:// link for Apple/Outlook, plus copy-link fallbacks. All-trips feed reachable from dashboard. - Feed base URL read from the existing APP_URL env var. Purely additive: new endpoints + two nullable columns, no breaking changes. Tests: server/tests/e2e/feeds.e2e.test.ts covers lazy token generate + idempotency, regenerate-invalidates-old, 401/404 auth+access, public feed content-type + hint injection, unknown-token 404, and the archived/>90-day all-trips exclusion. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
import { useState } from 'react'
|
||||
import { ChevronsDownUp, ChevronsUpDown, FileDown, Undo2, ArrowUpDown } from 'lucide-react'
|
||||
import React, { useState, useRef } from 'react'
|
||||
import { ChevronsDownUp, ChevronsUpDown, FileDown, Undo2, ArrowUpDown, CalendarPlus } from 'lucide-react'
|
||||
import { downloadTripPDF } from '../PDF/TripPDF'
|
||||
import { DayReorderPopup } from './DayReorderPopup'
|
||||
import Tooltip from '../shared/Tooltip'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { IcsSubscribeModal } from './IcsSubscribeModal'
|
||||
import type { Trip, Day, Place, Category, AssignmentsMap, Reservation, DayNote } from '../../types'
|
||||
|
||||
interface DayPlanSidebarToolbarProps {
|
||||
@@ -36,11 +37,35 @@ interface DayPlanSidebarToolbarProps {
|
||||
|
||||
export function DayPlanSidebarToolbar({
|
||||
tripId, trip, days, places, categories, assignments, reservations, dayNotes,
|
||||
t, locale, toast, pdfHover, setPdfHover, icsHover, setIcsHover,
|
||||
t, locale, toast, pdfHover, setPdfHover, setIcsHover,
|
||||
expandedDays, setExpandedDays, onUndo, canUndo, undoHover, setUndoHover, lastActionLabel,
|
||||
canEditDays, onReorderDays, onAddDay,
|
||||
}: DayPlanSidebarToolbarProps) {
|
||||
const [reorderOpen, setReorderOpen] = useState(false)
|
||||
const [subscribeOpen, setSubscribeOpen] = useState(false)
|
||||
const icsMenuTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const [icsMenuVisible, setIcsMenuVisible] = useState(false)
|
||||
|
||||
const showIcsMenu = () => {
|
||||
if (icsMenuTimeoutRef.current) clearTimeout(icsMenuTimeoutRef.current)
|
||||
setIcsMenuVisible(true)
|
||||
setIcsHover(true)
|
||||
}
|
||||
const hideIcsMenu = () => {
|
||||
icsMenuTimeoutRef.current = setTimeout(() => {
|
||||
setIcsMenuVisible(false)
|
||||
setIcsHover(false)
|
||||
}, 120)
|
||||
}
|
||||
|
||||
const menuItemStyle: React.CSSProperties = {
|
||||
display: 'flex', alignItems: 'center', gap: 7,
|
||||
width: '100%', padding: '7px 12px', border: 'none',
|
||||
background: 'transparent', cursor: 'pointer',
|
||||
fontSize: 11, fontWeight: 500, fontFamily: 'inherit',
|
||||
color: 'var(--text-primary)', textAlign: 'left',
|
||||
transition: 'background 0.1s',
|
||||
}
|
||||
return (
|
||||
<div className="border-b border-edge-faint" style={{ padding: '12px 16px', flexShrink: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 8 }}>
|
||||
@@ -83,25 +108,12 @@ export function DayPlanSidebarToolbar({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ position: 'relative', flexShrink: 0 }}>
|
||||
<div
|
||||
style={{ position: 'relative', flexShrink: 0 }}
|
||||
onMouseEnter={showIcsMenu}
|
||||
onMouseLeave={hideIcsMenu}
|
||||
>
|
||||
<button
|
||||
onClick={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)
|
||||
} catch { toast.error(t('planner.icsExportFailed')) }
|
||||
}}
|
||||
onMouseEnter={() => setIcsHover(true)}
|
||||
onMouseLeave={() => setIcsHover(false)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5,
|
||||
padding: '5px 10px', borderRadius: 8,
|
||||
@@ -113,19 +125,61 @@ export function DayPlanSidebarToolbar({
|
||||
<FileDown size={13} strokeWidth={2} />
|
||||
ICS
|
||||
</button>
|
||||
{icsHover && (
|
||||
<div style={{
|
||||
position: 'absolute', top: 'calc(100% + 6px)', right: 0,
|
||||
whiteSpace: 'nowrap', pointerEvents: 'none', zIndex: 200,
|
||||
background: 'var(--bg-card, white)', color: 'var(--text-primary, #111827)',
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500, padding: '5px 10px',
|
||||
borderRadius: 8, boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
border: '1px solid var(--border-faint, #e5e7eb)',
|
||||
}}>
|
||||
{t('dayplan.icsTooltip')}
|
||||
{icsMenuVisible && (
|
||||
<div
|
||||
onMouseEnter={showIcsMenu}
|
||||
onMouseLeave={hideIcsMenu}
|
||||
style={{
|
||||
position: 'absolute', top: 'calc(100% + 4px)', right: 0,
|
||||
zIndex: 200, minWidth: 160,
|
||||
background: 'var(--bg-card, white)',
|
||||
borderRadius: 8, boxShadow: '0 4px 16px rgba(0,0,0,0.15)',
|
||||
border: '1px solid var(--border-faint, #e5e7eb)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={async () => {
|
||||
setIcsMenuVisible(false)
|
||||
setIcsHover(false)
|
||||
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')) }
|
||||
}}
|
||||
style={menuItemStyle}
|
||||
onMouseEnter={e => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--bg-hover, #f3f4f6)' }}
|
||||
onMouseLeave={e => { (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
|
||||
>
|
||||
<FileDown size={12} strokeWidth={2} />
|
||||
Download ICS
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIcsMenuVisible(false)
|
||||
setIcsHover(false)
|
||||
setSubscribeOpen(true)
|
||||
}}
|
||||
style={menuItemStyle}
|
||||
onMouseEnter={e => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--bg-hover, #f3f4f6)' }}
|
||||
onMouseLeave={e => { (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
|
||||
>
|
||||
<CalendarPlus size={12} strokeWidth={2} />
|
||||
Subscribe to calendar
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{subscribeOpen && (
|
||||
<IcsSubscribeModal tripId={tripId} onClose={() => setSubscribeOpen(false)} />
|
||||
)}
|
||||
{(() => {
|
||||
const allExpanded = days.length > 0 && days.every(d => expandedDays.has(d.id))
|
||||
const label = allExpanded ? t('dayplan.collapseAll') : t('dayplan.expandAll')
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X, RefreshCw, Calendar } from 'lucide-react'
|
||||
import { SubscribeLinks } from './SubscribeLinks'
|
||||
|
||||
interface IcsSubscribeModalProps {
|
||||
tripId: number
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function IcsSubscribeModal({ tripId, onClose }: IcsSubscribeModalProps) {
|
||||
const [feedUrl, setFeedUrl] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [regenerating, setRegenerating] = useState(false)
|
||||
|
||||
const httpsUrl = feedUrl ?? ''
|
||||
const webcalUrl = feedUrl ? feedUrl.replace(/^https?:\/\//, 'webcal://') : ''
|
||||
|
||||
const loadToken = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
// Try to get existing token first
|
||||
let res = await fetch(`/api/trips/${tripId}/feed/token`, { credentials: 'include' })
|
||||
if (!res.ok) { setLoading(false); return }
|
||||
const data = await res.json() as { feed_url: string | null }
|
||||
if (data.feed_url) {
|
||||
setFeedUrl(data.feed_url)
|
||||
} else {
|
||||
// Lazily generate on first open
|
||||
res = await fetch(`/api/trips/${tripId}/feed/token`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
})
|
||||
if (res.ok) {
|
||||
const gen = await res.json() as { feed_url: string }
|
||||
setFeedUrl(gen.feed_url)
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false)
|
||||
}, [tripId])
|
||||
|
||||
useEffect(() => { loadToken() }, [loadToken])
|
||||
|
||||
const regenerate = async () => {
|
||||
setRegenerating(true)
|
||||
try {
|
||||
const res = await fetch(`/api/trips/${tripId}/feed/token`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json() as { feed_url: string }
|
||||
setFeedUrl(data.feed_url)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
setRegenerating(false)
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed', inset: 0, zIndex: 9999,
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '16px',
|
||||
}}
|
||||
onClick={e => { if (e.target === e.currentTarget) onClose() }}
|
||||
>
|
||||
<div style={{
|
||||
background: 'var(--bg-card, white)',
|
||||
borderRadius: 14,
|
||||
padding: '22px 24px',
|
||||
width: '100%',
|
||||
maxWidth: 420,
|
||||
boxShadow: '0 16px 48px rgba(0,0,0,0.22)',
|
||||
border: '1px solid var(--border-faint)',
|
||||
color: 'var(--text-primary)',
|
||||
fontFamily: 'inherit',
|
||||
position: 'relative',
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Calendar size={16} strokeWidth={2} style={{ color: 'var(--accent, #6366f1)' }} />
|
||||
<span style={{ fontWeight: 600, fontSize: 14 }}>Subscribe to Calendar</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer', padding: 4,
|
||||
color: 'var(--text-muted)', borderRadius: 6, display: 'flex',
|
||||
}}
|
||||
>
|
||||
<X size={15} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 16, lineHeight: 1.5 }}>
|
||||
This link stays in sync with your trip automatically. Calendar apps re-fetch it every hour.
|
||||
</p>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '16px 0', color: 'var(--text-muted)', fontSize: 12 }}>
|
||||
Generating link…
|
||||
</div>
|
||||
) : !feedUrl ? (
|
||||
<div style={{ textAlign: 'center', padding: '16px 0', color: 'var(--text-muted)', fontSize: 12 }}>
|
||||
Could not generate feed link.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<SubscribeLinks httpsUrl={httpsUrl} webcalUrl={webcalUrl} />
|
||||
|
||||
<div style={{ marginTop: 16, paddingTop: 12, borderTop: '1px solid var(--border-faint)' }}>
|
||||
<button
|
||||
onClick={regenerate}
|
||||
disabled={regenerating}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
background: 'none', border: '1px solid var(--border-primary)',
|
||||
borderRadius: 7, padding: '5px 10px',
|
||||
fontSize: 11, color: 'var(--text-muted)',
|
||||
cursor: regenerating ? 'default' : 'pointer',
|
||||
fontFamily: 'inherit', opacity: regenerating ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={11} strokeWidth={2} style={{ animation: regenerating ? 'spin 0.8s linear infinite' : 'none' }} />
|
||||
Regenerate link
|
||||
</button>
|
||||
<p style={{ fontSize: 10, color: 'var(--text-muted)', marginTop: 6, lineHeight: 1.4 }}>
|
||||
Regenerating creates a new link and invalidates the old one.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useState } from 'react'
|
||||
import { Copy, Check, CalendarPlus, Calendar } from 'lucide-react'
|
||||
|
||||
interface SubscribeLinksProps {
|
||||
httpsUrl: string
|
||||
webcalUrl: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared presentation for calendar subscription URLs. Renders one-click
|
||||
* subscribe actions (Google deep link + webcal handoff) plus a copy fallback.
|
||||
* Used by both the per-trip and all-trips subscribe modals.
|
||||
*/
|
||||
export function SubscribeLinks({ httpsUrl, webcalUrl }: SubscribeLinksProps) {
|
||||
const [copied, setCopied] = useState<'https' | 'webcal' | null>(null)
|
||||
|
||||
// Google Calendar's add-by-URL deep link. The cid must carry the webcal://
|
||||
// scheme (not https) and the feed must be served over HTTPS.
|
||||
const googleUrl = `https://www.google.com/calendar/render?cid=${webcalUrl}`
|
||||
|
||||
const copy = async (url: string, which: 'https' | 'webcal') => {
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(url)
|
||||
} else {
|
||||
// Fallback for non-secure contexts (plain HTTP) where navigator.clipboard is unavailable
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = url
|
||||
ta.style.position = 'fixed'
|
||||
ta.style.left = '-9999px'
|
||||
document.body.appendChild(ta)
|
||||
ta.focus()
|
||||
ta.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(ta)
|
||||
}
|
||||
setCopied(which)
|
||||
setTimeout(() => setCopied(null), 2000)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* One-click subscribe actions */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 16 }}>
|
||||
<a
|
||||
href={googleUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||||
padding: '9px 14px', borderRadius: 9, textDecoration: 'none',
|
||||
background: 'var(--accent, #6366f1)', color: 'var(--accent-text, #fff)',
|
||||
fontSize: 12, fontWeight: 600, fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<CalendarPlus size={14} strokeWidth={2} />
|
||||
Add to Google Calendar
|
||||
</a>
|
||||
<a
|
||||
href={webcalUrl}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||||
padding: '9px 14px', borderRadius: 9, textDecoration: 'none',
|
||||
background: 'none', border: '1px solid var(--border-primary)',
|
||||
color: 'var(--text-primary)', fontSize: 12, fontWeight: 600, fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<Calendar size={14} strokeWidth={2} />
|
||||
Add to Apple Calendar / Outlook
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Manual fallback — raw URLs for any other client / "From URL" boxes */}
|
||||
<details style={{ fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
<summary style={{ cursor: 'pointer', userSelect: 'none', marginBottom: 8 }}>
|
||||
Or copy a link manually
|
||||
</summary>
|
||||
<UrlRow
|
||||
label="Google Calendar"
|
||||
hint="paste into “From URL”"
|
||||
url={httpsUrl}
|
||||
copied={copied === 'https'}
|
||||
onCopy={() => copy(httpsUrl, 'https')}
|
||||
/>
|
||||
<UrlRow
|
||||
label="Apple Calendar / Outlook"
|
||||
hint="webcal://"
|
||||
url={webcalUrl}
|
||||
copied={copied === 'webcal'}
|
||||
onCopy={() => copy(webcalUrl, 'webcal')}
|
||||
/>
|
||||
</details>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function UrlRow({ label, hint, url, copied, onCopy }: {
|
||||
label: string; hint: string; url: string; copied: boolean; onCopy: () => void
|
||||
}) {
|
||||
return (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 500, marginBottom: 5, color: 'var(--text-primary)' }}>
|
||||
{label} <span style={{ color: 'var(--text-muted)', fontWeight: 400 }}>— {hint}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<div style={{
|
||||
flex: 1, fontSize: 10, fontFamily: 'monospace',
|
||||
padding: '5px 8px', borderRadius: 6,
|
||||
border: '1px solid var(--border-faint)',
|
||||
background: 'var(--bg-subtle, #f9fafb)',
|
||||
color: 'var(--text-muted)',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{url}
|
||||
</div>
|
||||
<button
|
||||
onClick={onCopy}
|
||||
title="Copy"
|
||||
style={{
|
||||
flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 28, height: 28, borderRadius: 6,
|
||||
border: '1px solid var(--border-primary)', background: 'none',
|
||||
cursor: 'pointer', color: copied ? 'var(--accent, #6366f1)' : 'var(--text-muted)',
|
||||
transition: 'color 0.15s',
|
||||
}}
|
||||
>
|
||||
{copied ? <Check size={12} strokeWidth={2.5} /> : <Copy size={12} strokeWidth={2} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import React, { useEffect, useState, useCallback } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useTranslation } from '../i18n'
|
||||
import Navbar from '../components/Layout/Navbar'
|
||||
import DemoBanner from '../components/Layout/DemoBanner'
|
||||
@@ -16,8 +17,9 @@ import {
|
||||
import {
|
||||
Plus, Edit2, Trash2, Archive, Copy, ArrowRight, MapPin,
|
||||
Plane, Hotel, Utensils, Clock, RefreshCw, ArrowRightLeft, Calendar,
|
||||
LayoutGrid, List, Ticket, X,
|
||||
LayoutGrid, List, Ticket, X, CalendarPlus,
|
||||
} from 'lucide-react'
|
||||
import { SubscribeLinks } from '../components/Planner/SubscribeLinks'
|
||||
import { formatTime, splitReservationDateTime } from '../utils/formatters'
|
||||
import { convertDistance, getDistanceUnitLabel } from '../utils/units'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
@@ -91,6 +93,7 @@ export default function DashboardPage(): React.ReactElement {
|
||||
showForm, setShowForm, editingTrip, setEditingTrip,
|
||||
deleteTrip, setDeleteTrip, copyTrip, setCopyTrip, setTrips,
|
||||
handleCreate, handleUpdate, confirmDelete, handleArchive, handleUnarchive, confirmCopy,
|
||||
allSubOpen, setAllSubOpen,
|
||||
} = useDashboard()
|
||||
|
||||
// Per-device dashboard widget visibility (from the appearance config).
|
||||
@@ -149,11 +152,21 @@ export default function DashboardPage(): React.ReactElement {
|
||||
<button className={tripFilter === 'archive' ? 'on' : ''} onClick={() => setTripFilter('archive')}>{t('dashboard.archived')}</button>
|
||||
<button className={tripFilter === 'completed' ? 'on' : ''} onClick={() => setTripFilter('completed')}>{t('dashboard.mobile.completed')}</button>
|
||||
</div>
|
||||
<button
|
||||
className="tool-action"
|
||||
aria-label="Subscribe to all trips calendar"
|
||||
title="Subscribe to all trips"
|
||||
onClick={() => setAllSubOpen(true)}
|
||||
style={{ width: 38, height: 38, borderRadius: 11 }}
|
||||
>
|
||||
<CalendarPlus size={17} />
|
||||
</button>
|
||||
<button className="tool-action" aria-label={t('dashboard.aria.toggleView')} onClick={toggleViewMode} style={{ width: 38, height: 38, borderRadius: 11 }}>
|
||||
{viewMode === 'grid' ? <List size={17} /> : <LayoutGrid size={17} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{allSubOpen && <AllTripsSubscribeModal onClose={() => setAllSubOpen(false)} />}
|
||||
|
||||
{gridTrips.length === 0 && tripFilter === 'planned' && !isLoading && !loadError && (
|
||||
<div className="trips-empty">
|
||||
@@ -742,3 +755,101 @@ function UpcomingTool({ items, locale, onOpen }: {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── All-trips subscribe modal ────────────────────────────────────────────────
|
||||
function AllTripsSubscribeModal({ onClose }: { onClose: () => void }) {
|
||||
const [feedUrl, setFeedUrl] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [regenerating, setRegenerating] = useState(false)
|
||||
|
||||
const httpsUrl = feedUrl ?? ''
|
||||
const webcalUrl = feedUrl ? feedUrl.replace(/^https?:\/\//, 'webcal://') : ''
|
||||
|
||||
const loadToken = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
let res = await fetch('/api/feed/user/token', { credentials: 'include' })
|
||||
if (!res.ok) { setLoading(false); return }
|
||||
const data = await res.json() as { feed_url: string | null }
|
||||
if (data.feed_url) {
|
||||
setFeedUrl(data.feed_url)
|
||||
} else {
|
||||
res = await fetch('/api/feed/user/token', { method: 'POST', credentials: 'include' })
|
||||
if (res.ok) {
|
||||
const gen = await res.json() as { feed_url: string }
|
||||
setFeedUrl(gen.feed_url)
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => { loadToken() }, [loadToken])
|
||||
|
||||
const regenerate = async () => {
|
||||
setRegenerating(true)
|
||||
try {
|
||||
const res = await fetch('/api/feed/user/token', { method: 'DELETE', credentials: 'include' })
|
||||
if (res.ok) {
|
||||
const data = await res.json() as { feed_url: string }
|
||||
setFeedUrl(data.feed_url)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
setRegenerating(false)
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed', inset: 0, zIndex: 9999,
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '16px',
|
||||
}}
|
||||
onClick={e => { if (e.target === e.currentTarget) onClose() }}
|
||||
>
|
||||
<div style={{
|
||||
background: 'var(--bg-card, white)',
|
||||
borderRadius: 14, padding: '22px 24px',
|
||||
width: '100%', maxWidth: 420,
|
||||
boxShadow: '0 16px 48px rgba(0,0,0,0.22)',
|
||||
border: '1px solid var(--border-faint)',
|
||||
color: 'var(--text-primary)', fontFamily: 'inherit',
|
||||
position: 'relative',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<CalendarPlus size={16} strokeWidth={2} style={{ color: 'var(--accent, #6366f1)' }} />
|
||||
<span style={{ fontWeight: 600, fontSize: 14 }}>Subscribe to All Trips</span>
|
||||
</div>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4, color: 'var(--text-muted)', borderRadius: 6, display: 'flex' }}>
|
||||
<X size={15} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 16, lineHeight: 1.5 }}>
|
||||
Subscribe to all your active trips in one calendar feed. Updates automatically. Excludes archived trips and trips that ended more than 90 days ago.
|
||||
</p>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '16px 0', color: 'var(--text-muted)', fontSize: 12 }}>Generating link…</div>
|
||||
) : !feedUrl ? (
|
||||
<div style={{ textAlign: 'center', padding: '16px 0', color: 'var(--text-muted)', fontSize: 12 }}>Could not generate feed link.</div>
|
||||
) : (
|
||||
<>
|
||||
<SubscribeLinks httpsUrl={httpsUrl} webcalUrl={webcalUrl} />
|
||||
<div style={{ marginTop: 16, paddingTop: 12, borderTop: '1px solid var(--border-faint)' }}>
|
||||
<button onClick={regenerate} disabled={regenerating} style={{ display: 'flex', alignItems: 'center', gap: 6, background: 'none', border: '1px solid var(--border-primary)', borderRadius: 7, padding: '5px 10px', fontSize: 11, color: 'var(--text-muted)', cursor: regenerating ? 'default' : 'pointer', fontFamily: 'inherit', opacity: regenerating ? 0.6 : 1 }}>
|
||||
<RefreshCw size={11} strokeWidth={2} style={{ animation: regenerating ? 'spin 0.8s linear infinite' : 'none' }} />
|
||||
Regenerate link
|
||||
</button>
|
||||
<p style={{ fontSize: 10, color: 'var(--text-muted)', marginTop: 6, lineHeight: 1.4 }}>Regenerating creates a new link and invalidates the old one.</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ export function useDashboard() {
|
||||
const [deleteTrip, setDeleteTrip] = useState<DashboardTrip | null>(null)
|
||||
const [copyTrip, setCopyTrip] = useState<DashboardTrip | null>(null)
|
||||
const [tripFilter, setTripFilter] = useState<'planned' | 'archive' | 'completed'>('planned')
|
||||
const [allSubOpen, setAllSubOpen] = useState<boolean>(false)
|
||||
const [loadError, setLoadError] = useState<boolean>(false)
|
||||
|
||||
const [stats, setStats] = useState<TravelStats | null>(null)
|
||||
@@ -192,6 +193,7 @@ export function useDashboard() {
|
||||
tripFilter, setTripFilter, viewMode, toggleViewMode,
|
||||
showForm, setShowForm, editingTrip, setEditingTrip,
|
||||
deleteTrip, setDeleteTrip, copyTrip, setCopyTrip, setTrips,
|
||||
allSubOpen, setAllSubOpen,
|
||||
// actions
|
||||
handleCreate, handleUpdate, confirmDelete, handleArchive, handleUnarchive, confirmCopy,
|
||||
}
|
||||
|
||||
@@ -3078,6 +3078,21 @@ function runMigrations(db: Database.Database): void {
|
||||
if (!err.message?.includes('duplicate column name')) throw err;
|
||||
}
|
||||
},
|
||||
// Calendar feed tokens — subscribable ICS links for per-trip and all-trips feeds
|
||||
() => {
|
||||
try {
|
||||
db.exec('ALTER TABLE trips ADD COLUMN feed_token TEXT');
|
||||
} catch (err: any) {
|
||||
if (!err.message?.includes('duplicate column name')) throw err;
|
||||
}
|
||||
try {
|
||||
db.exec('ALTER TABLE users ADD COLUMN feed_token TEXT');
|
||||
} catch (err: any) {
|
||||
if (!err.message?.includes('duplicate column name')) throw err;
|
||||
}
|
||||
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_trips_feed_token ON trips(feed_token) WHERE feed_token IS NOT NULL');
|
||||
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_users_feed_token ON users(feed_token) WHERE feed_token IS NOT NULL');
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
@@ -29,6 +29,7 @@ import { MemoriesModule } from './memories/memories.module';
|
||||
import { AirtrailModule } from './integrations/airtrail.module';
|
||||
import { JourneyModule } from './journey/journey.module';
|
||||
import { ShareModule } from './share/share.module';
|
||||
import { FeedsModule } from './feeds/feeds.module';
|
||||
import { SettingsModule } from './settings/settings.module';
|
||||
import { BackupModule } from './backup/backup.module';
|
||||
import { BookingImportModule } from './booking-import/booking-import.module';
|
||||
@@ -47,7 +48,7 @@ import { IdempotencyInterceptor } from './common/idempotency.interceptor';
|
||||
* migrated.
|
||||
*/
|
||||
@Module({
|
||||
imports: [DatabaseModule, WeatherModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, MapsModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, ShareModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, BookingImportModule],
|
||||
imports: [DatabaseModule, WeatherModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, MapsModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, ShareModule, FeedsModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, BookingImportModule],
|
||||
controllers: [HealthController],
|
||||
providers: [
|
||||
HealthService,
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import {
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpException,
|
||||
Param,
|
||||
Post,
|
||||
Res,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import { FeedsService } from './feeds.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import type { User } from '../../types';
|
||||
import { db } from '../../db/database';
|
||||
|
||||
/**
|
||||
* Public subscribable ICS feed endpoints — no auth required.
|
||||
* The secret token in the URL acts as the access credential.
|
||||
*/
|
||||
@Controller('api/feed')
|
||||
export class FeedsPublicController {
|
||||
constructor(private readonly feeds: FeedsService) {}
|
||||
|
||||
@Get('trip/:token.ics')
|
||||
tripFeed(@Param('token') token: string, @Res() res: Response): void {
|
||||
const result = this.feeds.buildTripIcs(token);
|
||||
if (!result) {
|
||||
res.status(404).json({ error: 'Feed not found' });
|
||||
return;
|
||||
}
|
||||
res.setHeader('Content-Type', 'text/calendar; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', `inline; filename="${result.filename}"`);
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store');
|
||||
res.setHeader('X-Published-TTL', 'PT1H');
|
||||
res.send(result.ics);
|
||||
}
|
||||
|
||||
@Get('user/:token.ics')
|
||||
userFeed(@Param('token') token: string, @Res() res: Response): void {
|
||||
const result = this.feeds.buildUserIcs(token);
|
||||
if (!result) {
|
||||
res.status(404).json({ error: 'Feed not found' });
|
||||
return;
|
||||
}
|
||||
res.setHeader('Content-Type', 'text/calendar; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', `inline; filename="all-trips.ics"`);
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store');
|
||||
res.setHeader('X-Published-TTL', 'PT1H');
|
||||
res.send(result.ics);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticated token management — generate / regenerate feed tokens.
|
||||
*/
|
||||
@Controller('api/trips/:tripId/feed')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class TripFeedTokenController {
|
||||
constructor(private readonly feeds: FeedsService) {}
|
||||
|
||||
@Get('token')
|
||||
get(@CurrentUser() user: User, @Param('tripId') tripId: string) {
|
||||
const result = this.feeds.getTripToken(tripId, user.id);
|
||||
return result ?? { feed_url: null };
|
||||
}
|
||||
|
||||
@Post('token')
|
||||
generate(@CurrentUser() user: User, @Param('tripId') tripId: string) {
|
||||
const row = db
|
||||
.prepare('SELECT id FROM trips WHERE id = ? AND (user_id = ? OR id IN (SELECT trip_id FROM trip_members WHERE user_id = ?))')
|
||||
.get(tripId, user.id, user.id);
|
||||
if (!row) throw new HttpException({ error: 'Trip not found' }, 404);
|
||||
return this.feeds.generateTripToken(tripId, user.id);
|
||||
}
|
||||
|
||||
@Delete('token')
|
||||
regenerate(@CurrentUser() user: User, @Param('tripId') tripId: string) {
|
||||
const row = db
|
||||
.prepare('SELECT id FROM trips WHERE id = ? AND (user_id = ? OR id IN (SELECT trip_id FROM trip_members WHERE user_id = ?))')
|
||||
.get(tripId, user.id, user.id);
|
||||
if (!row) throw new HttpException({ error: 'Trip not found' }, 404);
|
||||
return this.feeds.regenerateTripToken(tripId, user.id);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('api/feed/user')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class UserFeedTokenController {
|
||||
constructor(private readonly feeds: FeedsService) {}
|
||||
|
||||
@Get('token')
|
||||
get(@CurrentUser() user: User) {
|
||||
return this.feeds.getUserToken(user.id) ?? { feed_url: null };
|
||||
}
|
||||
|
||||
@Post('token')
|
||||
generate(@CurrentUser() user: User) {
|
||||
return this.feeds.generateUserToken(user.id);
|
||||
}
|
||||
|
||||
@Delete('token')
|
||||
regenerate(@CurrentUser() user: User) {
|
||||
return this.feeds.regenerateUserToken(user.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { FeedsService } from './feeds.service';
|
||||
import { FeedsPublicController, TripFeedTokenController, UserFeedTokenController } from './feeds.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [FeedsPublicController, TripFeedTokenController, UserFeedTokenController],
|
||||
providers: [FeedsService],
|
||||
})
|
||||
export class FeedsModule {}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { db } from '../../db/database';
|
||||
import { exportICS } from '../../services/tripService';
|
||||
|
||||
const ninetyDaysAgo = () => {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - 90);
|
||||
return d.toISOString().slice(0, 10);
|
||||
};
|
||||
|
||||
function feedUrl(token: string, scope: 'trip' | 'user'): string {
|
||||
const base = (process.env.APP_URL || '').replace(/\/$/, '');
|
||||
return `${base}/api/feed/${scope}/${token}.ics`;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class FeedsService {
|
||||
// ── Trip feed token ─────────────────────────────────────────────────────
|
||||
|
||||
getTripToken(tripId: string, userId: number): { feed_url: string } | null {
|
||||
const row = db
|
||||
.prepare('SELECT feed_token FROM trips WHERE id = ? AND (user_id = ? OR id IN (SELECT trip_id FROM trip_members WHERE user_id = ?))')
|
||||
.get(tripId, userId, userId) as { feed_token: string | null } | undefined;
|
||||
if (!row || !row.feed_token) return null;
|
||||
return { feed_url: feedUrl(row.feed_token, 'trip') };
|
||||
}
|
||||
|
||||
generateTripToken(tripId: string, userId: number): { feed_url: string } {
|
||||
const existing = this.getTripToken(tripId, userId);
|
||||
if (existing) return existing;
|
||||
const token = randomUUID();
|
||||
db.prepare('UPDATE trips SET feed_token = ? WHERE id = ?').run(token, tripId);
|
||||
return { feed_url: feedUrl(token, 'trip') };
|
||||
}
|
||||
|
||||
regenerateTripToken(tripId: string, userId: number): { feed_url: string } {
|
||||
const trip = db
|
||||
.prepare('SELECT id FROM trips WHERE id = ? AND (user_id = ? OR id IN (SELECT trip_id FROM trip_members WHERE user_id = ?))')
|
||||
.get(tripId, userId, userId);
|
||||
if (!trip) return { feed_url: '' };
|
||||
const token = randomUUID();
|
||||
db.prepare('UPDATE trips SET feed_token = ? WHERE id = ?').run(token, tripId);
|
||||
return { feed_url: feedUrl(token, 'trip') };
|
||||
}
|
||||
|
||||
// ── User (all-trips) feed token ──────────────────────────────────────────
|
||||
|
||||
getUserToken(userId: number): { feed_url: string } | null {
|
||||
const row = db.prepare('SELECT feed_token FROM users WHERE id = ?').get(userId) as
|
||||
| { feed_token: string | null }
|
||||
| undefined;
|
||||
if (!row || !row.feed_token) return null;
|
||||
return { feed_url: feedUrl(row.feed_token, 'user') };
|
||||
}
|
||||
|
||||
generateUserToken(userId: number): { feed_url: string } {
|
||||
const existing = this.getUserToken(userId);
|
||||
if (existing) return existing;
|
||||
const token = randomUUID();
|
||||
db.prepare('UPDATE users SET feed_token = ? WHERE id = ?').run(token, userId);
|
||||
return { feed_url: feedUrl(token, 'user') };
|
||||
}
|
||||
|
||||
regenerateUserToken(userId: number): { feed_url: string } {
|
||||
const token = randomUUID();
|
||||
db.prepare('UPDATE users SET feed_token = ? WHERE id = ?').run(token, userId);
|
||||
return { feed_url: feedUrl(token, 'user') };
|
||||
}
|
||||
|
||||
// ── ICS generation ───────────────────────────────────────────────────────
|
||||
|
||||
buildTripIcs(token: string): { ics: string; filename: string } | null {
|
||||
const row = db.prepare('SELECT id FROM trips WHERE feed_token = ?').get(token) as
|
||||
| { id: number }
|
||||
| undefined;
|
||||
if (!row) return null;
|
||||
try {
|
||||
const { ics, filename } = exportICS(row.id);
|
||||
// Inject calendar-subscription refresh hints into the VCALENDAR header so
|
||||
// clients re-fetch hourly. The one-time download path (exportICS) is left
|
||||
// untouched; this is feed-only.
|
||||
const withHints = ics.replace(
|
||||
'METHOD:PUBLISH\r\n',
|
||||
'METHOD:PUBLISH\r\nREFRESH-INTERVAL;VALUE=DURATION:PT1H\r\nX-PUBLISHED-TTL:PT1H\r\n',
|
||||
);
|
||||
return { ics: withHints, filename };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
buildUserIcs(token: string): { ics: string; calName: string } | null {
|
||||
const user = db.prepare('SELECT id, username FROM users WHERE feed_token = ?').get(token) as
|
||||
| { id: number; username: string }
|
||||
| undefined;
|
||||
if (!user) return null;
|
||||
|
||||
const cutoff = ninetyDaysAgo();
|
||||
const trips = db
|
||||
.prepare(
|
||||
`SELECT id FROM trips
|
||||
WHERE user_id = ?
|
||||
AND is_archived = 0
|
||||
AND (end_date IS NULL OR end_date >= ?)
|
||||
ORDER BY start_date ASC`,
|
||||
)
|
||||
.all(user.id, cutoff) as { id: number }[];
|
||||
|
||||
const esc = (s: string) =>
|
||||
s.replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\r?\n/g, '\\n');
|
||||
|
||||
let combined =
|
||||
'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//TREK//Travel Planner//EN\r\nCALSCALE:GREGORIAN\r\nMETHOD:PUBLISH\r\n';
|
||||
combined += `X-WR-CALNAME:${esc(user.username + ' – All Trips')}\r\n`;
|
||||
combined += 'REFRESH-INTERVAL;VALUE=DURATION:PT1H\r\nX-PUBLISHED-TTL:PT1H\r\n';
|
||||
|
||||
for (const { id } of trips) {
|
||||
try {
|
||||
const { ics } = exportICS(id);
|
||||
// Strip outer VCALENDAR wrapper and extract VEVENT blocks
|
||||
const events = [...ics.matchAll(/BEGIN:VEVENT[\s\S]*?END:VEVENT/g)].map((m) => m[0]);
|
||||
for (const ev of events) combined += ev + '\r\n';
|
||||
} catch {
|
||||
// skip failed trips
|
||||
}
|
||||
}
|
||||
|
||||
combined += 'END:VCALENDAR\r\n';
|
||||
return { ics: combined, calName: user.username + ' – All Trips' };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Calendar-feed e2e — exercises the subscribable ICS feeds end-to-end against a
|
||||
* temp in-memory SQLite db through the REAL JwtAuthGuard:
|
||||
* - JWT-guarded token endpoints (/api/trips/:id/feed/token, /api/feed/user/token):
|
||||
* lazy generate, idempotency, regenerate-invalidates-old, 404 on no access, 401 no cookie
|
||||
* - public unguarded feeds (/api/feed/trip/:token.ics, /api/feed/user/:token.ics):
|
||||
* valid token → 200 text/calendar with the injected REFRESH-INTERVAL / X-PUBLISHED-TTL
|
||||
* hints, unknown token → 404, all-trips feed excludes archived + >90-day-old trips
|
||||
*
|
||||
* exportICS is mocked so the test owns the ICS payload and can assert which trips
|
||||
* the all-trips feed pulled in without seeding the full trip/day/reservation schema.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import type { Server } from 'http';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { seedUser, sessionCookie } from './harness';
|
||||
|
||||
const { db } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const Database = require('better-sqlite3');
|
||||
const tmp = new Database(':memory:');
|
||||
tmp.exec('PRAGMA journal_mode = WAL');
|
||||
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user',
|
||||
password_version INTEGER NOT NULL DEFAULT 0, feed_token TEXT);`);
|
||||
tmp.exec(`CREATE TABLE trips (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL,
|
||||
title TEXT, is_archived INTEGER NOT NULL DEFAULT 0, start_date TEXT, end_date TEXT, feed_token TEXT);`);
|
||||
tmp.exec(`CREATE TABLE trip_members (trip_id INTEGER NOT NULL, user_id INTEGER NOT NULL);`);
|
||||
return { db: tmp };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} }));
|
||||
|
||||
// Own the ICS payload so we control the events and can assert which trips were pulled.
|
||||
const SAMPLE_ICS =
|
||||
'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//TREK//Travel Planner//EN\r\nCALSCALE:GREGORIAN\r\n' +
|
||||
'METHOD:PUBLISH\r\nX-WR-CALNAME:Sample\r\n' +
|
||||
'BEGIN:VEVENT\r\nUID:trek-trip-x@trek\r\nDTSTAMP:20260101T000000Z\r\n' +
|
||||
'DTSTART;VALUE=DATE:20260101\r\nDTEND;VALUE=DATE:20260102\r\nSUMMARY:Sample\r\nEND:VEVENT\r\n' +
|
||||
'END:VCALENDAR\r\n';
|
||||
const { exportICS } = vi.hoisted(() => ({ exportICS: vi.fn() }));
|
||||
vi.mock('../../src/services/tripService', () => ({ exportICS }));
|
||||
|
||||
import { FeedsModule } from '../../src/nest/feeds/feeds.module';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
|
||||
const BASE = 'https://trek.example.test';
|
||||
|
||||
describe('Calendar-feed e2e (real auth guard + temp SQLite)', () => {
|
||||
let server: Server;
|
||||
let app: Awaited<ReturnType<typeof build>>;
|
||||
let prevAppUrl: string | undefined;
|
||||
|
||||
async function build() {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [FeedsModule] }).compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
await nest.init();
|
||||
return nest;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
prevAppUrl = process.env.APP_URL;
|
||||
process.env.APP_URL = BASE;
|
||||
seedUser(db as never, { id: 1, username: 'e2e-user' });
|
||||
seedUser(db as never, { id: 2, username: 'other-user', email: 'other@example.test' });
|
||||
app = await build();
|
||||
server = app.getHttpServer();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
exportICS.mockReset();
|
||||
exportICS.mockReturnValue({ ics: SAMPLE_ICS, filename: 'sample.ics' });
|
||||
// Reset feed tokens + trips between tests for isolation.
|
||||
db.exec('DELETE FROM trips; DELETE FROM trip_members; UPDATE users SET feed_token = NULL;');
|
||||
db.prepare("INSERT INTO trips (id, user_id, title, is_archived, start_date, end_date) VALUES (5, 1, 'Owned', 0, '2026-01-01', '2099-01-01')").run();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (prevAppUrl === undefined) delete process.env.APP_URL;
|
||||
else process.env.APP_URL = prevAppUrl;
|
||||
await app.close();
|
||||
});
|
||||
|
||||
// ── Trip token endpoints ───────────────────────────────────────────────────
|
||||
|
||||
it('401 without a session cookie', async () => {
|
||||
expect((await request(server).get('/api/trips/5/feed/token')).status).toBe(401);
|
||||
});
|
||||
|
||||
it('GET token returns {feed_url:null} before one is generated', async () => {
|
||||
const res = await request(server).get('/api/trips/5/feed/token').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ feed_url: null });
|
||||
});
|
||||
|
||||
it('POST generates a token lazily and is idempotent', async () => {
|
||||
const first = await request(server).post('/api/trips/5/feed/token').set('Cookie', sessionCookie(1));
|
||||
expect(first.status).toBe(201);
|
||||
expect(first.body.feed_url).toMatch(new RegExp(`^${BASE}/api/feed/trip/[0-9a-f-]+\\.ics$`));
|
||||
|
||||
const second = await request(server).post('/api/trips/5/feed/token').set('Cookie', sessionCookie(1));
|
||||
expect(second.body.feed_url).toBe(first.body.feed_url); // same token, not a new one
|
||||
});
|
||||
|
||||
it('DELETE regenerates: a new token works and the old one 404s', async () => {
|
||||
const gen = await request(server).post('/api/trips/5/feed/token').set('Cookie', sessionCookie(1));
|
||||
const oldToken = gen.body.feed_url.match(/trip\/([0-9a-f-]+)\.ics$/)![1];
|
||||
|
||||
const regen = await request(server).delete('/api/trips/5/feed/token').set('Cookie', sessionCookie(1));
|
||||
const newToken = regen.body.feed_url.match(/trip\/([0-9a-f-]+)\.ics$/)![1];
|
||||
expect(newToken).not.toBe(oldToken);
|
||||
|
||||
expect((await request(server).get(`/api/feed/trip/${oldToken}.ics`)).status).toBe(404);
|
||||
expect((await request(server).get(`/api/feed/trip/${newToken}.ics`)).status).toBe(200);
|
||||
});
|
||||
|
||||
it('404 when generating for a trip the user cannot access', async () => {
|
||||
const res = await request(server).post('/api/trips/5/feed/token').set('Cookie', sessionCookie(2));
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({ error: 'Trip not found' });
|
||||
});
|
||||
|
||||
// ── Public trip feed ───────────────────────────────────────────────────────
|
||||
|
||||
it('public trip feed: 200 text/calendar with injected refresh hints', async () => {
|
||||
const gen = await request(server).post('/api/trips/5/feed/token').set('Cookie', sessionCookie(1));
|
||||
const token = gen.body.feed_url.match(/trip\/([0-9a-f-]+)\.ics$/)![1];
|
||||
|
||||
const res = await request(server).get(`/api/feed/trip/${token}.ics`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('text/calendar');
|
||||
expect(res.text).toContain('REFRESH-INTERVAL;VALUE=DURATION:PT1H');
|
||||
expect(res.text).toContain('X-PUBLISHED-TTL:PT1H');
|
||||
expect(res.text).toContain('BEGIN:VEVENT');
|
||||
expect(exportICS).toHaveBeenCalledWith(5);
|
||||
});
|
||||
|
||||
it('public trip feed: 404 for an unknown token', async () => {
|
||||
const res = await request(server).get('/api/feed/trip/00000000-0000-0000-0000-000000000000.ics');
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
// ── User (all-trips) feed ──────────────────────────────────────────────────
|
||||
|
||||
it('user feed token: 401 without a cookie, generates with one', async () => {
|
||||
expect((await request(server).get('/api/feed/user/token')).status).toBe(401);
|
||||
const gen = await request(server).post('/api/feed/user/token').set('Cookie', sessionCookie(1));
|
||||
expect(gen.status).toBe(201);
|
||||
expect(gen.body.feed_url).toMatch(new RegExp(`^${BASE}/api/feed/user/[0-9a-f-]+\\.ics$`));
|
||||
});
|
||||
|
||||
it('all-trips feed excludes archived and >90-day-old trips', async () => {
|
||||
// user 1 already owns active trip 5 (end 2099). Add an archived + a long-finished one.
|
||||
db.prepare("INSERT INTO trips (id, user_id, title, is_archived, start_date, end_date) VALUES (6, 1, 'Archived', 1, '2026-01-01', '2099-01-01')").run();
|
||||
db.prepare("INSERT INTO trips (id, user_id, title, is_archived, start_date, end_date) VALUES (7, 1, 'Old', 0, '2000-01-01', '2000-01-10')").run();
|
||||
|
||||
const gen = await request(server).post('/api/feed/user/token').set('Cookie', sessionCookie(1));
|
||||
const token = gen.body.feed_url.match(/user\/([0-9a-f-]+)\.ics$/)![1];
|
||||
|
||||
const res = await request(server).get(`/api/feed/user/${token}.ics`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('text/calendar');
|
||||
expect(res.text).toContain('REFRESH-INTERVAL;VALUE=DURATION:PT1H');
|
||||
expect(res.text).toContain('X-WR-CALNAME:e2e-user');
|
||||
|
||||
const calledIds = exportICS.mock.calls.map((c) => c[0]).sort();
|
||||
expect(calledIds).toEqual([5]); // only the active, recent trip — not 6 (archived) or 7 (old)
|
||||
});
|
||||
|
||||
it('public user feed: 404 for an unknown token', async () => {
|
||||
const res = await request(server).get('/api/feed/user/00000000-0000-0000-0000-000000000000.ics');
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -27,7 +27,7 @@ Complete reference for all environment variables TREK reads.
|
||||
| `SESSION_DURATION_REMEMBER` | Session length used when the user **ticks "Remember me"** on login: a longer-lived JWT `exp` claim plus a **persistent** `trek_session` cookie whose `maxAge` matches, so the session survives browser restarts. Same `ms`-style format and startup-fallback behaviour as `SESSION_DURATION`. | `30d` |
|
||||
| `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email notification links | same-origin |
|
||||
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IPs. Set `true` if Immich or other integrated services are on your local network. Loopback (`127.x`) and link-local (`169.254.x`) addresses remain blocked regardless. | `false` |
|
||||
| `APP_URL` | Public base URL (e.g. `https://trek.example.com`). Required when OIDC is enabled — must match the redirect URI registered with your IdP. Also used as the base URL for email notification links. | — |
|
||||
| `APP_URL` | Public base URL (e.g. `https://trek.example.com`). Required when OIDC is enabled — must match the redirect URI registered with your IdP. Also used as the base URL for email notification links and subscribable calendar feed URLs (the `webcal://`/`https://` links the Subscribe dialog hands to Google/Apple/Outlook). | — |
|
||||
|
||||
### `HOST` — Source and Proxmox installs only
|
||||
|
||||
|
||||
Reference in New Issue
Block a user