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>
This commit is contained in:
Mattsson
2026-08-24 16:32:44 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 4fb449a474
commit f75ea2384d
17 changed files with 221 additions and 72 deletions
@@ -0,0 +1,78 @@
/**
* Tests for /api/calendar/feed/[token] (public ICS serve route).
*
* The membership check matters most: the token is the only authentication,
* so removal from company_members must stop the feed. Without the check an
* offboarded consultant's subscribed calendar keeps receiving the company's
* deadlines and invoice details indefinitely.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
const { supabase, enqueue, reset } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/service-client', () => ({
createServiceRoleClient: () => supabase,
}))
import { GET } from '../route'
const FEED = {
id: 'feed-1',
company_id: 'company-1',
user_id: 'user-1',
is_active: true,
expires_at: null,
access_count: 0,
include_tax_deadlines: true,
include_invoices: false,
}
function tokenRequest(token: string) {
return [
new Request(`http://localhost/api/calendar/feed/${token}`),
{ params: Promise.resolve({ token }) },
] as const
}
beforeEach(() => {
vi.clearAllMocks()
reset()
process.env.NEXT_PUBLIC_SUPABASE_URL = 'http://localhost:54321'
process.env.SUPABASE_SERVICE_ROLE_KEY = 'service-role-key'
})
describe('GET /api/calendar/feed/[token]', () => {
it('returns 400 for a non-UUID token', async () => {
const [req, ctx] = tokenRequest('not-a-uuid')
const res = await GET(req, ctx)
expect(res.status).toBe(400)
})
it('returns 404 when no active feed matches the token', async () => {
enqueue({ data: null, error: { message: 'not found' } })
const [req, ctx] = tokenRequest('11111111-1111-1111-1111-111111111111')
const res = await GET(req, ctx)
expect(res.status).toBe(404)
})
it('returns 404 when the feed creator is no longer a company member', async () => {
enqueue({ data: FEED }) // calendar_feeds lookup
enqueue({ data: null }) // company_members lookup: offboarded
const [req, ctx] = tokenRequest('22222222-2222-2222-2222-222222222222')
const res = await GET(req, ctx)
expect(res.status).toBe(404)
})
it('serves an ICS calendar while the creator is still a member', async () => {
enqueue({ data: FEED }) // calendar_feeds lookup
enqueue({ data: { user_id: 'user-1' } }) // company_members lookup
enqueue({ data: null }) // access tracking update
enqueue({ data: [] }) // deadlines page 1 (empty: pagination stops)
const [req, ctx] = tokenRequest('33333333-3333-3333-3333-333333333333')
const res = await GET(req, ctx)
expect(res.status).toBe(200)
expect(res.headers.get('Content-Type')).toContain('text/calendar')
expect(await res.text()).toContain('BEGIN:VCALENDAR')
})
})
+64 -28
View File
@@ -1,7 +1,9 @@
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')
@@ -78,6 +80,21 @@ export async function GET(
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')
@@ -97,36 +114,55 @@ export async function GET(
const startStr = startDate.toISOString().split('T')[0]
const endStr = endDate.toISOString().split('T')[0]
// 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.
const [deadlinesResult, invoicesResult] = await Promise.all([
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'),
// Invoices
feed.include_invoices
? supabase
.from('invoices')
.select('*, customer:customers(*)')
.eq('company_id', feed.company_id)
.gte('due_date', startStr)
.lte('due_date', endStr)
.order('due_date')
: { data: [] },
])
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: deadlinesResult.data || [],
invoices: invoicesResult.data || [],
deadlines,
invoices,
},
{
includeTaxDeadlines: feed.include_tax_deadlines,
@@ -137,7 +173,7 @@ export async function GET(
return new NextResponse(icsContent, {
headers: {
'Content-Type': 'text/calendar; charset=utf-8',
'Content-Disposition': 'attachment; filename="erp-base.ics"',
'Content-Disposition': 'attachment; filename="accounted.ics"',
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0',
+7 -1
View File
@@ -20,7 +20,13 @@ const UpdateFeedSchema = z
)
function feedUrls(feedToken: string) {
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://app.erp-base.se'
// 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}`,