Files
accounted/app/api/calendar/feed/[token]/route.ts
T
MattssonandClaude Fable 5 f75ea2384d feat(calendar): enable calendar sync for Viktiga datum (#1853)
Turns on the calendar extension (built Feb 2026, stripped in the 2026-03-02 production readiness deploy, never re-enabled): ICS feed settings, calendar workspace, subscribe button on the Viktiga datum page.

Hardening before first real use: feed serve route now requires the creator to still be a company member (offboarding stops the feed); stable pagination (due_date + id, dedupe) on feed queries; fetches inside the logged try block; invoice events limited to sent/paid/partially_paid/overdue; event UIDs rebranded to accounted.se while zero feeds exist; APP_URL fallback fails closed in production; mobile stacking for the deadlines header; settings note that Google Calendar needs default notifications on subscribed calendars; calendar workspace aligned with the design system.

Skeptic reviewed (3 refutations, all fixed) plus one compliance swarm finding (fixed). No migrations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 16:32:44 +02:00

187 lines
6.3 KiB
TypeScript

import { createServiceRoleClient } from '@/lib/supabase/service-client'
import { NextResponse } from 'next/server'
import { generateCalendarFeed } from '@/lib/calendar/ics-generator'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { createLogger } from '@/lib/logger'
import type { Deadline, Invoice } from '@/types'
const log = createLogger('api/calendar/feed-token')
// In-memory rate limiting: token -> { count, resetAt }
const rateLimitMap = new Map<string, { count: number; resetAt: number }>()
const RATE_LIMIT_WINDOW_MS = 60_000 // 1 minute
const RATE_LIMIT_MAX = 60 // 60 requests per minute per token
// Periodic cleanup to prevent memory leaks (every 5 minutes)
let lastCleanup = Date.now()
function cleanupRateLimitMap() {
const now = Date.now()
if (now - lastCleanup < 5 * 60_000) return
lastCleanup = now
for (const [key, value] of rateLimitMap) {
if (now > value.resetAt) rateLimitMap.delete(key)
}
}
/**
* GET /api/calendar/feed/[token]
* Returns an ICS calendar feed for the given token
* No authentication required - the token IS the authentication
*/
export async function GET(
request: Request,
{ params }: { params: Promise<{ token: string }> }
) {
const { token } = await params
// Validate token format (UUID)
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
if (!uuidRegex.test(token)) {
return new NextResponse('Invalid token', { status: 400 })
}
// Rate limiting per token
cleanupRateLimitMap()
const nowMs = Date.now()
const rateEntry = rateLimitMap.get(token)
if (rateEntry && nowMs < rateEntry.resetAt) {
if (rateEntry.count >= RATE_LIMIT_MAX) {
return new NextResponse('Too many requests', { status: 429 })
}
rateEntry.count++
} else {
rateLimitMap.set(token, { count: 1, resetAt: nowMs + RATE_LIMIT_WINDOW_MS })
}
// Create service client (no user auth required)
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
if (!supabaseUrl || !supabaseServiceKey) {
return new NextResponse('Server configuration error', { status: 500 })
}
const supabase = createServiceRoleClient(supabaseUrl, supabaseServiceKey)
// Fetch feed settings by token
const { data: feed, error: feedError } = await supabase
.from('calendar_feeds')
.select('*')
.eq('feed_token', token)
.eq('is_active', true)
.single()
if (feedError || !feed) {
return new NextResponse('Feed not found or inactive', { status: 404 })
}
// Check token expiry
if (feed.expires_at && new Date(feed.expires_at) < new Date()) {
return new NextResponse('Feed token has expired', { status: 410 })
}
// The token authenticates the feed, but the feed's creator must still be a
// member of the company: offboarding (removal from company_members) must
// stop the feed, or an ex-member's subscribed calendar keeps receiving the
// company's deadlines and invoice details indefinitely.
const { data: membership } = await supabase
.from('company_members')
.select('user_id')
.eq('company_id', feed.company_id)
.eq('user_id', feed.user_id)
.maybeSingle()
if (!membership) {
return new NextResponse('Feed not found or inactive', { status: 404 })
}
// Update access tracking
await supabase
.from('calendar_feeds')
.update({
last_accessed_at: new Date().toISOString(),
access_count: feed.access_count + 1,
})
.eq('id', feed.id)
// Calculate date range: 3 months back, 12 months forward
const now = new Date()
const startDate = new Date(now)
startDate.setMonth(startDate.getMonth() - 3)
const endDate = new Date(now)
endDate.setMonth(endDate.getMonth() + 12)
const startStr = startDate.toISOString().split('T')[0]
const endStr = endDate.toISOString().split('T')[0]
try {
// Fetch relevant data based on feed options. Deadlines are always
// fetched: include_tax_deadlines only hides SYSTEM rows (the generator
// filters by source), while user-created deadlines always appear.
// The secondary .order('id') gives the stable total order paging
// requires: due dates cluster hard (invoice batches, tax deadlines), so
// ordering by due_date alone leaves the page boundary inside a run of
// tied rows, where Postgres may drop or repeat rows between pages.
const [deadlines, invoices] = await Promise.all([
fetchAllRows<Deadline>(
({ from, to }) =>
supabase
.from('deadlines')
.select('*')
.eq('company_id', feed.company_id)
.is('dismissed_at', null)
.gte('due_date', startStr)
.lte('due_date', endStr)
.order('due_date')
.order('id')
.range(from, to),
{ dedupeBy: (row) => row.id }
),
// Invoices with a real due date to remind about: drafts, cancelled and
// credited invoices have no payable due date and would leak
// speculative amounts into the subscriber's calendar.
feed.include_invoices
? fetchAllRows<Invoice>(
({ from, to }) =>
supabase
.from('invoices')
.select('*, customer:customers(*)')
.eq('company_id', feed.company_id)
.in('status', ['sent', 'paid', 'partially_paid', 'overdue'])
.gte('due_date', startStr)
.lte('due_date', endStr)
.order('due_date')
.order('id')
.range(from, to),
{ dedupeBy: (row) => row.id }
)
: Promise.resolve([]),
])
const icsContent = await generateCalendarFeed(
{
deadlines,
invoices,
},
{
includeTaxDeadlines: feed.include_tax_deadlines,
includeInvoices: feed.include_invoices,
}
)
return new NextResponse(icsContent, {
headers: {
'Content-Type': 'text/calendar; charset=utf-8',
'Content-Disposition': 'attachment; filename="accounted.ics"',
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0',
},
})
} catch (error) {
log.error('Error generating ICS feed', error as Error, { feedId: feed.id })
return new NextResponse('Failed to generate calendar feed', { status: 500 })
}
}