f266c386f3
* chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers Remove 33 dead files, ~270 unreferenced exports/types, 13 dead i18n namespaces and 4 unused dependencies; fold byte-identical helper copies into one canonical home each (lib/utils chunk/sleep/utcDateStamp, lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format, lib/pdf/number-text, lib/browser/panel-request, lib/api/v1/body + v1ValidationError rolled out to ~55 v1 routes, booking-template schemas). No behaviour change: v1 bodies and status codes, MCP tool schemas, DB writes and money math are untouched. Naive ore rounding was deliberately not swapped for roundOre; see DECISIONS.md 2026-09-02 for the full list of things left alone on purpose. tsc, lint, 19588 unit tests and check:guards green; antipattern baseline ratcheted (naive-ore-round 622 -> 620, hand-rolled-invariant 115 -> 113). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(transactions): import RawTransaction from @/types after the ingest re-export removal CI's type ratchet (check:types, full tsconfig) caught the one test file that still imported the type through lib/transactions/ingest. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
167 lines
5.6 KiB
TypeScript
167 lines
5.6 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'
|
|
import { createTokenRateLimiter } from '@/lib/api/token-rate-limit'
|
|
import { UUID_RE } from '@/lib/invariants/uuid'
|
|
|
|
const log = createLogger('api/calendar/feed-token')
|
|
|
|
// 60 requests per minute per token, process-local.
|
|
const rateLimiter = createTokenRateLimiter({ max: 60, windowMs: 60_000 })
|
|
|
|
/**
|
|
* 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)
|
|
if (!UUID_RE.test(token)) {
|
|
return new NextResponse('Invalid token', { status: 400 })
|
|
}
|
|
|
|
// Rate limiting per token
|
|
if (!rateLimiter.allow(token)) {
|
|
return new NextResponse('Too many requests', { status: 429 })
|
|
}
|
|
|
|
// 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 })
|
|
}
|
|
}
|