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>
174 lines
4.7 KiB
TypeScript
174 lines
4.7 KiB
TypeScript
import { NextResponse } from 'next/server'
|
|
import { z } from 'zod'
|
|
import { withRouteContext } from '@/lib/api/with-route-context'
|
|
import { validateBody } from '@/lib/api/validate'
|
|
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
|
|
|
// Only the two content toggles are user-settable. Strict: the previous
|
|
// implementation passed the raw JSON body into .update(), which would have
|
|
// let a caller set feed_token (token fixation on a public URL), expires_at,
|
|
// or access_count.
|
|
const UpdateFeedSchema = z
|
|
.object({
|
|
include_tax_deadlines: z.boolean().optional(),
|
|
include_invoices: z.boolean().optional(),
|
|
})
|
|
.strict()
|
|
.refine(
|
|
(v) => v.include_tax_deadlines !== undefined || v.include_invoices !== undefined,
|
|
{ message: 'Nothing to update' },
|
|
)
|
|
|
|
function feedUrls(feedToken: string) {
|
|
// Fail closed in production: an http:// fallback would mint a link that
|
|
// carries the feed's bearer token over an unencrypted channel.
|
|
const envUrl = process.env.NEXT_PUBLIC_APP_URL
|
|
if (!envUrl && process.env.NODE_ENV === 'production') {
|
|
throw new Error('NEXT_PUBLIC_APP_URL must be set in production')
|
|
}
|
|
const baseUrl = envUrl || 'http://localhost:3000'
|
|
return {
|
|
webcalUrl: `webcal://${baseUrl.replace(/^https?:\/\//, '')}/api/calendar/feed/${feedToken}`,
|
|
httpsUrl: `${baseUrl}/api/calendar/feed/${feedToken}`,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* GET /api/calendar/feed
|
|
* Get current user's calendar feed settings
|
|
*/
|
|
export const GET = withRouteContext('calendar_feed.get', async (_request, ctx) => {
|
|
const { supabase, companyId } = ctx
|
|
|
|
const { data: feed, error } = await supabase
|
|
.from('calendar_feeds')
|
|
.select('*')
|
|
.eq('company_id', companyId)
|
|
.single()
|
|
|
|
if (error && error.code !== 'PGRST116') {
|
|
// PGRST116 = no rows returned, which is fine
|
|
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
|
|
}
|
|
|
|
if (feed) {
|
|
return NextResponse.json({
|
|
data: { ...feed, ...feedUrls(feed.feed_token) },
|
|
})
|
|
}
|
|
|
|
return NextResponse.json({ data: null })
|
|
})
|
|
|
|
/**
|
|
* POST /api/calendar/feed
|
|
* Create a new calendar feed for the current user
|
|
*/
|
|
export const POST = withRouteContext(
|
|
'calendar_feed.create',
|
|
async (_request, ctx) => {
|
|
const { supabase, companyId, user } = ctx
|
|
|
|
// Check if feed already exists
|
|
const { data: existingFeed } = await supabase
|
|
.from('calendar_feeds')
|
|
.select('id')
|
|
.eq('company_id', companyId)
|
|
.single()
|
|
|
|
if (existingFeed) {
|
|
return NextResponse.json(
|
|
{ error: 'Calendar feed already exists' },
|
|
{ status: 409 }
|
|
)
|
|
}
|
|
|
|
// Create new feed
|
|
const { data: feed, error } = await supabase
|
|
.from('calendar_feeds')
|
|
.insert({
|
|
user_id: user.id,
|
|
company_id: companyId,
|
|
is_active: true,
|
|
include_tax_deadlines: true,
|
|
include_invoices: true,
|
|
})
|
|
.select()
|
|
.single()
|
|
|
|
if (error) {
|
|
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
|
|
}
|
|
|
|
return NextResponse.json({
|
|
data: { ...feed, ...feedUrls(feed.feed_token) },
|
|
})
|
|
},
|
|
{ requireWrite: true },
|
|
)
|
|
|
|
/**
|
|
* PUT /api/calendar/feed
|
|
* Update calendar feed settings
|
|
*/
|
|
export const PUT = withRouteContext(
|
|
'calendar_feed.update',
|
|
async (request, ctx) => {
|
|
const { supabase, companyId, log } = ctx
|
|
|
|
const validation = await validateBody(request, UpdateFeedSchema, {
|
|
log,
|
|
operation: 'calendar_feed.update',
|
|
})
|
|
if (!validation.success) return validation.response
|
|
|
|
const { data: feed, error } = await supabase
|
|
.from('calendar_feeds')
|
|
.update(validation.data)
|
|
.eq('company_id', companyId)
|
|
.select()
|
|
.single()
|
|
|
|
if (error) {
|
|
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
|
|
}
|
|
|
|
return NextResponse.json({
|
|
data: { ...feed, ...feedUrls(feed.feed_token) },
|
|
})
|
|
},
|
|
{ requireWrite: true },
|
|
)
|
|
|
|
/**
|
|
* DELETE /api/calendar/feed
|
|
* Regenerate calendar feed token (invalidates old URL)
|
|
*/
|
|
export const DELETE = withRouteContext(
|
|
'calendar_feed.rotate_token',
|
|
async (_request, ctx) => {
|
|
const { supabase, companyId } = ctx
|
|
|
|
// Generate a new token by updating with a new UUID
|
|
const { data: feed, error } = await supabase
|
|
.from('calendar_feeds')
|
|
.update({
|
|
feed_token: crypto.randomUUID(),
|
|
access_count: 0,
|
|
last_accessed_at: null,
|
|
})
|
|
.eq('company_id', companyId)
|
|
.select()
|
|
.single()
|
|
|
|
if (error) {
|
|
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
|
|
}
|
|
|
|
return NextResponse.json({
|
|
data: { ...feed, ...feedUrls(feed.feed_token) },
|
|
})
|
|
},
|
|
{ requireWrite: true },
|
|
)
|