diff --git a/admin-compliance/app/api/sdk/v1/projects/[projectId]/permanent/route.ts b/admin-compliance/app/api/sdk/v1/projects/[projectId]/permanent/route.ts new file mode 100644 index 0000000..089bb7b --- /dev/null +++ b/admin-compliance/app/api/sdk/v1/projects/[projectId]/permanent/route.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from 'next/server' + +const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002' + +/** + * Proxy: DELETE /api/sdk/v1/projects/{projectId}/permanent → Backend (hard delete) + */ +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ projectId: string }> } +) { + try { + const { projectId } = await params + const tenantId = request.headers.get('X-Tenant-ID') || + new URL(request.url).searchParams.get('tenant_id') || '' + + const response = await fetch( + `${BACKEND_URL}/api/compliance/v1/projects/${projectId}/permanent?tenant_id=${encodeURIComponent(tenantId)}`, + { + method: 'DELETE', + headers: { 'X-Tenant-ID': tenantId }, + } + ) + + if (!response.ok) { + const errorText = await response.text() + return NextResponse.json( + { error: 'Backend error', details: errorText }, + { status: response.status } + ) + } + + return NextResponse.json(await response.json()) + } catch (error) { + console.error('Failed to permanently delete project:', error) + return NextResponse.json( + { error: 'Failed to connect to backend' }, + { status: 503 } + ) + } +} diff --git a/admin-compliance/app/api/sdk/v1/projects/[projectId]/restore/route.ts b/admin-compliance/app/api/sdk/v1/projects/[projectId]/restore/route.ts new file mode 100644 index 0000000..a4c93bf --- /dev/null +++ b/admin-compliance/app/api/sdk/v1/projects/[projectId]/restore/route.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from 'next/server' + +const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002' + +/** + * Proxy: POST /api/sdk/v1/projects/{projectId}/restore → Backend + */ +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ projectId: string }> } +) { + try { + const { projectId } = await params + const tenantId = request.headers.get('X-Tenant-ID') || + new URL(request.url).searchParams.get('tenant_id') || '' + + const response = await fetch( + `${BACKEND_URL}/api/compliance/v1/projects/${projectId}/restore?tenant_id=${encodeURIComponent(tenantId)}`, + { + method: 'POST', + headers: { 'X-Tenant-ID': tenantId }, + } + ) + + if (!response.ok) { + const errorText = await response.text() + return NextResponse.json( + { error: 'Backend error', details: errorText }, + { status: response.status } + ) + } + + return NextResponse.json(await response.json()) + } catch (error) { + console.error('Failed to restore project:', error) + return NextResponse.json( + { error: 'Failed to connect to backend' }, + { status: 503 } + ) + } +} diff --git a/admin-compliance/app/sdk/page.tsx b/admin-compliance/app/sdk/page.tsx index eefc5ac..f252461 100644 --- a/admin-compliance/app/sdk/page.tsx +++ b/admin-compliance/app/sdk/page.tsx @@ -3,9 +3,8 @@ import React from 'react' import Link from 'next/link' import { useSDK, SDK_PACKAGES, getStepsForPackage } from '@/lib/sdk' -import { CustomerTypeSelector } from '@/components/sdk/CustomerTypeSelector' import { ProjectSelector } from '@/components/sdk/ProjectSelector/ProjectSelector' -import type { CustomerType, SDKPackageId } from '@/lib/sdk/types' +import type { SDKPackageId } from '@/lib/sdk/types' // ============================================================================= // DASHBOARD CARDS @@ -171,6 +170,9 @@ function QuickActionCard({ export default function SDKDashboard() { const { state, packageCompletion, completionPercentage, setCustomerType, projectId } = useSDK() + // customerType is set during project creation — default to 'new' for legacy projects + const effectiveCustomerType = state.customerType || 'new' + // No project selected → show project list if (!projectId) { return @@ -180,7 +182,7 @@ export default function SDKDashboard() { const totalSteps = SDK_PACKAGES.reduce((sum, pkg) => { const steps = getStepsForPackage(pkg.id) // Filter import step for new customers - return sum + steps.filter(s => !(s.id === 'import' && state.customerType === 'new')).length + return sum + steps.filter(s => !(s.id === 'import' && effectiveCustomerType === 'new')).length }, 0) // Calculate stats @@ -200,19 +202,6 @@ export default function SDKDashboard() { return packageCompletion[prevPkg.id] < 100 } - // Show customer type selector if not set - if (!state.customerType) { - return ( -
- { - setCustomerType(type) - }} - /> -
- ) - } - return (
{/* Header */} @@ -220,16 +209,16 @@ export default function SDKDashboard() {

AI Compliance SDK

- {state.customerType === 'new' + {effectiveCustomerType === 'new' ? 'Neukunden-Modus: Erstellen Sie alle Compliance-Dokumente von Grund auf.' : 'Bestandskunden-Modus: Erweitern Sie bestehende Dokumente.'}

@@ -282,7 +271,7 @@ export default function SDKDashboard() { {/* Bestandskunden: Gap Analysis Banner */} - {state.customerType === 'existing' && state.importedDocuments.length === 0 && ( + {effectiveCustomerType === 'existing' && state.importedDocuments.length === 0 && (
@@ -348,7 +337,7 @@ export default function SDKDashboard() {
{SDK_PACKAGES.map(pkg => { const steps = getStepsForPackage(pkg.id) - const visibleSteps = steps.filter(s => !(s.id === 'import' && state.customerType === 'new')) + const visibleSteps = steps.filter(s => !(s.id === 'import' && effectiveCustomerType === 'new')) return ( 1 ? 'en' : ''}` +} + // ============================================================================= // CREATE PROJECT DIALOG // ============================================================================= @@ -41,7 +76,7 @@ function CreateProjectDialog({ open, onClose, onCreated, existingProjects }: Cre customerType, copyFromId || undefined ) - onCreated(project) + onCreated(normalizeProject(project)) setName('') setCopyFromId('') onClose() @@ -164,73 +199,253 @@ function CreateProjectDialog({ open, onClose, onCreated, existingProjects }: Cre } // ============================================================================= -// PROJECT CARD +// PROJECT ACTION DIALOG (Archive / Permanent Delete) // ============================================================================= -function ProjectCard({ project, onClick }: { project: ProjectInfo; onClick: () => void }) { - const timeAgo = formatTimeAgo(project.updatedAt) +type ActionStep = 'choose' | 'confirm-delete' + +function ProjectActionDialog({ + project, + onArchive, + onPermanentDelete, + onCancel, + isProcessing, +}: { + project: ProjectInfo + onArchive: () => void + onPermanentDelete: () => void + onCancel: () => void + isProcessing: boolean +}) { + const [step, setStep] = useState('choose') + + if (step === 'confirm-delete') { + return ( +
+
e.stopPropagation()} + > +
+
+ + + +
+

Endgueltig loeschen

+
+ +
+

+ Sind Sie sicher, dass Sie {project.name} unwiderruflich loeschen moechten? +

+

+ Alle Projektdaten, SDK-States und Dokumente werden permanent geloescht. Diese Aktion kann nicht rueckgaengig gemacht werden. +

+
+ +
+ + +
+
+
+ ) + } return ( - + + {/* Permanent delete option */} + +
+ +
- +
) } // ============================================================================= -// HELPER +// PROJECT CARD // ============================================================================= -function formatTimeAgo(dateStr: string): string { - const date = new Date(dateStr) - const now = Date.now() - const diff = now - date.getTime() - const seconds = Math.floor(diff / 1000) - if (seconds < 60) return 'Gerade eben' - const minutes = Math.floor(seconds / 60) - if (minutes < 60) return `vor ${minutes} Min` - const hours = Math.floor(minutes / 60) - if (hours < 24) return `vor ${hours} Std` - const days = Math.floor(hours / 24) - return `vor ${days} Tag${days > 1 ? 'en' : ''}` +function ProjectCard({ + project, + onClick, + onDelete, + onRestore, +}: { + project: ProjectInfo + onClick: () => void + onDelete?: () => void + onRestore?: () => void +}) { + const timeAgo = formatTimeAgo(project.updatedAt) + const isArchived = project.status === 'archived' + + return ( +
+ {/* Action buttons */} +
+ {isArchived && onRestore && ( + + )} + {onDelete && ( + + )} +
+ + {/* Card content */} + +
+ ) } // ============================================================================= @@ -239,10 +454,14 @@ function formatTimeAgo(dateStr: string): string { export function ProjectSelector() { const router = useRouter() - const { listProjects } = useSDK() + const { listProjects, archiveProject, restoreProject, permanentlyDeleteProject } = useSDK() const [projects, setProjects] = useState([]) + const [archivedProjects, setArchivedProjects] = useState([]) const [loading, setLoading] = useState(true) const [showCreateDialog, setShowCreateDialog] = useState(false) + const [actionTarget, setActionTarget] = useState(null) + const [isProcessing, setIsProcessing] = useState(false) + const [showArchived, setShowArchived] = useState(false) useEffect(() => { loadProjects() @@ -252,7 +471,9 @@ export function ProjectSelector() { setLoading(true) try { const result = await listProjects() - setProjects(result) + const all = result.map(normalizeProject) + setProjects(all.filter(p => p.status === 'active')) + setArchivedProjects(all.filter(p => p.status === 'archived')) } catch (error) { console.error('Failed to load projects:', error) } finally { @@ -261,6 +482,7 @@ export function ProjectSelector() { } const handleProjectClick = (project: ProjectInfo) => { + if (project.status === 'archived') return // archived projects are read-only in list router.push(`/sdk?project=${project.id}`) } @@ -268,6 +490,47 @@ export function ProjectSelector() { router.push(`/sdk?project=${project.id}`) } + const handleArchive = async () => { + if (!actionTarget) return + setIsProcessing(true) + try { + await archiveProject(actionTarget.id) + // Move from active to archived + setProjects(prev => prev.filter(p => p.id !== actionTarget.id)) + setArchivedProjects(prev => [...prev, { ...actionTarget, status: 'archived' as const }]) + setActionTarget(null) + } catch (error) { + console.error('Failed to archive project:', error) + } finally { + setIsProcessing(false) + } + } + + const handlePermanentDelete = async () => { + if (!actionTarget) return + setIsProcessing(true) + try { + await permanentlyDeleteProject(actionTarget.id) + setProjects(prev => prev.filter(p => p.id !== actionTarget.id)) + setArchivedProjects(prev => prev.filter(p => p.id !== actionTarget.id)) + setActionTarget(null) + } catch (error) { + console.error('Failed to delete project:', error) + } finally { + setIsProcessing(false) + } + } + + const handleRestore = async (project: ProjectInfo) => { + try { + await restoreProject(project.id) + setArchivedProjects(prev => prev.filter(p => p.id !== project.id)) + setProjects(prev => [...prev, { ...project, status: 'active' as const }]) + } catch (error) { + console.error('Failed to restore project:', error) + } + } + return (
{/* Header */} @@ -297,7 +560,7 @@ export function ProjectSelector() { )} {/* Empty State */} - {!loading && projects.length === 0 && ( + {!loading && projects.length === 0 && archivedProjects.length === 0 && (
@@ -321,7 +584,7 @@ export function ProjectSelector() {
)} - {/* Project Grid */} + {/* Active Projects */} {!loading && projects.length > 0 && (
{projects.map(project => ( @@ -329,11 +592,68 @@ export function ProjectSelector() { key={project.id} project={project} onClick={() => handleProjectClick(project)} + onDelete={() => setActionTarget(project)} /> ))}
)} + {/* No active but has archived */} + {!loading && projects.length === 0 && archivedProjects.length > 0 && ( +
+

Keine aktiven Projekte

+

+ Sie haben {archivedProjects.length} archivierte{archivedProjects.length === 1 ? 's' : ''} Projekt{archivedProjects.length === 1 ? '' : 'e'}. + Stellen Sie ein Projekt wieder her oder erstellen Sie ein neues. +

+ +
+ )} + + {/* Archived Projects Section */} + {!loading && archivedProjects.length > 0 && ( +
+ + + {showArchived && ( +
+ {archivedProjects.map(project => ( + handleProjectClick(project)} + onRestore={() => handleRestore(project)} + onDelete={() => setActionTarget(project)} + /> + ))} +
+ )} +
+ )} + {/* Create Dialog */} + + {/* Action Dialog (Archive / Delete) */} + {actionTarget && ( + setActionTarget(null)} + isProcessing={isProcessing} + /> + )}
) } diff --git a/admin-compliance/lib/sdk/api-client.ts b/admin-compliance/lib/sdk/api-client.ts index 18480b7..87f148c 100644 --- a/admin-compliance/lib/sdk/api-client.ts +++ b/admin-compliance/lib/sdk/api-client.ts @@ -600,9 +600,9 @@ export class SDKApiClient { /** * List all projects for the current tenant */ - async listProjects(): Promise<{ projects: ProjectInfo[]; total: number }> { + async listProjects(includeArchived = true): Promise<{ projects: ProjectInfo[]; total: number }> { const response = await this.fetchWithRetry<{ projects: ProjectInfo[]; total: number }>( - `${this.baseUrl}/projects?tenant_id=${encodeURIComponent(this.tenantId)}`, + `${this.baseUrl}/projects?tenant_id=${encodeURIComponent(this.tenantId)}&include_archived=${includeArchived}`, { method: 'GET', headers: { @@ -664,6 +664,23 @@ export class SDKApiClient { return response } + /** + * Get a single project by ID + */ + async getProject(projectId: string): Promise { + const response = await this.fetchWithRetry( + `${this.baseUrl}/projects/${projectId}?tenant_id=${encodeURIComponent(this.tenantId)}`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'X-Tenant-ID': this.tenantId, + }, + } + ) + return response + } + /** * Archive (soft-delete) a project */ @@ -680,6 +697,39 @@ export class SDKApiClient { ) } + /** + * Restore an archived project + */ + async restoreProject(projectId: string): Promise { + const response = await this.fetchWithRetry( + `${this.baseUrl}/projects/${projectId}/restore?tenant_id=${encodeURIComponent(this.tenantId)}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Tenant-ID': this.tenantId, + }, + } + ) + return response + } + + /** + * Permanently delete a project and all data + */ + async permanentlyDeleteProject(projectId: string): Promise { + await this.fetchWithRetry<{ success: boolean }>( + `${this.baseUrl}/projects/${projectId}/permanent?tenant_id=${encodeURIComponent(this.tenantId)}`, + { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + 'X-Tenant-ID': this.tenantId, + }, + } + ) + } + /** * Health check */ diff --git a/admin-compliance/lib/sdk/context.tsx b/admin-compliance/lib/sdk/context.tsx index 7c27d46..08df34a 100644 --- a/admin-compliance/lib/sdk/context.tsx +++ b/admin-compliance/lib/sdk/context.tsx @@ -560,6 +560,8 @@ interface SDKContextValue { listProjects: () => Promise switchProject: (projectId: string) => void archiveProject: (projectId: string) => Promise + restoreProject: (projectId: string) => Promise + permanentlyDeleteProject: (projectId: string) => Promise } const SDKContext = createContext(null) @@ -580,7 +582,7 @@ interface SDKProviderProps { export function SDKProvider({ children, - tenantId = 'default', + tenantId = '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e', userId = 'default', projectId, enableBackendSync = false, @@ -713,6 +715,16 @@ export function SDKProvider({ } } } + + // Load project metadata (name, status, etc.) from backend + if (enableBackendSync && projectId && apiClientRef.current) { + try { + const info = await apiClientRef.current.getProject(projectId) + dispatch({ type: 'SET_STATE', payload: { projectInfo: info } }) + } catch (err) { + console.warn('Failed to load project info:', err) + } + } } catch (error) { console.error('Failed to load SDK state:', error) } @@ -1101,6 +1113,9 @@ export function SDKProvider({ // Project Management const createProject = useCallback( async (name: string, customerType: CustomerType, copyFromProjectId?: string): Promise => { + if (!apiClientRef.current && enableBackendSync) { + apiClientRef.current = getSDKApiClient(tenantId, projectId) + } if (!apiClientRef.current) { throw new Error('Backend sync not enabled') } @@ -1110,16 +1125,20 @@ export function SDKProvider({ copy_from_project_id: copyFromProjectId, }) }, - [] + [enableBackendSync, tenantId, projectId] ) const listProjectsFn = useCallback(async (): Promise => { + // Ensure API client exists (may not be set yet if useEffect hasn't fired) + if (!apiClientRef.current && enableBackendSync) { + apiClientRef.current = getSDKApiClient(tenantId, projectId) + } if (!apiClientRef.current) { return [] } const result = await apiClientRef.current.listProjects() return result.projects - }, []) + }, [enableBackendSync, tenantId, projectId]) const switchProject = useCallback( (newProjectId: string) => { @@ -1133,12 +1152,41 @@ export function SDKProvider({ const archiveProjectFn = useCallback( async (archiveId: string): Promise => { + if (!apiClientRef.current && enableBackendSync) { + apiClientRef.current = getSDKApiClient(tenantId, projectId) + } if (!apiClientRef.current) { throw new Error('Backend sync not enabled') } await apiClientRef.current.archiveProject(archiveId) }, - [] + [enableBackendSync, tenantId, projectId] + ) + + const restoreProjectFn = useCallback( + async (restoreId: string): Promise => { + if (!apiClientRef.current && enableBackendSync) { + apiClientRef.current = getSDKApiClient(tenantId, projectId) + } + if (!apiClientRef.current) { + throw new Error('Backend sync not enabled') + } + return apiClientRef.current.restoreProject(restoreId) + }, + [enableBackendSync, tenantId, projectId] + ) + + const permanentlyDeleteProjectFn = useCallback( + async (deleteId: string): Promise => { + if (!apiClientRef.current && enableBackendSync) { + apiClientRef.current = getSDKApiClient(tenantId, projectId) + } + if (!apiClientRef.current) { + throw new Error('Backend sync not enabled') + } + await apiClientRef.current.permanentlyDeleteProject(deleteId) + }, + [enableBackendSync, tenantId, projectId] ) // Export @@ -1206,6 +1254,8 @@ export function SDKProvider({ listProjects: listProjectsFn, switchProject, archiveProject: archiveProjectFn, + restoreProject: restoreProjectFn, + permanentlyDeleteProject: permanentlyDeleteProjectFn, } return {children} diff --git a/backend-compliance/compliance/api/project_routes.py b/backend-compliance/compliance/api/project_routes.py index d08fec3..7e511de 100644 --- a/backend-compliance/compliance/api/project_routes.py +++ b/backend-compliance/compliance/api/project_routes.py @@ -179,7 +179,7 @@ async def create_project( db.execute( text(""" INSERT INTO sdk_states (tenant_id, project_id, state, version, created_at, updated_at) - VALUES (:tenant_id, :project_id, :state::jsonb, 1, NOW(), NOW()) + VALUES (:tenant_id, :project_id, CAST(:state AS jsonb), 1, NOW(), NOW()) """), { "tenant_id": tenant_id, @@ -298,3 +298,79 @@ async def archive_project( raise finally: db.close() + + +@router.post("/{project_id}/restore") +async def restore_project( + project_id: str, + tenant_id: str = Depends(get_tenant_id), +): + """Restore an archived project back to active.""" + db = SessionLocal() + try: + result = db.execute( + text(""" + UPDATE compliance_projects + SET status = 'active', archived_at = NULL, updated_at = NOW() + WHERE id = :project_id AND tenant_id = :tenant_id AND status = 'archived' + RETURNING id, tenant_id, name, description, customer_type, status, + project_version, completion_percentage, created_at, updated_at + """), + {"project_id": project_id, "tenant_id": tenant_id}, + ) + row = result.fetchone() + if not row: + raise HTTPException(status_code=404, detail="Project not found or not archived") + db.commit() + logger.info("Restored project %s for tenant %s", project_id, tenant_id) + return _row_to_response(row) + except HTTPException: + raise + except Exception: + db.rollback() + raise + finally: + db.close() + + +@router.delete("/{project_id}/permanent") +async def permanently_delete_project( + project_id: str, + tenant_id: str = Depends(get_tenant_id), +): + """Permanently delete a project and all associated data.""" + db = SessionLocal() + try: + # Verify project exists and belongs to tenant + check = db.execute( + text(""" + SELECT id FROM compliance_projects + WHERE id = :project_id AND tenant_id = :tenant_id + """), + {"project_id": project_id, "tenant_id": tenant_id}, + ).fetchone() + if not check: + raise HTTPException(status_code=404, detail="Project not found") + + # Delete sdk_states (CASCADE should handle this, but be explicit) + db.execute( + text("DELETE FROM sdk_states WHERE project_id = :project_id AND tenant_id = :tenant_id"), + {"project_id": project_id, "tenant_id": tenant_id}, + ) + + # Delete the project itself + db.execute( + text("DELETE FROM compliance_projects WHERE id = :project_id AND tenant_id = :tenant_id"), + {"project_id": project_id, "tenant_id": tenant_id}, + ) + + db.commit() + logger.info("Permanently deleted project %s for tenant %s", project_id, tenant_id) + return {"success": True, "id": project_id, "status": "deleted"} + except HTTPException: + raise + except Exception: + db.rollback() + raise + finally: + db.close()