diff --git a/admin-compliance/app/sdk/_components/doc-labels.ts b/admin-compliance/app/sdk/_components/doc-labels.ts index be1c3cb..4331f70 100644 --- a/admin-compliance/app/sdk/_components/doc-labels.ts +++ b/admin-compliance/app/sdk/_components/doc-labels.ts @@ -107,6 +107,9 @@ export const DOC_LABELS: Record = { ai_usage_policy: { label: 'KI-Nutzungsrichtlinie', category: 'KI & Cyber' }, cybersecurity_policy: { label: 'Cybersecurity-Richtlinie (CRA)', category: 'KI & Cyber' }, byod_policy: { label: 'BYOD-Richtlinie', category: 'KI & Cyber' }, + + // ── SOP ────────────────────────────────────────────────────────── + standard_operating_procedure: { label: 'Standard Operating Procedure', category: 'Prozesse' }, } export const CATEGORY_COLORS: Record = { @@ -124,4 +127,5 @@ export const CATEGORY_COLORS: Record = { BCM: 'bg-yellow-50 text-yellow-700', 'KI & Cyber': 'bg-cyan-50 text-cyan-700', Marketing: 'bg-pink-50 text-pink-700', + Prozesse: 'bg-teal-50 text-teal-700', } diff --git a/admin-compliance/lib/sdk/company-profile-preset-data.ts b/admin-compliance/lib/sdk/company-profile-preset-data.ts index 601c57a..bd5fb0e 100644 --- a/admin-compliance/lib/sdk/company-profile-preset-data.ts +++ b/admin-compliance/lib/sdk/company-profile-preset-data.ts @@ -224,7 +224,7 @@ export const COMPANY_PROFILE_PRESETS: CompanyProfilePreset[] = [ 'transfer_impact_assessment', 'scc_companion', 'vendor_risk_management_policy', 'third_party_security_policy', 'business_continuity_policy', 'disaster_recovery_policy', 'crisis_management_policy', - 'ai_usage_policy', + 'ai_usage_policy', 'standard_operating_procedure', ], }, { @@ -262,6 +262,7 @@ export const COMPANY_PROFILE_PRESETS: CompanyProfilePreset[] = [ 'vendor_risk_management_policy', 'third_party_security_policy', 'business_continuity_policy', 'disaster_recovery_policy', 'ai_usage_policy', 'cybersecurity_policy', 'byod_policy', + 'standard_operating_procedure', ], }, { @@ -296,6 +297,7 @@ export const COMPANY_PROFILE_PRESETS: CompanyProfilePreset[] = [ 'transfer_impact_assessment', 'vendor_risk_management_policy', 'supplier_security_policy', 'business_continuity_policy', 'disaster_recovery_policy', 'crisis_management_policy', + 'standard_operating_procedure', ], }, { diff --git a/admin-compliance/lib/sdk/types/document-generator.ts b/admin-compliance/lib/sdk/types/document-generator.ts index ac86239..3472939 100644 --- a/admin-compliance/lib/sdk/types/document-generator.ts +++ b/admin-compliance/lib/sdk/types/document-generator.ts @@ -85,6 +85,8 @@ export type TemplateType = | 'tom_documentation' | 'loeschkonzept' | 'pflichtenregister' + // SOP (Migration 112) + | 'standard_operating_procedure' export type Jurisdiction = 'DE' | 'AT' | 'CH' | 'EU' | 'US' | 'INTL' diff --git a/backend-compliance/compliance/api/__init__.py b/backend-compliance/compliance/api/__init__.py index b35b11c..6d681bc 100644 --- a/backend-compliance/compliance/api/__init__.py +++ b/backend-compliance/compliance/api/__init__.py @@ -63,6 +63,8 @@ _ROUTER_MODULES = [ "tom_mapping_routes", "llm_audit_routes", "assertion_routes", + "org_role_routes", + "document_review_routes", ] _loaded_count = 0 diff --git a/backend-compliance/compliance/api/document_review_routes.py b/backend-compliance/compliance/api/document_review_routes.py new file mode 100644 index 0000000..a4f5d05 --- /dev/null +++ b/backend-compliance/compliance/api/document_review_routes.py @@ -0,0 +1,312 @@ +""" +FastAPI routes for Document Review Workflow. + +Tracks which compliance documents have been sent for review, their status, +and handles email notifications to reviewers. + +Endpoints: + GET /document-reviews — list reviews with filters + GET /document-reviews/stats — counts by status + POST /document-reviews — create review (auto-assign from mapping) + GET /document-reviews/{id} — single review + POST /document-reviews/{id}/send — send notification email + POST /document-reviews/{id}/approve — mark as approved + POST /document-reviews/{id}/reject — mark as rejected + GET /document-reviews/for-document — reviews for a specific doc type +""" + +import hashlib +import logging +from datetime import datetime +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel +from sqlalchemy import text +from sqlalchemy.orm import Session + +from classroom_engine.database import get_db +from .tenant_utils import get_tenant_id as _get_tenant_id +from .db_utils import row_to_dict as _row_to_dict + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/document-reviews", tags=["document-reviews"]) + + +# ============================================================================= +# Schemas +# ============================================================================= + + +class ReviewCreate(BaseModel): + document_type: str + document_title: str + document_content: Optional[str] = None + project_id: Optional[str] = None + submitted_by: Optional[str] = None + review_link: Optional[str] = None + + +class ReviewReject(BaseModel): + comment: str + + +# ============================================================================= +# Routes +# ============================================================================= + + +@router.get("") +def list_reviews( + project_id: Optional[str] = Query(None), + status: Optional[str] = Query(None), + document_type: Optional[str] = Query(None), + reviewer_role_key: Optional[str] = Query(None), + limit: int = Query(50, le=200), + offset: int = Query(0), + db: Session = Depends(get_db), + tenant_id: str = Depends(_get_tenant_id), +): + where = ["tenant_id = :tid"] + params = {"tid": tenant_id, "lim": limit, "off": offset} + if project_id: + where.append("project_id = :pid") + params["pid"] = project_id + if status: + where.append("status = :status") + params["status"] = status + if document_type: + where.append("document_type = :dt") + params["dt"] = document_type + if reviewer_role_key: + where.append("reviewer_role_key = :rrk") + params["rrk"] = reviewer_role_key + + q = text(f""" + SELECT * FROM compliance_document_reviews + WHERE {' AND '.join(where)} + ORDER BY created_at DESC LIMIT :lim OFFSET :off + """) + rows = db.execute(q, params).fetchall() + return [_row_to_dict(r) for r in rows] + + +@router.get("/stats") +def review_stats( + project_id: Optional[str] = Query(None), + db: Session = Depends(get_db), + tenant_id: str = Depends(_get_tenant_id), +): + where = "tenant_id = :tid" + params = {"tid": tenant_id} + if project_id: + where += " AND project_id = :pid" + params["pid"] = project_id + q = text(f"SELECT status, COUNT(*) as count FROM compliance_document_reviews WHERE {where} GROUP BY status") + rows = db.execute(q, params).fetchall() + return {r.status: r.count for r in rows} + + +@router.get("/for-document") +def reviews_for_document( + document_type: str = Query(...), + project_id: Optional[str] = Query(None), + db: Session = Depends(get_db), + tenant_id: str = Depends(_get_tenant_id), +): + where = "tenant_id = :tid AND document_type = :dt" + params = {"tid": tenant_id, "dt": document_type} + if project_id: + where += " AND project_id = :pid" + params["pid"] = project_id + q = text(f"SELECT * FROM compliance_document_reviews WHERE {where} ORDER BY created_at DESC LIMIT 10") + rows = db.execute(q, params).fetchall() + return [_row_to_dict(r) for r in rows] + + +@router.post("") +def create_review( + body: ReviewCreate, + db: Session = Depends(get_db), + tenant_id: str = Depends(_get_tenant_id), +): + # Find reviewer(s) from mapping + org_roles + q = text(""" + SELECT m.role_key, m.is_primary, r.person_name, r.person_email, r.role_label + FROM compliance_document_role_mapping m + LEFT JOIN compliance_org_roles r + ON r.tenant_id = m.tenant_id AND r.role_key = m.role_key + AND (r.project_id = :pid OR r.project_id IS NULL) + WHERE m.tenant_id = :tid AND m.document_type = :dt + ORDER BY m.is_primary DESC + """) + mappings = db.execute(q, {"tid": tenant_id, "dt": body.document_type, "pid": body.project_id}).fetchall() + + if not mappings: + raise HTTPException(404, f"No reviewer mapping found for document type '{body.document_type}'") + + content_hash = hashlib.sha256(body.document_content.encode()).hexdigest() if body.document_content else None + created = [] + for m in mappings: + m_dict = _row_to_dict(m) + ins = text(""" + INSERT INTO compliance_document_reviews + (tenant_id, project_id, document_type, document_title, document_content_hash, + reviewer_role_key, reviewer_name, reviewer_email, submitted_by, review_link, submitted_at) + VALUES (:tid, :pid, :dt, :title, :hash, :rrk, :rn, :re, :sb, :rl, NOW()) + RETURNING * + """) + row = db.execute(ins, { + "tid": tenant_id, "pid": body.project_id, "dt": body.document_type, + "title": body.document_title, "hash": content_hash, + "rrk": m_dict["role_key"], "rn": m_dict.get("person_name"), + "re": m_dict.get("person_email"), "sb": body.submitted_by, + "rl": body.review_link, + }).fetchone() + created.append(_row_to_dict(row)) + db.commit() + return created + + +@router.get("/{review_id}") +def get_review( + review_id: str, + db: Session = Depends(get_db), + tenant_id: str = Depends(_get_tenant_id), +): + q = text("SELECT * FROM compliance_document_reviews WHERE id = :rid AND tenant_id = :tid") + row = db.execute(q, {"rid": review_id, "tid": tenant_id}).fetchone() + if not row: + raise HTTPException(404, "Review not found") + return _row_to_dict(row) + + +@router.post("/{review_id}/send") +def send_notification( + review_id: str, + db: Session = Depends(get_db), + tenant_id: str = Depends(_get_tenant_id), +): + q = text("SELECT * FROM compliance_document_reviews WHERE id = :rid AND tenant_id = :tid") + row = db.execute(q, {"rid": review_id, "tid": tenant_id}).fetchone() + if not row: + raise HTTPException(404, "Review not found") + review = _row_to_dict(row) + if not review.get("reviewer_email"): + raise HTTPException(400, "No email for reviewer — assign a person to this role first") + + try: + from compliance.services.smtp_sender import send_email + result = send_email( + recipient=review["reviewer_email"], + subject=f"[BreakPilot] Dokument zur Pruefung: {review['document_title']}", + body_html=f""" +

Dokument zur Pruefung

+

Sehr geehrte/r {review.get('reviewer_name') or 'Pruefer/in'},

+

das folgende Dokument wurde Ihnen zur inhaltlichen Pruefung zugewiesen:

+ + + + + + + +
Dokument:{review['document_title']}
Typ:{review['document_type']}
Eingereicht von:{review.get('submitted_by') or 'System'}
+

Bitte pruefen Sie das Dokument auf inhaltliche Richtigkeit, + Vollstaendigkeit und Umsetzbarkeit.

+ {f'

Dokument oeffnen

' if review.get("review_link") else ''} +

BreakPilot Compliance SDK

+ """, + ) + # Update review status + db.execute(text(""" + UPDATE compliance_document_reviews + SET status = 'in_review', email_sent = TRUE, email_sent_at = NOW(), updated_at = NOW() + WHERE id = :rid + """), {"rid": review_id}) + db.commit() + return {"sent": True, "email": review["reviewer_email"], "result": result} + except Exception as e: + logger.error("Failed to send review email: %s", e) + raise HTTPException(500, f"Email sending failed: {e}") + + +@router.post("/{review_id}/approve") +def approve_review( + review_id: str, + db: Session = Depends(get_db), + tenant_id: str = Depends(_get_tenant_id), +): + q = text(""" + UPDATE compliance_document_reviews + SET status = 'approved', reviewed_at = NOW(), updated_at = NOW() + WHERE id = :rid AND tenant_id = :tid + RETURNING * + """) + row = db.execute(q, {"rid": review_id, "tid": tenant_id}).fetchone() + if not row: + raise HTTPException(404, "Review not found") + db.commit() + review = _row_to_dict(row) + + # Notify all OTHER roles mapped to this document type about the approval + _notify_approval(db, tenant_id, review) + + return review + + +def _notify_approval(db: Session, tenant_id: str, review: dict): + """Send approval notification to all other roles mapped to this document type.""" + try: + from compliance.services.smtp_sender import send_email + q = text(""" + SELECT DISTINCT r.person_name, r.person_email, r.role_label + FROM compliance_document_role_mapping m + JOIN compliance_org_roles r + ON r.tenant_id = m.tenant_id AND r.role_key = m.role_key + AND (r.project_id = :pid OR r.project_id IS NULL) + WHERE m.tenant_id = :tid AND m.document_type = :dt + AND m.role_key != :reviewer_key AND r.person_email IS NOT NULL + """) + others = db.execute(q, { + "tid": tenant_id, "dt": review["document_type"], + "pid": review.get("project_id"), "reviewer_key": review["reviewer_role_key"], + }).fetchall() + for other in others: + o = _row_to_dict(other) + send_email( + recipient=o["person_email"], + subject=f"[BreakPilot] Freigabe: {review['document_title']}", + body_html=f""" +

Dokument freigegeben

+

Sehr geehrte/r {o.get('person_name') or o['role_label']},

+

das Dokument {review['document_title']} wurde von + {review.get('reviewer_name') or review['reviewer_role_key']} freigegeben.

+

Bitte pruefen Sie, ob fuer Ihren Verantwortungsbereich Handlungsbedarf besteht + (z.B. Schulungsbedarf, Prozessanpassungen).

+

BreakPilot Compliance SDK

+ """, + ) + logger.info("Notified %d other roles about approval of %s", len(others), review["document_title"]) + except Exception as e: + logger.warning("Approval notification failed (non-blocking): %s", e) + + +@router.post("/{review_id}/reject") +def reject_review( + review_id: str, + body: ReviewReject, + db: Session = Depends(get_db), + tenant_id: str = Depends(_get_tenant_id), +): + q = text(""" + UPDATE compliance_document_reviews + SET status = 'rejected', reviewed_at = NOW(), review_comment = :comment, updated_at = NOW() + WHERE id = :rid AND tenant_id = :tid + RETURNING * + """) + row = db.execute(q, {"rid": review_id, "tid": tenant_id, "comment": body.comment}).fetchone() + if not row: + raise HTTPException(404, "Review not found") + db.commit() + return _row_to_dict(row) diff --git a/backend-compliance/compliance/api/org_role_routes.py b/backend-compliance/compliance/api/org_role_routes.py new file mode 100644 index 0000000..9068896 --- /dev/null +++ b/backend-compliance/compliance/api/org_role_routes.py @@ -0,0 +1,255 @@ +""" +FastAPI routes for Organizational Compliance Roles. + +Manages the 7 standard compliance roles (DSB, GF, IT-Leiter, etc.) +and the document-to-role mapping that determines who reviews which documents. + +Endpoints: + GET /org-roles — list roles for tenant/project + POST /org-roles — create/upsert a role + PUT /org-roles/{id} — update role details + DELETE /org-roles/{id} — remove a role + GET /org-roles/defaults — 7 standard role definitions + POST /org-roles/seed — seed default roles for a project + POST /org-roles/{id}/send-test — send test email to role + GET /org-roles/mapping — document-to-role mapping + PUT /org-roles/mapping — update mapping +""" + +import logging +from typing import Optional, List + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel +from sqlalchemy import text +from sqlalchemy.orm import Session + +from classroom_engine.database import get_db +from .tenant_utils import get_tenant_id as _get_tenant_id +from .db_utils import row_to_dict as _row_to_dict + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/org-roles", tags=["org-roles"]) + +# ============================================================================= +# Standard role definitions +# ============================================================================= + +DEFAULT_ROLES = [ + {"role_key": "dsb", "role_label": "Datenschutzbeauftragter (DSB)"}, + {"role_key": "gf", "role_label": "Geschaeftsfuehrung"}, + {"role_key": "it_leiter", "role_label": "IT-Leiter / CISO"}, + {"role_key": "hr_leitung", "role_label": "HR-Leitung"}, + {"role_key": "marketing_leitung", "role_label": "Marketing-Leitung"}, + {"role_key": "compliance_beauftragter", "role_label": "Compliance-Beauftragter"}, + {"role_key": "einkauf", "role_label": "Einkauf / Vendor Management"}, +] + +# ============================================================================= +# Schemas +# ============================================================================= + + +class OrgRoleCreate(BaseModel): + role_key: str + role_label: str + person_name: Optional[str] = None + person_email: Optional[str] = None + department: Optional[str] = None + project_id: Optional[str] = None + + +class OrgRoleUpdate(BaseModel): + role_label: Optional[str] = None + person_name: Optional[str] = None + person_email: Optional[str] = None + department: Optional[str] = None + is_active: Optional[bool] = None + + +class MappingEntry(BaseModel): + document_type: str + role_key: str + is_primary: bool = True + + +class MappingUpdate(BaseModel): + entries: List[MappingEntry] + + +# ============================================================================= +# Routes +# ============================================================================= + + +@router.get("") +def list_roles( + project_id: Optional[str] = Query(None), + db: Session = Depends(get_db), + tenant_id: str = Depends(_get_tenant_id), +): + q = text(""" + SELECT * FROM compliance_org_roles + WHERE tenant_id = :tid AND (project_id = :pid OR (:pid IS NULL AND project_id IS NULL)) + ORDER BY role_key + """) + rows = db.execute(q, {"tid": tenant_id, "pid": project_id}).fetchall() + return [_row_to_dict(r) for r in rows] + + +@router.get("/defaults") +def get_defaults(): + return DEFAULT_ROLES + + +@router.post("") +def create_role( + body: OrgRoleCreate, + db: Session = Depends(get_db), + tenant_id: str = Depends(_get_tenant_id), +): + q = text(""" + INSERT INTO compliance_org_roles (tenant_id, project_id, role_key, role_label, person_name, person_email, department) + VALUES (:tid, :pid, :rk, :rl, :pn, :pe, :dept) + ON CONFLICT (tenant_id, project_id, role_key) DO UPDATE + SET role_label = EXCLUDED.role_label, + person_name = COALESCE(EXCLUDED.person_name, compliance_org_roles.person_name), + person_email = COALESCE(EXCLUDED.person_email, compliance_org_roles.person_email), + department = COALESCE(EXCLUDED.department, compliance_org_roles.department), + updated_at = NOW() + RETURNING * + """) + row = db.execute(q, { + "tid": tenant_id, "pid": body.project_id, "rk": body.role_key, + "rl": body.role_label, "pn": body.person_name, "pe": body.person_email, + "dept": body.department, + }).fetchone() + db.commit() + return _row_to_dict(row) + + +@router.put("/{role_id}") +def update_role( + role_id: str, + body: OrgRoleUpdate, + db: Session = Depends(get_db), + tenant_id: str = Depends(_get_tenant_id), +): + sets, params = [], {"rid": role_id, "tid": tenant_id} + for field in ["role_label", "person_name", "person_email", "department", "is_active"]: + val = getattr(body, field, None) + if val is not None: + sets.append(f"{field} = :{field}") + params[field] = val + if not sets: + raise HTTPException(400, "No fields to update") + sets.append("updated_at = NOW()") + q = text(f"UPDATE compliance_org_roles SET {', '.join(sets)} WHERE id = :rid AND tenant_id = :tid RETURNING *") + row = db.execute(q, params).fetchone() + if not row: + raise HTTPException(404, "Role not found") + db.commit() + return _row_to_dict(row) + + +@router.delete("/{role_id}") +def delete_role( + role_id: str, + db: Session = Depends(get_db), + tenant_id: str = Depends(_get_tenant_id), +): + q = text("DELETE FROM compliance_org_roles WHERE id = :rid AND tenant_id = :tid") + result = db.execute(q, {"rid": role_id, "tid": tenant_id}) + db.commit() + if result.rowcount == 0: + raise HTTPException(404, "Role not found") + return {"deleted": True} + + +@router.post("/seed") +def seed_roles( + project_id: Optional[str] = Query(None), + db: Session = Depends(get_db), + tenant_id: str = Depends(_get_tenant_id), +): + created = 0 + for role in DEFAULT_ROLES: + q = text(""" + INSERT INTO compliance_org_roles (tenant_id, project_id, role_key, role_label) + VALUES (:tid, :pid, :rk, :rl) + ON CONFLICT (tenant_id, project_id, role_key) DO NOTHING + """) + result = db.execute(q, {"tid": tenant_id, "pid": project_id, "rk": role["role_key"], "rl": role["role_label"]}) + created += result.rowcount + db.commit() + return {"seeded": created, "total": len(DEFAULT_ROLES)} + + +@router.post("/{role_id}/send-test") +def send_test_email( + role_id: str, + db: Session = Depends(get_db), + tenant_id: str = Depends(_get_tenant_id), +): + q = text("SELECT * FROM compliance_org_roles WHERE id = :rid AND tenant_id = :tid") + role = db.execute(q, {"rid": role_id, "tid": tenant_id}).fetchone() + if not role: + raise HTTPException(404, "Role not found") + role_dict = _row_to_dict(role) + if not role_dict.get("person_email"): + raise HTTPException(400, "No email configured for this role") + + try: + from compliance.services.smtp_sender import send_email + result = send_email( + recipient=role_dict["person_email"], + subject=f"[BreakPilot] Test-E-Mail fuer {role_dict['role_label']}", + body_html=f""" +

Test-E-Mail

+

Diese E-Mail bestaetigt, dass die Zustellung an die Rolle + {role_dict['role_label']} funktioniert.

+

Empfaenger: {role_dict['person_name'] or 'N/A'} ({role_dict['person_email']})

+

Gesendet von BreakPilot Compliance SDK

+ """, + ) + return {"sent": True, "email": role_dict["person_email"], "result": result} + except Exception as e: + logger.error("Failed to send test email: %s", e) + raise HTTPException(500, f"Email sending failed: {e}") + + +# ============================================================================= +# Document-to-Role Mapping +# ============================================================================= + + +@router.get("/mapping") +def get_mapping( + db: Session = Depends(get_db), + tenant_id: str = Depends(_get_tenant_id), +): + q = text(""" + SELECT * FROM compliance_document_role_mapping + WHERE tenant_id = :tid + ORDER BY document_type, role_key + """) + rows = db.execute(q, {"tid": tenant_id}).fetchall() + return [_row_to_dict(r) for r in rows] + + +@router.put("/mapping") +def update_mapping( + body: MappingUpdate, + db: Session = Depends(get_db), + tenant_id: str = Depends(_get_tenant_id), +): + for entry in body.entries: + q = text(""" + INSERT INTO compliance_document_role_mapping (tenant_id, document_type, role_key, is_primary) + VALUES (:tid, :dt, :rk, :ip) + ON CONFLICT (tenant_id, document_type, role_key) DO UPDATE + SET is_primary = EXCLUDED.is_primary + """) + db.execute(q, {"tid": tenant_id, "dt": entry.document_type, "rk": entry.role_key, "ip": entry.is_primary}) + db.commit() + return {"updated": len(body.entries)} diff --git a/backend-compliance/migrations/111_org_roles_reviews.sql b/backend-compliance/migrations/111_org_roles_reviews.sql new file mode 100644 index 0000000..4628849 --- /dev/null +++ b/backend-compliance/migrations/111_org_roles_reviews.sql @@ -0,0 +1,177 @@ +-- Migration 111: Organizational Compliance Roles + Document Review Workflow +-- Creates tables for: +-- 1. compliance_org_roles — role assignments per project (DSB, GF, IT-Leiter, etc.) +-- 2. compliance_document_reviews — review tracking for generated documents +-- 3. compliance_document_role_mapping — which roles review which document types + +BEGIN; + +-- ============================================================================= +-- Table 1: Organizational Compliance Roles +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS compliance_org_roles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + project_id UUID, + role_key VARCHAR(50) NOT NULL, + role_label VARCHAR(200) NOT NULL, + person_name VARCHAR(300), + person_email VARCHAR(300), + department VARCHAR(200), + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(tenant_id, project_id, role_key) +); + +CREATE INDEX IF NOT EXISTS idx_org_roles_tenant ON compliance_org_roles(tenant_id); +CREATE INDEX IF NOT EXISTS idx_org_roles_project ON compliance_org_roles(tenant_id, project_id); + +-- ============================================================================= +-- Table 2: Document Reviews +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS compliance_document_reviews ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + project_id UUID, + document_type VARCHAR(100) NOT NULL, + document_title VARCHAR(500) NOT NULL, + document_content_hash VARCHAR(64), + reviewer_role_key VARCHAR(50) NOT NULL, + reviewer_name VARCHAR(300), + reviewer_email VARCHAR(300), + status VARCHAR(20) NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'in_review', 'approved', 'rejected')), + submitted_at TIMESTAMPTZ, + submitted_by VARCHAR(200), + reviewed_at TIMESTAMPTZ, + review_comment TEXT, + review_link TEXT, + email_sent BOOLEAN NOT NULL DEFAULT FALSE, + email_sent_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_doc_reviews_tenant ON compliance_document_reviews(tenant_id); +CREATE INDEX IF NOT EXISTS idx_doc_reviews_status ON compliance_document_reviews(tenant_id, status); +CREATE INDEX IF NOT EXISTS idx_doc_reviews_doctype ON compliance_document_reviews(document_type); + +-- ============================================================================= +-- Table 3: Document-to-Role Mapping (seed defaults) +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS compliance_document_role_mapping ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + document_type VARCHAR(100) NOT NULL, + role_key VARCHAR(50) NOT NULL, + is_primary BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(tenant_id, document_type, role_key) +); + +CREATE INDEX IF NOT EXISTS idx_role_mapping_tenant ON compliance_document_role_mapping(tenant_id); + +-- ============================================================================= +-- Seed: Default document-to-role mapping (tenant_id = '__default__') +-- Every document type has at least one primary reviewer. +-- Tenants get a copy on first use; editable per tenant. +-- ============================================================================= + +-- DSB — Datenschutzbeauftragter +INSERT INTO compliance_document_role_mapping (tenant_id, document_type, role_key) VALUES +('__default__', 'privacy_policy', 'dsb'), ('__default__', 'vvt_register', 'dsb'), +('__default__', 'tom_documentation', 'dsb'), ('__default__', 'dsfa', 'dsb'), +('__default__', 'loeschkonzept', 'dsb'), ('__default__', 'dpa', 'dsb'), +('__default__', 'pflichtenregister', 'dsb'), ('__default__', 'data_protection_concept', 'dsb'), +('__default__', 'data_protection_policy', 'dsb'), ('__default__', 'data_retention_policy', 'dsb'), +('__default__', 'data_transfer_policy', 'dsb'), ('__default__', 'data_classification_policy', 'dsb'), +('__default__', 'privacy_incident_policy', 'dsb'), ('__default__', 'informationspflichten', 'dsb'), +('__default__', 'verpflichtungserklaerung', 'dsb'), ('__default__', 'consent_texts', 'dsb'), +('__default__', 'dsr_process_art15', 'dsb'), ('__default__', 'dsr_process_art16', 'dsb'), +('__default__', 'dsr_process_art17', 'dsb'), ('__default__', 'dsr_process_art18', 'dsb'), +('__default__', 'dsr_process_art19', 'dsb'), ('__default__', 'dsr_process_art20', 'dsb'), +('__default__', 'dsr_process_art21', 'dsb') +ON CONFLICT DO NOTHING; + +-- GF — Geschaeftsfuehrung +INSERT INTO compliance_document_role_mapping (tenant_id, document_type, role_key) VALUES +('__default__', 'agb', 'gf'), ('__default__', 'terms_of_use', 'gf'), +('__default__', 'nda', 'gf'), ('__default__', 'sla', 'gf'), +('__default__', 'impressum', 'gf'), ('__default__', 'widerruf', 'gf'), +('__default__', 'whistleblower_policy', 'gf'), +('__default__', 'cloud_service_agreement', 'gf'), +('__default__', 'standard_operating_procedure', 'gf'), +('__default__', 'data_usage_clause', 'gf') +ON CONFLICT DO NOTHING; + +-- IT-Leiter / CISO +INSERT INTO compliance_document_role_mapping (tenant_id, document_type, role_key) VALUES +('__default__', 'it_security_concept', 'it_leiter'), +('__default__', 'isms_manual', 'it_leiter'), +('__default__', 'backup_recovery_concept', 'it_leiter'), +('__default__', 'logging_concept', 'it_leiter'), +('__default__', 'incident_response_plan', 'it_leiter'), +('__default__', 'access_control_concept', 'it_leiter'), +('__default__', 'risk_management_concept', 'it_leiter'), +('__default__', 'information_security_policy', 'it_leiter'), +('__default__', 'access_control_policy', 'it_leiter'), +('__default__', 'password_policy', 'it_leiter'), +('__default__', 'encryption_policy', 'it_leiter'), +('__default__', 'logging_policy', 'it_leiter'), +('__default__', 'backup_policy', 'it_leiter'), +('__default__', 'incident_response_policy', 'it_leiter'), +('__default__', 'change_management_policy', 'it_leiter'), +('__default__', 'patch_management_policy', 'it_leiter'), +('__default__', 'asset_management_policy', 'it_leiter'), +('__default__', 'cloud_security_policy', 'it_leiter'), +('__default__', 'devsecops_policy', 'it_leiter'), +('__default__', 'secrets_management_policy', 'it_leiter'), +('__default__', 'vulnerability_management_policy', 'it_leiter'), +('__default__', 'cybersecurity_policy', 'it_leiter'), +('__default__', 'business_continuity_policy', 'it_leiter'), +('__default__', 'disaster_recovery_policy', 'it_leiter'), +('__default__', 'crisis_management_policy', 'it_leiter') +ON CONFLICT DO NOTHING; + +-- HR-Leitung +INSERT INTO compliance_document_role_mapping (tenant_id, document_type, role_key) VALUES +('__default__', 'employee_dsi', 'hr_leitung'), +('__default__', 'applicant_dsi', 'hr_leitung'), +('__default__', 'employee_security_policy', 'hr_leitung'), +('__default__', 'security_awareness_policy', 'hr_leitung'), +('__default__', 'remote_work_policy', 'hr_leitung'), +('__default__', 'offboarding_policy', 'hr_leitung'), +('__default__', 'byod_policy', 'hr_leitung') +ON CONFLICT DO NOTHING; + +-- Marketing-Leitung +INSERT INTO compliance_document_role_mapping (tenant_id, document_type, role_key) VALUES +('__default__', 'cookie_banner', 'marketing_leitung'), +('__default__', 'cookie_policy', 'marketing_leitung'), +('__default__', 'social_media_dsi', 'marketing_leitung'), +('__default__', 'community_guidelines', 'marketing_leitung'), +('__default__', 'acceptable_use', 'marketing_leitung'), +('__default__', 'media_content_policy', 'marketing_leitung'), +('__default__', 'copyright_policy', 'marketing_leitung'), +('__default__', 'video_conference_dsi', 'marketing_leitung') +ON CONFLICT DO NOTHING; + +-- Compliance-Beauftragter +INSERT INTO compliance_document_role_mapping (tenant_id, document_type, role_key) VALUES +('__default__', 'ai_usage_policy', 'compliance_beauftragter') +ON CONFLICT DO NOTHING; + +-- Einkauf / Vendor Management +INSERT INTO compliance_document_role_mapping (tenant_id, document_type, role_key) VALUES +('__default__', 'vendor_risk_management_policy', 'einkauf'), +('__default__', 'third_party_security_policy', 'einkauf'), +('__default__', 'supplier_security_policy', 'einkauf'), +('__default__', 'transfer_impact_assessment', 'einkauf'), +('__default__', 'scc_companion', 'einkauf') +ON CONFLICT DO NOTHING; + +COMMIT; diff --git a/backend-compliance/migrations/112_sop_template.sql b/backend-compliance/migrations/112_sop_template.sql new file mode 100644 index 0000000..b23bead --- /dev/null +++ b/backend-compliance/migrations/112_sop_template.sql @@ -0,0 +1,137 @@ +-- Migration 112: Standard Operating Procedure (SOP) Template +-- ISO 9001-konforme Struktur, eigene Formulierung (kein Normtext) + +INSERT INTO compliance_legal_templates ( + tenant_id, document_type, template_type, language, jurisdiction, + document_title, description, version, status, + text, placeholders, + license_id, license_name, source_name, attribution_required +) VALUES ( + '__default__', + 'standard_operating_procedure', + 'standard_operating_procedure', + 'de', + 'DE', + 'Standard Operating Procedure (SOP)', + 'Vorlage fuer standardisierte Verfahrensanweisungen mit Rollen-Matrix, Ablaufbeschreibung und Freigabeprozess', + '1.0', + 'published', + $template$# Standard Operating Procedure (SOP) + +**{{COMPANY_NAME}}** | SOP-Nr.: {{SOP_NUMBER}} | Version {{DOCUMENT_VERSION}} | Stand: {{VERSION_DATE}} + +--- + +## 1. Zweck + +{{SOP_PURPOSE}} + +## 2. Geltungsbereich + +{{SOP_SCOPE}} + +## 3. Verantwortlichkeiten + +| Rolle | Verantwortung | Name | +|-------|---------------|------| +| Ersteller | Erstellung und Pflege dieser SOP | {{SOP_AUTHOR_NAME}} | +| Pruefer/in | Fachliche Pruefung auf Richtigkeit | {{SOP_REVIEWER_NAME}} | +| Freigebende/r | Formale Freigabe und Inkraftsetzung | {{SOP_APPROVER_NAME}} | +| Durchfuehrende/r | Operative Umsetzung des Verfahrens | {{SOP_EXECUTOR_NAME}} | + +### Zusaetzliche Rollen + +{{#IF HAS_ADDITIONAL_ROLES}} +| Rolle | Verantwortung | Kontakt | +|-------|---------------|---------| +{{SOP_ADDITIONAL_ROLES}} +{{/IF}} + +## 4. Begriffe und Abkuerzungen + +{{SOP_DEFINITIONS}} + +## 5. Ablaufbeschreibung + +### 5.1 Voraussetzungen + +{{SOP_PREREQUISITES}} + +### 5.2 Durchfuehrung + +{{SOP_PROCEDURE_STEPS}} + +### 5.3 Nachbereitung und Qualitaetskontrolle + +{{SOP_POST_STEPS}} + +### 5.4 Eskalation + +{{#IF HAS_ESCALATION}} +Bei Abweichungen vom Standardprozess gilt folgender Eskalationsweg: + +{{SOP_ESCALATION_PATH}} +{{/IF}} +{{#IF_NOT HAS_ESCALATION}} +Abweichungen sind dem/der Freigebenden ({{SOP_APPROVER_NAME}}) unverzueglich zu melden. +{{/IF_NOT}} + +## 6. Dokumentation und Nachweise + +{{SOP_DOCUMENTATION_REQUIREMENTS}} + +**Aufbewahrungsfrist:** {{SOP_RETENTION_PERIOD}} + +**Speicherort:** {{SOP_STORAGE_LOCATION}} + +## 7. Abweichungsbehandlung + +{{SOP_DEVIATION_HANDLING}} + +## 8. Referenzen + +{{SOP_REFERENCES}} + +## 9. Revisionshistorie + +| Version | Datum | Autor | Aenderungen | +|---------|-------|-------|-------------| +| {{DOCUMENT_VERSION}} | {{VERSION_DATE}} | {{SOP_AUTHOR_NAME}} | Erstversion | + +## 10. Freigabe + +| Rolle | Name | Datum | Unterschrift | +|-------|------|-------|-------------| +| Erstellt von | {{SOP_AUTHOR_NAME}} | {{VERSION_DATE}} | _______________ | +| Geprueft von | {{SOP_REVIEWER_NAME}} | | _______________ | +| Freigegeben von | {{SOP_APPROVER_NAME}} | | _______________ | + +--- + +*Dieses Dokument wurde mit dem BreakPilot Compliance SDK erstellt.* +$template$, + '[ + {"key": "{{COMPANY_NAME}}", "label": "Firmenname", "required": true, "section": "PROVIDER"}, + {"key": "{{SOP_NUMBER}}", "label": "SOP-Nummer (z.B. SOP-DS-001)", "required": true, "section": "LEGAL"}, + {"key": "{{DOCUMENT_VERSION}}", "label": "Version (z.B. 1.0)", "required": true, "section": "LEGAL"}, + {"key": "{{VERSION_DATE}}", "label": "Datum (YYYY-MM-DD)", "required": true, "section": "LEGAL"}, + {"key": "{{SOP_PURPOSE}}", "label": "Zweck der SOP", "required": true, "section": "LEGAL"}, + {"key": "{{SOP_SCOPE}}", "label": "Geltungsbereich", "required": true, "section": "LEGAL"}, + {"key": "{{SOP_AUTHOR_NAME}}", "label": "Ersteller (Name)", "required": true, "section": "PROVIDER"}, + {"key": "{{SOP_REVIEWER_NAME}}", "label": "Pruefer/in (Name)", "required": true, "section": "PROVIDER"}, + {"key": "{{SOP_APPROVER_NAME}}", "label": "Freigebende/r (Name)", "required": true, "section": "PROVIDER"}, + {"key": "{{SOP_EXECUTOR_NAME}}", "label": "Durchfuehrende/r (Name)", "required": true, "section": "PROVIDER"}, + {"key": "{{SOP_ADDITIONAL_ROLES}}", "label": "Weitere Rollen (Markdown-Tabelle)", "required": false, "section": "PROVIDER"}, + {"key": "{{SOP_DEFINITIONS}}", "label": "Begriffe und Abkuerzungen", "required": false, "section": "LEGAL"}, + {"key": "{{SOP_PREREQUISITES}}", "label": "Voraussetzungen", "required": true, "section": "LEGAL"}, + {"key": "{{SOP_PROCEDURE_STEPS}}", "label": "Ablaufbeschreibung (Schritte)", "required": true, "section": "LEGAL"}, + {"key": "{{SOP_POST_STEPS}}", "label": "Nachbereitung / QS", "required": false, "section": "LEGAL"}, + {"key": "{{SOP_ESCALATION_PATH}}", "label": "Eskalationsweg", "required": false, "section": "LEGAL"}, + {"key": "{{SOP_DOCUMENTATION_REQUIREMENTS}}", "label": "Dokumentationspflichten", "required": true, "section": "LEGAL"}, + {"key": "{{SOP_RETENTION_PERIOD}}", "label": "Aufbewahrungsfrist", "required": false, "section": "LEGAL"}, + {"key": "{{SOP_STORAGE_LOCATION}}", "label": "Speicherort", "required": false, "section": "LEGAL"}, + {"key": "{{SOP_DEVIATION_HANDLING}}", "label": "Abweichungsbehandlung", "required": true, "section": "LEGAL"}, + {"key": "{{SOP_REFERENCES}}", "label": "Referenzen / Normen", "required": false, "section": "LEGAL"} + ]'::jsonb, + 'mit', 'MIT License', 'BreakPilot Compliance', false +) ON CONFLICT DO NOTHING;