diff --git a/app/(dashboard)/deadlines/page.tsx b/app/(dashboard)/deadlines/page.tsx index 80208f9c..2aa72642 100644 --- a/app/(dashboard)/deadlines/page.tsx +++ b/app/(dashboard)/deadlines/page.tsx @@ -14,7 +14,8 @@ import { HelpPopover } from '@/components/ui/help-popover' import { AttnLine } from '@/components/ui/attn-line' import { Button } from '@/components/ui/button' import { Skeleton } from '@/components/ui/skeleton' -import { Lock, Plus } from 'lucide-react' +import { CalendarPlus, Lock, Plus } from 'lucide-react' +import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions' import { useCompany } from '@/contexts/CompanyContext' import { useCanWrite } from '@/lib/hooks/use-can-write' import { formatCurrency } from '@/lib/utils' @@ -430,14 +431,26 @@ export default function DeadlinesPage() { } action={ - + // Stacks full-width on mobile: PageHeader's [&>*]:w-full lands on + // this wrapper, so the buttons themselves must go w-full below sm +
+ {ENABLED_EXTENSION_IDS.has('calendar') && ( + + )} + +
} /> ) diff --git a/app/api/calendar/feed/[token]/__tests__/route.test.ts b/app/api/calendar/feed/[token]/__tests__/route.test.ts new file mode 100644 index 00000000..eb5c5e4d --- /dev/null +++ b/app/api/calendar/feed/[token]/__tests__/route.test.ts @@ -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') + }) +}) diff --git a/app/api/calendar/feed/[token]/route.ts b/app/api/calendar/feed/[token]/route.ts index 6d7fb01f..f09d33f5 100644 --- a/app/api/calendar/feed/[token]/route.ts +++ b/app/api/calendar/feed/[token]/route.ts @@ -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( + ({ 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( + ({ 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', diff --git a/app/api/calendar/feed/route.ts b/app/api/calendar/feed/route.ts index bb181763..2f59a6d5 100644 --- a/app/api/calendar/feed/route.ts +++ b/app/api/calendar/feed/route.ts @@ -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}`, diff --git a/components/settings/CalendarFeedSettings.tsx b/components/settings/CalendarFeedSettings.tsx index 82297251..6322d6b5 100644 --- a/components/settings/CalendarFeedSettings.tsx +++ b/components/settings/CalendarFeedSettings.tsx @@ -230,6 +230,10 @@ export function CalendarFeedSettings() { +

+ {t('google_reminders_note')} +

+ {/* Live feed stats stay visible: they are state, not instructions */} {feed.last_accessed_at && ( diff --git a/extensions.config.json b/extensions.config.json index 07fc986b..1b46c56a 100644 --- a/extensions.config.json +++ b/extensions.config.json @@ -1 +1 @@ -{"$schema":"./extensions.schema.json","extensions":["enable-banking","email","arcim-migration","tic","mcp-server","cloud-backup","skatteverket","invoice-inbox","document-extraction","stripe","whatsapp-inbox","woocommerce","shopify","mail"]} \ No newline at end of file +{"$schema":"./extensions.schema.json","extensions":["calendar","enable-banking","email","arcim-migration","tic","mcp-server","cloud-backup","skatteverket","invoice-inbox","document-extraction","stripe","whatsapp-inbox","woocommerce","shopify","mail"]} \ No newline at end of file diff --git a/extensions/general/calendar/components/CalendarDayCell.tsx b/extensions/general/calendar/components/CalendarDayCell.tsx index 37d5f581..1b414d92 100644 --- a/extensions/general/calendar/components/CalendarDayCell.tsx +++ b/extensions/general/calendar/components/CalendarDayCell.tsx @@ -57,7 +57,7 @@ export function CalendarDayCell({ !isCurrentMonth && 'bg-muted/30 text-muted-foreground', isToday && 'bg-primary/5', hasOverdue && 'border-l-2 border-l-destructive', - !hasOverdue && hasActionNeeded && 'border-l-2 border-l-orange-500' + !hasOverdue && hasActionNeeded && 'border-l-2 border-l-warning' )} >
0 && (
- + {deadlinesByStatus.action_needed} åtgärd
diff --git a/extensions/general/calendar/components/CalendarDayView.tsx b/extensions/general/calendar/components/CalendarDayView.tsx index 3bcb0d01..43813bdc 100644 --- a/extensions/general/calendar/components/CalendarDayView.tsx +++ b/extensions/general/calendar/components/CalendarDayView.tsx @@ -92,7 +92,7 @@ export function CalendarDayView({
Faktura {invoice.invoice_number} {isInvoiceOverdue(invoice) && ( - + Förfallen )} @@ -131,14 +131,14 @@ export function CalendarDayView({ {paidInvoices.map((invoice) => (
Faktura {invoice.invoice_number} - + Betald
@@ -159,7 +159,7 @@ export function CalendarDayView({
{deadline.title} {isDeadlineOverdue(deadline) && ( - + Försenad )} @@ -186,9 +186,9 @@ export function CalendarDayView({ {DEADLINE_TYPE_LABELS[deadline.deadline_type] || deadline.deadline_type} {deadline.priority !== 'normal' && ( {PRIORITY_LABELS[deadline.priority]} @@ -208,7 +208,7 @@ export function CalendarDayView({ {completedDeadlines.map((deadline) => (
@@ -218,7 +218,7 @@ export function CalendarDayView({
{DEADLINE_TYPE_LABELS[deadline.deadline_type] || deadline.deadline_type} - Klar + Klar
diff --git a/extensions/general/calendar/components/CalendarWeekView.tsx b/extensions/general/calendar/components/CalendarWeekView.tsx index 0523726e..949bb2e6 100644 --- a/extensions/general/calendar/components/CalendarWeekView.tsx +++ b/extensions/general/calendar/components/CalendarWeekView.tsx @@ -92,7 +92,7 @@ export function CalendarWeekView({
- {modes.map((mode) => ( - - ))} -
+ ({ value: mode, label: VIEW_MODE_LABELS[mode] }))} + aria-label="Kalendervy" + /> ) } diff --git a/lib/calendar/ics-generator.ts b/lib/calendar/ics-generator.ts index d651d520..a01bba1b 100644 --- a/lib/calendar/ics-generator.ts +++ b/lib/calendar/ics-generator.ts @@ -20,7 +20,7 @@ export interface CalendarData { * Generate a stable UID for calendar events * This ensures updates to events are recognized by calendar apps */ -function getEventUID(type: string, id: string, domain: string = 'erp-base.se'): string { +function getEventUID(type: string, id: string, domain: string = 'accounted.se'): string { return `${type}-${id}@${domain}` } diff --git a/lib/extensions/_generated/enabled-extensions.ts b/lib/extensions/_generated/enabled-extensions.ts index acb7144c..4b552a69 100644 --- a/lib/extensions/_generated/enabled-extensions.ts +++ b/lib/extensions/_generated/enabled-extensions.ts @@ -1,6 +1,7 @@ // AUTO-GENERATED: do not edit. Run `npm run setup:extensions` to regenerate. export const ENABLED_EXTENSION_IDS: ReadonlySet = new Set([ + 'calendar', 'enable-banking', 'email', 'arcim-migration', diff --git a/lib/extensions/_generated/extension-list.ts b/lib/extensions/_generated/extension-list.ts index 985e8f8e..991569ac 100644 --- a/lib/extensions/_generated/extension-list.ts +++ b/lib/extensions/_generated/extension-list.ts @@ -1,5 +1,6 @@ // AUTO-GENERATED: do not edit. Run `npm run setup:extensions` to regenerate. import type { Extension } from '../types' +import { calendarExtension } from '@/extensions/general/calendar' import { enableBankingExtension } from '@/extensions/general/enable-banking' import { emailExtension } from '@/extensions/general/email' import { arcimMigrationExtension } from '@/extensions/general/arcim-migration' @@ -16,6 +17,7 @@ import { shopifyExtension } from '@/extensions/general/shopify' import { mailExtension } from '@/extensions/general/mail' export const FIRST_PARTY_EXTENSIONS: Extension[] = [ + calendarExtension, enableBankingExtension, emailExtension, arcimMigrationExtension, diff --git a/lib/extensions/_generated/sector-definitions.ts b/lib/extensions/_generated/sector-definitions.ts index 88585a12..8acca9f4 100644 --- a/lib/extensions/_generated/sector-definitions.ts +++ b/lib/extensions/_generated/sector-definitions.ts @@ -3,6 +3,21 @@ import type { ExtensionDefinition } from '../types' export const EXTENSION_DEFINITIONS: Record = { 'general': [ + { + "slug": "calendar", + "name": "Kalender", + "sector": "general", + "category": "operations", + "icon": "Calendar", + "dataPattern": "core", + "description": "Fullständig kalendervy med månads-, vecko- och dagsvisning", + "longDescription": "Se alla fakturadatum och deadlines i en interaktiv kalender med månads-, vecko- och dagsvy.", + "readsCoreTables": [ + "invoices", + "deadlines", + "customers" + ] + }, { "slug": "enable-banking", "name": "Bankintegration (PSD2)", diff --git a/lib/extensions/_generated/workspace-map.tsx b/lib/extensions/_generated/workspace-map.tsx index f7cc76f0..0e5568d1 100644 --- a/lib/extensions/_generated/workspace-map.tsx +++ b/lib/extensions/_generated/workspace-map.tsx @@ -4,6 +4,7 @@ import type { ComponentType } from 'react' import type { WorkspaceComponentProps } from '../workspace-registry' export const WORKSPACES: Record> = { + 'general/calendar': dynamic(() => import('@/components/extensions/general/CalendarWorkspace')), 'general/enable-banking': dynamic(() => import('@/components/extensions/general/EnableBankingWorkspace')), 'general/arcim-migration': dynamic(() => import('@/components/extensions/general/ArcimMigrationWorkspace')), 'general/tic': dynamic(() => import('@/components/extensions/general/TicWorkspace')), diff --git a/messages/en.json b/messages/en.json index d16c7ac5..32cb33ad 100644 --- a/messages/en.json +++ b/messages/en.json @@ -776,6 +776,7 @@ "group_cancel": "Cancel", "group_confirm": "Confirm", "new_deadline": "New deadline", + "subscribe_calendar": "Subscribe in your calendar", "read_only_tooltip": "You have read-only access in this company", "generating": "Generating…", "help_text": "Deadlines for VAT, employer declarations and F-tax are generated automatically from your company's tax settings. Add your own with New deadline; click a row to edit it.", @@ -2903,6 +2904,7 @@ "add_to_apple_calendar": "Add to Apple Calendar", "calendar_link_label": "Calendar link (for Google Calendar etc.)", "calendar_link_help": "Copy this link and add it as a URL subscription in your calendar app.", + "google_reminders_note": "Google Calendar does not show reminders from subscribed calendars. Set default notifications on the subscribed calendar in Google Calendar to get alerts.", "last_fetched": "Last fetched:", "times_count": "{count} times", "creating_new_link": "Creating new link...", diff --git a/messages/sv.json b/messages/sv.json index 530726c1..1b30bfb2 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -776,6 +776,7 @@ "group_cancel": "Avbryt", "group_confirm": "Bekräfta", "new_deadline": "Ny deadline", + "subscribe_calendar": "Prenumerera i din kalender", "read_only_tooltip": "Du har endast läsbehörighet i detta företag", "generating": "Genererar…", "help_text": "Deadlines för moms, arbetsgivardeklaration och F-skatt skapas automatiskt från företagets skatteinställningar. Egna deadlines lägger du till med Ny deadline; klicka på en rad för att ändra den.", @@ -2903,6 +2904,7 @@ "add_to_apple_calendar": "Lägg till i Apple Calendar", "calendar_link_label": "Kalenderlänk (för Google Calendar m.fl.)", "calendar_link_help": "Kopiera denna länk och lägg till som URL-prenumeration i din kalenderapp.", + "google_reminders_note": "Google Calendar visar inte påminnelser från prenumererade kalendrar. Sätt standardaviseringar på den prenumererade kalendern i Google Calendar för att få notiser.", "last_fetched": "Senast hämtad:", "times_count": "{count} gånger", "creating_new_link": "Skapar ny länk...",