import { NextRequest, NextResponse } from 'next/server' import { Pool } from 'pg' /** * SDK State Management API (Multi-Project) * * GET /api/sdk/v1/state?tenantId=xxx&projectId=yyy - Load state for a tenant+project * POST /api/sdk/v1/state - Save state for a tenant+project * DELETE /api/sdk/v1/state?tenantId=xxx&projectId=yyy - Clear state * * Features: * - Versioning for optimistic locking * - Last-Modified headers * - ETag support for caching * - PostgreSQL persistence (with InMemory fallback) * - projectId support for multi-project architecture */ // ============================================================================= // TYPES // ============================================================================= interface StoredState { state: unknown version: number userId?: string createdAt: string updatedAt: string } // ============================================================================= // STORAGE LAYER // ============================================================================= interface StateStore { get(tenantId: string, projectId?: string): Promise save(tenantId: string, state: unknown, userId?: string, expectedVersion?: number, projectId?: string): Promise delete(tenantId: string, projectId?: string): Promise } class InMemoryStateStore implements StateStore { private store: Map = new Map() private key(tenantId: string, projectId?: string): string { return projectId ? `${tenantId}:${projectId}` : tenantId } async get(tenantId: string, projectId?: string): Promise { return this.store.get(this.key(tenantId, projectId)) || null } async save( tenantId: string, state: unknown, userId?: string, expectedVersion?: number, projectId?: string ): Promise { const k = this.key(tenantId, projectId) const existing = this.store.get(k) if (expectedVersion !== undefined && existing && existing.version !== expectedVersion) { const error = new Error('Version conflict') as Error & { status: number } error.status = 409 throw error } const now = new Date().toISOString() const newVersion = existing ? existing.version + 1 : 1 const stored: StoredState = { state: { ...(state as object), lastModified: now, }, version: newVersion, userId, createdAt: existing?.createdAt || now, updatedAt: now, } this.store.set(k, stored) return stored } async delete(tenantId: string, projectId?: string): Promise { return this.store.delete(this.key(tenantId, projectId)) } } class PostgreSQLStateStore implements StateStore { private pool: Pool constructor(connectionString: string) { this.pool = new Pool({ connectionString, max: 5, // Set search_path for compliance schema options: '-c search_path=compliance,core,public', }) } async get(tenantId: string, projectId?: string): Promise { let result if (projectId) { result = await this.pool.query( 'SELECT state, version, user_id, created_at, updated_at FROM sdk_states WHERE tenant_id = $1 AND project_id = $2', [tenantId, projectId] ) } else { // Backwards compatibility: find the single active project for this tenant result = await this.pool.query( `SELECT s.state, s.version, s.user_id, s.created_at, s.updated_at FROM sdk_states s LEFT JOIN compliance_projects p ON s.project_id = p.id WHERE s.tenant_id = $1 AND (p.status = 'active' OR p.id IS NULL) ORDER BY s.updated_at DESC LIMIT 1`, [tenantId] ) } if (result.rows.length === 0) return null const row = result.rows[0] return { state: row.state, version: row.version, userId: row.user_id, createdAt: row.created_at instanceof Date ? row.created_at.toISOString() : row.created_at, updatedAt: row.updated_at instanceof Date ? row.updated_at.toISOString() : row.updated_at, } } async save(tenantId: string, state: unknown, userId?: string, expectedVersion?: number, projectId?: string): Promise { const now = new Date().toISOString() const stateWithTimestamp = { ...(state as object), lastModified: now, } let result if (projectId) { // Multi-project: UPSERT on (tenant_id, project_id) result = await this.pool.query(` INSERT INTO sdk_states (tenant_id, project_id, user_id, state, version, created_at, updated_at) VALUES ($1, $5, $2, $3::jsonb, 1, NOW(), NOW()) ON CONFLICT (tenant_id, project_id) DO UPDATE SET state = $3::jsonb, user_id = COALESCE($2, sdk_states.user_id), version = sdk_states.version + 1, updated_at = NOW() WHERE ($4::int IS NULL OR sdk_states.version = $4) RETURNING version, user_id, created_at, updated_at `, [tenantId, userId, JSON.stringify(stateWithTimestamp), expectedVersion ?? null, projectId]) } else { // Backwards compatibility: find the single project for this tenant // First try to find an existing project const projectResult = await this.pool.query( `SELECT id FROM compliance_projects WHERE tenant_id = $1 AND status = 'active' ORDER BY created_at ASC LIMIT 1`, [tenantId] ) if (projectResult.rows.length > 0) { const foundProjectId = projectResult.rows[0].id result = await this.pool.query(` INSERT INTO sdk_states (tenant_id, project_id, user_id, state, version, created_at, updated_at) VALUES ($1, $5, $2, $3::jsonb, 1, NOW(), NOW()) ON CONFLICT (tenant_id, project_id) DO UPDATE SET state = $3::jsonb, user_id = COALESCE($2, sdk_states.user_id), version = sdk_states.version + 1, updated_at = NOW() WHERE ($4::int IS NULL OR sdk_states.version = $4) RETURNING version, user_id, created_at, updated_at `, [tenantId, userId, JSON.stringify(stateWithTimestamp), expectedVersion ?? null, foundProjectId]) } else { // No project exists — create a default one const newProject = await this.pool.query( `INSERT INTO compliance_projects (tenant_id, name, customer_type, status) VALUES ($1, 'Projekt 1', 'new', 'active') RETURNING id`, [tenantId] ) const newProjectId = newProject.rows[0].id result = await this.pool.query(` INSERT INTO sdk_states (tenant_id, project_id, user_id, state, version, created_at, updated_at) VALUES ($1, $5, $2, $3::jsonb, 1, NOW(), NOW()) ON CONFLICT (tenant_id, project_id) DO UPDATE SET state = $3::jsonb, user_id = COALESCE($2, sdk_states.user_id), version = sdk_states.version + 1, updated_at = NOW() WHERE ($4::int IS NULL OR sdk_states.version = $4) RETURNING version, user_id, created_at, updated_at `, [tenantId, userId, JSON.stringify(stateWithTimestamp), expectedVersion ?? null, newProjectId]) } } if (result.rows.length === 0) { const error = new Error('Version conflict') as Error & { status: number } error.status = 409 throw error } const row = result.rows[0] return { state: stateWithTimestamp, version: row.version, userId: row.user_id, createdAt: row.created_at instanceof Date ? row.created_at.toISOString() : row.created_at, updatedAt: row.updated_at instanceof Date ? row.updated_at.toISOString() : row.updated_at, } } async delete(tenantId: string, projectId?: string): Promise { let result if (projectId) { result = await this.pool.query( 'DELETE FROM sdk_states WHERE tenant_id = $1 AND project_id = $2', [tenantId, projectId] ) } else { result = await this.pool.query( 'DELETE FROM sdk_states WHERE tenant_id = $1', [tenantId] ) } return (result.rowCount ?? 0) > 0 } } // ============================================================================= // STORE INITIALIZATION // ============================================================================= function createStateStore(): StateStore { const databaseUrl = process.env.DATABASE_URL if (databaseUrl) { console.log('[SDK State] Using PostgreSQL state store') return new PostgreSQLStateStore(databaseUrl) } console.log('[SDK State] Using in-memory state store (no DATABASE_URL)') return new InMemoryStateStore() } const stateStore: StateStore = createStateStore() // ============================================================================= // HELPER FUNCTIONS // ============================================================================= function generateETag(version: number, updatedAt: string): string { return `"${version}-${Buffer.from(updatedAt).toString('base64').slice(0, 8)}"` } // ============================================================================= // HANDLERS // ============================================================================= export async function GET(request: NextRequest) { try { const { searchParams } = new URL(request.url) const tenantId = searchParams.get('tenantId') const projectId = searchParams.get('projectId') || undefined if (!tenantId) { return NextResponse.json( { success: false, error: 'tenantId is required' }, { status: 400 } ) } const stored = await stateStore.get(tenantId, projectId) if (!stored) { return NextResponse.json( { success: false, error: 'State not found', tenantId }, { status: 404 } ) } const etag = generateETag(stored.version, stored.updatedAt) // Check If-None-Match header for caching const ifNoneMatch = request.headers.get('If-None-Match') if (ifNoneMatch === etag) { return new NextResponse(null, { status: 304 }) } return NextResponse.json( { success: true, data: { tenantId, projectId: projectId || null, state: stored.state, version: stored.version, lastModified: stored.updatedAt, }, }, { headers: { 'ETag': etag, 'Last-Modified': stored.updatedAt, 'Cache-Control': 'private, no-cache', }, } ) } catch (error) { console.error('Failed to load SDK state:', error) return NextResponse.json( { success: false, error: 'Failed to load state' }, { status: 500 } ) } } export async function POST(request: NextRequest) { try { const body = await request.json() const { tenantId, state, version, projectId } = body if (!tenantId) { return NextResponse.json( { success: false, error: 'tenantId is required' }, { status: 400 } ) } if (!state) { return NextResponse.json( { success: false, error: 'state is required' }, { status: 400 } ) } // Check If-Match header for optimistic locking const ifMatch = request.headers.get('If-Match') const expectedVersion = ifMatch ? parseInt(ifMatch, 10) : version const stored = await stateStore.save(tenantId, state, body.userId, expectedVersion, projectId || undefined) const etag = generateETag(stored.version, stored.updatedAt) return NextResponse.json( { success: true, data: { tenantId, projectId: projectId || null, state: stored.state, version: stored.version, lastModified: stored.updatedAt, }, }, { headers: { 'ETag': etag, 'Last-Modified': stored.updatedAt, }, } ) } catch (error) { const err = error as Error & { status?: number } // Handle version conflict if (err.status === 409 || err.message === 'Version conflict') { return NextResponse.json( { success: false, error: 'Version conflict. State was modified by another request.', code: 'VERSION_CONFLICT', }, { status: 409 } ) } console.error('Failed to save SDK state:', error) return NextResponse.json( { success: false, error: 'Failed to save state' }, { status: 500 } ) } } export async function DELETE(request: NextRequest) { try { const { searchParams } = new URL(request.url) const tenantId = searchParams.get('tenantId') const projectId = searchParams.get('projectId') || undefined if (!tenantId) { return NextResponse.json( { success: false, error: 'tenantId is required' }, { status: 400 } ) } const deleted = await stateStore.delete(tenantId, projectId) if (!deleted) { return NextResponse.json( { success: false, error: 'State not found', tenantId }, { status: 404 } ) } return NextResponse.json({ success: true, tenantId, deletedAt: new Date().toISOString(), }) } catch (error) { console.error('Failed to delete SDK state:', error) return NextResponse.json( { success: false, error: 'Failed to delete state' }, { status: 500 } ) } } // ============================================================================= // HEALTH CHECK // ============================================================================= export async function OPTIONS() { return NextResponse.json({ status: 'ok' }, { headers: { 'Allow': 'GET, POST, DELETE, OPTIONS', }, }) }