Files
accounted/app/api/calendar/feed/[token]/route.ts
T
MattssonandClaude Opus 4.6 0dd1f5ebc1 feat: multi-tenant company refactor (GNU-19) (#153)
* feat: multi-tenant company refactor (GNU-19)

Introduce companies table, company_members, and user_preferences to
support multiple companies per user. All data scoping changes from
user_id to company_id across the entire codebase.

Key changes:
- Database migration: new tables, company_id on 40+ tables, backfill,
  RLS rewrite from user_id to company-member-based, updated RPCs
- Types: Company, CompanyMember, CompanyRole, UserPreferences types;
  company_id added to all entity interfaces; companyId on all events
- Engine: all 7 core functions take companyId; storno, period, year-end
  services updated; 16 report generators updated
- Middleware: company context resolution (cookie → prefs → first company)
- API routes: ~120 routes updated with requireCompanyId()
- Frontend: CompanyProvider context, layout/dashboard/onboarding updated
- Extensions: context factory, 9 extensions, all lib files updated
- Tests: 1880 tests passing, all helpers updated with company_id defaults

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add database migrations for multi-tenant company and team system (GNU-19)

Adds company_invitations, company creation RPC, team_members, account
deletion RPC, and teams table refactor migrations. Updates base
multi-tenant migration with cascading FKs and onboarding_step column.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add team types and update core infrastructure for multi-tenancy (GNU-19)

Adds TeamRole, MemberSource, and Team types. Refactors Supabase service
client to be stateless, updates middleware for team-aware routing, extends
CompanyContext with team/role fields, and updates extension service types
to accept companyId.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: thread company_id through business logic functions (GNU-19)

Replaces user_id scoping with company_id across all lib modules:
bookkeeping, documents, transactions, invoices, reconciliation, tax,
deadlines, and import. Updates corresponding tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: thread company_id through API routes and extensions (GNU-19)

Updates all existing API routes to extract and pass companyId. Updates
enable-banking and arcim-migration extensions for company-scoped
transaction ingestion and sync.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add company and team management API routes (GNU-19)

Adds CRUD endpoints for company members, company invitations, team
members, and team invitations. Includes invite token utilities, email
templates, and company switch server action.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add team/company UI components, pages, and dashboard updates (GNU-19)

Adds CompanySwitcher, ConsultantEmptyState, Step0RoleChoice, company
members and team management panels. Updates dashboard layout for
team-aware routing, onboarding for multi-step role choice, and auth
callback for team invite acceptance. Ignores supabase/.branches/.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add null guards for company in import page (GNU-19)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: move appUrl declaration to outer scope in invite route (GNU-19)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add optional chaining for company.name in members section (GNU-19)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add optional chaining for second company.name in members section (GNU-19)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add null guards for company in extension components (GNU-19)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: pass companyId to executeSIEImport in arcim-migration extension (GNU-19)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: update tests to use companyId instead of userId and improve type handling

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 16:41:52 +02:00

148 lines
4.4 KiB
TypeScript

import { createClient } from '@supabase/supabase-js'
import { NextResponse } from 'next/server'
import { generateCalendarFeed } from '@/lib/calendar/ics-generator'
// 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 = createClient(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 })
}
// 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]
// Fetch relevant data based on feed options
const [deadlinesResult, invoicesResult] = await Promise.all([
// Deadlines
feed.include_tax_deadlines
? supabase
.from('deadlines')
.select('*')
.eq('company_id', feed.company_id)
.gte('due_date', startStr)
.lte('due_date', endStr)
.order('due_date')
: { data: [] },
// 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 {
const icsContent = await generateCalendarFeed(
{
deadlines: deadlinesResult.data || [],
invoices: invoicesResult.data || [],
},
{
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="erp-base.ics"',
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0',
},
})
} catch (error) {
console.error('Error generating ICS feed:', error)
return new NextResponse('Failed to generate calendar feed', { status: 500 })
}
}