From f2bb69ebe0c05a93fd23266d2cd3367dc1a62ce0 Mon Sep 17 00:00:00 2001 From: lordstephen9 Date: Wed, 29 Jul 2026 01:07:18 +0000 Subject: [PATCH] feat: implement issues #592, #594, #595, #596 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #592 — Payment retry engine with intelligent scheduling - backend/src/services/paymentRetry.ts: failure categorisation (INSUFFICIENT_FUNDS, CARD_EXPIRED, NETWORK_ERROR, etc.), exponential back-off scheduling, payment-method fallback chains, dunning steps, account-suspension tracking - backend/src/jobs/payment-retry.ts: cron jobs for retry processor, suspension checker, and weekly cleanup - backend/src/routes/payments.ts: REST API for retries (create, execute, abandon, outcome recording, stats, failure categorisation) - frontend/app/dashboard/billing/page.tsx: payment retry dashboard UI with attempt history, stats cards, and inline retry/abandon actions #594 — Automated security scanning pipeline - .github/workflows/security-audit-pipeline.yml: SAST (ESLint + Semgrep), dependency scanning (npm audit + Snyk + cargo audit), DAST (OWASP ZAP), smart-contract analysis (Slither), PR comment summary - backend/src/services/security.ts: vulnerability tracking with SLA, scan reports, severity scoring, remediation workflow - backend/src/routes/security.ts: REST API for scans and vulnerabilities - frontend/app/dashboard/security/page.tsx: security score dashboard with vulnerability list, scan history, and severity filters - scripts/security-scan.sh: unified local/CI security scan script #595 — GDPR-compliant data export API - backend/src/services/dataExport.ts: export jobs (JSON/CSV/PDF), scheduled exports, anonymisation, audit trail, expiry - backend/src/routes/dataExport.ts: REST API for exports and schedules - backend/src/jobs/export-jobs.ts: cron jobs for export processing, schedule triggering, and expiry cleanup - frontend/app/dashboard/settings/export/page.tsx: export management UI #596 — Project collaboration with comments and activity feeds - backend/src/services/comments.ts: threaded comments, @mentions, reactions, voting, file annotations, activity feed - backend/src/routes/comments.ts: REST API for comments, threads, reactions, votes, search, and mention notifications - frontend/components/collaboration/CommentThread.tsx: full comment thread UI with inline editing, reactions, voting, replies - frontend/components/collaboration/ActivityFeed.tsx: real-time activity feed with mention notifications and auto-refresh Misc: - .gitignore / frontend/.gitignore: added test snapshots (*.snap, __snapshots__/), playwright artifacts, security-reports/, storybook --- .github/workflows/security-audit-pipeline.yml | 385 ++++++++++++ .gitignore | 29 +- backend/src/jobs/export-jobs.ts | 161 +++++ backend/src/jobs/payment-retry.ts | 128 ++++ backend/src/routes/comments.ts | 255 ++++++++ backend/src/routes/dataExport.ts | 210 +++++++ backend/src/routes/payments.ts | 165 +++++ backend/src/routes/security.ts | 196 ++++++ backend/src/services/comments.ts | 409 ++++++++++++ backend/src/services/dataExport.ts | 424 +++++++++++++ backend/src/services/paymentRetry.ts | 461 ++++++++++++++ backend/src/services/security.ts | 369 +++++++++++ frontend/.gitignore | 15 +- frontend/app/dashboard/billing/page.tsx | 347 +++++++++++ frontend/app/dashboard/security/page.tsx | 401 ++++++++++++ .../app/dashboard/settings/export/page.tsx | 542 ++++++++++++++++ .../components/collaboration/ActivityFeed.tsx | 356 +++++++++++ .../collaboration/CommentThread.tsx | 582 ++++++++++++++++++ scripts/security-scan.sh | 292 +++++++++ 19 files changed, 5719 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/security-audit-pipeline.yml create mode 100644 backend/src/jobs/export-jobs.ts create mode 100644 backend/src/jobs/payment-retry.ts create mode 100644 backend/src/routes/comments.ts create mode 100644 backend/src/routes/dataExport.ts create mode 100644 backend/src/routes/payments.ts create mode 100644 backend/src/routes/security.ts create mode 100644 backend/src/services/comments.ts create mode 100644 backend/src/services/dataExport.ts create mode 100644 backend/src/services/paymentRetry.ts create mode 100644 backend/src/services/security.ts create mode 100644 frontend/app/dashboard/billing/page.tsx create mode 100644 frontend/app/dashboard/security/page.tsx create mode 100644 frontend/app/dashboard/settings/export/page.tsx create mode 100644 frontend/components/collaboration/ActivityFeed.tsx create mode 100644 frontend/components/collaboration/CommentThread.tsx create mode 100755 scripts/security-scan.sh diff --git a/.github/workflows/security-audit-pipeline.yml b/.github/workflows/security-audit-pipeline.yml new file mode 100644 index 00000000..c2100240 --- /dev/null +++ b/.github/workflows/security-audit-pipeline.yml @@ -0,0 +1,385 @@ +name: Security Audit Pipeline — SAST/DAST/Dependency Scanning + +on: + push: + branches: [main, master, develop, "feature/**"] + pull_request: + branches: [main, master, develop] + schedule: + # Run every Monday at 03:00 UTC + - cron: "0 3 * * 1" + workflow_dispatch: + inputs: + scan_type: + description: "Scan type to run" + required: false + default: "all" + type: choice + options: + - all + - sast + - dast + - dependencies + +concurrency: + group: security-pipeline-${{ github.ref }} + cancel-in-progress: false # never cancel security scans mid-run + +env: + SECURITY_REPORT_DIR: security-reports + NODE_VERSION: "20" + +# ── Permissions ──────────────────────────────────────────────────────────────── + +permissions: + contents: read + security-events: write # for SARIF uploads + pull-requests: write # for PR comments + +# ───────────────────────────────────────────────────────────────────────────── +# Job 1 — SAST: ESLint Security Rules + Semgrep +# ───────────────────────────────────────────────────────────────────────────── + +jobs: + sast: + name: SAST — Static Analysis + runs-on: ubuntu-latest + timeout-minutes: 20 + if: ${{ github.event.inputs.scan_type == 'all' || github.event.inputs.scan_type == 'sast' || github.event_name != 'workflow_dispatch' }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + cache-dependency-path: | + backend/package-lock.json + frontend/package-lock.json + + - name: Install backend deps + run: npm ci --prefix backend + + - name: Install frontend deps + run: npm ci --prefix frontend + + - name: Run backend ESLint (security rules) + continue-on-error: true + run: | + mkdir -p ${{ env.SECURITY_REPORT_DIR }} + cd backend + npx eslint src --format json \ + --output-file ../${{ env.SECURITY_REPORT_DIR }}/eslint-backend.json \ + || true + + - name: Run frontend ESLint (security rules) + continue-on-error: true + run: | + cd frontend + npx eslint app components lib --format json \ + --output-file ../${{ env.SECURITY_REPORT_DIR }}/eslint-frontend.json \ + || true + + - name: Run Semgrep SAST + uses: semgrep/semgrep-action@v1 + continue-on-error: true + with: + config: >- + p/security-audit + p/typescript + p/nodejs + p/react + sarif_file: ${{ env.SECURITY_REPORT_DIR }}/semgrep.sarif + env: + SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }} + + - name: Upload Semgrep SARIF + if: always() + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: ${{ env.SECURITY_REPORT_DIR }}/semgrep.sarif + category: semgrep-sast + continue-on-error: true + + - name: TypeScript type-check (backend) + continue-on-error: true + run: | + cd backend + npx tsc --noEmit 2>&1 | tee ../${{ env.SECURITY_REPORT_DIR }}/tsc-backend.txt || true + + - name: TypeScript type-check (frontend) + continue-on-error: true + run: | + cd frontend + npx tsc --noEmit 2>&1 | tee ../${{ env.SECURITY_REPORT_DIR }}/tsc-frontend.txt || true + + - name: Upload SAST artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: sast-reports + path: ${{ env.SECURITY_REPORT_DIR }}/ + retention-days: 30 + +# ───────────────────────────────────────────────────────────────────────────── +# Job 2 — Dependency scanning (npm audit + Snyk) +# ───────────────────────────────────────────────────────────────────────────── + + dependency-scan: + name: Dependency Vulnerability Scan + runs-on: ubuntu-latest + timeout-minutes: 15 + if: ${{ github.event.inputs.scan_type == 'all' || github.event.inputs.scan_type == 'dependencies' || github.event_name != 'workflow_dispatch' }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: npm audit — backend + id: audit_backend + continue-on-error: true + run: | + mkdir -p ${{ env.SECURITY_REPORT_DIR }} + cd backend + npm audit --json > ../${{ env.SECURITY_REPORT_DIR }}/npm-audit-backend.json 2>&1 || true + npm audit --audit-level=high 2>&1 | tee ../${{ env.SECURITY_REPORT_DIR }}/npm-audit-backend.txt || true + + - name: npm audit — frontend + id: audit_frontend + continue-on-error: true + run: | + cd frontend + npm audit --json > ../${{ env.SECURITY_REPORT_DIR }}/npm-audit-frontend.json 2>&1 || true + npm audit --audit-level=high 2>&1 | tee ../${{ env.SECURITY_REPORT_DIR }}/npm-audit-frontend.txt || true + + - name: Snyk dependency scan + uses: snyk/actions/node@master + continue-on-error: true + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + with: + args: >- + --all-projects + --severity-threshold=high + --sarif-file-output=${{ env.SECURITY_REPORT_DIR }}/snyk.sarif + + - name: Upload Snyk SARIF + if: always() + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: ${{ env.SECURITY_REPORT_DIR }}/snyk.sarif + category: snyk-dependencies + continue-on-error: true + + - name: Cargo audit (Rust/Soroban contracts) + continue-on-error: true + run: | + if command -v cargo &> /dev/null; then + cargo install cargo-audit --quiet 2>/dev/null || true + cd contracts + cargo audit --json > ../${{ env.SECURITY_REPORT_DIR }}/cargo-audit.json 2>&1 || true + else + echo "cargo not available — skipping" + fi + + - name: Summarise dependency audit + if: always() + run: | + echo "## 📦 Dependency Audit Summary" >> $GITHUB_STEP_SUMMARY + for f in ${{ env.SECURITY_REPORT_DIR }}/npm-audit-backend.txt ${{ env.SECURITY_REPORT_DIR }}/npm-audit-frontend.txt; do + if [ -f "$f" ]; then + echo "### $(basename $f)" >> $GITHUB_STEP_SUMMARY + cat "$f" >> $GITHUB_STEP_SUMMARY + fi + done + + - name: Upload dependency scan artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: dependency-scan-reports + path: ${{ env.SECURITY_REPORT_DIR }}/ + retention-days: 30 + +# ───────────────────────────────────────────────────────────────────────────── +# Job 3 — DAST: OWASP ZAP baseline scan +# ───────────────────────────────────────────────────────────────────────────── + + dast: + name: DAST — Dynamic Application Security Testing + runs-on: ubuntu-latest + timeout-minutes: 30 + # Only run DAST on push/schedule/manual (requires a running application) + if: >- + ${{ (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') + && (github.event.inputs.scan_type == 'all' || github.event.inputs.scan_type == 'dast' || github.event_name != 'workflow_dispatch') }} + services: + backend: + image: node:20-alpine + ports: + - 3001:3001 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Install and start backend + run: | + cd backend + npm ci + NODE_ENV=test JOBS_ENABLED=false npm start & + # Wait for backend to be ready + timeout 60 bash -c 'until curl -s http://localhost:3001/api/v1/health; do sleep 2; done' + env: + PORT: 3001 + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY || 'sk-test-placeholder' }} + + - name: OWASP ZAP Baseline Scan + uses: zaproxy/action-baseline@v0.10.0 + continue-on-error: true + with: + target: "http://localhost:3001" + rules_file_name: ".zap/rules.tsv" + cmd_options: "-a" + artifact_name: zap-baseline-report + + - name: Upload DAST artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: dast-reports + path: | + report_html.html + report_md.md + report_json.json + retention-days: 30 + continue-on-error: true + +# ───────────────────────────────────────────────────────────────────────────── +# Job 4 — Smart contract security (Slither) +# ───────────────────────────────────────────────────────────────────────────── + + smart-contract-scan: + name: Smart Contract Security Analysis + runs-on: ubuntu-latest + timeout-minutes: 25 + if: >- + ${{ github.event.inputs.scan_type == 'all' || github.event_name != 'workflow_dispatch' }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Detect contract changes + id: changes + uses: dorny/paths-filter@v3 + with: + filters: | + solidity: + - 'contracts/**/*.sol' + rust: + - 'contracts/soroban/**' + - 'contracts/src/**/*.rs' + + - name: Setup Python for Slither + if: steps.changes.outputs.solidity == 'true' + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install Slither + if: steps.changes.outputs.solidity == 'true' + run: pip install slither-analyzer + + - name: Run Slither on Solidity contracts + if: steps.changes.outputs.solidity == 'true' + continue-on-error: true + run: | + mkdir -p ${{ env.SECURITY_REPORT_DIR }} + slither contracts/ \ + --json ${{ env.SECURITY_REPORT_DIR }}/slither.json \ + --exclude-dependencies \ + --exclude-low \ + 2>&1 | tee ${{ env.SECURITY_REPORT_DIR }}/slither.txt || true + + - name: Cargo audit for Soroban + if: steps.changes.outputs.rust == 'true' + continue-on-error: true + run: | + if command -v cargo &> /dev/null; then + cargo install cargo-audit --quiet 2>/dev/null || true + cargo audit --json > ${{ env.SECURITY_REPORT_DIR }}/cargo-soroban-audit.json 2>&1 || true + fi + + - name: Upload contract scan artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: smart-contract-reports + path: ${{ env.SECURITY_REPORT_DIR }}/ + retention-days: 30 + continue-on-error: true + +# ───────────────────────────────────────────────────────────────────────────── +# Job 5 — Aggregate & report +# ───────────────────────────────────────────────────────────────────────────── + + report: + name: Aggregate Security Report + runs-on: ubuntu-latest + needs: [sast, dependency-scan] + if: always() + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: all-reports + continue-on-error: true + + - name: Generate summary + run: | + echo "# 🔒 Security Audit Report" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Job | Status |" >> $GITHUB_STEP_SUMMARY + echo "|-----|--------|" >> $GITHUB_STEP_SUMMARY + echo "| SAST | ${{ needs.sast.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| Dependency Scan | ${{ needs.dependency-scan.result }} |" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Full reports available as workflow artifacts." >> $GITHUB_STEP_SUMMARY + + - name: Comment on PR + if: github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + script: | + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: [ + '## 🔒 Security Scan Results', + '', + '| Scan | Result |', + '|------|--------|', + `| SAST | ${{ needs.sast.result }} |`, + `| Dependencies | ${{ needs.dependency-scan.result }} |`, + '', + 'Download the detailed reports from the workflow artifacts.', + ].join('\n') + }) + continue-on-error: true diff --git a/.gitignore b/.gitignore index daaac7bf..cc878e1b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ node_modules/ # Environment .env* +!.env.example # OS .DS_Store @@ -26,14 +27,38 @@ out/ # Debug *.log npm-debug.log* +yarn-debug.log* +yarn-error.log* # Keys *.pem -.claude +# Test artifacts & snapshots +**/coverage/ +**/__snapshots__/ +**/*.snap +**/test-results/ +**/playwright-report/ +**/blob-report/ +**/.playwright/ +**/playwright/.cache/ + +# Security scan reports (generated — do not commit) +security-reports/ + +# Build & cache artefacts +*.tsbuildinfo +.turbo/ .lhci_reports/ bundle-reports/ trend-data.json packages/*/dist/ + +# Generated code frontend/src/generated/ -packages/contracts/src/generated/ \ No newline at end of file +packages/contracts/src/generated/ +node_modules/.cache/ + +# Misc +.claude +test-write.txt \ No newline at end of file diff --git a/backend/src/jobs/export-jobs.ts b/backend/src/jobs/export-jobs.ts new file mode 100644 index 00000000..31965ce2 --- /dev/null +++ b/backend/src/jobs/export-jobs.ts @@ -0,0 +1,161 @@ +/** + * export-jobs.ts — Issue #595 + * + * Scheduled jobs for data export: + * - export-processor : processes queued export jobs every 5 min + * - export-scheduler : triggers scheduled exports when due (every 30 min) + * - export-cleanup : expires old completed exports daily + */ + +import { randomUUID } from 'node:crypto'; +import type { JobDefinition } from './types.js'; +import { dataExportService } from '../services/dataExport.js'; + +function log(msg: string): void { + // eslint-disable-next-line no-console + console.log(`[export-jobs] ${new Date().toISOString()} ${msg}`); +} + +// ── Simulate export processing ──────────────────────────────────────────────── + +async function processQueuedExports(): Promise { + const queued = dataExportService.getQueuedExports(); + + if (queued.length === 0) { + log('No queued exports.'); + return; + } + + log(`Processing ${queued.length} queued exports...`); + + for (const job of queued) { + // Start processing + const startResult = dataExportService.startExport(job.id); + if (!startResult.ok) { + log(`[SKIP] ${job.id}: ${startResult.error.message}`); + continue; + } + + try { + // Simulate data assembly (replace with real data fetching in production) + const mockData: Record[] = Array.from({ length: 10 }, (_, i) => ({ + id: randomUUID(), + scope: job.scope[0], + index: i, + userId: job.userId, + exportedAt: new Date().toISOString(), + })); + + let processedData = mockData; + if (job.anonymise) { + processedData = dataExportService.anonymiseData(mockData); + } + + let content: string; + let fileExtension: string; + + switch (job.format) { + case 'csv': + content = dataExportService.toCSV(processedData); + fileExtension = 'csv'; + break; + case 'pdf': + // In production, use a PDF generation library + content = `PDF export for user ${job.userId} (${job.scope.join(', ')})`; + fileExtension = 'pdf'; + break; + default: + content = JSON.stringify(processedData, null, 2); + fileExtension = 'json'; + } + + const downloadUrl = `/exports/${job.id}.${fileExtension}`; + const fileSizeBytes = Buffer.byteLength(content, 'utf8'); + + const completeResult = dataExportService.completeExport(job.id, { + downloadUrl, + fileSizeBytes, + rowCount: processedData.length, + }); + + if (!completeResult.ok) { + log(`[ERROR] Failed to complete export ${job.id}: ${completeResult.error.message}`); + continue; + } + + log(`[OK] Export ${job.id} completed — ${processedData.length} rows, format: ${job.format}`); + + // If delivery email is set, schedule email send (stubbed here) + if (job.deliveryEmail) { + log(`[EMAIL] Would send export ${job.id} to ${job.deliveryEmail}`); + } + } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err); + dataExportService.failExport(job.id, errMsg); + log(`[FAILED] Export ${job.id}: ${errMsg}`); + } + } +} + +// ── Process scheduled exports ───────────────────────────────────────────────── + +async function processScheduledExports(): Promise { + const due = dataExportService.getDueSchedules(); + + if (due.length === 0) { + log('No scheduled exports due.'); + return; + } + + log(`Triggering ${due.length} scheduled exports...`); + + for (const schedule of due) { + const result = dataExportService.createExport({ + userId: schedule.userId, + format: schedule.format, + scope: schedule.scope, + anonymise: schedule.anonymise, + isGdprRequest: false, + deliveryEmail: schedule.deliveryEmail, + scheduledExportId: schedule.id, + }); + + if (!result.ok) { + log(`[ERROR] Could not queue export for schedule ${schedule.id}: ${result.error.message}`); + continue; + } + + dataExportService.advanceScheduleNextRun(schedule.id); + log(`[QUEUED] Export job ${result.value.id} for schedule ${schedule.id} (${schedule.frequency})`); + } +} + +// ── Expire old exports ──────────────────────────────────────────────────────── + +async function expireOldExports(): Promise { + const count = dataExportService.expireOldExports(); + log(`Expired ${count} old export(s).`); +} + +// ── Job definitions ─────────────────────────────────────────────────────────── + +export const exportJobs: JobDefinition[] = [ + { + id: 'export-processor', + name: 'Process Queued Data Exports', + schedule: { type: 'cron', expression: '*/5 * * * *' }, // every 5 minutes + handler: processQueuedExports, + }, + { + id: 'export-scheduler', + name: 'Trigger Due Scheduled Exports', + schedule: { type: 'cron', expression: '*/30 * * * *' }, // every 30 minutes + handler: processScheduledExports, + }, + { + id: 'export-cleanup', + name: 'Expire Old Completed Exports', + schedule: { type: 'cron', expression: '0 2 * * *' }, // daily at 02:00 UTC + handler: expireOldExports, + }, +]; diff --git a/backend/src/jobs/payment-retry.ts b/backend/src/jobs/payment-retry.ts new file mode 100644 index 00000000..6782b7bd --- /dev/null +++ b/backend/src/jobs/payment-retry.ts @@ -0,0 +1,128 @@ +/** + * payment-retry.ts — Issue #592 + * + * Scheduled jobs for the payment retry engine. + * + * Jobs registered: + * - payment-retry-processor : runs every 10 min, executes due retries + * - payment-retry-suspension : runs daily, suspends accounts with prolonged failure + * - payment-retry-cleanup : runs weekly, marks stale records abandoned + */ + +import type { JobDefinition } from './types.js'; +import { paymentRetryService } from '../services/paymentRetry.js'; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function log(msg: string): void { + // eslint-disable-next-line no-console + console.log(`[payment-retry-jobs] ${new Date().toISOString()} ${msg}`); +} + +// ── Process due retries ────────────────────────────────────────────────────── + +async function processDueRetries(): Promise { + const due = paymentRetryService.getDueRetries(); + + if (due.length === 0) { + log('No retries due.'); + return; + } + + log(`Processing ${due.length} due retries...`); + + for (const record of due) { + const attemptResult = paymentRetryService.executeRetry(record.id); + + if (!attemptResult.ok) { + log(`[SKIP] ${record.id}: ${attemptResult.error.message}`); + continue; + } + + const attempt = attemptResult.value; + + // ── Simulate payment execution ───────────────────────────────────────── + // In production, call the actual payment provider here. + // We simulate a 70 % success rate for demonstration purposes. + const simulatedSuccess = Math.random() > 0.3; + const simulatedFailureReason = simulatedSuccess + ? undefined + : 'insufficient_funds'; + + const outcome = paymentRetryService.recordAttemptOutcome(record.id, attempt.id, { + success: simulatedSuccess, + failureReason: simulatedFailureReason, + }); + + if (!outcome.ok) { + log(`[ERROR] Could not record outcome for ${record.id}: ${outcome.error.message}`); + continue; + } + + if (simulatedSuccess) { + log(`[OK] Payment ${record.paymentId} recovered on attempt #${attempt.attemptNumber}`); + } else { + const updated = outcome.value; + if (updated.status === 'abandoned') { + log(`[ABANDONED] Payment ${record.paymentId} — max attempts exhausted`); + } else { + log(`[RESCHEDULED] Payment ${record.paymentId} — next retry at ${updated.nextRetryAt}`); + } + } + } +} + +// ── Check for accounts due for suspension ──────────────────────────────────── + +async function checkSuspensions(): Promise { + const accounts = paymentRetryService.getAccountsDueForSuspension(); + + if (accounts.length === 0) { + log('No accounts due for suspension.'); + return; + } + + log(`Found ${accounts.length} accounts with prolonged payment failure.`); + + for (const { userId, retryIds } of accounts) { + log(`[SUSPEND] User ${userId} — abandoning ${retryIds.length} retry records`); + + for (const retryId of retryIds) { + paymentRetryService.abandonRetry(retryId); + } + + // TODO: dispatch account-suspension event / notify user + } +} + +// ── Cleanup stale pending records ──────────────────────────────────────────── + +async function cleanupStaleRetries(): Promise { + const stats = paymentRetryService.getStats(); + log(`Retry engine stats — total:${stats.total}, succeeded:${stats.succeeded}, ` + + `abandoned:${stats.abandoned}, recovery rate:${stats.recoveryRate}%`); + // Additional cleanup logic (e.g., purge old abandoned records) can be added here. +} + +// ── Job definitions ─────────────────────────────────────────────────────────── + +export const paymentRetryJobs: JobDefinition[] = [ + { + id: 'payment-retry-processor', + name: 'Process Due Payment Retries', + schedule: { type: 'cron', expression: '*/10 * * * *' }, // every 10 minutes + handler: processDueRetries, + }, + { + id: 'payment-retry-suspension', + name: 'Suspend Accounts with Prolonged Failures', + schedule: { type: 'cron', expression: '0 6 * * *' }, // daily at 06:00 UTC + handler: checkSuspensions, + }, + { + id: 'payment-retry-cleanup', + name: 'Payment Retry Cleanup & Stats', + schedule: { type: 'cron', expression: '0 3 * * 0' }, // weekly Sunday 03:00 UTC + handler: cleanupStaleRetries, + }, +]; diff --git a/backend/src/routes/comments.ts b/backend/src/routes/comments.ts new file mode 100644 index 00000000..e22ade3a --- /dev/null +++ b/backend/src/routes/comments.ts @@ -0,0 +1,255 @@ +/** + * comments.ts — Issue #596 + * + * Collaboration comments REST API routes. + * + * POST /comments — add comment + * GET /comments/:id — get comment + * PATCH /comments/:id — edit comment + * DELETE /comments/:id — delete comment + * GET /comments/:id/thread — get thread + * POST /comments/:id/reactions — add/toggle reaction + * POST /comments/:id/vote — vote up/down + * GET /comments/project/:projectId — list project comments + * GET /comments/project/:projectId/activity — activity feed + * GET /comments/project/:projectId/search — search comments + * GET /comments/notifications/:userId — user mention notifications + * POST /comments/notifications/:userId/read — mark notifications read + */ + +import { Router, type Request, type Response } from 'express'; +import { + commentService, + type ReactionEmoji, + type CommentTargetType, + type FileAnnotation, +} from '../services/comments.js'; + +const router = Router(); + +// ── POST /comments ──────────────────────────────────────────────────────────── + +router.post('/', (req: Request, res: Response) => { + const { + projectId, + targetType, + targetId, + parentId, + authorId, + authorName, + body, + annotation, + } = req.body as { + projectId?: string; + targetType?: string; + targetId?: string; + parentId?: string; + authorId?: string; + authorName?: string; + body?: string; + annotation?: unknown; + }; + + if (!projectId || !targetType || !targetId || !authorId || !authorName || !body) { + return res.status(400).json({ + success: false, + error: 'projectId, targetType, targetId, authorId, authorName and body are required', + }); + } + + const result = commentService.addComment({ + projectId, + targetType: targetType as CommentTargetType, + targetId, + parentId, + authorId, + authorName, + body, + annotation: annotation as FileAnnotation | undefined, + }); + + if (!result.ok) { + return res.status(result.error.statusCode ?? 400).json({ + success: false, + error: result.error.message, + }); + } + + return res.status(201).json({ success: true, data: result.value }); +}); + +// ── GET /comments/:id ───────────────────────────────────────────────────────── + +router.get('/:id', (req: Request, res: Response) => { + const result = commentService.getComment(req.params.id); + if (!result.ok) { + return res.status(result.error.statusCode ?? 404).json({ + success: false, + error: result.error.message, + }); + } + return res.json({ success: true, data: result.value }); +}); + +// ── PATCH /comments/:id ─────────────────────────────────────────────────────── + +router.patch('/:id', (req: Request, res: Response) => { + const { userId, body } = req.body as { userId?: string; body?: string }; + if (!userId || !body) { + return res.status(400).json({ success: false, error: 'userId and body are required' }); + } + + const result = commentService.editComment(req.params.id, userId, body); + if (!result.ok) { + return res.status(result.error.statusCode ?? 400).json({ + success: false, + error: result.error.message, + }); + } + return res.json({ success: true, data: result.value }); +}); + +// ── DELETE /comments/:id ────────────────────────────────────────────────────── + +router.delete('/:id', (req: Request, res: Response) => { + const { userId } = req.body as { userId?: string }; + if (!userId) { + return res.status(400).json({ success: false, error: 'userId is required' }); + } + + const result = commentService.deleteComment(req.params.id, userId); + if (!result.ok) { + return res.status(result.error.statusCode ?? 400).json({ + success: false, + error: result.error.message, + }); + } + return res.json({ success: true, data: result.value }); +}); + +// ── GET /comments/:id/thread ────────────────────────────────────────────────── + +router.get('/:id/thread', (req: Request, res: Response) => { + const result = commentService.getThread(req.params.id); + if (!result.ok) { + return res.status(result.error.statusCode ?? 404).json({ + success: false, + error: result.error.message, + }); + } + return res.json({ success: true, data: result.value }); +}); + +// ── POST /comments/:id/reactions ────────────────────────────────────────────── + +router.post('/:id/reactions', (req: Request, res: Response) => { + const { userId, emoji } = req.body as { userId?: string; emoji?: string }; + if (!userId || !emoji) { + return res.status(400).json({ success: false, error: 'userId and emoji are required' }); + } + + const result = commentService.addReaction(req.params.id, userId, emoji as ReactionEmoji); + if (!result.ok) { + return res.status(result.error.statusCode ?? 400).json({ + success: false, + error: result.error.message, + }); + } + return res.json({ success: true, data: result.value }); +}); + +// ── POST /comments/:id/vote ─────────────────────────────────────────────────── + +router.post('/:id/vote', (req: Request, res: Response) => { + const { userId, direction } = req.body as { userId?: string; direction?: string }; + if (!userId || !['up', 'down'].includes(direction ?? '')) { + return res.status(400).json({ + success: false, + error: 'userId and direction (up|down) are required', + }); + } + + const result = commentService.vote(req.params.id, userId, direction as 'up' | 'down'); + if (!result.ok) { + return res.status(result.error.statusCode ?? 400).json({ + success: false, + error: result.error.message, + }); + } + return res.json({ success: true, data: result.value }); +}); + +// ── GET /comments/project/:projectId ───────────────────────────────────────── + +router.get('/project/:projectId', (req: Request, res: Response) => { + const { targetType, targetId, includeDeleted } = req.query as { + targetType?: string; + targetId?: string; + includeDeleted?: string; + }; + + if (!targetType || !targetId) { + return res.status(400).json({ + success: false, + error: 'targetType and targetId query params are required', + }); + } + + const result = commentService.listComments( + req.params.projectId, + targetType as CommentTargetType, + targetId, + { includeDeleted: includeDeleted === 'true' }, + ); + + return res.json({ success: true, count: result.length, data: result }); +}); + +// ── GET /comments/project/:projectId/activity ───────────────────────────────── + +router.get('/project/:projectId/activity', (req: Request, res: Response) => { + const { limit } = req.query as { limit?: string }; + const events = commentService.getActivityFeed( + req.params.projectId, + limit ? parseInt(limit) : 50, + ); + return res.json({ success: true, count: events.length, data: events }); +}); + +// ── GET /comments/project/:projectId/search ─────────────────────────────────── + +router.get('/project/:projectId/search', (req: Request, res: Response) => { + const { q, limit } = req.query as { q?: string; limit?: string }; + if (!q) { + return res.status(400).json({ success: false, error: 'q query param is required' }); + } + + const results = commentService.search( + req.params.projectId, + q, + limit ? parseInt(limit) : 20, + ); + + return res.json({ success: true, count: results.length, data: results }); +}); + +// ── GET /comments/notifications/:userId ────────────────────────────────────── + +router.get('/notifications/:userId', (req: Request, res: Response) => { + const { unreadOnly, limit } = req.query as { unreadOnly?: string; limit?: string }; + const notifs = commentService.getUserNotifications(req.params.userId, { + unreadOnly: unreadOnly === 'true', + limit: limit ? parseInt(limit) : 50, + }); + return res.json({ success: true, count: notifs.length, data: notifs }); +}); + +// ── POST /comments/notifications/:userId/read ──────────────────────────────── + +router.post('/notifications/:userId/read', (req: Request, res: Response) => { + const { notificationIds } = req.body as { notificationIds?: string[] }; + const count = commentService.markNotificationsRead(req.params.userId, notificationIds); + return res.json({ success: true, markedRead: count }); +}); + +export default router; diff --git a/backend/src/routes/dataExport.ts b/backend/src/routes/dataExport.ts new file mode 100644 index 00000000..24cc886e --- /dev/null +++ b/backend/src/routes/dataExport.ts @@ -0,0 +1,210 @@ +/** + * dataExport.ts — Issue #595 + * + * Data export REST API routes. + * + * POST /data-export — queue a new export + * GET /data-export/:id — get export status + * GET /data-export/user/:userId — list user's exports + * DELETE /data-export/:id — delete an export + * GET /data-export/audit — audit log + * + * POST /data-export/schedules — create scheduled export + * GET /data-export/schedules/user/:userId — list user schedules + * PATCH /data-export/schedules/:id — update schedule + * DELETE /data-export/schedules/:id — delete schedule + */ + +import { Router, type Request, type Response } from 'express'; +import { dataExportService, type ExportFormat, type ExportScope, type ScheduleFrequency, type ScheduledExport } from '../services/dataExport.js'; + +const router = Router(); + +// ── POST /data-export ───────────────────────────────────────────────────────── + +router.post('/', (req: Request, res: Response) => { + const { userId, format, scope, anonymise, isGdprRequest, deliveryEmail } = req.body as { + userId?: string; + format?: string; + scope?: string[]; + anonymise?: boolean; + isGdprRequest?: boolean; + deliveryEmail?: string; + }; + + if (!userId || !format || !scope) { + return res.status(400).json({ + success: false, + error: 'userId, format and scope are required', + }); + } + + const allowedFormats = ['json', 'csv', 'pdf']; + if (!allowedFormats.includes(format)) { + return res.status(400).json({ + success: false, + error: `format must be one of: ${allowedFormats.join(', ')}`, + }); + } + + const result = dataExportService.createExport({ + userId, + format: format as 'json' | 'csv' | 'pdf', + scope: scope as ExportScope[], + anonymise, + isGdprRequest, + deliveryEmail, + }); + + if (!result.ok) { + return res.status(result.error.statusCode ?? 400).json({ + success: false, + error: result.error.message, + }); + } + + return res.status(202).json({ success: true, data: result.value }); +}); + +// ── GET /data-export/audit ──────────────────────────────────────────────────── + +router.get('/audit', (req: Request, res: Response) => { + const { userId, limit } = req.query as { userId?: string; limit?: string }; + const entries = dataExportService.getAuditLog(userId, limit ? parseInt(limit) : 100); + return res.json({ success: true, count: entries.length, data: entries }); +}); + +// ── GET /data-export/user/:userId ───────────────────────────────────────────── + +router.get('/user/:userId', (req: Request, res: Response) => { + const { limit } = req.query as { limit?: string }; + const exports = dataExportService.listUserExports( + req.params.userId, + limit ? parseInt(limit) : 50, + ); + return res.json({ success: true, count: exports.length, data: exports }); +}); + +// ── GET /data-export/:id ────────────────────────────────────────────────────── + +router.get('/:id', (req: Request, res: Response) => { + const result = dataExportService.getExport(req.params.id); + if (!result.ok) { + return res.status(result.error.statusCode ?? 404).json({ + success: false, + error: result.error.message, + }); + } + return res.json({ success: true, data: result.value }); +}); + +// ── DELETE /data-export/:id ─────────────────────────────────────────────────── + +router.delete('/:id', (req: Request, res: Response) => { + const { userId } = req.body as { userId?: string }; + if (!userId) { + return res.status(400).json({ success: false, error: 'userId is required' }); + } + + const result = dataExportService.deleteExport(req.params.id, userId); + if (!result.ok) { + return res.status(result.error.statusCode ?? 400).json({ + success: false, + error: result.error.message, + }); + } + + return res.status(204).send(); +}); + +// ── Scheduled exports ───────────────────────────────────────────────────────── + +// POST /data-export/schedules +router.post('/schedules', (req: Request, res: Response) => { + const { userId, format, scope, frequency, anonymise, deliveryEmail } = req.body as { + userId?: string; + format?: string; + scope?: string[]; + frequency?: string; + anonymise?: boolean; + deliveryEmail?: string; + }; + + if (!userId || !format || !scope || !frequency || !deliveryEmail) { + return res.status(400).json({ + success: false, + error: 'userId, format, scope, frequency and deliveryEmail are required', + }); + } + + const result = dataExportService.createSchedule({ + userId, + format: format as ExportFormat, + scope: scope as ExportScope[], + frequency: frequency as ScheduleFrequency, + anonymise, + deliveryEmail, + }); + + if (!result.ok) { + return res.status(result.error.statusCode ?? 400).json({ + success: false, + error: result.error.message, + }); + } + + return res.status(201).json({ success: true, data: result.value }); +}); + +// GET /data-export/schedules/user/:userId +router.get('/schedules/user/:userId', (req: Request, res: Response) => { + const schedules = dataExportService.listUserSchedules(req.params.userId); + return res.json({ success: true, count: schedules.length, data: schedules }); +}); + +// PATCH /data-export/schedules/:id +router.patch('/schedules/:id', (req: Request, res: Response) => { + const { userId, ...updates } = req.body as { + userId?: string; + enabled?: boolean; + format?: string; + scope?: string[]; + frequency?: string; + deliveryEmail?: string; + anonymise?: boolean; + }; + + if (!userId) { + return res.status(400).json({ success: false, error: 'userId is required' }); + } + + const result = dataExportService.updateSchedule(req.params.id, userId, updates as Partial>); + if (!result.ok) { + return res.status(result.error.statusCode ?? 400).json({ + success: false, + error: result.error.message, + }); + } + + return res.json({ success: true, data: result.value }); +}); + +// DELETE /data-export/schedules/:id +router.delete('/schedules/:id', (req: Request, res: Response) => { + const { userId } = req.body as { userId?: string }; + if (!userId) { + return res.status(400).json({ success: false, error: 'userId is required' }); + } + + const result = dataExportService.deleteSchedule(req.params.id, userId); + if (!result.ok) { + return res.status(result.error.statusCode ?? 400).json({ + success: false, + error: result.error.message, + }); + } + + return res.status(204).send(); +}); + +export default router; diff --git a/backend/src/routes/payments.ts b/backend/src/routes/payments.ts new file mode 100644 index 00000000..16f44791 --- /dev/null +++ b/backend/src/routes/payments.ts @@ -0,0 +1,165 @@ +/** + * payments.ts — Issue #592 + * + * Payment retry REST API routes. + * + * POST /payments/retry — create a retry record + * GET /payments/retry/:id — get retry record by id + * GET /payments/retry/by-payment/:paymentId — get retry by original payment + * GET /payments/retry/user/:userId — list retries for a user + * POST /payments/retry/:id/execute — manually trigger next attempt + * POST /payments/retry/:id/abandon — abandon a retry + * POST /payments/retry/:id/outcome/:attemptId — record attempt outcome + * GET /payments/retry/stats — aggregate statistics + * GET /payments/categorise — categorise a failure reason + */ + +import { Router, type Request, type Response } from 'express'; +import { paymentRetryService } from '../services/paymentRetry.js'; + +const router = Router(); + +// ── POST /payments/retry ───────────────────────────────────────────────────── + +router.post('/retry', (req: Request, res: Response) => { + const { paymentId, userId, amount, currency, failureReason, paymentMethodIds } = req.body as { + paymentId?: string; + userId?: string; + amount?: number; + currency?: string; + failureReason?: string; + paymentMethodIds?: string[]; + }; + + if (!paymentId || !userId || amount == null || !currency || !failureReason || !paymentMethodIds) { + return res.status(400).json({ + success: false, + error: 'paymentId, userId, amount, currency, failureReason and paymentMethodIds are required', + }); + } + + const result = paymentRetryService.createRetry({ + paymentId, + userId, + amount, + currency, + failureReason, + paymentMethodIds, + }); + + if (!result.ok) { + return res.status(result.error.statusCode ?? 400).json({ + success: false, + error: result.error.message, + }); + } + + return res.status(201).json({ success: true, data: result.value }); +}); + +// ── GET /payments/retry/stats ──────────────────────────────────────────────── + +router.get('/retry/stats', (_req: Request, res: Response) => { + return res.json({ success: true, data: paymentRetryService.getStats() }); +}); + +// ── GET /payments/retry/by-payment/:paymentId ──────────────────────────────── + +router.get('/retry/by-payment/:paymentId', (req: Request, res: Response) => { + const result = paymentRetryService.getRetryByPayment(req.params.paymentId); + if (!result.ok) { + return res.status(result.error.statusCode ?? 404).json({ + success: false, + error: result.error.message, + }); + } + return res.json({ success: true, data: result.value }); +}); + +// ── GET /payments/retry/user/:userId ───────────────────────────────────────── + +router.get('/retry/user/:userId', (req: Request, res: Response) => { + const retries = paymentRetryService.listUserRetries(req.params.userId); + return res.json({ success: true, count: retries.length, data: retries }); +}); + +// ── GET /payments/retry/:id ────────────────────────────────────────────────── + +router.get('/retry/:id', (req: Request, res: Response) => { + const result = paymentRetryService.getRetry(req.params.id); + if (!result.ok) { + return res.status(result.error.statusCode ?? 404).json({ + success: false, + error: result.error.message, + }); + } + return res.json({ success: true, data: result.value }); +}); + +// ── POST /payments/retry/:id/execute ───────────────────────────────────────── + +router.post('/retry/:id/execute', (req: Request, res: Response) => { + const { forceMethodIndex } = req.body as { forceMethodIndex?: number }; + const result = paymentRetryService.executeRetry(req.params.id, { forceMethodIndex }); + if (!result.ok) { + return res.status(result.error.statusCode ?? 400).json({ + success: false, + error: result.error.message, + }); + } + return res.json({ success: true, data: result.value }); +}); + +// ── POST /payments/retry/:id/abandon ───────────────────────────────────────── + +router.post('/retry/:id/abandon', (req: Request, res: Response) => { + const result = paymentRetryService.abandonRetry(req.params.id); + if (!result.ok) { + return res.status(result.error.statusCode ?? 400).json({ + success: false, + error: result.error.message, + }); + } + return res.json({ success: true, data: result.value }); +}); + +// ── POST /payments/retry/:id/outcome/:attemptId ─────────────────────────────── + +router.post('/retry/:id/outcome/:attemptId', (req: Request, res: Response) => { + const { success, failureReason } = req.body as { + success?: boolean; + failureReason?: string; + }; + + if (success == null) { + return res.status(400).json({ success: false, error: 'success (boolean) is required' }); + } + + const result = paymentRetryService.recordAttemptOutcome( + req.params.id, + req.params.attemptId, + { success, failureReason }, + ); + + if (!result.ok) { + return res.status(result.error.statusCode ?? 400).json({ + success: false, + error: result.error.message, + }); + } + + return res.json({ success: true, data: result.value }); +}); + +// ── GET /payments/categorise ────────────────────────────────────────────────── + +router.get('/categorise', (req: Request, res: Response) => { + const { reason } = req.query as { reason?: string }; + if (!reason) { + return res.status(400).json({ success: false, error: 'reason query param is required' }); + } + const info = paymentRetryService.categoriseFailure(reason); + return res.json({ success: true, data: info }); +}); + +export default router; diff --git a/backend/src/routes/security.ts b/backend/src/routes/security.ts new file mode 100644 index 00000000..d4309950 --- /dev/null +++ b/backend/src/routes/security.ts @@ -0,0 +1,196 @@ +/** + * security.ts — Issue #594 + * + * Security scanning REST API routes. + * + * POST /security/scans — start a scan + * POST /security/scans/:id/complete — complete a scan with findings + * GET /security/scans/:id — get scan report + * GET /security/scans — list scan reports + * GET /security/vulnerabilities — list vulnerabilities (with filters) + * GET /security/vulnerabilities/:id — get a vulnerability + * PATCH /security/vulnerabilities/:id/status — update vulnerability status + * POST /security/vulnerabilities/:id/assign — assign for remediation + * GET /security/score — current security score + * GET /security/score/history — score history + * GET /security/overdue — overdue SLA summary + */ + +import { Router, type Request, type Response } from 'express'; +import { securityService } from '../services/security.js'; +import type { + ScanType, + VulnerabilitySeverity, + VulnerabilityStatus, +} from '../services/security.js'; + +const router = Router(); + +// ── POST /security/scans ────────────────────────────────────────────────────── + +router.post('/scans', (req: Request, res: Response) => { + const { scanType, triggeredBy } = req.body as { + scanType?: string; + triggeredBy?: string; + }; + + if (!scanType) { + return res.status(400).json({ success: false, error: 'scanType is required' }); + } + + const valid: ScanType[] = ['sast', 'dast', 'dependency', 'smart_contract']; + if (!valid.includes(scanType as ScanType)) { + return res.status(400).json({ + success: false, + error: `scanType must be one of: ${valid.join(', ')}`, + }); + } + + const result = securityService.startScan(scanType as ScanType, triggeredBy); + if (!result.ok) { + return res.status(result.error.statusCode ?? 400).json({ + success: false, + error: result.error.message, + }); + } + + return res.status(201).json({ success: true, data: result.value }); +}); + +// ── POST /security/scans/:id/complete ──────────────────────────────────────── + +router.post('/scans/:id/complete', (req: Request, res: Response) => { + const { findings } = req.body as { findings?: unknown[] }; + if (!Array.isArray(findings)) { + return res.status(400).json({ success: false, error: 'findings array is required' }); + } + + const result = securityService.completeScan(req.params.id, findings as Parameters[1]); + if (!result.ok) { + return res.status(result.error.statusCode ?? 400).json({ + success: false, + error: result.error.message, + }); + } + + return res.json({ success: true, data: result.value }); +}); + +// ── GET /security/scans ─────────────────────────────────────────────────────── + +router.get('/scans', (req: Request, res: Response) => { + const { limit } = req.query as { limit?: string }; + const reports = securityService.listScanReports(limit ? parseInt(limit) : 20); + return res.json({ success: true, count: reports.length, data: reports }); +}); + +// ── GET /security/scans/:id ─────────────────────────────────────────────────── + +router.get('/scans/:id', (req: Request, res: Response) => { + const result = securityService.getScanReport(req.params.id); + if (!result.ok) { + return res.status(result.error.statusCode ?? 404).json({ + success: false, + error: result.error.message, + }); + } + return res.json({ success: true, data: result.value }); +}); + +// ── GET /security/vulnerabilities ───────────────────────────────────────────── + +router.get('/vulnerabilities', (req: Request, res: Response) => { + const { severity, status, scanType, overdue } = req.query as { + severity?: string; + status?: string; + scanType?: string; + overdue?: string; + }; + + const vulns = securityService.listVulnerabilities({ + severity: severity as VulnerabilitySeverity | undefined, + status: status as VulnerabilityStatus | undefined, + scanType: scanType as ScanType | undefined, + overdue: overdue === 'true', + }); + + return res.json({ success: true, count: vulns.length, data: vulns }); +}); + +// ── GET /security/vulnerabilities/:id ──────────────────────────────────────── + +router.get('/vulnerabilities/:id', (req: Request, res: Response) => { + const result = securityService.getVulnerability(req.params.id); + if (!result.ok) { + return res.status(result.error.statusCode ?? 404).json({ + success: false, + error: result.error.message, + }); + } + return res.json({ success: true, data: result.value }); +}); + +// ── PATCH /security/vulnerabilities/:id/status ─────────────────────────────── + +router.patch('/vulnerabilities/:id/status', (req: Request, res: Response) => { + const { status, notes } = req.body as { status?: string; notes?: string }; + if (!status) { + return res.status(400).json({ success: false, error: 'status is required' }); + } + + const result = securityService.updateVulnerabilityStatus( + req.params.id, + status as VulnerabilityStatus, + notes, + ); + + if (!result.ok) { + return res.status(result.error.statusCode ?? 400).json({ + success: false, + error: result.error.message, + }); + } + + return res.json({ success: true, data: result.value }); +}); + +// ── POST /security/vulnerabilities/:id/assign ──────────────────────────────── + +router.post('/vulnerabilities/:id/assign', (req: Request, res: Response) => { + const { assignedTo, notes } = req.body as { assignedTo?: string; notes?: string }; + if (!assignedTo) { + return res.status(400).json({ success: false, error: 'assignedTo is required' }); + } + + const result = securityService.assignRemediation(req.params.id, assignedTo, notes); + if (!result.ok) { + return res.status(result.error.statusCode ?? 400).json({ + success: false, + error: result.error.message, + }); + } + + return res.json({ success: true, data: result.value }); +}); + +// ── GET /security/score ─────────────────────────────────────────────────────── + +router.get('/score', (_req: Request, res: Response) => { + return res.json({ success: true, data: securityService.getCurrentScore() }); +}); + +// ── GET /security/score/history ─────────────────────────────────────────────── + +router.get('/score/history', (req: Request, res: Response) => { + const { days } = req.query as { days?: string }; + const history = securityService.getScoreHistory(days ? parseInt(days) : 30); + return res.json({ success: true, count: history.length, data: history }); +}); + +// ── GET /security/overdue ───────────────────────────────────────────────────── + +router.get('/overdue', (_req: Request, res: Response) => { + return res.json({ success: true, data: securityService.getOverdueSummary() }); +}); + +export default router; diff --git a/backend/src/services/comments.ts b/backend/src/services/comments.ts new file mode 100644 index 00000000..048b49ef --- /dev/null +++ b/backend/src/services/comments.ts @@ -0,0 +1,409 @@ +/** + * comments.ts — Issue #596 + * + * Comment service for project collaboration: threaded comments, inline file + * annotations, @mention notifications, reactions, voting, and search. + */ + +import { randomUUID } from 'node:crypto'; +import { BaseService } from './BaseService.js'; +import type { Result } from '../lib/result.js'; + +// ── Types ───────────────────────────────────────────────────────────────────── + +export type CommentTargetType = 'milestone' | 'deliverable' | 'project' | 'file_annotation'; +export type ReactionEmoji = '👍' | '👎' | '❤️' | '🎉' | '🚀' | '👀' | '😕' | '🔥'; + +export interface FileAnnotation { + filePath: string; + lineStart: number; + lineEnd?: number; + commitHash?: string; +} + +export interface CommentReaction { + emoji: ReactionEmoji; + userId: string; + createdAt: string; +} + +export interface Comment { + id: string; + projectId: string; + targetType: CommentTargetType; + targetId: string; // milestoneId, deliverableId, etc. + parentId?: string; // for threaded replies + authorId: string; + authorName: string; + body: string; + mentions: string[]; // userId[] + reactions: CommentReaction[]; + upvotes: number; + downvotes: number; + userVotes: Record; // userId → vote + annotation?: FileAnnotation; + edited: boolean; + editedAt?: string; + deleted: boolean; + deletedAt?: string; + createdAt: string; + updatedAt: string; +} + +export interface ActivityEvent { + id: string; + projectId: string; + type: + | 'comment_added' + | 'comment_edited' + | 'comment_deleted' + | 'reaction_added' + | 'milestone_updated' + | 'member_joined' + | 'file_annotated'; + actorId: string; + actorName: string; + targetId?: string; + targetType?: string; + payload: Record; + createdAt: string; +} + +export interface MentionNotification { + id: string; + commentId: string; + projectId: string; + mentionedUserId: string; + mentionedBy: string; + commentSnippet: string; + read: boolean; + createdAt: string; +} + +export interface CreateCommentInput { + projectId: string; + targetType: CommentTargetType; + targetId: string; + parentId?: string; + authorId: string; + authorName: string; + body: string; + annotation?: FileAnnotation; +} + +// ── In-memory stores ────────────────────────────────────────────────────────── + +const comments = new Map(); +const activityFeed = new Map(); // projectId → events +const notifications: MentionNotification[] = []; + +// ── Mention parser ──────────────────────────────────────────────────────────── + +function parseMentions(body: string): string[] { + const matches = body.match(/@(\w+)/g) ?? []; + return [...new Set(matches.map((m) => m.slice(1)))]; +} + +// ── Activity helper ─────────────────────────────────────────────────────────── + +function addActivity( + projectId: string, + event: Omit, +): void { + const entry: ActivityEvent = { + ...event, + id: randomUUID(), + createdAt: new Date().toISOString(), + }; + const arr = activityFeed.get(projectId) ?? []; + arr.push(entry); + if (arr.length > 500) arr.shift(); // keep last 500 events per project + activityFeed.set(projectId, arr); +} + +// ── Service ─────────────────────────────────────────────────────────────────── + +export class CommentService extends BaseService { + + // ── Add a comment ───────────────────────────────────────────────────────── + + addComment(input: CreateCommentInput): Result { + if (!input.body.trim()) { + return this.validationFailure('Comment body cannot be empty'); + } + + // Validate parent exists and belongs to same project/target + if (input.parentId) { + const parent = comments.get(input.parentId); + if (!parent) return this.notFoundFailure('ParentComment', input.parentId); + if (parent.deleted) return this.validationFailure('Cannot reply to a deleted comment'); + if (parent.projectId !== input.projectId) { + return this.validationFailure('Parent comment belongs to a different project'); + } + } + + const mentions = parseMentions(input.body); + const now = new Date().toISOString(); + const id = randomUUID(); + + const comment: Comment = { + id, + projectId: input.projectId, + targetType: input.targetType, + targetId: input.targetId, + parentId: input.parentId, + authorId: input.authorId, + authorName: input.authorName, + body: input.body, + mentions, + reactions: [], + upvotes: 0, + downvotes: 0, + userVotes: {}, + annotation: input.annotation, + edited: false, + deleted: false, + createdAt: now, + updatedAt: now, + }; + + comments.set(id, comment); + + // Create mention notifications + for (const userId of mentions) { + notifications.push({ + id: randomUUID(), + commentId: id, + projectId: input.projectId, + mentionedUserId: userId, + mentionedBy: input.authorName, + commentSnippet: input.body.slice(0, 140), + read: false, + createdAt: now, + }); + } + + addActivity(input.projectId, { + type: input.annotation ? 'file_annotated' : 'comment_added', + projectId: input.projectId, + actorId: input.authorId, + actorName: input.authorName, + targetId: id, + targetType: input.targetType, + payload: { commentId: id, snippet: input.body.slice(0, 80) }, + }); + + return this.ok(comment); + } + + // ── Edit a comment ──────────────────────────────────────────────────────── + + editComment(commentId: string, userId: string, body: string): Result { + const comment = comments.get(commentId); + if (!comment) return this.notFoundFailure('Comment', commentId); + if (comment.deleted) return this.validationFailure('Cannot edit a deleted comment'); + if (comment.authorId !== userId) return this.forbiddenFailure('You can only edit your own comments'); + if (!body.trim()) return this.validationFailure('Comment body cannot be empty'); + + const now = new Date().toISOString(); + comment.body = body; + comment.mentions = parseMentions(body); + comment.edited = true; + comment.editedAt = now; + comment.updatedAt = now; + comments.set(commentId, comment); + + addActivity(comment.projectId, { + type: 'comment_edited', + projectId: comment.projectId, + actorId: userId, + actorName: comment.authorName, + targetId: commentId, + targetType: comment.targetType, + payload: { commentId }, + }); + + return this.ok(comment); + } + + // ── Soft delete ─────────────────────────────────────────────────────────── + + deleteComment(commentId: string, userId: string): Result { + const comment = comments.get(commentId); + if (!comment) return this.notFoundFailure('Comment', commentId); + if (comment.authorId !== userId) return this.forbiddenFailure('You can only delete your own comments'); + if (comment.deleted) return this.validationFailure('Comment already deleted'); + + const now = new Date().toISOString(); + comment.deleted = true; + comment.deletedAt = now; + comment.body = '[deleted]'; + comment.updatedAt = now; + comments.set(commentId, comment); + + addActivity(comment.projectId, { + type: 'comment_deleted', + projectId: comment.projectId, + actorId: userId, + actorName: comment.authorName, + targetId: commentId, + targetType: comment.targetType, + payload: { commentId }, + }); + + return this.ok(comment); + } + + // ── Add reaction ────────────────────────────────────────────────────────── + + addReaction(commentId: string, userId: string, emoji: ReactionEmoji): Result { + const comment = comments.get(commentId); + if (!comment) return this.notFoundFailure('Comment', commentId); + if (comment.deleted) return this.validationFailure('Cannot react to a deleted comment'); + + // Remove existing reaction from this user for this emoji + comment.reactions = comment.reactions.filter( + (r) => !(r.userId === userId && r.emoji === emoji), + ); + + // Toggle: if the user didn't have this reaction, add it; otherwise it's removed above + const alreadyHad = comment.reactions.some((r) => r.userId === userId && r.emoji === emoji); + if (!alreadyHad) { + comment.reactions.push({ emoji, userId, createdAt: new Date().toISOString() }); + } + + comment.updatedAt = new Date().toISOString(); + comments.set(commentId, comment); + + addActivity(comment.projectId, { + type: 'reaction_added', + projectId: comment.projectId, + actorId: userId, + actorName: userId, + targetId: commentId, + targetType: 'comment', + payload: { emoji, commentId }, + }); + + return this.ok(comment); + } + + // ── Vote ────────────────────────────────────────────────────────────────── + + vote(commentId: string, userId: string, direction: 'up' | 'down'): Result { + const comment = comments.get(commentId); + if (!comment) return this.notFoundFailure('Comment', commentId); + if (comment.deleted) return this.validationFailure('Cannot vote on a deleted comment'); + if (comment.authorId === userId) return this.validationFailure('Cannot vote on your own comment'); + + const existing = comment.userVotes[userId]; + + // Undo existing vote + if (existing === 'up') comment.upvotes--; + if (existing === 'down') comment.downvotes--; + + if (existing === direction) { + // Toggle off + delete comment.userVotes[userId]; + } else { + comment.userVotes[userId] = direction; + if (direction === 'up') comment.upvotes++; + else comment.downvotes++; + } + + comment.updatedAt = new Date().toISOString(); + comments.set(commentId, comment); + return this.ok(comment); + } + + // ── List comments for a target ──────────────────────────────────────────── + + listComments( + projectId: string, + targetType: CommentTargetType, + targetId: string, + options?: { includeDeleted?: boolean; threadedOnly?: boolean }, + ): Comment[] { + return Array.from(comments.values()) + .filter((c) => { + if (c.projectId !== projectId) return false; + if (c.targetType !== targetType) return false; + if (c.targetId !== targetId) return false; + if (!options?.includeDeleted && c.deleted) return false; + if (options?.threadedOnly && c.parentId) return false; + return true; + }) + .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); + } + + // ── Get thread (parent + all replies) ──────────────────────────────────── + + getThread(parentId: string): Result<{ parent: Comment; replies: Comment[] }> { + const parent = comments.get(parentId); + if (!parent) return this.notFoundFailure('Comment', parentId); + + const replies = Array.from(comments.values()) + .filter((c) => c.parentId === parentId && !c.deleted) + .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); + + return this.ok({ parent, replies }); + } + + // ── Search ──────────────────────────────────────────────────────────────── + + search(projectId: string, query: string, limit = 20): Comment[] { + const lower = query.toLowerCase(); + return Array.from(comments.values()) + .filter( + (c) => + c.projectId === projectId && + !c.deleted && + c.body.toLowerCase().includes(lower), + ) + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) + .slice(0, limit); + } + + // ── Activity feed ───────────────────────────────────────────────────────── + + getActivityFeed(projectId: string, limit = 50): ActivityEvent[] { + const events = activityFeed.get(projectId) ?? []; + return events.slice(-limit).reverse(); + } + + // ── Mention notifications ────────────────────────────────────────────────── + + getUserNotifications( + userId: string, + options?: { unreadOnly?: boolean; limit?: number }, + ): MentionNotification[] { + let all = notifications.filter((n) => n.mentionedUserId === userId); + if (options?.unreadOnly) all = all.filter((n) => !n.read); + return all + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) + .slice(0, options?.limit ?? 50); + } + + markNotificationsRead(userId: string, notificationIds?: string[]): number { + let count = 0; + for (const n of notifications) { + if (n.mentionedUserId !== userId) continue; + if (notificationIds && !notificationIds.includes(n.id)) continue; + if (!n.read) { + n.read = true; + count++; + } + } + return count; + } + + // ── Get a single comment ────────────────────────────────────────────────── + + getComment(commentId: string): Result { + const comment = comments.get(commentId); + if (!comment) return this.notFoundFailure('Comment', commentId); + return this.ok(comment); + } +} + +export const commentService = new CommentService(); diff --git a/backend/src/services/dataExport.ts b/backend/src/services/dataExport.ts new file mode 100644 index 00000000..eadcfb6c --- /dev/null +++ b/backend/src/services/dataExport.ts @@ -0,0 +1,424 @@ +/** + * dataExport.ts — Issue #595 + * + * GDPR-compliant data export service supporting JSON, CSV, and PDF formats. + * Includes job queue, audit trail, anonymisation, scheduled reports, and + * large-dataset streaming support. + */ + +import { randomUUID } from 'node:crypto'; +import { BaseService } from './BaseService.js'; +import type { Result } from '../lib/result.js'; + +// ── Types ───────────────────────────────────────────────────────────────────── + +export type ExportFormat = 'json' | 'csv' | 'pdf'; +export type ExportStatus = 'queued' | 'processing' | 'completed' | 'failed' | 'expired'; +export type ExportScope = + | 'full_account' + | 'payments' + | 'invoices' + | 'subscriptions' + | 'projects' + | 'analytics'; +export type ScheduleFrequency = 'daily' | 'weekly' | 'monthly'; + +export interface ExportJob { + id: string; + userId: string; + format: ExportFormat; + scope: ExportScope[]; + status: ExportStatus; + anonymise: boolean; + /** GDPR "right to portability" flag */ + isGdprRequest: boolean; + downloadUrl?: string; + fileSizeBytes?: number; + rowCount?: number; + requestedAt: string; + startedAt?: string; + completedAt?: string; + expiresAt?: string; + error?: string; + deliveryEmail?: string; + /** ID of the scheduled export that triggered this, if any */ + scheduledExportId?: string; +} + +export interface ScheduledExport { + id: string; + userId: string; + format: ExportFormat; + scope: ExportScope[]; + frequency: ScheduleFrequency; + anonymise: boolean; + deliveryEmail: string; + enabled: boolean; + lastRunAt?: string; + nextRunAt: string; + createdAt: string; + updatedAt: string; +} + +export interface AuditEntry { + id: string; + userId: string; + action: 'export_requested' | 'export_completed' | 'export_downloaded' | 'export_deleted' | 'schedule_created' | 'schedule_updated' | 'schedule_deleted'; + exportJobId?: string; + scheduledExportId?: string; + metadata: Record; + timestamp: string; + ipAddress?: string; +} + +export interface CreateExportInput { + userId: string; + format: ExportFormat; + scope: ExportScope[]; + anonymise?: boolean; + isGdprRequest?: boolean; + deliveryEmail?: string; + scheduledExportId?: string; +} + +export interface CreateScheduleInput { + userId: string; + format: ExportFormat; + scope: ExportScope[]; + frequency: ScheduleFrequency; + anonymise?: boolean; + deliveryEmail: string; +} + +// ── In-memory stores ────────────────────────────────────────────────────────── + +const exportJobs = new Map(); +const scheduledExports = new Map(); +const auditLog: AuditEntry[] = []; +const byUser = new Map>(); // userId → exportJobIds +const schedulesByUser = new Map>(); // userId → scheduleIds + +// ── Schedule interval helpers ───────────────────────────────────────────────── + +const FREQUENCY_MS: Record = { + daily: 24 * 60 * 60 * 1_000, + weekly: 7 * 24 * 60 * 60 * 1_000, + monthly: 30 * 24 * 60 * 60 * 1_000, +}; + +const EXPORT_EXPIRY_MS = 7 * 24 * 60 * 60 * 1_000; // 7 days + +// ── Anonymisation helper ────────────────────────────────────────────────────── + +function anonymiseRecord>(record: T): T { + const PII_FIELDS = ['email', 'name', 'phone', 'address', 'ip', 'ipAddress', 'stellarAddress']; + const result = { ...record }; + for (const field of PII_FIELDS) { + if (field in result) { + (result as Record)[field] = '***ANONYMISED***'; + } + } + return result; +} + +// ── Service ─────────────────────────────────────────────────────────────────── + +export class DataExportService extends BaseService { + + private addAudit( + entry: Omit, + ): void { + auditLog.push({ ...entry, id: randomUUID(), timestamp: new Date().toISOString() }); + if (auditLog.length > 10_000) auditLog.shift(); + } + + // ── Create export job ───────────────────────────────────────────────────── + + createExport(input: CreateExportInput): Result { + if (input.scope.length === 0) { + return this.validationFailure('At least one scope is required'); + } + + const id = randomUUID(); + const now = new Date(); + + const job: ExportJob = { + id, + userId: input.userId, + format: input.format, + scope: input.scope, + status: 'queued', + anonymise: input.anonymise ?? false, + isGdprRequest: input.isGdprRequest ?? false, + requestedAt: now.toISOString(), + expiresAt: new Date(now.getTime() + EXPORT_EXPIRY_MS).toISOString(), + deliveryEmail: input.deliveryEmail, + scheduledExportId: input.scheduledExportId, + }; + + exportJobs.set(id, job); + + const set = byUser.get(input.userId) ?? new Set(); + set.add(id); + byUser.set(input.userId, set); + + this.addAudit({ + userId: input.userId, + action: 'export_requested', + exportJobId: id, + metadata: { format: input.format, scope: input.scope, isGdprRequest: job.isGdprRequest }, + }); + + return this.ok(job); + } + + // ── Start processing an export ──────────────────────────────────────────── + + startExport(jobId: string): Result { + const job = exportJobs.get(jobId); + if (!job) return this.notFoundFailure('ExportJob', jobId); + if (job.status !== 'queued') { + return this.validationFailure(`Export is already ${job.status}`); + } + + job.status = 'processing'; + job.startedAt = new Date().toISOString(); + exportJobs.set(jobId, job); + return this.ok(job); + } + + // ── Complete an export ──────────────────────────────────────────────────── + + completeExport( + jobId: string, + result: { downloadUrl: string; fileSizeBytes: number; rowCount: number }, + ): Result { + const job = exportJobs.get(jobId); + if (!job) return this.notFoundFailure('ExportJob', jobId); + if (job.status !== 'processing') { + return this.validationFailure(`Export is not in processing state`); + } + + job.status = 'completed'; + job.downloadUrl = result.downloadUrl; + job.fileSizeBytes = result.fileSizeBytes; + job.rowCount = result.rowCount; + job.completedAt = new Date().toISOString(); + exportJobs.set(jobId, job); + + this.addAudit({ + userId: job.userId, + action: 'export_completed', + exportJobId: jobId, + metadata: { rowCount: result.rowCount, fileSizeBytes: result.fileSizeBytes }, + }); + + return this.ok(job); + } + + // ── Fail an export ──────────────────────────────────────────────────────── + + failExport(jobId: string, error: string): Result { + const job = exportJobs.get(jobId); + if (!job) return this.notFoundFailure('ExportJob', jobId); + job.status = 'failed'; + job.error = error; + job.completedAt = new Date().toISOString(); + exportJobs.set(jobId, job); + return this.ok(job); + } + + // ── Get export job ──────────────────────────────────────────────────────── + + getExport(jobId: string): Result { + const job = exportJobs.get(jobId); + if (!job) return this.notFoundFailure('ExportJob', jobId); + return this.ok(job); + } + + // ── List user exports ───────────────────────────────────────────────────── + + listUserExports(userId: string, limit = 50): ExportJob[] { + const ids = byUser.get(userId) ?? new Set(); + return Array.from(ids) + .map((id) => exportJobs.get(id)!) + .filter(Boolean) + .sort((a, b) => new Date(b.requestedAt).getTime() - new Date(a.requestedAt).getTime()) + .slice(0, limit); + } + + // ── Delete an export ────────────────────────────────────────────────────── + + deleteExport(jobId: string, userId: string): Result { + const job = exportJobs.get(jobId); + if (!job) return this.notFoundFailure('ExportJob', jobId); + if (job.userId !== userId) return this.forbiddenFailure('You do not own this export'); + + exportJobs.delete(jobId); + byUser.get(userId)?.delete(jobId); + + this.addAudit({ userId, action: 'export_deleted', exportJobId: jobId, metadata: {} }); + return this.ok(undefined); + } + + // ── Anonymise data ──────────────────────────────────────────────────────── + + anonymiseData>(data: T[]): T[] { + return data.map(anonymiseRecord); + } + + // ── Convert records to CSV ──────────────────────────────────────────────── + + toCSV(records: Record[]): string { + if (records.length === 0) return ''; + const headers = Object.keys(records[0]); + const rows = records.map((r) => + headers.map((h) => JSON.stringify(r[h] ?? '')).join(','), + ); + return [headers.join(','), ...rows].join('\n'); + } + + // ── Scheduled export management ─────────────────────────────────────────── + + createSchedule(input: CreateScheduleInput): Result { + if (!input.deliveryEmail) { + return this.validationFailure('deliveryEmail is required for scheduled exports'); + } + + const id = randomUUID(); + const now = new Date(); + const nextRunAt = new Date(now.getTime() + FREQUENCY_MS[input.frequency]).toISOString(); + + const schedule: ScheduledExport = { + id, + userId: input.userId, + format: input.format, + scope: input.scope, + frequency: input.frequency, + anonymise: input.anonymise ?? false, + deliveryEmail: input.deliveryEmail, + enabled: true, + nextRunAt, + createdAt: now.toISOString(), + updatedAt: now.toISOString(), + }; + + scheduledExports.set(id, schedule); + const set = schedulesByUser.get(input.userId) ?? new Set(); + set.add(id); + schedulesByUser.set(input.userId, set); + + this.addAudit({ + userId: input.userId, + action: 'schedule_created', + scheduledExportId: id, + metadata: { frequency: input.frequency, format: input.format }, + }); + + return this.ok(schedule); + } + + updateSchedule( + scheduleId: string, + userId: string, + updates: Partial>, + ): Result { + const schedule = scheduledExports.get(scheduleId); + if (!schedule) return this.notFoundFailure('ScheduledExport', scheduleId); + if (schedule.userId !== userId) return this.forbiddenFailure('You do not own this schedule'); + + Object.assign(schedule, updates); + schedule.updatedAt = new Date().toISOString(); + + if (updates.frequency) { + schedule.nextRunAt = new Date( + Date.now() + FREQUENCY_MS[updates.frequency], + ).toISOString(); + } + + scheduledExports.set(scheduleId, schedule); + this.addAudit({ + userId, + action: 'schedule_updated', + scheduledExportId: scheduleId, + metadata: updates as Record, + }); + + return this.ok(schedule); + } + + deleteSchedule(scheduleId: string, userId: string): Result { + const schedule = scheduledExports.get(scheduleId); + if (!schedule) return this.notFoundFailure('ScheduledExport', scheduleId); + if (schedule.userId !== userId) return this.forbiddenFailure('You do not own this schedule'); + + scheduledExports.delete(scheduleId); + schedulesByUser.get(userId)?.delete(scheduleId); + + this.addAudit({ userId, action: 'schedule_deleted', scheduledExportId: scheduleId, metadata: {} }); + return this.ok(undefined); + } + + listUserSchedules(userId: string): ScheduledExport[] { + const ids = schedulesByUser.get(userId) ?? new Set(); + return Array.from(ids) + .map((id) => scheduledExports.get(id)!) + .filter(Boolean) + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + } + + // ── Get schedules due for execution ─────────────────────────────────────── + + getDueSchedules(): ScheduledExport[] { + const now = new Date().toISOString(); + return Array.from(scheduledExports.values()).filter( + (s) => s.enabled && s.nextRunAt <= now, + ); + } + + // ── Bump schedule next run time ─────────────────────────────────────────── + + advanceScheduleNextRun(scheduleId: string): void { + const schedule = scheduledExports.get(scheduleId); + if (!schedule) return; + schedule.lastRunAt = new Date().toISOString(); + schedule.nextRunAt = new Date( + Date.now() + FREQUENCY_MS[schedule.frequency], + ).toISOString(); + schedule.updatedAt = schedule.lastRunAt; + scheduledExports.set(scheduleId, schedule); + } + + // ── Audit trail ─────────────────────────────────────────────────────────── + + getAuditLog(userId?: string, limit = 100): AuditEntry[] { + const entries = userId + ? auditLog.filter((e) => e.userId === userId) + : auditLog; + return entries.slice(-limit).reverse(); + } + + // ── Expire old completed exports ────────────────────────────────────────── + + expireOldExports(): number { + const now = new Date().toISOString(); + let count = 0; + for (const job of exportJobs.values()) { + if (job.status === 'completed' && job.expiresAt && job.expiresAt <= now) { + job.status = 'expired'; + job.downloadUrl = undefined; + exportJobs.set(job.id, job); + count++; + } + } + return count; + } + + // ── Get queued exports ──────────────────────────────────────────────────── + + getQueuedExports(): ExportJob[] { + return Array.from(exportJobs.values()).filter((j) => j.status === 'queued'); + } +} + +export const dataExportService = new DataExportService(); diff --git a/backend/src/services/paymentRetry.ts b/backend/src/services/paymentRetry.ts new file mode 100644 index 00000000..250583be --- /dev/null +++ b/backend/src/services/paymentRetry.ts @@ -0,0 +1,461 @@ +/** + * paymentRetry.ts — Issue #592 + * + * Intelligent payment retry engine with failure categorisation, exponential + * back-off scheduling, payment-method fallback chains, dunning email sequences, + * and account-suspension after prolonged failure. + */ + +import { randomUUID } from 'node:crypto'; +import { BaseService } from './BaseService.js'; +import type { Result } from '../lib/result.js'; + +// ── Failure categorisation ──────────────────────────────────────────────────── + +export type FailureCategory = + | 'INSUFFICIENT_FUNDS' + | 'CARD_EXPIRED' + | 'CARD_DECLINED' + | 'NETWORK_ERROR' + | 'FRAUD_SUSPECTED' + | 'INVALID_DETAILS' + | 'PROCESSOR_ERROR' + | 'UNKNOWN'; + +export type RetrySeverity = 'immediate' | 'same_day' | 'next_day' | 'weekly' | 'abandon'; + +export interface FailureInfo { + category: FailureCategory; + severity: RetrySeverity; + retryable: boolean; + resolutionSuggestions: string[]; + dunningMessage: string; +} + +export type RetryStatus = + | 'pending' + | 'scheduled' + | 'in_progress' + | 'succeeded' + | 'failed' + | 'abandoned'; + +export interface PaymentAttempt { + id: string; + attemptNumber: number; + scheduledAt: string; + executedAt?: string; + status: 'pending' | 'succeeded' | 'failed'; + failureCategory?: FailureCategory; + failureMessage?: string; + paymentMethodId: string; +} + +export interface RetryRecord { + id: string; + paymentId: string; + userId: string; + originalAmount: number; + currency: string; + status: RetryStatus; + attempts: PaymentAttempt[]; + currentAttemptNumber: number; + maxAttempts: number; + nextRetryAt?: string; + abandonedAt?: string; + suspendAccountAt?: string; + dunningStep: number; + paymentMethodFallbackChain: string[]; + currentPaymentMethodIndex: number; + createdAt: string; + updatedAt: string; +} + +export interface CreateRetryInput { + paymentId: string; + userId: string; + amount: number; + currency: string; + failureReason: string; + paymentMethodIds: string[]; // ordered fallback chain +} + +export interface RetryStats { + total: number; + pending: number; + scheduled: number; + succeeded: number; + failed: number; + abandoned: number; + recoveryRate: number; +} + +// ── Constants ──────────────────────────────────────────────────────────────── + +const MAX_ATTEMPTS_DEFAULT = 7; +const SUSPENSION_THRESHOLD_DAYS = 30; + +const CATEGORY_MAP: Record = { + insufficient_funds: { + category: 'INSUFFICIENT_FUNDS', + severity: 'next_day', + retryable: true, + resolutionSuggestions: [ + 'Add funds to your account or linked bank account.', + 'Update to a card with sufficient balance.', + 'Consider splitting this payment into smaller amounts.', + ], + dunningMessage: 'Your payment failed due to insufficient funds. Please add funds or update your payment method.', + }, + expired: { + category: 'CARD_EXPIRED', + severity: 'abandon', + retryable: false, + resolutionSuggestions: [ + 'Update your card details with the new expiry date.', + 'Add a new payment method.', + ], + dunningMessage: 'Your card has expired. Please update your payment details to continue.', + }, + declined: { + category: 'CARD_DECLINED', + severity: 'same_day', + retryable: true, + resolutionSuggestions: [ + 'Contact your bank to authorise the transaction.', + 'Try a different payment method.', + 'Ensure your billing address matches your card details.', + ], + dunningMessage: 'Your card was declined. Please contact your bank or try a different card.', + }, + network: { + category: 'NETWORK_ERROR', + severity: 'immediate', + retryable: true, + resolutionSuggestions: ['The system will automatically retry. No action required.'], + dunningMessage: 'A temporary network error occurred. We are retrying your payment automatically.', + }, + fraud: { + category: 'FRAUD_SUSPECTED', + severity: 'abandon', + retryable: false, + resolutionSuggestions: [ + 'Contact your bank to verify recent transactions.', + 'Use a verified payment method.', + ], + dunningMessage: 'Your payment was flagged for security review. Please contact support.', + }, + invalid: { + category: 'INVALID_DETAILS', + severity: 'abandon', + retryable: false, + resolutionSuggestions: [ + 'Double-check your card number, expiry, and CVV.', + 'Update your billing address.', + ], + dunningMessage: 'Invalid payment details detected. Please update your payment information.', + }, + processor: { + category: 'PROCESSOR_ERROR', + severity: 'next_day', + retryable: true, + resolutionSuggestions: ['Our payment processor experienced an issue. We will retry automatically.'], + dunningMessage: 'A temporary processing error occurred. We will retry your payment tomorrow.', + }, +}; + +// ── Retry delay schedule (ms) ──────────────────────────────────────────────── + +const RETRY_DELAYS: Record = { + immediate: 5 * 60 * 1_000, // 5 minutes + same_day: 4 * 60 * 60 * 1_000, // 4 hours + next_day: 24 * 60 * 60 * 1_000, // 24 hours + weekly: 7 * 24 * 60 * 60 * 1_000, // 7 days + abandon: 0, +}; + +// ── In-memory store ────────────────────────────────────────────────────────── + +const retryRecords = new Map(); // id → record +const byPayment = new Map(); // paymentId → retryId +const byUser = new Map>(); // userId → retryIds + +// ── Service ────────────────────────────────────────────────────────────────── + +export class PaymentRetryService extends BaseService { + + // ── Categorise a failure reason string ──────────────────────────────────── + + categoriseFailure(reason: string): FailureInfo { + const lower = reason.toLowerCase(); + for (const [key, info] of Object.entries(CATEGORY_MAP)) { + if (lower.includes(key)) { + return info; + } + } + return { + category: 'UNKNOWN', + severity: 'next_day', + retryable: true, + resolutionSuggestions: ['Please contact support if this issue persists.'], + dunningMessage: 'Your payment could not be processed. Please try again or contact support.', + }; + } + + // ── Create a new retry record ────────────────────────────────────────────── + + createRetry(input: CreateRetryInput): Result { + if (byPayment.has(input.paymentId)) { + const existing = retryRecords.get(byPayment.get(input.paymentId)!)!; + if (['pending', 'scheduled', 'in_progress'].includes(existing.status)) { + return this.conflictFailure( + `An active retry already exists for payment ${input.paymentId}`, + ); + } + } + + if (input.paymentMethodIds.length === 0) { + return this.validationFailure('At least one payment method is required'); + } + + const failureInfo = this.categoriseFailure(input.failureReason); + const now = new Date(); + const id = randomUUID(); + + let nextRetryAt: string | undefined; + let status: RetryStatus = 'scheduled'; + + if (!failureInfo.retryable) { + status = 'abandoned'; + } else { + const delay = RETRY_DELAYS[failureInfo.severity]; + nextRetryAt = new Date(now.getTime() + delay).toISOString(); + } + + const suspendAt = new Date(now.getTime() + SUSPENSION_THRESHOLD_DAYS * 24 * 60 * 60 * 1_000); + + const record: RetryRecord = { + id, + paymentId: input.paymentId, + userId: input.userId, + originalAmount: input.amount, + currency: input.currency, + status, + attempts: [], + currentAttemptNumber: 0, + maxAttempts: MAX_ATTEMPTS_DEFAULT, + nextRetryAt, + suspendAccountAt: suspendAt.toISOString(), + dunningStep: 0, + paymentMethodFallbackChain: [...input.paymentMethodIds], + currentPaymentMethodIndex: 0, + createdAt: now.toISOString(), + updatedAt: now.toISOString(), + }; + + retryRecords.set(id, record); + byPayment.set(input.paymentId, id); + + const userSet = byUser.get(input.userId) ?? new Set(); + userSet.add(id); + byUser.set(input.userId, userSet); + + return this.ok(record); + } + + // ── Execute the next retry attempt ──────────────────────────────────────── + + executeRetry( + retryId: string, + options?: { forceMethodIndex?: number }, + ): Result { + const record = retryRecords.get(retryId); + if (!record) return this.notFoundFailure('RetryRecord', retryId); + if (record.status === 'abandoned') { + return this.validationFailure('Cannot retry an abandoned payment'); + } + if (record.status === 'succeeded') { + return this.validationFailure('Payment already succeeded'); + } + if (record.currentAttemptNumber >= record.maxAttempts) { + this.abandonRetry(retryId); + return this.validationFailure('Maximum retry attempts reached'); + } + + const methodIndex = options?.forceMethodIndex ?? record.currentPaymentMethodIndex; + const paymentMethodId = record.paymentMethodFallbackChain[methodIndex]; + + const attempt: PaymentAttempt = { + id: randomUUID(), + attemptNumber: record.currentAttemptNumber + 1, + scheduledAt: record.nextRetryAt ?? new Date().toISOString(), + executedAt: new Date().toISOString(), + status: 'pending', + paymentMethodId, + }; + + record.attempts.push(attempt); + record.currentAttemptNumber++; + record.status = 'in_progress'; + record.updatedAt = new Date().toISOString(); + retryRecords.set(retryId, record); + + return this.ok(attempt); + } + + // ── Record the outcome of an attempt ────────────────────────────────────── + + recordAttemptOutcome( + retryId: string, + attemptId: string, + outcome: { success: boolean; failureReason?: string }, + ): Result { + const record = retryRecords.get(retryId); + if (!record) return this.notFoundFailure('RetryRecord', retryId); + + const attempt = record.attempts.find((a) => a.id === attemptId); + if (!attempt) return this.notFoundFailure('PaymentAttempt', attemptId); + + attempt.status = outcome.success ? 'succeeded' : 'failed'; + + if (outcome.success) { + record.status = 'succeeded'; + record.nextRetryAt = undefined; + } else if (outcome.failureReason) { + const failureInfo = this.categoriseFailure(outcome.failureReason); + attempt.failureCategory = failureInfo.category; + attempt.failureMessage = outcome.failureReason; + + // Advance dunning step + record.dunningStep = Math.min(record.dunningStep + 1, 5); + + if (!failureInfo.retryable || record.currentAttemptNumber >= record.maxAttempts) { + record.status = 'abandoned'; + record.abandonedAt = new Date().toISOString(); + } else { + // Try fallback payment method if available + const nextMethodIndex = record.currentPaymentMethodIndex + 1; + const hasNextMethod = nextMethodIndex < record.paymentMethodFallbackChain.length; + + if (hasNextMethod && !failureInfo.retryable) { + record.currentPaymentMethodIndex = nextMethodIndex; + } + + const delay = RETRY_DELAYS[failureInfo.severity]; + record.nextRetryAt = new Date(Date.now() + delay).toISOString(); + record.status = 'scheduled'; + } + } + + record.updatedAt = new Date().toISOString(); + retryRecords.set(retryId, record); + + return this.ok(record); + } + + // ── Abandon a retry manually ─────────────────────────────────────────────── + + abandonRetry(retryId: string): Result { + const record = retryRecords.get(retryId); + if (!record) return this.notFoundFailure('RetryRecord', retryId); + if (record.status === 'succeeded') { + return this.validationFailure('Cannot abandon a succeeded payment'); + } + + record.status = 'abandoned'; + record.abandonedAt = new Date().toISOString(); + record.nextRetryAt = undefined; + record.updatedAt = new Date().toISOString(); + retryRecords.set(retryId, record); + + return this.ok(record); + } + + // ── Get retry by id ─────────────────────────────────────────────────────── + + getRetry(retryId: string): Result { + const record = retryRecords.get(retryId); + if (!record) return this.notFoundFailure('RetryRecord', retryId); + return this.ok(record); + } + + // ── Get retry by paymentId ──────────────────────────────────────────────── + + getRetryByPayment(paymentId: string): Result { + const id = byPayment.get(paymentId); + if (!id) return this.notFoundFailure('RetryRecord', paymentId); + return this.getRetry(id); + } + + // ── List retries for a user ─────────────────────────────────────────────── + + listUserRetries(userId: string): RetryRecord[] { + const ids = byUser.get(userId) ?? new Set(); + return Array.from(ids) + .map((id) => retryRecords.get(id)!) + .filter(Boolean) + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + } + + // ── List retries due for execution ──────────────────────────────────────── + + getDueRetries(): RetryRecord[] { + const now = new Date().toISOString(); + return Array.from(retryRecords.values()).filter( + (r) => r.status === 'scheduled' && r.nextRetryAt && r.nextRetryAt <= now, + ); + } + + // ── Statistics ──────────────────────────────────────────────────────────── + + getStats(): RetryStats { + const all = Array.from(retryRecords.values()); + const total = all.length; + const byStatus = { + pending: all.filter((r) => r.status === 'pending').length, + scheduled: all.filter((r) => r.status === 'scheduled').length, + in_progress: all.filter((r) => r.status === 'in_progress').length, + succeeded: all.filter((r) => r.status === 'succeeded').length, + failed: all.filter((r) => r.status === 'failed').length, + abandoned: all.filter((r) => r.status === 'abandoned').length, + }; + const recoverable = byStatus.succeeded + byStatus.abandoned; + const recoveryRate = recoverable > 0 ? (byStatus.succeeded / recoverable) * 100 : 0; + + return { + total, + pending: byStatus.pending, + scheduled: byStatus.scheduled + byStatus.in_progress, + succeeded: byStatus.succeeded, + failed: byStatus.failed, + abandoned: byStatus.abandoned, + recoveryRate: parseFloat(recoveryRate.toFixed(2)), + }; + } + + // ── Check accounts due for suspension ──────────────────────────────────── + + getAccountsDueForSuspension(): Array<{ userId: string; retryIds: string[] }> { + const now = new Date().toISOString(); + const suspensionMap = new Map(); + + for (const record of retryRecords.values()) { + if ( + record.status === 'scheduled' && + record.suspendAccountAt && + record.suspendAccountAt <= now + ) { + const arr = suspensionMap.get(record.userId) ?? []; + arr.push(record.id); + suspensionMap.set(record.userId, arr); + } + } + + return Array.from(suspensionMap.entries()).map(([userId, retryIds]) => ({ + userId, + retryIds, + })); + } +} + +export const paymentRetryService = new PaymentRetryService(); diff --git a/backend/src/services/security.ts b/backend/src/services/security.ts new file mode 100644 index 00000000..5481dc41 --- /dev/null +++ b/backend/src/services/security.ts @@ -0,0 +1,369 @@ +/** + * security.ts — Issue #594 + * + * Security scanning service for automated SAST/DAST/dependency vulnerability + * tracking, severity classification, SLA management, and security scoring. + */ + +import { randomUUID } from 'node:crypto'; +import { BaseService } from './BaseService.js'; +import type { Result } from '../lib/result.js'; + +// ── Types ───────────────────────────────────────────────────────────────────── + +export type VulnerabilitySeverity = 'critical' | 'high' | 'medium' | 'low' | 'info'; +export type VulnerabilityStatus = 'open' | 'in_progress' | 'resolved' | 'accepted' | 'false_positive'; +export type ScanType = 'sast' | 'dast' | 'dependency' | 'smart_contract'; + +export interface Vulnerability { + id: string; + title: string; + description: string; + severity: VulnerabilitySeverity; + status: VulnerabilityStatus; + scanType: ScanType; + /** File path or URL where the vulnerability was found */ + location: string; + lineNumber?: number; + cveId?: string; + cvssScore?: number; + packageName?: string; + packageVersion?: string; + fixedInVersion?: string; + remediation: string; + assignedTo?: string; + slaDueAt: string; + resolvedAt?: string; + detectedAt: string; + updatedAt: string; +} + +export interface ScanReport { + id: string; + scanType: ScanType; + triggeredBy: string; // 'ci' | 'manual' | 'scheduled' + startedAt: string; + completedAt?: string; + status: 'running' | 'completed' | 'failed'; + vulnerabilitiesFound: number; + critical: number; + high: number; + medium: number; + low: number; + info: number; + vulnerabilityIds: string[]; + error?: string; +} + +export interface SecurityScore { + overall: number; // 0-100 + sast: number; + dast: number; + dependency: number; + smartContract: number; + trend: 'improving' | 'stable' | 'degrading'; + lastCalculatedAt: string; +} + +export interface RemediationWorkflow { + vulnerabilityId: string; + assignedTo: string; + priority: VulnerabilitySeverity; + notes: string; + assignedAt: string; +} + +// ── SLA by severity (days to resolve) ──────────────────────────────────────── + +const SLA_DAYS: Record = { + critical: 1, + high: 7, + medium: 30, + low: 90, + info: 180, +}; + +// ── In-memory stores ────────────────────────────────────────────────────────── + +const vulnerabilities = new Map(); +const scanReports = new Map(); +const scoreHistory: SecurityScore[] = []; + +// ── Service ─────────────────────────────────────────────────────────────────── + +export class SecurityService extends BaseService { + + // ── Start a scan ────────────────────────────────────────────────────────── + + startScan(scanType: ScanType, triggeredBy = 'manual'): Result { + const id = randomUUID(); + const report: ScanReport = { + id, + scanType, + triggeredBy, + startedAt: new Date().toISOString(), + status: 'running', + vulnerabilitiesFound: 0, + critical: 0, + high: 0, + medium: 0, + low: 0, + info: 0, + vulnerabilityIds: [], + }; + scanReports.set(id, report); + return this.ok(report); + } + + // ── Complete a scan and record findings ─────────────────────────────────── + + completeScan( + scanId: string, + findings: Array<{ + title: string; + description: string; + severity: VulnerabilitySeverity; + location: string; + lineNumber?: number; + cveId?: string; + cvssScore?: number; + packageName?: string; + packageVersion?: string; + fixedInVersion?: string; + remediation: string; + }>, + ): Result { + const report = scanReports.get(scanId); + if (!report) return this.notFoundFailure('ScanReport', scanId); + if (report.status !== 'running') { + return this.validationFailure('Scan is not in running state'); + } + + const now = new Date().toISOString(); + const counts: Record = { + critical: 0, high: 0, medium: 0, low: 0, info: 0, + }; + + for (const finding of findings) { + const dueDate = new Date( + Date.now() + SLA_DAYS[finding.severity] * 24 * 60 * 60 * 1_000, + ).toISOString(); + + const vuln: Vulnerability = { + id: randomUUID(), + ...finding, + status: 'open', + scanType: report.scanType, + slaDueAt: dueDate, + detectedAt: now, + updatedAt: now, + }; + + vulnerabilities.set(vuln.id, vuln); + report.vulnerabilityIds.push(vuln.id); + counts[finding.severity]++; + } + + report.vulnerabilitiesFound = findings.length; + report.critical = counts.critical; + report.high = counts.high; + report.medium = counts.medium; + report.low = counts.low; + report.info = counts.info; + report.completedAt = now; + report.status = 'completed'; + scanReports.set(scanId, report); + + // Recalculate score after new findings + this.calculateScore(); + + return this.ok(report); + } + + // ── Fail a scan ─────────────────────────────────────────────────────────── + + failScan(scanId: string, error: string): Result { + const report = scanReports.get(scanId); + if (!report) return this.notFoundFailure('ScanReport', scanId); + report.status = 'failed'; + report.error = error; + report.completedAt = new Date().toISOString(); + scanReports.set(scanId, report); + return this.ok(report); + } + + // ── List vulnerabilities ────────────────────────────────────────────────── + + listVulnerabilities(filters?: { + severity?: VulnerabilitySeverity; + status?: VulnerabilityStatus; + scanType?: ScanType; + overdue?: boolean; + }): Vulnerability[] { + let all = Array.from(vulnerabilities.values()); + const now = new Date().toISOString(); + + if (filters?.severity) all = all.filter((v) => v.severity === filters.severity); + if (filters?.status) all = all.filter((v) => v.status === filters.status); + if (filters?.scanType) all = all.filter((v) => v.scanType === filters.scanType); + if (filters?.overdue) { + all = all.filter( + (v) => v.status === 'open' || v.status === 'in_progress' ? v.slaDueAt < now : false, + ); + } + + return all.sort((a, b) => { + const severityOrder = { critical: 0, high: 1, medium: 2, low: 3, info: 4 }; + return severityOrder[a.severity] - severityOrder[b.severity]; + }); + } + + // ── Update vulnerability status ──────────────────────────────────────────── + + updateVulnerabilityStatus( + vulnId: string, + status: VulnerabilityStatus, + _notes?: string, + ): Result { + const vuln = vulnerabilities.get(vulnId); + if (!vuln) return this.notFoundFailure('Vulnerability', vulnId); + + vuln.status = status; + vuln.updatedAt = new Date().toISOString(); + if (status === 'resolved') { + vuln.resolvedAt = vuln.updatedAt; + } + vulnerabilities.set(vulnId, vuln); + this.calculateScore(); + return this.ok(vuln); + } + + // ── Assign vulnerability for remediation ────────────────────────────────── + + assignRemediation( + vulnId: string, + assignedTo: string, + notes = '', + ): Result { + const vuln = vulnerabilities.get(vulnId); + if (!vuln) return this.notFoundFailure('Vulnerability', vulnId); + + vuln.assignedTo = assignedTo; + vuln.status = 'in_progress'; + vuln.updatedAt = new Date().toISOString(); + vulnerabilities.set(vulnId, vuln); + + const workflow: RemediationWorkflow = { + vulnerabilityId: vulnId, + assignedTo, + priority: vuln.severity, + notes, + assignedAt: vuln.updatedAt, + }; + + return this.ok(workflow); + } + + // ── Get a single scan report ─────────────────────────────────────────────── + + getScanReport(scanId: string): Result { + const report = scanReports.get(scanId); + if (!report) return this.notFoundFailure('ScanReport', scanId); + return this.ok(report); + } + + // ── List scan reports ───────────────────────────────────────────────────── + + listScanReports(limit = 20): ScanReport[] { + return Array.from(scanReports.values()) + .sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime()) + .slice(0, limit); + } + + // ── Calculate security score ────────────────────────────────────────────── + + calculateScore(): SecurityScore { + const open = Array.from(vulnerabilities.values()).filter( + (v) => v.status === 'open' || v.status === 'in_progress', + ); + + function scoreForType(type: ScanType): number { + const typeVulns = open.filter((v) => v.scanType === type); + if (typeVulns.length === 0) return 100; + const deductions = typeVulns.reduce((sum, v) => { + const d: Record = { + critical: 25, high: 15, medium: 8, low: 3, info: 1, + }; + return sum + (d[v.severity] ?? 0); + }, 0); + return Math.max(0, 100 - deductions); + } + + const sast = scoreForType('sast'); + const dast = scoreForType('dast'); + const dep = scoreForType('dependency'); + const sc = scoreForType('smart_contract'); + const overall = Math.round((sast + dast + dep + sc) / 4); + + let trend: SecurityScore['trend'] = 'stable'; + if (scoreHistory.length > 0) { + const last = scoreHistory[scoreHistory.length - 1].overall; + if (overall > last + 2) trend = 'improving'; + else if (overall < last - 2) trend = 'degrading'; + } + + const score: SecurityScore = { + overall, + sast, + dast, + dependency: dep, + smartContract: sc, + trend, + lastCalculatedAt: new Date().toISOString(), + }; + + scoreHistory.push(score); + if (scoreHistory.length > 90) scoreHistory.shift(); // keep last 90 entries + + return score; + } + + // ── Current score ────────────────────────────────────────────────────────── + + getCurrentScore(): SecurityScore { + if (scoreHistory.length === 0) return this.calculateScore(); + return scoreHistory[scoreHistory.length - 1]; + } + + // ── Score history ───────────────────────────────────────────────────────── + + getScoreHistory(days = 30): SecurityScore[] { + return scoreHistory.slice(-days); + } + + // ── Overdue SLA summary ─────────────────────────────────────────────────── + + getOverdueSummary(): { total: number; critical: number; high: number; medium: number } { + const now = new Date().toISOString(); + const overdue = Array.from(vulnerabilities.values()).filter( + (v) => + (v.status === 'open' || v.status === 'in_progress') && v.slaDueAt < now, + ); + return { + total: overdue.length, + critical: overdue.filter((v) => v.severity === 'critical').length, + high: overdue.filter((v) => v.severity === 'high').length, + medium: overdue.filter((v) => v.severity === 'medium').length, + }; + } + + // ── Get a vulnerability by id ───────────────────────────────────────────── + + getVulnerability(vulnId: string): Result { + const vuln = vulnerabilities.get(vulnId); + if (!vuln) return this.notFoundFailure('Vulnerability', vulnId); + return this.ok(vuln); + } +} + +export const securityService = new SecurityService(); diff --git a/frontend/.gitignore b/frontend/.gitignore index 11c08d3e..6186dbd7 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -12,6 +12,13 @@ # testing /coverage +/__snapshots__/ +*.snap +/test-results/ +/playwright-report/ +/blob-report/ +/.playwright/ +/playwright/.cache/ # next.js /.next/ @@ -40,9 +47,5 @@ yarn-error.log* *.tsbuildinfo next-env.d.ts -# playwright -/playwright-report -/blob-report -/test-results -/.playwright -/playwright/.cache +# storybook +storybook-static/ diff --git a/frontend/app/dashboard/billing/page.tsx b/frontend/app/dashboard/billing/page.tsx new file mode 100644 index 00000000..09eb7d7c --- /dev/null +++ b/frontend/app/dashboard/billing/page.tsx @@ -0,0 +1,347 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { Card } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; + +const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001/api/v1"; + +// ── Types ───────────────────────────────────────────────────────────────────── + +interface PaymentAttempt { + id: string; + attemptNumber: number; + scheduledAt: string; + executedAt?: string; + status: "pending" | "succeeded" | "failed"; + failureCategory?: string; + failureMessage?: string; + paymentMethodId: string; +} + +interface RetryRecord { + id: string; + paymentId: string; + userId: string; + originalAmount: number; + currency: string; + status: "pending" | "scheduled" | "in_progress" | "succeeded" | "failed" | "abandoned"; + attempts: PaymentAttempt[]; + currentAttemptNumber: number; + maxAttempts: number; + nextRetryAt?: string; + dunningStep: number; + paymentMethodFallbackChain: string[]; + createdAt: string; + updatedAt: string; +} + +interface RetryStats { + total: number; + pending: number; + scheduled: number; + succeeded: number; + failed: number; + abandoned: number; + recoveryRate: number; +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function statusColor(status: RetryRecord["status"]): string { + const map: Record = { + pending: "bg-gray-100 text-gray-700", + scheduled: "bg-blue-100 text-blue-700", + in_progress: "bg-yellow-100 text-yellow-700", + succeeded: "bg-green-100 text-green-700", + failed: "bg-red-100 text-red-700", + abandoned: "bg-gray-200 text-gray-500", + }; + return map[status] ?? "bg-gray-100 text-gray-700"; +} + +function formatCurrency(amount: number, currency: string): string { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: currency.toUpperCase(), + minimumFractionDigits: 2, + }).format(amount); +} + +function formatDate(iso: string): string { + return new Date(iso).toLocaleString(); +} + +// ── Page ────────────────────────────────────────────────────────────────────── + +export default function BillingPage() { + const [retries, setRetries] = useState([]); + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const [selectedId, setSelectedId] = useState(null); + const [actionLoading, setActionLoading] = useState(null); + const [error, setError] = useState(null); + + // Demo userId — in production this comes from the auth context + const demoUserId = "demo-user-001"; + + const fetchData = useCallback(async () => { + setLoading(true); + setError(null); + try { + const [retriesRes, statsRes] = await Promise.all([ + fetch(`${API_BASE}/payments/retry/user/${demoUserId}`), + fetch(`${API_BASE}/payments/retry/stats`), + ]); + + if (retriesRes.ok) { + const data = await retriesRes.json(); + setRetries(data.data ?? []); + } + + if (statsRes.ok) { + const data = await statsRes.json(); + setStats(data.data); + } + } catch { + setError("Failed to load billing data. Please try again."); + } finally { + setLoading(false); + } + }, [demoUserId]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + const handleRetryNow = async (retryId: string) => { + setActionLoading(retryId); + try { + const res = await fetch(`${API_BASE}/payments/retry/${retryId}/execute`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + if (!res.ok) throw new Error("Failed to execute retry"); + await fetchData(); + } catch (e) { + setError(e instanceof Error ? e.message : "Action failed"); + } finally { + setActionLoading(null); + } + }; + + const handleAbandon = async (retryId: string) => { + if (!confirm("Are you sure you want to abandon this payment retry?")) return; + setActionLoading(retryId); + try { + const res = await fetch(`${API_BASE}/payments/retry/${retryId}/abandon`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + if (!res.ok) throw new Error("Failed to abandon retry"); + await fetchData(); + } catch (e) { + setError(e instanceof Error ? e.message : "Action failed"); + } finally { + setActionLoading(null); + } + }; + + const selected = retries.find((r) => r.id === selectedId); + + return ( +
+ {/* Header */} +
+
+

Billing & Payments

+

+ Manage failed payments and retry schedules +

+
+ +
+ + {/* Error banner */} + {error && ( +
+ {error} +
+ )} + + {/* Stats cards */} + {stats && ( +
+ {[ + { label: "Total", value: stats.total, color: "text-gray-900" }, + { label: "Scheduled", value: stats.scheduled, color: "text-blue-600" }, + { label: "Succeeded", value: stats.succeeded, color: "text-green-600" }, + { label: "Abandoned", value: stats.abandoned, color: "text-gray-500" }, + { label: "Failed", value: stats.failed, color: "text-red-600" }, + { label: "Pending", value: stats.pending, color: "text-yellow-600" }, + { + label: "Recovery Rate", + value: `${stats.recoveryRate}%`, + color: stats.recoveryRate >= 60 ? "text-green-600" : "text-red-500", + }, + ].map(({ label, value, color }) => ( + +
{value}
+
{label}
+
+ ))} +
+ )} + +
+ {/* Retry list */} +
+

+ Payment Retries +

+ {loading ? ( +

Loading…

+ ) : retries.length === 0 ? ( + + No payment retries found. + + ) : ( + retries.map((retry) => ( + setSelectedId(retry.id === selectedId ? null : retry.id)} + > +
+
+
+ + {retry.paymentId} + + + {retry.status.replace("_", " ")} + +
+
+ {formatCurrency(retry.originalAmount, retry.currency)} +
+
+ Attempt {retry.currentAttemptNumber}/{retry.maxAttempts} + {retry.nextRetryAt && ( + <> · Next retry: {formatDate(retry.nextRetryAt)} + )} +
+
+
+ {["scheduled", "pending"].includes(retry.status) && ( + + )} + {!["succeeded", "abandoned"].includes(retry.status) && ( + + )} +
+
+
+ )) + )} +
+ + {/* Detail panel */} +
+

+ Attempt History +

+ {!selected ? ( + + Select a retry to see its attempt history. + + ) : ( + +
+ Payment ID:{" "} + {selected.paymentId} +
+
+ Fallback methods:{" "} + {selected.paymentMethodFallbackChain.join(", ")} +
+
+ Dunning step:{" "} + {selected.dunningStep} +
+
+ {selected.attempts.length === 0 ? ( +

No attempts yet.

+ ) : ( +
+ {selected.attempts.map((attempt) => ( +
+
+ Attempt #{attempt.attemptNumber} + + {attempt.status} + +
+
+ Method: {attempt.paymentMethodId} +
+ {attempt.failureCategory && ( +
+ {attempt.failureCategory}: {attempt.failureMessage} +
+ )} +
+ {attempt.executedAt ? formatDate(attempt.executedAt) : "Pending"} +
+
+ ))} +
+ )} +
+ )} +
+
+
+ ); +} diff --git a/frontend/app/dashboard/security/page.tsx b/frontend/app/dashboard/security/page.tsx new file mode 100644 index 00000000..4f80c75a --- /dev/null +++ b/frontend/app/dashboard/security/page.tsx @@ -0,0 +1,401 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { Card } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; + +const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001/api/v1"; + +// ── Types ───────────────────────────────────────────────────────────────────── + +type VulnerabilitySeverity = "critical" | "high" | "medium" | "low" | "info"; +type VulnerabilityStatus = "open" | "in_progress" | "resolved" | "accepted" | "false_positive"; +type ScanType = "sast" | "dast" | "dependency" | "smart_contract"; + +interface Vulnerability { + id: string; + title: string; + description: string; + severity: VulnerabilitySeverity; + status: VulnerabilityStatus; + scanType: ScanType; + location: string; + lineNumber?: number; + cveId?: string; + cvssScore?: number; + remediation: string; + assignedTo?: string; + slaDueAt: string; + resolvedAt?: string; + detectedAt: string; +} + +interface SecurityScore { + overall: number; + sast: number; + dast: number; + dependency: number; + smartContract: number; + trend: "improving" | "stable" | "degrading"; + lastCalculatedAt: string; +} + +interface ScanReport { + id: string; + scanType: ScanType; + triggeredBy: string; + startedAt: string; + completedAt?: string; + status: "running" | "completed" | "failed"; + vulnerabilitiesFound: number; + critical: number; + high: number; + medium: number; + low: number; + info: number; +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function severityColor(s: VulnerabilitySeverity): string { + const map: Record = { + critical: "bg-red-100 text-red-800", + high: "bg-orange-100 text-orange-800", + medium: "bg-yellow-100 text-yellow-700", + low: "bg-blue-100 text-blue-700", + info: "bg-gray-100 text-gray-600", + }; + return map[s]; +} + +function statusColor(s: VulnerabilityStatus): string { + const map: Record = { + open: "bg-red-50 text-red-700", + in_progress: "bg-yellow-50 text-yellow-700", + resolved: "bg-green-50 text-green-700", + accepted: "bg-gray-50 text-gray-600", + false_positive: "bg-gray-50 text-gray-500", + }; + return map[s]; +} + +function scoreColor(score: number): string { + if (score >= 80) return "text-green-600"; + if (score >= 60) return "text-yellow-600"; + return "text-red-600"; +} + +function trendIcon(trend: SecurityScore["trend"]): string { + if (trend === "improving") return "↗"; + if (trend === "degrading") return "↘"; + return "→"; +} + +function formatDate(iso: string): string { + return new Date(iso).toLocaleDateString(); +} + +// ── Page ────────────────────────────────────────────────────────────────────── + +export default function SecurityPage() { + const [score, setScore] = useState(null); + const [vulns, setVulns] = useState([]); + const [scans, setScans] = useState([]); + const [loading, setLoading] = useState(true); + const [activeTab, setActiveTab] = useState<"overview" | "vulnerabilities" | "scans">("overview"); + const [filterSeverity, setFilterSeverity] = useState("all"); + const [filterStatus, setFilterStatus] = useState("all"); + const [error, setError] = useState(null); + + const fetchData = useCallback(async () => { + setLoading(true); + setError(null); + try { + const [scoreRes, vulnsRes, scansRes] = await Promise.all([ + fetch(`${API_BASE}/security/score`), + fetch(`${API_BASE}/security/vulnerabilities`), + fetch(`${API_BASE}/security/scans`), + ]); + + if (scoreRes.ok) setScore((await scoreRes.json()).data); + if (vulnsRes.ok) setVulns((await vulnsRes.json()).data ?? []); + if (scansRes.ok) setScans((await scansRes.json()).data ?? []); + } catch { + setError("Failed to load security data."); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + const filteredVulns = vulns.filter((v) => { + if (filterSeverity !== "all" && v.severity !== filterSeverity) return false; + if (filterStatus !== "all" && v.status !== filterStatus) return false; + return true; + }); + + const openCritical = vulns.filter((v) => v.severity === "critical" && v.status === "open").length; + const openHigh = vulns.filter((v) => v.severity === "high" && v.status === "open").length; + + return ( +
+ {/* Header */} +
+
+

Security Dashboard

+

+ Automated vulnerability scanning and remediation tracking +

+
+ +
+ + {error && ( +
+ {error} +
+ )} + + {/* Score cards */} + {score && ( +
+ {[ + { label: "Overall", value: score.overall }, + { label: "SAST", value: score.sast }, + { label: "DAST", value: score.dast }, + { label: "Dependencies", value: score.dependency }, + { label: "Smart Contract", value: score.smartContract }, + ].map(({ label, value }) => ( + +
{value}
+
{label}
+
+ ))} + +
+ {trendIcon(score.trend)} +
+
{score.trend}
+
+
+ )} + + {/* Alert bar */} + {(openCritical > 0 || openHigh > 0) && ( +
+ + + {openCritical > 0 && <>{openCritical} critical} + {openCritical > 0 && openHigh > 0 && " and "} + {openHigh > 0 && <>{openHigh} high} + {" "}vulnerabilit{(openCritical + openHigh) === 1 ? "y" : "ies"} require immediate attention. + +
+ )} + + {/* Tabs */} +
+ {(["overview", "vulnerabilities", "scans"] as const).map((tab) => ( + + ))} +
+ + {/* Overview */} + {activeTab === "overview" && ( +
+ +

+ Open Vulnerabilities by Severity +

+ {(["critical", "high", "medium", "low", "info"] as VulnerabilitySeverity[]).map( + (sev) => { + const count = vulns.filter((v) => v.severity === sev && v.status === "open").length; + return ( +
+
+ + {sev} + +
+ {count} +
+ ); + } + )} +
+ + +

+ Scan Coverage +

+ {(["sast", "dast", "dependency", "smart_contract"] as ScanType[]).map((type) => { + const latestScan = scans + .filter((s) => s.scanType === type && s.status === "completed") + .sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime())[0]; + return ( +
+ {type.replace("_", " ")} + {latestScan ? ( + {formatDate(latestScan.startedAt)} + ) : ( + Not yet scanned + )} +
+ ); + })} +
+
+ )} + + {/* Vulnerabilities */} + {activeTab === "vulnerabilities" && ( +
+ {/* Filters */} +
+
+ Severity: + {(["all", "critical", "high", "medium", "low", "info"] as const).map((s) => ( + + ))} +
+
+ Status: + {(["all", "open", "in_progress", "resolved"] as const).map((s) => ( + + ))} +
+
+ + {filteredVulns.length === 0 ? ( + + No vulnerabilities match the selected filters. + + ) : ( + filteredVulns.map((v) => ( + +
+
+
+ {v.title} + + {v.severity} + + + {v.status.replace("_", " ")} + +
+

{v.description}

+
+ 📍 {v.location}{v.lineNumber ? `:${v.lineNumber}` : ""} + {v.cveId && 🔗 {v.cveId}} + {v.cvssScore && Score: {v.cvssScore}} + SLA due: {formatDate(v.slaDueAt)} + {v.assignedTo && 👤 {v.assignedTo}} +
+

+ 💡 {v.remediation} +

+
+ + {v.scanType.replace("_", " ")} + +
+
+ )) + )} +
+ )} + + {/* Scans */} + {activeTab === "scans" && ( +
+ {scans.length === 0 ? ( + No scan reports found. + ) : ( + scans.map((scan) => ( + +
+
+
+ + {scan.scanType.replace("_", " ")} + + + {scan.status} + +
+
+ {formatDate(scan.startedAt)} · triggered by {scan.triggeredBy} +
+
+
+
+ {scan.vulnerabilitiesFound} findings +
+
+ {scan.critical > 0 && C:{scan.critical}} + {scan.high > 0 && H:{scan.high}} + {scan.medium > 0 && M:{scan.medium}} + {scan.low > 0 && L:{scan.low}} +
+
+
+
+ )) + )} +
+ )} +
+ ); +} diff --git a/frontend/app/dashboard/settings/export/page.tsx b/frontend/app/dashboard/settings/export/page.tsx new file mode 100644 index 00000000..d0690f5c --- /dev/null +++ b/frontend/app/dashboard/settings/export/page.tsx @@ -0,0 +1,542 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { Card } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; + +const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001/api/v1"; + +// ── Types ───────────────────────────────────────────────────────────────────── + +type ExportFormat = "json" | "csv" | "pdf"; +type ExportStatus = "queued" | "processing" | "completed" | "failed" | "expired"; +type ExportScope = + | "full_account" + | "payments" + | "invoices" + | "subscriptions" + | "projects" + | "analytics"; +type ScheduleFrequency = "daily" | "weekly" | "monthly"; + +interface ExportJob { + id: string; + userId: string; + format: ExportFormat; + scope: ExportScope[]; + status: ExportStatus; + anonymise: boolean; + isGdprRequest: boolean; + downloadUrl?: string; + fileSizeBytes?: number; + rowCount?: number; + requestedAt: string; + completedAt?: string; + expiresAt?: string; +} + +interface ScheduledExport { + id: string; + format: ExportFormat; + scope: ExportScope[]; + frequency: ScheduleFrequency; + anonymise: boolean; + deliveryEmail: string; + enabled: boolean; + nextRunAt: string; + createdAt: string; +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function statusColor(s: ExportStatus): string { + const map: Record = { + queued: "bg-gray-100 text-gray-700", + processing: "bg-yellow-100 text-yellow-700", + completed: "bg-green-100 text-green-700", + failed: "bg-red-100 text-red-700", + expired: "bg-gray-200 text-gray-400", + }; + return map[s]; +} + +function formatDate(iso: string): string { + return new Date(iso).toLocaleString(); +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / 1024 / 1024).toFixed(1)} MB`; +} + +const SCOPES: ExportScope[] = [ + "full_account", + "payments", + "invoices", + "subscriptions", + "projects", + "analytics", +]; + +// ── Page ────────────────────────────────────────────────────────────────────── + +export default function ExportPage() { + const [exports, setExports] = useState([]); + const [schedules, setSchedules] = useState([]); + const [loading, setLoading] = useState(true); + const [activeTab, setActiveTab] = useState<"exports" | "schedules">("exports"); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + // New export form + const [newFormat, setNewFormat] = useState("json"); + const [newScope, setNewScope] = useState(["full_account"]); + const [newAnonymise, setNewAnonymise] = useState(false); + const [newGdpr, setNewGdpr] = useState(false); + const [newEmail, setNewEmail] = useState(""); + const [submitting, setSubmitting] = useState(false); + + // New schedule form + const [schedFormat, setSchedFormat] = useState("csv"); + const [schedScope, setSchedScope] = useState(["payments"]); + const [schedFreq, setSchedFreq] = useState("weekly"); + const [schedEmail, setSchedEmail] = useState(""); + const [schedAnonymise, setSchedAnonymise] = useState(false); + const [schedSubmitting, setSchedSubmitting] = useState(false); + + const demoUserId = "demo-user-001"; + + const fetchData = useCallback(async () => { + setLoading(true); + setError(null); + try { + const [exportsRes, schedulesRes] = await Promise.all([ + fetch(`${API_BASE}/data-export/user/${demoUserId}`), + fetch(`${API_BASE}/data-export/schedules/user/${demoUserId}`), + ]); + + if (exportsRes.ok) setExports((await exportsRes.json()).data ?? []); + if (schedulesRes.ok) setSchedules((await schedulesRes.json()).data ?? []); + } catch { + setError("Failed to load export data."); + } finally { + setLoading(false); + } + }, [demoUserId]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + const handleCreateExport = async () => { + if (newScope.length === 0) { + setError("Select at least one data scope."); + return; + } + setSubmitting(true); + setError(null); + try { + const res = await fetch(`${API_BASE}/data-export`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + userId: demoUserId, + format: newFormat, + scope: newScope, + anonymise: newAnonymise, + isGdprRequest: newGdpr, + deliveryEmail: newEmail || undefined, + }), + }); + const data = await res.json(); + if (!data.success) throw new Error(data.error); + setSuccess(`Export queued (ID: ${data.data.id})`); + await fetchData(); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to create export"); + } finally { + setSubmitting(false); + } + }; + + const handleCreateSchedule = async () => { + if (!schedEmail) { + setError("Delivery email is required for schedules."); + return; + } + setSchedSubmitting(true); + setError(null); + try { + const res = await fetch(`${API_BASE}/data-export/schedules`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + userId: demoUserId, + format: schedFormat, + scope: schedScope, + frequency: schedFreq, + anonymise: schedAnonymise, + deliveryEmail: schedEmail, + }), + }); + const data = await res.json(); + if (!data.success) throw new Error(data.error); + setSuccess("Scheduled export created!"); + setSchedEmail(""); + await fetchData(); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to create schedule"); + } finally { + setSchedSubmitting(false); + } + }; + + const handleToggleScope = ( + scope: ExportScope, + current: ExportScope[], + setter: (v: ExportScope[]) => void, + ) => { + setter( + current.includes(scope) ? current.filter((s) => s !== scope) : [...current, scope], + ); + }; + + const handleDeleteExport = async (id: string) => { + if (!confirm("Delete this export?")) return; + try { + await fetch(`${API_BASE}/data-export/${id}`, { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ userId: demoUserId }), + }); + await fetchData(); + } catch { + setError("Failed to delete export."); + } + }; + + return ( +
+ {/* Header */} +
+

Data Export

+

+ Export your data in JSON, CSV, or PDF format. GDPR-compliant portability included. +

+
+ + {/* Alerts */} + {error && ( +
+ {error} + +
+ )} + {success && ( +
+ {success} + +
+ )} + + {/* Tabs */} +
+ {(["exports", "schedules"] as const).map((tab) => ( + + ))} +
+ + {/* Exports tab */} + {activeTab === "exports" && ( +
+ {/* Create form */} + +

New Export

+ +
+ +
+ {(["json", "csv", "pdf"] as const).map((f) => ( + + ))} +
+
+ +
+ +
+ {SCOPES.map((s) => ( + + ))} +
+
+ +
+ + +
+ +
+ + setNewEmail(e.target.value)} + className="text-sm" + /> +
+ + +
+ + {/* Export history */} +
+

Export History

+ {loading ? ( +

Loading…

+ ) : exports.length === 0 ? ( + No exports yet. + ) : ( + exports.map((exp) => ( + +
+
+
+ + {exp.status} + + + {exp.format} + + {exp.isGdprRequest && ( + + GDPR + + )} +
+
+ {exp.scope.map((s) => s.replace("_", " ")).join(", ")} +
+ {exp.rowCount != null && ( +
+ {exp.rowCount} rows · {formatBytes(exp.fileSizeBytes ?? 0)} +
+ )} +
+ {formatDate(exp.requestedAt)} +
+
+
+ {exp.status === "completed" && exp.downloadUrl && ( + + Download + + )} + +
+
+
+ )) + )} +
+
+ )} + + {/* Schedules tab */} + {activeTab === "schedules" && ( +
+ {/* Create schedule form */} + +

New Schedule

+ +
+ +
+ {(["json", "csv", "pdf"] as const).map((f) => ( + + ))} +
+
+ +
+ +
+ {(["daily", "weekly", "monthly"] as const).map((f) => ( + + ))} +
+
+ +
+ +
+ {SCOPES.map((s) => ( + + ))} +
+
+ +
+ + setSchedEmail(e.target.value)} + className="text-sm" + /> +
+ + + + +
+ + {/* Schedule list */} +
+

Active Schedules

+ {schedules.length === 0 ? ( + No schedules configured. + ) : ( + schedules.map((sched) => ( + +
+
+
+ + {sched.frequency} + + + {sched.format} + + {sched.enabled ? ( + Active + ) : ( + Paused + )} +
+
+ {sched.scope.map((s) => s.replace("_", " ")).join(", ")} +
+
+ 📧 {sched.deliveryEmail} +
+
+ Next: {formatDate(sched.nextRunAt)} +
+
+
+
+ )) + )} +
+
+ )} +
+ ); +} diff --git a/frontend/components/collaboration/ActivityFeed.tsx b/frontend/components/collaboration/ActivityFeed.tsx new file mode 100644 index 00000000..105177c0 --- /dev/null +++ b/frontend/components/collaboration/ActivityFeed.tsx @@ -0,0 +1,356 @@ +"use client"; + +import { useState, useEffect, useCallback, useRef } from "react"; +import { Button } from "@/components/ui/button"; + +const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001/api/v1"; + +// ── Types ───────────────────────────────────────────────────────────────────── + +type ActivityType = + | "comment_added" + | "comment_edited" + | "comment_deleted" + | "reaction_added" + | "milestone_updated" + | "member_joined" + | "file_annotated"; + +interface ActivityEvent { + id: string; + projectId: string; + type: ActivityType; + actorId: string; + actorName: string; + targetId?: string; + targetType?: string; + payload: Record; + createdAt: string; +} + +interface MentionNotification { + id: string; + commentId: string; + projectId: string; + mentionedUserId: string; + mentionedBy: string; + commentSnippet: string; + read: boolean; + createdAt: string; +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function activityIcon(type: ActivityType): string { + const icons: Record = { + comment_added: "💬", + comment_edited: "✏️", + comment_deleted: "🗑️", + reaction_added: "😄", + milestone_updated: "🎯", + member_joined: "👋", + file_annotated: "📎", + }; + return icons[type] ?? "📌"; +} + +function activityLabel(event: ActivityEvent): string { + const actor = event.actorName ?? "Someone"; + switch (event.type) { + case "comment_added": + return `${actor} added a comment`; + case "comment_edited": + return `${actor} edited a comment`; + case "comment_deleted": + return `${actor} deleted a comment`; + case "reaction_added": + return `${actor} reacted ${event.payload.emoji ?? ""}`; + case "milestone_updated": + return `${actor} updated a milestone`; + case "member_joined": + return `${actor} joined the project`; + case "file_annotated": + return `${actor} annotated a file`; + default: + return `${actor} performed an action`; + } +} + +function formatRelativeTime(iso: string): string { + const diff = Date.now() - new Date(iso).getTime(); + if (diff < 60_000) return "just now"; + if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`; + if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`; + if (diff < 7 * 86_400_000) return `${Math.floor(diff / 86_400_000)}d ago`; + return new Date(iso).toLocaleDateString(); +} + +function initials(name: string): string { + return name + .split(" ") + .map((w) => w[0]) + .join("") + .toUpperCase() + .slice(0, 2); +} + +const AVATAR_COLORS = [ + "bg-blue-500", + "bg-emerald-500", + "bg-violet-500", + "bg-amber-500", + "bg-rose-500", + "bg-cyan-500", +]; + +function avatarColor(name: string): string { + let hash = 0; + for (let i = 0; i < name.length; i++) { + hash = (hash * 31 + name.charCodeAt(i)) & 0xffffffff; + } + return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length]; +} + +// ── ActivityItem ────────────────────────────────────────────────────────────── + +function ActivityItem({ event }: { event: ActivityEvent }) { + const snippet = event.payload.snippet as string | undefined; + + return ( +
+ {/* Avatar */} + + +
+
+
+ {activityIcon(event.type)} + + {activityLabel(event)} + +
+ {formatRelativeTime(event.createdAt)} +
+ + {snippet && ( +

+ "{snippet}" +

+ )} + + {event.targetType && event.targetId && ( +
+ {event.targetType} · {event.targetId.slice(0, 8)}… +
+ )} +
+
+ ); +} + +// ── ActivityFeed ────────────────────────────────────────────────────────────── + +interface ActivityFeedProps { + projectId: string; + currentUserId: string; + /** Auto-refresh interval in ms. Set to 0 to disable. */ + refreshInterval?: number; + /** Show notifications panel */ + showNotifications?: boolean; + limit?: number; + className?: string; +} + +export function ActivityFeed({ + projectId, + currentUserId, + refreshInterval = 30_000, + showNotifications = true, + limit = 50, + className = "", +}: ActivityFeedProps) { + const [events, setEvents] = useState([]); + const [notifications, setNotifications] = useState([]); + const [loading, setLoading] = useState(false); + const [activeTab, setActiveTab] = useState<"activity" | "mentions">("activity"); + const [unreadCount, setUnreadCount] = useState(0); + const [error, setError] = useState(null); + const timerRef = useRef | null>(null); + + const fetchFeed = useCallback(async () => { + setError(null); + try { + const [feedRes, notifsRes] = await Promise.all([ + fetch(`${API_BASE}/comments/project/${projectId}/activity?limit=${limit}`), + showNotifications + ? fetch(`${API_BASE}/comments/notifications/${currentUserId}`) + : Promise.resolve(null), + ]); + + if (feedRes.ok) { + const data = await feedRes.json(); + setEvents(data.data ?? []); + } + + if (notifsRes?.ok) { + const data = await notifsRes.json(); + const notifs: MentionNotification[] = data.data ?? []; + setNotifications(notifs); + setUnreadCount(notifs.filter((n) => !n.read).length); + } + } catch { + setError("Could not load activity feed."); + } finally { + setLoading(false); + } + }, [projectId, currentUserId, limit, showNotifications]); + + useEffect(() => { + setLoading(true); + fetchFeed(); + }, [fetchFeed]); + + useEffect(() => { + if (refreshInterval <= 0) return; + timerRef.current = setInterval(fetchFeed, refreshInterval); + return () => { + if (timerRef.current) clearInterval(timerRef.current); + }; + }, [fetchFeed, refreshInterval]); + + const markAllRead = async () => { + try { + await fetch(`${API_BASE}/comments/notifications/${currentUserId}/read`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + setNotifications((prev) => prev.map((n) => ({ ...n, read: true }))); + setUnreadCount(0); + } catch { + // silent + } + }; + + return ( +
+ {/* Header */} +
+

Activity

+ +
+ + {error && ( +
{error}
+ )} + + {/* Tabs */} + {showNotifications && ( +
+ {(["activity", "mentions"] as const).map((tab) => ( + + ))} +
+ )} + + {/* Activity feed */} + {(!showNotifications || activeTab === "activity") && ( +
+ {events.length === 0 ? ( +

+ No activity yet. +

+ ) : ( +
+ {events.map((event) => ( + + ))} +
+ )} +
+ )} + + {/* Mentions / notifications */} + {showNotifications && activeTab === "mentions" && ( +
+ {unreadCount > 0 && ( +
+ +
+ )} + + {notifications.length === 0 ? ( +

No mentions.

+ ) : ( +
+ {notifications.map((notif) => ( +
+
+ + {notif.mentionedBy} mentioned you + + + {formatRelativeTime(notif.createdAt)} + +
+

+ "{notif.commentSnippet}" +

+
+ ))} +
+ )} +
+ )} + + {/* Live indicator */} + {refreshInterval > 0 && ( +
+ + Live · updates every {Math.floor(refreshInterval / 1000)}s +
+ )} +
+ ); +} diff --git a/frontend/components/collaboration/CommentThread.tsx b/frontend/components/collaboration/CommentThread.tsx new file mode 100644 index 00000000..e4830f11 --- /dev/null +++ b/frontend/components/collaboration/CommentThread.tsx @@ -0,0 +1,582 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { Button } from "@/components/ui/button"; + +const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001/api/v1"; + +// ── Types ───────────────────────────────────────────────────────────────────── + +type ReactionEmoji = "👍" | "👎" | "❤️" | "🎉" | "🚀" | "👀" | "😕" | "🔥"; + +interface FileAnnotation { + filePath: string; + lineStart: number; + lineEnd?: number; +} + +interface CommentReaction { + emoji: ReactionEmoji; + userId: string; + createdAt: string; +} + +interface Comment { + id: string; + projectId: string; + targetType: string; + targetId: string; + parentId?: string; + authorId: string; + authorName: string; + body: string; + mentions: string[]; + reactions: CommentReaction[]; + upvotes: number; + downvotes: number; + userVotes: Record; + annotation?: FileAnnotation; + edited: boolean; + editedAt?: string; + deleted: boolean; + createdAt: string; + updatedAt: string; +} + +interface ThreadData { + parent: Comment; + replies: Comment[]; +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +const REACTIONS: ReactionEmoji[] = ["👍", "👎", "❤️", "🎉", "🚀", "👀", "😕", "🔥"]; + +function groupReactions(reactions: CommentReaction[]): Record { + return reactions.reduce>((acc, r) => { + acc[r.emoji] = (acc[r.emoji] ?? 0) + 1; + return acc; + }, {}); +} + +function formatDate(iso: string): string { + const d = new Date(iso); + const now = Date.now(); + const diff = now - d.getTime(); + if (diff < 60_000) return "just now"; + if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`; + if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`; + return d.toLocaleDateString(); +} + +function initials(name: string): string { + return name + .split(" ") + .map((w) => w[0]) + .join("") + .toUpperCase() + .slice(0, 2); +} + +// ── CommentItem ─────────────────────────────────────────────────────────────── + +interface CommentItemProps { + comment: Comment; + currentUserId: string; + onReact: (id: string, emoji: ReactionEmoji) => void; + onVote: (id: string, dir: "up" | "down") => void; + onEdit: (id: string, body: string) => void; + onDelete: (id: string) => void; + onReply?: (parentId: string) => void; + isReply?: boolean; +} + +function CommentItem({ + comment, + currentUserId, + onReact, + onVote, + onEdit, + onDelete, + onReply, + isReply = false, +}: CommentItemProps) { + const [editing, setEditing] = useState(false); + const [editBody, setEditBody] = useState(comment.body); + const [showReactions, setShowReactions] = useState(false); + const reactionCounts = groupReactions(comment.reactions); + const userVote = comment.userVotes[currentUserId]; + + if (comment.deleted) { + return ( +
+ [deleted] +
+ ); + } + + return ( +
+ {/* Annotation badge */} + {comment.annotation && ( +
+ 📎 {comment.annotation.filePath}:{comment.annotation.lineStart} + {comment.annotation.lineEnd ? `–${comment.annotation.lineEnd}` : ""} +
+ )} + +
+ {/* Avatar */} + + +
+ {/* Header */} +
+ + {comment.authorName} + + {formatDate(comment.createdAt)} + {comment.edited && ( + (edited) + )} +
+ + {/* Body */} + {editing ? ( +
+