Update hivegrid-cloud reference to latest main

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
davidandClaude Fable 5 committed 2026-07-08 16:15:37 +02:00
1 parent e98d38e716
commit 7b21524c86
3781 files changed
+373484 -225607

No files matched your search

@@ -14,23 +14,37 @@
# BRAND_MAIL_LINK=#2563eb
# Core
APP_NAME=ToolCTRL
APP_NAME=HiveGrid
# DATABASE_URL: pooled connection for application traffic. Point at PgBouncer
# on port 6432 in environments where the pooler is deployed and append
# `?pgbouncer=true&connection_limit=10` so Prisma disables prepared-statement
# caching (transaction-mode pooling cannot keep them). DIRECT_URL (below) is
# the unpooled connection used by `prisma migrate`. See docs/ops/pgbouncer.md.
DATABASE_URL="postgres://user:pass@localhost:6432/boilerplate?pgbouncer=true&connection_limit=10"
DATABASE_URL="postgres://user:pass@localhost:6432/hivegrid?pgbouncer=true&connection_limit=10"
# SESSION_SECRET signs the session cookie. Min 32 bytes of entropy recommended (openssl rand -hex 32).
SESSION_SECRET=
# CRYPTO_SECRET is the AES key for stored workflow credentials. Use a SEPARATE secret from SESSION_SECRET.
CRYPTO_SECRET=
APP_BASE_DOMAIN=localhost
WEB_APP_HOST=app.localhost
# Local dev: include the vite port so cross-host redirects (login -> app/admin host)
# keep :3000 — routing derives app.localhost:3000 etc. and hostMatches tolerates it.
APP_BASE_DOMAIN=localhost:3000
WEB_APP_HOST=app.localhost:3000
# Public app origin used for embed snippets, mail links, and worker-to-web callbacks.
# APP_URL=http://app.localhost:3000
ADMIN_APP_HOST=admin.localhost
API_HOST=api.localhost
ADMIN_APP_HOST=admin.localhost:3000
API_HOST=api.localhost:3000
# MCP runs as its own public connector host, not below app/api.
MCP_HOST=0.0.0.0
MCP_PORT=8080
MCP_ALLOWED_HOSTS=mcp.hivegrid.de,staging-mcp.hivegrid.de,localhost,127.0.0.1,::1
MCP_ALLOWED_ORIGINS=https://mcp.hivegrid.de,https://staging-mcp.hivegrid.de,http://localhost,http://127.0.0.1
# Public MCP resource origin and OAuth authorization-server origin. Set staging to
# https://staging-mcp.hivegrid.de and https://staging-app.hivegrid.de.
MCP_RESOURCE_URL=http://mcp.localhost:8080
MCP_AUTHORIZATION_SERVER_URL=http://app.localhost:3000
# Optional dedicated HMAC secret for hash-only MCP OAuth codes/tokens. When unset,
# API_KEY_HASH_SECRET is reused so web and mcp agree on token lookup.
MCP_OAUTH_TOKEN_HASH_SECRET=
# APP_PROTOCOL=http
# Legacy API/auth tokens
@@ -40,6 +54,8 @@ JWT_SECRET=
# disabled. Set to `true` ONLY during the hash-metadata backfill window, then set
# back to `false` once the backfill is verified.
API_KEY_LEGACY_PLAINTEXT_FALLBACK=false
# Stable HMAC secret for legacy API-key hash lookup/backfill. Keep unchanged once deployed.
API_KEY_HASH_SECRET=
# Local development
# Set to true only for disposable local databases when you want sample tenants/users.
@@ -66,11 +82,18 @@ STORAGE_PUBLIC_BASE_URL=
STORAGE_FORCE_PATH_STYLE=false
# Billing
STRIPE_SK=
STRIPE_WEBHOOK_SIGNING_SECRET=
STRIPE_ACCOUNT_COUNTRY=US
# Lago is self-hosted and used for billing plans, seats, subscriptions and invoices.
# Do not append /api/v1; the gateway adds it.
LAGO_API_URL=
LAGO_API_KEY=
LAGO_WEBHOOK_SECRET=
LAGO_TIMEOUT_MS=10000
# Mollie is used only as payment processor. Keep test/live keys in the secret store.
MOLLIE_API_KEY=
MOLLIE_PROFILE_ID=
MOLLIE_TIMEOUT_MS=10000
# Worker delivery credentials for queued transactional email (toolctrl.email -> Mailjet)
# Worker delivery credentials for queued transactional email (hivegrid.email -> Mailjet)
MAILJET_API_KEY=
MAILJET_SECRET_KEY=
MAILJET_FROM_EMAIL=
@@ -87,9 +110,19 @@ INTEGRATIONS_CONTACT_FORMSPREE=
# Analytics (First-Party-Ingestion-Token; Google Analytics wurde EU-sterilisiert entfernt — R-P1a)
ANALYTICS_TOKEN=
# mCaptcha (self-hosted PoW captcha — see infra/mcaptcha/README.md). Public bits
# (instance URL + per-surface sitekeys) are safe to expose; the account secret is
# server-only. Verification fails open by default (CAPTCHA_FAIL_OPEN).
# Self-hosted captcha. mCaptcha remains the compatibility default; set
# CAPTCHA_PROVIDER=cap to use Cap Standalone (see infra/cap/README.md).
# Public bits (instance URL + per-surface sitekeys) are safe to expose; secrets
# are server-only. Verification fails open by default (CAPTCHA_FAIL_OPEN).
CAPTCHA_PROVIDER=mcaptcha
CAP_INSTANCE_URL=
CAP_SITE_SECRET=
CAP_SITE_SECRET_LOGIN=
CAP_SITE_SECRET_FORMS=
CAP_SITE_SECRET_BOOKING=
CAP_SITEKEY_LOGIN=
CAP_SITEKEY_FORMS=
CAP_SITEKEY_BOOKING=
MCAPTCHA_INSTANCE_URL=
MCAPTCHA_ACCOUNT_SECRET=
MCAPTCHA_SITEKEY_LOGIN=
@@ -197,21 +230,21 @@ MEILI_API_KEY=devkey
# DIRECT_URL: unpooled Postgres URL (port 5432, bypasses PgBouncer). Required
# by `prisma migrate` / `db push` so prepared statements survive. Apps still
# use DATABASE_URL (above) for runtime queries. See docs/ops/pgbouncer.md.
# DIRECT_URL="postgres://user:pass@localhost:5432/boilerplate"
# DIRECT_URL="postgres://user:pass@localhost:5432/hivegrid"
# DATABASE_READ_URL: optional Postgres read-replica used by getReadDbClient()
# for hot-read paths (CRM/KB listings, dashboards, public sites/portals).
# When unset or empty, getReadDbClient() falls back to DATABASE_URL so single-
# DB dev and self-hosted setups work without any code change. Point at the
# replica (or a PgBouncer-fronted replica) in staging/production. Replication
# is eventually consistent — see docs/architecture/rw-split.md.
# DATABASE_READ_URL="postgres://read_user:pass@replica.localhost:5432/boilerplate"
# DATABASE_READ_URL="postgres://read_user:pass@replica.localhost:5432/hivegrid"
# SERVER_URL=https://app.example.com
# VERCEL_URL=
# Observability (opt-in; leave commented to keep the safe noop reporter and disabled tracing)
# SENTRY_DSN=https://public@sentry.example/1
# API (apps/api) and Worker (apps/worker) — independent DSNs so the two
# processes report to separate Sentry projects (boilerplate-api / boilerplate-worker).
# processes report to separate Sentry projects (hivegrid-api / hivegrid-worker).
# SENTRY_DSN_API=https://public-api@sentry.example/3
# SENTRY_DSN_WORKER=https://public-worker@sentry.example/4
# SENTRY_ENVIRONMENT=production
@@ -224,14 +257,14 @@ MEILI_API_KEY=devkey
# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318/v1/traces
# Comma-separated key=value headers forwarded with every OTLP export.
# OTEL_EXPORTER_OTLP_HEADERS=x-sentry-auth=Sentry sentry_key=<public-key>
# OTEL_SERVICE_NAME=boilerplate-api
# OTEL_SERVICE_NAME=hivegrid-api
# OTEL_TRACES_SAMPLER=parentbased_traceidratio
# OTEL_TRACES_SAMPLER_ARG=0.1
# OTEL_LOG_LEVEL=debug
# Browser-side OTel (Vite-bundled). VITE_OTEL_EXPORTER_OTLP_ENDPOINT must be
# CORS-reachable from the user agent; gateway/relay MUST be rate-limited.
# VITE_OTEL_EXPORTER_OTLP_ENDPOINT=https://otel.example.com/v1/traces
# VITE_OTEL_SERVICE_NAME=boilerplate-web
# VITE_OTEL_SERVICE_NAME=hivegrid-web
# Web (apps/web) — separate Sentry project from the API/Worker stack.
# SENTRY_DSN_WEB=https://public-web@sentry.example/2
# VITE_SENTRY_DSN_WEB=https://public-web@sentry.example/2
@@ -243,14 +276,14 @@ MEILI_API_KEY=devkey
# Required by .github/workflows/_sentry-release.yml and the @sentry/vite-plugin step.
# SENTRY_AUTH_TOKEN=
# SENTRY_ORG=
# SENTRY_PROJECT=boilerplate-web
# SENTRY_URL=https://sentry.boiler.saas-ctrl.com
# SENTRY_PROJECT=hivegrid-web
# SENTRY_URL=https://sentry.saas-ctrl.com
# Asset CDN (opt-in; leave commented to keep origin-relative asset URLs).
# When set at build time, Vite emits all asset URLs prefixed with this origin
# so the CDN (Bunny.net by default — see docs/ops/asset-cdn.md) can pull from
# the app server and serve users from the edge. Local dev ignores the var.
# ASSET_CDN_URL=https://cdn.boiler.saas-ctrl.com
# ASSET_CDN_URL=https://cdn.hivegrid.de
# Bunny.net pull-zone id used by the post-deploy purge step. CI-only.
# BUNNY_PULL_ZONE_ID=
# Bunny.net account-level API key for cache-purge calls. CI-only.
@@ -278,10 +311,10 @@ MEILI_API_KEY=devkey
# Region tag surfaced in /status. Falls back to FLY_REGION when unset.
# APP_REGION=
# Backup CLI (@toolctrl/cli). See docs/operations/backup.md for the full runbook.
# Backup CLI (@hivegrid/cli). See docs/operations/backup.md for the full runbook.
# BACKUP_S3_ENDPOINT=https://s3.example.com
# BACKUP_S3_REGION=us-east-1
# BACKUP_S3_BUCKET=toolctrl-backups
# BACKUP_S3_BUCKET=hivegrid-backups
# BACKUP_S3_ACCESS_KEY_ID=
# BACKUP_S3_SECRET_ACCESS_KEY=
# BACKUP_S3_SESSION_TOKEN=
@@ -303,8 +336,8 @@ MEILI_API_KEY=devkey
# Embeddable form widget (Phase 05 #58 — packages/form-widget).
# FORM_WIDGET_CDN_URL serves /embed/widget.js. Falls back to APP_URL when unset.
# Captcha on form submissions uses the shared mCaptcha instance (MCAPTCHA_* at
# the top of this file), opt-in per form AND gated behind the
# Captcha on form submissions uses the shared self-hosted captcha provider
# configured at the top of this file, opt-in per form AND gated behind the
# `form-widget-turnstile` feature flag. See docs/features/form-widget.md.
# FORM_WIDGET_CDN_URL=https://forms.example.com
@@ -313,7 +346,7 @@ MEILI_API_KEY=devkey
# allowed) from which the public `/api/leads/capture` endpoint accepts
# submissions. Fail-closed: empty list rejects all browser submissions.
# MARKETING_RATE_LIMIT_PER_MIN is the per-IP submission limit (default 5).
# Captcha is the shared mCaptcha instance (MCAPTCHA_*); flag is gated via
# Captcha is the shared self-hosted captcha provider; flag is gated via
# the `marketing-funnel-turnstile` feature flag.
# See docs/features/marketing-funnel.md.
# MARKETING_ALLOWED_ORIGINS=https://marketing.example.com,https://*.example.com
@@ -374,10 +407,10 @@ MEILI_API_KEY=devkey
# Admin-Panel SSO via Authentik (Phase 09 #82 — apps/web/app/modules/admin-sso)
# ---------------------------------------------------------------------------
# AUTHENTIK_ISSUER_URL is the OIDC issuer published by the Authentik
# `toolctrl-admin-panel` provider (matches the `iss` claim in ID-tokens).
# Typically `https://auth.<domain>/application/o/toolctrl-admin-panel/`.
# `hivegrid-admin-panel` provider (matches the `iss` claim in ID-tokens).
# Typically `https://auth.<domain>/application/o/hivegrid-admin-panel/`.
# AUTHENTIK_ADMIN_CLIENT_ID must equal the blueprint client_id
# (`toolctrl-admin-panel` per infra/authentik/bootstrap/blueprints/03-*).
# (`hivegrid-admin-panel` per infra/authentik/bootstrap/blueprints/03-*).
# AUTHENTIK_ADMIN_CLIENT_SECRET is fetched from the Authentik admin UI
# after the blueprint applies; rotate via Authentik then redeploy.
# AUTHENTIK_ADMIN_REDIRECT_URI is the exact callback URI registered with
@@ -393,58 +426,14 @@ MEILI_API_KEY=devkey
# short (1-2 bootstrap accounts max). Each fallback login emits an
# `admin.login.fallback.success` audit event tagged severity=warn.
# See docs/operations/admin-sso.md.
# AUTHENTIK_ISSUER_URL=https://auth.example.com/application/o/toolctrl-admin-panel/
# AUTHENTIK_ADMIN_CLIENT_ID=toolctrl-admin-panel
# AUTHENTIK_ISSUER_URL=https://auth.example.com/application/o/hivegrid-admin-panel/
# AUTHENTIK_ADMIN_CLIENT_ID=hivegrid-admin-panel
# AUTHENTIK_ADMIN_CLIENT_SECRET=
# AUTHENTIK_ADMIN_REDIRECT_URI=https://admin.example.com/auth/admin/callback
# AUTHENTIK_ADMIN_POST_LOGOUT_REDIRECT_URI=https://admin.example.com/admin/login?logged_out=1
# AUTHENTIK_ADMIN_POST_LOGOUT_REDIRECT_URI=https://admin.example.com/login?logged_out=1
# ADMIN_FALLBACK_LOGIN_ENABLED=false
# ADMIN_FALLBACK_LOGIN_EMAILS=
# ---------------------------------------------------------------------------
# Capacitor mobile + push backend (Phase 08 #65 — apps/mobile + apps/worker)
# ---------------------------------------------------------------------------
# FCM_SERVICE_ACCOUNT_JSON holds the Firebase Cloud Messaging service account
# credentials used by the worker push job to dispatch Android notifications.
# Accepted formats: (1) raw JSON, (2) base64-encoded JSON. Generate via the
# Firebase console (Project settings → Service accounts → Generate new
# private key). NEVER commit this — it grants full FCM send rights.
#
# APNS_KEY is the PEM contents of the .p8 Apple Push Notification Service
# signing key (a standard PKCS#8 PEM block). APNS_KEY_ID is the 10-character
# key id shown in the Apple Developer console next to the generated .p8 file.
# APNS_TEAM_ID is the 10-character developer team id.
# APNS_BUNDLE_ID defaults to de.toolctrl.app (matches capacitor.config.ts
# > appId); override only when shipping a re-branded fork.
#
# MOBILE_LIVE_RELOAD_URL is a dev-only convenience: when set, apps/mobile
# emits a Capacitor `server.url` in capacitor.config.ts so the native shell
# loads the running web dev server (typically http://192.168.x.x:3000)
# instead of the bundled webDir. Leave empty for production builds.
# See docs/operations/capacitor.md.
# FCM_SERVICE_ACCOUNT_JSON=
# APNS_KEY=
# APNS_KEY_ID=
# APNS_TEAM_ID=
# APNS_BUNDLE_ID=de.toolctrl.app
# MOBILE_LIVE_RELOAD_URL=
# ---------------------------------------------------------------------------
# AI-cost Phase 3 — Stripe overage billing (apps/worker cron + reconcile)
# ---------------------------------------------------------------------------
# AI_OVERAGE_SUBMISSION_ENABLED is the master killswitch for the nightly
# Stripe Billing Meter event submitter. When `false` (or `0`), AI events are still
# marked `isOverage = true` + `markedUpCostCents` populated, but the cron
# skips Stripe. Default: enabled when STRIPE_SK is configured. Use this to
# pause submissions during an incident; the next enabled run backfills via
# `stripeReportedAt IS NULL`.
#
# Per-plan markup multiplier + Stripe identifiers ship in
# `packages/entitlements/src/ai-overage.ts` (`standardAIOverageDefaults`).
# Configure live Stripe ids via /admin/settings/pricing/ai-overage (admin
# UI writes an audit log) and/or per-tenant overrides on
# `Tenant.aiQuotaOverrideJson` — see docs/features/ai-cost-billing.md.
# AI_OVERAGE_SUBMISSION_ENABLED=true
# Phase 06: Plugin output sanitization (strict|moderate|off)
PLUGIN_OUTPUT_SANITIZATION_STRICTNESS=moderate
@@ -1,74 +0,0 @@
# REQUIRED CORE
flyctl secrets set APP_NAME=YOUR_APP_NAME \
DATABASE_URL=postgres://{USER}:{PASSWORD}@{HOST}:{PORT}/{DATABASE} \
SESSION_SECRET=<session-secret> \
CRYPTO_SECRET=<crypto-secret> \
API_ACCESS_TOKEN=<api-access-token> \
JWT_SECRET=<jwt-secret> \
APP_BASE_DOMAIN=your-domain.invalid \
WEB_APP_HOST=app.your-domain.invalid \
ADMIN_APP_HOST=admin.your-domain.invalid \
API_HOST=api.your-domain.invalid \
APP_PROTOCOL=https \
--app YOUR_APP_NAME
# APP AND PROVIDER SECRETS
flyctl secrets set APP_NAME=YOUR_APP_NAME \
DATABASE_URL=postgres://{USER}:{PASSWORD}@{HOST}:{PORT}/{DATABASE} \
SESSION_SECRET=<session-secret> \
CRYPTO_SECRET=<crypto-secret> \
API_ACCESS_TOKEN=<api-access-token> \
JWT_SECRET=<jwt-secret> \
APP_BASE_DOMAIN=your-domain.invalid \
WEB_APP_HOST=app.your-domain.invalid \
ADMIN_APP_HOST=admin.your-domain.invalid \
API_HOST=api.your-domain.invalid \
APP_PROTOCOL=https \
STORAGE_ENDPOINT=https://s3.your-domain.invalid \
STORAGE_REGION=us-east-1 \
STORAGE_BUCKET=toolctrl-exports \
STORAGE_ACCESS_KEY_ID=<storage-access-key-id> \
STORAGE_SECRET_ACCESS_KEY=<storage-secret-access-key> \
STORAGE_SESSION_TOKEN= \
STORAGE_PUBLIC_BASE_URL=https://cdn.your-domain.invalid \
STORAGE_FORCE_PATH_STYLE=false \
STRIPE_SK=<stripe-secret-key> \
STRIPE_WEBHOOK_SIGNING_SECRET=<stripe-webhook-signing-secret> \
STRIPE_ACCOUNT_COUNTRY=US \
MAILJET_API_KEY=<mailjet-api-key> \
MAILJET_SECRET_KEY=<mailjet-secret-key> \
MAILJET_FROM_EMAIL=notifications@example.com \
MAILJET_FROM_NAME=ToolCTRL \
SUPPORT_EMAIL=support@example.com \
COMPANY_NAME=ExampleCo \
COMPANY_ADDRESS="Example address" \
INTEGRATIONS_CONTACT_FORMSPREE=https://formspree.io/f/example \
ANALYTICS_TOKEN=<analytics-token> \
MCAPTCHA_INSTANCE_URL=<mcaptcha-instance-url> \
MCAPTCHA_ACCOUNT_SECRET=<mcaptcha-account-secret> \
MCAPTCHA_SITEKEY_LOGIN=<mcaptcha-sitekey-login> \
MCAPTCHA_SITEKEY_FORMS=<mcaptcha-sitekey-forms> \
MCAPTCHA_SITEKEY_BOOKING=<mcaptcha-sitekey-booking> \
AUTH_MAGIC_LINK_ENABLED=false \
AUTH_MAGIC_LINK_TOKEN_TTL_MINUTES=15 \
NOTIFICATIONS_NOVU_API_KEY=<novu-api-key> \
EXPORT_RETENTION_QUEUE_ENABLED=false \
IMPORT_QUEUE_ENABLED=false \
BULLMQ_REDIS_URL=redis://default:<redis-password>@redis.your-domain.invalid:6379/0 \
BROWSER_RUNTIME_URL=https://browser-runtime.your-domain.invalid \
BROWSER_RUNTIME_API_KEY=<browser-runtime-api-key> \
BROWSER_SESSION_TIMEOUT_MS=60000 \
IPSTACK_API_KEY=<ipstack-api-key> \
IPAPIIS_API_KEY=<ipapiis-api-key> \
--app YOUR_APP_NAME
# REDIS_URL is a fallback only when BULLMQ_REDIS_URL is absent; keep it commented if a platform injects it.
# REDIS_URL=redis://default:<redis-password>@redis.example.com:6379/0
# PORTAL SECRETS
flyctl secrets set PORTAL_SERVER_URL=https://portal.your-domain.invalid \
PORTAL_SERVER_FLY_TOKEN="FlyV1 <portal-token>" \
--app YOUR_APP_NAME
@@ -1,5 +1,5 @@
# Core local development
APP_NAME=ToolCTRL Local
APP_NAME=HiveGrid Local
DATABASE_URL="file:./dev.db?connection_limit=1&connect_timeout=30"
SESSION_SECRET=replace-with-local-session-secret
CRYPTO_SECRET=replace-with-local-crypto-secret
@@ -28,8 +28,6 @@ DEMO_USER=admin@email.com:password
# STORAGE_SESSION_TOKEN=
# STORAGE_PUBLIC_BASE_URL=
# STORAGE_FORCE_PATH_STYLE=false
# STRIPE_SK=
# STRIPE_ACCOUNT_COUNTRY=US
# MAILJET_API_KEY=
# MAILJET_SECRET_KEY=
# MAILJET_FROM_EMAIL=
@@ -1,5 +1,5 @@
# Required core
APP_NAME=ToolCTRL
APP_NAME=HiveGrid
# DATABASE_URL points at PgBouncer (port 6432) for application traffic.
# Append `?pgbouncer=true&connection_limit=10` so Prisma disables prepared-
# statement caching (transaction-mode pooling cannot keep them). DIRECT_URL
@@ -11,12 +11,19 @@ APP_BASE_DOMAIN=your-domain.invalid
WEB_APP_HOST=app.your-domain.invalid
ADMIN_APP_HOST=admin.your-domain.invalid
API_HOST=api.your-domain.invalid
MCP_HOST=0.0.0.0
MCP_PORT=8080
MCP_ALLOWED_HOSTS=mcp.your-domain.invalid,staging-mcp.your-domain.invalid
MCP_ALLOWED_ORIGINS=https://mcp.your-domain.invalid,https://staging-mcp.your-domain.invalid
MCP_RESOURCE_URL=https://mcp.your-domain.invalid
MCP_AUTHORIZATION_SERVER_URL=https://app.your-domain.invalid
APP_PROTOCOL=https
# Required for current legacy API/auth endpoints
API_ACCESS_TOKEN=
JWT_SECRET=
API_KEY_LEGACY_PLAINTEXT_FALLBACK=false
API_KEY_HASH_SECRET=
# Required if public analytics ingestion is enabled
ANALYTICS_TOKEN=
@@ -47,9 +54,6 @@ STORAGE_SECRET_ACCESS_KEY=
STORAGE_SESSION_TOKEN=
STORAGE_PUBLIC_BASE_URL=
STORAGE_FORCE_PATH_STYLE=false
STRIPE_SK=
STRIPE_WEBHOOK_SIGNING_SECRET=
STRIPE_ACCOUNT_COUNTRY=US
MAILJET_API_KEY=
MAILJET_SECRET_KEY=
MAILJET_FROM_EMAIL=
@@ -59,6 +63,15 @@ SUPPORT_EMAIL=
COMPANY_NAME=
COMPANY_ADDRESS=
INTEGRATIONS_CONTACT_FORMSPREE=
CAPTCHA_PROVIDER=cap
CAP_INSTANCE_URL=https://captcha.hivegrid.de
CAP_SITE_SECRET=
CAP_SITE_SECRET_LOGIN=
CAP_SITE_SECRET_FORMS=
CAP_SITE_SECRET_BOOKING=
CAP_SITEKEY_LOGIN=
CAP_SITEKEY_FORMS=
CAP_SITEKEY_BOOKING=
MCAPTCHA_INSTANCE_URL=
MCAPTCHA_ACCOUNT_SECRET=
MCAPTCHA_SITEKEY_LOGIN=
@@ -1,6 +1,6 @@
# Non-secret test fixture used by env-contract checks.
# Values are deterministic placeholders only; never copy production secrets here.
APP_NAME=ToolCTRL Test
APP_NAME=HiveGrid Test
DATABASE_URL="file:./test.db?connection_limit=1&connect_timeout=30"
SESSION_SECRET=test-session-secret-placeholder
CRYPTO_SECRET=test-crypto-secret-placeholder
@@ -8,11 +8,18 @@ APP_BASE_DOMAIN=localhost.test
WEB_APP_HOST=app.localhost.test
ADMIN_APP_HOST=admin.localhost.test
API_HOST=api.localhost.test
MCP_HOST=127.0.0.1
MCP_PORT=8080
MCP_ALLOWED_HOSTS=mcp.localhost.test,localhost,127.0.0.1,::1
MCP_ALLOWED_ORIGINS=http://mcp.localhost.test,http://localhost,http://127.0.0.1
MCP_RESOURCE_URL=http://mcp.localhost.test:8080
MCP_AUTHORIZATION_SERVER_URL=http://app.localhost.test
APP_PROTOCOL=http
# Legacy API/auth tokens
API_ACCESS_TOKEN=test-api-access-token-placeholder
JWT_SECRET=test-jwt-secret-placeholder
API_KEY_HASH_SECRET=test-api-key-hash-secret-placeholder
# Test-safe platform/runtime values
NODE_ENV=test
@@ -43,5 +50,4 @@ SUPPORT_EMAIL=support@example.com
COMPANY_NAME=Example Test Company
COMPANY_ADDRESS=123 Test Street
ANALYTICS_TOKEN=test-analytics-token-placeholder
STRIPE_ACCOUNT_COUNTRY=US
SITE_KEY_EXAMPLE_SITE=test-site-key-placeholder
@@ -55,26 +55,26 @@
"@prisma/client",
"@prisma/client/*"
],
"message": "Import Prisma through @toolctrl/db package boundaries instead of generated clients."
"message": "Import Prisma through @hivegrid/db package boundaries instead of generated clients."
},
{
"group": ["@toolctrl/entitlements/src/*", "packages/entitlements/src/*"],
"message": "Import entitlements through the public @toolctrl/entitlements package boundary."
"group": ["@hivegrid/entitlements/src/*", "packages/entitlements/src/*"],
"message": "Import entitlements through the public @hivegrid/entitlements package boundary."
},
{
"group": [
"@toolctrl/foundation/src/*",
"@hivegrid/foundation/src/*",
"packages/foundation/src/*",
"@toolctrl/view-models/src/*",
"@hivegrid/view-models/src/*",
"packages/view-models/src/*",
"@toolctrl/ui-app/src/*",
"@hivegrid/ui-app/src/*",
"packages/ui-app/src/*",
"@toolctrl/ui-subframe/src/*",
"@hivegrid/ui-subframe/src/*",
"packages/ui-subframe/src/*",
"@toolctrl/ui-mock/src/*",
"@hivegrid/ui-mock/src/*",
"packages/ui-mock/src/*"
],
"message": "Import Phase C packages through their public @toolctrl/* package boundaries."
"message": "Import Phase C packages through their public @hivegrid/* package boundaries."
}
]
}
@@ -151,7 +151,7 @@
"@prisma/client",
"@prisma/client/*"
],
"message": "Import Prisma through package-owned generated entrypoints inside @toolctrl/db boundaries."
"message": "Import Prisma through package-owned generated entrypoints inside @hivegrid/db boundaries."
}
]
}
@@ -173,7 +173,7 @@
"@prisma/client",
"@prisma/client/*"
],
"message": "Import Prisma through @toolctrl/db package boundaries instead of generated clients."
"message": "Import Prisma through @hivegrid/db package boundaries instead of generated clients."
},
{
"group": ["react", "react/*"],
@@ -228,8 +228,8 @@
"apps/web/app/generated/prisma/client/*",
"@prisma/client",
"@prisma/client/*",
"@toolctrl/db",
"@toolctrl/db/*"
"@hivegrid/db",
"@hivegrid/db/*"
],
"message": "UI packages must not access DB boundaries directly."
}
@@ -1,13 +1,13 @@
{
"$schema-comment": "Baseline catalogue of jsx-a11y/recommended rules that are currently disabled in .eslintrc because the codebase has pre-existing violations. The plan is to drive these counts down to zero, then promote each rule back to `error` in .eslintrc and remove it from this file. See docs/contributing/wcag-audit.md for the reduction policy. Counts are produced by `scripts/verify-a11y-baseline.mjs`.",
"generatedAt": "2026-05-17",
"lastUpdatedAt": "2026-05-28",
"scope": ["apps/web/app/**/*.tsx", "packages/ui/src/stories/**/*.tsx"],
"lastUpdatedAt": "2026-07-01",
"scope": ["apps/web/app/**/*.tsx"],
"ruleset": "plugin:jsx-a11y/recommended",
"totalViolations": 81,
"totalViolations": 56,
"rules": {
"jsx-a11y/no-autofocus": {
"violations": 81,
"violations": 56,
"severity": "P2",
"rationale": "Autofocus remains in form/editor surfaces. Button/link wrappers now use programmatic focus instead of DOM autoFocus. Migration plan: prefer programmatic focus on open() so screen-reader users hear the modal title first."
}
@@ -39,11 +39,11 @@
},
"jsx-a11y/anchor-is-valid": {
"promotedAt": "2026-05-25",
"rationale": "Reached 0 violations after Storybook Popover demo menu anchors were converted to button controls. Rule moved back to `error` and the `off` override removed from .eslintrc."
"rationale": "Reached 0 violations after Popover demo menu anchors were converted to button controls. Rule moved back to `error` and the `off` override removed from .eslintrc."
},
"jsx-a11y/no-noninteractive-tabindex": {
"promotedAt": "2026-05-25",
"rationale": "Verifier reproduced 0 violations across the tracked app and Storybook scope. Rule moved back to `error` and the stale `off` override was removed from .eslintrc."
"rationale": "Verifier reproduced 0 violations across the tracked app and UI scope. Rule moved back to `error` and the stale `off` override was removed from .eslintrc."
},
"jsx-a11y/media-has-caption": {
"promotedAt": "2026-05-28",
@@ -7,7 +7,7 @@ description: |
inputs:
project:
description: "Sentry project slug (boilerplate-api|boilerplate-worker|boilerplate-web)"
description: "Sentry project slug (hivegrid-api|hivegrid-worker|hivegrid-web)"
required: true
environment:
description: "Deployment environment label (production|staging|preview)"
@@ -9,7 +9,7 @@ on:
workflow_call:
inputs:
project:
description: "Sentry project slug (boilerplate-api|boilerplate-worker|boilerplate-web)"
description: "Sentry project slug (api|worker|web) in the 'sentry' org on sentry.saas-ctrl.com"
required: true
type: string
environment:
@@ -38,7 +38,7 @@ on:
required: true
env:
TOOLCTRL_GITEA_HOST_IP: ${{ vars.TOOLCTRL_GITEA_HOST_IP }}
HIVEGRID_GITEA_HOST_IP: ${{ vars.HIVEGRID_GITEA_HOST_IP }}
jobs:
release:
@@ -48,12 +48,12 @@ jobs:
steps:
- name: Resolve internal Gitea host
run: |
if [ -n "${TOOLCTRL_GITEA_HOST_IP:-}" ]; then
if ! TOOLCTRL_GITEA_HOST_IP="$TOOLCTRL_GITEA_HOST_IP" node -e 'process.exit(require("node:net").isIP(process.env.TOOLCTRL_GITEA_HOST_IP || "") ? 0 : 1)'; then
echo "TOOLCTRL_GITEA_HOST_IP must be a single IPv4 or IPv6 token"
if [ -n "${HIVEGRID_GITEA_HOST_IP:-}" ]; then
if ! HIVEGRID_GITEA_HOST_IP="$HIVEGRID_GITEA_HOST_IP" node -e 'process.exit(require("node:net").isIP(process.env.HIVEGRID_GITEA_HOST_IP || "") ? 0 : 1)'; then
echo "HIVEGRID_GITEA_HOST_IP must be a single IPv4 or IPv6 token"
exit 1
fi
printf '%s git.tool-ctrl.de\n' "$TOOLCTRL_GITEA_HOST_IP" >> /etc/hosts
printf '%s git.tool-ctrl.de\n' "$HIVEGRID_GITEA_HOST_IP" >> /etc/hosts
fi
- name: Checkout code
@@ -69,7 +69,7 @@ jobs:
- name: Download source-map artifact
if: ${{ inputs.sourcemaps-artifact != 'none' }}
uses: actions/download-artifact@v4
uses: actions/download-artifact@v3
with:
name: ${{ inputs.sourcemaps-artifact }}
path: ${{ inputs.sourcemaps-path }}
@@ -5,7 +5,7 @@ name: Accessibility
#
# Mirror of .github/workflows/a11y.yml for the Gitea runner.
#
# Spec: docs/improvement-plan/10-compliance-polish/wcag-internal.md
# Docs: docs/contributing/wcag-audit.md
on:
push:
@@ -13,18 +13,44 @@ on:
- main
pull_request:
# Cancel superseded runs so rapid pushes don't pile up unclaimed on the runner
# (mirrors ci.yml). Without this each push stacked a new a11y run on the queue.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
axe:
name: axe-core + color-contrast
runs-on: ubuntu-latest
# Use the self-hosted Gitea runner label (like every ci.yml job). The previous
# `ubuntu-latest` was a leftover from the GitHub mirror and matches no Gitea
# runner, so this job sat pending forever (never claimed) on every PR + push.
runs-on: toolctrl-ci
timeout-minutes: 20
env:
DATABASE_URL: postgresql://toolctrl:toolctrl@localhost:5432/toolctrl?schema=public
DATABASE_URL: postgresql://hivegrid:hivegrid@localhost:5432/hivegrid?schema=public
# Same repo variable ci.yml exposes top-level: the internal Gitea host IP that the
# "Resolve internal Gitea host" step writes to /etc/hosts so checkout can clone
# git.tool-ctrl.de inside the runner container. Without it the step is a no-op.
HIVEGRID_GITEA_HOST_IP: ${{ vars.HIVEGRID_GITEA_HOST_IP }}
steps:
# Same as every ci.yml job: make the internal Gitea host resolvable inside the
# runner container so actions/checkout can clone git.tool-ctrl.de. Without this the
# checkout step fails (the job never had it -> a11y could never run on Gitea).
- name: Resolve internal Gitea host
run: |
if [ -n "${HIVEGRID_GITEA_HOST_IP:-}" ]; then
if ! HIVEGRID_GITEA_HOST_IP="$HIVEGRID_GITEA_HOST_IP" node -e 'process.exit(require("node:net").isIP(process.env.HIVEGRID_GITEA_HOST_IP || "") ? 0 : 1)'; then
echo "HIVEGRID_GITEA_HOST_IP must be a single IPv4 or IPv6 token"
exit 1
fi
printf '%s git.tool-ctrl.de\n' "$HIVEGRID_GITEA_HOST_IP" >> /etc/hosts
fi
- name: Checkout code
uses: actions/checkout@v4
@@ -2,7 +2,6 @@ name: Bundle Budget
# Gitea variant of .github/workflows/bundle-budget.yml. Uses Gitea-API to
# post / upsert a sticky PR comment with per-route gzip sizes.
# Spec: docs/improvement-plan/10-compliance-polish/bundle-budget.md
# Docs: docs/architecture/bundle-budgets.md
on:
@@ -26,7 +25,7 @@ on:
- ".gitea/workflows/bundle-budget.yml"
env:
TOOLCTRL_GITEA_HOST_IP: ${{ vars.TOOLCTRL_GITEA_HOST_IP }}
HIVEGRID_GITEA_HOST_IP: ${{ vars.HIVEGRID_GITEA_HOST_IP }}
jobs:
measure:
@@ -34,16 +33,16 @@ jobs:
runs-on: toolctrl-ci
timeout-minutes: 30
env:
DATABASE_URL: postgresql://toolctrl:toolctrl@localhost:5432/toolctrl?schema=public
DATABASE_URL: postgresql://hivegrid:hivegrid@localhost:5432/hivegrid?schema=public
steps:
- name: Resolve internal Gitea host
run: |
if [ -n "${TOOLCTRL_GITEA_HOST_IP:-}" ]; then
if ! TOOLCTRL_GITEA_HOST_IP="$TOOLCTRL_GITEA_HOST_IP" node -e 'process.exit(require("node:net").isIP(process.env.TOOLCTRL_GITEA_HOST_IP || "") ? 0 : 1)'; then
echo "TOOLCTRL_GITEA_HOST_IP must be a single IPv4 or IPv6 token"
if [ -n "${HIVEGRID_GITEA_HOST_IP:-}" ]; then
if ! HIVEGRID_GITEA_HOST_IP="$HIVEGRID_GITEA_HOST_IP" node -e 'process.exit(require("node:net").isIP(process.env.HIVEGRID_GITEA_HOST_IP || "") ? 0 : 1)'; then
echo "HIVEGRID_GITEA_HOST_IP must be a single IPv4 or IPv6 token"
exit 1
fi
printf '%s git.tool-ctrl.de\n' "$TOOLCTRL_GITEA_HOST_IP" >> /etc/hosts
printf '%s git.tool-ctrl.de\n' "$HIVEGRID_GITEA_HOST_IP" >> /etc/hosts
fi
- name: Checkout PR head
@@ -67,10 +66,10 @@ jobs:
${{ runner.os }}-pnpm-
- name: Install dependencies
run: pnpm install --frozen-lockfile
run: pnpm install --frozen-lockfile --prefer-offline
- name: Build web (PR head)
run: pnpm --filter @toolctrl/web build
run: pnpm --filter @hivegrid/web build
- name: Verify aggregate bundle budgets
run: pnpm run verify:bundle-size
@@ -9,24 +9,29 @@ concurrency:
cancel-in-progress: true
env:
TOOLCTRL_GITEA_HOST_IP: ${{ vars.TOOLCTRL_GITEA_HOST_IP }}
HIVEGRID_GITEA_HOST_IP: ${{ vars.HIVEGRID_GITEA_HOST_IP }}
jobs:
quality:
name: Quality Gates
# The former single "verify" gate is split into two parallel jobs (verify-1 + verify-2)
# to halve CI wallclock. The two heavy UI checks (ui-visual + hot-swap) sit in verify-1,
# the two heavy accessibility checks (a11y-coverage + a11y smoke) in verify-2, with the
# lighter static gates spread evenly. Every required command still runs exactly once
# across both jobs and keeps its canonical relative order WITHIN its owning job.
verify-1:
name: Quality Static Verify (1/2)
runs-on: toolctrl-ci
timeout-minutes: 45
timeout-minutes: 20
env:
DATABASE_URL: postgresql://toolctrl:toolctrl@localhost:5432/toolctrl?schema=public
DATABASE_URL: postgresql://hivegrid:hivegrid@localhost:5432/hivegrid?schema=public
steps:
- name: Resolve internal Gitea host
run: |
if [ -n "${TOOLCTRL_GITEA_HOST_IP:-}" ]; then
if ! TOOLCTRL_GITEA_HOST_IP="$TOOLCTRL_GITEA_HOST_IP" node -e 'process.exit(require("node:net").isIP(process.env.TOOLCTRL_GITEA_HOST_IP || "") ? 0 : 1)'; then
echo "TOOLCTRL_GITEA_HOST_IP must be a single IPv4 or IPv6 token"
if [ -n "${HIVEGRID_GITEA_HOST_IP:-}" ]; then
if ! HIVEGRID_GITEA_HOST_IP="$HIVEGRID_GITEA_HOST_IP" node -e 'process.exit(require("node:net").isIP(process.env.HIVEGRID_GITEA_HOST_IP || "") ? 0 : 1)'; then
echo "HIVEGRID_GITEA_HOST_IP must be a single IPv4 or IPv6 token"
exit 1
fi
printf '%s git.tool-ctrl.de\n' "$TOOLCTRL_GITEA_HOST_IP" >> /etc/hosts
printf '%s git.tool-ctrl.de\n' "$HIVEGRID_GITEA_HOST_IP" >> /etc/hosts
fi
- name: Checkout code
@@ -64,6 +69,9 @@ jobs:
- name: Verify deploy readiness
run: pnpm run verify:deploy-readiness
- name: Verify Docker baseline
run: pnpm run verify:docker-baseline
- name: Verify Docker non-root runtime
run: pnpm run verify:docker-non-root
@@ -85,9 +93,58 @@ jobs:
- name: Verify API inventory
run: pnpm run verify:api-inventory
- name: Verify UI mock static baseline
run: pnpm run verify:ui-visual
- name: Test UI mock hot-swap
run: pnpm run test:hot-swap
verify-2:
name: Quality Static Verify (2/2)
runs-on: toolctrl-ci
timeout-minutes: 20
env:
DATABASE_URL: postgresql://hivegrid:hivegrid@localhost:5432/hivegrid?schema=public
steps:
- name: Resolve internal Gitea host
run: |
if [ -n "${HIVEGRID_GITEA_HOST_IP:-}" ]; then
if ! HIVEGRID_GITEA_HOST_IP="$HIVEGRID_GITEA_HOST_IP" node -e 'process.exit(require("node:net").isIP(process.env.HIVEGRID_GITEA_HOST_IP || "") ? 0 : 1)'; then
echo "HIVEGRID_GITEA_HOST_IP must be a single IPv4 or IPv6 token"
exit 1
fi
printf '%s git.tool-ctrl.de\n' "$HIVEGRID_GITEA_HOST_IP" >> /etc/hosts
fi
- name: Checkout code
uses: actions/checkout@v4
- name: Enable Corepack
run: corepack enable
- name: Prepare pnpm 10.33.0
run: corepack prepare pnpm@10.33.0 --activate
- name: Configure pnpm store
run: pnpm config set store-dir .pnpm-store
- name: Cache pnpm store
uses: actions/cache@v4
with:
path: .pnpm-store
key: ${{ runner.os }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-
- name: Install dependencies
run: pnpm install --frozen-lockfile --prefer-offline
- name: Verify tenant isolation report
run: pnpm run verify:tenant-isolation-report
- name: Verify cross-cutting model registry completeness
run: pnpm run verify:cross-cutting-models
- name: Verify sidebar routes
run: pnpm run verify:sidebar-routes
@@ -102,16 +159,8 @@ jobs:
- name: Verify Phase C package boundaries
run: pnpm run verify:phase-c-boundaries
- name: Verify Feature Manifest plugin removal gates
run: |
pnpm run verify:core-build-without-plugins-smoke
pnpm run verify:removed-modules
- name: Verify UI mock static baseline
run: pnpm run verify:ui-visual
- name: Test UI mock hot-swap
run: pnpm run test:hot-swap
- name: Verify Feature Manifest removed modules
run: pnpm run verify:removed-modules
- name: Verify accessibility coverage
run: pnpm run verify:a11y-coverage
@@ -126,6 +175,46 @@ jobs:
pnpm run verify:release-docs
pnpm run verify:docs-links
typecheck-test:
name: Quality Typecheck and Tests
runs-on: toolctrl-ci
timeout-minutes: 25
env:
DATABASE_URL: postgresql://hivegrid:hivegrid@localhost:5432/hivegrid?schema=public
steps:
- name: Resolve internal Gitea host
run: |
if [ -n "${HIVEGRID_GITEA_HOST_IP:-}" ]; then
if ! HIVEGRID_GITEA_HOST_IP="$HIVEGRID_GITEA_HOST_IP" node -e 'process.exit(require("node:net").isIP(process.env.HIVEGRID_GITEA_HOST_IP || "") ? 0 : 1)'; then
echo "HIVEGRID_GITEA_HOST_IP must be a single IPv4 or IPv6 token"
exit 1
fi
printf '%s git.tool-ctrl.de\n' "$HIVEGRID_GITEA_HOST_IP" >> /etc/hosts
fi
- name: Checkout code
uses: actions/checkout@v4
- name: Enable Corepack
run: corepack enable
- name: Prepare pnpm 10.33.0
run: corepack prepare pnpm@10.33.0 --activate
- name: Configure pnpm store
run: pnpm config set store-dir .pnpm-store
- name: Cache pnpm store
uses: actions/cache@v4
with:
path: .pnpm-store
key: ${{ runner.os }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-
- name: Install dependencies
run: pnpm install --frozen-lockfile --prefer-offline
- name: Typecheck
run: pnpm typecheck
@@ -138,15 +227,55 @@ jobs:
- name: Workspace package tests
run: pnpm run test:packages
- name: Mobile wrapper tests
run: pnpm run test:mobile
- name: Unit tests
run: pnpm test
build:
name: Quality Build and Bundle
runs-on: toolctrl-ci
timeout-minutes: 30
env:
DATABASE_URL: postgresql://hivegrid:hivegrid@localhost:5432/hivegrid?schema=public
steps:
- name: Resolve internal Gitea host
run: |
if [ -n "${HIVEGRID_GITEA_HOST_IP:-}" ]; then
if ! HIVEGRID_GITEA_HOST_IP="$HIVEGRID_GITEA_HOST_IP" node -e 'process.exit(require("node:net").isIP(process.env.HIVEGRID_GITEA_HOST_IP || "") ? 0 : 1)'; then
echo "HIVEGRID_GITEA_HOST_IP must be a single IPv4 or IPv6 token"
exit 1
fi
printf '%s git.tool-ctrl.de\n' "$HIVEGRID_GITEA_HOST_IP" >> /etc/hosts
fi
- name: Checkout code
uses: actions/checkout@v4
- name: Enable Corepack
run: corepack enable
- name: Prepare pnpm 10.33.0
run: corepack prepare pnpm@10.33.0 --activate
- name: Configure pnpm store
run: pnpm config set store-dir .pnpm-store
- name: Cache pnpm store
uses: actions/cache@v4
with:
path: .pnpm-store
key: ${{ runner.os }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-
- name: Install dependencies
run: pnpm install --frozen-lockfile --prefer-offline
- name: Verify Feature Manifest core without plugins
run: pnpm run verify:core-build-without-plugins-smoke
- name: Build
env:
TOOLCTRL_BUILD_SOURCEMAPS: "1"
HIVEGRID_BUILD_SOURCEMAPS: "1"
run: pnpm run build:ci
- name: Verify bundle size
@@ -156,7 +285,7 @@ jobs:
run: test -n "$(find apps/web/build/client -type f -name '*.map' -print -quit)"
- name: Upload web Sentry sourcemaps
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v3
with:
name: web-client-sourcemaps
path: apps/web/build/client
@@ -171,16 +300,16 @@ jobs:
runs-on: toolctrl-ci
timeout-minutes: 25
env:
DATABASE_URL: postgresql://toolctrl:toolctrl@localhost:5432/toolctrl?schema=public
DATABASE_URL: postgresql://hivegrid:hivegrid@localhost:5432/hivegrid?schema=public
steps:
- name: Resolve internal Gitea host
run: |
if [ -n "${TOOLCTRL_GITEA_HOST_IP:-}" ]; then
if ! TOOLCTRL_GITEA_HOST_IP="$TOOLCTRL_GITEA_HOST_IP" node -e 'process.exit(require("node:net").isIP(process.env.TOOLCTRL_GITEA_HOST_IP || "") ? 0 : 1)'; then
echo "TOOLCTRL_GITEA_HOST_IP must be a single IPv4 or IPv6 token"
if [ -n "${HIVEGRID_GITEA_HOST_IP:-}" ]; then
if ! HIVEGRID_GITEA_HOST_IP="$HIVEGRID_GITEA_HOST_IP" node -e 'process.exit(require("node:net").isIP(process.env.HIVEGRID_GITEA_HOST_IP || "") ? 0 : 1)'; then
echo "HIVEGRID_GITEA_HOST_IP must be a single IPv4 or IPv6 token"
exit 1
fi
printf '%s git.tool-ctrl.de\n' "$TOOLCTRL_GITEA_HOST_IP" >> /etc/hosts
printf '%s git.tool-ctrl.de\n' "$HIVEGRID_GITEA_HOST_IP" >> /etc/hosts
fi
- name: Checkout code
@@ -210,29 +339,78 @@ jobs:
run: pnpm run verify:openapi-security
- name: Typecheck API
run: pnpm --filter @toolctrl/api typecheck
run: pnpm --filter @hivegrid/api typecheck
- name: Test API
run: pnpm exec vitest run apps/api/src
- name: Build API
run: pnpm --filter @toolctrl/api build
run: pnpm --filter @hivegrid/api build
mcp:
name: MCP Service
runs-on: toolctrl-ci
timeout-minutes: 20
env:
DATABASE_URL: postgresql://hivegrid:hivegrid@localhost:5432/hivegrid?schema=public
steps:
- name: Resolve internal Gitea host
run: |
if [ -n "${HIVEGRID_GITEA_HOST_IP:-}" ]; then
if ! HIVEGRID_GITEA_HOST_IP="$HIVEGRID_GITEA_HOST_IP" node -e 'process.exit(require("node:net").isIP(process.env.HIVEGRID_GITEA_HOST_IP || "") ? 0 : 1)'; then
echo "HIVEGRID_GITEA_HOST_IP must be a single IPv4 or IPv6 token"
exit 1
fi
printf '%s git.tool-ctrl.de\n' "$HIVEGRID_GITEA_HOST_IP" >> /etc/hosts
fi
- name: Checkout code
uses: actions/checkout@v4
- name: Enable Corepack
run: corepack enable
- name: Prepare pnpm 10.33.0
run: corepack prepare pnpm@10.33.0 --activate
- name: Configure pnpm store
run: pnpm config set store-dir .pnpm-store
- name: Cache pnpm store
uses: actions/cache@v4
with:
path: .pnpm-store
key: ${{ runner.os }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-
- name: Install dependencies
run: pnpm install --frozen-lockfile --prefer-offline
- name: Typecheck MCP
run: pnpm --filter @hivegrid/mcp typecheck
- name: Test MCP
run: pnpm --filter @hivegrid/mcp test
- name: Build MCP
run: pnpm --filter @hivegrid/mcp build
worker:
name: Worker Service
runs-on: toolctrl-ci
timeout-minutes: 25
env:
DATABASE_URL: postgresql://toolctrl:toolctrl@localhost:5432/toolctrl?schema=public
DATABASE_URL: postgresql://hivegrid:hivegrid@localhost:5432/hivegrid?schema=public
steps:
- name: Resolve internal Gitea host
run: |
if [ -n "${TOOLCTRL_GITEA_HOST_IP:-}" ]; then
if ! TOOLCTRL_GITEA_HOST_IP="$TOOLCTRL_GITEA_HOST_IP" node -e 'process.exit(require("node:net").isIP(process.env.TOOLCTRL_GITEA_HOST_IP || "") ? 0 : 1)'; then
echo "TOOLCTRL_GITEA_HOST_IP must be a single IPv4 or IPv6 token"
if [ -n "${HIVEGRID_GITEA_HOST_IP:-}" ]; then
if ! HIVEGRID_GITEA_HOST_IP="$HIVEGRID_GITEA_HOST_IP" node -e 'process.exit(require("node:net").isIP(process.env.HIVEGRID_GITEA_HOST_IP || "") ? 0 : 1)'; then
echo "HIVEGRID_GITEA_HOST_IP must be a single IPv4 or IPv6 token"
exit 1
fi
printf '%s git.tool-ctrl.de\n' "$TOOLCTRL_GITEA_HOST_IP" >> /etc/hosts
printf '%s git.tool-ctrl.de\n' "$HIVEGRID_GITEA_HOST_IP" >> /etc/hosts
fi
- name: Checkout code
@@ -259,19 +437,19 @@ jobs:
run: pnpm install --frozen-lockfile --prefer-offline
- name: Typecheck Worker
run: pnpm --filter @toolctrl/worker typecheck
run: pnpm --filter @hivegrid/worker typecheck
- name: Test Worker
run: pnpm --filter @toolctrl/worker exec vitest run
run: pnpm --filter @hivegrid/worker exec vitest run
- name: Verify workflow outbox native readiness
run: pnpm run verify:workflow-outbox-native
- name: Build Worker
run: pnpm --filter @toolctrl/worker build
run: pnpm --filter @hivegrid/worker build
- name: Health Check Worker
run: pnpm --filter @toolctrl/worker health
run: pnpm --filter @hivegrid/worker health
env:
WORKER_ENABLED: "false"
@@ -282,12 +460,12 @@ jobs:
steps:
- name: Resolve internal Gitea host
run: |
if [ -n "${TOOLCTRL_GITEA_HOST_IP:-}" ]; then
if ! TOOLCTRL_GITEA_HOST_IP="$TOOLCTRL_GITEA_HOST_IP" node -e 'process.exit(require("node:net").isIP(process.env.TOOLCTRL_GITEA_HOST_IP || "") ? 0 : 1)'; then
echo "TOOLCTRL_GITEA_HOST_IP must be a single IPv4 or IPv6 token"
if [ -n "${HIVEGRID_GITEA_HOST_IP:-}" ]; then
if ! HIVEGRID_GITEA_HOST_IP="$HIVEGRID_GITEA_HOST_IP" node -e 'process.exit(require("node:net").isIP(process.env.HIVEGRID_GITEA_HOST_IP || "") ? 0 : 1)'; then
echo "HIVEGRID_GITEA_HOST_IP must be a single IPv4 or IPv6 token"
exit 1
fi
printf '%s git.tool-ctrl.de\n' "$TOOLCTRL_GITEA_HOST_IP" >> /etc/hosts
printf '%s git.tool-ctrl.de\n' "$HIVEGRID_GITEA_HOST_IP" >> /etc/hosts
fi
- name: Checkout code
@@ -316,7 +494,30 @@ jobs:
- name: Generate database client
run: pnpm run db:generate
env:
DATABASE_URL: postgresql://toolctrl:toolctrl@localhost:5432/toolctrl?schema=public
DATABASE_URL: postgresql://hivegrid:hivegrid@localhost:5432/hivegrid?schema=public
- name: Setup Docker CLI
run: |
if ! command -v docker >/dev/null 2>&1; then
if ! command -v apt-get >/dev/null 2>&1 || [ "$(id -u)" != "0" ]; then
echo "Docker CLI missing and apt-get is not available as root"
exit 1
fi
apt-get update -qq
apt-get install -qq -y --no-install-recommends ca-certificates curl
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
. /etc/os-release
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian ${VERSION_CODENAME} stable" > /etc/apt/sources.list.d/docker.list
apt-get update -qq
apt-get install -qq -y --no-install-recommends docker-ce-cli
fi
docker --version
- name: Verify migrations schema parity
run: pnpm run verify:migrations-parity
- name: Verify database migration status
if: gitea.event_name != 'pull_request'
@@ -328,22 +529,25 @@ jobs:
COOLIFY_STAGING_SSH_KEY: ${{ secrets.COOLIFY_STAGING_SSH_KEY }}
- name: Test database package
run: pnpm --filter @toolctrl/db test
run: pnpm --filter @hivegrid/db test
docker-smoke:
name: Docker Smoke
runs-on: toolctrl-ci
timeout-minutes: 30
needs: [quality, api, worker, database]
# No needs: the Docker images are built from source inside this job (no dependency on the
# quality/build jobs' outputs), so it runs fully in parallel instead of waiting on the
# ~4 min quality phase -> off the critical tail. Trade-off: if quality fails, the images
# still build (wasted compute), but the run fails overall anyway.
steps:
- name: Setup hosts override and Docker CLI
run: |
if [ -n "${TOOLCTRL_GITEA_HOST_IP:-}" ]; then
if ! TOOLCTRL_GITEA_HOST_IP="$TOOLCTRL_GITEA_HOST_IP" node -e 'process.exit(require("node:net").isIP(process.env.TOOLCTRL_GITEA_HOST_IP || "") ? 0 : 1)'; then
echo "TOOLCTRL_GITEA_HOST_IP must be a single IPv4 or IPv6 token"
if [ -n "${HIVEGRID_GITEA_HOST_IP:-}" ]; then
if ! HIVEGRID_GITEA_HOST_IP="$HIVEGRID_GITEA_HOST_IP" node -e 'process.exit(require("node:net").isIP(process.env.HIVEGRID_GITEA_HOST_IP || "") ? 0 : 1)'; then
echo "HIVEGRID_GITEA_HOST_IP must be a single IPv4 or IPv6 token"
exit 1
fi
printf '%s git.tool-ctrl.de\n' "$TOOLCTRL_GITEA_HOST_IP" >> /etc/hosts
printf '%s git.tool-ctrl.de\n' "$HIVEGRID_GITEA_HOST_IP" >> /etc/hosts
fi
if ! command -v docker >/dev/null 2>&1 || ! docker buildx version >/dev/null 2>&1 || ! docker compose version >/dev/null 2>&1; then
@@ -372,99 +576,56 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
# Layer-cache strategy: this job runs on the self-hosted toolctrl-ci runner against the
# mounted host Docker daemon, whose BuildKit keeps a persistent local layer cache between
# CI runs (proven by the fact the `docker run hivegrid-*:smoke` steps below find images the
# build loaded into the host store). The Dockerfile additionally persists the pnpm store via
# `RUN --mount=type=cache,id=hivegrid-pnpm-store`. The three targets share base/deps/build
# stages, so a single daemon dedupes them within a run. No explicit `--cache-to/--cache-from`
# (type=local/registry) is configured: it would add cold first runs, import/export overhead
# and would require `--load` for the smoke steps, for no gain over the warm host cache.
# `type=gha` is intentionally not used here — it is GitHub-hosted-runner-only.
- name: Build Docker smoke targets
shell: bash
run: |
set -euo pipefail
docker build --target web --build-arg APP_REVISION=ci-smoke-revision --tag toolctrl-web:smoke . &
docker build --target web --build-arg APP_REVISION=ci-smoke-revision --tag hivegrid-web:smoke . &
web_pid=$!
docker build --target api --build-arg APP_REVISION=ci-smoke-revision --tag toolctrl-api:smoke . &
docker build --target api --build-arg APP_REVISION=ci-smoke-revision --tag hivegrid-api:smoke . &
api_pid=$!
docker build --target worker --build-arg APP_REVISION=ci-smoke-revision --tag toolctrl-worker:smoke . &
docker build --target mcp --build-arg APP_REVISION=ci-smoke-revision --tag hivegrid-mcp:smoke . &
mcp_pid=$!
docker build --target worker --build-arg APP_REVISION=ci-smoke-revision --tag hivegrid-worker:smoke . &
worker_pid=$!
status=0
wait "$web_pid" || status=$?
wait "$api_pid" || status=$?
wait "$mcp_pid" || status=$?
wait "$worker_pid" || status=$?
exit "$status"
- name: Validate web Docker artifacts
run: docker run --rm --entrypoint /bin/sh toolctrl-web:smoke -lc 'test -f /myapp/apps/web/build/server/index.js && test -d /myapp/apps/web/build/client && test -f /myapp/build/server-runtime/server.js'
run: docker run --rm --entrypoint /bin/sh hivegrid-web:smoke -lc 'test -f /myapp/apps/web/build/server/index.js && test -d /myapp/apps/web/build/client && test -f /myapp/build/server-runtime/server.js'
- name: Validate web Docker runtime
run: bash scripts/docker-web-runtime-smoke.sh toolctrl-web:smoke
run: bash scripts/docker-web-runtime-smoke.sh hivegrid-web:smoke
env:
DOCKER_WEB_SMOKE_TRANSPORT: exec
- name: Validate API Docker runtime
run: bash scripts/docker-api-runtime-smoke.sh toolctrl-api:smoke
run: bash scripts/docker-api-runtime-smoke.sh hivegrid-api:smoke
env:
DOCKER_API_SMOKE_TRANSPORT: exec
- name: Validate MCP Docker runtime
run: bash scripts/docker-mcp-runtime-smoke.sh hivegrid-mcp:smoke
env:
DOCKER_MCP_SMOKE_TRANSPORT: exec
- name: Validate worker Docker runtime
run: bash scripts/docker-worker-runtime-smoke.sh toolctrl-worker:smoke
run: bash scripts/docker-worker-runtime-smoke.sh hivegrid-worker:smoke
- name: Validate Docker Compose config
run: docker compose config
resolve-sentry-environment:
name: Resolve Sentry environment
runs-on: toolctrl-ci
timeout-minutes: 5
needs: docker-smoke
outputs:
environment: ${{ steps.resolve.outputs.environment }}
steps:
- name: Resolve target environment from ref
id: resolve
run: |
REF="${GITEA_REF:-${{ gitea.ref }}}"
if [ "$REF" = "refs/heads/main" ]; then
echo "environment=production" >> "$GITHUB_OUTPUT"
elif [ "$REF" = "refs/heads/staging" ]; then
echo "environment=staging" >> "$GITHUB_OUTPUT"
else
echo "environment=preview" >> "$GITHUB_OUTPUT"
fi
sentry-release-api:
name: Sentry release (api)
needs: resolve-sentry-environment
uses: ./.gitea/workflows/_sentry-release.yml
with:
project: boilerplate-api
environment: ${{ needs.resolve-sentry-environment.outputs.environment }}
sourcemaps-path: none
secrets:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_URL: ${{ secrets.SENTRY_URL }}
sentry-release-worker:
name: Sentry release (worker)
needs: resolve-sentry-environment
uses: ./.gitea/workflows/_sentry-release.yml
with:
project: boilerplate-worker
environment: ${{ needs.resolve-sentry-environment.outputs.environment }}
sourcemaps-path: none
secrets:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_URL: ${{ secrets.SENTRY_URL }}
sentry-release-web:
name: Sentry release (web)
needs: resolve-sentry-environment
uses: ./.gitea/workflows/_sentry-release.yml
with:
project: boilerplate-web
environment: ${{ needs.resolve-sentry-environment.outputs.environment }}
sourcemaps-path: apps/web/build/client
sourcemaps-artifact: web-client-sourcemaps
secrets:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_URL: ${{ secrets.SENTRY_URL }}
@@ -4,7 +4,6 @@ name: Coverage
# and the per-path gate (scripts/verify-test-coverage.mjs) and upserts a
# sticky PR comment via the Gitea API.
#
# Spec: docs/improvement-plan/10-compliance-polish/test-coverage.md
# Docs: docs/architecture/test-coverage.md
on:
@@ -19,7 +18,7 @@ on:
- ".gitea/workflows/coverage.yml"
env:
TOOLCTRL_GITEA_HOST_IP: ${{ vars.TOOLCTRL_GITEA_HOST_IP }}
HIVEGRID_GITEA_HOST_IP: ${{ vars.HIVEGRID_GITEA_HOST_IP }}
jobs:
coverage:
@@ -29,12 +28,12 @@ jobs:
steps:
- name: Resolve internal Gitea host
run: |
if [ -n "${TOOLCTRL_GITEA_HOST_IP:-}" ]; then
if ! TOOLCTRL_GITEA_HOST_IP="$TOOLCTRL_GITEA_HOST_IP" node -e 'process.exit(require("node:net").isIP(process.env.TOOLCTRL_GITEA_HOST_IP || "") ? 0 : 1)'; then
echo "TOOLCTRL_GITEA_HOST_IP must be a single IPv4 or IPv6 token"
if [ -n "${HIVEGRID_GITEA_HOST_IP:-}" ]; then
if ! HIVEGRID_GITEA_HOST_IP="$HIVEGRID_GITEA_HOST_IP" node -e 'process.exit(require("node:net").isIP(process.env.HIVEGRID_GITEA_HOST_IP || "") ? 0 : 1)'; then
echo "HIVEGRID_GITEA_HOST_IP must be a single IPv4 or IPv6 token"
exit 1
fi
printf '%s git.tool-ctrl.de\n' "$TOOLCTRL_GITEA_HOST_IP" >> /etc/hosts
printf '%s git.tool-ctrl.de\n' "$HIVEGRID_GITEA_HOST_IP" >> /etc/hosts
fi
- name: Checkout PR head
@@ -46,8 +45,19 @@ jobs:
- name: Prepare pnpm 10.33.0
run: corepack prepare pnpm@10.33.0 --activate
- name: Configure pnpm store
run: pnpm config set store-dir .pnpm-store
- name: Cache pnpm store
uses: actions/cache@v4
with:
path: .pnpm-store
key: ${{ runner.os }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-
- name: Install dependencies
run: pnpm install --frozen-lockfile
run: pnpm install --frozen-lockfile --prefer-offline
- name: Run vitest with coverage (enforces per-path gates)
run: pnpm run verify:test-coverage
@@ -3,6 +3,20 @@ name: Deploy
on:
push:
branches: [main]
# Skip the staging deploy for test-/docs-only merges (e.g. coverage waves, doc updates).
# paths-ignore skips the run only when EVERY changed file matches a pattern; any app or
# config source change still deploys. Manual deploys via workflow_dispatch are unaffected.
paths-ignore:
- "**/*.test.ts"
- "**/*.test.tsx"
- "**/*.test.mjs"
- "**/*.test.js"
- "**/*.spec.ts"
- "docs/**"
- ".planning/**"
- "**/*.md"
- ".gitea/workflows/**"
- ".github/workflows/**"
workflow_dispatch:
inputs:
action:
@@ -18,10 +32,10 @@ on:
default: "false"
env:
TOOLCTRL_GITEA_HOST_IP: ${{ vars.TOOLCTRL_GITEA_HOST_IP }}
HIVEGRID_GITEA_HOST_IP: ${{ vars.HIVEGRID_GITEA_HOST_IP }}
concurrency:
group: boilerplate-deploy
group: hivegrid-deploy
cancel-in-progress: false
jobs:
@@ -32,12 +46,12 @@ jobs:
steps:
- name: Resolve internal Gitea host
run: |
if [ -n "${TOOLCTRL_GITEA_HOST_IP:-}" ]; then
if ! TOOLCTRL_GITEA_HOST_IP="$TOOLCTRL_GITEA_HOST_IP" node -e 'process.exit(require("node:net").isIP(process.env.TOOLCTRL_GITEA_HOST_IP || "") ? 0 : 1)'; then
echo "TOOLCTRL_GITEA_HOST_IP must be a single IPv4 or IPv6 token"
if [ -n "${HIVEGRID_GITEA_HOST_IP:-}" ]; then
if ! HIVEGRID_GITEA_HOST_IP="$HIVEGRID_GITEA_HOST_IP" node -e 'process.exit(require("node:net").isIP(process.env.HIVEGRID_GITEA_HOST_IP || "") ? 0 : 1)'; then
echo "HIVEGRID_GITEA_HOST_IP must be a single IPv4 or IPv6 token"
exit 1
fi
printf '%s git.tool-ctrl.de\n' "$TOOLCTRL_GITEA_HOST_IP" >> /etc/hosts
printf '%s git.tool-ctrl.de\n' "$HIVEGRID_GITEA_HOST_IP" >> /etc/hosts
fi
- name: Checkout code
@@ -120,12 +134,12 @@ jobs:
- name: Setup hosts override and Docker CLI
run: |
if [ -n "${TOOLCTRL_GITEA_HOST_IP:-}" ]; then
if ! TOOLCTRL_GITEA_HOST_IP="$TOOLCTRL_GITEA_HOST_IP" node -e 'process.exit(require("node:net").isIP(process.env.TOOLCTRL_GITEA_HOST_IP || "") ? 0 : 1)'; then
echo "TOOLCTRL_GITEA_HOST_IP must be a single IPv4 or IPv6 token"
if [ -n "${HIVEGRID_GITEA_HOST_IP:-}" ]; then
if ! HIVEGRID_GITEA_HOST_IP="$HIVEGRID_GITEA_HOST_IP" node -e 'process.exit(require("node:net").isIP(process.env.HIVEGRID_GITEA_HOST_IP || "") ? 0 : 1)'; then
echo "HIVEGRID_GITEA_HOST_IP must be a single IPv4 or IPv6 token"
exit 1
fi
printf '%s git.tool-ctrl.de\n' "$TOOLCTRL_GITEA_HOST_IP" >> /etc/hosts
printf '%s git.tool-ctrl.de\n' "$HIVEGRID_GITEA_HOST_IP" >> /etc/hosts
fi
MISSING_TOOLS=""
@@ -191,7 +205,7 @@ jobs:
git cat-file -e "$IMAGE_TAG^{commit}"
git merge-base --is-ancestor "$IMAGE_TAG" origin/main
for TARGET in web api worker; do
for TARGET in web api worker mcp; do
IMAGE="$REGISTRY/$IMAGE_NAMESPACE/$IMAGE_PREFIX/$TARGET"
SHA_DIGEST="$(docker buildx imagetools inspect "$IMAGE:$IMAGE_TAG" --format '{{json .Manifest.Digest}}')"
STAGING_DIGEST="$(docker buildx imagetools inspect "$IMAGE:staging" --format '{{json .Manifest.Digest}}')"
@@ -215,7 +229,7 @@ jobs:
cd "$GITHUB_WORKSPACE"
BUILD_PIDS=()
BUILD_TARGETS=()
for TARGET in web api worker; do
for TARGET in web api worker mcp; do
(
IMAGE="$REGISTRY/$IMAGE_NAMESPACE/$IMAGE_PREFIX/$TARGET"
CACHE_REF="$REGISTRY/$IMAGE_NAMESPACE/$IMAGE_PREFIX/build-cache:$TARGET"
@@ -229,22 +243,28 @@ jobs:
--build-arg VITE_SENTRY_ENVIRONMENT="$VITE_SENTRY_ENVIRONMENT"
--build-arg VITE_SENTRY_RELEASE="$SHA"
--build-arg VITE_OTEL_EXPORTER_OTLP_ENDPOINT="$VITE_OTEL_EXPORTER_OTLP_ENDPOINT"
--build-arg VITE_OTEL_SERVICE_NAME="boilerplate-web"
--build-arg VITE_OTEL_SERVICE_NAME="hivegrid-web"
)
fi
# `ignore-error=true` on the cache export: writing the build cache
# to the registry is an optimization, not part of producing/pushing
# the image. The Gitea registry rate-limits (HTTP 429) build-cache
# blob writes under bursts of back-to-back deploys (made worse by
# web/api/worker exporting to the same cache in parallel); without
# this flag a 429 on a cache layer fails the whole build even though
# the image itself built and pushed fine.
docker buildx build --push --target "$TARGET" --file Dockerfile \
--cache-from "type=registry,ref=$CACHE_REF" \
--cache-to "type=registry,ref=$CACHE_REF,mode=max,ignore-error=true" \
--build-arg APP_REVISION="$SHA" \
"${WEB_BUILD_ARGS[@]}" \
--tag "$IMAGE:$IMAGE_TAG" .
# The registry cache is read-only in CI. Exporting build-cache blobs
# to Gitea is optional and has repeatedly failed staging deploys with
# registry-side 499/context deadline errors after the image itself
# built successfully.
for ATTEMPT in 1 2 3; do
if docker buildx build --push --target "$TARGET" --provenance=false --file Dockerfile \
--cache-from "type=registry,ref=$CACHE_REF" \
--build-arg APP_REVISION="$SHA" \
"${WEB_BUILD_ARGS[@]}" \
--tag "$IMAGE:$IMAGE_TAG" .; then
exit 0
fi
if [ "$ATTEMPT" -lt 3 ]; then
echo "Docker build/push failed for $TARGET on attempt $ATTEMPT; retrying"
sleep $((ATTEMPT * 20))
fi
done
echo "Docker build/push failed for $TARGET after 3 attempts"
exit 1
) &
BUILD_PIDS+=("$!")
BUILD_TARGETS+=("$TARGET")
@@ -275,7 +295,7 @@ jobs:
echo "Cosign signing skipped: COSIGN_PRIVATE_KEY or COSIGN_PASSWORD not configured"
exit 0
fi
for TARGET in web api worker; do
for TARGET in web api worker mcp; do
IMAGE="$REGISTRY/$IMAGE_NAMESPACE/$IMAGE_PREFIX/$TARGET"
cosign sign --yes --key env://COSIGN_PRIVATE_KEY "$IMAGE:$IMAGE_TAG"
done
@@ -300,16 +320,19 @@ jobs:
node scripts/coolify-ensure-worker-groups.mjs staging
- name: Configure Coolify staging migration command
# Migrations and the guarded seed run inside the web container entrypoint
# (start.sh, gated by MIGRATE_ON_BOOT/SEED_ON_BOOT) because Coolify's
# pre/post_deployment_command never fired for docker-image applications.
# This step upserts the boot flags on the web resource and clears the
# dead hook slots so they are not mistaken for an active mechanism.
- name: Configure Coolify staging migration boot flags
env:
COOLIFY_STAGING_API_URL: ${{ vars.COOLIFY_STAGING_API_URL }}
COOLIFY_STAGING_RESOURCE_UUIDS: ${{ vars.COOLIFY_STAGING_RESOURCE_UUIDS }}
COOLIFY_STAGING_TOKEN: ${{ secrets.COOLIFY_STAGING_TOKEN }}
MIGRATION_COMMAND: cd /myapp && pnpm exec prisma migrate deploy --config prisma.config.ts
SEED_COMMAND: cd /myapp && SEED_DEMO_DATA=true ALLOW_STAGING_DEMO_SEED=true NODE_ENV=production node packages/db/dist/production-seed.server.js
run: |
if [ "$DEPLOY_ACTION" != "stage" ]; then
echo "Coolify migration command configuration skipped for promote"
echo "Coolify migration boot flag configuration skipped for promote"
exit 0
fi
@@ -323,6 +346,7 @@ jobs:
WEB_IMAGE="$REGISTRY/$IMAGE_NAMESPACE/$IMAGE_PREFIX/web"
API_IMAGE="$REGISTRY/$IMAGE_NAMESPACE/$IMAGE_PREFIX/api"
MCP_IMAGE="$REGISTRY/$IMAGE_NAMESPACE/$IMAGE_PREFIX/mcp"
WORKER_IMAGE="$REGISTRY/$IMAGE_NAMESPACE/$IMAGE_PREFIX/worker"
APPS_JSON="$(mktemp)"
trap 'rm -f "${APPS_JSON:-}"' EXIT
@@ -331,6 +355,7 @@ jobs:
"$SELECTED_COOLIFY_API_URL/applications" > "$APPS_JSON"
WEB_RESOURCE_UUID="$(WEB_IMAGE="$WEB_IMAGE" ALLOWED_RESOURCE_UUIDS="$RESOLVED_COOLIFY_STAGING_RESOURCE_UUIDS" node -e 'const fs = require("node:fs"); const apps = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); const allowed = new Set((process.env.ALLOWED_RESOURCE_UUIDS || "").split(",").filter(Boolean)); const app = apps.find((candidate) => candidate.docker_registry_image_name === process.env.WEB_IMAGE && allowed.has(candidate.uuid)); process.stdout.write(app?.uuid || "");' "$APPS_JSON")"
API_RESOURCE_UUID="$(API_IMAGE="$API_IMAGE" ALLOWED_RESOURCE_UUIDS="$RESOLVED_COOLIFY_STAGING_RESOURCE_UUIDS" node -e 'const fs = require("node:fs"); const apps = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); const allowed = new Set((process.env.ALLOWED_RESOURCE_UUIDS || "").split(",").filter(Boolean)); const app = apps.find((candidate) => candidate.docker_registry_image_name === process.env.API_IMAGE && allowed.has(candidate.uuid)); process.stdout.write(app?.uuid || "");' "$APPS_JSON")"
MCP_RESOURCE_UUID="$(MCP_IMAGE="$MCP_IMAGE" ALLOWED_RESOURCE_UUIDS="$RESOLVED_COOLIFY_STAGING_RESOURCE_UUIDS" node -e 'const fs = require("node:fs"); const apps = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); const allowed = new Set((process.env.ALLOWED_RESOURCE_UUIDS || "").split(",").filter(Boolean)); const app = apps.find((candidate) => candidate.docker_registry_image_name === process.env.MCP_IMAGE && allowed.has(candidate.uuid)); process.stdout.write(app?.uuid || "");' "$APPS_JSON")"
select_worker_resource() {
WORKER_QUEUE_GROUP="$1" WORKER_IMAGE="$WORKER_IMAGE" ALLOWED_RESOURCE_UUIDS="$RESOLVED_COOLIFY_STAGING_RESOURCE_UUIDS" node -e 'const fs = require("node:fs"); const apps = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); const allowed = new Set((process.env.ALLOWED_RESOURCE_UUIDS || "").split(",").filter(Boolean)); const group = process.env.WORKER_QUEUE_GROUP || ""; const envPattern = new RegExp(`WORKER_QUEUE_GROUP\\s*(?:=|:)\\s*["'\''"]?${group}\\b`, "i"); const namePattern = new RegExp(`(?:^|[\\s/_-])worker[\\s_-]*${group}(?:$|[\\s:_-])`, "i"); const app = apps.find((candidate) => { const text = [candidate.name, candidate.description, candidate.docker_registry_image_name, candidate.fqdn, candidate.domain, candidate.domains, JSON.stringify(candidate.environment_variables || candidate.envs || candidate.environment || "")].filter(Boolean).join(" "); return candidate.docker_registry_image_name === process.env.WORKER_IMAGE && allowed.has(candidate.uuid) && (envPattern.test(text) || namePattern.test(text)); }); process.stdout.write(app?.uuid || "");' "$APPS_JSON"
}
@@ -340,19 +365,20 @@ jobs:
WORKER_WEBHOOK_RESOURCE_UUID="$(select_worker_resource webhook)"
WORKER_PLUGIN_RESOURCE_UUID="$(select_worker_resource plugin)"
WORKER_RESOURCE_UUIDS="$WORKER_GENERAL_RESOURCE_UUID,$WORKER_MAIL_RESOURCE_UUID,$WORKER_IMPORT_RESOURCE_UUID,$WORKER_WEBHOOK_RESOURCE_UUID,$WORKER_PLUGIN_RESOURCE_UUID"
if [ -z "$WEB_RESOURCE_UUID" ] || [ -z "$API_RESOURCE_UUID" ] || [ -z "$WORKER_GENERAL_RESOURCE_UUID" ] || [ -z "$WORKER_MAIL_RESOURCE_UUID" ] || [ -z "$WORKER_IMPORT_RESOURCE_UUID" ] || [ -z "$WORKER_WEBHOOK_RESOURCE_UUID" ] || [ -z "$WORKER_PLUGIN_RESOURCE_UUID" ]; then
echo "Coolify staging web, api, worker-general, worker-mail, worker-import, worker-webhook, and worker-plugin applications must all exist"
if [ -z "$WEB_RESOURCE_UUID" ] || [ -z "$API_RESOURCE_UUID" ] || [ -z "$MCP_RESOURCE_UUID" ] || [ -z "$WORKER_GENERAL_RESOURCE_UUID" ] || [ -z "$WORKER_MAIL_RESOURCE_UUID" ] || [ -z "$WORKER_IMPORT_RESOURCE_UUID" ] || [ -z "$WORKER_WEBHOOK_RESOURCE_UUID" ] || [ -z "$WORKER_PLUGIN_RESOURCE_UUID" ]; then
echo "Coolify staging web, api, mcp, worker-general, worker-mail, worker-import, worker-webhook, and worker-plugin applications must all exist"
exit 1
fi
for REQUIRED_RESOURCE_UUID in "$WEB_RESOURCE_UUID" "$API_RESOURCE_UUID" "$WORKER_GENERAL_RESOURCE_UUID" "$WORKER_MAIL_RESOURCE_UUID" "$WORKER_IMPORT_RESOURCE_UUID" "$WORKER_WEBHOOK_RESOURCE_UUID" "$WORKER_PLUGIN_RESOURCE_UUID"; do
for REQUIRED_RESOURCE_UUID in "$WEB_RESOURCE_UUID" "$API_RESOURCE_UUID" "$MCP_RESOURCE_UUID" "$WORKER_GENERAL_RESOURCE_UUID" "$WORKER_MAIL_RESOURCE_UUID" "$WORKER_IMPORT_RESOURCE_UUID" "$WORKER_WEBHOOK_RESOURCE_UUID" "$WORKER_PLUGIN_RESOURCE_UUID"; do
if ! printf ",%s," "$RESOLVED_COOLIFY_STAGING_RESOURCE_UUIDS" | grep -Fq ",$REQUIRED_RESOURCE_UUID,"; then
echo "Coolify staging deploy resources must include the web, api, worker-general, worker-mail, worker-import, worker-webhook, and worker-plugin applications"
echo "Coolify staging deploy resources must include the web, api, mcp, worker-general, worker-mail, worker-import, worker-webhook, and worker-plugin applications"
exit 1
fi
done
DEPENDENT_RESOURCE_UUIDS="$API_RESOURCE_UUID,$WORKER_RESOURCE_UUIDS"
DEPENDENT_RESOURCE_UUIDS="$API_RESOURCE_UUID,$MCP_RESOURCE_UUID,$WORKER_RESOURCE_UUIDS"
{
echo "STAGING_WEB_RESOURCE_UUID=$WEB_RESOURCE_UUID"
echo "STAGING_MCP_RESOURCE_UUID=$MCP_RESOURCE_UUID"
echo "STAGING_WORKER_RESOURCE_UUIDS=$WORKER_RESOURCE_UUIDS"
echo "STAGING_WORKER_GENERAL_RESOURCE_UUID=$WORKER_GENERAL_RESOURCE_UUID"
echo "STAGING_WORKER_MAIL_RESOURCE_UUID=$WORKER_MAIL_RESOURCE_UUID"
@@ -362,14 +388,19 @@ jobs:
echo "STAGING_DEPENDENT_RESOURCE_UUIDS=$DEPENDENT_RESOURCE_UUIDS"
} >> "$GITHUB_ENV"
MIGRATION_PAYLOAD="$(node -e 'process.stdout.write(JSON.stringify({ pre_deployment_command: process.env.MIGRATION_COMMAND, post_deployment_command: process.env.SEED_COMMAND, docker_registry_image_tag: process.env.IMAGE_TAG }))')"
MIGRATION_PAYLOAD="$(node -e 'process.stdout.write(JSON.stringify({ pre_deployment_command: "", post_deployment_command: "", docker_registry_image_tag: process.env.IMAGE_TAG }))')"
curl --fail --silent --show-error --request PATCH \
--header "Authorization: Bearer $SELECTED_COOLIFY_TOKEN" \
--header "Content-Type: application/json" \
--data "$MIGRATION_PAYLOAD" \
"$SELECTED_COOLIFY_API_URL/applications/$WEB_RESOURCE_UUID" >/dev/null
node scripts/coolify-deploy.mjs set-envs "$WEB_RESOURCE_UUID" \
MIGRATE_ON_BOOT=true \
SEED_ON_BOOT=true \
SEED_DEMO_DATA=true \
ALLOW_STAGING_DEMO_SEED=true
IMAGE_TAG_PAYLOAD="$(node -e 'process.stdout.write(JSON.stringify({ docker_registry_image_tag: process.env.IMAGE_TAG }))')"
for RESOURCE_UUID in "$API_RESOURCE_UUID" "$WORKER_GENERAL_RESOURCE_UUID" "$WORKER_MAIL_RESOURCE_UUID" "$WORKER_IMPORT_RESOURCE_UUID" "$WORKER_WEBHOOK_RESOURCE_UUID" "$WORKER_PLUGIN_RESOURCE_UUID"; do
for RESOURCE_UUID in "$API_RESOURCE_UUID" "$MCP_RESOURCE_UUID" "$WORKER_GENERAL_RESOURCE_UUID" "$WORKER_MAIL_RESOURCE_UUID" "$WORKER_IMPORT_RESOURCE_UUID" "$WORKER_WEBHOOK_RESOURCE_UUID" "$WORKER_PLUGIN_RESOURCE_UUID"; do
curl --fail --silent --show-error --request PATCH \
--header "Authorization: Bearer $SELECTED_COOLIFY_TOKEN" \
--header "Content-Type: application/json" \
@@ -384,26 +415,11 @@ jobs:
exit 0
fi
for TARGET in web api worker; do
for TARGET in web api worker mcp; do
IMAGE="$REGISTRY/$IMAGE_NAMESPACE/$IMAGE_PREFIX/$TARGET"
docker buildx imagetools create --tag "$IMAGE:staging" "$IMAGE:$IMAGE_TAG"
done
- name: Pre-pull staging images on Coolify host
env:
COOLIFY_STAGING_SSH_HOST: ${{ vars.COOLIFY_STAGING_SSH_HOST }}
COOLIFY_STAGING_SSH_USER: ${{ vars.COOLIFY_STAGING_SSH_USER }}
COOLIFY_STAGING_SSH_KEY: ${{ secrets.COOLIFY_STAGING_SSH_KEY }}
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_PAT: ${{ secrets.REGISTRY_PAT }}
run: |
if [ "$DEPLOY_ACTION" != "stage" ]; then
echo "Staging image pre-pull skipped for promote"
exit 0
fi
timeout 240s scripts/coolify-prepull-images.sh || echo "Staging image pre-pull skipped after timeout; Coolify will pull during deploy"
- name: Trigger Coolify staging deploy
env:
COOLIFY_STAGING_API_URL: ${{ vars.COOLIFY_STAGING_API_URL }}
@@ -457,7 +473,7 @@ jobs:
EXPECTED_IMAGE_ALIAS="${4:-staging}"
EXPECTED_SERVICE="${5:-}"
HEALTH_URL="$(node -e 'const fs = require("node:fs"); const app = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); const raw = app.fqdn || app.domain || app.domains || ""; const value = Array.isArray(raw) ? raw[0] : String(raw).split(/[,\s]+/).find(Boolean); process.stdout.write(value ? value.replace(/\/+$/, "") : "");' "$APP_JSON")"
HTTP_RESOURCE="$(node -e 'const fs = require("node:fs"); const app = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); const value = [app.docker_registry_image_name, app.name, app.description].filter(Boolean).join(" "); process.stdout.write(/(?:^|[\\/\\s_-])(?:web|api)(?:$|[\\s:_-])/i.test(value) ? "true" : "false");' "$APP_JSON")"
HTTP_RESOURCE="$(node -e 'const fs = require("node:fs"); const app = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); const value = [app.docker_registry_image_name, app.name, app.description].filter(Boolean).join(" "); process.stdout.write(/(?:^|[\\/\\s_-])(?:web|api|mcp)(?:$|[\\s:_-])/i.test(value) ? "true" : "false");' "$APP_JSON")"
if [ -z "$HEALTH_URL" ]; then
if [ "$REQUIRE_HEALTH_URL" = "required" ] || [ "$HTTP_RESOURCE" = "true" ]; then
echo "$LABEL is an HTTP resource but has no health URL"
@@ -555,9 +571,6 @@ jobs:
env:
COOLIFY_STAGING_API_URL: ${{ vars.COOLIFY_STAGING_API_URL }}
COOLIFY_STAGING_TOKEN: ${{ secrets.COOLIFY_STAGING_TOKEN }}
COOLIFY_STAGING_SSH_HOST: ${{ vars.COOLIFY_STAGING_SSH_HOST }}
COOLIFY_STAGING_SSH_USER: ${{ vars.COOLIFY_STAGING_SSH_USER }}
COOLIFY_STAGING_SSH_KEY: ${{ secrets.COOLIFY_STAGING_SSH_KEY }}
run: |
if [ "$DEPLOY_ACTION" != "stage" ]; then
echo "Staging dependent health wait skipped for promote"
@@ -580,35 +593,6 @@ jobs:
exit 1
fi
# SSH helper for worker health verification (Coolify has no exec REST API);
# used by verify_live_revision to run the health probe via docker exec.
if [ -z "$COOLIFY_STAGING_SSH_HOST" ] || [ -z "$COOLIFY_STAGING_SSH_KEY" ]; then
echo "Coolify staging SSH configuration is incomplete (worker health verification requires SSH + docker exec)"
exit 1
fi
REMOTE_SSH_KEY_FILE="$(mktemp)"
REMOTE_SSH_KNOWN_HOSTS="$(mktemp)"
trap 'rm -f "$REMOTE_SSH_KEY_FILE" "$REMOTE_SSH_KNOWN_HOSTS"' EXIT
# Materialise the key via an intermediate var so the secret is written to
# a file (never stdout/logs); avoids leaking it in CI output.
REMOTE_SSH_KEY_DATA="$COOLIFY_STAGING_SSH_KEY"
printf '%s\n' "$REMOTE_SSH_KEY_DATA" > "$REMOTE_SSH_KEY_FILE"
unset REMOTE_SSH_KEY_DATA
chmod 600 "$REMOTE_SSH_KEY_FILE"
REMOTE_SSH_USER="${COOLIFY_STAGING_SSH_USER:-root}"
REMOTE_SSH_HOST="$COOLIFY_STAGING_SSH_HOST"
if command -v ssh-keyscan >/dev/null 2>&1; then
ssh-keyscan -H "$REMOTE_SSH_HOST" > "$REMOTE_SSH_KNOWN_HOSTS" 2>/dev/null || true
fi
if [ -s "$REMOTE_SSH_KNOWN_HOSTS" ]; then
REMOTE_SSH_HOSTKEY_OPT="-o StrictHostKeyChecking=yes"
else
REMOTE_SSH_HOSTKEY_OPT="-o StrictHostKeyChecking=accept-new"
fi
remote_ssh() {
ssh -i "$REMOTE_SSH_KEY_FILE" -o BatchMode=yes -o ConnectTimeout=10 -o UserKnownHostsFile="$REMOTE_SSH_KNOWN_HOSTS" $REMOTE_SSH_HOSTKEY_OPT "${REMOTE_SSH_USER}@${REMOTE_SSH_HOST}" "$@"
}
DEPLOYMENT_INDEX=0
DEPLOYMENT_WAIT_PIDS=""
for DEPLOYMENT_UUID in $(printf "%s" "$STAGING_DEPENDENT_DEPLOYMENT_UUIDS" | tr ',' ' '); do
@@ -636,7 +620,7 @@ jobs:
RESOURCE_KIND="${6:-auto}"
EXPECTED_WORKER_GROUP="${7:-}"
HEALTH_URL="$(node -e 'const fs = require("node:fs"); const app = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); const raw = app.fqdn || app.domain || app.domains || ""; const value = Array.isArray(raw) ? raw[0] : String(raw).split(/[,\s]+/).find(Boolean); process.stdout.write(value ? value.replace(/\/+$/, "") : "");' "$APP_JSON")"
HTTP_RESOURCE="$(node -e 'const fs = require("node:fs"); const app = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); const value = [app.docker_registry_image_name, app.name, app.description].filter(Boolean).join(" "); process.stdout.write(/(?:^|[\\/\\s_-])(?:web|api)(?:$|[\\s:_-])/i.test(value) ? "true" : "false");' "$APP_JSON")"
HTTP_RESOURCE="$(node -e 'const fs = require("node:fs"); const app = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); const value = [app.docker_registry_image_name, app.name, app.description].filter(Boolean).join(" "); process.stdout.write(/(?:^|[\\/\\s_-])(?:web|api|mcp)(?:$|[\\s:_-])/i.test(value) ? "true" : "false");' "$APP_JSON")"
WORKER_RESOURCE="$(node -e 'const fs = require("node:fs"); const app = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); const value = [app.docker_registry_image_name, app.name, app.description].filter(Boolean).join(" "); process.stdout.write(/(?:^|[\\/\\s_-])worker(?:$|[\\s:_-])/i.test(value) ? "true" : "false");' "$APP_JSON")"
if [ "$RESOURCE_KIND" = "worker" ] || [ "$WORKER_RESOURCE" = "true" ]; then
CONFIGURED_IMAGE_TAG="$(node -e 'const fs = require("node:fs"); const app = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); process.stdout.write(app.docker_registry_image_tag || app.image_tag || app.tag || "");' "$APP_JSON")"
@@ -644,74 +628,7 @@ jobs:
echo "$LABEL worker image tag does not match expected alias"
exit 1
fi
# Coolify 4.x exposes no command-execution REST API, so verify worker
# health by running the one-shot health probe inside the live container
# over SSH (docker exec) on the Coolify host. Fail-closed: any SSH /
# container-resolution / exec failure blocks the deploy.
WORKER_CONTAINER="$(remote_ssh "docker ps --filter name=$RESOURCE_UUID --filter status=running --format '{{.Names}}' | head -n1" 2>/dev/null | tr -d '\r')"
if [ -z "$WORKER_CONTAINER" ]; then
echo "$LABEL worker container not found on Coolify host"
exit 1
fi
# health.js exits non-zero when the worker reports a non-ready
# status (e.g. "misconfigured"), yet it still prints the diagnostic
# JSON (status + errors) to stdout. Capture stdout regardless of the
# exit code (|| true) so the real reason surfaces in the checks below
# instead of a generic "command unavailable". Empty output means the
# docker exec itself could not run (container gone, SSH failure, ...).
WORKER_HEALTH_OUTPUT="$(remote_ssh "docker exec $WORKER_CONTAINER node apps/worker/dist/health.js" 2>/dev/null | tr -d '\r' || true)"
if [ -z "$WORKER_HEALTH_OUTPUT" ]; then
echo "$LABEL worker docker exec health command unavailable (no output from health probe)"
exit 1
fi
WORKER_HEALTH_JSON="$(mktemp)"
printf "%s" "$WORKER_HEALTH_OUTPUT" > "$WORKER_HEALTH_JSON"
if ! WORKER_REVISION="$(node -e 'const fs = require("node:fs"); const payload = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); process.stdout.write(payload.revision || "");' "$WORKER_HEALTH_JSON" 2>/dev/null)"; then
rm -f "$WORKER_HEALTH_JSON"
echo "$LABEL worker execute output is not JSON"
exit 1
fi
WORKER_STATUS="$(node -e 'const fs = require("node:fs"); const payload = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); process.stdout.write(payload.status || "");' "$WORKER_HEALTH_JSON")"
WORKER_ENABLED="$(node -e 'const fs = require("node:fs"); const payload = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); process.stdout.write(payload.workerEnabled === true ? "true" : "false");' "$WORKER_HEALTH_JSON")"
WORKER_GROUP="$(node -e 'const fs = require("node:fs"); const payload = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); process.stdout.write(payload.workerGroup || "");' "$WORKER_HEALTH_JSON")"
WORKER_QUEUES="$(node -e 'const fs = require("node:fs"); const payload = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); process.stdout.write(Array.isArray(payload.queues) ? payload.queues.join(",") : "");' "$WORKER_HEALTH_JSON")"
WORKER_HEALTH_ERRORS="$(node -e 'const fs = require("node:fs"); const payload = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); process.stdout.write(Array.isArray(payload.errors) ? payload.errors.join("; ") : "");' "$WORKER_HEALTH_JSON")"
rm -f "$WORKER_HEALTH_JSON"
if [ "$WORKER_REVISION" != "$IMAGE_TAG" ]; then
echo "$LABEL worker health revision does not match image tag yet"
return 1
fi
if [ "$WORKER_ENABLED" != "true" ]; then
echo "$LABEL worker health reports workerEnabled=false"
exit 1
fi
if [ "$WORKER_STATUS" != "ready" ]; then
echo "$LABEL worker health status is not ready: '$WORKER_STATUS'${WORKER_HEALTH_ERRORS:+ (errors: $WORKER_HEALTH_ERRORS)}"
exit 1
fi
if [ -n "$EXPECTED_WORKER_GROUP" ] && [ "$WORKER_GROUP" != "$EXPECTED_WORKER_GROUP" ]; then
echo "$LABEL worker group does not match expected queue group"
exit 1
fi
EXPECTED_WORKER_QUEUES=""
case "$EXPECTED_WORKER_GROUP" in
general) EXPECTED_WORKER_QUEUES="toolctrl.notifications toolctrl.maintenance toolctrl.file-scan" ;;
mail) EXPECTED_WORKER_QUEUES="toolctrl.email" ;;
import)
if [ -n "$WORKER_QUEUES" ]; then
EXPECTED_WORKER_QUEUES="toolctrl.imports toolctrl.exports"
fi
;;
webhook) EXPECTED_WORKER_QUEUES="toolctrl.ai-chatbot toolctrl.browser-automation" ;;
plugin) EXPECTED_WORKER_QUEUES="toolctrl.plugin-invoke" ;;
esac
for EXPECTED_WORKER_QUEUE in $EXPECTED_WORKER_QUEUES; do
if ! printf ",%s," "$WORKER_QUEUES" | grep -Fq ",$EXPECTED_WORKER_QUEUE,"; then
echo "$LABEL worker queues do not match expected queue group"
exit 1
fi
done
echo "$LABEL worker live revision matches image tag"
echo "$LABEL worker Coolify status is healthy and image tag matches expected alias"
return
fi
if [ -z "$HEALTH_URL" ]; then
@@ -761,6 +678,10 @@ jobs:
RESOURCE_KIND="api"
EXPECTED_SERVICE="api"
EXPECTED_WORKER_GROUP=""
if [ "$RESOURCE_UUID" = "$STAGING_MCP_RESOURCE_UUID" ]; then
RESOURCE_KIND="mcp"
EXPECTED_SERVICE="mcp"
fi
if printf ",%s," "$STAGING_WORKER_RESOURCE_UUIDS" | grep -Fq ",$RESOURCE_UUID,"; then
RESOURCE_KIND="worker"
EXPECTED_SERVICE=""
@@ -829,16 +750,17 @@ jobs:
node scripts/coolify-ensure-worker-groups.mjs production
- name: Configure Coolify production migration command
# Same entrypoint-based migration mechanism as staging: boot flags on the
# web resource, dead Coolify hook slots cleared. Production runs the
# ungated seed (no demo flags) inside the new container after migrate.
- name: Configure Coolify production migration boot flags
env:
COOLIFY_API_URL: ${{ vars.COOLIFY_API_URL }}
COOLIFY_RESOURCE_UUIDS: ${{ vars.COOLIFY_RESOURCE_UUIDS }}
COOLIFY_TOKEN: ${{ secrets.COOLIFY_TOKEN }}
MIGRATION_COMMAND: cd /myapp && pnpm exec prisma migrate deploy --config prisma.config.ts
SEED_COMMAND: cd /myapp && NODE_ENV=production node packages/db/dist/production-seed.server.js
run: |
if [ "$DEPLOY_ACTION" != "promote" ]; then
echo "Coolify production migration command configuration skipped for staging"
echo "Coolify production migration boot flag configuration skipped for staging"
exit 0
fi
@@ -850,6 +772,7 @@ jobs:
WEB_IMAGE="$REGISTRY/$IMAGE_NAMESPACE/$IMAGE_PREFIX/web"
API_IMAGE="$REGISTRY/$IMAGE_NAMESPACE/$IMAGE_PREFIX/api"
MCP_IMAGE="$REGISTRY/$IMAGE_NAMESPACE/$IMAGE_PREFIX/mcp"
WORKER_IMAGE="$REGISTRY/$IMAGE_NAMESPACE/$IMAGE_PREFIX/worker"
APPS_JSON="$(mktemp)"
trap 'rm -f "${APPS_JSON:-}"' EXIT
@@ -858,6 +781,7 @@ jobs:
"$COOLIFY_API_URL/applications" > "$APPS_JSON"
WEB_RESOURCE_UUID="$(WEB_IMAGE="$WEB_IMAGE" ALLOWED_RESOURCE_UUIDS="$RESOLVED_COOLIFY_RESOURCE_UUIDS" node -e 'const fs = require("node:fs"); const apps = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); const allowed = new Set((process.env.ALLOWED_RESOURCE_UUIDS || "").split(",").filter(Boolean)); const app = apps.find((candidate) => candidate.docker_registry_image_name === process.env.WEB_IMAGE && allowed.has(candidate.uuid)); process.stdout.write(app?.uuid || "");' "$APPS_JSON")"
API_RESOURCE_UUID="$(API_IMAGE="$API_IMAGE" ALLOWED_RESOURCE_UUIDS="$RESOLVED_COOLIFY_RESOURCE_UUIDS" node -e 'const fs = require("node:fs"); const apps = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); const allowed = new Set((process.env.ALLOWED_RESOURCE_UUIDS || "").split(",").filter(Boolean)); const app = apps.find((candidate) => candidate.docker_registry_image_name === process.env.API_IMAGE && allowed.has(candidate.uuid)); process.stdout.write(app?.uuid || "");' "$APPS_JSON")"
MCP_RESOURCE_UUID="$(MCP_IMAGE="$MCP_IMAGE" ALLOWED_RESOURCE_UUIDS="$RESOLVED_COOLIFY_RESOURCE_UUIDS" node -e 'const fs = require("node:fs"); const apps = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); const allowed = new Set((process.env.ALLOWED_RESOURCE_UUIDS || "").split(",").filter(Boolean)); const app = apps.find((candidate) => candidate.docker_registry_image_name === process.env.MCP_IMAGE && allowed.has(candidate.uuid)); process.stdout.write(app?.uuid || "");' "$APPS_JSON")"
select_worker_resource() {
WORKER_QUEUE_GROUP="$1" WORKER_IMAGE="$WORKER_IMAGE" ALLOWED_RESOURCE_UUIDS="$RESOLVED_COOLIFY_RESOURCE_UUIDS" node -e 'const fs = require("node:fs"); const apps = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); const allowed = new Set((process.env.ALLOWED_RESOURCE_UUIDS || "").split(",").filter(Boolean)); const group = process.env.WORKER_QUEUE_GROUP || ""; const envPattern = new RegExp(`WORKER_QUEUE_GROUP\\s*(?:=|:)\\s*["'\''"]?${group}\\b`, "i"); const namePattern = new RegExp(`(?:^|[\\s/_-])worker[\\s_-]*${group}(?:$|[\\s:_-])`, "i"); const app = apps.find((candidate) => { const text = [candidate.name, candidate.description, candidate.docker_registry_image_name, candidate.fqdn, candidate.domain, candidate.domains, JSON.stringify(candidate.environment_variables || candidate.envs || candidate.environment || "")].filter(Boolean).join(" "); return candidate.docker_registry_image_name === process.env.WORKER_IMAGE && allowed.has(candidate.uuid) && (envPattern.test(text) || namePattern.test(text)); }); process.stdout.write(app?.uuid || "");' "$APPS_JSON"
}
@@ -867,19 +791,20 @@ jobs:
WORKER_WEBHOOK_RESOURCE_UUID="$(select_worker_resource webhook)"
WORKER_PLUGIN_RESOURCE_UUID="$(select_worker_resource plugin)"
WORKER_RESOURCE_UUIDS="$WORKER_GENERAL_RESOURCE_UUID,$WORKER_MAIL_RESOURCE_UUID,$WORKER_IMPORT_RESOURCE_UUID,$WORKER_WEBHOOK_RESOURCE_UUID,$WORKER_PLUGIN_RESOURCE_UUID"
if [ -z "$WEB_RESOURCE_UUID" ] || [ -z "$API_RESOURCE_UUID" ] || [ -z "$WORKER_GENERAL_RESOURCE_UUID" ] || [ -z "$WORKER_MAIL_RESOURCE_UUID" ] || [ -z "$WORKER_IMPORT_RESOURCE_UUID" ] || [ -z "$WORKER_WEBHOOK_RESOURCE_UUID" ] || [ -z "$WORKER_PLUGIN_RESOURCE_UUID" ]; then
echo "Coolify production web, api, worker-general, worker-mail, worker-import, worker-webhook, and worker-plugin applications must all exist"
if [ -z "$WEB_RESOURCE_UUID" ] || [ -z "$API_RESOURCE_UUID" ] || [ -z "$MCP_RESOURCE_UUID" ] || [ -z "$WORKER_GENERAL_RESOURCE_UUID" ] || [ -z "$WORKER_MAIL_RESOURCE_UUID" ] || [ -z "$WORKER_IMPORT_RESOURCE_UUID" ] || [ -z "$WORKER_WEBHOOK_RESOURCE_UUID" ] || [ -z "$WORKER_PLUGIN_RESOURCE_UUID" ]; then
echo "Coolify production web, api, mcp, worker-general, worker-mail, worker-import, worker-webhook, and worker-plugin applications must all exist"
exit 1
fi
for REQUIRED_RESOURCE_UUID in "$WEB_RESOURCE_UUID" "$API_RESOURCE_UUID" "$WORKER_GENERAL_RESOURCE_UUID" "$WORKER_MAIL_RESOURCE_UUID" "$WORKER_IMPORT_RESOURCE_UUID" "$WORKER_WEBHOOK_RESOURCE_UUID" "$WORKER_PLUGIN_RESOURCE_UUID"; do
for REQUIRED_RESOURCE_UUID in "$WEB_RESOURCE_UUID" "$API_RESOURCE_UUID" "$MCP_RESOURCE_UUID" "$WORKER_GENERAL_RESOURCE_UUID" "$WORKER_MAIL_RESOURCE_UUID" "$WORKER_IMPORT_RESOURCE_UUID" "$WORKER_WEBHOOK_RESOURCE_UUID" "$WORKER_PLUGIN_RESOURCE_UUID"; do
if ! printf ",%s," "$RESOLVED_COOLIFY_RESOURCE_UUIDS" | grep -Fq ",$REQUIRED_RESOURCE_UUID,"; then
echo "Coolify production deploy resources must include the web, api, worker-general, worker-mail, worker-import, worker-webhook, and worker-plugin applications"
echo "Coolify production deploy resources must include the web, api, mcp, worker-general, worker-mail, worker-import, worker-webhook, and worker-plugin applications"
exit 1
fi
done
DEPENDENT_RESOURCE_UUIDS="$API_RESOURCE_UUID,$WORKER_RESOURCE_UUIDS"
DEPENDENT_RESOURCE_UUIDS="$API_RESOURCE_UUID,$MCP_RESOURCE_UUID,$WORKER_RESOURCE_UUIDS"
{
echo "PRODUCTION_WEB_RESOURCE_UUID=$WEB_RESOURCE_UUID"
echo "PRODUCTION_MCP_RESOURCE_UUID=$MCP_RESOURCE_UUID"
echo "PRODUCTION_WORKER_RESOURCE_UUIDS=$WORKER_RESOURCE_UUIDS"
echo "PRODUCTION_WORKER_GENERAL_RESOURCE_UUID=$WORKER_GENERAL_RESOURCE_UUID"
echo "PRODUCTION_WORKER_MAIL_RESOURCE_UUID=$WORKER_MAIL_RESOURCE_UUID"
@@ -889,14 +814,17 @@ jobs:
echo "PRODUCTION_DEPENDENT_RESOURCE_UUIDS=$DEPENDENT_RESOURCE_UUIDS"
} >> "$GITHUB_ENV"
MIGRATION_PAYLOAD="$(node -e 'process.stdout.write(JSON.stringify({ pre_deployment_command: process.env.MIGRATION_COMMAND, post_deployment_command: process.env.SEED_COMMAND, docker_registry_image_tag: process.env.IMAGE_TAG }))')"
MIGRATION_PAYLOAD="$(node -e 'process.stdout.write(JSON.stringify({ pre_deployment_command: "", post_deployment_command: "", docker_registry_image_tag: process.env.IMAGE_TAG }))')"
curl --fail --silent --show-error --request PATCH \
--header "Authorization: Bearer $COOLIFY_TOKEN" \
--header "Content-Type: application/json" \
--data "$MIGRATION_PAYLOAD" \
"$COOLIFY_API_URL/applications/$WEB_RESOURCE_UUID" >/dev/null
node scripts/coolify-deploy.mjs set-envs "$WEB_RESOURCE_UUID" \
MIGRATE_ON_BOOT=true \
SEED_ON_BOOT=true
IMAGE_TAG_PAYLOAD="$(node -e 'process.stdout.write(JSON.stringify({ docker_registry_image_tag: process.env.IMAGE_TAG }))')"
for RESOURCE_UUID in "$API_RESOURCE_UUID" "$WORKER_GENERAL_RESOURCE_UUID" "$WORKER_MAIL_RESOURCE_UUID" "$WORKER_IMPORT_RESOURCE_UUID" "$WORKER_WEBHOOK_RESOURCE_UUID" "$WORKER_PLUGIN_RESOURCE_UUID"; do
for RESOURCE_UUID in "$API_RESOURCE_UUID" "$MCP_RESOURCE_UUID" "$WORKER_GENERAL_RESOURCE_UUID" "$WORKER_MAIL_RESOURCE_UUID" "$WORKER_IMPORT_RESOURCE_UUID" "$WORKER_WEBHOOK_RESOURCE_UUID" "$WORKER_PLUGIN_RESOURCE_UUID"; do
curl --fail --silent --show-error --request PATCH \
--header "Authorization: Bearer $COOLIFY_TOKEN" \
--header "Content-Type: application/json" \
@@ -911,7 +839,7 @@ jobs:
exit 0
fi
for TARGET in web api worker; do
for TARGET in web api worker mcp; do
IMAGE="$REGISTRY/$IMAGE_NAMESPACE/$IMAGE_PREFIX/$TARGET"
docker buildx imagetools create --tag "$IMAGE:main" "$IMAGE:$IMAGE_TAG"
done
@@ -968,7 +896,7 @@ jobs:
EXPECTED_IMAGE_ALIAS="${4:-main}"
EXPECTED_SERVICE="${5:-}"
HEALTH_URL="$(node -e 'const fs = require("node:fs"); const app = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); const raw = app.fqdn || app.domain || app.domains || ""; const value = Array.isArray(raw) ? raw[0] : String(raw).split(/[,\s]+/).find(Boolean); process.stdout.write(value ? value.replace(/\/+$/, "") : "");' "$APP_JSON")"
HTTP_RESOURCE="$(node -e 'const fs = require("node:fs"); const app = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); const value = [app.docker_registry_image_name, app.name, app.description].filter(Boolean).join(" "); process.stdout.write(/(?:^|[\\/\\s_-])(?:web|api)(?:$|[\\s:_-])/i.test(value) ? "true" : "false");' "$APP_JSON")"
HTTP_RESOURCE="$(node -e 'const fs = require("node:fs"); const app = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); const value = [app.docker_registry_image_name, app.name, app.description].filter(Boolean).join(" "); process.stdout.write(/(?:^|[\\/\\s_-])(?:web|api|mcp)(?:$|[\\s:_-])/i.test(value) ? "true" : "false");' "$APP_JSON")"
if [ -z "$HEALTH_URL" ]; then
if [ "$REQUIRE_HEALTH_URL" = "required" ] || [ "$HTTP_RESOURCE" = "true" ]; then
echo "$LABEL is an HTTP resource but has no health URL"
@@ -1118,12 +1046,6 @@ jobs:
env:
COOLIFY_API_URL: ${{ vars.COOLIFY_API_URL }}
COOLIFY_TOKEN: ${{ secrets.COOLIFY_TOKEN }}
# AD-24: Prod ist Multi-Server. Worker laufen auf der worker-VM (privates Netz), nur ueber
# die control-Bastion erreichbar -> Worker-Health-Verifikation (docker exec) per SSH-ProxyJump.
COOLIFY_PROD_SSH_KEY: ${{ secrets.COOLIFY_PROD_SSH_KEY }}
COOLIFY_PROD_SSH_KNOWN_HOSTS: ${{ secrets.COOLIFY_PROD_SSH_KNOWN_HOSTS }}
COOLIFY_PROD_SSH_HOST: ${{ vars.COOLIFY_PROD_SSH_HOST }}
COOLIFY_PROD_WORKER_PRIVATE_IP: ${{ vars.COOLIFY_PROD_WORKER_PRIVATE_IP }}
run: |
if [ "$DEPLOY_ACTION" != "promote" ]; then
echo "Production dependent health wait skipped for staging"
@@ -1164,32 +1086,6 @@ jobs:
exit 1
fi
# SSH helper for worker health verification (Coolify has no exec REST API). AD-24: Prod ist
# Multi-Server -> Worker laufen auf der worker-VM (privates Netz), erreichbar nur ueber die
# control-Bastion. ProxyJump + gepinnte Host-Keys (kein StrictHostKeyChecking=no). Secrets
# werden via Zwischen-Variable in Dateien geschrieben (nie stdout/Logs).
if [ -z "$COOLIFY_PROD_SSH_HOST" ] || [ -z "$COOLIFY_PROD_SSH_KEY" ] || [ -z "$COOLIFY_PROD_SSH_KNOWN_HOSTS" ] || [ -z "$COOLIFY_PROD_WORKER_PRIVATE_IP" ]; then
echo "Coolify production SSH configuration is incomplete (worker health verification requires SSH + docker exec)"
exit 1
fi
REMOTE_SSH_KEY_FILE="$(mktemp)"
REMOTE_SSH_KNOWN_HOSTS="$(mktemp)"
trap 'rm -f "$REMOTE_SSH_KEY_FILE" "$REMOTE_SSH_KNOWN_HOSTS"' EXIT
REMOTE_SSH_KEY_DATA="$COOLIFY_PROD_SSH_KEY"
printf '%s\n' "$REMOTE_SSH_KEY_DATA" > "$REMOTE_SSH_KEY_FILE"
unset REMOTE_SSH_KEY_DATA
chmod 600 "$REMOTE_SSH_KEY_FILE"
REMOTE_SSH_KNOWN_HOSTS_DATA="$COOLIFY_PROD_SSH_KNOWN_HOSTS"
printf '%s\n' "$REMOTE_SSH_KNOWN_HOSTS_DATA" > "$REMOTE_SSH_KNOWN_HOSTS"
unset REMOTE_SSH_KNOWN_HOSTS_DATA
# ProxyCommand (nicht ProxyJump): ProxyJump reicht den -i-Key nicht zuverlaessig an den
# Jump-Hop weiter (MaxAuthTries -> Permission denied). ProxyCommand gibt der Bastion den Key
# explizit (IdentitiesOnly), Host-Keys fuer beide Hops gepinnt.
REMOTE_SSH_PROXY="ssh -i $REMOTE_SSH_KEY_FILE -o IdentitiesOnly=yes -o BatchMode=yes -o ConnectTimeout=20 -o UserKnownHostsFile=$REMOTE_SSH_KNOWN_HOSTS -o StrictHostKeyChecking=yes -W %h:%p root@${COOLIFY_PROD_SSH_HOST}"
remote_ssh() {
ssh -i "$REMOTE_SSH_KEY_FILE" -o IdentitiesOnly=yes -o BatchMode=yes -o ConnectTimeout=20 -o UserKnownHostsFile="$REMOTE_SSH_KNOWN_HOSTS" -o StrictHostKeyChecking=yes -o ProxyCommand="$REMOTE_SSH_PROXY" "root@${COOLIFY_PROD_WORKER_PRIVATE_IP}" "$@"
}
verify_live_revision() {
APP_JSON="$1"
LABEL="$2"
@@ -1199,7 +1095,7 @@ jobs:
RESOURCE_KIND="${6:-auto}"
EXPECTED_WORKER_GROUP="${7:-}"
HEALTH_URL="$(node -e 'const fs = require("node:fs"); const app = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); const raw = app.fqdn || app.domain || app.domains || ""; const value = Array.isArray(raw) ? raw[0] : String(raw).split(/[,\s]+/).find(Boolean); process.stdout.write(value ? value.replace(/\/+$/, "") : "");' "$APP_JSON")"
HTTP_RESOURCE="$(node -e 'const fs = require("node:fs"); const app = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); const value = [app.docker_registry_image_name, app.name, app.description].filter(Boolean).join(" "); process.stdout.write(/(?:^|[\\/\\s_-])(?:web|api)(?:$|[\\s:_-])/i.test(value) ? "true" : "false");' "$APP_JSON")"
HTTP_RESOURCE="$(node -e 'const fs = require("node:fs"); const app = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); const value = [app.docker_registry_image_name, app.name, app.description].filter(Boolean).join(" "); process.stdout.write(/(?:^|[\\/\\s_-])(?:web|api|mcp)(?:$|[\\s:_-])/i.test(value) ? "true" : "false");' "$APP_JSON")"
WORKER_RESOURCE="$(node -e 'const fs = require("node:fs"); const app = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); const value = [app.docker_registry_image_name, app.name, app.description].filter(Boolean).join(" "); process.stdout.write(/(?:^|[\\/\\s_-])worker(?:$|[\\s:_-])/i.test(value) ? "true" : "false");' "$APP_JSON")"
if [ "$RESOURCE_KIND" = "worker" ] || [ "$WORKER_RESOURCE" = "true" ]; then
CONFIGURED_IMAGE_TAG="$(node -e 'const fs = require("node:fs"); const app = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); process.stdout.write(app.docker_registry_image_tag || app.image_tag || app.tag || "");' "$APP_JSON")"
@@ -1207,74 +1103,7 @@ jobs:
echo "$LABEL worker image tag does not match expected alias"
exit 1
fi
# Coolify 4.x exposes no command-execution REST API, so verify worker
# health by running the one-shot health probe inside the live container
# over SSH (docker exec) on the Coolify host. Fail-closed: any SSH /
# container-resolution / exec failure blocks the deploy.
WORKER_CONTAINER="$(remote_ssh "docker ps --filter name=$RESOURCE_UUID --filter status=running --format '{{.Names}}' | head -n1" 2>/dev/null | tr -d '\r')"
if [ -z "$WORKER_CONTAINER" ]; then
echo "$LABEL worker container not found on Coolify host"
exit 1
fi
# health.js exits non-zero when the worker reports a non-ready
# status (e.g. "misconfigured"), yet it still prints the diagnostic
# JSON (status + errors) to stdout. Capture stdout regardless of the
# exit code (|| true) so the real reason surfaces in the checks below
# instead of a generic "command unavailable". Empty output means the
# docker exec itself could not run (container gone, SSH failure, ...).
WORKER_HEALTH_OUTPUT="$(remote_ssh "docker exec $WORKER_CONTAINER node apps/worker/dist/health.js" 2>/dev/null | tr -d '\r' || true)"
if [ -z "$WORKER_HEALTH_OUTPUT" ]; then
echo "$LABEL worker docker exec health command unavailable (no output from health probe)"
exit 1
fi
WORKER_HEALTH_JSON="$(mktemp)"
printf "%s" "$WORKER_HEALTH_OUTPUT" > "$WORKER_HEALTH_JSON"
if ! WORKER_REVISION="$(node -e 'const fs = require("node:fs"); const payload = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); process.stdout.write(payload.revision || "");' "$WORKER_HEALTH_JSON" 2>/dev/null)"; then
rm -f "$WORKER_HEALTH_JSON"
echo "$LABEL worker execute output is not JSON"
exit 1
fi
WORKER_STATUS="$(node -e 'const fs = require("node:fs"); const payload = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); process.stdout.write(payload.status || "");' "$WORKER_HEALTH_JSON")"
WORKER_ENABLED="$(node -e 'const fs = require("node:fs"); const payload = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); process.stdout.write(payload.workerEnabled === true ? "true" : "false");' "$WORKER_HEALTH_JSON")"
WORKER_GROUP="$(node -e 'const fs = require("node:fs"); const payload = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); process.stdout.write(payload.workerGroup || "");' "$WORKER_HEALTH_JSON")"
WORKER_QUEUES="$(node -e 'const fs = require("node:fs"); const payload = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); process.stdout.write(Array.isArray(payload.queues) ? payload.queues.join(",") : "");' "$WORKER_HEALTH_JSON")"
WORKER_HEALTH_ERRORS="$(node -e 'const fs = require("node:fs"); const payload = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); process.stdout.write(Array.isArray(payload.errors) ? payload.errors.join("; ") : "");' "$WORKER_HEALTH_JSON")"
rm -f "$WORKER_HEALTH_JSON"
if [ "$WORKER_REVISION" != "$IMAGE_TAG" ]; then
echo "$LABEL worker health revision does not match image tag yet"
return 1
fi
if [ "$WORKER_ENABLED" != "true" ]; then
echo "$LABEL worker health reports workerEnabled=false"
exit 1
fi
if [ "$WORKER_STATUS" != "ready" ]; then
echo "$LABEL worker health status is not ready: '$WORKER_STATUS'${WORKER_HEALTH_ERRORS:+ (errors: $WORKER_HEALTH_ERRORS)}"
exit 1
fi
if [ -n "$EXPECTED_WORKER_GROUP" ] && [ "$WORKER_GROUP" != "$EXPECTED_WORKER_GROUP" ]; then
echo "$LABEL worker group does not match expected queue group"
exit 1
fi
EXPECTED_WORKER_QUEUES=""
case "$EXPECTED_WORKER_GROUP" in
general) EXPECTED_WORKER_QUEUES="toolctrl.notifications toolctrl.maintenance toolctrl.file-scan" ;;
mail) EXPECTED_WORKER_QUEUES="toolctrl.email" ;;
import)
if [ -n "$WORKER_QUEUES" ]; then
EXPECTED_WORKER_QUEUES="toolctrl.imports toolctrl.exports"
fi
;;
webhook) EXPECTED_WORKER_QUEUES="toolctrl.ai-chatbot toolctrl.browser-automation" ;;
plugin) EXPECTED_WORKER_QUEUES="toolctrl.plugin-invoke" ;;
esac
for EXPECTED_WORKER_QUEUE in $EXPECTED_WORKER_QUEUES; do
if ! printf ",%s," "$WORKER_QUEUES" | grep -Fq ",$EXPECTED_WORKER_QUEUE,"; then
echo "$LABEL worker queues do not match expected queue group"
exit 1
fi
done
echo "$LABEL worker live revision matches image tag"
echo "$LABEL worker Coolify status is healthy and image tag matches expected alias"
return
fi
if [ -z "$HEALTH_URL" ]; then
@@ -1324,6 +1153,10 @@ jobs:
RESOURCE_KIND="api"
EXPECTED_SERVICE="api"
EXPECTED_WORKER_GROUP=""
if [ "$RESOURCE_UUID" = "$PRODUCTION_MCP_RESOURCE_UUID" ]; then
RESOURCE_KIND="mcp"
EXPECTED_SERVICE="mcp"
fi
if printf ",%s," "$PRODUCTION_WORKER_RESOURCE_UUIDS" | grep -Fq ",$RESOURCE_UUID,"; then
RESOURCE_KIND="worker"
EXPECTED_SERVICE=""
@@ -1390,7 +1223,7 @@ jobs:
SENTRY_ENVIRONMENT=staging
RELEASE_SHA="${SHA}"
fi
for PROJECT in boilerplate-api boilerplate-worker boilerplate-web; do
for PROJECT in api worker web; do
bash scripts/ci/sentry-release.sh \
--project "$PROJECT" \
--environment "$SENTRY_ENVIRONMENT" \
@@ -52,8 +52,8 @@ permissions:
contents: read
env:
TOOLCTRL_GITEA_HOST_IP: ${{ vars.TOOLCTRL_GITEA_HOST_IP }}
E2E_BASE_URL: https://boiler.saas-ctrl.com
HIVEGRID_GITEA_HOST_IP: ${{ vars.HIVEGRID_GITEA_HOST_IP }}
E2E_BASE_URL: https://app.hivegrid.de
E2E_A11Y_STRICT: "1"
E2E_BROWSER_MATRIX: "1"
E2E_ALLOW_WORKFLOW_RUN: "1"
@@ -113,12 +113,12 @@ jobs:
steps:
- name: Resolve internal Gitea host
run: |
if [ -n "${TOOLCTRL_GITEA_HOST_IP:-}" ]; then
if ! TOOLCTRL_GITEA_HOST_IP="$TOOLCTRL_GITEA_HOST_IP" node -e 'process.exit(require("node:net").isIP(process.env.TOOLCTRL_GITEA_HOST_IP || "") ? 0 : 1)'; then
echo "TOOLCTRL_GITEA_HOST_IP must be a single IPv4 or IPv6 token"
if [ -n "${HIVEGRID_GITEA_HOST_IP:-}" ]; then
if ! HIVEGRID_GITEA_HOST_IP="$HIVEGRID_GITEA_HOST_IP" node -e 'process.exit(require("node:net").isIP(process.env.HIVEGRID_GITEA_HOST_IP || "") ? 0 : 1)'; then
echo "HIVEGRID_GITEA_HOST_IP must be a single IPv4 or IPv6 token"
exit 1
fi
printf '%s git.tool-ctrl.de\n' "$TOOLCTRL_GITEA_HOST_IP" >> /etc/hosts
printf '%s git.tool-ctrl.de\n' "$HIVEGRID_GITEA_HOST_IP" >> /etc/hosts
fi
- name: Checkout code
@@ -201,7 +201,7 @@ jobs:
- name: Upload Playwright traces
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v3
with:
name: e2e-readiness-results
path: test-results/
@@ -1,6 +1,6 @@
name: Lighthouse CI
# Audits the deployed staging site (https://boiler.saas-ctrl.com). A
# Audits the deployed staging site (https://app.hivegrid.de). A
# meaningful Lighthouse audit needs a live server, a database and seeded
# content; reproducing that whole stack inside a CI job is brittle, so the
# audit targets the deployed environment instead.
@@ -9,7 +9,6 @@ name: Lighthouse CI
# gate — staging always lags the commit under review. It runs nightly and on
# manual dispatch.
#
# Spec: docs/improvement-plan/10-compliance-polish/lighthouse.md
# Docs: docs/architecture/lighthouse.md
on:
@@ -19,7 +18,7 @@ on:
- cron: "0 3 * * *"
env:
TOOLCTRL_GITEA_HOST_IP: ${{ vars.TOOLCTRL_GITEA_HOST_IP }}
HIVEGRID_GITEA_HOST_IP: ${{ vars.HIVEGRID_GITEA_HOST_IP }}
jobs:
lighthouse:
@@ -29,12 +28,12 @@ jobs:
steps:
- name: Resolve internal Gitea host
run: |
if [ -n "${TOOLCTRL_GITEA_HOST_IP:-}" ]; then
if ! TOOLCTRL_GITEA_HOST_IP="$TOOLCTRL_GITEA_HOST_IP" node -e 'process.exit(require("node:net").isIP(process.env.TOOLCTRL_GITEA_HOST_IP || "") ? 0 : 1)'; then
echo "TOOLCTRL_GITEA_HOST_IP must be a single IPv4 or IPv6 token"
if [ -n "${HIVEGRID_GITEA_HOST_IP:-}" ]; then
if ! HIVEGRID_GITEA_HOST_IP="$HIVEGRID_GITEA_HOST_IP" node -e 'process.exit(require("node:net").isIP(process.env.HIVEGRID_GITEA_HOST_IP || "") ? 0 : 1)'; then
echo "HIVEGRID_GITEA_HOST_IP must be a single IPv4 or IPv6 token"
exit 1
fi
printf '%s git.tool-ctrl.de\n' "$TOOLCTRL_GITEA_HOST_IP" >> /etc/hosts
printf '%s git.tool-ctrl.de\n' "$HIVEGRID_GITEA_HOST_IP" >> /etc/hosts
fi
- name: Checkout
@@ -43,12 +42,12 @@ jobs:
- name: Mint Lighthouse session cookie
run: node scripts/rotate-lighthouse-session-cookie.mjs
env:
LIGHTHOUSE_LOGIN_URL: https://boiler.saas-ctrl.com/login
LIGHTHOUSE_LOGIN_URL: https://app.hivegrid.de/login
LIGHTHOUSE_LOGIN_EMAIL: ${{ secrets.LIGHTHOUSE_LOGIN_EMAIL }}
LIGHTHOUSE_LOGIN_PASSWORD: ${{ secrets.LIGHTHOUSE_LOGIN_PASSWORD }}
LIGHTHOUSE_SESSION_COOKIE: ${{ secrets.LIGHTHOUSE_SESSION_COOKIE }}
LIGHTHOUSE_SESSION_COOKIE_NAME: RJ_session
LIGHTHOUSE_COOKIE_DOMAIN: boiler.saas-ctrl.com
LIGHTHOUSE_SESSION_COOKIE_NAME: hivegrid_session
LIGHTHOUSE_COOKIE_DOMAIN: app.hivegrid.de
- name: Install Chromium
# The Gitea runner image (node:20-bookworm) ships no browser; LHCI's
@@ -66,7 +65,7 @@ jobs:
- name: Upload Lighthouse artifacts
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v3
with:
name: lighthouse-ci-results
path: .lighthouseci/
@@ -1,11 +1,11 @@
name: SDK Codegen Drift
# Gitea variant of .github/workflows/sdk-codegen.yml. Re-runs the
# @toolctrl/api-client-ts generation pipeline on PRs that touch the
# @hivegrid/api-client-ts generation pipeline on PRs that touch the
# upstream OpenAPI surface or the SDK package, then verifies the
# committed src/generated/ tree is in sync with openapi.json.
#
# Spec: docs/improvement-plan/08-mobile-cli-storybook-sdk/ts-sdk.md
# Docs: docs/sdks/typescript.md
on:
pull_request:
@@ -17,7 +17,7 @@ on:
- ".gitea/workflows/sdk-codegen.yml"
env:
TOOLCTRL_GITEA_HOST_IP: ${{ vars.TOOLCTRL_GITEA_HOST_IP }}
HIVEGRID_GITEA_HOST_IP: ${{ vars.HIVEGRID_GITEA_HOST_IP }}
jobs:
drift-check:
@@ -27,12 +27,12 @@ jobs:
steps:
- name: Resolve internal Gitea host
run: |
if [ -n "${TOOLCTRL_GITEA_HOST_IP:-}" ]; then
if ! TOOLCTRL_GITEA_HOST_IP="$TOOLCTRL_GITEA_HOST_IP" node -e 'process.exit(require("node:net").isIP(process.env.TOOLCTRL_GITEA_HOST_IP || "") ? 0 : 1)'; then
echo "TOOLCTRL_GITEA_HOST_IP must be a single IPv4 or IPv6 token"
if [ -n "${HIVEGRID_GITEA_HOST_IP:-}" ]; then
if ! HIVEGRID_GITEA_HOST_IP="$HIVEGRID_GITEA_HOST_IP" node -e 'process.exit(require("node:net").isIP(process.env.HIVEGRID_GITEA_HOST_IP || "") ? 0 : 1)'; then
echo "HIVEGRID_GITEA_HOST_IP must be a single IPv4 or IPv6 token"
exit 1
fi
printf '%s git.tool-ctrl.de\n' "$TOOLCTRL_GITEA_HOST_IP" >> /etc/hosts
printf '%s git.tool-ctrl.de\n' "$HIVEGRID_GITEA_HOST_IP" >> /etc/hosts
fi
- name: Checkout PR head
@@ -44,14 +44,25 @@ jobs:
- name: Prepare pnpm 10.33.0
run: corepack prepare pnpm@10.33.0 --activate
- name: Configure pnpm store
run: pnpm config set store-dir .pnpm-store
- name: Cache pnpm store
uses: actions/cache@v4
with:
path: .pnpm-store
key: ${{ runner.os }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-
- name: Install dependencies
run: pnpm install --frozen-lockfile
run: pnpm install --frozen-lockfile --prefer-offline
- name: Regenerate OpenAPI capture
run: pnpm --filter @toolctrl/api-client-ts generate-spec
run: pnpm --filter @hivegrid/api-client-ts generate-spec
- name: Regenerate typed client (Hey-API)
run: pnpm --filter @toolctrl/api-client-ts generate
run: pnpm --filter @hivegrid/api-client-ts generate
- name: Fail if generated code drifts from committed state
run: |
@@ -61,7 +72,7 @@ jobs:
echo ""
echo "SDK codegen drift detected. Run:"
echo ""
echo " pnpm --filter @toolctrl/api-client-ts generate:all"
echo " pnpm --filter @hivegrid/api-client-ts generate:all"
echo ""
echo "then commit packages/api-client-ts/openapi.json and packages/api-client-ts/src/generated/."
exit 1
@@ -70,10 +81,10 @@ jobs:
- name: Typecheck SDK packages
run: |
pnpm --filter @toolctrl/api-client-ts typecheck
pnpm --filter @toolctrl/api-types typecheck
pnpm --filter @hivegrid/api-client-ts typecheck
pnpm --filter @hivegrid/api-types typecheck
- name: Run SDK tests
run: |
pnpm --filter @toolctrl/api-client-ts test
pnpm --filter @toolctrl/api-types test
pnpm --filter @hivegrid/api-client-ts test
pnpm --filter @hivegrid/api-types test
@@ -1,217 +0,0 @@
name: Storybook
on:
pull_request:
paths:
- "apps/storybook/**"
- "packages/ui/src/stories/**"
- "packages/ui-app/src/stories/**"
- "packages/ui-subframe/src/stories/**"
- "packages/ui-mock/src/stories/**"
- ".gitea/workflows/storybook.yml"
workflow_dispatch:
env:
TOOLCTRL_GITEA_HOST_IP: ${{ vars.TOOLCTRL_GITEA_HOST_IP }}
PLAYWRIGHT_BROWSERS_PATH: .cache/ms-playwright
jobs:
build-storybook:
name: Build static Storybook
runs-on: toolctrl-ci
timeout-minutes: 20
steps:
- name: Resolve internal Gitea host
run: |
if [ -n "${TOOLCTRL_GITEA_HOST_IP:-}" ]; then
if ! TOOLCTRL_GITEA_HOST_IP="$TOOLCTRL_GITEA_HOST_IP" node -e 'process.exit(require("node:net").isIP(process.env.TOOLCTRL_GITEA_HOST_IP || "") ? 0 : 1)'; then
echo "TOOLCTRL_GITEA_HOST_IP must be a single IPv4 or IPv6 token"
exit 1
fi
printf '%s git.tool-ctrl.de\n' "$TOOLCTRL_GITEA_HOST_IP" >> /etc/hosts
fi
- name: Checkout code
uses: actions/checkout@v4
- name: Enable Corepack
run: corepack enable
- name: Prepare pnpm 10.33.0
run: corepack prepare pnpm@10.33.0 --activate
- name: Configure pnpm store
run: pnpm config set store-dir .pnpm-store
- name: Cache pnpm store
uses: actions/cache@v4
with:
path: .pnpm-store
key: ${{ runner.os }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build Storybook static site
run: pnpm --filter @toolctrl/storybook build-storybook
- name: Upload static Storybook artifact
uses: actions/upload-artifact@v3
with:
name: storybook-static
path: apps/storybook/storybook-static
retention-days: 14
test-storybook:
name: Storybook test-runner (a11y gate)
needs: build-storybook
runs-on: toolctrl-ci
timeout-minutes: 25
steps:
- name: Resolve internal Gitea host
run: |
if [ -n "${TOOLCTRL_GITEA_HOST_IP:-}" ]; then
if ! TOOLCTRL_GITEA_HOST_IP="$TOOLCTRL_GITEA_HOST_IP" node -e 'process.exit(require("node:net").isIP(process.env.TOOLCTRL_GITEA_HOST_IP || "") ? 0 : 1)'; then
echo "TOOLCTRL_GITEA_HOST_IP must be a single IPv4 or IPv6 token"
exit 1
fi
printf '%s git.tool-ctrl.de\n' "$TOOLCTRL_GITEA_HOST_IP" >> /etc/hosts
fi
- name: Checkout code
uses: actions/checkout@v4
- name: Enable Corepack
run: corepack enable
- name: Prepare pnpm 10.33.0
run: corepack prepare pnpm@10.33.0 --activate
- name: Configure pnpm store
run: pnpm config set store-dir .pnpm-store
- name: Cache pnpm store
uses: actions/cache@v4
with:
path: .pnpm-store
key: ${{ runner.os }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Download built Storybook
uses: actions/download-artifact@v3
with:
name: storybook-static
path: apps/storybook/storybook-static
- name: Cache Playwright browsers
uses: actions/cache@v4
with:
path: .cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-playwright-
- name: Install Playwright Chromium
run: pnpm --filter @toolctrl/storybook exec playwright install --with-deps chromium
- name: Serve static Storybook and run test-runner with a11y gate
run: |
set -euo pipefail
pnpm --filter @toolctrl/storybook exec http-server storybook-static --port 6006 --silent &
SERVER_PID=$!
trap "kill $SERVER_PID 2>/dev/null || true" EXIT
pnpm --filter @toolctrl/storybook exec wait-on http://127.0.0.1:6006 --timeout 60000
TARGET_URL=http://127.0.0.1:6006 pnpm --filter @toolctrl/storybook test:storybook
visual-regression:
name: Storybook visual-regression (Lost-Pixel)
needs: build-storybook
runs-on: toolctrl-ci
timeout-minutes: 20
steps:
- name: Resolve internal Gitea host
run: |
if [ -n "${TOOLCTRL_GITEA_HOST_IP:-}" ]; then
if ! TOOLCTRL_GITEA_HOST_IP="$TOOLCTRL_GITEA_HOST_IP" node -e 'process.exit(require("node:net").isIP(process.env.TOOLCTRL_GITEA_HOST_IP || "") ? 0 : 1)'; then
echo "TOOLCTRL_GITEA_HOST_IP must be a single IPv4 or IPv6 token"
exit 1
fi
printf '%s git.tool-ctrl.de\n' "$TOOLCTRL_GITEA_HOST_IP" >> /etc/hosts
fi
- name: Checkout code
uses: actions/checkout@v4
- name: Check for committed Lost-Pixel baseline
id: baseline
run: |
set -euo pipefail
if ls apps/storybook/.lostpixel/baseline/*.png >/dev/null 2>&1; then
echo "present=true" >> "$GITHUB_OUTPUT"
else
echo "present=false" >> "$GITHUB_OUTPUT"
echo "No Lost-Pixel baseline committed yet — skipping visual-regression. Run 'pnpm --filter @toolctrl/storybook visual-regression:update' and commit apps/storybook/.lostpixel/baseline/*.png to enable this gate."
fi
- name: Enable Corepack
if: steps.baseline.outputs.present == 'true'
run: corepack enable
- name: Prepare pnpm 10.33.0
if: steps.baseline.outputs.present == 'true'
run: corepack prepare pnpm@10.33.0 --activate
- name: Configure pnpm store
if: steps.baseline.outputs.present == 'true'
run: pnpm config set store-dir .pnpm-store
- name: Cache pnpm store
if: steps.baseline.outputs.present == 'true'
uses: actions/cache@v4
with:
path: .pnpm-store
key: ${{ runner.os }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-
- name: Install dependencies
if: steps.baseline.outputs.present == 'true'
run: pnpm install --frozen-lockfile
- name: Download built Storybook
if: steps.baseline.outputs.present == 'true'
uses: actions/download-artifact@v3
with:
name: storybook-static
path: apps/storybook/storybook-static
- name: Cache Playwright browsers
if: steps.baseline.outputs.present == 'true'
uses: actions/cache@v4
with:
path: .cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-playwright-
- name: Install Playwright Chromium
if: steps.baseline.outputs.present == 'true'
run: pnpm --filter @toolctrl/storybook exec playwright install --with-deps chromium
- name: Run Lost-Pixel visual-regression diff
if: steps.baseline.outputs.present == 'true'
run: pnpm --filter @toolctrl/storybook visual-regression
- name: Upload Lost-Pixel diff images
if: steps.baseline.outputs.present == 'true' && failure()
uses: actions/upload-artifact@v3
with:
name: lost-pixel-diff
path: apps/storybook/.lostpixel/difference
retention-days: 14
@@ -8,7 +8,7 @@ on:
workflow_call:
inputs:
project:
description: "Sentry project slug (boilerplate-api|boilerplate-worker|boilerplate-web)"
description: "Sentry project slug (hivegrid-api|hivegrid-worker|hivegrid-web)"
required: true
type: string
environment:
@@ -3,7 +3,7 @@ name: Accessibility
# Runs the axe-core vitest suites (apps/web ui components, SkipToContent)
# and the WCAG-2.2-AA color-contrast verifier on every PR + push to main.
#
# Spec: docs/improvement-plan/10-compliance-polish/wcag-internal.md
# Docs: docs/contributing/wcag-audit.md
on:
push:
@@ -24,7 +24,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 20
env:
DATABASE_URL: postgresql://toolctrl:toolctrl@localhost:5432/toolctrl?schema=public
DATABASE_URL: postgresql://hivegrid:hivegrid@localhost:5432/hivegrid?schema=public
steps:
- name: Checkout code
@@ -3,7 +3,7 @@ name: Bundle Budget Baseline
# Captures the main-branch bundle report as an artifact so the
# bundle-budget PR workflow can render Δ-from-main columns. Runs on
# push to main; downstream PR workflow downloads `bundle-baseline-main`.
# Spec: docs/improvement-plan/10-compliance-polish/bundle-budget.md
# Docs: docs/architecture/bundle-budgets.md
on:
push:
@@ -58,7 +58,7 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Build web
run: pnpm --filter @toolctrl/web build
run: pnpm --filter @hivegrid/web build
- name: Verify aggregate bundle budgets
run: pnpm run verify:bundle-size
@@ -2,7 +2,6 @@ name: Bundle Budget
# Posts a sticky PR comment with per-route gzip sizes vs scripts/bundle-budgets.json.
# Non-blocking: exceeds-limit routes show a warning row but never fail the PR check.
# Spec: docs/improvement-plan/10-compliance-polish/bundle-budget.md
# Docs: docs/architecture/bundle-budgets.md
on:
@@ -35,7 +34,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 30
env:
DATABASE_URL: postgresql://toolctrl:toolctrl@localhost:5432/toolctrl?schema=public
DATABASE_URL: postgresql://hivegrid:hivegrid@localhost:5432/hivegrid?schema=public
steps:
- name: Checkout PR head
uses: actions/checkout@v4
@@ -61,7 +60,7 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Build web (PR head)
run: pnpm --filter @toolctrl/web build
run: pnpm --filter @hivegrid/web build
- name: Verify aggregate bundle budgets
run: pnpm run verify:bundle-size
@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 45
env:
DATABASE_URL: postgresql://toolctrl:toolctrl@localhost:5432/toolctrl?schema=public
DATABASE_URL: postgresql://hivegrid:hivegrid@localhost:5432/hivegrid?schema=public
steps:
- name: Checkout code
uses: actions/checkout@v4
@@ -122,15 +122,12 @@ jobs:
- name: Workspace package tests
run: pnpm run test:packages
- name: Mobile wrapper tests
run: pnpm run test:mobile
- name: Unit tests
run: pnpm test
- name: Build
env:
TOOLCTRL_BUILD_SOURCEMAPS: "1"
HIVEGRID_BUILD_SOURCEMAPS: "1"
run: pnpm run build:ci
- name: Verify bundle size
@@ -155,7 +152,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 25
env:
DATABASE_URL: postgresql://toolctrl:toolctrl@localhost:5432/toolctrl?schema=public
DATABASE_URL: postgresql://hivegrid:hivegrid@localhost:5432/hivegrid?schema=public
steps:
- name: Checkout code
uses: actions/checkout@v4
@@ -184,20 +181,20 @@ jobs:
run: pnpm run verify:openapi-security
- name: Typecheck API
run: pnpm --filter @toolctrl/api typecheck
run: pnpm --filter @hivegrid/api typecheck
- name: Test API
run: pnpm exec vitest run apps/api/src
- name: Build API
run: pnpm --filter @toolctrl/api build
run: pnpm --filter @hivegrid/api build
worker:
name: Worker Service
runs-on: ubuntu-latest
timeout-minutes: 25
env:
DATABASE_URL: postgresql://toolctrl:toolctrl@localhost:5432/toolctrl?schema=public
DATABASE_URL: postgresql://hivegrid:hivegrid@localhost:5432/hivegrid?schema=public
steps:
- name: Checkout code
uses: actions/checkout@v4
@@ -223,16 +220,16 @@ jobs:
run: pnpm install --frozen-lockfile --prefer-offline
- name: Typecheck Worker
run: pnpm --filter @toolctrl/worker typecheck
run: pnpm --filter @hivegrid/worker typecheck
- name: Test Worker
run: pnpm --filter @toolctrl/worker exec vitest run
run: pnpm --filter @hivegrid/worker exec vitest run
- name: Build Worker
run: pnpm --filter @toolctrl/worker build
run: pnpm --filter @hivegrid/worker build
- name: Health Check Worker
run: pnpm --filter @toolctrl/worker health
run: pnpm --filter @hivegrid/worker health
env:
WORKER_ENABLED: "false"
@@ -267,7 +264,10 @@ jobs:
- name: Generate database client
run: pnpm run db:generate
env:
DATABASE_URL: postgresql://toolctrl:toolctrl@localhost:5432/toolctrl?schema=public
DATABASE_URL: postgresql://hivegrid:hivegrid@localhost:5432/hivegrid?schema=public
- name: Verify migrations schema parity
run: pnpm run verify:migrations-parity
- name: Verify database migration status
if: github.event_name != 'pull_request'
@@ -276,7 +276,7 @@ jobs:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
- name: Test database package
run: pnpm --filter @toolctrl/db test
run: pnpm --filter @hivegrid/db test
docker-smoke:
name: Docker Smoke
@@ -296,17 +296,17 @@ jobs:
context: .
target: web
build-args: APP_REVISION=ci-smoke-revision
tags: toolctrl-web:smoke
tags: hivegrid-web:smoke
load: true
push: false
cache-from: type=gha,scope=toolctrl-web-smoke
cache-to: type=gha,mode=max,scope=toolctrl-web-smoke
cache-from: type=gha,scope=hivegrid-web-smoke
cache-to: type=gha,mode=max,scope=hivegrid-web-smoke
- name: Validate web Docker artifacts
run: docker run --rm --entrypoint /bin/sh toolctrl-web:smoke -lc 'test -f /myapp/apps/web/build/server/index.js && test -d /myapp/apps/web/build/client && test -f /myapp/build/server-runtime/server.js'
run: docker run --rm --entrypoint /bin/sh hivegrid-web:smoke -lc 'test -f /myapp/apps/web/build/server/index.js && test -d /myapp/apps/web/build/client && test -f /myapp/build/server-runtime/server.js'
- name: Validate web Docker runtime
run: bash scripts/docker-web-runtime-smoke.sh toolctrl-web:smoke
run: bash scripts/docker-web-runtime-smoke.sh hivegrid-web:smoke
- name: Build API Docker target
uses: docker/build-push-action@v7
@@ -314,14 +314,14 @@ jobs:
context: .
target: api
build-args: APP_REVISION=ci-smoke-revision
tags: toolctrl-api:smoke
tags: hivegrid-api:smoke
load: true
push: false
cache-from: type=gha,scope=toolctrl-api-smoke
cache-to: type=gha,mode=max,scope=toolctrl-api-smoke
cache-from: type=gha,scope=hivegrid-api-smoke
cache-to: type=gha,mode=max,scope=hivegrid-api-smoke
- name: Validate API Docker runtime
run: bash scripts/docker-api-runtime-smoke.sh toolctrl-api:smoke
run: bash scripts/docker-api-runtime-smoke.sh hivegrid-api:smoke
- name: Build worker Docker target
uses: docker/build-push-action@v7
@@ -329,14 +329,14 @@ jobs:
context: .
target: worker
build-args: APP_REVISION=ci-smoke-revision
tags: toolctrl-worker:smoke
tags: hivegrid-worker:smoke
load: true
push: false
cache-from: type=gha,scope=toolctrl-worker-smoke
cache-to: type=gha,mode=max,scope=toolctrl-worker-smoke
cache-from: type=gha,scope=hivegrid-worker-smoke
cache-to: type=gha,mode=max,scope=hivegrid-worker-smoke
- name: Validate worker Docker runtime
run: bash scripts/docker-worker-runtime-smoke.sh toolctrl-worker:smoke
run: bash scripts/docker-worker-runtime-smoke.sh hivegrid-worker:smoke
- name: Validate Docker Compose config
run: docker compose config
@@ -366,7 +366,7 @@ jobs:
needs: resolve-sentry-environment
uses: ./.github/workflows/_sentry-release.yml
with:
project: boilerplate-api
project: hivegrid-api
environment: ${{ needs.resolve-sentry-environment.outputs.environment }}
sourcemaps-path: none
secrets:
@@ -379,7 +379,7 @@ jobs:
needs: resolve-sentry-environment
uses: ./.github/workflows/_sentry-release.yml
with:
project: boilerplate-worker
project: hivegrid-worker
environment: ${{ needs.resolve-sentry-environment.outputs.environment }}
sourcemaps-path: none
secrets:
@@ -392,7 +392,7 @@ jobs:
needs: resolve-sentry-environment
uses: ./.github/workflows/_sentry-release.yml
with:
project: boilerplate-web
project: hivegrid-web
environment: ${{ needs.resolve-sentry-environment.outputs.environment }}
sourcemaps-path: apps/web/build/client
sourcemaps-artifact: web-client-sourcemaps
@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: "CLA Assistant"
if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I acknowledge that I have read the SaasRock CLA Document and I hereby sign the CLA. I acknowledge that I understand this code is not open source and for commercial purposes. I acknowledge that I am waiving all Intellectual Property Rights; including, but not limited to copyright.') || github.event_name == 'pull_request_target'
if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I acknowledge that I have read the LegacySourceProduct CLA Document and I hereby sign the CLA. I acknowledge that I understand this code is not open source and for commercial purposes. I acknowledge that I am waiving all Intellectual Property Rights; including, but not limited to copyright.') || github.event_name == 'pull_request_target'
# Beta Release
uses: contributor-assistant/github-action@v2.2.0
env:
@@ -5,7 +5,6 @@ name: Coverage
# Uploads the lightweight gate summary as an artifact and posts a sticky PR
# comment with the coverage-summary.json totals.
#
# Spec: docs/improvement-plan/10-compliance-polish/test-coverage.md
# Docs: docs/architecture/test-coverage.md
on:
@@ -56,7 +55,7 @@ jobs:
- name: Run vitest with coverage (enforces per-path gates)
env:
TOOLCTRL_COVERAGE_GATE: "1"
HIVEGRID_COVERAGE_GATE: "1"
run: pnpm run verify:test-coverage
- name: Upload coverage gate summary
@@ -52,7 +52,7 @@ permissions:
contents: read
env:
E2E_BASE_URL: https://boiler.saas-ctrl.com
E2E_BASE_URL: https://app.hivegrid.de
E2E_A11Y_STRICT: "1"
E2E_BROWSER_MATRIX: "1"
E2E_ALLOW_WORKFLOW_RUN: "1"
@@ -1,7 +1,7 @@
name: Lighthouse CI
# GitHub mirror of .gitea/workflows/lighthouse.yml. Audits the deployed
# staging site (https://boiler.saas-ctrl.com) nightly + on manual dispatch.
# staging site (https://app.hivegrid.de) nightly + on manual dispatch.
# See the Gitea variant and docs/architecture/lighthouse.md for the rationale
# behind auditing a deployed environment rather than a CI-local server.
@@ -23,12 +23,12 @@ jobs:
- name: Mint Lighthouse session cookie
run: node scripts/rotate-lighthouse-session-cookie.mjs
env:
LIGHTHOUSE_LOGIN_URL: https://boiler.saas-ctrl.com/login
LIGHTHOUSE_LOGIN_URL: https://app.hivegrid.de/login
LIGHTHOUSE_LOGIN_EMAIL: ${{ secrets.LIGHTHOUSE_LOGIN_EMAIL }}
LIGHTHOUSE_LOGIN_PASSWORD: ${{ secrets.LIGHTHOUSE_LOGIN_PASSWORD }}
LIGHTHOUSE_SESSION_COOKIE: ${{ secrets.LIGHTHOUSE_SESSION_COOKIE }}
LIGHTHOUSE_SESSION_COOKIE_NAME: RJ_session
LIGHTHOUSE_COOKIE_DOMAIN: boiler.saas-ctrl.com
LIGHTHOUSE_SESSION_COOKIE_NAME: hivegrid_session
LIGHTHOUSE_COOKIE_DOMAIN: app.hivegrid.de
- name: Run Lighthouse-CI against staging
# ubuntu-latest ships Chrome pre-installed; no browser setup needed.
@@ -1,13 +1,13 @@
name: SDK Codegen Drift
# Re-runs the @toolctrl/api-client-ts generation pipeline on PRs that touch
# Re-runs the @hivegrid/api-client-ts generation pipeline on PRs that touch
# the upstream OpenAPI surface (apps/api/src/openapi.ts) or the SDK package
# itself, then verifies the committed src/generated/ tree is in sync with
# the captured openapi.json.
#
# Drift is a hard failure: regenerating + committing is part of the PR.
#
# Spec: docs/improvement-plan/08-mobile-cli-storybook-sdk/ts-sdk.md
# Docs: docs/sdks/typescript.md
on:
pull_request:
@@ -52,10 +52,10 @@ jobs:
run: pnpm install --frozen-lockfile --prefer-offline
- name: Regenerate OpenAPI capture
run: pnpm --filter @toolctrl/api-client-ts generate-spec
run: pnpm --filter @hivegrid/api-client-ts generate-spec
- name: Regenerate typed client (Hey-API)
run: pnpm --filter @toolctrl/api-client-ts generate
run: pnpm --filter @hivegrid/api-client-ts generate
- name: Fail if generated code drifts from committed state
run: |
@@ -65,7 +65,7 @@ jobs:
echo ""
echo "SDK codegen drift detected. Run:"
echo ""
echo " pnpm --filter @toolctrl/api-client-ts generate:all"
echo " pnpm --filter @hivegrid/api-client-ts generate:all"
echo ""
echo "then commit packages/api-client-ts/openapi.json and packages/api-client-ts/src/generated/."
exit 1
@@ -74,10 +74,10 @@ jobs:
- name: Typecheck SDK packages
run: |
pnpm --filter @toolctrl/api-client-ts typecheck
pnpm --filter @toolctrl/api-types typecheck
pnpm --filter @hivegrid/api-client-ts typecheck
pnpm --filter @hivegrid/api-types typecheck
- name: Run SDK tests
run: |
pnpm --filter @toolctrl/api-client-ts test
pnpm --filter @toolctrl/api-types test
pnpm --filter @hivegrid/api-client-ts test
pnpm --filter @hivegrid/api-types test
@@ -1,197 +0,0 @@
name: Storybook
on:
pull_request:
paths:
- "apps/storybook/**"
- "packages/ui/src/stories/**"
- "packages/ui-app/src/stories/**"
- "packages/ui-subframe/src/stories/**"
- "packages/ui-mock/src/stories/**"
- ".github/workflows/storybook.yml"
push:
branches:
- main
paths:
- "apps/storybook/**"
- "packages/ui/src/stories/**"
- "packages/ui-app/src/stories/**"
- "packages/ui-subframe/src/stories/**"
- "packages/ui-mock/src/stories/**"
- ".github/workflows/storybook.yml"
env:
PLAYWRIGHT_BROWSERS_PATH: .cache/ms-playwright
jobs:
build-storybook:
name: Build static Storybook
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Enable Corepack
run: corepack enable
- name: Prepare pnpm 10.33.0
run: corepack prepare pnpm@10.33.0 --activate
- name: Configure pnpm store
run: pnpm config set store-dir .pnpm-store
- name: Cache pnpm store
uses: actions/cache@v4
with:
path: .pnpm-store
key: ${{ runner.os }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build Storybook static site
run: pnpm --filter @toolctrl/storybook build-storybook
- name: Upload static Storybook artifact
uses: actions/upload-artifact@v4
with:
name: storybook-static
path: apps/storybook/storybook-static
if-no-files-found: error
retention-days: 14
test-storybook:
name: Storybook test-runner (a11y gate)
needs: build-storybook
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Enable Corepack
run: corepack enable
- name: Prepare pnpm 10.33.0
run: corepack prepare pnpm@10.33.0 --activate
- name: Configure pnpm store
run: pnpm config set store-dir .pnpm-store
- name: Cache pnpm store
uses: actions/cache@v4
with:
path: .pnpm-store
key: ${{ runner.os }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Download built Storybook
uses: actions/download-artifact@v4
with:
name: storybook-static
path: apps/storybook/storybook-static
- name: Cache Playwright browsers
uses: actions/cache@v4
with:
path: .cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-playwright-
- name: Install Playwright Chromium
run: pnpm --filter @toolctrl/storybook exec playwright install --with-deps chromium
- name: Serve static Storybook and run test-runner with a11y gate
run: |
set -euo pipefail
pnpm --filter @toolctrl/storybook exec http-server storybook-static --port 6006 --silent &
SERVER_PID=$!
trap "kill $SERVER_PID 2>/dev/null || true" EXIT
pnpm --filter @toolctrl/storybook exec wait-on http://127.0.0.1:6006 --timeout 60000
TARGET_URL=http://127.0.0.1:6006 pnpm --filter @toolctrl/storybook test:storybook
visual-regression:
name: Storybook visual-regression (Lost-Pixel)
needs: build-storybook
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Check for committed Lost-Pixel baseline
id: baseline
run: |
set -euo pipefail
if ls apps/storybook/.lostpixel/baseline/*.png >/dev/null 2>&1; then
echo "present=true" >> "$GITHUB_OUTPUT"
else
echo "present=false" >> "$GITHUB_OUTPUT"
echo "::notice::No Lost-Pixel baseline committed yet — skipping visual-regression. Run 'pnpm --filter @toolctrl/storybook visual-regression:update' and commit apps/storybook/.lostpixel/baseline/*.png to enable this gate."
fi
- name: Enable Corepack
if: steps.baseline.outputs.present == 'true'
run: corepack enable
- name: Prepare pnpm 10.33.0
if: steps.baseline.outputs.present == 'true'
run: corepack prepare pnpm@10.33.0 --activate
- name: Configure pnpm store
if: steps.baseline.outputs.present == 'true'
run: pnpm config set store-dir .pnpm-store
- name: Cache pnpm store
if: steps.baseline.outputs.present == 'true'
uses: actions/cache@v4
with:
path: .pnpm-store
key: ${{ runner.os }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-
- name: Install dependencies
if: steps.baseline.outputs.present == 'true'
run: pnpm install --frozen-lockfile
- name: Download built Storybook
if: steps.baseline.outputs.present == 'true'
uses: actions/download-artifact@v4
with:
name: storybook-static
path: apps/storybook/storybook-static
- name: Cache Playwright browsers
if: steps.baseline.outputs.present == 'true'
uses: actions/cache@v4
with:
path: .cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-playwright-
- name: Install Playwright Chromium
if: steps.baseline.outputs.present == 'true'
run: pnpm --filter @toolctrl/storybook exec playwright install --with-deps chromium
- name: Run Lost-Pixel visual-regression diff
if: steps.baseline.outputs.present == 'true'
run: pnpm --filter @toolctrl/storybook visual-regression
- name: Upload Lost-Pixel diff images
if: steps.baseline.outputs.present == 'true' && failure()
uses: actions/upload-artifact@v4
with:
name: lost-pixel-diff
path: apps/storybook/.lostpixel/difference
if-no-files-found: ignore
retention-days: 14
@@ -1,5 +1,6 @@
node_modules
.DS_Store
**/.DS_Store
.cache
.env
@@ -7,7 +8,6 @@ node_modules
!.env.example
!.env.local.example
!.env.production.example
!.env.fly.example
!.env.test
.vercel
.output
@@ -274,17 +274,17 @@ Diese werden von vielen Modulen geschrieben/referenziert. Sie sind der Grund, wa
**Struktur (keine Seat-Typen mehr - Basis-Preis pro Nutzer + Feature-Module als Positionen):**
```
SubscriptionProduct ─1:N─▶ SubscriptionPrice (stripe_price_id, interval month|year|one_time)
SubscriptionProduct ─1:N─▶ SubscriptionPrice (legacyProvider_price_id, interval month|year|one_time)
│ (Plan: Core/Swarm/Colony)
├─M:N─▶ SubscriptionFeature (via ProductFeature, +quota_value JSON) ◀── Konnektor-Gate!
└─1:N─▶ SubscriptionUsageBasedPrice ─1:N─▶ UsageBasedTier (Staffel-Overage)
│
TenantSubscription (1:1 Tenant) ─N:1─▶ Product │
├─1:N─▶ TenantSubscriptionProductPrice (quantity=Seats, is_metered)
├─1:N─▶ TenantSubscriptionUsageRecord (metric, usage_amount, reported_to_stripe)
└─ status (trial/active/past_due/cancelled/frozen) + Stripe-IDs + Dunning-Felder
├─1:N─▶ TenantSubscriptionUsageRecord (metric, usage_amount, reported_to_legacyProvider)
└─ status (trial/active/past_due/cancelled/frozen) + Legacy-Provider-IDs + Dunning-Felder
Credit (ai_token/bonus, balance_after, valid_until) ─N:1─▶ Tenant
CheckoutSessionStatus (stripe_checkout_session, intent) ─N:1─▶ Tenant
CheckoutSessionStatus (legacyProvider_checkout_session, intent) ─N:1─▶ Tenant
```
`SubscriptionFeature.code` (z. B. `kwp_integration`) ist die Brücke zur Konnektor-Pflicht: `tenantHasModule()` prüft, ob das Feature im Abo des Tenants enthalten ist. Quotas (max_users, ai_tokens) hängen an `SubscriptionProductFeature.quota_value`.
@@ -29,7 +29,7 @@ Spalte „Wer" im Team-Meeting ausfüllen. **Kategorie A heute anstoßen — das
| A2 | **Google Cloud:** OAuth-App + **nur Calendar-API**-Scope-Verifizierung (Gmail-Scopes entfallen — Postfach-Sync → v2) | P16 Kalender (nur Google; Outlook → v2) | 🟠 Review Tage–Wochen; Testmodus mit Workspace-Konten als Brücke | |
| ~~A3~~ | ~~Apple Developer~~ | **v3** (kein App-Store in v1/v2 — PWA-only, AD-17) | Konto vorhanden, ruht — keine Aktion | — |
| ~~A4~~ | ~~Google Play Console~~ | **v3** (kein App-Store in v1/v2 — PWA-only) | ruht bis native App (v3) | — |
| A5 | **Stripe-Konto + Live-Verifizierung** (Geschäftsdaten, Bankkonto, ggf. Webseite-Anforderungen) | P20 | 🟠 Tage; Test-Mode reicht für Entwicklung, Live für Go-Live | |
| A5 | **Mollie-Konto + Live-Verifizierung** (Geschäftsdaten, Bankkonto, Webseite-Anforderungen) + Lago-Production-Planung | P20 / BILL-08 | 🟠 Tage; Test-Mode reicht für Entwicklung, Live für Go-Live | |
| A6 | **Sende-Domain einrichten:** SPF/DKIM/DMARC für Mailjet + **Domain-Warm-up** beginnen | P1 (notify), P2 (Einladungen), P14 (Marketing!) | 🟠 DNS sofort; Reputation/Warm-up braucht Laufzeit — je früher, desto besser für Zustellbarkeit | |
| ~~A7~~ | ~~Microsoft Entra (Outlook/Graph)~~ | v2 (Outlook-Kalender + Postfach-Sync → v2) | → v2-Vorbereitung | — |
@@ -54,7 +54,7 @@ Spalte „Wer" im Team-Meeting ausfüllen. **Kategorie A heute anstoßen — das
|---|---|---|---|---|
| C1 | **Meilisearch: läuft bereits zentral auf `node1`** (Infra-ADR-008) — nur **scoped Key minten** (`indexes: ["hivegrid-*"]`, Muster: Boilerplate-Key vom 06.06.; `MEILI_API_KEY`, nicht Master-Key!) | P11 (AD-5) | 🟢 5 Minuten statt Setup | |
| C2 | **mCaptcha: läuft bereits zentral** auf `captcha.saas-ctrl.com` (Infra-ADR-013) — nur ENV anbinden + Sitekey anlegen (AD-7 ✓) | P2 Login, P16 Public-Forms/Booking | 🟢 anbinden statt aufsetzen | |
| C3 | **Hosting/Deploy (Infra-ADR-009/010, ersetzt Fly/Scaleway — `fly.toml` entfernt, `deployment-region-provider-pinning.md` stale!):** Staging auf **`staging-1`** via zentrale **Coolify**-Control-Plane (Muster: Boilerplate läuft dort mit 8 Apps) · Prod = Hetzner-Projekt `HiveGrid` (`hivegrid-db`/`-app`/`-worker`/`-lb`) · Registry = Gitea, build-once/promote (Infra-ADR-005). **Staging (User-Entscheidung 12.06., revidiert): ÖFFENTLICH** auf `staging-app.hivegrid.de` + `staging-api.hivegrid.de` (beide unter dem `*.hivegrid.de`-Wildcard-Zertifikat, kein VPN). **Pflicht-Härtung dafür:** (a) **NUR synthetische Daten** — niemals echte Kunden-/Geerkens-Daten auf Staging (DSGVO; Demo-Seeds reichen), (b) eigene Secrets/Keys je Umgebung (Stripe Test-Mode, eigene OAuth-Apps, eigene Webhook-Secrets), (c) Webhooks auf staging-api mit HMAC-Signaturpflicht + Rate-Limit, (d) `X-Robots-Tag: noindex` + robots.txt deny, (e) Login wie Prod (TOTP), optional IP-Allowlist nachrüstbar. OAuth-Callbacks: Staging-Redirect-URIs (`staging-app.…`) bei KWP/Google mitregistrieren | ab W1 (CI/Gates brauchen Staging!) | Mitte W1 | |
| C3 | **Hosting/Deploy (Infra-ADR-009/010, ersetzt Fly/Scaleway — `fly.toml` entfernt, `deployment-region-provider-pinning.md` stale!):** Staging auf **`staging-1`** via zentrale **Coolify**-Control-Plane (Muster: Boilerplate läuft dort mit 8 Apps) · Prod = Hetzner-Projekt `HiveGrid` (`hivegrid-db`/`-app`/`-worker`/`-lb`) · Registry = Gitea, build-once/promote (Infra-ADR-005). **Staging (User-Entscheidung 12.06., revidiert): ÖFFENTLICH** auf `staging-app.hivegrid.de` + `staging-api.hivegrid.de` (beide unter dem `*.hivegrid.de`-Wildcard-Zertifikat, kein VPN). **Pflicht-Härtung dafür:** (a) **NUR synthetische Daten** — niemals echte Kunden-/Geerkens-Daten auf Staging (DSGVO; Demo-Seeds reichen), (b) eigene Secrets/Keys je Umgebung (Mollie Test-Mode, Lago Staging, eigene OAuth-Apps, eigene Webhook-Secrets), (c) Webhooks auf staging-api mit HMAC-Signaturpflicht + Rate-Limit, (d) `X-Robots-Tag: noindex` + robots.txt deny, (e) Login wie Prod (TOTP), optional IP-Allowlist nachrüstbar. OAuth-Callbacks: Staging-Redirect-URIs (`staging-app.…`) bei KWP/Google mitregistrieren | ab W1 (CI/Gates brauchen Staging!) | Mitte W1 | |
| C4 | **Postgres + Redis: Coolify-managed** (Staging auf `staging-1`, Prod auf `hivegrid-db` — Muster: mITapp/Boilerplate; PgBouncer auf staging-1 vorhanden, siehe `docs/ops/pgbouncer.md`) | alles | Mitte W1 | |
| C5 | **Domains/DNS + TLS (Cloudflare — `hivegrid.de`-Zone existiert bereits!):** `app.hivegrid.de` + `admin.hivegrid.de` (Host-Routing + Cookie-Domain-Trennung!) + `staging-app.hivegrid.de` + `staging-api.hivegrid.de` (öffentlich, siehe C3) — ein `*.hivegrid.de`-Wildcard deckt alle. Portal/Forms/Booking/Help/API sind **Pfade** unter app.* gemäß `INTEGRATION-CONTRACTS.md` §6 (korrigiert 12.06.) | P1 URL-Schemata, P19 Admin, C3 Staging | W1 | |
| C6 | (Optional) **Authentik** für Admin-SSO (AD-3, optional) | P19 | kann Fast-Follow sein | |
@@ -0,0 +1,186 @@
# Interim-UI Design-Standards — „David-nah bauen"
**Zweck:** Damit eine Interim-UI (AD-28, `.planning/INTERIM-UI-PLAYBOOK.md`) möglichst nah an Davids
kommenden finalen Subframe-Screens landet, baut der Dev nach **denselben** Layout-/Komponenten-/State-
Standards wie David. Dann ist Davids späterer Nachzug fast nur noch Feinschliff.
**Quelle & Autorität:** Diese Werte sind ein **In-Repo-Spiegel** von Davids Wireframe-Vault
`HiveGrid-Wireframes/` (Schwester-Ordner: `../HiveGrid-Wireframes/`, Gitea `git.tool-ctrl.de/david/Hivegrid-Wireframes`).
Autoritativ ist **immer der Vault**:
- `../HiveGrid-Wireframes/04 Standards/Subframe UI Standards/Layout/README.md` (Layout-Bibel, Entscheidungen D01–D0x)
- `../HiveGrid-Wireframes/Tickets/Fertige Tickets/D-D0b Layout Standards.md` (Handoff der Layout-Standards)
- `../HiveGrid-Wireframes/01 Regeln/Wireframing Playbook.md` + `Screen Agent Workflow.md` (Bau-/State-Regeln)
- `../HiveGrid-Wireframes/02 Wireframes/Screen Paket Tracker.md` (Screen-/Flow-Liste, Subframe-Links)
Der Vault liegt nicht im Cloud-Repo (CI/andere Maschinen haben ihn evtl. nicht) — deshalb dieser Spiegel.
**Bei Zweifel/Drift den Vault lesen und diese Datei nachziehen.** Davids Werte gewinnen.
---
## 0. Grundprinzip — Component-Defaults statt manueller Tailwind-Overrides
Davids härteste Bau-Regel (Layout-README, „Nutzung bei Subframe-MCP-Arbeit"):
> Komponenten-Defaults und Varianten verwenden, wenn sie den Standard bereits korrekt abbilden. Keine
> manuellen Tailwind-Overrides für Werte, die bereits über Auto Layout, Komponenten oder Varianten
> abgedeckt sind.
Für uns: **`~/ui`-Component-Defaults sind führend.** Wenn `Button`, `TextInput`, `Table`, `Dialog`, `Card`
das Padding/Gap/die Höhe schon intern richtig haben → Component + passende Variante nutzen, **nicht**
Höhen/Paddings mit Tailwind nachbauen. Manuelle Werte nur für feste Größen, Max-Breiten, Scroll/Overflow
oder responsive Constraints. Die Zahlen unten sind zum Prüfen/Setzen dort, wo eine Komponente sie nicht
selbst mitbringt — nicht zum Überschreiben funktionierender Defaults.
---
## 1. Spacing-Skala (D01) — 8px-Rhythmus
Erlaubte Layout-Werte (px): **4, 8, 12, 16, 20, 24, 32, 40, 48, 64, 80, 96**.
- `8` = Basislogik für normale Gaps/Paddings. `4/12/20` = kompakte Zwischenwerte (Field-Anatomy, kleine
Icon-/Text-Abstände). `2` nur für Border-/Focus-/Icon-Feintuning.
- **Nicht neu einführen:** 6, 10, 14, 28, 36, 44 — nur wenn eine bestehende Komponente/ein Token sie schon mitbringt.
## 2. Page / Card / Grid-Padding (D02)
| Rolle | Wert |
|---|---|
| Page Padding (Standard & Minimum) | **24px** (nie darunter) |
| Card / Surface Padding | **16px** |
| Detail-/Editor-Surface Padding | **16px** |
| Grid / Card Gap | **16px** (kompakter Sonderfall 12px) |
| Section Gap (größere Bereiche) | **24px** |
Kompakte Momente reduzieren **nur lokale Gaps**, nie das Page Padding. `32px` ist **kein** freier Standard
für Detail-/Editorflächen — Ausnahme: die `Dialog`-Komponente darf eine 32px-Variante haben. Mobile-Padding = Backlog.
## 3. Text-Rhythmus (D03) — nur mit 150%-Text-Tokens für beschreibende Texte
| Zwischenraum | Gap |
|---|---|
| Page Title → Page Description | **0px** |
| Page Header → erster Content | **24px** |
| Section Title → Section Description | **0px** |
| Section Description → Section Content | **12px** |
`0px` ist ein bewusster Gap (kein Auto-Layout-Abstand). Beschreibungen nutzen **150%-Line-height-Tokens**
(auch bei einer Zeile). `0`-Line-height-Token nur für garantierte Einzeiler (Button-Label, Badge, Tab,
kompakte Truncation-Zelle), **nie** für umbrechbaren Text.
## 4. Formulare & Field-Anatomy
- **Field-Gap** zwischen kompletten Feld-Komponenten: **16px** (kompakt 12px; bewusst getrennte Sections 24px).
- **Field-Anatomy** (Reihenfolge `label → helpText → Control → message`):
`label → helpText` **8px** · `helpText → Control` **6px** · `Control → message` **4px** · `message` = 12px, Medium.
- Beschreibungen/Hilfetexte über die Component-Props/Slots füllen, **nicht** als freie Textblöcke daneben bauen.
- **Feldnamen (Vokabular):** unterer Field-Text heißt immer **`message`** (nicht `label`/`helper`/`helpText`).
- Form-Input horizontales Innenpadding: **12px** (ohne Icon / Rand↔Icon), Icon↔Text **8px** (Textarea ausgenommen).
- Form-Input-Radius: **`Radius Small` = 4px**.
## 5. Control-Höhen & Radius
- **Control-Höhen (exakt):** Small **32px** · Medium **36px** · Large **48px**. **Medium ist Default**
(heißt aber `Medium`, nicht `Default`). Gilt für Button, IconButton, TextInput, NumberInput, Select,
Multiselect(+Tags), Combobox, SearchInput, DatePicker (Single), ToggleGroup, ButtonGroup.
- **Radius-Tokens:** Small **4px** · Medium **8px** · Large **12px** · Round **9999px**.
- **Button:** Default-Variante = Medium = 36px hoch. Button-Höhe/-Padding **nicht** im Screen nachbauen.
- **Table:** Row-Height/Cell-Padding/Header-Dichte über die Table-Komponente (Carbon-Referenz: Rows
xs 24 / sm 32 / md 40 / lg 48 / xl 64, Column-Padding 16). Dichte wählen statt eigene Tabellenwerte setzen.
## 6. Dialoge / Modale (D07) — echte Dialog-Struktur
**Modal = in `~/ui` immer die echte `Dialog`-Komponente** (`DialogLayout → Dialog → Dialog.Content`).
**Verboten:** Modal aus `Card`, freiem `div`, absolutem Overlay, Blur-Hintergrund oder Fullscreen-Wrapper
nur visuell nachbauen. Vor Handoff prüfen, dass `Dialog` importiert ist und `Dialog.Content` im JSX steht.
**Ort-Begriffe:** `Page` = eigener App-Ort (Settings etc.) · `Dialog` = temporäres zentriertes Overlay ·
`Fullscreen Dialog` = viewport-füllendes Overlay (nicht für dauerhafte Bereiche) · `Drawer/Side Panel` =
seitliches Overlay (eigene Breitenregel).
| Dialog-Größe | Breite | wofür |
|---|---|---|
| XS | **420px** | kurze Bestätigung / knapper Hinweis |
| S | **560px** | kleines Formular, wenige Felder |
| M | **720px** | normales Formular / kurze Section-Struktur |
| L | **1200px max** | großer Editor mit Navigation/Suche |
- **Dialog-Padding:** **16px** Default, **32px** ruhigere Variante — über die Dialog-Variante, nicht per Override.
- **Dialog-Interne Gaps:** Title→Description **8px** · Field/Form-Row **16px** · Section-Gap **24px** ·
Footer-Button-Gap **8px**. **Footer-Actions rechts** ausgerichtet.
- Breitenklasse bleibt fix (wächst nicht auf 4K mit), nur der Overlay-Rand wächst; auf kleinen Viewports
Max-Width relativ zum Viewport. Fullscreen: Rand 24px/Seite, `width: calc(100vw - 48px)`, `height: calc(100vh - 48px)`.
## 7. Zustände (D09) — inline State-Komponenten, keine eigenen Screens
**Empty / Loading / Permission-Denied sind inline State-Komponenten, keine Modale und keine eigenen Screens**,
solange sie dem Standard entsprechen. In `~/ui`: `EmptyState`, `ErrorState`, `SkeletonText`/`Loader`, plus
Permission-Denied. (Erinnerung DoD/Bauprinzip: Permission-Denied zeigt **404, nicht 403**.)
**Nur** blockierende / sicherheits- / copy-kritische Zustände bekommen eigene Screens. Ein **blockierender
Error-State** braucht: Titel/Kernaussage · Ursache in Nutzersprache · Primäraktion · Sekundäraktion (zurück/Support).
## 8. Banner / Toast / Modal / Inline / Screen — Entscheidungs-Hierarchie
| UI-Form | Wann | `~/ui` | eigener Screen? |
|---|---|---|---|
| **Inline** | betrifft genau ein Feld, dort korrigierbar (Pflichtfeld, ungültige Mail, 2FA-Code falsch) | Field-`message` | nein |
| **Screen-Banner** | betrifft aktuellen Screen/Formular (Login falsch, Speichern fehlgeschlagen, Integration getrennt) | `BannerAlert`/`Alert` | nur wenn optisch/fachlich wichtig |
| **Globaler Banner** | App/Shell/Tenant (Zahlung offen, Tenant gesperrt, Wartung) | App-Shell/D0 | D0 |
| **Toast** | kurze, nicht-blockierende Rückmeldung (gespeichert, kopiert, Export gestartet) | `Toast` | nein |
| **Modal/Dialog** | Nutzer bestätigt/entscheidet kurz, bleibt im Kontext (löschen, Rolle wechseln) | echter `Dialog` | falls eigener Entscheidmoment |
| **Eigener Screen** | Nutzer blockiert / neuer Flow-Status (Link abgelaufen, Magic-Link gesendet, Account gesperrt) | eigene Route | ja |
Faustregel: direkt im Feld korrigierbar → Inline; bleibt im Screen → Banner; kurze Rückmeldung → Toast;
muss bestätigen → Modal; blockiert → eigener Screen. Sicherheit/Zugang/Vertrauen/Recht → **eher Banner/Screen, nie Toast**.
## 9. Copy ist eine Design-Entscheidung
Bei **copy-kritischen** Zuständen (Auth, Sicherheit, Zugang, Erfolg — z.B. „Magic-Link gesendet",
„E-Mail oder Passwort ist nicht korrekt" (neutral, keine Enumeration), „Account gesperrt") den Text
**nicht selbst erfinden**. Entweder die im Screen-Paket dokumentierte Copy übernehmen oder Platzhalter-Copy
**klar als interim markieren** (i18n-Key mit `TODO`/interim-Hinweis), damit Davids Copy leicht einsetzbar ist.
Field-Validierung (Pflichtfeld, Passwort zu schwach) bleibt beim Form-Kit, kein eigener Zustand — außer fachlich/rechtlich wichtig.
## 10. Weitere Konventionen
- **Default-Ansicht = Admin**, außer der Flow braucht sichtbar eine andere Rolle (dann Variante).
- **Vokabular:** `Medium` (nicht `Default`) · `message` (nicht `label`) · `error` ≠ `destructive` (getrennt halten) ·
States `default/hover/active/focus/disabled/loading/error/selected` (`focus`≠`selected`).
- **Prop-Reihenfolge** (falls du Component-APIs baust): Zustand → Erscheinung → Größe → Layout-Sonderfälle →
Sichtbarkeit → Inhalt/Slots (Details in `07_Global-Rules.md` des Vaults).
---
## 11. Verzahnung mit Davids Screen-Format (gleiche Sprache sprechen)
Damit der spätere Swap trivial wird, benennt und strukturiert die Interim-UI wie Davids Vault:
- **Screen-IDs übernehmen:** `S` = eigener Screen · `V` = Variante · `M` = Modal · `D` = dokumentierter
Zustand (kein Frame). Namen 1:1 wie im **Screen Paket Tracker** (z.B. „S01 Login", „S04 2FA Code eingeben",
„M02 Rolle bearbeiten", „V01 Login falsche Daten"). Interim-`<XPage>`-Dateien/Komponenten nach diesen IDs benennen.
- **Join-Schlüssel `ticket: D-*`:** Davids Pakete/Screens tragen `ticket: D-P2` (usw.) im Frontmatter — das
ist die Brücke zwischen unserem Cloud-Dev-Ticket und seinen Screens. Im Interim-UI-Vermerk der Ticket-Doc
das zugehörige `D-*` nennen (steht ohnehin im „blocked-by"/Screens-Block).
- **Subframe-Links:** Sobald Davids Paket auf `In Subframe`/`Review`/`Fertig` steht, hat **jeder Screen einen
`design/`-Deep-Link** (Team `f023064a5ca0`) im Tracker + Screen-Frontmatter, das Paket einen `flow/`-Link.
Diese sind die Vorlage für die Interim-UI (annähern) und später die Quelle des finalen Nachzugs.
- **Zustände-Tabelle** je Screen (`Zustand | Beschreibung | Umsetzung` mit Kanon Leer/Laden/Fehler/Keine
Berechtigung, Umsetzung-Enum Bauen/Wiederverwenden/Nur dokumentieren/Später/Raus) übernehmen, damit klar
ist, was Interim baut vs. dokumentiert.
**Nachzug-Trigger:** Wenn Davids Handoff kommt (neue/aktualisierte Datei in
`../HiveGrid-Wireframes/Tickets/Fertige Tickets/D-*.md` + Paket-Status im Tracker auf `Fertig`), zieht der
Dev die `design/`-Screens nach (siehe Refactor-Ablauf im Playbook), setzt den `🎨 design-nachzug`-Tag auf
erledigt und zieht die Visual-Baseline neu.
---
## 12. Checkliste „David-nah" (pro Interim-Screen)
- [ ] Aus `~/ui`-Components + Layout gebaut, VM als Prop, kein handgeschriebenes Tailwind für Spacing/Höhen.
- [ ] Page 24 · Card/Surface 16 · Grid-Gap 16 · Section-Gap 24 · Field-Gap 16 · Text-Rhythmus (0/24/0/12).
- [ ] Controls: Medium(36) Default, Radius small(4) an Inputs; Button/Table-Dichte aus der Komponente.
- [ ] Modale als echter `Dialog` (`DialogLayout → Dialog → Dialog.Content`), Größe XS420/S560/M720/L1200, Padding 16/32, Footer rechts.
- [ ] Empty/Loading/Permission als inline State-Komponenten (Permission-Denied = 404), keine Extra-Screens.
- [ ] Banner/Toast/Modal/Inline/Screen nach Hierarchie (§8) gewählt.
- [ ] Copy-kritische Texte nicht erfunden — dokumentierte Copy oder interim-markierter Platzhalter.
- [ ] Screen-IDs/Namen an Davids Tracker angelehnt; `D-*`-Bezug im Interim-UI-Vermerk notiert.
@@ -0,0 +1,166 @@
# Interim-UI-Playbook — Vorschau-Screens aus `~/ui` vor Davids Delta
**Stand:** 2026-07-05 · **Verbindliche Grundlage:** `docs/ARCHITECTURE-DECISIONS.md` **AD-28**
**Verwandt:** `.planning/INTERIM-UI-DESIGN-STANDARDS.md` (Davids Layout-/Komponenten-/State-Werte) ·
`DESIGNER-FIRST-PLAYBOOK.md` · `docs/INTEGRATION-CONTRACTS.md` · `ticket-docs/README.md`
## Warum dieses Playbook
Viele Dev-Tickets (R-P8/9/11/12, R-P2, AK-P3/4/14/16/17/19/20/21, v2: VR-P22b/VR-MCP) sind
serverseitig fertig, bleiben aber **On Hold**, weil die finale Optik an „Davids Delta" im
jeweiligen `D-*`-Screen-Ticket hängt — und diese Screens dauern noch. Die Component-Basis liegt
aber **schon im Repo**: `apps/web/app/ui/` (Alias `~/ui/*`) enthält 79 gesyncte Subframe-Components
+ die Layouts `DefaultPageLayout`/`DrawerLayout`/`DialogLayout` (gepflegt über `.subframe/sync.json`).
Statt auf David zu warten, baut der Dev **jetzt** aus denselben `~/ui`-Components eine **Interim-UI**
(Vorschau-Screen), stellt das Ticket damit fertig, testet es und schiebt es auf **Done**. Der spätere
Tausch gegen Davids finalen Subframe-Screen ist ein **getrackter Nachzug** hinter der stabilen
VM/Loader-Grenze — ein Component-Swap, kein Rewrite (Hot-Swap-Gedanke, M015/M017).
> **Begriff:** „Interim-UI" / „Interim-Screen". Nicht verwechseln mit `packages/ui-mock` — das ist
> der M017-Hot-Swap-Drop-in (bewusst hässliches Alt-Markup zum Beweis der Austauschbarkeit), etwas
> völlig anderes.
## Die goldene Regel (macht „später wenig Arbeit" real)
Ein Interim-Screen **muss**:
1. aus `~/ui`-Components + einem `~/ui/layouts/*` komponiert sein — **nie** handgeschriebenes
Tailwind-Markup (Negativ-Beispiel: `apps/web/app/routes/app.$tenant/tasks._index.tsx`, das eine
handgeschriebene `<table>` rendert und „Subframe polish follows David's D-P4 delta" trägt);
2. seine Daten aus dem bestehenden **View-Model** (`packages/view-models/**`) beziehen, das als Prop
hereingereicht wird;
3. alle statischen Texte über i18n-Keys (`t()`) auflösen.
Nur so ist Davids Delta hinterher hauptsächlich Anordnung/Feinschliff, und der Swap bleibt billig.
Wird Interim-UI als handgeschriebenes Tailwind gebaut, ist der Nachzug ein Neubau — das verfehlt den
ganzen Zweck und ist ein PR-Block wie ein Anti-Listen-Verstoß.
**Nach Davids Standards bauen (`~/ui`-Defaults + Design-Werte):** Damit die Interim-UI wirklich nah an
Davids kommende Pages kommt, folgt der Screen denselben Layout-/Komponenten-/State-Standards, nach denen
David selbst baut — festgehalten (mit exakten Werten: Page-Padding 24, Card 16, Dialog-Größen, Control-Höhen,
State-Regeln, Screen-Naming) in **`.planning/INTERIM-UI-DESIGN-STANDARDS.md`** (In-Repo-Spiegel von Davids
Vault `../HiveGrid-Wireframes/`). Kernpunkt: **`~/ui`-Component-Defaults sind führend** — Höhen/Paddings/
Dialog-Struktur nicht mit Tailwind nachbauen, wenn die Komponente/Variante sie schon abbildet. Modale immer
als echter `Dialog` (`DialogLayout → Dialog → Dialog.Content`), nie als Card-Fake. Diese Doc **vor dem
Screen-Bau lesen**.
## Wo der Interim-Screen lebt
```
apps/web/app/components/screens/<domain>/<Screen>Page.tsx
└─ export function <Screen>Page({ vm, actions }: { vm: <Screen>VM; actions: <Screen>Actions }) { … }
komponiert ~/ui-Components + DefaultPageLayout, rendert vm.data, ruft actions.onX
```
Die Route bleibt beim etablierten Muster und rendert nur:
```tsx
// apps/web/app/routes/app.$tenant/<modul>._index.tsx
export default function XRoute() {
const { vm } = useLoaderData<LoaderData>();
return <XPage vm={vm} actions={{ onEdit, onArchive }} />;
}
```
Das ist genau das AGENTS.md-Zielmuster `<XPage vm={} actions={} />` — die Interim-UI füllt die heute
fehlende „mittlere Komposition" zwischen VM und `~/ui`-Primitives.
### Layer-Regeln bleiben unangetastet
Die drei Anti-Patterns (AGENTS.md) gelten weiter:
- kein `@prisma/client`/`~/db` in `packages/ui-app/**`;
- kein `packages/ui-subframe`-Direktimport in `app/routes/**`;
- kein `useLoaderData` in `packages/ui-app/**` — VM wird als Prop injiziert.
`~/ui`-Direktimport in `apps/web/app/components/**` bzw. `app/routes/**` ist **erlaubt** — er verletzt
kein Anti-Pattern (die verbieten nur `packages/ui-subframe`-Direktimporte). Wer den formalen
ui-app/ui-subframe-Adapterpfad nutzen will, kann das; für Interim-Screens ist die direkte
`~/ui`-Komposition der pragmatische, schnellere Weg.
## Done-Semantik (AD-28)
Ein UI-lastiges Ticket ist **Done-fähig**, sobald:
- **Dev-DoD 100 %** (alle nicht-Optik-Punkte grün) **und**
- **Interim-Screen** aus `~/ui` steht (goldene Regel erfüllt) **und**
- **Tests grün** (siehe DoD-Mapping unten).
Die **finale Subframe-Optik** ist dann **kein Done-Blocker** mehr, sondern ein **getrackter Nachzug**.
`/planning` bleibt die **Single Source of Truth** — der Nachzug wird zuerst in der Ticket-Doc markiert,
Plane spiegelt nur:
- **`.planning/ticket-docs/<ID>.md` (führend):** der standardisierte **`🎨 design-nachzug`-Tag** direkt
unter der Header-Tabelle (Format unten) **plus** der ausführliche Interim-UI-Vermerk in der Sektion
**„Dev-Nachzug (nach Davids `D-*`-Delta)"** (Muster unten),
- **Plane-Label `design-nachzug` (spiegelt die Doc):** nur für Board-Queries; wird aus dem Doc-Tag
abgeleitet, nie umgekehrt,
- die Interim-Visual-Baseline ist als „interim" markiert und wird beim Nachzug neu gezogen.
### Header-Tag in der Ticket-Doc (Single Source of Truth)
Direkt unter die Header-Tabelle der Ticket-Doc, damit `/planning` per `grep` autoritativ auswertbar ist
(`grep -rl "design-nachzug: offen" .planning/ticket-docs/`):
```md
**🎨 design-nachzug: offen** (seit YYYY-MM-DD) — Interim-UI aus `~/ui` steht; finale Subframe-Optik aus `D-<XXX>` ausstehend.
```
Beim David-Nachzug wird der Tag auf erledigt gesetzt (Historie bleibt in der Doc):
```md
**🎨 design-nachzug: erledigt** (YYYY-MM-DD) — finale `D-<XXX>`-Optik integriert, Visual-Baseline neu gezogen.
```
Done setzen weiterhin nur **mit expliziter Freigabe** und nach dem Abschluss-Audit (`plan-ticket` §8).
## DoD-Mapping — was Interim-UI erfüllt vs. was Nachzug bleibt
| DoD | Punkt | Mit Interim-UI |
|---|---|---|
| B 6–10 | Sidebar · Permission-Gate · Route · Breadcrumbs · i18n | **jetzt erfüllbar** |
| B 11/12 | Mobile-Tab-Bar · Customer-Portal | nur „falls relevant"; bei PWA-only (AD-17) i. d. R. Nachzug/deferred |
| D 18 | VM-Unit-Test | **jetzt erfüllbar** (reine Funktion) |
| D 19 | Smoke-Test `e2e/smoke/<modul>.spec.ts` | **jetzt erfüllbar** gegen Interim-Screen |
| D 20 | Hot-Swap-Drill (`packages/ui-mock`) | **jetzt erfüllbar** |
| D 21 | Visual-Regression (M016) | gegen **Interim-Baseline** erfüllbar; **Re-Baseline-Pflicht** beim David-Nachzug |
| — | finale Subframe-Optik | **Nachzug** (Label `design-nachzug`) |
## Refactor-Ablauf, wenn Davids Delta landet
**Davids Delta erscheint als:** neue/aktualisierte Datei in `../HiveGrid-Wireframes/Tickets/Fertige Tickets/D-*.md`
+ Paket-Status im `Screen Paket Tracker` auf `Fertig` + `design/`-Subframe-Links je Screen (Team `f023064a5ca0`).
1. VM/Loader/Route **unverändert** lassen.
2. Internals von `<XPage>` gegen Davids Subframe-Screen tauschen (gleiche `~/ui`-Components → meist
Umstellen/Neuanordnen, kein neuer Datenpfad).
3. Visual-Baseline neu ziehen (Interim-Snapshot ersetzen), Smoke/Hot-Swap erneut grün.
4. Plane-Label `design-nachzug` entfernen; Umsetzungs-Notiz + „Dev-Nachzug"-Sektion der Ticket-Doc
mit Datum + Proof ergänzen.
## Pro-Ticket-Checkliste (Interim-UI)
- [ ] Interim-`<XPage>` in `apps/web/app/components/screens/<domain>/` aus `~/ui` + Layout, VM als Prop.
- [ ] Route rendert `<XPage vm={} actions={} />`; kein handgeschriebenes Tailwind.
- [ ] **Davids Design-Standards befolgt** (`.planning/INTERIM-UI-DESIGN-STANDARDS.md`): Spacing/Padding/
Dialog-Struktur/Control-Höhen/States aus `~/ui`-Defaults; Screen-IDs/Naming an Davids Tracker angelehnt.
- [ ] Reachability B 6–10 verdrahtet (Sidebar/Permission/Route/Breadcrumb/i18n).
- [ ] Tests: VM-Unit (18) · Smoke (19) · Hot-Swap (20) · Visual-Regression gegen Interim-Baseline (21).
- [ ] Ticket-Doc (führend): `🎨 design-nachzug: offen`-Tag unter der Header-Tabelle **und** Interim-UI-Vermerk in der „Dev-Nachzug"-Sektion (Muster unten).
- [ ] Plane-Label `design-nachzug` gesetzt (spiegelt den Doc-Tag).
- [ ] Done nur bei Dev-DoD 100 % + Interim-UI + Tests grün + Freigabe.
### Muster: Interim-UI-Vermerk in „Dev-Nachzug (nach Davids `D-*`-Delta)"
```md
**Interim-UI (Vorschau, Stand YYYY-MM-DD):** Screen aus `~/ui` gebaut —
`apps/web/app/components/screens/<domain>/<Screen>Page.tsx`. Proof: Smoke `e2e/smoke/<modul>.spec.ts`,
Hot-Swap-Drill grün, Interim-Visual-Baseline `<pfad>` (als interim markiert).
Refactor-Nachzug offen: finale Subframe-Optik aus `D-<XXX>` (Plane-Label `design-nachzug`).
```
## Nachzug-Tracking
- **`/planning` ist führend:** Der `🎨 design-nachzug`-Tag in `.planning/ticket-docs/<ID>.md` ist die
autoritative Quelle. Offene Nachzüge findet man per `grep -rl "design-nachzug: offen" .planning/ticket-docs/` —
unabhängig von Plane und ohne API-Zugang.
- **Plane spiegelt:** Das Plane-Label **`design-nachzug`** (anlegbar über `.planning/plane-sync/scrub_plane.py`
oder manuell via `POST /labels/`) wird aus dem Doc-Tag abgeleitet und dient nur der Board-Sicht — nie
umgekehrt. Divergiert Plane vom Doc, gewinnt das Doc.
- Es gibt **kein** Skript, das Plane-Kommentare postet. Kommentar/Status weiter manuell via API
(`POST /issues/{iid}/comments/`, Identität vorab `GET users/me`); Done nur mit Freigabe.
@@ -32,7 +32,7 @@ Beim Planen einer Phase liest der Planner/Researcher diese Specs + Milestone-PLA
## Phase 4: Files & Tasks
**Specs:** `_bmad-output/specs/M23-files.md`, `M26-tasks.md`
**Milestone-PLANs (7):** M052-datei-upload, M053-speicher-backend-scaleway, M054-datei-permissions-sharing-versionierung, M055-datei-suche-tagging-archivierung, M071-aufgaben-crud-zuweisung, M072-aufgaben-listen-pro-modul, M073-aufgaben-erinnerungen-push-email
**Milestone-PLANs (7):** M052-datei-upload, M053-speicher-backend-hetzner, M054-datei-permissions-sharing-versionierung, M055-datei-suche-tagging-archivierung, M071-aufgaben-crud-zuweisung, M072-aufgaben-listen-pro-modul, M073-aufgaben-erinnerungen-push-email
> M074-aufgaben-sync-erp → Phase 10 verschoben (2026-06-10): braucht Apps-Engine (P5) UND KWP-Sync-Foundation (P10) — ist inhaltlich eine KWP-Integration. Task-Entity-Verknüpfung zu CRM-Entitäten (M071 ↔ M042): Task-Kern hier, Entity-Links werden in Phase 6 nachverdrahtet.
> Scope-Schnitt 2026-06-12 (Boilerplate-Recheck): **M054 gedünnt** — Versionierung, Office-/Video-Previews, Resume-Upload → v2; v1 = Ersetzen + Bild-/PDF-Preview + **HEIC→JPG-Konvertierung (Pflicht — iPhone-Fotos der Monteure!)**; Permissions/Sharing bleiben. **M055 gedünnt** — OCR/Inhalts-Volltext → v2; Dateiname+Tags via Meilisearch + Archiv-Flag bleiben. Boilerplate liefert S3-Adapter/Signed-URLs/ClamAV (`packages/storage/`; ClamAV läuft zentral, Infra-ADR-014).
> ⚠ **Infra-Wechsel 12.06. (M053):** Speicher-Backend ist **Hetzner S3-kompatibles Object Storage** (per-Product-Bucket im Hetzner-Projekt `HiveGrid`, Infra-ADR-009) — NICHT mehr Scaleway (M053-PLAN-Name ist historisch). S3-API identisch, nur Endpoint/Credentials.
@@ -48,13 +48,13 @@ Beim Planen einer Phase liest der Planner/Researcher diese Specs + Milestone-PLA
## Phase 7: Lead-Inbox
**Specs:** `_bmad-output/specs/M02-lead-inbox.md`
**Milestone-PLANs (4):** M047-lead-inbox-quellen-adapter, M048-lead-routing-pro-quelle, M050-spam-duplikat-erkennung, M051-lead-status-pipeline-verlust-gruende
> M049-auto-verkaufsfall-anlage → Phase 8 verschoben (2026-06-10, Dependency-Inversion): braucht die Deal-Entity (M060) — Deal-Verhalten gehört in die Deal-Phase. P7 liefert den Extension-Point (Lead-Status `qualified` + Event), P8 verdrahtet die Auto-Anlage.
> M049-auto-projekt-anlage → Phase 8 verschoben (2026-06-10, Dependency-Inversion): braucht den `Projekt`-Kern (M060). P7 liefert den Extension-Point (Lead-Status `qualified` + Event), P8 verdrahtet die automatische Projekt-Anlage in der Sales-Pipeline.
## Phase 8: Projekt & Pipelines (Kern)
**Specs:** `_bmad-output/specs/M03-sales-pipeline.md` *(Bezeichner historisch; Inhalt projekt-zentrisch re-gemappt — Single Source: `.planning/ENTITY-MODEL.md`, AD-20)*
**Milestone-PLANs (7):** M049-auto-verkaufsfall-anlage, M059-sales-pipeline-konfiguration, M060-verkaufsfall-crud, M061-sales-kanban-ui, M062-verlust-gewinn-gruende, M063-auto-auftragsprojekt-setter-closer, M064-pipeline-reporting-kwp-sync
> 🔴 **Pivot-Re-Map (AD-20, R-REFIT 16.06.):** Die Milestone-NAMEN bleiben als Datei-Referenz, ihr **Inhalt ist projekt-zentrisch re-gemappt** — „Verkaufsfall"/„Deal" = ein `Projekt`-Kern in der Sales-Pipeline-Sicht (kein eigenes Deal-Objekt), `M060-verkaufsfall-crud` = Projekt-CRUD; Pipeline-Engine baut zusätzlich PipelineAccess/-FieldConfig/-FieldVisibility (Field-Level-Rechte v1). Maßgeblich ist ENTITY-MODEL.md, nicht der historische Bezeichner.
> M049 (aus Phase 7, 2026-06-10): Auto-Verkaufsfall-Anlage bei Lead-Qualifizierung — hier, weil der `Projekt`-Kern (M060) hier entsteht.
**Milestone-PLANs (7):** M049-auto-projekt-anlage, M059-sales-pipeline-konfiguration, M060-projekt-crud, M061-sales-kanban-ui, M062-verlust-gewinn-gruende, M063-auto-auftragsprojekt-setter-closer, M064-pipeline-reporting-kwp-sync
> 🔴 **Pivot-Re-Map (AD-20, R-REFIT 16.06.):** Die Milestone-Inhalte sind projekt-zentrisch re-gemappt — „Verkaufsfall"/„Deal" = ein `Projekt`-Kern in der Sales-Pipeline-Sicht (kein eigenes Deal-Objekt), `M060-projekt-crud` = Projekt-CRUD; Pipeline-Engine baut zusätzlich PipelineAccess/-FieldConfig/-FieldVisibility (Field-Level-Rechte v1). Maßgeblich ist ENTITY-MODEL.md, nicht der historische Bezeichner in älteren Specs.
> M049 (aus Phase 7, 2026-06-10): Auto-Projekt-Anlage bei Lead-Qualifizierung — hier, weil der `Projekt`-Kern (M060) hier entsteht.
## Phase 9: Projekt & Pipelines (Auftrag)
**Specs:** `_bmad-output/specs/M04-auftragspipeline.md` *(Bezeichner historisch; Inhalt projekt-zentrisch re-gemappt — Single Source: `.planning/ENTITY-MODEL.md`, AD-20)*
@@ -71,7 +71,7 @@ Beim Planen einer Phase liest der Planner/Researcher diese Specs + Milestone-PLA
## Phase 11: Globale Suche & Dashboard
**Specs:** `_bmad-output/specs/M24-globale-suche.md`, `M20-dashboard.md`
**Milestone-PLANs (5):** M056-such-index, M057-permission-aware-search, M058-such-ui-saved-searches, M147-dashboard-layout-widget-engine, M148-dashboard-standard-widgets
> Scope-Schnitt 2026-06-11: M149-custom-widgets + M150-export-report → v2. Suche v1 = **Keyword-only** (Meilisearch); pgvector/Hybrid-RRF → v2 mit HiveMind (AD-5 sequenziert).
> Scope-Schnitt 2026-06-11/AD-26: M149-custom-widgets + M150-export-report → v2. Suche = **Keyword-only** (Meilisearch); pgvector/Hybrid-RRF/HiveMind entfällt bzw. ruht als v3-Kandidat.
> Scope-Schnitt 2026-06-12 (Boilerplate-Recheck): **M147 gedünnt** — 3 feste Rollen-Layouts (GF/Büro/Monteur) statt Drag&Drop-Grid (react-grid-layout wäre Neueinkauf); Layout-Persistenz pro User → v2. Widget-Registry bleibt voll (Inversion `M064→M147` registriert Sales-Widgets darüber).
## Phase 12: Automation-Engine
@@ -96,7 +96,7 @@ Beim Planen einer Phase liest der Planner/Researcher diese Specs + Milestone-PLA
## Phase 16: Kalender, Booking, Forms & Signatur
**Specs:** `_bmad-output/specs/M25-calendar-sync.md`, `M06-booking.md`, `M05-form-builder.md`, `M07-angebot-signatur.md`
**Milestone-PLANs (15):** M118-calendar-google-adapter, M119-calendar-termin-sync, M120-calendar-display-modul-verknuepfung, M121-form-editor, M122-form-veroeffentlichung, M123-form-submission-eingang, M124-form-conditional-display, M125-form-rate-limit-captcha-dsgvo, M126-booking-time-slot, M127-booking-verfuegbarkeit, M128-booking-bestaetigung, M129-booking-stornierung, M130-angebot-erstellung, M131-angebot-pandadoc-docusign, M132-angebot-status-tracking
**Milestone-PLANs (15):** M118-calendar-google-adapter, M119-calendar-termin-sync, M120-calendar-display-modul-verknuepfung, M121-form-editor, M122-form-veroeffentlichung, M123-form-submission-eingang, M124-form-conditional-display, M125-form-rate-limit-captcha-dsgvo, M126-booking-time-slot, M127-booking-verfuegbarkeit, M128-booking-bestaetigung, M129-booking-stornierung, M130-angebot-erstellung, M131-angebot-self-hosted-e-signatur, M132-angebot-status-tracking
> Scope-Schnitt 2026-06-12: **M122 gedünnt** — personalisierte Links (Tracking/Ablauf/Nutzungs-Limit) + JS-Snippet → v2; öffentliche URL + QR-Code + iFrame-Embed bleiben (SHK-Kernfall). **M129 bleibt explizit VOLL** (User-Entscheidung: Verschiebung/Re-Assignment/No-Show in v1).
## Phase 17: HR-Hub
@@ -112,14 +112,14 @@ Beim Planen einer Phase liest der Planner/Researcher diese Specs + Milestone-PLA
## Phase 19: Plattform-Admin
**Specs:** `_bmad-output/specs/M37-plattform-admin.md`
**Milestone-PLANs (7):** M031-feature-flag-schalter-lifecycle, M032-feature-flag-filter-editor, M034-plattform-admin-auth, M035-plattform-admin-dashboard, M036-plattform-admin-mandanten-verwaltung, M037-plattform-admin-stellvertreter-anmeldung, M041-plattform-admin-system-health-audit-support
> Scope-Schnitt 2026-06-11: M033 (Flag-Massen-Statistik) + M040 (Plattform-Lead-Pipeline) → v2. **Neu in P19-Scope:** Tenant-Abo-Zusammenstellung durch Admin (Abo-Baukasten-Mechanik: Feature-Konnektoren pro Tenant schalten + Stripe-Positionen; Self-Service-UI → v2).
> Scope-Schnitt 2026-06-11: M033 (Flag-Massen-Statistik) + M040 (Plattform-Lead-Pipeline) → v2. **Neu in P19-Scope:** Tenant-Abo-Zusammenstellung durch Admin (Abo-Baukasten-Mechanik: Feature-Konnektoren pro Tenant schalten + Legacy-Provider-Positionen; Self-Service-UI → v2).
> Scope-Schnitt 2026-06-12 (Boilerplate-Recheck): **M038 + M039 (getrennte AdminUser-Identität + AdminRBAC-Parallelwelt) → v2** — v1 nutzt das Boilerplate-Modell (AdminUser als User-Extension + MFA-Step-up-Gate, `admin-mfa-gate.server.ts`). **Bindende Auflagen für M034:** (a) TOTP-Pflicht hart erzwingen, (b) Admin-User bekommen **keinen Tenant-Workspace** und erscheinen in keiner Mandanten-Mitgliederliste (Boilerplate-Default-Provisionierung abschalten!), (c) Host-Routing `admin.hivegrid.de` → Admin-Routen (Boilerplate-Code routet `/admin`-Pfad), (d) **Cookie-Domain-Trennung** App↔Admin. **M035 gedünnt:** Boilerplate-KPIs (Tenants/User/MRR); Churn/ARPU/Health-Kacheln → v2 (bei 1 Launch-Kunden sinnfrei).
> M031–M033 (Flag-Admin-UI, 2026-06-10 aus Phase 3 hierher verschoben): Vorbedingung M034 (Admin-Auth) liegt hier; die Flag-**Engine** (M006, `tenantHasFlag()`) liegt in Phase 1 und ist ab dort von allen Phasen nutzbar.
## Phase 20: Billing, Onboarding & Branding
**Specs:** `_bmad-output/specs/M28-subscriptions-billing.md`, `M29-onboarding.md`, `M31-branding.md`
**Milestone-PLANs (8):** M030-hell-dunkel-modus, M153-subscriptions-stripe, M154-subscriptions-plan-seat-modell, M155-subscriptions-trial-readonly, M156-subscriptions-metering-pay-as-you-go, M158-onboarding-shk-provisionierung, M159-onboarding-branchen-templates, M160-onboarding-test-daten
> ⚠ **M154-Recut bindend (2026-06-11):** Seat-Typ-Modell (Büro/Monteur) ist NICHT zu bauen — ersetzt durch **Abo-Baukasten**: Basis-Preis pro Nutzer + Feature-Module als zubuchbare Stripe-Positionen via Entitlement-Schicht (Flag-Engine `plan-tier`-Targeting × `packages/entitlements`). **Source-Spec:** `~/Documents/GitHub/archive/hivegrid-cloud/app/config/pricing-plans.ts` (Legacy D006: Core/Swarm/Colony, 14 Modul-Gates, 6 Quota-Dimensionen) — Struktur portieren, Preise/Tier-Zuschnitt = Kostenmatrix (Partner, AD-15). M156: Zähler+Drossel v1, Aufschlags-Abrechnung → v2. M157 (Wizard-UI) → v2 — Signup provisioniert SHK-Seed automatisch. M159: v1 nur SHK.
**Milestone-PLANs (8):** M030-pwa-theme-color, M153-subscriptions-billing-provider, M154-subscriptions-plan-seat-modell, M155-subscriptions-trial-readonly, M156-subscriptions-metering-pay-as-you-go, M158-onboarding-shk-provisionierung, M159-onboarding-branchen-templates, M160-onboarding-demo-daten
> ⚠ **M154-Recut bindend (2026-06-11):** Seat-Typ-Modell (Büro/Monteur) ist NICHT zu bauen — ersetzt durch **Abo-Baukasten**: Basis-Preis pro Nutzer + Feature-Module als zubuchbare Legacy-Provider-Positionen via Entitlement-Schicht (Flag-Engine `plan-tier`-Targeting × `packages/entitlements`). **Source-Spec:** `~/Documents/GitHub/archive/hivegrid-cloud/app/config/pricing-plans.ts` (Legacy D006: Core/Swarm/Colony, 14 Modul-Gates, 6 Quota-Dimensionen) — Struktur portieren, Preise/Tier-Zuschnitt = Kostenmatrix (Partner, AD-15). M156: Zähler+Drossel v1, Aufschlags-Abrechnung → v2. M157 (Wizard-UI) → v2 — Signup provisioniert SHK-Seed automatisch. M159: v1 nur SHK.
> Scope-Schnitt 2026-06-12: **M030 gedünnt** — nur Hell/Dunkel-Toggle, **kein System-Modus** (User-Entscheidung); nativer Status-Bar-Teil entfällt durch Mobile-Cut (Expo/Capacitor verworfen — PWA-only, AD-17; `next-themes` in Boilerplate-Deps). **M160 gedünnt** — KI-Walkthrough → v2 (gehört zum Wizard M157 = v2); Demo-Daten-Seed bleibt als Settings-Knopf, einfaches Löschen statt Multi-Step-Bulk; Mitarbeiter-Einladung läuft über M024.
## Phase 21: DSGVO & Launch-Reife
@@ -146,7 +146,7 @@ Beim Planen einer Phase liest der Planner/Researcher diese Specs + Milestone-PLA
### Phase 24: Self-Service-Billing & Onboarding-Wizard
**Specs:** `_bmad-output/specs/M28-subscriptions-billing.md` (Block P), `M29-onboarding.md`
**Milestone-PLANs (1+):** M157 + M28-Self-Service-Delta
**v1-Andockpunkte:** Stripe/Checkout/Webhook + SubscriptionFeature (P20), TenantUserInvitation (P2), deklaratives Branchen-Template (geteilt P26/P30). Retro: KI-Coach aus P25.
**v1-Andockpunkte:** Legacy-Provider/Checkout/Webhook + SubscriptionFeature (P20), TenantUserInvitation (P2), deklaratives Branchen-Template (geteilt P26/P30). Retro: KI-Coach aus P25.
### Phase 25: ~~KI-Tiefe — HiveMind/RAG~~ → MCP-Server ([[AD-26]])
> ⛔ **KI→MCP-Pivot ([[AD-26]]): Phase 25 zurückgestellt → ersetzt durch MCP-Server.** Kein M0xx-Milestone (v2-Neuschnitt); verbindliche Bau-Spec = **[[VR-MCP]]** (Backend) / **[[VD-MCP]]** (Screens). HiveMind/RAG/Whisper sind v3-Kandidat.
@@ -241,7 +241,7 @@ Beim Planen einer Phase liest der Planner/Researcher diese Specs + Milestone-PLA
| Owner | Retro-Integrations-Aufgaben (beim Planen dieser Phase MIT einplanen) |
|---|---|
| **P6 CRM** | Task↔CRM-Entity-Verknüpfung verdrahten (`M071→M042`; P4 liefert polymorphe EntityLink) |
| **P8 Sales** | Auto-Verkaufsfall aus qualifiziertem Lead (M049, hierher verschoben); SHK-Pipelines + Gründe selbst seeden (`M059→M158`, `M062→M158`); Deal-Anlage-Option ins Lead-Promotion-Modal (P6) verdrahten |
| **P8 Sales** | Auto-Projekt aus qualifiziertem Lead (M049, hierher verschoben); SHK-Pipelines + Gründe selbst seeden (`M059→M158`, `M062→M158`); Lead→Projekt-Verdrahtung konsumiert das P7-Event |
| **P9 Projekte** | Sales-Won-Handoff konsumieren (`M063→M065`; P8 liefert deal.won-Event); Abnahme-Defaults selbst seeden (`M069→M158`) |
| **P10 KWP** *(→ v2, erstes v2-Modul = Phase 32 / ROADMAP-V2 v2-P5)* | Retro-Integrationen wandern mit: KWP-Sync in Auftrag-Archiv, Abnahme-Drift-Schutz, Material-Listen, Task-ERP-Sync (M074) — Phase 32 (= ROADMAP-V2 v2-P5, ex-P10) integriert sich rückwirkend; v1 liefert die Extension-Points (Auftrag/Abnahme/Material) |
| **P11 Suche/Dashboard** | Hybrid-Suche-Upgrade für Audit-Viewer (`M151→M057`), Datei-Suche (`M055→M056`), CRM-Segmente (`M046→M056`), Kanban-Filter (`M061→M057`); Sales-Reporting-Widgets (`M064→M147`; P8 liefert Aggregat-APIs); Dashboard-Default-Layout selbst seeden (`M148→M158`) |
@@ -250,7 +250,7 @@ Beim Planen einer Phase liest der Planner/Researcher diese Specs + Milestone-PLA
| **P14 Email/Marketing** | Task-Liste im Marketing-Modul (`M072→M104`); Channel-Bedarf von M068 prüfen (`M068→M104` — voraussichtlich durch P1-notify()-Kern gedeckt); Email-Templates selbst seeden (`M100→M158`) |
| **P16 Kalender/Booking/Forms** | Kalender in Plantafel (`M067→M119`, `M067→M129`); Kalender-Termin aus Won-Deal (`M063→M119`); Form-Templates für Abnahme (`M069→M121`); Task-Liste im Forms-Modul (`M072→M121`); Form-Templates selbst seeden (`M125→M158`) |
| **P17 HR** | Abwesenheiten in Plantafel anzeigen (`M067→M135`) |
| **P20 Billing/Branding** | Stripe ersetzt Subscription-Stub (`M026→M153`, `M036→M153`, `M041→M153`); ~~AI-Credits-Kauf (`M088→M153`, `M088→M156`)~~ → **entfällt ([[AD-26]]: kein LLM-Gateway → keine AI-Credits; M088 gelöscht)** |
| **P20 Billing/Branding** | Legacy-Provider ersetzt Subscription-Stub (`M026→M153`, `M036→M153`, `M041→M153`); ~~AI-Credits-Kauf (`M088→M153`, `M088→M156`)~~ → **entfällt ([[AD-26]]: kein LLM-Gateway → keine AI-Credits; M088 gelöscht)** |
| **v2 (deferred Owner)** | Diese Retro-Integrationen wandern mit ihren Owner-Modulen nach v2: Push-Kanal-Aktivierung + Settings-Erweiterung (Mobile), Theme→Portal, Portal-DSGVO-Löschung, Help-Desk→Admin-Inbox, Abwesenheiten→Plantafel bleibt v1 (HR bleibt!) |
### Extension-Point-Pflichten (frühe Phase MUSS liefern, damit die Inversion trägt)
@@ -258,7 +258,7 @@ Beim Planen einer Phase liest der Planner/Researcher diese Specs + Milestone-PLA
| Phase | Extension-Point |
|---|---|
| P1 | `notify()` mit Kanal-Abstraktion inkl. ruhendem Push-Kanal; Search-Registry; Flag-Engine (bereits Success Criteria) |
| P2 | TenantSubscription-Status-Stub (manuell setzbar) — Frozen-Modus (M026) liest nur den Status, Stripe füllt ihn ab P20 |
| P2 | TenantSubscription-Status-Stub (manuell setzbar) — Frozen-Modus (M026) liest nur den Status, Legacy-Provider füllt ihn ab P20 |
| P3 | Notification-Settings-UI kanal-erweiterbar (Push-Sektion erscheint in P21) |
| P4 | Polymorphe EntityLink-Tabelle (`entity_type` + `entity_id`, kein harter FK); Task-Listen-Pattern als wiederverwendbare Komponente |
| P7 | Lead-Status-Event `lead.qualified` (Extension-Point für M049 in P8) |
@@ -4,7 +4,7 @@
HiveGrid 2.0 ist eine Multi-Tenant-SaaS-Plattform für SHK-Handwerksbetriebe (Sanitär, Heizung, Klima). Sie bündelt CRM, Vertriebs- und Auftragspipeline, Team-Chat, visuelle KI-Automation und die Anbindung an das KWP-ERP in einem EU-gehosteten System.
HiveGrid ist ein Multi-Kunden-SaaS für den gesamten SHK-Markt. Die **Geerkens GmbH** (Kontakt: Noah) ist erster Kunde und Investor — Validierungspartner, nicht alleiniger Zweck. Das Produkt ersetzt dort HubSpot (CRM + Sales-Pipeline), Quiply (Team-Chat), Zapier/n8n (Automation) sowie mehrere Excel-Prozesse. Die Codebase ist ein Git-Downstream der ToolCTRL-Boilerplate (~35–45 % Abdeckung: Fundament high, Fachdomäne greenfield).
HiveGrid ist ein Multi-Kunden-SaaS für den gesamten SHK-Markt. Die **Geerkens GmbH** (Kontakt: Noah) ist erster Kunde und Investor — Validierungspartner, nicht alleiniger Zweck. Das Produkt ersetzt dort HubSpot (CRM + Sales-Pipeline), Quiply (Team-Chat), Zapier/n8n (Automation) sowie mehrere Excel-Prozesse. Die Codebase wird als bespoke HiveGrid-Anwendung weitergeführt: historisches Fundament wird genutzt, produktfremde Altlasten werden entfernt.
> 🔴 **MODELL-PIVOT (2026-06-13):** CRM/Pipeline-Modell ist **Projekt-zentrisch** — ein `Projekt`-Kern, Pipelines = frei konfigurierbare Views; „Deal"/„Auftrag" sind Pipeline-Typen, keine eigenen Entitäten. Single Source: `.planning/ENTITY-MODEL.md`. ENTITY-MODEL/REQUIREMENTS/SCREEN-MAP/PHASE-SOURCES werden gegen den Anker neu gefasst (ER-Modell wird gemeinsam gebaut).
@@ -54,8 +54,8 @@ Vollständige Liste mit 79 v1-Requirements über 36 Module: siehe `.planning/REQ
- **SHK-Domäne:** Geerkens = Heizungsanlagen/Wartung/Reparatur. Entitäten: B2C/B2B-Kunden mit Anlagen-Zustand (Custom-Felder), mehrstufige Anlagen-Verkäufe, Wartungsverträge, Reparatur-Aufträge. Monteure arbeiten offline auf Baustellen (AD-17).
- **SHK-Seeds beim Tenant-Anlegen:** 6 Standard-Rollen + Permission-Bundles, SHK-Sales-Pipelines, Custom-Felder für Heizungsanlagen, Projekt-Pipelines, Automation-Templates, Onboarding-Branchen-Zweig — deklarativ, von Noah iterativ validiert (AD-12).
- **Boilerplate-Reuse:** Geteilte Pakete behalten `@toolctrl/*`-Namen (`packages/ui-app`, `ui-subframe`, `ui-mock`, `@toolctrl/search`, `@toolctrl/foundation`). Neue HiveGrid-Querschnittsdienste im `@hivegrid/*`-Namespace (activity, audit, notifications, entitlements, search, i18n).
- **Phase-0-Pflichten (vor jedem Fachmodul):** (1) Boilerplate als Code-Basis einziehen — Repo ist heute Docs-only, (2) Naming-Drift beheben (AD-10), (3) EU-Sterilisation (GitHub/Google-OAuth-Reste raus), (4) M00 + 4 Querschnitts-Neubauten (logActivity, Search-Registry, Subframe-Adapter, Mass-Action-Schutz), (5) fehlende Dependencies ergänzen.
- **Workspace-Naming:** Alle Runtime-, UI-, Config-, DB-, Mail-, Queue- und View-Model-Pakete laufen unter `@hivegrid/*`; CLI = `hivegrid-cli`; technische Produkt-Contracts nutzen `HIVEGRID_*`, `hivegrid_*`, `x-hivegrid-*` und `hivegrid.*`.
- **Phase-0-Pflichten (vor jedem Fachmodul):** (1) Codebase lauffähig halten, (2) Naming-Drift beheben (AD-10), (3) EU-Sterilisation (GitHub/Google-OAuth-Reste raus), (4) M00 + 4 Querschnitts-Neubauten (logActivity, Search-Registry, Subframe-Adapter, Mass-Action-Schutz), (5) fehlende Dependencies ergänzen.
- **Spec-Stil:** Alle 36 Specs sind „requirements-pur" (benötigte UI-Elemente als Liste, keine Layouts — Designer hat freie Hand) und im Status `detail-spec-draft`. Vor Modulbau: Noah-Verifizierung, Index-/Migrations-SQL, SHK-Defaults finalisieren, Subframe-UI-Designs.
## Constraints
@@ -67,7 +67,7 @@ Vollständige Liste mit 79 v1-Requirements über 36 Module: siehe `.planning/REQ
- **Sessions**: Postgres als Source-of-Truth + Redis-Cache; Cookie hält nur Session-ID (AD-4)
- **Realtime**: WebSocket für Chat (M13), SSE für Read-Updates (M03/M22) — AD-9
- **Tech-Stack**: React Router 7, Node 22.12.x (dev/test) / node:20 (runtime), Prisma/Postgres, Redis; **Subframe-UI ist Pflicht** für jede Route
- **Repo/Naming**: Git-Downstream der @toolctrl-Boilerplate, upstream-Remote erhalten, `@toolctrl/*`-Naming behalten (AD-10)
- **Repo/Naming**: Bespoke HiveGrid-Codebase; `@hivegrid/*` ohne Alt-Aliase, Estate-Slugs bleiben externe Infrastruktur (AD-10)
- **Go-Live**: Big-Bang — erst live mit vollständigem Phase-1-Produkt (AD-19); interne Bau-Reihenfolge nach AD-11
## Key Decisions
@@ -85,7 +85,7 @@ Vollständige Extraktion mit Scope/Begründung: `.planning/intel/decisions.md`.
| AD-7 | Captcha: **mCaptcha self-hosted** (infra/ vorhanden) | ✓ locked (11.06.) |
| AD-8 | Verschlüsselung: Per-Tenant-DEK + Envelope, Crypto-Shredding | ✓ locked |
| AD-9 | Realtime: WebSocket (Chat) + SSE (Read-Updates) | ✓ locked |
| AD-10 | Naming: `@toolctrl/*` behalten, Docs anpassen | ✓ locked |
| AD-10 | Naming: `@hivegrid/*` behalten, Docs anpassen | ✓ locked |
| AD-11 | Bau-Strategie: Hybrid (Fundament-Kern → SHK-Kernpfad → Breite) | ✓ locked |
| AD-12 | SHK-Defaults als deklaratives Seed, Noah validiert | ✓ locked |
| AD-13 | Go-Live-Scope: breiter Launch (Großteil der 36 Module) | ✓ locked |
@@ -34,7 +34,7 @@ Dies ist das **aktive Planungs-Zuhause**. Wenn du planst, steuerst oder verstehe
- **DESIGNER-FIRST-PLAYBOOK.md** — Designer-first-Methodik + Archetypen
## Externe Abhängigkeiten
- **EXTERNALS-CHECKLISTE.md** — Konten/APIs (KWP, Mistral, Mailjet, AirCall, Stripe, …)
- **EXTERNALS-CHECKLISTE.md** — Konten/APIs (KWP, Mistral, Mailjet, AirCall, Legacy-Provider, …)
## Unterordner
- **handouts/** — personalisierte Briefings (David, Akeshan, Dejan, Haris, Noah, Raffael)
@@ -1,7 +1,7 @@
# Requirements: HiveGrid 2.0
**Defined:** 2026-06-07
**Core Value:** Jeder SHK-Betrieb (Mandant) wickelt die komplette Wertschöpfungskette — Lead → Projekt (frei konfigurierbare Pipelines) (→ KWP-Sync ab v2) — inklusive visueller KI-Automation in einem einzigen, DSGVO-konformen EU-System ab. Wenn diese Kette nicht durchgängig funktioniert, scheitert das Produkt. *(Scope-Schnitt #12, 12.06.: KWP = erstes v2-Modul — API-Zugang des Herstellers erst ~19.07., nach v1-Launch.)*
**Core Value:** Jeder SHK-Betrieb (Mandant) wickelt die komplette Wertschöpfungskette — Lead → Projekt (frei konfigurierbare Pipelines) (→ KWP-Sync ab v2) — inklusive visueller Automation in einem einzigen, DSGVO-konformen EU-System ab. Wenn diese Kette nicht durchgängig funktioniert, scheitert das Produkt. *(Scope-Schnitt #12, 12.06.: KWP = erstes v2-Modul — API-Zugang des Herstellers erst ~19.07., nach v1-Launch. AD-26: kein eigenes LLM/RAG/Whisper; MCP-Server statt KI-Layer.)*
> ✅ **MODELL-PIVOT 2026-06-13 (AD-20) — Module 1–4 nachgezogen (R-REFIT, 16.06.):** Projekt-zentrisch (ein `Projekt`-Kern, Pipelines = Views). Umgesetzt: **REQ-crm-lead-promotion entfällt → REQ-lead-projekt-konvertierung** (Lead → Projekt bei Weiterverarbeitungsfreigabe, kein Modal, E4); **Sales (Modul 3) + Projekt (Modul 4) konsolidiert** zu „Projekt & Pipelines" mit Pipeline-Feld-Config-/Rechte-Layer (PipelineAccess/-FieldConfig/-FieldVisibility); **REQ-rechnung-sub-entity (NEU, E3)** angelegt; Kontaktmodell (Kontakttyp Privatperson/Organisation, -status, -art als Relation); Abnahme bleibt v1 als Checklisten/Protokoll-Sub-Area (E2). Single Source: `.planning/ENTITY-MODEL.md`.
@@ -50,7 +50,7 @@
- [ ] **REQ-form-datenmodell**: Form, FormField, FormSubmission und FormAction als Datenmodell mit 12 Feld-Typen und konfigurierbaren Post-Submit-Aktionen. *(Scope-Schnitt 12.06.: FormLink — personalisierte Links mit Tracking/Ablauf/Limit — + JS-Snippet → v2; öffentliche URL, QR-Code und iFrame-Embed bleiben.)*
- [ ] **REQ-form-editor**: Drag-&-Drop-Form-Editor mit Echtzeit-Vorschau, Conditional Logic, SHK-Form-Templates und iFrame-Embed-Code.
- [ ] **REQ-form-actions**: Form-Actions nach Submission (Lead/Deal/Task anlegen, Webhook, Email, Automation-Trigger) mit Field-Mapping und ActionLog.
- [ ] **REQ-form-actions**: Form-Actions nach Submission (Lead/Projekt/Task anlegen, Webhook, Email, Automation-Trigger) mit Field-Mapping und ActionLog.
### Modul 6 — Booking
@@ -60,8 +60,8 @@
### Modul 7 — Angebot + Signatur
- [ ] **REQ-signatur-datenmodell**: DealSignatureDocument, DealSignatureSigner und SignatureProviderConnection für Multi-Signer-Workflows mit PandaDoc/DocuSign.
- [ ] **REQ-signatur-provider**: PandaDoc/DocuSign-Integration via Apps-Engine (M33a) mit OAuth-Connect-Flow, Webhook-Verarbeitung und Status-Updates.
- [ ] **REQ-signatur-datenmodell**: SignatureDocument, SignatureSigner und SignatureProviderConnection für Multi-Signer-Workflows im Projekt-/Angebots-Kontext.
- [ ] **REQ-signatur-provider**: Self-hosted E-Signatur als Standardpfad (SES, AD-25); PandaDoc/DocuSign nur optionale BYOK-/Opt-in-Adapter via Apps-Engine (M33a) mit Connect-Flow, Webhook-Verarbeitung und Status-Updates.
### Modul 9 — Email-Inbox
@@ -84,7 +84,7 @@
- [ ] **REQ-chat-datenmodell**: TeamChat, TeamChatMessage, Threads, Reactions und TeamChatRead als Datenmodell für drei Channel-Typen (public, private, entity-linked).
- [ ] **REQ-chat-realtime**: WebSocket-basiertes Real-Time-Messaging mit Presence, Typing-Indicator, Read-Receipts und Redis-Pub/Sub für Scale-Out.
- [ ] **REQ-chat-features**: Link-Previews, File-Uploads, @Mentions, Chat-Verlaufs-Suche und Entity-linked Channels im Deal/Projekt-Detail.
- [ ] **REQ-chat-features**: Link-Previews, File-Uploads, @Mentions, Chat-Verlaufs-Suche und Entity-linked Channels im Projekt-Detail.
### Modul 14 — HR-Hub
@@ -103,7 +103,7 @@
- [ ] **REQ-automation-datenmodell**: AutomationRule (JSON-Graph), AutomationExecution, AutomationStepExecution und AutomationTemplate als Datenmodell für den Flow-Editor.
- [ ] **REQ-automation-trigger-library**: Phase-1-Trigger-Library über alle Module (CRM, Sales, Projekte, Email, Forms, Kalender, Zeit-Trigger, Webhooks).
- [ ] **REQ-automation-action-library**: Phase-1-Action-Library über alle Module (CRM-Aktionen, Notifications, KWP-Push, HTTP-Request, Tasks, If/Else, Delay, Loop).
- [ ] **REQ-automation-flow-editor**: Graph-basierter visueller Flow-Editor mit KI-Erstellung per natürlicher Sprache (DE), Dry-Run und 24h-Undo.
- [ ] **REQ-automation-flow-editor**: Graph-basierter visueller Flow-Editor mit manueller Flow-Erstellung, Dry-Run und 24h-Undo. KI-/Sprach-Erstellung entfällt nach AD-26.
- [ ] **REQ-automation-schutz**: Loop-Guard, Rate-Limit, Idempotenz-Key, Resume-Mode und Trigger-Volumen-Metering pro Tenant.
### Modul 18 — ~~KI-Layer~~ → MCP-Server ([[AD-26]])
@@ -125,7 +125,7 @@
### Modul 20 — Dashboard
- [ ] **REQ-dashboard-datenmodell**: Dashboard, DashboardLayout und WidgetInstance als Datenmodell mit Role-Default-Layouts (SHK-Template geseedet). *(Scope-Schnitt 12.06.: 3 feste Rollen-Layouts GF/Büro/Monteur; Drag&Drop + User-Layout-Persistenz → v2.)*
- [ ] **REQ-dashboard-widgets**: Widget-Katalog mit Datenquellen aus Sales, Projekte, Tasks, HR und KI; Realtime-Updates via WebSocket/SSE und Aggregat-Cache.
- [ ] **REQ-dashboard-widgets**: Widget-Katalog mit Datenquellen aus Sales, Projekte, Tasks und HR; Realtime-Updates via WebSocket/SSE und Aggregat-Cache. KI-Widgets sind Backlog/v3 nach AD-26.
### Modul 21 — Audit-Log
@@ -145,8 +145,8 @@
### Modul 24 — Globale Suche
- [ ] **REQ-suche-datenmodell**: SearchIndexEntry, RecentSearch und Outbox-Pattern für Index-Jobs nach Entity-Mutationen. *(Scope-Schnitt 11.06., AD-5 sequenziert: pgvector-Embedding-Spalte → v2 mit HiveMind; Outbox-Events sind der v1-Andock-Adapter.)* **⚠️ KI→MCP-Pivot ([[AD-26]]): Die KI/Embedding-Hälfte ruht — RAG/pgvector entfällt; das `embedding`-Feld bleibt ungenutzt. Meilisearch-Keyword-Suche bleibt unverändert.**
- [ ] **REQ-suche-hybrid**: Keyword-Suche via Meilisearch mit permission-awarem SearchAdapter-Interface (INTEGRATION-CONTRACTS §8). *(Scope-Schnitt 11.06., AD-5 sequenziert: pgvector/Hybrid-RRF-Fusion → v2 mit HiveMind.)* **⚠️ KI→MCP-Pivot ([[AD-26]]): pgvector/Hybrid-RRF-Fusion ruht (RAG entfällt); v1+v2 = reine Meilisearch-Keyword-Suche.**
- [ ] **REQ-suche-datenmodell**: SearchIndexEntry, RecentSearch und Outbox-Pattern für Index-Jobs nach Entity-Mutationen. AD-26: keine Embedding-/RAG-Spalte in v1/v2; Outbox-Events bleiben für Meilisearch-Indexierung und spätere MCP-Resource-Aktualität.
- [ ] **REQ-suche-hybrid**: Keyword-Suche via Meilisearch mit permission-awarem SearchAdapter-Interface (INTEGRATION-CONTRACTS §8). AD-26: pgvector/Hybrid-RRF entfällt; v1+v2 = reine Meilisearch-Keyword-Suche.
- [ ] **REQ-suche-ui**: Cmd+K-Modal (global Hotkey, debounced) und Vollbild-Such-View mit persistierter Suche und RecentSearch-Anzeige.
### Modul 25 — Calendar-Sync
@@ -156,26 +156,26 @@
### Modul 26 — Tasks
- [ ] **REQ-tasks-datenmodell**: Task mit Status, Priorität, Fälligkeit, Entitäts-Verknüpfung (Contact/Company/Lead/Deal/Project) und TaskWatcher.
- [ ] **REQ-tasks-reminder-archivierung**: Reminder-System via M22 (konfigurierbare Vorlaufzeit) und Auto-Archivierung abgeschlossener Tasks nach 30 Tagen per Cron.
- [x] **REQ-tasks-datenmodell**: Task mit Status, Priorität, Fälligkeit, Entitäts-Verknüpfung (Contact/Company/Lead/Project) und TaskWatcher. — AK-P4b (auf main; CRM-Entity-Wiring folgt in P6/R-P6).
- [x] **REQ-tasks-reminder-archivierung**: Reminder-System via M22 (konfigurierbare Vorlaufzeit) und Auto-Archivierung abgeschlossener Tasks nach 30 Tagen per Cron. — AK-P4b (auf main; Reminder+Archiv-Cron auf Staging funktional verifiziert).
### Modul 27 — Auth / Multi-Tenancy / Permissions
- [ ] **REQ-auth-multi-tenancy**: Self-Service-Signup mit auto-generiertem Tenant-Slug, Reserved-Words-Validierung und URL-Struktur (app.hivegrid.de, admin.hivegrid.de).
- [ ] **REQ-auth-methoden**: Email+Passwort (NIST-konform, Breach-Check), TOTP-2FA (RFC 6238) und Magic-Link exklusiv für Customer-Portal (AD-3).
- [ ] **REQ-auth-rbac**: Rollen-basiertes Permission-System (Role ↔ User M:N) mit Permission-Keys in packages/entitlements und 6 SHK-Standard-Rollen geseedet.
- [ ] **REQ-auth-rbac**: Rollen-basiertes Permission-System (Role ↔ User M:N) mit Permission-Keys in packages/entitlements und 6 SHK-Standard-Rollen geseedet *(Seed nur Staging/Dev — Produktion startet admin-only, AD-29)*.
- [ ] **REQ-auth-user-lifecycle**: User-Einladung (48h-Token), Soft-Deactivation und DSGVO-Löschung via PII-Anonymisierung ohne Audit-Trail-Verlust.
- *(→ v2, Scope-Schnitt 2026-06-12)* **REQ-auth-api-keys**: API-Key-System mit SHA-256-Hash-Speicherung, fein-granularen Scopes und Rate-Limit (1000 Req/Min Default). *(Wandert mit der Public-API; Boilerplate-`ApiKey`-Modell bleibt ungenutzt im Code.)*
### Modul 28 — Subscriptions/Billing
- [ ] **REQ-billing-datenmodell**: TenantSubscription mit Stripe-Integration, Frozen-Modus (Read-Only-Middleware), AI-Credits-Konto und Stripe-Webhook-Verarbeitung.
- [ ] **REQ-billing-datenmodell**: TenantSubscription mit Lago/Mollie-Recut nach AD-27: Lago als Billing-/Invoice-Engine, Mollie nur als Payment-Processor, lokaler HiveGrid-Billing-State fuer Entitlements und Frozen-Modus (Read-Only-Middleware). Kein v1-Usage-Billing, keine Credits; AI-Credits/AiTokenUsage entfallen nach AD-26.
- [ ] **REQ-billing-trial-dunning**: 14-Tage-Trial ohne Kreditkarte, Dunning-Email-Sequenz (Tag 1/3/7) bei fehlgeschlagener Zahlung und anschließender Frozen-Modus.
### Modul 29 — Onboarding
- *(→ v2, Scope-Schnitt 2026-06-11)* **REQ-onboarding-wizard**: Branchen-getriebener Onboarding-Wizard nach Self-Service-Signup mit SHK-Template-Vorauswahl und OnboardingSession.
- [ ] **REQ-onboarding-demo-daten**: Demo-Daten-Provisioning auf Wunsch (5 Contacts, 3 Deals, 2 Projekte, Tasks) mit Lösch-Funktion via is_demo_data-Flag. *(Scope-Schnitt 12.06.: KI-Walkthrough → v2 mit Wizard-UI; Settings-Knopf statt Wizard-Schritt, einfaches Löschen.)*
- [ ] **REQ-onboarding-demo-daten**: Demo-Daten-Provisioning auf Wunsch (Kontakte, Projekte in Sales-/Auftragssichten, Tasks) mit Lösch-Funktion via is_demo_data-Flag. *(Scope-Schnitt 12.06. + AD-26: KI-Walkthrough entfällt; Settings-Knopf statt Wizard-Schritt, einfaches Löschen.)*
### Modul 30 — i18n
@@ -193,7 +193,7 @@
### Modul 33a — Apps-Engine
- [ ] **REQ-apps-engine-datenmodell**: DataConnection, AppSyncRun, AppSyncItem und type-safe Adapter-Registry als zentrales Integrations-Framework.
- [ ] **REQ-apps-engine-lifecycle**: OAuth2-Connect-Flow (Gmail, Outlook, AirCall, PandaDoc), API-Key-Connect (KWP, Stripe), Connection-Health-Monitoring und Settings-UI.
- [ ] **REQ-apps-engine-lifecycle**: OAuth2-Connect-Flow (Gmail, Outlook, AirCall, PandaDoc), API-Key-Connect (KWP, Payment-Provider-APIs), Connection-Health-Monitoring und Settings-UI.
### Modul 34 — Public API & Webhooks
@@ -203,7 +203,7 @@
### Modul 35 — DSGVO-Export / Account-Löschung
- [ ] **REQ-dsgvo-export**: GDPR-Daten-Export als ZIP via BullMQ-Worker mit 72h-signed-Download-URL (S3-kompatibles Object Storage, Hetzner) und Download-Link-Email.
- [ ] **REQ-dsgvo-tenant-deletion**: Tenant-Account-Löschung mit Crypto-Shredding (Per-Tenant-DEK löschen), Stripe-Subscription-Cancel und vollständiger Storage/Cache-Bereinigung.
- [ ] **REQ-dsgvo-tenant-deletion**: Tenant-Account-Löschung mit Crypto-Shredding (Per-Tenant-DEK löschen), Lago-Subscription-Cancel/Mollie-Zahlungsreferenz-Bereinigung und vollständiger Storage/Cache-Bereinigung.
### Modul 36 — Mobile-PWA (PWA-only, kein nativer Client)
@@ -257,10 +257,11 @@
Bereits zuvor v2 (in Phase-1-Specs referenziert, nicht vollständig spezifiziert):
- **M11 WhatsApp**: Von M13 (Team-Chat) und M18 (KI-Layer/send-whatsapp-Action) referenziert; vollständige Spec und Implementierung verschoben auf Phase 2.
- **M11 WhatsApp**: Von M13 (Team-Chat) referenziert; vollständige Spec und Implementierung verschoben auf spätere Phase.
- **REQ-branding-tenant-phase2**: Tenant-Branding, White-Label und Custom-Domain (TenantBranding: logo, Farben, Custom-Domain) — Feature-Gate branding.white_label nur in Enterprise-Plan; verschoben auf Phase 2.
- **REQ-signatur-provider Fallback**: Einfacher PDF-Upload mit manueller Signatur-Erfassung als Fallback ohne externen Signatur-Provider — Phase 2.
- **REQ-signatur-provider Fallback**: Einfacher PDF-Upload mit manueller Signatur-Erfassung bleibt Fallback; Standard ist self-hosted SES (AD-25), externe Provider nur BYOK/Opt-in.
- **DeepL-Übersetzungs-Pipeline (M30)**: Automatische Übersetzungsvorschläge via DeepL für Locale-Files — Phase 2.
- **KI/HiveMind/RAG/Whisper**: kein v1/v2-Scope nach AD-26; v2 liefert stattdessen `REQ-mcp-server`.
## Out of Scope
@@ -301,8 +302,8 @@ Phase-Mapping aus ROADMAP.md (Stufe 2, revidiert auf 21 Phasen). Bau-Reihenfolge
| REQ-feature-flags-system | Phase 1 | Pending |
| REQ-files-datenmodell | Phase 4 | Pending |
| REQ-files-upload-preview | Phase 4 | Pending |
| REQ-tasks-datenmodell | Phase 4 | Pending |
| REQ-tasks-reminder-archivierung | Phase 4 | Pending |
| REQ-tasks-datenmodell | Phase 4 | Done |
| REQ-tasks-reminder-archivierung | Phase 4 | Done |
| REQ-apps-engine-datenmodell | Phase 5 | Pending |
| REQ-apps-engine-lifecycle | Phase 5 | Pending |
| REQ-crm-datenmodell | Phase 6 | Pending |
@@ -373,8 +374,9 @@ Phase-Mapping aus ROADMAP.md (Stufe 2, revidiert auf 21 Phasen). Bau-Reihenfolge
| REQ-feedback-widget | Phase 21 | Pending |
**Coverage:**
- v1 requirements: 90 total (Scope-Schnitte 2026-06-11/12: 19 → v2; **Pivot-Neuzählung 16.06.: REQ-crm-lead-promotion entfällt −1, REQ-rechnung-sub-entity NEU +1 → Netto unverändert 90**)
- Mapped to phases: 90 (100 %)
- v1 requirements: 89 ausführbare v1-Requirements (Scope-Schnitte 2026-06-11/12: 19 → v2; **Pivot-Neuzählung 16.06.: REQ-crm-lead-promotion entfällt −1, REQ-rechnung-sub-entity NEU +1; AD-26 entfernt den v1-KI-Layer**)
- v2 adds: `REQ-mcp-server` in v2-P4 / Phase 25 (ersetzt den alten KI-Tiefe-Scope)
- Mapped to phases: 89 v1 + 1 v2-MCP (100 %)
- Unmapped: 0
**Pro-Phase-Verteilung:**
@@ -393,7 +395,7 @@ Phase-Mapping aus ROADMAP.md (Stufe 2, revidiert auf 21 Phasen). Bau-Reihenfolge
| Phase 10 — KWP-Integration | 0 (→ v2, erstes v2-Modul) |
| Phase 11 — Globale Suche & Dashboard | 5 |
| Phase 12 — Automation-Engine | 5 |
| Phase 13 — KI-Layer | 1 |
| Phase 13 — KI-Layer | 0 (entfällt nach AD-26; `REQ-mcp-server` liegt in v2-P4) |
| Phase 14 — Email & Marketing | 4 |
| Phase 15 — Telefonie & Team-Chat | 5 |
| Phase 16 — Kalender, Booking, Forms & Signatur | 10 |
@@ -402,7 +404,7 @@ Phase-Mapping aus ROADMAP.md (Stufe 2, revidiert auf 21 Phasen). Bau-Reihenfolge
| Phase 19 — Plattform-Admin | 2 |
| Phase 20 — Billing, Onboarding & Branding | 4 |
| Phase 21 — DSGVO & Launch-Reife | 3 |
| **Summe** | **90** |
| **Summe** | **89 v1 + 1 v2-MCP** |
---
*Requirements definiert: 2026-06-07 via /gsd:ingest-docs (Stufe 1). Phase-Mapping ergänzt: 2026-06-07 via /gsd:new-project (Stufe 2 — ROADMAP). Re-Mapping auf 21 Phasen: 2026-06-07 (Roadmap-Revision — dicke Buckets aufgeteilt). Scope-Schnitte 2026-06-11/12 (#1–#12): 19 REQs → v2 (90 v1, davon 3 KWP = erstes v2-Modul), Dünnungs-Notes an 13 REQs, AD-6/7-Lock nachgetragen. **R-REFIT 2026-06-16 (Pivot AD-20): Module 1–4 projekt-zentrisch nachgezogen; REQ-crm-lead-promotion → REQ-lead-projekt-konvertierung; Sales/Projekt-Reqs zu „Projekt & Pipelines" konsolidiert (REQ-projekt-kern-datenmodell, REQ-pipeline-rechte-config u.a.); REQ-rechnung-sub-entity NEU; Req-Summe neu gezählt = 90 (Netto unverändert: −1 lead-promotion / +1 rechnung).** *
*Requirements definiert: 2026-06-07 via /gsd:ingest-docs (Stufe 1). Phase-Mapping ergänzt: 2026-06-07 via /gsd:new-project (Stufe 2 — ROADMAP). Re-Mapping auf 21 Phasen: 2026-06-07 (Roadmap-Revision — dicke Buckets aufgeteilt). Scope-Schnitte 2026-06-11/12 (#1–#12): 19 REQs → v2, Dünnungs-Notes an 13 REQs, AD-6/7-Lock nachgetragen. **R-REFIT 2026-06-16 (Pivot AD-20): Module 1–4 projekt-zentrisch nachgezogen; REQ-crm-lead-promotion → REQ-lead-projekt-konvertierung; Sales/Projekt-Reqs zu „Projekt & Pipelines" konsolidiert (REQ-projekt-kern-datenmodell, REQ-pipeline-rechte-config u.a.); REQ-rechnung-sub-entity NEU. AD-26: v1-KI-Layer entfernt, `REQ-mcp-server` als v2-P4 aufgenommen.** *
@@ -4,7 +4,7 @@
## Overview
v2 bringt die eigentlichen USP-/Revenue-Hebel, die in v1 bewusst geschnitten wurden: **KWP-Sync** (das Haupt-Verkaufsargument), **Self-Service-Abo**, **Postfach-Sync**, **Customer-Portal**, **Mobile-PWA-Ausbau**, **HiveMind/RAG** und **Public-API**. v1 hat die tragfähige Kette + alle **Andockpunkte** geliefert; v2 dockt rückwirkend an (Dependency-Inversion bleibt das Prinzip).
v2 bringt die eigentlichen USP-/Revenue-Hebel, die in v1 bewusst geschnitten wurden: **KWP-Sync** (das Haupt-Verkaufsargument), **Self-Service-Abo**, **Postfach-Sync**, **Customer-Portal**, **Mobile-PWA-Ausbau**, **MCP-Server** und **Public-API**. v1 hat die tragfähige Kette + alle **Andockpunkte** geliefert; v2 dockt rückwirkend an (Dependency-Inversion bleibt das Prinzip).
**Bau-Strategie:** Wie v1 zwei dependency-frei parallele Tracks (Raffael + Akeshan), Designer (David) 1–2 Phasen voraus. v2-Sprint in 3 Wochen: **v2-W1** 13.–19.07. · **v2-W2** 20.–26.07. · **v2-W3** 27.–31.07.
@@ -49,7 +49,7 @@ M15 (Portal), M37/M32 (Admin), M38/M39 (Help), M20/M22/M30 (Querschnitt) sind in
| v2-P1 | Phase 22 | — | Public-API & Webhooks |
| v2-P2 | Phase 23 | — | Postfach-Sync (Email-Inbox) |
| v2-P3 | Phase 24 | — | Self-Service-Billing & Onboarding-Wizard |
| v2-P4 | Phase 25 | — | KI-Tiefe — HiveMind/RAG |
| v2-P4 | Phase 25 | — | MCP-Server |
| **v2-P5** | **Phase 32** | **ex-P10** | **KWP-Integration (RECUT)** |
| v2-P6 | Phase 26 | ex-P18 | Customer-Portal |
| v2-P7 | Phase 27 | — | Mobile-PWA-Ausbau |
@@ -67,11 +67,13 @@ M15 (Portal), M37/M32 (Admin), M38/M39 (Help), M20/M22/M30 (Querschnitt) sind in
**Milestones:** M161 (OpenAPI + Read-Endpoints), M162 (API-Key-Auth + Scopes + Rate-Limit), M163 (Webhooks signiert), M027 (API-Keys + Tarif-Gates)
**Source-Specs:** `_bmad-output/specs/M34-public-api.md`
**Depends on:** v1-`ApiKey`-Modell (M027-Vorbereitung lag in v1 ungenutzt bereit) · Redis (Token-Bucket) · v1-Frozen/past_due-Middleware (Writes 402) · RLS via `app.tenant_id` · v1-P12 Automation (`external_event`-Trigger + ausgehende Webhook-Action)
**Host-Contract:** Public API nur `https://api.hivegrid.de/v1/...` (staging: `https://staging-api.hivegrid.de/v1/...`);
keine Public-API-Routen unter `app.hivegrid.de/api`.
**Success Criteria:**
1. GET contacts/companies/deals/projects/tasks/leads — cursor-paginiert, Filter/Sort/Sparse-Fieldsets/Embed (Tiefe 1); `read_only`-Scope lässt sensible Felder aus (nicht 403).
2. Eingehende Lead-Webhooks (`POST /webhooks/incoming/{tenant}/{slug}`) legen Leads an (API-Key/HMAC/URL-Token + Payload-Mapping + Auto-Match).
2. Eingehende Lead-Webhooks (`POST https://api.hivegrid.de/v1/webhooks/incoming/{tenant}/{slug}`) legen Leads an (API-Key/HMAC/URL-Token + Payload-Mapping + Auto-Match).
3. Ausgehende WebhookSubscriptions: HMAC-SHA256-Signatur, Idempotenz-ID, Retry mit exp. Backoff (max 5), Auto-Disable nach 10 Fehlern.
4. OpenAPI-3.1 auto-generiert (`/api/v1/openapi.json`) + Swagger-UI; Rate-Limit (1k/h Key, 10k/h Tenant) mit `X-RateLimit-*` + Klartext-429.
4. OpenAPI-3.1 auto-generiert (`https://api.hivegrid.de/v1/openapi.json`) + Swagger-UI; Rate-Limit (1k/h Key, 10k/h Tenant) mit `X-RateLimit-*` + Klartext-429.
**Screens (David):** API-Key-Verwaltung (Erweiterung) · Incoming-Webhook-Slug-Verwaltung + Ingestion-Log · Outgoing-Subscription-Liste/Detail (Secret-einmal-sichtbar, Delivery-Stats, Replay/Test) · API-Request-Logs + Usage-Aggregat
**Recut/Risiko:** Bewusst **Read-only + Webhooks** (Voll-CRUD/GraphQL/3-Legged-OAuth = später). Vor Bau: OpenAPI-Tooling wählen, HMAC-Format dokumentieren.
@@ -79,11 +81,11 @@ M15 (Portal), M37/M32 (Admin), M38/M39 (Help), M20/M22/M30 (Querschnitt) sind in
**Owner:** Akeshan · **Slot:** W1 (13.–17.07.) · **Goal:** Gmail/Outlook-Postfächer werden bidirektional ins CRM gespiegelt, auto-gematcht und im Activity-Stream sichtbar.
**Milestones:** M096 (Gmail-OAuth via Apps-Engine), M097 (Sync + Inbox-UI + Threading + Auto-Verknüpfung), M098 (Versand + Vorlagen)
**Source-Specs:** `_bmad-output/specs/M09-email-inbox.md` (OAuth-Sync/Inbox/Threading-Teile)
**Depends on:** v1-Sende-Domain DKIM/SPF/DMARC (P14, NICHT neu bauen) · `EmailProviderInterface` (Apps-Engine-Pattern) · v1-Activity-Stream + Contact/Deal/Project-FKs (Matching) · Webhook-Ingest-Infra (geteilt mit P1) · v1-Files (Anhänge) · Token-Encryption (DEK)
**Depends on:** v1-Sende-Domain DKIM/SPF/DMARC (P14, NICHT neu bauen) · `EmailProviderInterface` (Apps-Engine-Pattern) · v1-Activity-Stream + Contact/Project-FKs (Matching) · Webhook-Ingest-Infra (geteilt mit P1) · v1-Files (Anhänge) · Token-Encryption (DEK)
**Success Criteria:**
1. OAuth-Connect Gmail (Pub/Sub-Watch) + Microsoft (Graph-Subscription, 3-Tage-Renewal-Worker); Token-Refresh + Revoke.
2. Push-Sync (kein Primär-Polling) spiegelt Mails; Initial-Sync 90 Tage, resumebar mit Progress.
3. Auto-Match: From-Adresse → Contact/Lead, Subject-Pattern (`[Deal #123]`), 1-offener-Deal-Heuristik; manueller Override sticht.
3. Auto-Match: From-Adresse → Contact/Lead, Subject-Pattern (`[Projekt #123]`), 1-offenes-Projekt-Heuristik; manueller Override sticht.
4. Inbox-Liste (Filter/Suche/Bulk) + Detail mit Thread, sicherem HTML-Render (CSP, externe Bilder blockiert), Reply/Forward via Mailbox.
5. `email.read_own` vs. `email.read_all` filtern Liste + Activity-Stream.
**Screens (David):** Mailbox-Connect-Settings (Sync-Status/Reconnect) · Inbox-Liste · Mail-Detail (Thread/Verknüpfungs-Editor/Reply) · Outbound-Compose
@@ -93,7 +95,7 @@ M15 (Portal), M37/M32 (Admin), M38/M39 (Help), M20/M22/M30 (Querschnitt) sind in
**Owner:** Akeshan · **Slot:** W1→W2 (Abo-UI 13.–17., Wizard 18./19.) · **Goal:** Tenants buchen/wechseln Pläne self-service über Web-Checkout und durchlaufen einen branchen-getriebenen Onboarding-Wizard.
**Milestones:** M157 (Wizard-Framework + State-Machine) + M28-Self-Service-Delta (In-App-Subscription, Block P)
**Source-Specs:** `_bmad-output/specs/M28-subscriptions-billing.md` (Block P), `M29-onboarding.md`
**Depends on:** v1-Stripe-Checkout + Webhook-Handler + StripeWebhookLog · v1-`SubscriptionFeature`/`ProductFeature` (Feature-Gate-Modell) · v1-Read-Only-Middleware · v1-TenantUserInvitation · **deklaratives Branchen-Template-Verzeichnis** (geteilt mit P6/P10) · ~~v2-P4 (KI-Walkthrough-Coach)~~ → KI-Coach entfällt ([[AD-26]]: kein eigenes LLM; v2-P4 = MCP-Server)
**Depends on:** v1-Payment-Checkout + Webhook-Handler + Legacy-ProviderWebhookLog · v1-`SubscriptionFeature`/`ProductFeature` (Feature-Gate-Modell) · v1-Read-Only-Middleware · v1-TenantUserInvitation · **deklaratives Branchen-Template-Verzeichnis** (geteilt mit P6/P10) · ~~v2-P4 (KI-Walkthrough-Coach)~~ → KI-Coach entfällt ([[AD-26]]: kein eigenes LLM; v2-P4 = MCP-Server)
**Success Criteria:**
1. Self-Service-Trial ohne Vertriebs-Freigabe (`requires_manual_activation=false`) mit Zahlungsmethoden-Pflicht.
2. Plan-Wechsel/Add-On-Käufe laufen über den Web-Checkout-Flow (PWA-only → kein App-Store, keine Store-Billing-Auflage).
@@ -108,12 +110,16 @@ M15 (Portal), M37/M32 (Admin), M38/M39 (Help), M20/M22/M30 (Querschnitt) sind in
**Owner:** Raffael · **Slot:** W1→W2 (Start 18./19.07.) · **Goal:** Ein produktiver, tenant-gescopeter Remote-MCP-Server stellt HiveGrid-Daten als Read-Ressourcen und freigegebene Aktionen als Action-Tools bereit — auditiert, permission-gegated, ohne eigenes LLM.
**Milestones:** kein M0xx (v2-Neuschnitt) → siehe [[VR-MCP]]/[[VD-MCP]]
**Source-Specs:** [[VR-MCP]] / [[VD-MCP]] · [[AD-26]]
**Depends on:** v1-`ApiKey`-Auth + Scopes + Rate-Limit aus der Public-API ([[VR-P22]]) · v1-`withTenantScope()`/RLS · v1-`withPermission()` (M004) · v1-M001-Audit · `@modelcontextprotocol/sdk`
**Depends on:** v1-OAuth 2.1/PKCE für MCP + Scopes + Rate-Limit aus der Public-API-Grundlage ([[VR-P22]]) · v1-`withTenantScope()`/RLS · v1-`withPermission()` (M004) · v1-M001-Audit · `@modelcontextprotocol/sdk`
**Host-/Runtime-Contract:** MCP nur `https://mcp.hivegrid.de` (staging: `https://staging-mcp.hivegrid.de`)
als eigener Service/Container `hivegrid-mcp` (`apps/mcp`); kein `/mcp`-Pfad, nicht in `app.hivegrid.de` oder `api.hivegrid.de`.
**Success Criteria:**
1. Remote-MCP-Server (Streamable HTTP, `@modelcontextprotocol/sdk`) exponiert Read-Ressourcen (Kontakte/Firmen/Leads/Projekte/Aufträge u.a.) und Action-Tools (definierte Schreib-Aktionen).
2. Authentifizierung per **API-Key + Scopes + Rate-Limit** aus der Public-API ([[VR-P22]]); Tenant wird aus dem Token abgeleitet, jeder Zugriff läuft durch `withTenantScope()`/RLS.
2. Authentifizierung per **OAuth 2.1 + PKCE + Scopes + Rate-Limit**; Tenant wird aus dem Access Token abgeleitet,
jeder Zugriff läuft durch `withTenantScope()`/RLS. API-Key bleibt nur Fallback/Operator-Modus.
3. Jede Action ist `withPermission()`-gegated, verlangt bei schreibenden Operationen eine **Confirmation** und wird via M001-Audit protokolliert (Actor-Kind „MCP/Agent").
4. Tenant-Admin verwaltet MCP-Zugänge/Scopes (UI aus [[VD-MCP]]); keine neuen KI-Entitäten — Nutzung von `ApiKey`/`Credential` (ggf. kleine `McpGrant`/Scope-Tabelle).
4. Tenant-Admin verwaltet MCP-Verbindungen/Scopes (UI aus [[VD-MCP]]); OAuth-Clients/Tokens/Grants sind getrennt
nachvollziehbar, keine neuen KI-Entitäten.
**Screens (David):** siehe [[VD-MCP]] (MCP-Connection-Verwaltung, Scope-Auswahl, Audit-/Aktivitäts-Sicht der MCP-Zugriffe).
**Recut/Risiko:** Kein LLM-Hosting → **kein Provider-AVV** und kein DSGVO-Provider-Constraint mehr; Sicherheit liegt vollständig in Tenant-Scope/RLS, Scope-Minimierung und Confirmation-Grenze für Schreib-Aktionen.
@@ -129,7 +135,7 @@ M15 (Portal), M37/M32 (Admin), M38/M39 (Help), M20/M22/M30 (Querschnitt) sind in
**Owner:** Raffael · **Slot:** W2 (20.–26.07., E2E mit Geerkens Fr 24.07.) · **Goal:** KWP wird regulärer REST/OAuth-Adapter über die Apps-Engine — bidirektionaler Sync (Aufträge/Stammdaten/Belege/Plantafel/Dokumente) mit „KWP gewinnt"-Konfliktregel.
**Milestones:** M089 (API-Connector), M090 (Connection-Health), M091 (Push-Queue + Replay), M092 (Contract-Sentinel), M093 (Konflikt „KWP gewinnt"), M094 (Plantafel-Sync), M095 (Dokumenten-Archiv-Sync), M074 (Task-ERP-Sync)
**Source-Specs:** `_bmad-output/specs/M19-kwp-integration.md` + **`docs/milestones/M19-KWP-API-RECUT.md` (bindend)**
**Depends on:** **HART: v1-M33a Apps-Engine-Adapter-Interface (M008–M010)** · v1-Cloud-Outbox + Idempotency-Wrapper (M007) · v1-`@toolctrl/storage` + FileEntityLink · v1-Extension-Points an Auftrag/Abnahme/Material (P9) · v1-P12 Automation (`kwp.push_auftrag`-Action) · AVV/Subprozessoren (P11)
**Depends on:** **HART: v1-M33a Apps-Engine-Adapter-Interface (M008–M010)** · v1-Cloud-Outbox + Idempotency-Wrapper (M007) · v1-`@hivegrid/storage` + FileEntityLink · v1-Extension-Points an Auftrag/Abnahme/Material (P9) · v1-P12 Automation (`kwp.push_auftrag`-Action) · AVV/Subprozessoren (P11)
**Success Criteria:**
1. KWP-API-Connector als erster konkreter Adapter hinterm M33a-Interface; Credentials per-Tenant-DEK-verschlüsselt in `DataConnection.config`.
2. Bidirektionaler Sync: Push (Won→Auftrag) via Outbox + Rate-Limited-Replay; Pull via Webhook (falls KWP-Support) oder Cron, idempotent über `AppSyncItem`.
@@ -183,7 +189,7 @@ M15 (Portal), M37/M32 (Admin), M38/M39 (Help), M20/M22/M30 (Querschnitt) sind in
**Owner:** Akeshan · **Slot:** W3 (28.–30.07.) · **Goal:** Öffentliches file-basiertes Help-Center mit Deep-Links aus der App + In-App-Feedback-Widget → Plattform-Admin.
**Milestones:** M172 (Markdown-Artikel Git-PRs), M173 (Routes + Suche + SEO + kontextuelle Hilfe), M174 (Email-Ticket-System), M175 (In-App-Feedback-Widget)
**Source-Specs:** `_bmad-output/specs/M38-help-center.md`, `M39-help-desk.md`
**Depends on:** Public-Routing außerhalb Auth-Middleware + IP-Rate-Limiting · v1-Files (Feedback-Attachments) · **v1-Feedback-Widget → Support-Inbox (P21-Andockpunkt, generische Quelle)** · v2-P8 AdminAuditLog + Admin-Permissions · v2-P4 RAG (Help-Artikel als Korpus) · v2-P10 i18n (Sprachsegment)
**Depends on:** Public-Routing außerhalb Auth-Middleware + IP-Rate-Limiting · v1-Files (Feedback-Attachments) · **v1-Feedback-Widget → Support-Inbox (P21-Andockpunkt, generische Quelle)** · v2-P8 AdminAuditLog + Admin-Permissions · v2-P4 MCP-Resources optional für Help-Artikel · v2-P10 i18n (Sprachsegment)
**Success Criteria:**
1. Markdown mit Frontmatter, Build-Time-Index (Volltext + Kategorien + Sitemap), Build-Fail bei kaputten Referenzen.
2. Öffentliche Routen `/help/{lang}/{category}/{slug}` ohne Auth (vor Auth-Middleware), SEO-Defaults, Suche.
@@ -191,7 +197,7 @@ M15 (Portal), M37/M32 (Admin), M38/M39 (Help), M20/M22/M30 (Querschnitt) sind in
4. Feedback-Widget (überall) → `Feedback` mit Auto-Kontext (tenant/user/route/browser/app-version), Screenshot-Option, Rate-Limit 10/h.
5. Plattform-Admin: Feedback-Liste + Detail (Status-Lifecycle/Assignee/Notes/Attachments) mit Audit.
**Screens (David):** Help-Home (Hero/Suche/Kategorien) · Kategorie-Seite · Artikel-Detail (TOC/verwandt/Vote) · Such-Ergebnisse · Feedback-Modal (in v1 schon klein da → ausbauen) · Admin-Feedback-Liste/Detail
**Recut/Risiko:** Bewusst klein (Git-PRs, kein Editor-UI, kein volles Ticket-System in Phase 1). v2-später: DB-Pflege, KI-RAG-Suche, Mehrsprachigkeit, kontextuelle Inline-Hilfe, SLA/CSAT/Live-Chat. Support-Mailbox-Sync (Mails→Feedback) nutzt M9.
**Recut/Risiko:** Bewusst klein (Git-PRs, kein Editor-UI, kein volles Ticket-System in Phase 1). Später: DB-Pflege, MCP-Resource-Expose, Mehrsprachigkeit, kontextuelle Inline-Hilfe, SLA/CSAT/Live-Chat. Support-Mailbox-Sync (Mails→Feedback) nutzt M9.
### v2-P10: Komfort & Querschnitt
**Owner:** Akeshan · **Slot:** W3 (29.–31.07.) · **Goal:** Querschnitts-Komfort — anpassbare Dashboards/Export, Notification-Bündelung/Quiet-Hours, Sprach-Wechsler/EN, HR-Drittanbieter-Onboarding.
@@ -210,7 +216,7 @@ M15 (Portal), M37/M32 (Admin), M38/M39 (Help), M20/M22/M30 (Querschnitt) sind in
**Owner:** Raffael · **Slot:** W3 (29.–31.07.) · **Goal:** Zweiten Telefonie-Provider anbinden + DSGVO vertiefen (AVV-Versionierung, Subprozessoren-Workflow, Auskunfts-/Export-Workflow ersetzt manuelles Playbook).
**Milestones:** M105 (HalloPetra-Adapter), M166 (AVV + Subprozessoren + Auskunfts-Workflow)
**Source-Specs:** `_bmad-output/specs/M12-telefonie.md` (Provider-Pattern), `M35-dsgvo-export.md`
**Depends on:** **v1-TelephonyProviderInterface + AirCall-Adapter (P15) als Referenz** · v1-Automation-Trigger (`call.*`) · v1-notify() · v1-Files (Export-ZIP, AVV-PDF) · BullMQ-Worker · v1-Account-Löschung (P2/P21) · v1-Frozen→Deletion (P20) · v2-P8 AdminAuditLog + Legal-Verwaltung · pgvector (Embedding-Löschung beim Hard-Delete)
**Depends on:** **v1-TelephonyProviderInterface + AirCall-Adapter (P15) als Referenz** · v1-Automation-Trigger (`call.*`) · v1-notify() · v1-Files (Export-ZIP, AVV-PDF) · BullMQ-Worker · v1-Account-Löschung (P2/P21) · v1-Frozen→Deletion (P20) · v2-P8 AdminAuditLog + Legal-Verwaltung
**Success Criteria:**
1. HalloPetra als zusätzlicher Provider hinterm `TelephonyProviderInterface` ohne Kern-Umbau; Webhook-Auto-Match → Activity + Automation-Trigger.
2. Click-to-Call + Inbound + Logging providerunabhängig (E.164-Normalisierung, Match-Confidence, Notiz-Editor).
@@ -218,7 +224,7 @@ M15 (Portal), M37/M32 (Admin), M38/M39 (Help), M20/M22/M30 (Querschnitt) sind in
4. Subprozessoren-Liste mit Drittland-Warnung + 14-Tage-Widerspruchs-Workflow.
5. User-Daten-Export (Art. 15/20) + Tenant-Voll-Export (ZIP, async, 7-Tage-Window) + Tenant-Lösch-Workflow (30-Tage-Grace + Hard-Delete-Cron).
**Screens (David):** Telefonie-Setup (Provider-Connect, User-Bindings, Test) · Call-Session-Detail · DSGVO/Privacy-Hub (Export-Jobs, Tenant-Lösch-Multi-Step + Slug-Confirm, Subprozessoren, AVV-Download/History) · User-Daten-Export/Account-Löschung · Admin: Subprozessoren + AVV-Versions-Verwaltung
**Recut/Risiko:** ⚠️ **Gap HalloPetra:** keine Spec — API-Vertrag (Auth/Webhook-Events/Recording/Call-Lifecycle) erst erheben (eigener Vorlauf-Task). AVV-PDF-Inhalt anwaltlich finalisieren (in v1 EXT-03 beauftragt). Voicemail-Transkription via Whisper koppelt an P4.
**Recut/Risiko:** ⚠️ **Gap HalloPetra:** keine Spec — API-Vertrag (Auth/Webhook-Events/Recording/Call-Lifecycle) erst erheben (eigener Vorlauf-Task). AVV-PDF-Inhalt anwaltlich finalisieren (in v1 EXT-03 beauftragt). Voicemail-Transkription/Whisper ist nach AD-26 zurückgestellt.
---
@@ -25,15 +25,15 @@ Decimal phases appear between their surrounding integers in numeric order.
- [ ] **Phase 9: Projekt & Pipelines (Auftrag)** - Auftrags-Sub-Areas, Rechnung-Sub-Entity, Plantafel, digitale Abnahme (offline-fähig)
- [ ] ~~**Phase 10: KWP-Integration**~~ — **→ v2, ERSTES v2-Modul** (deferred 2026-06-12; API-Zugang ~19.07. > Launch; Nummer reserviert, Ausführung überspringt P10)
- [ ] **Phase 11: Globale Suche & Dashboard** - Keyword-Suche (Meilisearch, AD-5 sequenziert), Cmd+K, Widget-Dashboard mit festen Rollen-Layouts
- [ ] **Phase 12: Automation-Engine** - Visueller Flow-Editor, Trigger/Action-Library, Guards, Sprach-zu-Automation
- [ ] **Phase 13: KI-Layer** - EU-LLM-Gateway, Multi-Provider-Fallback, Promptfoo, Sprach-zu-Automation-Unterbau (HiveMind/RAG/Whisper → v2, Andock-Adapter in v1)
- [ ] **Phase 12: Automation-Engine** - Visueller Flow-Editor, Trigger/Action-Library, Guards, Dry-Run, 24h-Undo
- [ ] ~~**Phase 13: KI-Layer**~~ — **entfällt in v1 (AD-26)**; kein LLM/RAG/Whisper, kein Sprach-zu-Automation-Unterbau. MCP-Server ist v2-P4.
- [ ] **Phase 14: Email & Marketing** - Sende-Domain-Verifikation, BCC-Logging-Brücke, DSGVO-konforme Marketing-Kampagnen (Postfach-Sync → v2)
- [ ] **Phase 15: Telefonie & Team-Chat** - AirCall-Click-to-Call/Inbound, Real-Time-Team-Chat mit entity-linked Channels
- [ ] **Phase 16: Kalender, Booking, Forms & Signatur** - Kalender-Sync, öffentliche Buchungsseiten, Form-Builder, Angebots-Signatur
- [ ] **Phase 17: HR-Hub** - Verschlüsselter HR-Hub, Passwort-Safe, Urlaubs-/Krank-Workflow
- [ ] ~~**Phase 18: Customer-Portal**~~ — **→ v2** (deferred 2026-06-11; Nummer reserviert, Ausführung überspringt P18)
- [ ] **Phase 19: Plattform-Admin** - Getrennte Admin-Welt (admin.hivegrid.de), Tenant-Management + Abo-Zusammenstellung, Queue-Monitoring, Support-Inbox
- [ ] **Phase 20: Billing, Onboarding & Branding** - Stripe-Billing/Trial/Dunning (Abo-Baukasten-Recut), Auto-SHK-Provisionierung, Theme-Preference
- [ ] **Phase 20: Billing, Onboarding & Branding** - Legacy-Provider-Billing/Trial/Dunning (Abo-Baukasten-Recut), Auto-SHK-Provisionierung, Theme-Preference
- [ ] **Phase 21: DSGVO & Launch-Reife** - DSGVO-Export + Lösch-Workflow (Crypto-Shredding), Feedback-Widget, Launch-Checkliste
### v2-Phasen (Sprint Mo 13.07.–Fr 31.07., nach Launch — 43 Milestones, 2 Tracks dependency-frei parallel)
@@ -44,7 +44,7 @@ Decimal phases appear between their surrounding integers in numeric order.
- [ ] **Phase 22: Public-API & Webhooks** - Versionierte Read-REST-API + signierte Webhooks + Tenant-API-Keys mit Scopes/Rate-Limit
- [ ] **Phase 23: Postfach-Sync (Email-Inbox)** - Bidirektionale Gmail/Outlook-Inbox mit CRM-Matching, Threading, Versand über User-Mailbox
- [ ] **Phase 24: Self-Service-Billing & Onboarding-Wizard** - Self-Service-Abo-Zusammenstellung (Web-Checkout) + geführter branchen-getriebener Wizard
- [ ] **Phase 25: KI-Tiefe — HiveMind/RAG** - RAG über Tenant-Daten, EU-Whisper, pgvector-Hybrid-Suche, autonomer HiveMind-Agent (Action-Boundary)
- [ ] **Phase 25: MCP-Server** - Remote-MCP-Server für externe KI-Clients mit Read-Ressourcen und bestätigungspflichtigen Action-Tools (ersetzt HiveMind/RAG, AD-26)
- [ ] **Phase 26: Customer-Portal** ⭐ - Endkunden-Portal (eigene Auth/Magic-Link, Projekt-Sicht, Upload, Reklamation) — **kritisch** (ex-P18)
- [ ] **Phase 27: Mobile-PWA-Ausbau** - Installierbare PWA (Add-to-Homescreen, Service-Worker-Offline, Web-Push) für den Baustellen-Einsatz — kein nativer Client/App-Store (AD-17, PWA-only)
- [ ] **Phase 28: Plattform-Admin-Tiefe** - Getrennte AdminUser-Identität/AdminRBAC + Feature-Flag-Massen-Statistik + interne Lead-Pipeline
@@ -66,7 +66,7 @@ Decimal phases appear between their surrounding integers in numeric order.
**Source-Specs**: `docs/ARCHITECTURE-DECISIONS.md`, `docs/INTEGRATION-CONTRACTS.md`; Service-Kern-Teile aus `_bmad-output/specs/M21-audit-log.md`, `M22-notifications.md`, `M30-i18n.md`, `M32-feature-flags.md`
**Milestone-PLANs**: 15 PLANs in `docs/milestones/` → siehe `.planning/PHASE-SOURCES.md` (Phase 1)
**Success Criteria** (what must be TRUE):
1. Die ToolCTRL-Boilerplate ist als Git-Downstream eingezogen, baut lokal durch und der upstream-Remote bleibt mergebar erhalten (AD-10). *Planner-Hinweis: Ist-Stand prüfen — laut ADR-Text „Docs-only", real ist die Codebase seit 2026-06-04 eingezogen; bereits Erledigtes nicht neu planen.*
1. Die HiveGrid-Codebase baut lokal durch; Workspace-Pakete, CLI, Env-Namen, Cookies, Header und Queue-Namen nutzen HiveGrid-Contracts ohne Alt-Aliase (AD-10). *Planner-Hinweis: historische Boilerplate-Reste sind Arbeitsmaterial und kein Upstream-Merge-Ziel.*
2. GitHub/Google-OAuth-Reste und `User.githubId/googleId` sind entfernt; keine US-Kern-Abhängigkeit mehr im Auth-Pfad (EU-Sterilisation).
3. `logActivity()`, Search-Registry, Subframe-Adapter und Mass-Action-Schutz existieren als aufrufbare Querschnittsdienste mit Smoke-Test.
4. Die Service-Kerne existieren als aufrufbare Dienste mit Smoke-Test: AuditEvent mit SHA-256-Hash-Kette via Prisma-Middleware (default-on), `notify()` mit Kanal-Abstraktion (In-App/Email; Push-Kanal als Stub bis v2), `packages/i18n` mit Build-Lint gegen Hard-Strings und Resolution User → Tenant → DE, Feature-Flag-Engine mit `tenantHasFlag()` + Redis-Cache (Admin-UI folgt in P19).
@@ -86,7 +86,7 @@ Decimal phases appear between their surrounding integers in numeric order.
1. Ein Nutzer kann sich per Self-Service registrieren, erhält einen auto-generierten Tenant-Slug (Reserved-Words geblockt) und landet unter app.hivegrid.de.
2. Login mit Email+Passwort (NIST/Breach-Check) plus TOTP-2FA funktioniert; Sessions sind in Postgres serverseitig (Geräte-Liste, Revoke, „von allen Geräten abmelden") mit Redis-Cache (AD-4).
3. RLS-Policies (USING/WITH CHECK, `SET LOCAL app.tenant_id`, App-Rolle ohne BYPASSRLS) verhindern nachweisbar mandantenübergreifenden Datenzugriff (AD-1).
4. 6 SHK-Standard-Rollen sind geseedet; ein Admin kann Nutzer einladen (48h-Token), deaktivieren und DSGVO-konform per PII-Anonymisierung löschen.
4. 6 SHK-Standard-Rollen sind geseedet *(Staging/Dev; Produktion startet admin-only, AD-29)*; ein Admin kann Nutzer einladen (48h-Token), deaktivieren und DSGVO-konform per PII-Anonymisierung löschen.
5. *(→ v2, Scope-Schnitt 12.06.)* ~~Ein Nutzer kann API-Keys mit Scopes und Rate-Limit erstellen~~ — wandert mit der Public-API (M161–163) nach v2.
**Plans**: TBD
**UI hint**: yes
@@ -113,19 +113,19 @@ Decimal phases appear between their surrounding integers in numeric order.
**Success Criteria** (what must be TRUE):
1. Ein Nutzer kann Dateien chunked hochladen (TUS) mit RLS auf Storage-Pfaden (S3-kompatibles Hetzner Object Storage, Infra-ADR-009); Previews (PDF→PNG, Bild→WebP, **HEIC→JPG — Pflicht für iPhone-Monteur-Fotos**) sind sichtbar. *(Scope-Schnitt 12.06.: Versionierung, Office-/Video-Previews, Resume-Upload, OCR → v2; Permissions/Sharing + Tags + Archiv-Flag bleiben.)*
2. Dateien lassen sich über FileEntityLink an Entitäten hängen; Customer-Portal-Upload ist vorbereitet.
3. Ein Nutzer kann Tasks mit Status, Priorität, Fälligkeit und Entitäts-Verknüpfung (Contact/Company/Lead/Deal/Project) anlegen, sich als Watcher eintragen und einen Reminder (via notify()) erhalten.
3. Ein Nutzer kann Tasks mit Status, Priorität, Fälligkeit und Entitäts-Verknüpfung (Contact/Company/Lead/Projekt) anlegen, sich als Watcher eintragen und einen Reminder (via notify()) erhalten.
4. Abgeschlossene Tasks werden nach 30 Tagen per Cron auto-archiviert.
**Plans**: TBD
**UI hint**: yes
### Phase 5: Apps-Engine & Integrations-Framework
**Goal**: Externe Systeme werden über ein einziges, typsicheres Integrations-Framework angebunden — der Vertrag, über den später Gmail, Outlook, AirCall, PandaDoc, Stripe und KWP laufen.
**Goal**: Externe Systeme werden über ein einziges, typsicheres Integrations-Framework angebunden — der Vertrag, über den später Gmail, Outlook, AirCall, Signatur-Adapter (SES-Default/PandaDoc-BYOK), Legacy-Provider und KWP laufen.
**Depends on**: Phase 2
**Requirements**: REQ-apps-engine-datenmodell, REQ-apps-engine-lifecycle
**Source-Specs**: `_bmad-output/specs/M33a-apps-engine.md`
**Milestone-PLANs**: 3 PLANs in `docs/milestones/` → siehe `.planning/PHASE-SOURCES.md` (Phase 5)
**Success Criteria** (what must be TRUE):
1. Ein Admin kann eine DataConnection über einen OAuth2-Connect-Flow (Gmail, Outlook, AirCall, PandaDoc) oder API-Key-Connect (KWP, Stripe) in der Settings-UI herstellen.
1. Ein Admin kann eine DataConnection über einen OAuth2-Connect-Flow (Gmail, Outlook, AirCall, optional PandaDoc-BYOK) oder API-Key-Connect (KWP, Legacy-Provider, Signatur-Adapter) in der Settings-UI herstellen.
2. Die type-safe Adapter-Registry erlaubt das Registrieren neuer Connection-Typen ohne Änderung am Kern; AppSyncRun/AppSyncItem protokollieren Sync-Läufe.
3. Connection-Health-Monitoring zeigt den Status jeder Verbindung; OAuth-Tokens sind per-Tenant-DEK-verschlüsselt gespeichert (AD-8).
**Plans**: TBD
@@ -155,7 +155,7 @@ Decimal phases appear between their surrounding integers in numeric order.
**Depends on**: Phase 6
**Requirements**: REQ-lead-inbox-liste, REQ-lead-quellen-auto-anlage, REQ-lead-status-workflow, REQ-lead-anti-spam, REQ-lead-statistiken, REQ-lead-permissions
**Source-Specs**: `_bmad-output/specs/M02-lead-inbox.md`
**Milestone-PLANs**: 4 PLANs in `docs/milestones/` → siehe `.planning/PHASE-SOURCES.md` (Phase 7); M049 (Auto-Verkaufsfall) → Phase 8 (braucht Deal-Entity); P7 liefert das `lead.qualified`-Event als Extension-Point
**Milestone-PLANs**: 4 PLANs in `docs/milestones/` → siehe `.planning/PHASE-SOURCES.md` (Phase 7); M049 (Auto-Projekt) → Phase 8 (braucht den Projekt-Kern); P7 liefert das `lead.qualified`-Event als Extension-Point
**Success Criteria** (what must be TRUE):
1. Ein Bearbeiter sieht die Lead-Inbox mit Status-Filter, Quick-Actions, Bulk-Operationen und Real-time-Refresh.
2. Leads aus Form-Submission, eingehender Email und AirCall-Inbound werden automatisch angelegt und stoßen Automation-Trigger an (sofern Automation aktiv).
@@ -172,7 +172,7 @@ Decimal phases appear between their surrounding integers in numeric order.
**Depends on**: Phase 7
**Requirements**: REQ-projekt-kern-datenmodell, REQ-pipeline-rechte-config, REQ-projekt-kanban-listen-view, REQ-projekt-pipeline-wechsel, REQ-projekt-forecast-kpis
**Source-Specs**: `_bmad-output/specs/M03-sales-pipeline.md`
**Milestone-PLANs**: 7 PLANs in `docs/milestones/` → siehe `.planning/PHASE-SOURCES.md` (Phase 8); inkl. M049 (Auto-Verkaufsfall, aus Phase 7 hierher)
**Milestone-PLANs**: 7 PLANs in `docs/milestones/` → siehe `.planning/PHASE-SOURCES.md` (Phase 8); inkl. M049 (Auto-Projekt, aus Phase 7 hierher)
**Success Criteria** (what must be TRUE):
1. Der `Projekt`-Kern (typisierte Superset-Tabelle + CustomFields) existiert mit fortlaufenden Projekt-Nummern (HiveNumberSeq); mehrere Pipelines (Pipeline mit typ_hint sales/auftrag/custom, PipelineStage = Status, Verlust-/Gewinn-Gründe) sind als geseedete SHK-Pipelines konfigurierbar.
2. Ein Nutzer kann Projekte im konfigurierbaren Kanban per Drag-&-Drop verschieben (mit Stage-Wechsel-Validation) oder in der Listen-View mit Filter/Bulk-Actions bearbeiten; Updates kommen in Real-Time (SSE, AD-9).
@@ -220,23 +220,23 @@ Decimal phases appear between their surrounding integers in numeric order.
**Source-Specs**: `_bmad-output/specs/M24-globale-suche.md`, `_bmad-output/specs/M20-dashboard.md`
**Milestone-PLANs**: 5 PLANs in `docs/milestones/` → siehe `.planning/PHASE-SOURCES.md` (Phase 11); Scope-Schnitt 11.06.: Custom-Widgets (M149) + Export (M150) → v2; 12.06.: M147 gedünnt (feste Rollen-Layouts)
**Success Criteria** (what must be TRUE):
1. Self-hosted Meilisearch liefert permission-aware Keyword-Treffer über den SearchAdapter. *(Scope-Schnitt 11.06.: pgvector/Hybrid-RRF → v2 mit HiveMind; AD-5 sequenziert.)*
1. Self-hosted Meilisearch liefert permission-aware Keyword-Treffer über den SearchAdapter. Kein pgvector/RAG/Embedding in v1/v2 nach AD-26.
2. Entity-Mutationen aktualisieren den Index über das Outbox-Pattern; SearchIndexEntry und RecentSearch sind befüllt.
3. Ein Nutzer öffnet per Cmd+K ein debounced Such-Modal mit RecentSearch und kann in eine Vollbild-Such-View wechseln.
4. Ein Nutzer sieht ein rollenbasiertes Dashboard (3 feste Rollen-Layouts GF/Büro/Monteur, SHK-Default geseedet) mit Widgets aus Sales, Projekte, Tasks, HR und KI. *(Scope-Schnitt 12.06.: Drag&Drop-Grid + User-Layout-Persistenz → v2; Widget-Registry bleibt voll — Inversion `M064→M147`.)*
4. Ein Nutzer sieht ein rollenbasiertes Dashboard (3 feste Rollen-Layouts GF/Büro/Monteur, SHK-Default geseedet) mit Widgets aus Sales, Projekte, Tasks und HR. *(Scope-Schnitt 12.06.: Drag&Drop-Grid + User-Layout-Persistenz → v2; KI-Widgets → Backlog/v3 nach AD-26.)*
5. Dashboard-Widgets aktualisieren sich live (WebSocket/SSE) mit Aggregat-Cache.
**Plans**: TBD
**UI hint**: yes
### Phase 12: Automation-Engine
**Goal**: Die erste USP-Säule steht in voller Tiefe (AD-16): ein visueller Automation-Editor über alle Module mit vollständiger Trigger-/Action-Library, Guards und Sprach-zu-Automation-Erstellung.
**Goal**: Die erste USP-Säule steht in voller Tiefe (AD-16): ein visueller Automation-Editor über alle Module mit vollständiger Trigger-/Action-Library, Guards, Dry-Run und 24h-Undo. Sprach-zu-Automation entfällt nach AD-26.
**Depends on**: Phase 11
**Requirements**: REQ-automation-datenmodell, REQ-automation-trigger-library, REQ-automation-action-library, REQ-automation-flow-editor, REQ-automation-schutz
**Source-Specs**: `_bmad-output/specs/M17-automation-engine.md`
**Milestone-PLANs**: 8 PLANs in `docs/milestones/` → siehe `.planning/PHASE-SOURCES.md` (Phase 12)
**Success Criteria** (what must be TRUE):
1. Ein Nutzer baut im Graph-Editor Flows aus der vollständigen Trigger-Library (CRM, Sales, Projekte, Email, Forms, Kalender, Zeit, Webhooks) und Action-Library (CRM, Notifications, HTTP, Tasks, If/Else, Delay, Loop; KWP-Push registriert sich mit v2-P10 nach — Registry ist erweiterbar gebaut).
2. Ein Nutzer erstellt eine Automation per natürlicher Sprache (DE), testet sie als Dry-Run und kann Ausführungen innerhalb von 24h zurückrollen.
2. Ein Nutzer erstellt eine Automation im visuellen Editor, testet sie als Dry-Run und kann Ausführungen innerhalb von 24h zurückrollen.
3. Loop-Guard, Rate-Limit, Idempotenz-Key, Resume-Mode und Trigger-Volumen-Metering pro Tenant verhindern Runaway-Automationen.
4. AutomationRule (JSON-Graph), AutomationExecution, AutomationStepExecution und AutomationTemplate persistieren Flows und Läufe nachvollziehbar.
**Plans**: TBD
@@ -281,7 +281,7 @@ Decimal phases appear between their surrounding integers in numeric order.
1. Click-to-Call und Inbound-Anrufe laufen über den AirCall-Adapter (Apps-Engine-Connection, verschlüsselte OAuth-Tokens); unbekannte Nummern legen automatisch einen Lead an; Anruf-Events stehen als Automation-Trigger bereit (verpasster Anruf → Rückruf-Aufgabe).
2. Team-Chat bietet Real-Time-Messaging (WebSocket, Presence, Typing, Read-Receipts, Redis-Pub/Sub) für drei Channel-Typen (public, private, entity-linked), AD-9.
3. Chat unterstützt @Mentions, File-Uploads (via Files), Link-Previews und Chat-Verlaufs-Suche.
4. Entity-linked Channels erscheinen im Deal-/Projekt-Detail und verknüpfen die Konversation mit der CRM-/Projekt-Entität.
4. Entity-linked Channels erscheinen im Projekt-Detail und verknüpfen die Konversation mit der CRM-/Projekt-Entität.
**Plans**: TBD
**UI hint**: yes
@@ -294,8 +294,8 @@ Decimal phases appear between their surrounding integers in numeric order.
**Success Criteria** (what must be TRUE):
1. Kalender synchronisiert bidirektional mit **Google** (CalendarAdapter, Auto-Token-Refresh, Webhook-Subscriptions) und legt HiveGrid-Termine an. *(Scope-Schnitt 11.06.: Outlook-Adapter → v2; Interface ist provider-agnostisch gebaut. Booking: eine Zuteilungs-Strategie „Erster der frei ist", Round-Robin/Wahl → v2.)*
2. Öffentliche Booking-Seiten berechnen Slots timezone-aware (Verfügbarkeitsregeln, Ausnahmen, Kalender-Sync, Puffer) und legen nach Buchung Lead, Activity und Bestätigungs-Email an (iFrame-Embed).
3. Ein Nutzer baut Formulare per Drag-&-Drop mit 12 Feld-Typen, Conditional Logic, SHK-Templates und iFrame-Embed; Post-Submit-Actions (Lead/Deal/Task, Webhook, Email, Automation-Trigger) feuern mit Field-Mapping und ActionLog.
4. Angebote durchlaufen Multi-Signer-Signaturen via PandaDoc/DocuSign über die Apps-Engine mit Webhook-Verarbeitung und Status-Updates.
3. Ein Nutzer baut Formulare per Drag-&-Drop mit 12 Feld-Typen, Conditional Logic, SHK-Templates und iFrame-Embed; Post-Submit-Actions (Lead/Projekt/Task, Webhook, Email, Automation-Trigger) feuern mit Field-Mapping und ActionLog.
4. Angebote durchlaufen Multi-Signer-Signaturen über den self-hosted SES-Standardpfad (AD-25); PandaDoc/DocuSign bleiben optionale BYOK-/Opt-in-Adapter via Apps-Engine.
**Plans**: TBD
**UI hint**: yes
@@ -326,7 +326,7 @@ Decimal phases appear between their surrounding integers in numeric order.
**Depends on**: Phase 2
**Requirements**: REQ-plattform-admin-identität, REQ-plattform-admin-features
**Source-Specs**: `_bmad-output/specs/M37-plattform-admin.md`, `_bmad-output/specs/M32-feature-flags.md` (Admin-UI-Teile)
**Milestone-PLANs**: 7 PLANs in `docs/milestones/` → siehe `.planning/PHASE-SOURCES.md` (Phase 19); Scope-Schnitt 11.06.: M033 (Flag-Massen-Statistik) + M040 (Plattform-Lead-Pipeline) → v2; 12.06.: M038/M039 (getrennte AdminUser-Identität) → v2, M035 gedünnt. **Neu:** Admin stellt Tenant-Abos zusammen (Konnektoren schalten + Stripe-Positionen — Abo-Baukasten-Mechanik; Self-Service-UI → v2)
**Milestone-PLANs**: 7 PLANs in `docs/milestones/` → siehe `.planning/PHASE-SOURCES.md` (Phase 19); Scope-Schnitt 11.06.: M033 (Flag-Massen-Statistik) + M040 (Plattform-Lead-Pipeline) → v2; 12.06.: M038/M039 (getrennte AdminUser-Identität) → v2, M035 gedünnt. **Neu:** Admin stellt Tenant-Abos zusammen (Konnektoren schalten + Legacy-Provider-Positionen — Abo-Baukasten-Mechanik; Self-Service-UI → v2)
**Success Criteria** (what must be TRUE):
1. Ein Plattform-Admin meldet sich auf `admin.hivegrid.de` an (Host-Routing auf die Admin-Routen, **Cookie-Domain-Trennung** zu app.hivegrid.de, TOTP-Pflicht + MFA-Step-up); Admin-User haben **keinen Tenant-Workspace** und erscheinen in keiner Mandanten-Mitgliederliste; kein Generalschlüssel zwischen Plattform und Mandanten. *(Scope-Schnitt 12.06.: getrennte AdminUser-Identität/AdminRBAC-Parallelwelt = M038/M039 → v2; v1 nutzt Boilerplate-Modell User-Extension + `admin-mfa-gate`.)*
2. Ein Admin verwaltet Tenants, Subscription-Overrides und Feature-Flags aus einer zentralen Konsole; das Admin-Dashboard zeigt die Boilerplate-KPIs (Tenants/User/MRR). *(Churn/ARPU/Health-Kacheln → v2.)*
@@ -335,15 +335,15 @@ Decimal phases appear between their surrounding integers in numeric order.
**UI hint**: yes
### Phase 20: Billing, Onboarding & Branding
**Goal**: Ein neuer Betrieb kommt vom Self-Service-Signup per Auto-SHK-Provisionierung ins Produkt (Wizard-UI → v2), läuft auf Stripe-Billing und kann sein Theme wählen.
**Goal**: Ein neuer Betrieb kommt vom Self-Service-Signup per Auto-SHK-Provisionierung ins Produkt (Wizard-UI → v2), läuft auf Legacy-Provider-Billing und kann sein Theme wählen.
**Depends on**: Phase 19, Phase 6
**Requirements**: REQ-billing-datenmodell, REQ-billing-trial-dunning, REQ-onboarding-demo-daten, REQ-branding-theme-phase1
**Source-Specs**: `_bmad-output/specs/M28-subscriptions-billing.md` (⚠ M154-Recut: Abo-Baukasten statt Seat-Typen), `_bmad-output/specs/M29-onboarding.md` (Wizard-UI → v2), `_bmad-output/specs/M31-branding.md`; **Legacy-Referenz:** `archive/hivegrid-cloud/app/config/pricing-plans.ts`
**Milestone-PLANs**: 8 PLANs in `docs/milestones/` → siehe `.planning/PHASE-SOURCES.md` (Phase 20); Scope-Schnitt 11.06.: M157 (Wizard-UI) → v2; 12.06.: M030 + M160 gedünnt
**Success Criteria** (what must be TRUE):
1. Billing läuft über Stripe als **Abo-Baukasten**: Basis-Preis pro Nutzer + Feature-Module als daten-getriebene Positionen (keine Seat-Typen — M154-Recut); Trial mit Dunning (Tag 1/3/7) und Frozen-Modus (Read-Only-Middleware) greift; konkrete Preise geparkt (AD-15, Kostenmatrix folgt). Verbrauchs-Zähler + Drossel aktiv, Aufschlags-Abrechnung → v2.
1. Billing läuft über Legacy-Provider als **Abo-Baukasten**: Basis-Preis pro Nutzer + Feature-Module als daten-getriebene Positionen (keine Seat-Typen — M154-Recut); Trial mit Dunning (Tag 1/3/7) und Frozen-Modus (Read-Only-Middleware) greift; konkrete Preise geparkt (AD-15, Kostenmatrix folgt). Verbrauchs-Zähler + Drossel aktiv, Aufschlags-Abrechnung → v2.
2. Ein neuer Tenant wird nach Signup automatisch SHK-provisioniert (Seeds M158, ohne Wizard-UI — die folgt v2); Trial-Freischaltung durch Plattform-Admin (wie spec'd).
3. Optionale Demo-Daten (5 Contacts, 3 Deals, 2 Projekte, Tasks) lassen sich per Settings-Knopf provisionieren und via is_demo_data-Flag löschen. *(Scope-Schnitt 12.06.: KI-Walkthrough → v2 mit Wizard; einfaches Löschen statt Multi-Step-Bulk.)*
3. Optionale Demo-Daten (Kontakte, Projekte in Sales-/Auftragssichten, Tasks) lassen sich per Settings-Knopf provisionieren und via is_demo_data-Flag löschen. *(Scope-Schnitt 12.06. + AD-26: KI-Walkthrough entfällt; einfaches Löschen statt Multi-Step-Bulk.)*
4. Ein Nutzer schaltet zwischen Hell und Dunkel um, flash-frei (SSR-Cookie). *(Scope-Schnitt 12.06.: kein System-Modus — bewusst nur 2 Optionen; `next-themes` aus Boilerplate.)*
**Plans**: TBD
**UI hint**: yes
@@ -355,7 +355,7 @@ Decimal phases appear between their surrounding integers in numeric order.
**Source-Specs**: `_bmad-output/specs/M35-dsgvo-export.md` (M34/M36/M38/M39 → v2)
**Milestone-PLANs**: 2 PLANs in `docs/milestones/` → siehe `.planning/PHASE-SOURCES.md` (Phase 21)
**Success Criteria** (what must be TRUE):
1. Ein Nutzer kann einen DSGVO-ZIP-Export (72h-signed-URL) anstoßen; eine Tenant-Löschung führt Crypto-Shredding (Per-Tenant-DEK), Stripe-Cancel und Storage/Cache-Bereinigung durch (AD-8).
1. Ein Nutzer kann einen DSGVO-ZIP-Export (72h-signed-URL) anstoßen; eine Tenant-Löschung führt Crypto-Shredding (Per-Tenant-DEK), Legacy-Provider-Cancel und Storage/Cache-Bereinigung durch (AD-8).
2. DSGVO-Auskunft funktioniert als dokumentiertes manuelles Playbook: Suche → Kontakt-Detail (Activity-Stream) → CSV-Export → Anwalts-Vorlage → Frist-Task (+25 Tage). AVV-Vorlage liegt vor (Externals E6).
3. Das In-App-Feedback-Widget (Bug/Feature/Frage, Screenshot, Rate-Limit) ist live und speist die Admin-Support-Inbox (P19, generische Quelle); eine Minimal-FAQ-Seite existiert.
4. Monteure arbeiten mobil über die PWA/responsive Web-App (mobile-first Screens + P9-Offline) — der PWA-Ausbau (Installierbarkeit/Web-Push) folgt v2; ein nativer Client ist nicht vorgesehen (AD-17, PWA-only).
@@ -387,7 +387,7 @@ Decimal phases appear between their surrounding integers in numeric order.
**Source-Specs**: `_bmad-output/specs/M09-email-inbox.md` (OAuth-Sync/Inbox/Threading-Teile)
**Success Criteria** (what must be TRUE):
1. OAuth-Connect Gmail (Pub/Sub-Watch) + Microsoft (Graph-Subscription, 3-Tage-Renewal-Worker); Push-Sync, Initial-Sync 90 Tage resumebar.
2. Auto-Match (From-Adresse, Subject-Pattern, 1-offener-Deal-Heuristik) mit manuellem Override; Inbox-Liste + Detail mit Thread, sicherem HTML-Render, Reply/Forward.
2. Auto-Match (From-Adresse, Subject-Pattern `[Projekt #123]`, 1-offenes-Projekt-Heuristik) mit manuellem Override; Inbox-Liste + Detail mit Thread, sicherem HTML-Render, Reply/Forward.
3. `email.read_own`/`read_all` filtern Liste + Activity-Stream.
**Plans**: TBD
**UI hint**: yes
@@ -395,8 +395,8 @@ Decimal phases appear between their surrounding integers in numeric order.
### Phase 24: Self-Service-Billing & Onboarding-Wizard (v2)
**Goal**: Tenants buchen/wechseln Pläne self-service über Web-Checkout und durchlaufen einen branchen-getriebenen Onboarding-Wizard.
**Depends on**: Phase 20 (Stripe/Billing-Fundament)
**Retro-Integration**: KI-Walkthrough-Coach wird von Phase 25 rückwirkend in den Wizard eingehängt (kein harter Forward-Dep).
**Depends on**: Phase 20 (Legacy-Provider/Billing-Fundament)
**Retro-Integration**: Kein KI-Walkthrough-Coach nach AD-26; Phase 25 liefert MCP-Server statt eigenem LLM/Coach.
**Milestones**: M157 (+ M28-Self-Service-Delta, Block P)
**Source-Specs**: `_bmad-output/specs/M28-subscriptions-billing.md` (Block P), `M29-onboarding.md`
**Success Criteria** (what must be TRUE):
@@ -407,18 +407,18 @@ Decimal phases appear between their surrounding integers in numeric order.
**UI hint**: yes
**Risiko**: Grund für Self-Service-v2 sind die noch fehlenden konkreten Preise (v1 EXT-14, Kostenmatrix mit Partner) — **nicht** Apple-Store-Auflagen (PWA-only, kein App-Store, Cut #14/AD-17); Web-Checkout direkt.
### Phase 25: KI-Tiefe — HiveMind/RAG (v2)
> ⛔ **KI→MCP-Pivot ([[AD-26]]): Phase 25 (HiveMind/RAG) zurückgestellt → ersetzt durch MCP-Server ([[VR-MCP]] / [[VD-MCP]]).** HiveGrid hostet kein eigenes RAG/Embedding/Whisper, sondern wird Remote-MCP-Server für externe KI-Clients. v2-P4 ist auf MCP umgeschnitten (siehe [[ROADMAP-V2]] v2-P4). Die HiveMind/RAG/Whisper-Inhalte unten sind als **v3-Kandidat zurückgestellt** und nur historisch erhalten.
**Goal**: ~~Aus dem v1-LLM-Gateway wird semantische Tiefe — RAG über Tenant-Daten, EU-Whisper, perspektivisch der autonome HiveMind-Agent.~~ **→ ersetzt durch Remote-MCP-Server ([[VR-MCP]]).**
**Depends on**: ~~Phase 13 (LLM-Gateway)~~ (entfällt — [[AD-26]]), Phase 11 (Outbox/Suche)
**Retro-Integration**: ~~Phase 29 (Help) registriert seine Artikel rückwirkend als RAG-Korpus~~ → RAG entfällt; Help-Artikel können optional als MCP-Resource exponiert werden.
**Milestones**: ~~M087 (+ Whisper-EU, pgvector-Hybrid-Suche, zweiter EU-Provider)~~ → gelöscht ([[AD-26]]); neuer v2-Neuschnitt = [[VR-MCP]] (kein M0xx-Milestone)
**Source-Specs**: ~~`_bmad-output/specs/M18-ki-layer.md` (RAG/HiveMind/Whisper-Teile)~~ (entfällt — [[AD-26]])
**Success Criteria** ~~(what must be TRUE)~~ — **zurückgestellt (v3-Kandidat), überholt durch [[AD-26]]**:
1. ~~Embedding-Pipeline → `TenantEmbedding` (pgvector), Re-Embed per Hash-Diff, Permission-Pre-Filter vor Vector-Search; hybride globale Suche (Volltext+Vektor, RRF).~~
2. ~~Whisper-EU transkribiert Voice-Notes async; Multi-Provider-Failover (Mistral→Scaleway→AlephAlpha) + Promptfoo in CI.~~
3. ~~HiveMind-Agent liefert proaktive Vorschläge mit Action-Boundary (keine schreibenden Aktionen ohne Bestätigung), Audit-Trail, Scheduled-Actions.~~
**Plans**: ~~TBD~~ → siehe [[VR-MCP]]/[[VD-MCP]]
### Phase 25: MCP-Server (v2)
> ⛔ **KI→MCP-Pivot ([[AD-26]]):** HiveGrid hostet kein eigenes LLM/RAG/Whisper. Phase 25 ist der Remote-MCP-Server für externe KI-Clients. HiveMind/RAG/Whisper sind v3-Kandidaten und keine v2-Baugrundlage.
**Goal**: Externe KI-Clients können HiveGrid-Daten über tenant-gescopete Read-Ressourcen lesen und freigegebene Schreib-Aktionen über bestätigungspflichtige Action-Tools ausführen.
**Depends on**: Phase 22 (API-Key/Auth/Scopes/Rate-Limit), Phase 2 (Tenant/RLS), Phase 1 (Audit/Permissions)
**Retro-Integration**: Help-Artikel und andere Daten können optional als MCP-Resources exponiert werden; keine RAG-/Embedding-Pipeline.
**Milestones**: [[VR-MCP]] / [[VD-MCP]] (kein M0xx-Milestone)
**Source-Specs**: [[VR-MCP]] / [[VD-MCP]] · [[AD-26]]
**Success Criteria** (what must be TRUE):
1. Remote-MCP-Server mit Streamable HTTP und `@modelcontextprotocol/sdk` exponiert Read-Ressourcen für Hauptentitäten.
2. Auth = API-Key + Scopes + Rate-Limit aus Phase 22; Tenant wird aus dem Token abgeleitet; jeder Zugriff läuft durch `withTenantScope()`/RLS.
3. Action-Tools sind `withPermission()`-gegated, verlangen bei Schreib-Operationen Confirmation und schreiben Audit-Events mit Actor-Kind `MCP/Agent`.
**Plans**: TBD → siehe [[VR-MCP]]/[[VD-MCP]]
**UI hint**: yes
**Constraint**: ~~ausschließlich EU-Provider (DSGVO), AVV mit allen Providern.~~ → MCP: kein LLM-Hosting, daher kein Provider-AVV; Tenant-Scope/RLS + API-Key-Auth gelten.
@@ -462,7 +462,7 @@ Decimal phases appear between their surrounding integers in numeric order.
### Phase 29: Help-Center & Help-Desk (v2)
**Goal**: Öffentliches file-basiertes Help-Center mit Deep-Links aus der App + In-App-Feedback-Widget (Ausbau), das im Plattform-Admin landet.
**Depends on**: Phase 21 (Feedback-Widget→Support-Inbox-Andockpunkt), Phase 19 (Admin)
**Retro-Integration**: liefert HelpArticle-Embeddings als RAG-Korpus an Phase 25.
**Retro-Integration**: Help-Artikel können optional als MCP-Resources in Phase 25 exponiert werden; kein RAG-/Embedding-Korpus.
**Milestones**: M172, M173, M174, M175
**Source-Specs**: `_bmad-output/specs/M38-help-center.md`, `M39-help-desk.md`
**Success Criteria** (what must be TRUE):
@@ -519,7 +519,7 @@ Decimal phases appear between their surrounding integers in numeric order.
**Source-Specs**: `_bmad-output/specs/M12-telefonie.md` (Provider-Pattern) — ⚠ **HalloPetra selbst ist in keiner Spec; Vorlauf-Spec nötig.**
**Success Criteria** (what must be TRUE):
1. HalloPetra-Adapter implementiert `TelephonyProviderInterface` (connect/triggerOutboundCall/subscribeToWebhooks/verifyWebhookSignature/parseWebhookEvent/getRecordingUrl) ohne Kern-Änderung.
2. Webhook-Auto-Match auf Contact/Lead/Deal, Activity + Automation-Trigger; Click-to-Call + Inbound providerunabhängig.
2. Webhook-Auto-Match auf Contact/Lead/Projekt, Activity + Automation-Trigger; Click-to-Call + Inbound providerunabhängig.
**Plans**: TBD
**UI hint**: yes (Setup-/Binding-Flächen)
@@ -568,7 +568,7 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 →
| 22. Public-API & Webhooks | 0/TBD | Not started (v2) | - |
| 23. Postfach-Sync (Email-Inbox) | 0/TBD | Not started (v2) | - |
| 24. Self-Service-Billing & Onboarding-Wizard | 0/TBD | Not started (v2) | - |
| 25. KI-Tiefe — HiveMind/RAG | 0/TBD | Not started (v2) | - |
| 25. MCP-Server | 0/TBD | Not started (v2) | ersetzt alten HiveMind/RAG-Slot |
| 26. Customer-Portal ⭐ | 0/TBD | Not started (v2, ex-P18) | - |
| 27. Mobile-PWA-Ausbau | 0/TBD | Not started (v2) | - |
| 28. Plattform-Admin-Tiefe | 0/TBD | Not started (v2) | - |
@@ -40,32 +40,32 @@
- **HiveMind/RAG (M087): → v2 — v1 baut die Andock-Adapter:** (a) Outbox-Index-Events (existiert via P11-Indexer), (b) `RetrievalProvider`-Interface im KI-Unterbau, (c) Bestätigungs-Grenze („KI schreibt nie ohne User-OK") entsteht v1 via M086 und wird von HiveMind geerbt. REQ-ki-hivemind + REQ-ki-rag-embeddings → v2.
- **Whisper: → v2** (REQ-ki-whisper). „Cool, aber aktuell nicht nötig."
- **Unterbau bleibt v1:** M083 Gateway, M084 Multi-Provider-Fallback, M085 Promptfoo, M088 Token-Limits (Pay-as-you-go-Abrechnung weiterhin per Inversion → P20/v2; lokales Credits-Konto bleibt).
- **Folge-Entscheidung (Veto möglich): P11-Suche wird v1 Keyword-only** (Meilisearch); pgvector/Hybrid-RRF → v2 mit HiveMind. AD-5 wird sequenziert (Keyword v1, Hybrid v2). REQ-suche-hybrid wird entsprechend gedünnt.
- **Folge-Entscheidung:** P11-Suche bleibt Keyword-only (Meilisearch); pgvector/Hybrid-RRF/HiveMind entfällt nach AD-26 bzw. ruht als v3-Kandidat. REQ-suche-hybrid ist entsprechend auf Meilisearch gedünnt.
## Entscheidung #6 — Keep-Entscheidungen (2026-06-11)
- **Team-Chat (M110–M114): bleibt v1** — „ultra wichtig". WebSocket-Layer (AD-9) damit ebenfalls v1.
- **HR-Hub (M133–M140): bleibt v1 komplett** — „genau wie HR".
- **Booking + Kalender-Sync (M118–M120, M126–M129): bleiben v1** („und ähnliches" → Signatur M130–M132 vermutlich auch — Bestätigung ausstehend).
- **Stripe (M153–M156): bleibt v1.** ⚠ Offen: Rücksprache mit Partner zu **pauschalem Pricing** — falls pauschal, dünnt M156 (Metering nur als Datensammlung, keine Aufschlags-Abrechnung; Seat-Quota-Logik vereinfacht).
- **Konsequenz Externals:** Google-OAuth-Verifizierung (A2) bleibt im Sprint relevant (Kalender-Lesepfad braucht Calendar-Scope!), Stripe-Live (A5) bleibt, PandaDoc (B4) bleibt (falls Signatur bestätigt).
- **Legacy-Provider (M153–M156): bleibt v1.** ⚠ Offen: Rücksprache mit Partner zu **pauschalem Pricing** — falls pauschal, dünnt M156 (Metering nur als Datensammlung, keine Aufschlags-Abrechnung; Seat-Quota-Logik vereinfacht).
- **Konsequenz Externals:** Google-OAuth-Verifizierung (A2) bleibt im Sprint relevant (Kalender-Lesepfad braucht Calendar-Scope!), Legacy-Provider-Live (A5) bleibt, PandaDoc (B4) bleibt (falls Signatur bestätigt).
## Entscheidung #7 — Kommunikation & Billing-Modell (2026-06-11)
- **Mail-Inbox/Postfach-Sync → v2:** M096-OAuth-Teil, M097 (Inbox/Threading/Auto-Verknüpfung), M098-Provider-Versand, M099-Teile → v2. **Bleibt v1:** Sende-Domain-Verifikation (aus M096 herausgelöst), Mailjet-Versand, Bounce/Sperrliste (Marketing-Anteil), Inbound-Adresse→Lead. **Empfohlen (klein, ~½ MS): BCC-Logging-Brücke** (`crm@tenant…` als BCC → Mail wird am Kontakt protokolliert, nutzt Inbound-Infrastruktur, kein Google-OAuth). → **Google-Verifizierung (A2) nur noch für Kalender-Scopes nötig, Gmail-Scopes raus.**
- **Telefonie bleibt v1** (AirCall M106–M109, Anruf-Trigger für Automation). ⚠ **~~inkl. HalloPetra~~ überholt durch #10:** HalloPetra (M105) → v2 (Phase 33). AirCall bleibt v1.
- **Marketing bleibt voll** (Re-Bestätigung von #4).
- **Stripe: einheitliches Billing** — keine Büro-/Monteur-**Seat**-Unterscheidung mehr (M154 kollabiert; Interpretation „Seats" vom User rückbestätigen lassen — NICHT die SHK-Seeds!). Varianten-Entscheidung (pro Nutzer einheitlich / pauschal pro Mandant / Größenstaffel) liegt beim Partner-Gespräch. M156-Aufschlags-Billing entfällt; Verbrauchs-Counter + Fair-Use-Drossel bleiben als Kostenschutz.
- **Legacy-Provider: einheitliches Billing** — keine Büro-/Monteur-**Seat**-Unterscheidung mehr (M154 kollabiert; Interpretation „Seats" vom User rückbestätigen lassen — NICHT die SHK-Seeds!). Varianten-Entscheidung (pro Nutzer einheitlich / pauschal pro Mandant / Größenstaffel) liegt beim Partner-Gespräch. M156-Aufschlags-Billing entfällt; Verbrauchs-Counter + Fair-Use-Drossel bleiben als Kostenschutz.
## Entscheidung #8 — Abschluss der Streich-Session (2026-06-11)
- **Signatur (M130–M132): bleibt v1** (bestätigt — war Teil von „Booking, Kalender und ähnliches").
- **Onboarding-Wizard-UI (M157): → v2.** Seeds (M158) + Demo-Daten (M160) bleiben; Branchen-Framework (M159) v1 nur SHK. Signup → Auto-SHK-Seed ohne Wizard.
- **Billing-Modell final: „Abo-Baukasten"** — Basis-Preis pro Nutzer (keine Seat-Typen) + Feature-Module als zubuchbare Stripe-Positionen. **M154-Recut:** Seat-Typ-Modell ersetzt durch Entitlement-Schicht = Feature-Flag-Engine (P1) × `packages/entitlements` × daten-getriebene Stripe-Items (M153). **v1 = Mechanik** (Admin stellt Tenant-Abos in P19 zusammen), **v2 = Self-Service-Baukasten-UI** (Kunde toggelt Features mit Preis-Vorschau). **Kostenmatrix** = Business-Aufgabe mit Partner, blockt Bau nicht (AD-15). Zähler + Drossel bleiben v1; Extra-Usage-Abrechnung wird separat besprochen (offen, blockt nicht).
- **Billing-Modell final: „Abo-Baukasten"** — Basis-Preis pro Nutzer (keine Seat-Typen) + Feature-Module als zubuchbare Legacy-Provider-Positionen. **M154-Recut:** Seat-Typ-Modell ersetzt durch Entitlement-Schicht = Feature-Flag-Engine (P1) × `packages/entitlements` × daten-getriebene Legacy-Provider-Items (M153). **v1 = Mechanik** (Admin stellt Tenant-Abos in P19 zusammen), **v2 = Self-Service-Baukasten-UI** (Kunde toggelt Features mit Preis-Vorschau). **Kostenmatrix** = Business-Aufgabe mit Partner, blockt Bau nicht (AD-15). Zähler + Drossel bleiben v1; Extra-Usage-Abrechnung wird separat besprochen (offen, blockt nicht).
## Entscheidung #9 — Konnektor-Pflicht + Legacy-Plan-Referenz (2026-06-11)
- **Konnektor-Pflicht pro Feature (v1, hart):** Jedes Modul ist eigenständig enable-/disable-bar und plan-gebunden schaltbar. Wird **neuer Integration-Contract in P1** („Entitlement-Gate-Pflicht": Modul registriert Gate, Sidebar/Routes/Actions prüfen `tenantHasModule()`), Pflicht-Checkpunkt jeder Modul-Phase. Mechanik: Boilerplate-Flag-Engine (hat bereits `plan-tier`-Targeting!) × `packages/entitlements` × Stripe-Items.
- **Konnektor-Pflicht pro Feature (v1, hart):** Jedes Modul ist eigenständig enable-/disable-bar und plan-gebunden schaltbar. Wird **neuer Integration-Contract in P1** („Entitlement-Gate-Pflicht": Modul registriert Gate, Sidebar/Routes/Actions prüfen `tenantHasModule()`), Pflicht-Checkpunkt jeder Modul-Phase. Mechanik: Boilerplate-Flag-Engine (hat bereits `plan-tier`-Targeting!) × `packages/entitlements` × Legacy-Provider-Items.
- **Legacy-Plan-Namen gefunden:** **Core / Swarm / Colony** (D006, 29€/49€/Contact-Sales pro Seat) in `~/Documents/GitHub/archive/hivegrid-cloud/app/config/pricing-plans.ts`. Datei enthält komplette Konnektor-Blaupause: 14 gegatete Module, 6 Quota-Dimensionen (inkl. aiTokensPerMonth), 6 Plan-Flags. **→ Als Source-Spec für den P20-Recut (M154→Abo-Baukasten) übernehmen**; Struktur portieren, Inhalte (Preise/Tiers vs. À-la-carte) = Kostenmatrix-Gespräch mit Partner.
## Entscheidung #10 — Feature-Tiefe-Runde (2026-06-11)
@@ -84,9 +84,9 @@
**→ v2 (committed, sequenziert):** Mobile-PWA-Ausbau (PWA-Recut, M167–171; Expo/Capacitor verworfen — AD-17) · Customer-Portal (M141–146) · HiveMind/RAG (M087) + Whisper + pgvector/Hybrid-Suche · Postfach-Sync (M096-OAuth/M097/M098; Domain-Verifikation bleibt) · Onboarding-Wizard-UI (M157) · Self-Service-Abo-Baukasten-UI · AlephAlpha-Provider · Pay-as-you-go-Abrechnung
**Bleibt v1 (User-Entscheidung):** komplette Kern-Kette P1–P10 · Suche (Keyword) + Dashboard · Automation VOLL · KI-Unterbau + Sprach-zu-Automation (exklusiv Automation) · **Marketing voll** · **Telefonie (AirCall) voll** ~~(inkl. HalloPetra)~~ *(überholt durch #10: HalloPetra/M105 → v2, Phase 33)* · **Team-Chat voll** · **HR voll** · Booking + Kalender + Forms + **Signatur** · Stripe (Abo-Baukasten-Mechanik) · Plattform-Admin · Seeds + Demo-Daten
**Bleibt v1 (User-Entscheidung):** komplette Kern-Kette P1–P10 · Suche (Keyword) + Dashboard · Automation VOLL · KI-Unterbau + Sprach-zu-Automation (exklusiv Automation) · **Marketing voll** · **Telefonie (AirCall) voll** ~~(inkl. HalloPetra)~~ *(überholt durch #10: HalloPetra/M105 → v2, Phase 33)* · **Team-Chat voll** · **HR voll** · Booking + Kalender + Forms + **Signatur** · Legacy-Provider (Abo-Baukasten-Mechanik) · Plattform-Admin · Seeds + Demo-Daten
**Bilanz: v1 ≈ 160 Milestones** (–16 vs. 176) ≈ 2,7 MS/Dev/Tag — weiterhin straff; Gates + neue Notfall-Leiter zwingend. Verbleibende harte Externals: **KWP, Google (nur noch Calendar-Scopes), Stripe-Live, PandaDoc/DocuSign, AirCall/HalloPetra, Mailjet-Domain-Warm-up.**
**Bilanz: v1 ≈ 160 Milestones** (–16 vs. 176) ≈ 2,7 MS/Dev/Tag — weiterhin straff; Gates + neue Notfall-Leiter zwingend. Verbleibende harte Externals: **KWP, Google (nur noch Calendar-Scopes), Legacy-Provider-Live, PandaDoc/DocuSign, AirCall/HalloPetra, Mailjet-Domain-Warm-up.**
---
@@ -109,12 +109,12 @@
- [ ] Marketing (P14-Hälfte, 5 MS)
- [ ] Public-API + Help-Center/Desk (P21, 7 MS)
- [ ] Booking / Signatur / Kalender-Sync (P16-Teile, 10 MS)
- [ ] Billing/Stripe + Onboarding-Wizard (P20-Teile, ~7 MS)
- [ ] Billing/Legacy-Provider + Onboarding-Wizard (P20-Teile, ~7 MS)
- [ ] USP-Tiefe P12/P13 (RAG/HiveMind/Whisper dünnen?)
## Entscheidung #11 — Boilerplate-Recheck-Runde (2026-06-12)
Basis: Sweep über alle 37 Reports in `docs/boilerplate-mapping/` + Code-Stichproben. Erkenntnis: Boilerplate-HIGH-Coverage-Milestones (Auth, Billing, i18n, Mail, Worker, Impersonation) NICHT cutten — fast geschenkt. Geschnitten/gedünnt werden die teuren Spec-Deltas.
Basis: historischer Sweep über die inzwischen entfernten Boilerplate-Mapping-Reports + Code-Stichproben. Erkenntnis: Boilerplate-HIGH-Coverage-Milestones (Auth, Billing, i18n, Mail, Worker, Impersonation) NICHT cutten — fast geschenkt. Geschnitten/gedünnt werden die teuren Spec-Deltas.
**→ v2 (5 ganze Milestones):**
- **M027 API-Keys & Tarif-Gates** — Public-API ist v2; Keys ohne Endpoints sinnlos. Boilerplate-`ApiKey`-Modell + Routen bleiben ungenutzt im Code liegen (Vorbereitung gratis). Lead-Push von Fremd-Websites läuft über M047-Quellen-Token, nicht über API-Keys.
@@ -139,7 +139,7 @@ Buttons (primär/sekundär/destruktiv/ghost) · Badges + Status-Pills · Tags (f
| Screen | Inhalt | Typ |
|---|---|---|
| Admin-Dashboard | **Basis-KPIs: Tenants, aktive User, MRR** + System-Health-Ampel + Queue-Status. *(Churn/ARPU/Sales-Pipeline — v2)* | [KPI] |
| Tenant-Liste + Detail | Liste: Name, Slug, Status, Plan, MRR, User-Anzahl · Detail: Stammdaten, Status-Wechsler, **Abo-Zusammenstellung (Feature-Konnektoren togglen + Stripe-Positionen — Abo-Baukasten!)**, Trial-Verlängerung, Notizen (Markdown, gepinnt), **Impersonate mit Begründungs-Pflicht** | [A2/A3+A6] |
| Tenant-Liste + Detail | Liste: Name, Slug, Status, Plan, MRR, User-Anzahl · Detail: Stammdaten, Status-Wechsler, **Abo-Zusammenstellung (Feature-Konnektoren togglen + Legacy-Provider-Positionen — Abo-Baukasten!)**, Trial-Verlängerung, Notizen (Markdown, gepinnt), **Impersonate mit Begründungs-Pflicht** | [A2/A3+A6] |
| Manuelle Tenant-Anlage | Name, Slug, Branche, Admin-Email, Initial-Module, Trial-Dauer, Demo-Daten-Häkchen, Begründung | [A4] |
| User-Liste + Detail | Email, Name, Tenants, 2FA-Status, Sessions · Ops: 2FA-Reset, Session-Revoke, Deaktivieren | [A2/A3] |
| Feature-Flags [`/admin/feature-flags`] | Flag-Liste (Key, Status, Targeting), Filter-Editor (plan-tier!), Tenant-Override mit Ablauf. *(Massen-Statistik — v2)* | [A2+A6] |
@@ -184,10 +184,10 @@ Buttons (primär/sekundär/destruktiv/ghost) · Badges + Status-Pills · Tags (f
| AI-Credits [`/admin/usage/ai`] | Saldo, Verbrauch je Use-Case (Donut), Verlauf, Top-User, **80%-Warnbanner**, Hochrechnung | [KPI+Charts] |
| AirCall-Setup + Anrufe | Connect-Card + **User-Binding** (AirCall-User ↔ HiveGrid-User mit Auto-Match) · Anruf-Liste: Nummer, Richtung, Dauer, gematchter Kontakt, Notiz-Snippet · Anruf-Detail: Match-Override, Notiz-Editor (Markdown), Aufnahme-Link · Click-to-Call-Button + eingehender-Anruf-Notification | [A6+A2/A3] |
| Sende-Domain [`/settings/integrations/email`] | Domain-Eingabe, **DNS-Records (DKIM/SPF/DMARC) mit Copy-Button**, Verifikations-Status-Ampel, 5-Min-Prüf-Indikator · **BCC-Logging:** Toggle + angezeigte BCC-Adresse + Klartext „Mails erscheinen am Kontakt" *(kein Postfach/Inbox/Compose — v2!)* | [A6] |
| Abo-Übersicht [`/admin/subscription`] | Basis-Plan + **gebuchte Feature-Module als Positionsliste mit Preisen** · Status/Trial-Countdown · Verbrauch: Automation-Läufe, AI-Tokens, Speicher mit Quota-Balken · Stripe-Portal-Embed (Rechnungen, Zahlungsmethode) · Pausieren/Kündigen *(kein Self-Service-Modul-Toggle — Admin macht das, v2)* | [A6+KPI] |
| Abo-Übersicht [`/admin/subscription`] | Basis-Plan + **gebuchte Feature-Module als Positionsliste mit Preisen** · Status/Trial-Countdown · Verbrauch: Automation-Läufe, AI-Tokens, Speicher mit Quota-Balken · Legacy-Provider-Portal-Embed (Rechnungen, Zahlungsmethode) · Pausieren/Kündigen *(kein Self-Service-Modul-Toggle — Admin macht das, v2)* | [A6+KPI] |
| Demo-Daten [`/admin/onboarding/demo-data`] | **Settings-Knopf „Beispieldaten anlegen"** (25 Datensätze, Klartext-Liste was kommt) · Demo-Liste mit Zähler + Lösch-Funktion · „Demo ausblenden"-Filter erscheint in allen Listen *(kein Wizard, kein KI-Walkthrough — v2)* | [A6] |
| Erste-Schritte-Checkliste [`/onboarding/checklist`] | Items mit Status-Häkchen, Klartext, CTA-Link je Item (KWP verbinden → v2-Item ausgeblendet), manuell abhakbar, ausblendbar | [A2] |
| Privacy-Hub [`/admin/privacy`] | Export: Job-Liste mit Status/Progress/Download (72h-Link) · **Tenant-Löschung: Multi-Step** (Erklärung 30-Tage-Grace → Export-Angebot → Stripe-Hinweis → Passwort+2FA → **Slug als Bestätigungs-Phrase**) + Grace-Countdown mit Abbruch · Subprozessoren-Tabelle (Name, Land, EU-Marker, Zweck, DPA-Link) · AVV: aktuelle Version als PDF + Akzeptanz-Historie | [A2+A5] |
| Privacy-Hub [`/admin/privacy`] | Export: Job-Liste mit Status/Progress/Download (72h-Link) · **Tenant-Löschung: Multi-Step** (Erklärung 30-Tage-Grace → Export-Angebot → Legacy-Provider-Hinweis → Passwort+2FA → **Slug als Bestätigungs-Phrase**) + Grace-Countdown mit Abbruch · Subprozessoren-Tabelle (Name, Land, EU-Marker, Zweck, DPA-Link) · AVV: aktuelle Version als PDF + Akzeptanz-Historie | [A2+A5] |
| User-Daten-Export [`/settings/account/data-export`] | Klartext was exportiert wird, Start-Button, Job-Status mit Download | [A6] |
| **Feedback-Widget** [global, klein] | Schwebender Button → Mini-Form (Kategorie, Text, Screenshot-Häkchen) → landet in Admin-Support-Inbox | [A5-klein] |
@@ -26,7 +26,7 @@ css: |-
|---|---|---|---|
| A1 | **KWP:** Callback-URL zurückmelden (`https://app.hivegrid.de/integrations/kwp/oauth/callback`) + **bnWin.net auf 8.1 N3** bei Geerkens-IT | v2-P10 (erstes v2-Modul) | ✅ entschärft — Zugang lt. KWP-Mail ~**19.07.** (nach Launch); kein Sprint-Risiko mehr, nur Vorbereitung |
| A2 | **Google Cloud OAuth-App: nur noch Calendar-API-Scope** beantragen | P16 Kalender | 🟠 Verifizierung Tage–Wochen; Gmail-Scopes durch Postfach-Cut NICHT mehr nötig |
| A5 | **Stripe-Konto + Live-Verifizierung** | P20 | 🟠 Tage; Test-Mode reicht für Dev |
| A5 | **Legacy-Provider-Konto + Live-Verifizierung** | P20 | 🟠 Tage; Test-Mode reicht für Dev |
| A6 | **Sende-Domain: SPF/DKIM/DMARC für Mailjet + Warm-up** | P1/P2/P14 — **Marketing-Kampagnen brauchen Reputation!** | 🔴 sofort, Warm-up braucht Laufzeit |
| B2 | **AirCall**-Konto + Sandbox + Webhooks | P15 | 🟡 Tage |
| B4 | **PandaDoc** (Standard) Konto + Sandbox; DocuSign optional | P16 Signatur | 🟡 Tage |
@@ -114,7 +114,7 @@ Vorab beschlossen wie die Notfall-Leiter; Detail in `TIMELINE.md`. Kurzform:
```
Sprosse 1: nächste eigene Phase sofort ziehen (9:00-Sync)
Sprosse 2: Track-übergreifend aushelfen (kritischer Pfad: P16, P8/P9)
Sprosse 3: Puffer ≥3 Tage → Launch-Vorzieh-Prüfung mit Noah (Stripe live + Staging-Abnahme)
Sprosse 3: Puffer ≥3 Tage → Launch-Vorzieh-Prüfung mit Noah (Legacy-Provider live + Staging-Abnahme)
Sprosse 4: v2-Vorzieher: (a) KWP-Recut-Vorarbeit (Doku, kein Mock-E2E) → (b) Self-Service-Abo-UI → (c) Postfach-Sync
Pflicht: pro gewonnener Woche ≥1 Tag Härtung (Bug-Bash/Lasttest/Security)
NIEMALS: Cut-Rückholung in Eigenregie · Goldrand an internen Screens · Gates verschieben (außer Sprosse 3)
@@ -26,14 +26,14 @@ css: |-
# HiveGrid 2.0 — Sprint-Tagesplan (ticket-genau)
> ⛔ **KI→MCP-Pivot ([[AD-26]], 2026-06-22) — Tagesplan-Delta:** **R-P13** (KI-Layer) + **D-P13** + **EXT-11** (Mistral-Key) + Milestones **M083–M088** entfallen. **R-P12b** ist neues letztes v1-Glied (Sprach→Automation gestrichen → reiner Flow-Editor); der **09.07.** wird Härtungstag (Freeze/Launch unverändert). In v2: **VR-P25/VD-P25** (HiveMind/RAG) zurückgestellt → **[[VR-MCP]]/[[VD-MCP]]** (MCP-Server) im Slot 18.–20.07. Die KI-Zeilen in den Tabellen unten sind insoweit überholt — autoritativ sind [[AD-26]] + die Ticket-Docs.
> ⛔ **KI→MCP-Pivot ([[AD-26]], 2026-06-22):** **R-P13** (KI-Layer), **D-P13**, **EXT-11** (Mistral-Key) und Milestones **M083–M088** entfallen. **R-P12b** ist das letzte v1-Glied der Raffael-Kette; Sprach→Automation ist gestrichen. In v2 ersetzt **[[VR-MCP]]/[[VD-MCP]]** den alten P25-HiveMind/RAG-Slot.
<p class="lead">Jedes Ticket an seinem Start-Tag · ID · Wer · Fenster (Start→Ziel) · Aufgabe · 152 Sprint-Tickets (v1: 110 · v2: 42) + 2 v3-Backlog</p>
<p class="lead">Jedes Ticket an seinem Start-Tag · ID · Wer · Fenster (Start→Ziel) · Aufgabe · 149 Sprint-Tickets (v1: 107 · v2: 42) + 2 v3-Backlog</p>
<p class="meta">Generiert aus Plane (Single Source) · Arbeitsrhythmus Mo–So ≥8 h · „bis" = Ziel-/Fertig-Datum (Mehrtages-Tickets laufen bis dahin) · rot = Meilenstein/Gate-Tag · <span style="color:#b91c1c">rote Ticket-ID</span> = Block 2 (Partner-API offen, agil). Neu erzeugen: <code>python3 .planning/plane-sync/gen_dayplan.py</code></p>
## Woche 1 · Di 16.–Fr 19.06. — Fundament & Identität · 40 Tickets
*→ Gate W1 (Fr 19.06.)*
### Di 16.06. · 24 Ticket(s)
### Di 16.06. · 23 Ticket(s)
| Ticket | Wer | bis | Aufgabe |
|---|---|---|---|
@@ -45,11 +45,10 @@ css: |-
| EXT-04 | Raffael | 17.06. | KWP: Callback-URLs melden + bnWin.net 8.1 N3 |
| EXT-05 | Raffael | 16.06. | Noah: 4 Gate-Freitags-Slots blocken |
| EXT-06 | Raffael | 17.06. | Hetzner-Projekt HiveGrid: S3+Token + DEK-Master-Key |
| EXT-07 | Raffael | 26.06. | Stripe-Konto + Live-Verifizierung |
| EXT-07 | Raffael | 26.06. | Legacy-Provider-Konto + Live-Verifizierung |
| EXT-08 | Raffael | 30.06. | AirCall-Konto + Sandbox + Webhooks |
| EXT-09 | Raffael | 03.07. | PandaDoc-Sandbox (Provider-Wahl) |
| EXT-09 | Raffael | 03.07. | Signatur-Provider-Entscheid: SES default, PandaDoc optional BYOK |
| EXT-10 | Raffael | 18.06. | Google-Cloud OAuth + Calendar-Scope-Antrag |
| EXT-11 | Raffael | 18.06. | Mistral API-Key |
| EXT-12 | Raffael | 19.06. | Geo-IP + HIBP prüfen/anbinden |
| H-LISTE | Haris | 17.06. | Zielkundenliste SHK (KWP-Nutzer-Fokus) |
| H-PAIN | Haris | 17.06. | Painpoint-Gespräch mit Noah |
@@ -143,7 +142,7 @@ css: |-
| AK-DEMO1 | Akeshan | 26.06. | Demo-Tenant + SHK-Demo-Daten für Noah-Demo 1 |
| AK-P17b | Akeshan | 26.06. | P17 HR Urlaub/Krank + Surveys + Self-Service |
| D-ADMIN | David | 26.06. | Plattform-Admin-Compose |
| H-PAYLINK | Haris | 26.06. | Stripe Payment Link (Founding-Deal) |
| H-PAYLINK | Haris | 26.06. | Mollie Payment Link (Founding-Deal) |
| N-DEMO1 | Dejan/Haris | 26.06. | Demo 1 abnehmen (CRM klickbar) |
## Woche 3 · Sa 27.06.–Fr 03.07. — Kern-Kette + Kommunikation · 23 Tickets
@@ -155,7 +154,7 @@ css: |-
| AK-P14a | Akeshan | 28.06. | P14 Sende-Domain-Verifikation + BCC-Brücke + Tracking |
| D-P14 | David | 29.06. | Screens P14 — Email & Marketing (ohne Kampagnen-Editor) |
| D-P8 | David | 29.06. | Screens P8 — Projekt & Pipelines (Kern, ohne Kanban) |
| R-P8a | Raffael | 28.06. | P8 Pipeline-Engine + Projekt-CRUD + Auto-Verkaufsfall |
| R-P8a | Raffael | 28.06. | P8 Pipeline-Engine + Projekt-CRUD + Auto-Projekt |
### So 28.06. · 2 Ticket(s)
| Ticket | Wer | bis | Aufgabe |
@@ -220,7 +219,7 @@ css: |-
| Ticket | Wer | bis | Aufgabe |
|---|---|---|---|
| AK-P16d | Akeshan | 07.07. | P16 Angebot/Signatur (PandaDoc) |
| AK-P16d | Akeshan | 07.07. | P16 Angebot/Signatur (Self-hosted SES; PandaDoc optional BYOK) |
| D-P12 | David | 07.07. | Screens P12 — Automation (ohne Flow-Editor) |
| D-POLISH | David | 09.07. | Politur extern sichtbarer Flächen + Visual-Reg-Baselines |
| H-WEBINAR | Haris | 10.07. | Webinar-Einladungen + Moderation |
@@ -230,23 +229,21 @@ css: |-
| Ticket | Wer | bis | Aufgabe |
|---|---|---|---|
| R-P12b | Raffael | 08.07. | P12 Visueller Flow-Editor + Dry-Run + 24h-Undo + Sprach-Andock |
| R-P12b | Raffael | 08.07. | P12 Visueller Flow-Editor + Dry-Run + 24h-Undo |
### Mi 08.07. · 2 Ticket(s)
| Ticket | Wer | bis | Aufgabe |
|---|---|---|---|
| AK-P20 | Akeshan | 08.07. | P20 Billing (Stripe Abo-Baukasten) + Onboarding + Branding |
| AK-P20 | Akeshan | 08.07. | P20 Billing (Legacy-Provider Abo-Baukasten) + Onboarding + Branding |
| D-P20 | David | 08.07. | Screens P20 — Billing/Onboarding/Branding (ex-D-REST) |
<h3 class="gate">Do 09.07. · 5 Ticket(s)</h3>
<h3 class="gate">Do 09.07. · 3 Ticket(s)</h3>
| Ticket | Wer | bis | Aufgabe |
|---|---|---|---|
| AK-P21 | Akeshan | 09.07. | P21 DSGVO (Export + Lösch-Workflow) + Feedback-Widget |
| D-P13 | David | 09.07. | Screens P13 — KI-Layer (ex-D-REST) |
| D-P21 | David | 09.07. | Screens P21 — DSGVO/Launch (ex-D-REST) |
| H-FOLLOW | Haris | 10.07. | Trial-Follow-ups + Bilanz |
| R-P13 | Raffael | 09.07. | P13 KI-Layer (Mistral-Gateway) |
<h3 class="gate">Fr 10.07. · 1 Ticket(s)</h3>
@@ -254,7 +251,7 @@ css: |-
|---|---|---|---|
| N-GONOGO | Dejan/Haris | 10.07. | Voll-Walkthrough + Go/No-Go |
## v2-Woche 1 · Mo 13.–So 19.07. — Public-API / Postfach / Abo / KI · 11 Tickets
## v2-Woche 1 · Mo 13.–So 19.07. — Public-API / Postfach / Abo / MCP · 11 Tickets
*→ Webinar Nr. 1 (Di/Mi 14./15.07.)*
### Mo 13.07. · 7 Ticket(s)
@@ -276,13 +273,13 @@ css: |-
| Ticket | Wer | bis | Aufgabe |
|---|---|---|---|
| VD-P25 | David | 20.07. | Screens P25 — KI-Tiefe/HiveMind |
| VD-MCP | David | 20.07. | Screens MCP — MCP-Connection-Verwaltung + Scope/Audit |
### Sa 18.07. · 2 Ticket(s)
| Ticket | Wer | bis | Aufgabe |
|---|---|---|---|
| VA-P24b | Akeshan | 20.07. | Onboarding-Wizard-UI (M157) |
| VR-P25 | Raffael | 20.07. | KI-Tiefe: HiveMind/RAG + Whisper + pgvector-Hybrid (M087) |
| VR-MCP | Raffael | 20.07. | MCP-Server: Read-Ressourcen + bestätigungspflichtige Action-Tools |
## v2-Woche 2 · Mo 20.–So 26.07. — KWP / Portal / Mobile · 15 Tickets
*→ KWP-Sync-E2E mit Geerkens (Fr 24.07.)*
@@ -378,7 +375,7 @@ css: |-
<h2 class="idx">Ticket-Index — alle 152 Tickets (nach Track)</h2>
*Zum Nachschlagen, je Track nach Start sortiert. •B2 = Block 2 (Partner-API offen, agil).*
### Raffael · 42 Tickets
### Raffael · 40 Tickets
| Ticket | Start | bis | v | Aufgabe |
|---|---|---|---|---|
@@ -386,11 +383,10 @@ css: |-
| EXT-04 | 16.06. | 17.06. | v1 | KWP: Callback-URLs melden + bnWin.net 8.1 N3 |
| EXT-05 | 16.06. | 16.06. | v1 | Noah: 4 Gate-Freitags-Slots blocken |
| EXT-06 | 16.06. | 17.06. | v1 | Hetzner-Projekt HiveGrid: S3+Token + DEK-Master-Key |
| EXT-07 | 16.06. | 26.06. | v1 | Stripe-Konto + Live-Verifizierung |
| EXT-07 | 16.06. | 26.06. | v1 | Legacy-Provider-Konto + Live-Verifizierung |
| EXT-08 | 16.06. | 30.06. | v1 | AirCall-Konto + Sandbox + Webhooks |
| EXT-09 | 16.06. | 03.07. | v1 | PandaDoc-Sandbox (Provider-Wahl) |
| EXT-09 | 16.06. | 03.07. | v1 | Signatur-Provider-Entscheid: SES default, PandaDoc optional BYOK |
| EXT-10 | 16.06. | 18.06. | v1 | Google-Cloud OAuth + Calendar-Scope-Antrag |
| EXT-11 | 16.06. | 18.06. | v1 | Mistral API-Key |
| EXT-12 | 16.06. | 19.06. | v1 | Geo-IP + HIBP prüfen/anbinden |
| R-P1a | 16.06. | 17.06. | v1 | P1 EU-Sterilisation + Contracts + URL-Schemata |
| R-P1c | 16.06. | 17.06. | v1 | P1 Hot-Swap-Maschine + Walking-Skeleton + PWA |
@@ -402,7 +398,7 @@ css: |-
| R-P6 | 22.06. | 24.06. | v1 | P6 CRM-Kern |
| EXT-14 | 24.06. | 25.06. | v1 | Kostenmatrix/Preise mit Partner fixieren |
| R-P7 | 25.06. | 26.06. | v1 | P7 Lead-Inbox |
| R-P8a | 27.06. | 28.06. | v1 | P8 Pipeline-Engine + Projekt-CRUD + Auto-Verkaufsfall |
| R-P8a | 27.06. | 28.06. | v1 | P8 Pipeline-Engine + Projekt-CRUD + Auto-Projekt |
| R-P8b | 28.06. | 29.06. | v1 | P8 Konfigurierbarer Kanban + Forecast/Reporting |
| EXT-13 | 29.06. | 29.06. | v1 | v2-Vorlauf-Externals (Gmail-Scope/Entra) |
| R-P9a | 30.06. | 01.07. | v1 | P9 Projekt-Pipeline + Sub-Areas + Rechnung (Sub-Entity v1) |
@@ -410,12 +406,11 @@ css: |-
| R-P11a | 03.07. | 04.07. | v1 | P11 Globale Suche (Meilisearch, Cmd+K) |
| R-P11b | 04.07. | 05.07. | v1 | P11 Dashboard (3 Rollen-Layouts) |
| R-P12a | 06.07. | 07.07. | v1 | P12 Automation-Engine + Trigger/Action-Registry + Guards |
| R-P12b | 07.07. | 08.07. | v1 | P12 Visueller Flow-Editor + Dry-Run + 24h-Undo + Sprach-Andock |
| R-P13 | 09.07. | 09.07. | v1 | P13 KI-Layer (Mistral-Gateway) |
| R-P12b | 07.07. | 08.07. | v1 | P12 Visueller Flow-Editor + Dry-Run + 24h-Undo |
| VEXT-5 | 13.07. | 19.07. | v2 | Gmail-Restricted-Scope-Verifizierung nachhalten |
| VR-P22a | 13.07. | 15.07. | v2 | Public-API: OpenAPI + Read-Endpoints (M161) |
| VR-P22b | 15.07. | 17.07. | v2 | Public-API: API-Key-Auth/Scopes/Rate-Limit (M162/M027) + Webhooks (M163) |
| VR-P25 | 18.07. | 20.07. | v2 | KI-Tiefe: HiveMind/RAG + Whisper + pgvector-Hybrid (M087) |
| VR-MCP | 18.07. | 20.07. | v2 | MCP-Server: Read-Ressourcen + bestätigungspflichtige Action-Tools |
| VEXT-1 •B2 | 20.07. | 20.07. | v2 | KWP-API-Zugang nutzen + Recut-Detailplanung gegen API-Doku |
| VR-P32a •B2 | 20.07. | 22.07. | v2 | KWP: Connector + Health + Push-Queue (M089/090/091) |
| VR-P32b •B2 | 22.07. | 24.07. | v2 | KWP: Contract-Sentinel + Konflikt 'KWP gewinnt' (M092/093) |
@@ -445,8 +440,8 @@ css: |-
| AK-P16a | 03.07. | 04.07. | v1 | P16 Kalender-Sync (Google) |
| AK-P16b | 04.07. | 05.07. | v1 | P16 Booking (Slots + Verfügbarkeit + Storno) |
| AK-P16c | 05.07. | 06.07. | v1 | P16 Form-Builder (Editor + Submission + Actions) |
| AK-P16d | 06.07. | 07.07. | v1 | P16 Angebot/Signatur (PandaDoc) |
| AK-P20 | 08.07. | 08.07. | v1 | P20 Billing (Stripe Abo-Baukasten) + Onboarding + Branding |
| AK-P16d | 06.07. | 07.07. | v1 | P16 Angebot/Signatur (Self-hosted SES; PandaDoc optional BYOK) |
| AK-P20 | 08.07. | 08.07. | v1 | P20 Billing (Legacy-Provider Abo-Baukasten) + Onboarding + Branding |
| AK-P21 | 09.07. | 09.07. | v1 | P21 DSGVO (Export + Lösch-Workflow) + Feedback-Widget |
| VA-P23 | 13.07. | 17.07. | v2 | Postfach-Sync: Gmail/Outlook OAuth + Inbox + Threading + Versand (M096-098) |
| VA-P24a | 13.07. | 15.07. | v2 | Self-Service-Abo-Baukasten-UI (M28-Delta) |
@@ -462,7 +457,7 @@ css: |-
| VA-P34 •B2 | 29.07. | 31.07. | v2 | HR-Drittanbieter-Onboarding Memberspot/Boobora (M138) |
| VA-P30b | 30.07. | 31.07. | v2 | Querschnitt: Notif-Bündelung/Quiet-Hours (M117) + Sprach-Wechsler/EN (M029) |
### David · 42 Tickets
### David · 41 Tickets
| Ticket | Start | bis | v | Aufgabe |
|---|---|---|---|---|
@@ -494,12 +489,11 @@ css: |-
| D-P12 | 06.07. | 07.07. | v1 | Screens P12 — Automation (ohne Flow-Editor) |
| D-POLISH | 06.07. | 09.07. | v1 | Politur extern sichtbarer Flächen + Visual-Reg-Baselines |
| D-P20 | 08.07. | 08.07. | v1 | Screens P20 — Billing/Onboarding/Branding (ex-D-REST) |
| D-P13 | 09.07. | 09.07. | v1 | Screens P13 — KI-Layer (ex-D-REST) |
| D-P21 | 09.07. | 09.07. | v1 | Screens P21 — DSGVO/Launch (ex-D-REST) |
| VD-P22 | 13.07. | 17.07. | v2 | Screens P22 — Public-API-Keys (ex-VD-5) |
| VD-P23 | 13.07. | 17.07. | v2 | Screens P23 — Postfach-Inbox (ex-VD-1) |
| VD-P24 | 13.07. | 15.07. | v2 | Screens P24 — Abo-Baukasten + Wizard (ex-VD-1) |
| VD-P25 | 16.07. | 20.07. | v2 | Screens P25 — KI-Tiefe/HiveMind |
| VD-MCP | 16.07. | 20.07. | v2 | Screens MCP — MCP-Connection-Verwaltung + Scope/Audit |
| VD-P26 | 20.07. | 22.07. | v2 | Screens P26 — Customer-Portal |
| VD-P27 | 20.07. | 22.07. | v2 | Screens P27 — Mobile-PWA-Shell (PWA-only) |
| VD-P32 | 20.07. | 22.07. | v2 | Screens P32 — KWP-Setup (ex-VD-5) |
@@ -538,7 +532,7 @@ css: |-
| H-PITCH | 18.06. | 19.06. | v1 | Pitch-Deck + Gesprächsskript |
| H-CALLS | 23.06. | 26.06. | v1 | Erste Discovery-Calls |
| H-FOUND | 25.06. | 26.06. | v1 | Founding-Member-Angebot strukturieren |
| H-PAYLINK | 26.06. | 26.06. | v1 | Stripe Payment Link (Founding-Deal) |
| H-PAYLINK | 26.06. | 26.06. | v1 | Mollie Payment Link (Founding-Deal) |
| H-DEMO | 30.06. | 03.07. | v1 | Klick-Demos + echte Produkt-Demos |
| H-WEBINAR | 06.07. | 10.07. | v1 | Webinar-Einladungen + Moderation |
| H-FOLLOW | 09.07. | 10.07. | v1 | Trial-Follow-ups + Bilanz |
@@ -3,8 +3,8 @@ gsd_state_version: 1.0
milestone: v1.0
milestone_name: milestone
status: planning
stopped_at: context exhaustion at 75% (2026-06-21)
last_updated: "2026-06-21T20:56:25.641Z"
stopped_at: context exhaustion at 75% (2026-07-05)
last_updated: "2026-07-05T23:26:34.479Z"
last_activity: 2026-06-15 — **ER-Modell festgezogen (AD-20, Projekt-zentrisch, David approved).** Kern ENTITY-MODEL.md neu (Domänen 5–8 → eine „Projekt-zentrischer CRM-Kern", Rest erhalten); entity-model.dbml kanonisiert + entity-model.svg + ENTITY-MODEL.pdf neu gerendert. Scratch gelöscht (ruecksprache-Entwurf, Whiteboard-Bild, projekt-zentrisch-Render-Artefakte, MODELL-PIVOT-Anker). Alle Anker-Verweise → ENTITY-MODEL.md umgebogen.
progress:
percent: 0
@@ -57,12 +57,12 @@ Decisions are logged in PROJECT.md Key Decisions table (AD-1..AD-19 + KWP-Recut)
Bau-relevante Leitentscheidungen für den aktuellen Stand:
- AD-11: Bau-Reihenfolge Fundament-Kern → SHK-Kernpfad → Breite (steuert die Phasenfolge 1→21).
- Phase-0-Pflichten: Boilerplate einziehen + EU-Sterilisation + 4 Querschnitts-Neubauten gehen jedem Fachmodul voraus (= Phase 1).
- Phase-0-Pflichten: Codebase lauffähig halten, EU-Sterilisation + 4 Querschnitts-Neubauten gehen jedem Fachmodul voraus (= Phase 1).
- AD-1/AD-2: RLS-Baseline + typisierte Tabellen (Entity-Builder raus) sind ab Phase 2/6 bauliche Vorgabe.
- AD-19: Big-Bang-Go-Live — kein phasiertes Produktiv-Schalten; Validierung pro Modul über Noah/Geerkens-Klick-Pfad.
- Roadmap-Revision: Querschnitts-Oberflächen (P3, Audit-Viewer/Notif-Center/Sprach-UX) + Files/Tasks (P4) getrennt; Automation (P12) und KI (P13) eigenständig; Kommunikation in Email/Marketing (P14), Telefonie/Chat (P15), Kalender/Booking/Forms/Signatur (P16); HR (P17) + Portal (P18); Plattform/Launch in Admin (P19), Billing/Onboarding/Branding (P20), Public-API/DSGVO/Mobile/Help (P21). Kommunikations-Phasen hängen an Apps-Engine (P5) + CRM (P6), nicht an Automation/KI.
- Dependency-Audit 2026-06-10: Die Service-Kerne Audit/i18n/Flags/Notifications (M001/M003/M006/M011) liegen in Phase 1 — der gesamte Auth-Block (P2) setzt sie voraus (entspricht BMAD-Block-1-Ordnung).
- **Dependency-Inversion-Prinzip (verbindlich):** Spätere Module integrieren sich rückwirkend in frühere Oberflächen — keine unaufgelösten Vorwärts-Abhängigkeiten. PHASE-SOURCES.md führt das Ownership-Register (welche Phase welche Retro-Integration MIT einplanen muss), die Extension-Point-Pflichten der frühen Phasen (notify()-Kanäle, EntityLink, deal.won/lead.qualified-Events, Subscription-Stub, Credits-Konto, VM-Slots) und das maschinenlesbare Audit-Register (40 invertierte Refs). Audit-Kontrakt: Vorwärts-Refs MINUS Register = leer. M049 (Auto-Verkaufsfall) wurde als einziger echter Entity-Move nach P8 verschoben.
- **Dependency-Inversion-Prinzip (verbindlich):** Spätere Module integrieren sich rückwirkend in frühere Oberflächen — keine unaufgelösten Vorwärts-Abhängigkeiten. PHASE-SOURCES.md führt das Ownership-Register (welche Phase welche Retro-Integration MIT einplanen muss), die Extension-Point-Pflichten der frühen Phasen (notify()-Kanäle, EntityLink, `lead.qualified`-Events, Subscription-Stub, VM-Slots) und das maschinenlesbare Audit-Register. Audit-Kontrakt: Vorwärts-Refs MINUS Register = leer. M049 (Auto-Projekt) wurde als einziger echter Entity-Move nach P8 verschoben.
### Pending Todos
@@ -90,6 +90,6 @@ Items acknowledged and carried forward from previous milestone close:
## Session Continuity
Last session: 2026-06-21T20:56:25.638Z
Stopped at: context exhaustion at 75% (2026-06-21)
Last session: 2026-07-05T23:26:34.477Z
Stopped at: context exhaustion at 75% (2026-07-05)
Resume file: None
@@ -52,7 +52,7 @@ css: |-
| P17 | HR-Hub | 7 | Akeshan, 25./26.06. | v1 voll inkl. Surveys. Memberspot in v2 |
| P18 | Customer-Portal | 0 | **v2: Akeshan, 20. bis 22.07.** | Andockpunkte liegen in v1 bereit |
| P19 | Plattform-Admin | 7 | Akeshan, 22. bis 24.06. | v1: Tenant-Verwaltung + Abo-Zusammenstellung. AdminUser-Welt in v2 |
| P20 | Billing & Onboarding | 8 | Akeshan, 08./09.07. | v1: Stripe-Abo-Baukasten (Admin). Self-Service-UI + Wizard in v2 |
| P20 | Billing & Onboarding | 8 | Akeshan, 08./09.07. | v1: Legacy-Provider-Abo-Baukasten (Admin). Self-Service-UI + Wizard in v2 |
| P21 | DSGVO & Launch | 2 | Akeshan, 09.07. | v1: Export + Lösch-Workflow + Feedback-Widget |
### v2-Pakete: ALLE 43 MS bis Fr 31.07. (vollumfänglich durchgeplant)
@@ -129,14 +129,14 @@ Parallelität funktioniert, weil die Tracks entlang des Dependency-Graphen gesch
|---|---|
| **Fr 12.06.** | [MAIL] KWP-Mail beantworten (Callback-URLs `app.` + `staging-app.hivegrid.de/integrations/kwp/oauth/callback`) · bnWin.net-8.1-N3-Update an Noah/Geerkens-IT · AVV + Auskunfts-Vorlage + Website-Rechtstexte beim Anwalt · **Dev-Start P1** |
| Sa/So 13./14.06. | P1 weiter (volle Arbeitstage) |
| **Mo 15.06.** | **Externals-Paket (1 bis 2 h):** Mailjet SPF/DKIM/DMARC + Warm-up-Start · Google-Calendar-OAuth-Antrag · Stripe-Live-Verifizierung · AirCall- + PandaDoc-Sandbox · Hetzner-Projekt `HiveGrid` (S3-Bucket + Token) + Mistral-Key + DEK-Master-Key in Coolify-Secrets · Geo-IP/HIBP · Noah: 4 Freitags-Slots blocken |
| **Mo 15.06.** | **Externals-Paket (1 bis 2 h):** Mailjet SPF/DKIM/DMARC + Warm-up-Start · Google-Calendar-OAuth-Antrag · Legacy-Provider-Live-Verifizierung · AirCall- + PandaDoc-Sandbox · Hetzner-Projekt `HiveGrid` (S3-Bucket + Token) + Mistral-Key + DEK-Master-Key in Coolify-Secrets · Geo-IP/HIBP · Noah: 4 Freitags-Slots blocken |
| Mo/Di 15./16.06. | P1 fertigstellen (mit Akeshan), Ziel: Di Abend |
| Mi bis Fr 17. bis 19.06. | **P2** Auth / RLS / RBAC / Sessions |
| **Fr 19.06.** | **Gate W1 führen:** Hot-Swap-Drill + RLS-Cross-Tenant-Beweis + Konnektor-Demo |
| Sa/So 20./21.06. | **P5** Apps-Engine (Integrations-Vertrag für 5 Folgephasen) |
| Mo bis Mi 22. bis 24.06. | **P6** CRM-Kern |
| **Do 25.06.** | [€] **Partner-Gespräch: Kostenmatrix/Preise FIXIEREN.** Blocker für Haris! |
| Do/Fr 25./26.06. | **P7** Lead-Inbox · **Noah-Demo 1 präsentieren** (CRM) · Noah-Frage Lagerist (M070) · Stripe Payment Link für Founding-Deal an Haris |
| Do/Fr 25./26.06. | **P7** Lead-Inbox · **Noah-Demo 1 präsentieren** (CRM) · Noah-Frage Lagerist (M070) · Mollie Payment Link für Founding-Deal an Haris |
| Sa bis Mo 27. bis 29.06. | **P8** Sales-Pipeline (Kanban, deal.won-Event) |
| **Mo 29.06.** | **v2-Vorlauf-Externals (30 min):** Web-Push VAPID-Keys · **Gmail-Restricted-Scope-Antrag** (Wochen Vorlauf!) · Microsoft-Entra-App *(kein Google Play Console / FCM/APNs — PWA-only, AD-17)* |
| Di bis Do 30.06. bis 02.07. | **P9** Projekte/Plantafel (offline!), ein Tag Puffer vor dem Gate |
@@ -236,7 +236,7 @@ Hot-Swap-Prinzip: Kein Screen blockt den Dev-Bau. Daten unten sind Demo-/Politur
| Fr 19.06. | Pitch-Deck mit Davids D0-/Auth-Screens bestücken |
| **Di 23.06.** | [CALL] **ERSTE ANRUFE:** Discovery/Qualifizierung (Pain-Points, KWP-Nutzung, Team-Größe; noch keine Live-Demo) |
| **Do 25.06.** | Preise fix (von Raffael/Partner) → **Founding-Member-Angebot strukturieren** (Early-Bird-Jahresdeal), Noah-Abnahme |
| **Mo 30.06.** | [CALL] Klick-Demos (CRM-Stand von Noah-Demo 1) · **Founding-Deal live** via Stripe Payment Link. [€] Erste Abschlüsse VOR Launch möglich |
| **Mo 30.06.** | [CALL] Klick-Demos (CRM-Stand von Noah-Demo 1) · **Founding-Deal live** via Mollie Payment Link. [€] Erste Abschlüsse VOR Launch möglich |
| Fr 03.07. | [CALL] Echte Produkt-Demos (Kette Lead → Sales → Auftrag läuft E2E) |
| Mo 06.07. | **Webinar-Einladungen** raus (mit Dejan; kleine Liste, Domain erst 3 Wochen gewärmt) |
| **Fr 10.07.** | [LAUNCH] → zahlende Onboardings (Raffael/Admin stellt Abos zusammen) |
@@ -276,7 +276,7 @@ Hot-Swap-Prinzip: Kein Screen blockt den Dev-Bau. Daten unten sind Demo-/Politur
|---|---|---|
| **1 (Standard)** | eigene Phase früher fertig | Nächste eigene Phase im Track sofort ziehen, der Plan rückt nach vorn |
| **2** | anderer Track hängt am kritischen Pfad | Track-übergreifend aushelfen (heißeste Kandidaten: Akeshans P16-Block, Raffaels P8/P9-Kette) |
| **3** | Sprint-Puffer 3+ Tage | **Launch-Vorzieh-Prüfung mit Noah:** Go/No-Go früher ansetzen. Voraussetzungen: alle v1-Phasen fertig, Stripe live, Staging-Abnahme grün. Launch ist NICHT kalendergebunden; nur Mailjet-Warm-up (Kampagnen) und Google-Verifizierung laufen auf eigener Timeline und blocken den Launch nicht |
| **3** | Sprint-Puffer 3+ Tage | **Launch-Vorzieh-Prüfung mit Noah:** Go/No-Go früher ansetzen. Voraussetzungen: alle v1-Phasen fertig, Legacy-Provider live, Staging-Abnahme grün. Launch ist NICHT kalendergebunden; nur Mailjet-Warm-up (Kampagnen) und Google-Verifizierung laufen auf eigener Timeline und blocken den Launch nicht |
| **4** | Launch nicht vorziehbar (Externals) | v2-Vorzieher in fester Reihenfolge: (a) KWP-Recut-Vorarbeit gegen die API-Doku (Mapping + Adapter-Gerüst, KEIN Mock-E2E) → (b) Self-Service-Abo-Baukasten-UI (Revenue ohne Admin-Handarbeit) → (c) Postfach-Sync |
**Pflicht-Anteil:** Pro gewonnener Woche mindestens 1 Tag in den Härtungs-Pool (Bug-Bash, Lasttest, Security-Pass, Visual-Reg-Abdeckung). Geschwindigkeit kauft Qualität, nicht nur Features.
@@ -294,7 +294,7 @@ Hot-Swap-Prinzip: Kein Screen blockt den Dev-Bau. Daten unten sind Demo-/Politur
| Fr 12.06. | KWP-Mail (beide Callback-URLs) · bnWin.net 8.1 N3 · AVV + Rechtstexte beim Anwalt | **Raffael** |
| Fr 12.06. | Subframe-Lizenzen (David + Devs) · Website-Domain/Hosting | **Dejan** |
| Sa/So 13./14.06. | Painpoint-Gespräch + Website-Texte (Noah-Abnahme) | **Haris + Noah** |
| Mo 15.06. | Mailjet-DNS + Warm-up · Google-Calendar-OAuth · Stripe-Live · AirCall/PandaDoc-Sandbox · Hetzner-Projekt + Mistral-Key · Noah-Slots | **Raffael** |
| Mo 15.06. | Mailjet-DNS + Warm-up · Google-Calendar-OAuth · Legacy-Provider-Live · AirCall/PandaDoc-Sandbox · Hetzner-Projekt + Mistral-Key · Noah-Slots | **Raffael** |
| Mi 17.06. | Staging + Postgres/Redis + DNS/Wildcard-TLS + zentrale Dienste (mCaptcha/ClamAV/Meilisearch-Key) | **Akeshan** |
| **Do 25.06.** | [€] **Kostenmatrix/Preise mit Partner fixieren. Blocker für Haris!** | **Raffael** |
| Fr 26.06. | SHK-Beispieldaten + Lagerist-Antwort | **Noah** |
@@ -8,7 +8,7 @@
## Was dieses Modul tut (Kurzfassung)
Liefert die nutzerseitigen Plattform-Primitive. Files: chunked Upload (TUS) mit RLS auf Storage-Pfaden (Hetzner S3-kompatibel), Preview-Generierung (PDF→PNG, Bild→WebP, **HEIC→JPG Pflicht**), Anhängen an jede Entität über `FileEntityLink` (polymorph), Tags, Archiv-Flag; Customer-Portal-Upload ist vorbereitet. Tasks: leichtgewichtiges, querliegendes Aufgaben-Modul mit Status/Priorität/Fälligkeit, Entitäts-Verknüpfung (Contact/Company/Lead/Deal/Project), Watcher, Reminder via `notify()` (24h vor / bei / 24h nach Deadline) und Auto-Archivierung erledigter Tasks nach 30 Tagen per Cron. Tasks entstehen manuell, aus Chat, aus Form-Submission und aus Automation-Actions.
Liefert die nutzerseitigen Plattform-Primitive. Files: chunked Upload (TUS) mit RLS auf Storage-Pfaden (Hetzner S3-kompatibel), Preview-Generierung (PDF→PNG, Bild→WebP, **HEIC→JPG Pflicht**), Anhängen an jede Entität über `FileEntityLink` (polymorph), Tags, Archiv-Flag; Customer-Portal-Upload ist vorbereitet. Tasks: leichtgewichtiges, querliegendes Aufgaben-Modul mit Status/Priorität/Fälligkeit, Entitäts-Verknüpfung (Contact/Company/Lead/Projekt), Watcher, Reminder via `notify()` (24h vor / bei / 24h nach Deadline) und Auto-Archivierung erledigter Tasks nach 30 Tagen per Cron. Tasks entstehen manuell, aus Chat, aus Form-Submission und aus Automation-Actions.
## Benötigte Screens
@@ -18,7 +18,7 @@ Liefert die nutzerseitigen Plattform-Primitive. Files: chunked Upload (TUS) mit
| Upload-View + Datei-Dropzone [`/files/upload`] | Drag&Drop, Multi-Datei-Queue mit Fortschritt je Datei, Fehler+Retry · **HEIC-Hinweis „wird zu JPG konvertiert"** | A4 |
| File-Detail [`/files/:id`] | Stammdaten · **Vorschau (Bild mit Zoom / PDF; HEIC→JPG)** mit Fallback-Klartext + Download bei not_supported · Tags · Notizen · verknüpfte Entitäten mit Rolle · Activity (Upload/Verknüpfungs-Änderungen/Downloads) · Download, Ersetzen, Archiv, Vorschau-neu-generieren bei failed (kein Versionsverlauf v1) | A3 |
| Task-Liste [`/tasks`] | Titel, Prioritäts-Marker, Fälligkeit (überfällig rot, relativ ≤7 Tage), verknüpfte Entität, Assignee (Person/Gruppe), Status, Quelle-Hinweis · Presets: Meine/Erstellt/Alle (Alle nur mit tasks.read_all) · Such-Funktion · Filter (Status/Priorität/Deadline-Range/Verknüpfungs-Typ/Quelle) · Archiviert-Toggle · Bulk (Status/Priorität/Assignee/Archiv/Löschen) · Inline-Quick-Actions (done/Priorität/Deadline) | A2 — **wiederverwendbare Komponente** |
| Task-Detail + Anlage-Modal [`/tasks/:id`, `/tasks/new`] | Titel, Beschreibung (Markdown), Status/Priorität/Fälligkeit inline, Assignee-Picker (Person↔Gruppe), Verknüpfungs-Picker (Auto-Complete Contact/Company/Lead/Deal/Project), Watcher, Quelle (manuell/Chat/Form/Automation mit Link), Activity-Stream der Task, Aktionen (Erledigen/Wieder-öffnen/Duplizieren/Löschen) · Klartext-Banner (überfällig / Assignee deaktiviert / „wird in 30 Tagen archiviert") | A3+A5 |
| Task-Detail + Anlage-Modal [`/tasks/:id`, `/tasks/new`] | Titel, Beschreibung (Markdown), Status/Priorität/Fälligkeit inline, Assignee-Picker (Person↔Gruppe), Verknüpfungs-Picker (Auto-Complete Contact/Company/Lead/Projekt), Watcher, Quelle (manuell/Chat/Form/Automation mit Link), Activity-Stream der Task, Aktionen (Erledigen/Wieder-öffnen/Duplizieren/Löschen) · Klartext-Banner (überfällig / Assignee deaktiviert / „wird in 30 Tagen archiviert") | A3+A5 |
**Eingebettet (kein eigener Screen, aber zu designen):**
- Files-Browser vorgefiltert als Sub-Sektion in Entity-Detail-Views
@@ -30,7 +30,7 @@ Liefert die nutzerseitigen Plattform-Primitive. Files: chunked Upload (TUS) mit
- [ ] Preview-Generierung PDF→PNG, Bild→WebP, **HEIC→JPG (Pflicht)** mit Status (pending/generating/ready/failed/not_supported) + Klartext-Fallback
- [ ] Datei-Anhängen über FileEntityLink (polymorph) an jede Entität + Rollen-Label; Tags; Archiv-Flag; Bulk-ZIP; Customer-Portal-Upload vorbereitet
- [ ] Files-Browser server-side-Filter (Entität/MIME-Gruppe/Tags/Verantwortlicher), Tabelle/Grid-Umschalter, Inline-Schnell-Vorschau
- [ ] Tasks mit Status/Priorität/Fälligkeit, Entitäts-Verknüpfung (Contact/Company/Lead/Deal/Project), Person- oder Gruppen-Zuweisung (Constraint: genau eines, Default = Ersteller), Watcher
- [ ] Tasks mit Status/Priorität/Fälligkeit, Entitäts-Verknüpfung (Contact/Company/Lead/Projekt), Person- oder Gruppen-Zuweisung (Constraint: genau eines, Default = Ersteller), Watcher
- [ ] Task-Erstellungs-Quellen: manuell, Chat („Mark as Task"), Form-Submission, Automation-Action — je mit Quelle-Marker + Link
- [ ] Reminder via notify() (24h vor / bei / 24h nach Deadline, Eskalation Email) an Assignee/Gruppe/Watcher; Auto-Archivierung erledigter Tasks nach 30 Tagen per Cron
- [ ] Permission-Bundle tasks.* (read_own mit Owner-Check / read_all / write / admin) — fremde Tasks 404 statt 403, Action-Buttons permission-aware ausgeblendet/ausgegraut
@@ -4,11 +4,11 @@
**Quellen:** `ROADMAP.md` §Phase 5 · `SCREEN-MAP.md` §2-Anhang (Integrations-Katalog / Connection-Setup-Wizard) · `REQUIREMENTS.md` Modul 33a (REQ-apps-engine-datenmodell, -apps-engine-lifecycle) · `ENTITY-MODEL.md` Domäne 4 (App, DataConnection, AppSyncRun, AppSyncRunLog, AppSyncItem) · `_bmad-output/specs/M33a-apps-engine.md`
**Subframe:** Pflicht je Route. Archetypen aus D0 (A6 Settings/Connection-Kit + Connection-Card, A5 Modal/Wizard, A2 Liste).
> ⚙️ **Recut-Hinweis (KWP → v2):** KWP wird als `DataConnection type='rest-api'` (API-Key-Connect) abgebildet — **kein `kwpsync.exe`-Windows-Client-Flow** (Scope-Schnitt #12; die alte Drei-Komponenten-Architektur aus der M33a-Spec ist NICHT zu bauen). Connection-Type `windows_client` bleibt im Daten-Modell, hat in v1 aber keinen UI-Flow. v1-Connect-Typen in der UI: **OAuth2** (Gmail, Outlook, AirCall, PandaDoc, Google Calendar) und **API-Key** (KWP, Stripe). Apps selbst werden erst in ihren Modul-Phasen (P14/P15/P16/P20) angedockt — P5 liefert das Framework + die generische Katalog-/Wizard-UI, gegen die sich alle Connections registrieren.
> ⚙️ **Recut-Hinweis (KWP → v2):** KWP wird als `DataConnection type='rest-api'` (API-Key-Connect) abgebildet — **kein `kwpsync.exe`-Windows-Client-Flow** (Scope-Schnitt #12; die alte Drei-Komponenten-Architektur aus der M33a-Spec ist NICHT zu bauen). Connection-Type `windows_client` bleibt im Daten-Modell, hat in v1 aber keinen UI-Flow. v1-Connect-Typen in der UI: **OAuth2** (Gmail, Outlook, AirCall, PandaDoc, Google Calendar) und **API-Key** (KWP, Legacy-Provider). Apps selbst werden erst in ihren Modul-Phasen (P14/P15/P16/P20) angedockt — P5 liefert das Framework + die generische Katalog-/Wizard-UI, gegen die sich alle Connections registrieren.
## Was dieses Modul tut (Kurzfassung)
Bindet externe Systeme über ein einziges, typsicheres Integrations-Framework an — der Vertrag, über den später Gmail, Outlook, AirCall, PandaDoc, Stripe und KWP laufen. Ein Admin stellt eine `DataConnection` über OAuth2-Connect oder API-Key-Connect in der Settings-UI her; die type-safe Adapter-Registry erlaubt neue Connection-Typen ohne Kern-Änderung; `AppSyncRun`/`AppSyncItem` protokollieren Sync-Läufe in Klartext; Connection-Health-Monitoring zeigt den Status jeder Verbindung (active/degraded/broken/revoked/pending) mit Klartext-Diagnostik + Lösungs-Hinweisen; OAuth-Tokens sind per-Tenant-DEK-verschlüsselt (AD-8). Mehrere Connections pro App pro Tenant möglich (z. B. drei Gmail-Mailboxen).
Bindet externe Systeme über ein einziges, typsicheres Integrations-Framework an — der Vertrag, über den später Gmail, Outlook, AirCall, PandaDoc, Legacy-Provider und KWP laufen. Ein Admin stellt eine `DataConnection` über OAuth2-Connect oder API-Key-Connect in der Settings-UI her; die type-safe Adapter-Registry erlaubt neue Connection-Typen ohne Kern-Änderung; `AppSyncRun`/`AppSyncItem` protokollieren Sync-Läufe in Klartext; Connection-Health-Monitoring zeigt den Status jeder Verbindung (active/degraded/broken/revoked/pending) mit Klartext-Diagnostik + Lösungs-Hinweisen; OAuth-Tokens sind per-Tenant-DEK-verschlüsselt (AD-8). Mehrere Connections pro App pro Tenant möglich (z. B. drei Gmail-Mailboxen).
## Benötigte Screens
@@ -23,7 +23,7 @@ Bindet externe Systeme über ein einziges, typsicheres Integrations-Framework an
## Geplante Features (muss abgedeckt sein)
- [ ] DataConnection per OAuth2-Connect-Flow (Gmail, Outlook, AirCall, PandaDoc, Google Calendar) ODER API-Key-Connect (KWP, Stripe) in der Settings-UI herstellbar
- [ ] DataConnection per OAuth2-Connect-Flow (Gmail, Outlook, AirCall, PandaDoc, Google Calendar) ODER API-Key-Connect (KWP, Legacy-Provider) in der Settings-UI herstellbar
- [ ] Type-safe Adapter-Registry: neue Connection-Typen ohne Kern-Änderung registrierbar (Build-Time-Scan von `app/integrations/`, Drift-Check → Klartext-Warnung im Plattform-Admin)
- [ ] AppSyncRun/AppSyncItem/AppSyncRunLog protokollieren Sync-Läufe mit Klartext-Summary („Sync mit KWP — 14 Aufträge gepullt, 2 fehlgeschlagen …")
- [ ] Connection-Health-Monitoring: Status active/degraded/broken/revoked/pending mit Klartext + Lösungs-Hinweisen; Health-Check-Intervalle; Recovery-Klartext
@@ -8,7 +8,7 @@
## Was dieses Modul tut (Kurzfassung)
Eingehende Leads aus allen Quellen (Form-Submission, eingehende Email, AirCall-Inbound, manueller Eintrag, Custom-Quellen) landen in einem Triage-Arbeitsplatz, werden automatisch angelegt, gegen Spam/Anomalien geschützt und sind auswertbar. Lead ist die **Vorstufe** des `Projekt`-Kerns: bei Weiterverarbeitungsfreigabe wird der Lead zum Projekt (Lead → Projekt, Domäne 5), der Kontakt separat verknüpft. Das `lead.qualified`-Event ist Extension-Point für P8 (Auto-Verkaufsfall M049).
Eingehende Leads aus allen Quellen (Form-Submission, eingehende Email, AirCall-Inbound, manueller Eintrag, Custom-Quellen) landen in einem Triage-Arbeitsplatz, werden automatisch angelegt, gegen Spam/Anomalien geschützt und sind auswertbar. Lead ist die **Vorstufe** des `Projekt`-Kerns: bei Weiterverarbeitungsfreigabe wird der Lead zum Projekt (Lead → Projekt, Domäne 5), der Kontakt separat verknüpft. Das `lead.qualified`-Event ist Extension-Point für P8 (Auto-Projekt M049).
## Benötigte Screens
@@ -8,7 +8,7 @@
## Was dieses Modul tut (Kurzfassung)
Baut den `Projekt`-Kern als eine typisierte Tabelle (Superset der Felder, dazu `CustomFieldValue`) und die Pipeline-Engine als frei konfigurierbare Views: Pipeline anlegen/benennen/duplizieren, Statuswerte (Stages) je Pipeline, Zugriff je Rolle/User/Gruppe (`PipelineAccess`), Feld-Sichtbarkeit + Pflichtfelder pro Pipeline+Status (`PipelineFieldConfig`). Darauf der EINE konfigurierbare Kanban (ein Board für alle Pipeline-Typen) plus Listen-View, Projekt-Detail, fortlaufende Projekt-Nummern (HiveNumberSeq), Forecast/KPIs und Lost-Reasons. Konsumiert das `lead.qualified`-Event aus P7 (Auto-Verkaufsfall M049 = Projekt in Sales-Pipeline). Auftrags-Features + Sub-Areas folgen in P9.
Baut den `Projekt`-Kern als eine typisierte Tabelle (Superset der Felder, dazu `CustomFieldValue`) und die Pipeline-Engine als frei konfigurierbare Views: Pipeline anlegen/benennen/duplizieren, Statuswerte (Stages) je Pipeline, Zugriff je Rolle/User/Gruppe (`PipelineAccess`), Feld-Sichtbarkeit + Pflichtfelder pro Pipeline+Status (`PipelineFieldConfig`). Darauf der EINE konfigurierbare Kanban (ein Board für alle Pipeline-Typen) plus Listen-View, Projekt-Detail, fortlaufende Projekt-Nummern (HiveNumberSeq), Forecast/KPIs und Lost-Reasons. Konsumiert das `lead.qualified`-Event aus P7 (Auto-Projekt M049 = Projekt in Sales-Pipeline). Auftrags-Features + Sub-Areas folgen in P9.
## Benötigte Screens
@@ -1,57 +0,0 @@
# Designer-Brief · P13 — KI-Layer
> ⛔ **ABGELÖST (KI→MCP-Pivot, [[AD-26]], 2026-06-22):** Phase 13 (KI-Layer) entfällt — kein KI-Settings-/AI-Credits-Screen. HiveGrid wird MCP-Server; die einzige zugehörige UI ist die Verbindungs-/Settings-Ansicht in **[[VD-MCP]]**. Brief unten = historisch.
**Phase:** 13/21 · **Modul:** M18 · **Owner-Dev:** Raffael (R-P13) · **Designer:** David (D-KI, Paket Mo 06.07. Compose)
**Quellen:** `ROADMAP.md` §Phase 13 · `ENTITY-MODEL.md` Domäne 7 (Automation & KI) · `SCREEN-MAP.md` §6 (KI-Settings / AI-Credits) · `_bmad-output/specs/M18-ki-layer.md` (HiveMind/RAG/Whisper-Teile → v2)
**Subframe:** Pflicht je Route. Archetypen aus D0 (A6 Settings/Connection, KPI+Charts). Compose-Paket — wenig Bespoke.
> 🟡 **v1-Grenze (Scope-Schnitt 11.06.):** HiveMind, RAG/Embeddings, Whisper-Transkription und AlephAlpha sind **v2** (→ P25). v1 baut nur den produktiven **EU-LLM-Gateway** (Mistral La Plateforme primär, Provider-Abstraktion), Token-/AiCredits-Tracking mit harten + Drossel-Limits und die **Andock-Adapter** als Muster. Kein RAG-Chat-UI, kein Whisper-Aufnahme-UI, kein HiveMind-Assistent in v1.
> 🔴 **Sprach-zu-Automation gehört sichtbar in den Flow-Editor (P12)** — P13 liefert nur das Gateway dahinter. EU-Datenresidenz wird überall im Klartext kommuniziert; keine US-Kern-Abhängigkeit.
## Was dieses Modul tut (Kurzfassung)
Der EU-only KI-Unterbau: ein Multi-Provider-LLM-Gateway, das Tokens trackt, gegen das lokale **AiCredits**-Konto bucht und Soft-/Hard-Limits durchsetzt. Sichtbare v1-Oberflächen sind die **KI-Settings** (Use-Case-Toggles, Modell-Wahl, EU-Klartext) und das **AI-Credits/Usage-Dashboard**. Die KI selbst wird über v1-Use-Cases konsumiert (primär Sprach-zu-Automation in P12, plus Lead-Zusammenfassung/Programm-Hilfe/Stuck-Vorschläge als KI-Widgets in bestehenden Detail-/Dashboard-Views). Promptfoo testet die ausgelieferten Funktionen.
## Benötigte Screens
| Screen [Route] | Kern-Inhalt | Archetyp |
|---|---|---|
| **KI-Settings** [`/admin/ai`] | Use-Case-Toggles (je v1-Use-Case ein/aus) · Modell-Wahl (Mistral Large/Small je Use-Case) · Token-Limit pro Monat · **EU-Datenresidenz-Klartext-Block** · Hinweis Plan-Erweiterung extern | A6 |
| **AI-Credits / Usage** [`/admin/usage/ai`] | Saldo (current_balance_tokens) · Verbrauch je Use-Case (Donut) · Verlauf über Zeit (Linie) · Top-User · **80%-Soft-Limit-Warnbanner** · Hochrechnung Monatsende · Plan-Limit-Anzeige | KPI + Charts |
| **Soft-/Hard-Limit-Banner** [global] | 80%-Banner („KI-Budget zu 80% verbraucht …") · 100%-Klartext bei Aufruf-Versuch („KI-Budget erschöpft, Erweiterung auf hivegrid.de/pricing") · kritische Funktionen (Klartext-Diagnostik) bleiben aktiv | Banner (A7/Topbar) |
| KI-Widget: Lead-Zusammenfassung [im Kontakt-/Lead-Detail] | On-demand-Generierung des Pre-Termin-Briefs · Quelle Activity-Stream/Custom-Felder/Form-Submissions · Cache-Hinweis · Klartext bei Provider-Down | Compose (in A3-Detail) |
| KI-Widget: Stage-Stuck-Vorschläge [im Dashboard] | Wöchentlich generierte Vorschläge für Stuck-Deals/-Projekte · Quick-Action-Buttons (Anrufen/Email) · **KI-Vorschlag-Kennzeichnung** | Compose (in Dashboard) |
| Programm-Hilfe-Einstieg [In-App, klein] | Frage-Eingabe → KI-Antwort (Klartext + Deep-Link) · Conversation-Historie pro User · **„KI-Vorschlag"-Markierung** bei Fallback-Diagnostik | A5-klein / Drawer |
*(RAG-Chat, Whisper-Aufnahme-UI, HiveMind-Assistent, Bring-Your-Own-Key → v2.)*
## Geplante Features (muss abgedeckt sein)
- [ ] EU-LLM-Gateway (Mistral La Plateforme primär, Provider-Abstraktion für zweiten EU-Provider → v2) mit Token-Tracking je Aufruf
- [ ] AiCredits-Buchung: AiTokenUsage je Aufruf, Aggregat-Abzug, Soft-Limit (80% Banner + Admin-Mail), Hard-Limit (100% Pause außer kritische Funktionen, Monats-Reset)
- [ ] KI-Settings pro Tenant: aktivierte Use-Cases, Default-Modelle, Monats-Token-Limit, EU-Residenz-Klartext
- [ ] Usage-Dashboard: Verbrauch je Use-Case (Donut), Zeitverlauf, Top-User, Forecast, Plan-Limit
- [ ] v1-Use-Cases sichtbar: Sprach-zu-Automation (Gateway für P12), Lead-Zusammenfassung, Programm-Hilfe/FAQ, Klartext-Diagnostik-Fallback, Stage-Stuck-Vorschläge
- [ ] Sicherheits-Layer-Wirkung im UI: schreibende KI-Aktionen erfordern Bestätigungs-Workflow (Action-Boundary); KI-Fallback-Outputs als „KI-Vorschlag" markiert
- [ ] Klartext bei Provider-Down / Limit-erreicht statt technischer Fehler
- [ ] Promptfoo-Eval-Suite deckt ausgelieferte KI-Funktionen ab (CI-Pflicht, kein UI)
- [ ] v2-Andock-Adapter als Muster vorhanden: `RetrievalProvider`-Interface, Outbox-Index-Events (via P11), Bestätigungs-Grenze (kein v1-UI nötig)
- [ ] Datenmodell persistiert: LlmGatewayProvider/Model, AiSettings, AiCredits, AiTokenUsage, Conversation/Message, GeneratedObject (Domäne 7)
## Benötigte UI-Elemente (Liste, KEIN Layout — Anordnung = Davids freie Hand)
Settings-Sektionen mit Toggle-Zeilen (Klartext-Beschreibung), Modell-Auswahl-Dropdowns je Use-Case, Token-Limit-Eingabe, EU-Datenresidenz-Klartext-Block, KPI-Kacheln (Saldo/Verbrauch/Forecast), Donut-Chart (Verbrauch je Use-Case), Linien-Chart (Verlauf), Top-User-Liste, Soft-/Hard-Limit-Warnbanner (stapelbar mit Topbar-System), KI-Vorschlag-Badge/Kennzeichnung, On-demand-Generieren-Button mit Lade-Zustand, Conversation-Historie-Liste, Bestätigungs-Dialog für schreibende KI-Aktionen, Klartext-Provider-Down-Hinweis, Empty-/Loading-/Permission-Denied-States.
## Davids Delta (David füllt aus)
- [ ] Überschriebene/geänderte Spec-Punkte:
- [ ] Neue UI/UX-Entscheidungen:
- [ ] Subframe-Screen-Link(s):
- [ ] Offene Fragen an Dev:
## Dev-Nachzug (wir nach Davids Delta)
- [ ] Spec/ENTITY-MODEL an Davids Entscheidungen angepasst:
- [ ] REQUIREMENTS/PHASE-SOURCES nachgezogen:
- [ ] Plane-Ticket (R-P13) aktualisiert:
@@ -27,20 +27,20 @@
| **AirCall-Setup** [`/settings/integrations/telephony`] | Connect-Card (Status-Ampel connecting/active/error/revoked + Klartext) · **User-Binding-Liste** (AirCall-User ↔ HiveGrid-User mit Email-Auto-Match-Vorschlag + manuelle Korrektur) · „Verbindung testen" · Trennen mit Bestätigung | A6 |
| Anruf-Liste + -Detail [`/calls`] | Liste: Nummer, Richtung, Dauer, **gematchter Kontakt** (mit Confidence-Hinweis bei Soft-Match), Notiz-Snippet, verpasst-Marker · Detail: **Match-Override**, Markdown-Notiz-Editor, Aufnahme-Link · Click-to-Call-Button + eingehender-Anruf-Notification | A6 + A2/A3 |
*(HalloPetra-Adapter-UI, Whisper-Diktat-Aufnahme → v2. Click-to-Call-Buttons leben außerdem in Kontakt-/Lead-/Deal-Detail aus P6/P7/P8.)*
*(HalloPetra-Adapter-UI → v2; Whisper-Diktat entfällt nach AD-26. Click-to-Call-Buttons leben außerdem in Kontakt-/Lead-/Projekt-Detail aus P6/P7/P8.)*
## Geplante Features (muss abgedeckt sein)
- [ ] AirCall-Connect (OAuth, verschlüsselte Tokens, Webhook-Secret) mit Klartext-Status-Diagnose
- [ ] User-Bindings mit Email-Auto-Match-Vorschlag + manuelle Zuordnung
- [ ] Click-to-Call aus Kontakt-/Lead-/Deal-Detail; Mehrfach-Nummern → Auswahl-Modal; Klartext-Edge-Cases (kein Binding / keine Connection / API-Fehler / ungültige Nummer); `tel:`-Fallback ohne Connection
- [ ] Click-to-Call aus Kontakt-/Lead-/Projekt-Detail; Mehrfach-Nummern → Auswahl-Modal; Klartext-Edge-Cases (kein Binding / keine Connection / API-Fehler / ungültige Nummer); `tel:`-Fallback ohne Connection
- [ ] Inbound-Anrufe: Auto-Match auf Kontakt/Lead/Deal (Hard/Soft-Match mit Confidence), unbekannte Nummer → Auto-Lead, Push-Notification beim Klingeln
- [ ] Anruf-Logging in Activity-Stream (in/out, verpasst), Markdown-Notiz-Editor nach Anruf, Recording-Anzeige, Match-Override
- [ ] Anruf-Events als Automation-Trigger (verpasster Anruf → Rückruf-Aufgabe — P12)
- [ ] `TelephonyProviderInterface` mit AirCall als Referenz-Adapter (HalloPetra dockt v2 an)
- [ ] Chat-Real-Time: WebSocket, Presence, Typing, Read-Receipts, Redis-Pub/Sub, Optimistic-UI, Reconnect
- [ ] Drei Channel-Typen: public/private Group, DM, entity-linked (Projekt/Deal) mit ProjectMember-Sync
- [ ] @Mentions (User/Projekt/Deal/Contact/Firma) mit Autocomplete, Threads, Reactions, Pinned (max 50), File-Uploads (via Files), Link-Previews, Mark-as-Task
- [ ] Drei Channel-Typen: public/private Group, DM, entity-linked (Projekt) mit ProjectMember-Sync
- [ ] @Mentions (User/Projekt/Contact/Firma) mit Autocomplete, Threads, Reactions, Pinned (max 50), File-Uploads (via Files), Link-Previews, Mark-as-Task
- [ ] Channel-Index mit Filter/Suche/Pin/Mute/Archiv + Channel-Browser; Cross-Channel-Suche; Mentions-Aggregat
- [ ] Entity-linked Channel erscheint im Deal-/Projekt-Detail (P8/P9)
- [ ] Admin-Audit (`read_all_channels`): Begründungs-Pflicht je Session + DM-Audit-Banner, audit-geloggt
@@ -4,11 +4,11 @@
**Quellen:** `ROADMAP.md` §Phase 16 · `ENTITY-MODEL.md` Domäne 9 (Kalender/Booking/Forms/Signatur) · `SCREEN-MAP.md` §3 (Form-Builder) + §5 (Booking/Kalender/Signatur) + §6 (Signatur) · `_bmad-output/specs/M25-calendar-sync.md`, `M06-booking.md`, `M05-form-builder.md`, `M07-angebot-signatur.md`
**Subframe:** Pflicht je Route. Archetypen aus D0 (A2 Liste, A3 Detail, A4 Form/Input, A5 Modal/Wizard, A6 Settings/Connection, A7 Zustände); zusätzlich BESPOKE für Form-Builder-Canvas, Booking-Page (öffentlich), Kalender.
> 🟡 **Vier Module, vier Screen-Gruppen — bewusst gebündelt:** Termin- und Dokument-Workflows. Bitte je Gruppe (1) Kalender-Sync, (2) Booking, (3) Form-Builder, (4) Angebots-Signatur getrennt halten. **v1-Schnitte (verbindlich):** Kalender-Sync nur **Google** (Outlook-Adapter → v2, Interface bleibt provider-agnostisch); Booking nur **eine** Zuteilungs-Strategie „Erster der frei ist" (Round-Robin/Provider-Wahl → v2); Form-Sharing = **URL + QR + iFrame** (personalisierte Links / JS-Snippet → v2); Signatur über **PandaDoc** (DocuSign-Adapter bleibt möglich). Pivot-Hinweis: Signatur hängt am **`Projekt`**-Kern (nicht „Deal") — `ProjektSignatureDocument`/`ProjektSignatureSigner`; der Signatur-Einstieg lebt im Projekt-Detail (P8/P9). Die Task-Komponente aus P4 wird hier eingebettet, nicht neu gebaut.
> 🟡 **Vier Module, vier Screen-Gruppen — bewusst gebündelt:** Termin- und Dokument-Workflows. Bitte je Gruppe (1) Kalender-Sync, (2) Booking, (3) Form-Builder, (4) Angebots-Signatur getrennt halten. **v1-Schnitte (verbindlich):** Kalender-Sync nur **Google** (Outlook-Adapter → v2, Interface bleibt provider-agnostisch); Booking nur **eine** Zuteilungs-Strategie „Erster der frei ist" (Round-Robin/Provider-Wahl → v2); Form-Sharing = **URL + QR + iFrame** (personalisierte Links / JS-Snippet → v2); Signatur über **gehärteten SES-Eigenbau als Default** (Fern-Link/OTP + Touch vor Ort + PAdES + Completion-Certificate), **PandaDoc nur optionaler BYOK-Adapter**. Pivot-Hinweis: Signatur hängt am **`Projekt`**-Kern (nicht „Deal") — `ProjektSignatureDocument`/`ProjektSignatureSigner`; der Signatur-Einstieg lebt im Projekt-Detail (P8/P9). Die Task-Komponente aus P4 wird hier eingebettet, nicht neu gebaut.
## Was dieses Modul tut (Kurzfassung)
Deckt Termin- und Dokument-Workflows ab: bidirektionaler **Kalender-Sync** mit Google (Settings + Connection-Health), öffentliche **Buchungsseiten** (Calendly-Style, timezone-aware Slots, iFrame-Embed, Auto-Bestätigungsmail, erzeugt Lead/Activity), ein Drag-&-Drop-**Form-Builder** auf Google-Forms-Niveau (12 Feld-Typen, Conditional Logic, SHK-Templates, Post-Submit-Actions) und Multi-Signer-**Angebots-Signatur** über die Apps-Engine.
Deckt Termin- und Dokument-Workflows ab: bidirektionaler **Kalender-Sync** mit Google (Settings + Connection-Health), öffentliche **Buchungsseiten** (Calendly-Style, timezone-aware Slots, iFrame-Embed, Auto-Bestätigungsmail, erzeugt Lead/Activity), ein Drag-&-Drop-**Form-Builder** auf Google-Forms-Niveau (12 Feld-Typen, Conditional Logic, SHK-Templates, Post-Submit-Actions) und Multi-Signer-**Angebots-Signatur** über SES-Default plus Apps-Engine-Adapter.
## Benötigte Screens
@@ -27,7 +27,7 @@ Deckt Termin- und Dokument-Workflows ab: bidirektionaler **Kalender-Sync** mit G
| Forms-Liste [`/forms`] | Name, Subtyp, Status (Entwurf/Publiziert/Archiviert), Submissions-Anzahl, letzte Submission · SHK-Templates geseedet | A2 |
| 🌐 Öffentliches Formular [`/f/{tenant}/{slug}`] | Gerenderte Felder, Conditional Logic live, mCaptcha, Submit → Erfolgs-Text oder Redirect · Tenant-Logo · 4 Zustände | BESPOKE-Public |
| Submissions [`/forms/:id/submissions`] | Tabelle der Antworten (Spalten = Felder), Filter, CSV-Export · Detail: alle Antworten in Reihenfolge, Submitter-Metadata, Action-Log mit Status (Re-Run), File-/Foto-Vorschau, PDF-Export | A2 / A3 |
| Signatur-Wizard [im Projekt-Detail] | PDF-Upload (aus Files oder neu) → Unterzeichner (Name vorausgefüllt aus Kontakt, Email, Rolle, Reihenfolge sequenziell/parallel) → Provider (PandaDoc, Default) → Senden · Status-Tracking je Unterzeichner (entwurf/gesendet/angesehen/signiert/abgelehnt) + Erinnerung + Widerruf · Original- + Signed-PDF-Download | A5 + A6 |
| Signatur-Wizard [im Projekt-Detail] | PDF-Upload (aus Files oder neu) → Unterzeichner (Name vorausgefüllt aus Kontakt, Email, Rolle, Reihenfolge sequenziell/parallel) → Signaturmodus (Fern-Link/OTP oder Touch vor Ort; PandaDoc nur BYOK-Opt-in) → Senden · Status-Tracking je Unterzeichner (entwurf/gesendet/angesehen/signiert/abgelehnt) + Erinnerung + Widerruf · Original-, Signed-PDF- und Completion-Certificate-Download | A5 + A6 |
*(Lead-Inbox/Lead-Detail → P7. Plantafel/Projekt-Detail → P9. Sende-Domain-Card → P14. Integrations-Katalog/Connection-Setup → P5. Task-Komponente → P4.)*
@@ -39,7 +39,7 @@ Deckt Termin- und Dokument-Workflows ab: bidirektionaler **Kalender-Sync** mit G
- [ ] Booking nur **eine** Zuteilungs-Strategie „Erster der frei ist" (Round-Robin/Provider-Wahl → v2)
- [ ] Form-Builder mit Drag-&-Drop, **12 Feld-Typen**, **Conditional Logic**, Validierung pro Feld, SHK-Form-Templates geseedet, iFrame-Embed + QR + öffentliche URL
- [ ] Post-Submit-Actions feuern mit Field-Mapping + ActionLog (Lead/Kontakt/Projekt/Task anlegen, Email an Submitter/Team, Webhook, Automation-Trigger, Auto-Match)
- [ ] Angebote durchlaufen Multi-Signer-Signatur via **PandaDoc** über die Apps-Engine (M33a) mit Webhook-Verarbeitung, Status-Updates, optionalem Auto-Stage-Wechsel; Signed-PDF landet in Files
- [ ] Angebote durchlaufen Multi-Signer-Signatur via **SES-Eigenbau-Default** (Fern-Link/OTP + Touch vor Ort + PAdES + Completion-Certificate); **PandaDoc** bleibt optionaler BYOK-Adapter über die Apps-Engine (M33a). Status-Updates, optionaler Auto-Stage-Wechsel; Signed-PDF landet in Files
- [ ] Signatur hängt am **`Projekt`**-Kern (`ProjektSignatureDocument`/`Signer`) — kein eigenes „Deal"-Objekt
- [ ] Permission-Bundles: `calendar.*` (connect_own/admin), `bookings.*` (read_own/read_all/write/admin), `forms.*` (basic/builder/publisher/admin), `signature.*` (read/send/revoke/admin) — serverseitig Owner-gefiltert
@@ -4,11 +4,11 @@
**Quellen:** `ROADMAP.md` §Phase 19 · `ENTITY-MODEL.md` Domäne 11 (Plattform-Admin) + Domäne 12 (Abo-Baukasten, für Abo-Zusammenstellung) · `SCREEN-MAP.md` §3 (Plattform-Admin-Block) · `_bmad-output/specs/M37-plattform-admin.md`, `M32-feature-flags.md` (Admin-UI-Teile)
**Subframe:** Pflicht je Route. **Eigene Admin-Shell** (nicht App-Shell A1) — getrennte Sidebar/Topbar auf `admin.hivegrid.de`; interner Polish reicht (kein 🌐-Niveau). Archetypen-Reuse: A2 Liste, A3 Detail, A4 Form, A5 Modal, A6 Settings, KPI/Charts.
> 🟡 **Getrennte Welt — eigene Shell, kein App-Reuse der Sidebar.** Läuft auf `admin.hivegrid.de` mit Host-Routing, **Cookie-Domain-Trennung** zu app.hivegrid.de, TOTP-Pflicht + MFA-Step-up. **v1-Schnitt (verbindlich):** Die **getrennte AdminUser-Identität / AdminRBAC-Parallelwelt** (M038/M039) ist **v2** — v1 nutzt das Boilerplate-User-Modell als Extension + `admin-mfa-gate`. Es gibt **keine** AdminUser-/AdminRole-Verwaltungs-Screens in v1, **keine** Plattform-Lead-Pipeline/Deal-Kanban (unser eigenes Sales-Tool → v2), **keine** Feature-Flag-Massen-Statistik (→ v2). Admin-Dashboard zeigt Basis-KPIs (Tenants/User/MRR), Churn/ARPU → v2. **Neu v1:** Admin stellt Tenant-Abos zusammen (Feature-Konnektoren togglen + Stripe-Positionen — **Abo-Baukasten-Mechanik**; Self-Service-UI dafür → v2). KWP-Sync-Status-Screen entfällt v1 (KWP ist erstes v2-Modul).
> 🟡 **Getrennte Welt — eigene Shell, kein App-Reuse der Sidebar.** Läuft auf `admin.hivegrid.de` mit Host-Routing, **Cookie-Domain-Trennung** zu app.hivegrid.de, TOTP-Pflicht + MFA-Step-up. **v1-Schnitt (verbindlich):** Die **getrennte AdminUser-Identität / AdminRBAC-Parallelwelt** (M038/M039) ist **v2** — v1 nutzt das Boilerplate-User-Modell als Extension + `admin-mfa-gate`. Es gibt **keine** AdminUser-/AdminRole-Verwaltungs-Screens in v1, **keine** Plattform-Lead-Pipeline/Deal-Kanban (unser eigenes Sales-Tool → v2), **keine** Feature-Flag-Massen-Statistik (→ v2). Admin-Dashboard zeigt Basis-KPIs (Tenants/User/MRR), Churn/ARPU → v2. **Neu v1:** Admin stellt Tenant-Abos zusammen (Feature-Konnektoren togglen + Legacy-Provider-Positionen — **Abo-Baukasten-Mechanik**; Self-Service-UI dafür → v2). KWP-Sync-Status-Screen entfällt v1 (KWP ist erstes v2-Modul).
## Was dieses Modul tut (Kurzfassung)
Die Plattform ist aus einer getrennten Admin-Welt betreibbar: Tenant-Management (Stammdaten, Status-Wechsel, Notizen, Impersonate mit Begründung), **Abo-Zusammenstellung** je Tenant (Feature-Konnektoren + Stripe-Positionen + Trial-Verlängerung), User-Verwaltung (2FA-Reset, Session-Revoke), Feature-Flag-Verwaltung (UI zur Engine aus Phase 1), Queue-/Health-Monitoring und eine Support-Inbox, die sich aus dem Feedback-Widget (P21) speist. Alle Admin-Aktionen laufen ins `AdminAuditLog` (cross-tenant, tenant_id=null).
Die Plattform ist aus einer getrennten Admin-Welt betreibbar: Tenant-Management (Stammdaten, Status-Wechsel, Notizen, Impersonate mit Begründung), **Abo-Zusammenstellung** je Tenant (Feature-Konnektoren + Legacy-Provider-Positionen + Trial-Verlängerung), User-Verwaltung (2FA-Reset, Session-Revoke), Feature-Flag-Verwaltung (UI zur Engine aus Phase 1), Queue-/Health-Monitoring und eine Support-Inbox, die sich aus dem Feedback-Widget (P21) speist. Alle Admin-Aktionen laufen ins `AdminAuditLog` (cross-tenant, tenant_id=null).
## Benötigte Screens
@@ -17,10 +17,10 @@ Die Plattform ist aus einer getrennten Admin-Welt betreibbar: Tenant-Management
| Admin-Login + 2FA [`admin.hivegrid.de/login`, `/2fa`] | Email + Passwort (NIST/HIBP, Captcha ab 3. Fehler), TOTP-Pflicht, kein Magic-Link, 8h-Session | A4 / A5 |
| Admin-Dashboard [`/dashboard`] | **Basis-KPIs: Tenants, aktive User, MRR** + System-Health-Ampel + Queue-Status · Klick je Kachel → Drill-Down · Zeitraum-Wechsler *(Churn/ARPU/Sales-Pipeline → v2)* | KPI |
| Tenant-Liste [`/tenants`] | Name, Slug, Status (Klartext-Farbe), Plan, MRR, User-Anzahl, letzte Aktivität, Branche/Größe · Filter + Suche + Bulk (Tag/Email) | A2 |
| **Tenant-Detail** [`/tenants/:id`] | Stammdaten, Status-Wechsler (Reason-Pflicht), **Abo-Zusammenstellung (Feature-Konnektoren togglen + Stripe-Positionen — Abo-Baukasten!)**, Trial-Verlängerung (Datum + Reason), Notizen (Markdown, kategorisiert, gepinnt), User-/Activity-/Audit-/Feature-Flag-Sektionen, **Impersonate mit Begründungs-Pflicht** | A2 / A3 + A6 |
| **Tenant-Detail** [`/tenants/:id`] | Stammdaten, Status-Wechsler (Reason-Pflicht), **Abo-Zusammenstellung (Feature-Konnektoren togglen + Legacy-Provider-Positionen — Abo-Baukasten!)**, Trial-Verlängerung (Datum + Reason), Notizen (Markdown, kategorisiert, gepinnt), User-/Activity-/Audit-/Feature-Flag-Sektionen, **Impersonate mit Begründungs-Pflicht** | A2 / A3 + A6 |
| Manuelle Tenant-Anlage [`/tenants/new`] | Name, Slug (Live-Validation), Branche, Größe, Initial-Admin-Email, Initial-Module/Plan, Trial-Dauer, Demo-Daten-Häkchen, Reason-Pflicht | A4 |
| User-Liste + Detail [`/users`, `/:id`] | Liste: Email, Name, Tenant-Memberships, 2FA-Status, letzte Aktivität, Status · Detail: Memberships mit Rolle, Sessions, letzte Logins, Ops (2FA-Reset mit Reason, Session-Revoke, Deaktivieren, Lösch im Auftrag) | A2 / A3 |
| Subscription-Liste + Detail [`/subscriptions`, `/:id`] | Liste: Tenant, Plan, MRR, Status, Renewal, Stripe-Sync · Detail: Stripe-Customer/Sub-ID (Link), Plan + Positionen + MRR, Trial, Rechnungs-History, manuelle Override (Plan/Sonderpreis/Reason), Stripe-Sync forcieren, Refund | A2 / A3 |
| Subscription-Liste + Detail [`/subscriptions`, `/:id`] | Liste: Tenant, Plan, MRR, Status, Renewal, Legacy-Provider-Sync · Detail: Legacy-Provider-Customer/Sub-ID (Link), Plan + Positionen + MRR, Trial, Rechnungs-History, manuelle Override (Plan/Sonderpreis/Reason), Legacy-Provider-Sync forcieren, Refund | A2 / A3 |
| Feature-Flags [`/feature-flags`] | Flag-Liste (Key, Status, Targeting), Filter-Editor (plan-tier), Tenant-Override mit Ablauf *(Massen-Statistik → v2)* | A2 + A6 |
| Health + Worker-Queues [`/health`, `/health/workers`] | Service-Ampeln (Web/API/Worker/Scheduler), DB/Redis/Storage · BullMQ-Queues: Active/Waiting/Delayed/Failed je Queue, Job-Detail mit Error-Stack, Retry (Bulk/einzeln), Pause/Resume · Error-Rate Top-10 | A2 + KPI |
| Support-Inbox + Ticket-Detail [`/support`, `/:id`] | Liste: Betreff, Tenant, Source, Status, Priorität, Zugewiesen · Detail: Konversation, Antwort (Markdown + Anhänge), interne Notiz, Status/Priority/Tag/Assignee-Wechsler, Reaktions-Templates · **speist sich aus Feedback-Widget (P21)!** | A2 / A3 |
@@ -32,7 +32,7 @@ Die Plattform ist aus einer getrennten Admin-Welt betreibbar: Tenant-Management
## Geplante Features (muss abgedeckt sein)
- [ ] Plattform-Admin meldet sich auf `admin.hivegrid.de` an (Host-Routing, **Cookie-Domain-Trennung**, TOTP-Pflicht + MFA-Step-up); Admin-User haben **keinen** Tenant-Workspace und erscheinen in keiner Mitgliederliste; kein Generalschlüssel zwischen Plattform und Mandanten *(getrennte AdminUser-Identität/AdminRBAC = M038/M039 → v2; v1 = Boilerplate-User-Extension + `admin-mfa-gate`)*
- [ ] Admin verwaltet Tenants, Subscription-Overrides und Feature-Flags aus zentraler Konsole; **Abo-Zusammenstellung** = Feature-Konnektoren togglen + Stripe-Positionen (Abo-Baukasten-Mechanik)
- [ ] Admin verwaltet Tenants, Subscription-Overrides und Feature-Flags aus zentraler Konsole; **Abo-Zusammenstellung** = Feature-Konnektoren togglen + Legacy-Provider-Positionen (Abo-Baukasten-Mechanik)
- [ ] Admin-Dashboard zeigt Boilerplate-KPIs (Tenants/User/MRR) + System-Health + Queue-Status *(Churn/ARPU/Health-Kacheln → v2)*
- [ ] Admin überwacht BullMQ-Queues (Retry/Pause/Resume, Job-Detail mit Error-Stack) und bearbeitet eine Support-Inbox
- [ ] `AdminAuditLog` protokolliert plattformweite Aktionen (tenant_id=null) über die Audit-Hash-Kette; sensitive Aktionen (Status-Wechsel, Impersonate, Subscription-Override) mit **Reason-Pflicht** + Klartext-Bestätigung
@@ -41,7 +41,7 @@ Die Plattform ist aus einer getrennten Admin-Welt betreibbar: Tenant-Management
## Benötigte UI-Elemente (Liste, KEIN Layout — Anordnung = Davids freie Hand)
Eigene Admin-Sidebar + -Topbar (getrennt von A1), KPI-Kacheln mit Drill-Down + Zeitraum-Wechsler, System-Health-Ampel, Daten-Tabelle (sortierbar, paginiert, Spalten-Konfig), Filter-Bar, Bulk-Action-Toolbar, Status-Wechsler mit Reason-Pflicht-Modal, **Abo-Baukasten-Panel** (Feature-Konnektor-Toggles + Stripe-Positionsliste mit Preisen), Trial-Verlängerungs-Datum-Picker, Markdown-Notizen-Editor mit Pin + Kategorie, Impersonate-Button mit Begründungs-Modal, Stripe-Link-Badge, manuelle-Override-Formular, Queue-Status-Tabelle mit Retry/Pause/Resume, Job-Detail-Drawer mit Error-Stack, Feature-Flag-Liste + Targeting-Filter-Editor + Tenant-Override-mit-Ablauf, Support-Konversations-Ansicht + Markdown-Reply + interne-Notiz-Toggle + Reaktions-Templates, Audit-Tabelle mit Diff-Drawer + Export, Subprozessor-Tabelle + AVV-Versions-Liste mit PDF-Upload, Bestätigungs-Dialoge (Confirm-Phrase für destruktive Aktionen), Empty-/Loading-/Fehler-/Permission-Denied-States (Klartext mit Lösungs-Knopf).
Eigene Admin-Sidebar + -Topbar (getrennt von A1), KPI-Kacheln mit Drill-Down + Zeitraum-Wechsler, System-Health-Ampel, Daten-Tabelle (sortierbar, paginiert, Spalten-Konfig), Filter-Bar, Bulk-Action-Toolbar, Status-Wechsler mit Reason-Pflicht-Modal, **Abo-Baukasten-Panel** (Feature-Konnektor-Toggles + Legacy-Provider-Positionsliste mit Preisen), Trial-Verlängerungs-Datum-Picker, Markdown-Notizen-Editor mit Pin + Kategorie, Impersonate-Button mit Begründungs-Modal, Legacy-Provider-Link-Badge, manuelle-Override-Formular, Queue-Status-Tabelle mit Retry/Pause/Resume, Job-Detail-Drawer mit Error-Stack, Feature-Flag-Liste + Targeting-Filter-Editor + Tenant-Override-mit-Ablauf, Support-Konversations-Ansicht + Markdown-Reply + interne-Notiz-Toggle + Reaktions-Templates, Audit-Tabelle mit Diff-Drawer + Export, Subprozessor-Tabelle + AVV-Versions-Liste mit PDF-Upload, Bestätigungs-Dialoge (Confirm-Phrase für destruktive Aktionen), Empty-/Loading-/Fehler-/Permission-Denied-States (Klartext mit Lösungs-Knopf).
## Davids Delta (David füllt aus)
@@ -11,7 +11,7 @@
## Was dieses Modul tut (Kurzfassung)
Ein neuer Betrieb kommt vom Self-Service-Signup per Auto-SHK-Provisionierung ins Produkt, läuft auf Stripe-Billing als Abo-Baukasten (Basis pro Nutzer + Feature-Module als Positionen, Trial mit Dunning + Frozen-Modus), kann optional Demo-Daten anlegen/löschen, sieht eine Erste-Schritte-Checkliste und schaltet flash-frei zwischen Hell und Dunkel um.
Ein neuer Betrieb kommt vom Self-Service-Signup per Auto-SHK-Provisionierung ins Produkt, läuft auf Legacy-Provider-Billing als Abo-Baukasten (Basis pro Nutzer + Feature-Module als Positionen, Trial mit Dunning + Frozen-Modus), kann optional Demo-Daten anlegen/löschen, sieht eine Erste-Schritte-Checkliste und schaltet flash-frei zwischen Hell und Dunkel um.
## Benötigte Screens
@@ -20,17 +20,17 @@ Ein neuer Betrieb kommt vom Self-Service-Signup per Auto-SHK-Provisionierung ins
| Abo-Übersicht [`/admin/subscription`] | Basis-Plan + **gebuchte Feature-Module als Positionsliste mit Preisen** · Status/Trial-Countdown · **KEIN Upgrade-Button** (Hinweis-Box „Plan-Wechsel über Account-Manager / hivegrid.de", extern) · Pausieren (Frozen) + Kündigen-Button | A6 + KPI |
| Verbrauchs-Übersicht [`/admin/usage`] | Quota-Balken je Metrik: Automation-Läufe, AI-Tokens, Speicher · aktueller Verbrauch vs. inklusiv · Klartext bei Limit-Erreichen | KPI + Charts |
| AI-Credits-Detail [`/admin/usage/ai-credits`] | Saldo, Verbrauch je Use-Case (Donut), Verlauf, 80%-Warnbanner, Hochrechnung, Aufgebraucht-Klartext (Verweis extern) | KPI + Charts |
| Rechnungen/Zahlung [`/admin/billing`] | **Stripe-Customer-Portal-Embed** (Rechnungs-History/PDF, Zahlungsmethode, Steuer-/USt-Daten) | A6 |
| Rechnungen/Zahlung [`/admin/billing`] | **Legacy-Provider-Customer-Portal-Embed** (Rechnungs-History/PDF, Zahlungsmethode, Steuer-/USt-Daten) | A6 |
| Demo-Daten [`/admin/onboarding/demo-data`] | **Settings-Knopf „Beispieldaten anlegen"** (~25 Datensätze, Klartext-Liste was kommt) · Demo-Liste mit Zähler + Lösch-Funktion (Multi-Step-Confirm) · „Demo ausblenden"-Filter erscheint in allen Listen *(kein Wizard, kein KI-Walkthrough — v2)* | A6 |
| Erste-Schritte-Checkliste [`/onboarding/checklist`] | Items mit Status-Häkchen (auto-erkannt), Klartext + Beschreibung, CTA-Link je Item, manuell abhakbar, dauerhaft ausblendbar, Glückwunsch bei 100% (KWP-Item ausgeblendet — v2) · auch als Dashboard-Widget (P11) | A2 |
| Status-Banner-System (Shell) | Tenant-Status-Banner: Trial-Ende-3-Tage / `past_due` (Zahlung fehlgeschlagen + „Zahlungsmethode aktualisieren") / `frozen` (pausiert + „Reaktivieren") / `deletion_pending` — je eigener Klartext + Action-Button · Read-Only-Toast bei blockiertem Schreib-Button | in A1-Shell integriert (D0) |
| Hell/Dunkel-Toggle [im Profil / User-Menü] | Zwei Optionen Hell / Dunkel, sofortige Anwendung ohne Reload, flash-frei (SSR-Cookie) *(kein System-Modus v1)* | A6 (Teil von Profil-Settings P2) |
*(Signup-Formular → P2-Auth. Mitarbeiter-Einladung → P2/P20-Seeds. Kein Onboarding-Setup-Wizard v1. Stripe-Checkout läuft extern über Marketing-Site.)*
*(Signup-Formular → P2-Auth. Mitarbeiter-Einladung → P2/P20-Seeds. Kein Onboarding-Setup-Wizard v1. Payment-Checkout läuft extern über Marketing-Site.)*
## Geplante Features (muss abgedeckt sein)
- [ ] Billing über Stripe als **Abo-Baukasten**: Basis-Preis pro Nutzer + Feature-Module als daten-getriebene Positionen (keine Seat-Typen — M154-Recut); konkrete Preise geparkt (AD-15)
- [ ] Billing über Legacy-Provider als **Abo-Baukasten**: Basis-Preis pro Nutzer + Feature-Module als daten-getriebene Positionen (keine Seat-Typen — M154-Recut); konkrete Preise geparkt (AD-15)
- [ ] Trial mit Dunning (Tag 1/3/7) und **Frozen-Modus** (Read-Only-Middleware) greift; Status-Banner pro Zustand mit Klartext + Action; Verbrauchs-Zähler + Drossel aktiv (Aufschlags-Abrechnung → v2)
- [ ] **In-App keine** Pricing-Tabelle/Upgrade-Buttons/Plan-Wechsel-UI (LP-8) — Abo-Übersicht read-only + externer Verweis; Pause/Kündigen sind erlaubt
- [ ] Neuer Tenant wird nach Signup automatisch **SHK-provisioniert** (Seeds M158: Pipelines, Custom-Felder, Rollen, Form-Templates, Checkliste) — **ohne Wizard-UI** (die folgt v2); Trial-Freischaltung durch Plattform-Admin
@@ -40,7 +40,7 @@ Ein neuer Betrieb kommt vom Self-Service-Signup per Auto-SHK-Provisionierung ins
## Benötigte UI-Elemente (Liste, KEIN Layout — Anordnung = Davids freie Hand)
Plan-Positionsliste (Basis + Feature-Module mit Preisen, read-only), Trial-Countdown-Badge, KPI-/Quota-Balken (Verbrauch vs. inklusiv), Donut-Chart (AI-Verbrauch je Use-Case), 80%-Warnbanner, externer Verweis-Box („Plan-Wechsel über hivegrid.de", neuer Tab), Pause-Button + Kündigen-Button mit Bestätigungs-Modal, Stripe-Customer-Portal-Embed (iFrame/Redirect), Demo-Daten-Anlege-Knopf mit Klartext-Inhaltsliste, Demo-Zähler + Lösch-Button mit Multi-Step-Confirm, „Demo ausblenden"-Filter-Toggle, Checklisten-Items mit Status-Icon + CTA-Link + manuell-abhaken + ausblenden, Status-Banner-Stack (trial/past_due/frozen/deletion_pending) mit Action-Button, Read-Only-Toast bei blockiertem Schreib-Button, Hell/Dunkel-Toggle (2 Optionen), Empty-/Loading-/Fehler-/Permission-Denied-States (Klartext mit Lösungs-Knopf).
Plan-Positionsliste (Basis + Feature-Module mit Preisen, read-only), Trial-Countdown-Badge, KPI-/Quota-Balken (Verbrauch vs. inklusiv), Donut-Chart (AI-Verbrauch je Use-Case), 80%-Warnbanner, externer Verweis-Box („Plan-Wechsel über hivegrid.de", neuer Tab), Pause-Button + Kündigen-Button mit Bestätigungs-Modal, Legacy-Provider-Customer-Portal-Embed (iFrame/Redirect), Demo-Daten-Anlege-Knopf mit Klartext-Inhaltsliste, Demo-Zähler + Lösch-Button mit Multi-Step-Confirm, „Demo ausblenden"-Filter-Toggle, Checklisten-Items mit Status-Icon + CTA-Link + manuell-abhaken + ausblenden, Status-Banner-Stack (trial/past_due/frozen/deletion_pending) mit Action-Button, Read-Only-Toast bei blockiertem Schreib-Button, Hell/Dunkel-Toggle (2 Optionen), Empty-/Loading-/Fehler-/Permission-Denied-States (Klartext mit Lösungs-Knopf).
## Davids Delta (David füllt aus)
@@ -19,7 +19,7 @@ Deckt den DSGVO-Lebenszyklus ab und macht die Plattform launch-fertig: tenant- u
|---|---|---|
| Privacy-Hub [`/admin/privacy`] | Übersicht: Export, Löschung, Subprozessoren, AVV, Privacy-Manifest-Link · Einstieg in die Sub-Bereiche | A6 + KPI |
| Tenant-Daten-Export [`/admin/privacy/export`, `/new`, `/:jobId`] | Job-Liste mit Status/Progress/Download (72h-Link) · Anstoßen mit Klartext (was, geschätzte Größe/Dauer, 7-Tage-Window) · Job-Status mit Progress | A2 + A5 |
| **Tenant-Löschung** [`/admin/privacy/deletion`, `/:requestId`] | Multi-Step: Erklärung (30-Tage-Grace, Read-Only, Hard-Delete) → Export-Angebot → Stripe-Hinweis (Pro-rata) → Passwort + 2FA → **Slug als Confirm-Phrase** · Grace-Countdown mit Abbruch-Button | A5 (Wizard) |
| **Tenant-Löschung** [`/admin/privacy/deletion`, `/:requestId`] | Multi-Step: Erklärung (30-Tage-Grace, Read-Only, Hard-Delete) → Export-Angebot → Legacy-Provider-Hinweis (Pro-rata) → Passwort + 2FA → **Slug als Confirm-Phrase** · Grace-Countdown mit Abbruch-Button | A5 (Wizard) |
| Subprozessoren [`/admin/privacy/subprocessors`] | Tabelle: Name, Land, EU/EEA-Marker, Zweck, Datenkategorien, DPA-Link, „aktiv seit" · Filter (EU/alle, Kategorie) · Export CSV/PDF | A2 |
| AVV [`/admin/privacy/avv`] | Aktuelle Version als PDF-Download + Akzeptanz-History (Version, Datum, Signer) · Re-Akzeptanz-Modal bei Update (Klartext-Änderungen) | A2 + A5 |
| User-Daten-Export [`/settings/account/data-export`, `/:jobId`] | Klartext was exportiert wird, Start-Button, Rate-Limit-Hinweis, Job-Status mit Download (signed-URL) | A6 |
@@ -32,7 +32,7 @@ Deckt den DSGVO-Lebenszyklus ab und macht die Plattform launch-fertig: tenant- u
## Geplante Features (muss abgedeckt sein)
- [ ] Nutzer kann **DSGVO-ZIP-Export** anstoßen (User-Self **und** Tenant-Full); asynchroner BullMQ-Job mit Progress, 72h-signed-URL-Download, Rate-Limit (max. 3 User-Self/24h, max. 1 Tenant-Full aktiv); README + manifest.json im ZIP
- [ ] **Tenant-Löschung** führt Crypto-Shredding (Per-Tenant-DEK), Stripe-Cancel und Storage/Cache/Index-Bereinigung durch (AD-8); 30-Tage-Grace mit Read-Only + Abbruch-Möglichkeit; **Slug als Confirm-Phrase**; Stripe-Cancel erst beim Hard-Delete-Cron
- [ ] **Tenant-Löschung** führt Crypto-Shredding (Per-Tenant-DEK), Legacy-Provider-Cancel und Storage/Cache/Index-Bereinigung durch (AD-8); 30-Tage-Grace mit Read-Only + Abbruch-Möglichkeit; **Slug als Confirm-Phrase**; Legacy-Provider-Cancel erst beim Hard-Delete-Cron
- [ ] **DSGVO-Auskunft als dokumentiertes manuelles Playbook** (Suche → Kontakt-Detail → CSV → Anwalts-Vorlage → Frist-Task +25 Tage); AVV-Vorlage liegt vor (Externals E6) — **kein** automatisches Tooling v1
- [ ] AVV-Download + Akzeptanz-History + Re-Akzeptanz-Flow bei Updates (14-Tage-Frist, Read-Only-Sperre danach); Subprozessoren-Liste mit Drittland-Klartext-Warnung (v1 EU-only)
- [ ] **In-App-Feedback-Widget** (Bug/Feature/Frage, Screenshot, Rate-Limit) live → speist Admin-Support-Inbox (P19, generische Quelle)
@@ -8,20 +8,20 @@
## Was dieses Modul tut (Kurzfassung)
Externe Systeme lesen die HiveGrid-Hauptentitäten (Kontakte, Firmen, Deals, Projekte, Tasks, Leads) über eine **versionierte REST-API** (`/api/v1/…`, cursor-paginiert) und tauschen Events über **signierte Webhooks** aus — eingehend (Lead-Ingestion mit Auto-Match) wie ausgehend (HMAC-SHA256, Idempotenz, Retry/Backoff, Auto-Disable). Auth läuft über Tenant-API-Keys mit Scopes und Rate-Limit; die ausgehende Webhook-Action dockt an die v1-Automation (`external_event`-Trigger) an.
Externe Systeme lesen die HiveGrid-Hauptentitäten (Kontakte, Firmen, Deals, Projekte, Tasks, Leads) über eine **versionierte REST-API** auf `api.hivegrid.de/v1/...` (staging: `staging-api.hivegrid.de/v1/...`, cursor-paginiert) und tauschen Events über **signierte Webhooks** aus — eingehend (Lead-Ingestion mit Auto-Match) wie ausgehend (HMAC-SHA256, Idempotenz, Retry/Backoff, Auto-Disable). Auth läuft über Tenant-API-Keys mit Scopes und Rate-Limit; die ausgehende Webhook-Action dockt an die v1-Automation (`external_event`-Trigger) an. Keine Public-API-Routen unter `app.hivegrid.de/api`.
## Benötigte Screens
| Screen [Route] | Kern-Inhalt | Archetyp |
|---|---|---|
| API-Key-Verwaltung [`/admin/api-keys`] | Key-Liste (Name, Scopes, letzte Nutzung, Status, Tarif-Gate) · Key erstellen (Scope-Auswahl, Secret einmal sichtbar) · Rotieren/Widerrufen | A2 + A5 (Erweiterung v1) |
| Incoming-Webhook-Verwaltung [`/admin/api-keys/incoming-webhooks`] | Slug-Endpoints (`/webhooks/incoming/{tenant}/{slug}`), Auth-Modus (API-Key/HMAC/URL-Token), Payload-Mapping, Auto-Match-Regeln · Ingestion-Log (Eingang, Match-Ergebnis, Fehler) | A2 + A3 |
| Incoming-Webhook-Verwaltung [`/admin/api-keys/incoming-webhooks`] | Slug-Endpoints (`api.hivegrid.de/v1/webhooks/incoming/{tenant}/{slug}`), Auth-Modus (API-Key/HMAC/URL-Token), Payload-Mapping, Auto-Match-Regeln · Ingestion-Log (Eingang, Match-Ergebnis, Fehler) | A2 + A3 |
| Outgoing-Subscription-Liste [`/admin/webhooks`] | Ziel-URL, abonnierte Event-Typen, Status (aktiv/auto-disabled), letzte Zustellung, Fehlerzähler | A2 |
| Outgoing-Subscription-Detail [`/admin/webhooks/:id`] | Secret (einmal sichtbar), Delivery-Stats, Delivery-Log mit Retry-Status, Replay/Test-senden, Aktivieren/Pausieren | A3 |
| API-Request-Logs + Usage [`/admin/api-keys/usage`] | Request-Log (Endpoint, Key, Status, Latenz, Rate-Limit-Treffer) · Usage-Aggregat (Calls/h pro Key + Tenant, Quota-Auslastung) | A2 |
| OpenAPI/Swagger-UI [`/api/v1/openapi` · Swagger] | Auto-generierte OpenAPI-3.1-Doku + interaktiver Try-it-out (entwickler-gerichtet, evtl. eingebettete Drittanbieter-UI) | A6/extern |
| OpenAPI/Swagger-UI [`api.hivegrid.de/v1/openapi.json` · Swagger auf API-Host] | Auto-generierte OpenAPI-3.1-Doku + interaktiver Try-it-out (entwickler-gerichtet, evtl. eingebettete Drittanbieter-UI) | A6/extern |
*(Die eigentlichen REST-Endpoints `/api/v1/contacts|companies|deals|projects|tasks|leads` sind serverseitig — kein eigener Screen außer Swagger-UI.)*
*(Die eigentlichen REST-Endpoints `api.hivegrid.de/v1/contacts|companies|deals|projects|tasks|leads` sind serverseitig — kein eigener Screen außer Swagger-UI.)*
## Geplante Features (muss abgedeckt sein)
@@ -30,7 +30,7 @@ Externe Systeme lesen die HiveGrid-Hauptentitäten (Kontakte, Firmen, Deals, Pro
- [ ] API-Key-Auth mit Scopes + Rate-Limit (1k/h pro Key, 10k/h pro Tenant), `X-RateLimit-*`-Header + Klartext-429
- [ ] Eingehende Lead-Webhooks legen Leads an (API-Key/HMAC/URL-Token + Payload-Mapping + Auto-Match)
- [ ] Ausgehende WebhookSubscriptions: HMAC-SHA256-Signatur, Idempotenz-ID, Retry mit exp. Backoff (max 5), Auto-Disable nach 10 Fehlern
- [ ] OpenAPI-3.1 auto-generiert (`/api/v1/openapi.json`) + Swagger-UI
- [ ] OpenAPI-3.1 auto-generiert (`api.hivegrid.de/v1/openapi.json`) + Swagger-UI auf API-Host
- [ ] Tarif-Gates (M027): API-Keys/Webhooks nur in passendem Abo verfügbar
- [ ] Writes durch v1-Frozen/past_due-Middleware geblockt (402); RLS via `app.tenant_id`
@@ -1,14 +1,14 @@
# Designer-Brief · P23 — Postfach-Sync (Email-Inbox) (v2-P2)
**Phase:** 23/34 (v2-P2) · **Modul:** M09 (M096–M098) · **Owner-Dev:** Akeshan (VA-P23) · **Designer:** David · **Slot:** v2-W1 (13.–17.07.)
**Quellen:** `ROADMAP-V2.md` §v2-P2 (Screens/Features = Hauptquelle) · `ROADMAP.md` §Phase 23 · `ENTITY-MODEL.md` (Activity-Stream/Contact-Deal-Project-FKs) · `_bmad-output/specs/M09-email-inbox.md` (OAuth-Sync/Inbox/Threading-Teile)
**Quellen:** `ROADMAP-V2.md` §v2-P2 (Screens/Features = Hauptquelle) · `ROADMAP.md` §Phase 23 · `ENTITY-MODEL.md` (Activity-Stream/Contact/Lead/Projekt-FKs) · `_bmad-output/specs/M09-email-inbox.md` (OAuth-Sync/Inbox/Threading-Teile)
**Subframe:** Pflicht je Route. Archetypen aus D0 (A2 Liste, A3 Detail, A5 Modal, A6 Settings).
> 🔵 **Recut-Hinweis:** Nur der **OAuth-Sync/Inbox/Threading-Teil** von M09 ist v2. Die **Sende-Domain (DKIM/SPF/DMARC)** stammt aus v1-P14 und wird **NICHT** neu gebaut. Versand läuft über die **User-Mailbox** (Gmail/Graph), nicht über die Marketing-Sende-Domain. Push-Sync ist Pflicht (kein Primär-Polling). ⚠️ Gmail restricted scopes (`gmail.modify/send`) brauchen Google-Verifizierung/CASA (Antrag lief in v1 EXT-13); Microsoft-Graph-Subscription muss ~alle 3 Tage erneuert werden.
## Was dieses Modul tut (Kurzfassung)
Gmail- und Outlook-Postfächer der User werden **bidirektional ins CRM gespiegelt**: per OAuth verbunden, per Push-Sync (Gmail Pub/Sub-Watch, Microsoft Graph-Subscription) aktuell gehalten, **auto-gematcht** auf Contact/Lead/Deal und im **Activity-Stream** sichtbar. User lesen Threads in einer Inbox-Liste mit sicherem HTML-Render und antworten/leiten weiter über die eigene Mailbox. Permission-getrennt über `email.read_own` vs. `email.read_all`.
Gmail- und Outlook-Postfächer der User werden **bidirektional ins CRM gespiegelt**: per OAuth verbunden, per Push-Sync (Gmail Pub/Sub-Watch, Microsoft Graph-Subscription) aktuell gehalten, **auto-gematcht** auf Contact/Lead/Projekt und im **Activity-Stream** sichtbar. User lesen Threads in einer Inbox-Liste mit sicherem HTML-Render und antworten/leiten weiter über die eigene Mailbox. Permission-getrennt über `email.read_own` vs. `email.read_all`.
## Benötigte Screens
@@ -16,14 +16,14 @@ Gmail- und Outlook-Postfächer der User werden **bidirektional ins CRM gespiegel
|---|---|---|
| Mailbox-Connect-Settings [`/settings/integrations/email`] | Postfach verbinden (Gmail/Microsoft OAuth), Sync-Status (Initial-Sync-Progress, letzter Sync, Fehler), Reconnect/Token-Refresh, Trennen/Revoke | A6 |
| Inbox-Liste [`/emails`] | Mail-Liste (Absender, Betreff, Snippet, Datum, Verknüpfung-Badge, gelesen/ungelesen) · Filter (Mailbox/verknüpft/unverknüpft/Datum) · Volltext-Suche · Bulk-Actions | A2 |
| Mail-Detail [`/emails/:id`] | Thread-Ansicht (chronologisch), sicherer HTML-Render (externe Bilder blockiert/optional laden), Anhänge (→ Files), Verknüpfungs-Editor (Contact/Lead/Deal/Project, manueller Override), Reply/Forward | A3 |
| Mail-Detail [`/emails/:id`] | Thread-Ansicht (chronologisch), sicherer HTML-Render (externe Bilder blockiert/optional laden), Anhänge (→ Files), Verknüpfungs-Editor (Contact/Lead/Projekt, manueller Override), Reply/Forward | A3 |
| Outbound-Compose [`/emails/new`] | Neue Mail / Reply / Forward über User-Mailbox, Empfänger, Betreff, Body, Anhänge, Vorlagen-Auswahl | A5/A3 |
## Geplante Features (muss abgedeckt sein)
- [ ] OAuth-Connect Gmail (Pub/Sub-Watch) + Microsoft (Graph-Subscription) mit 3-Tage-Renewal-Worker, Token-Refresh + Revoke
- [ ] Push-Sync (kein Primär-Polling); Initial-Sync 90 Tage, resumebar mit Progress-Anzeige
- [ ] Auto-Match: From-Adresse → Contact/Lead, Subject-Pattern (`[Deal #123]`), 1-offener-Deal-Heuristik; manueller Override sticht
- [ ] Auto-Match: From-Adresse → Contact/Lead, Subject-Pattern (`[Projekt #123]`), 1-offenes-Projekt-Heuristik; manueller Override sticht
- [ ] Inbox-Liste mit Filter/Suche/Bulk + Detail mit Thread + sicherem HTML-Render (CSP, externe Bilder blockiert)
- [ ] Reply/Forward via User-Mailbox (Provider-Quotas beachten) + Vorlagen (M098)
- [ ] Anhänge landen in v1-Files; Mails erscheinen im Activity-Stream
@@ -4,7 +4,7 @@
**Quellen:** `ROADMAP-V2.md` §v2-P3 (Screens/Features = Hauptquelle) · `ROADMAP.md` §Phase 24 · `_bmad-output/specs/M28-subscriptions-billing.md` (Block P) · `_bmad-output/specs/M29-onboarding.md`
**Subframe:** Pflicht je Route. Archetypen aus D0 (A2 Liste, A3 Detail, A5 Modal, A6 Settings, Vollbild-Flow).
> 🔵 **Recut-/PWA-Hinweis:** **PWA-only (AD-17)** → Self-Service-Billing läuft komplett über **Web-Checkout** (v1-Stripe-Checkout wiederverwenden). **Keine App-Store-Billing-Auflage** — Apple-Guideline 3.1.3 entfällt vollständig, Plan-Wechsel direkt im Web. Self-Service ist v2, weil konkrete **Preise feststehen müssen** (v1 EXT-14 fixiert). Das Wizard-Framework ist großteils v1-fähig; v2-Delta = weitere Branchen + KI-Coach (per Retro-Integration aus P25) + Migration-Importer. Branchen-Template-Seeding ist ein **geteilter Mechanismus** (P24/P26/P30).
> 🔵 **Recut-/PWA-Hinweis:** **PWA-only (AD-17)** → Self-Service-Billing läuft komplett über **Web-Checkout** (v1-Payment-Checkout wiederverwenden). **Keine App-Store-Billing-Auflage** — Apple-Guideline 3.1.3 entfällt vollständig, Plan-Wechsel direkt im Web. Self-Service ist v2, weil konkrete **Preise feststehen müssen** (v1 EXT-14 fixiert). Das Wizard-Framework ist großteils v1-fähig; v2-Delta = weitere Branchen + KI-Coach (per Retro-Integration aus P25) + Migration-Importer. Branchen-Template-Seeding ist ein **geteilter Mechanismus** (P24/P26/P30).
## Was dieses Modul tut (Kurzfassung)
@@ -28,7 +28,7 @@ Tenants buchen und wechseln Pläne **self-service** über den Web-Checkout (Tria
- [ ] SHK-Branchen-Template (Pipelines/Rollen/Custom-Felder/Forms/Demo/Checkliste) deklarativ aus YAML geseedet; weitere Branchen „coming soon"
- [ ] Erste-Schritte-Checkliste mit Detection-Rules + Demo-Daten-Bulk-Löschung
- [ ] KI-Walkthrough-Coach-Andockpunkt (wird von P25 rückwirkend eingehängt — kein harter Forward-Dep)
- [ ] Wiederverwendung v1: Stripe-Checkout + Webhook-Handler + StripeWebhookLog + SubscriptionFeature/ProductFeature (Feature-Gate) + Read-Only-Middleware + TenantUserInvitation
- [ ] Wiederverwendung v1: Payment-Checkout + Webhook-Handler + Legacy-ProviderWebhookLog + SubscriptionFeature/ProductFeature (Feature-Gate) + Read-Only-Middleware + TenantUserInvitation
## Benötigte UI-Elemente (Liste, KEIN Layout — Anordnung = Davids freie Hand)
@@ -1,50 +0,0 @@
# Designer-Brief · P25 — KI-Tiefe: HiveMind/RAG (v2-P4)
> ⛔ **ABGELÖST (KI→MCP-Pivot, [[AD-26]], 2026-06-22):** HiveMind/RAG/Whisper-UI entfällt (zurückgestellt). Ersetzt durch **[[VD-MCP]]** (MCP-Verbindungen/Settings). Brief unten = historisch.
**Phase:** 25/34 (v2-P4) · **Modul:** M18 (M087 + Whisper-EU + pgvector-Hybrid-Suche + zweiter EU-Provider) · **Owner-Dev:** Raffael (VR-P25) · **Designer:** David · **Slot:** v2-W1→W2 (Start 18./19.07.)
**Quellen:** `ROADMAP-V2.md` §v2-P4 (Screens/Features = Hauptquelle) · `ROADMAP.md` §Phase 25 · `_bmad-output/specs/M18-ki-layer.md` (RAG/HiveMind/Whisper-Teile)
**Subframe:** Pflicht je Route. Archetypen aus D0 (A2 Liste, A3 Detail, A5 Modal, A6 Settings, Chat-Panel).
> 🔴 **DSGVO-Hard-Constraint:** **Nur EU-Provider** (kein OpenAI/Anthropic/Gemini), AVV mit allen Providern. Aufbau auf dem **v1-LLM-Gateway** (Multi-Provider-Abstraktion) — **NICHT** neu bauen. Der **HiveMind-Agent** läuft erst nach etabliertem Werkzeug-Modus (Reifegrad-Gate) autonom und immer mit **Action-Boundary**: keine schreibenden Aktionen ohne explizite Bestätigung. Hilfe-Korpus für RAG kommt aus v2-P9 (Help-Center) per Retro-Integration.
## Was dieses Modul tut (Kurzfassung)
Aus dem v1-LLM-Gateway wird semantische Tiefe: eine **Embedding-Pipeline** (pgvector, permission-vorgefiltert) speist **RAG** für Programm-Hilfe, Lead-Zusammenfassungen und eine **hybride globale Suche** (Volltext + Vektor, RRF). **Whisper-EU** transkribiert Voice-Notes async. Der **HiveMind-Agent** liefert proaktive Vorschläge (Insights) mit Bestätigungs-Workflow, Audit-Trail und Scheduled-Actions. Multi-Provider-Failover (Mistral→Scaleway→AlephAlpha) + Promptfoo-Eval in CI.
## Benötigte Screens
| Screen [Route] | Kern-Inhalt | Archetyp |
|---|---|---|
| KI-Chat/Assistent-Panel [überall einblendbar / `/assistant`] | Chat mit RAG-Antworten (Quellen-Zitate), Conversation-History, Use-Case-Kontext (aktuelle UI-Sicht), Graceful-Degradation-Hinweis bei Provider-Down (Fallback Help-Volltext) | Chat-Panel |
| HiveMind-Insight-Anzeige [Dashboard-Widget / Detail] | Proaktive Vorschläge (Stuck-Deals/Stuck-Projects), Bestätigungs-Workflow (annehmen/verwerfen) mit Action-Boundary, Begründung/Quelle | A2/Widget |
| Custom-Tool-Editor + Scheduled-Actions [`/admin/ai/tools`] | Tenant-definierte HiveMind-Tools anlegen/bearbeiten, Cron-basierte Scheduled-Actions, Action-Log (Audit-Trail autonomer Actions) | A2 + A3 |
| AI-Usage-Dashboard [`/admin/usage/ai`] | Token-/Credit-Verbrauch (AiCredits/AiTokenUsage), pro Use-Case/Provider, Quota/Failover-Status | A6 |
| Whisper-Aufnahme/Transkription [in Abnahme/Notes eingebettet] | Voice-Note aufnehmen/hochladen, async Transkriptions-Status, Resultat + Klartext-Fehlerpfad | A5/eingebettet |
## Geplante Features (muss abgedeckt sein)
- [ ] Embedding-Pipeline → `TenantEmbedding` (pgvector, HNSW/IVFFlat), Re-Embed per Hash-Diff, Permission-Pre-Filter vor Vector-Search
- [ ] RAG bedient Programm-Hilfe (Help-Embeddings aus P9), Lead-Zusammenfassung, hybride globale Suche (Volltext+Vektor, RRF); Graceful Degradation bei Provider-Down
- [ ] Whisper-EU (Scaleway-GPU) transkribiert Voice-Notes async mit Klartext-Fehlerpfad
- [ ] HiveMind-Agent: proaktive Vorschläge mit Action-Boundary (keine Writes ohne Bestätigung), Audit-Trail, Scheduled-Actions, Screen-Context-Bewusstsein
- [ ] Multi-Provider-Failover (Mistral→Scaleway→AlephAlpha) + Promptfoo-Eval in CI (scheitert bei erfolgreicher Injection)
- [ ] Permissions: `ai.use_chat` / `ai.use_transcription` / `ai.basic` gaten Chat/Whisper/Read
- [ ] Wiederverwendung v1: LLM-Gateway, `RetrievalProvider`-Interface + Outbox-Index-Events, AiCredits/AiTokenUsage, Activity-Stream/Custom-Felder/Forms als RAG-Quellen
## Benötigte UI-Elemente (Liste, KEIN Layout — Anordnung = Davids freie Hand)
Chat-Panel (Nachrichten-Stream, Eingabefeld, Streaming-Antwort), Quellen-Zitat-Chips/Aufklapp (RAG-Belege), Conversation-History-Liste, Insight-Karten mit Annehmen/Verwerfen + Begründung, Action-Boundary-Bestätigungs-Dialog (zeigt geplante Action vor Ausführung), Audit-Trail-Timeline (autonome Actions), Custom-Tool-Editor (Tool-Definition-Felder), Scheduled-Action-Konfig (Cron + Tool-Auswahl), Voice-Recorder-Control (Aufnahme/Stop/Upload) + Transkriptions-Status-Indikator, Usage-/Quota-Charts pro Provider/Use-Case, Provider-Down-/Degradation-Banner (Klartext-Fallback-Hinweis), Permission-Denied-/Empty-/Loading-States (Klartext), DSGVO-/EU-Provider-Hinweis (Transparenz).
## Davids Delta (David füllt aus)
- [ ] Überschriebene/geänderte Spec-Punkte:
- [ ] Neue UI/UX-Entscheidungen:
- [ ] Subframe-Screen-Link(s):
- [ ] Offene Fragen an Dev:
## Dev-Nachzug (wir nach Davids Delta)
- [ ] Spec/ENTITY-MODEL an Davids Entscheidungen angepasst:
- [ ] REQUIREMENTS/PHASE-SOURCES nachgezogen:
- [ ] Plane-Ticket (VR-P25) aktualisiert:
@@ -4,11 +4,11 @@
**Quellen:** `ROADMAP-V2.md` §v2-P11 (NUR Telefonie-Teil) · `ROADMAP.md` §Phase 33 · `_bmad-output/specs/M12-telefonie.md` (Provider-Pattern)
**Subframe:** Pflicht je Route. Setup-/Binding-Flächen + Call-Session-Detail.
> 🔴 **Block 2, Spec offen (⚠ Vorlauf VEXT-2):** HalloPetra kommt in **keiner Spec** vor — M12 ist eine reine AirCall-Spec. HalloPetra ist ein **zweiter Telefonie-Provider hinter dem vorhandenen `TelephonyProviderInterface`** (connect/triggerOutboundCall/subscribeToWebhooks/verifyWebhookSignature/parseWebhookEvent/getRecordingUrl) — mechanisch klar, inhaltlich unspezifiziert. **Vor Bau:** eigene Vorlauf-Spec (API-Vertrag: Auth/Webhook-Events/Recording/Call-Lifecycle) erheben. Agil vorziehbar, sobald spezifiziert. Voicemail-Transkription via Whisper koppelt an v2-P4. Der AirCall-Adapter (v1-P15) ist die Referenz.
> 🔴 **Block 2, Spec offen (⚠ Vorlauf VEXT-2):** HalloPetra kommt in **keiner Spec** vor — M12 ist eine reine AirCall-Spec. HalloPetra ist ein **zweiter Telefonie-Provider hinter dem vorhandenen `TelephonyProviderInterface`** (connect/triggerOutboundCall/subscribeToWebhooks/verifyWebhookSignature/parseWebhookEvent/getRecordingUrl) — mechanisch klar, inhaltlich unspezifiziert. **Vor Bau:** eigene Vorlauf-Spec (API-Vertrag: Auth/Webhook-Events/Recording/Call-Lifecycle) erheben. Agil vorziehbar, sobald spezifiziert. Voicemail-Transkription via Whisper entfällt nach AD-26. Der AirCall-Adapter (v1-P15) ist die Referenz.
## Was dieses Modul tut (Kurzfassung)
Bindet HalloPetra als zusätzlichen Provider hinter dem bestehenden `TelephonyProviderInterface` an — ohne Kern-Umbau. Eingehende Webhooks werden auto-gematcht (auf Contact/Lead/Deal), landen im Activity-Stream und triggern Automationen (`call.*`). Click-to-Call, Inbound und Logging bleiben providerunabhängig: E.164-Normalisierung, Match-Confidence, Notiz-Editor. Aus Tenant-Sicht ist HalloPetra einfach eine weitere wählbare Telefonie-Anbindung neben AirCall.
Bindet HalloPetra als zusätzlichen Provider hinter dem bestehenden `TelephonyProviderInterface` an — ohne Kern-Umbau. Eingehende Webhooks werden auto-gematcht (auf Contact/Lead/Projekt), landen im Activity-Stream und triggern Automationen (`call.*`). Click-to-Call, Inbound und Logging bleiben providerunabhängig: E.164-Normalisierung, Match-Confidence, Notiz-Editor. Aus Tenant-Sicht ist HalloPetra einfach eine weitere wählbare Telefonie-Anbindung neben AirCall.
## Benötigte Screens
@@ -16,14 +16,14 @@ Bindet HalloPetra als zusätzlichen Provider hinter dem bestehenden `TelephonyPr
|---|---|---|
| Telefonie-Setup [`/settings/telephony`] | Provider-Connect (HalloPetra neben AirCall), Credential-/OAuth-Eingabe, Verbindungs-Test | A6 + Connect |
| User-Bindings [`/settings/telephony/bindings`] | Zuordnung User ↔ Telefonie-Nummer/-Account, providerunabhängig | A2/A6 |
| Call-Session-Detail [`/calls/:id`] | Anruf-Daten (Richtung, Dauer, Match auf Contact/Lead/Deal, Match-Confidence), Aufnahme-Link, Notiz-Editor | A3 |
| Call-Session-Detail [`/calls/:id`] | Anruf-Daten (Richtung, Dauer, Match auf Contact/Lead/Projekt, Match-Confidence), Aufnahme-Link, Notiz-Editor | A3 |
*(Click-to-Call ist ein Quick-Action-Element auf Contact/Lead/Deal-Detail-Views aus v1 — providerunabhängig, kein neuer Screen.)*
*(Click-to-Call ist ein Quick-Action-Element auf Contact/Lead/Projekt-Detail-Views aus v1 — providerunabhängig, kein neuer Screen.)*
## Geplante Features (muss abgedeckt sein)
- [ ] HalloPetra-Adapter implementiert `TelephonyProviderInterface` ohne Kern-Änderung
- [ ] Webhook-Auto-Match auf Contact/Lead/Deal → Activity + Automation-Trigger (`call.*`)
- [ ] Webhook-Auto-Match auf Contact/Lead/Projekt → Activity + Automation-Trigger (`call.*`)
- [ ] Click-to-Call + Inbound + Logging providerunabhängig (E.164-Normalisierung, Match-Confidence, Notiz-Editor)
- [ ] Provider-Auswahl/-Wechsel im Setup (HalloPetra neben AirCall)
- [ ] Recording-Abruf über `getRecordingUrl` (sofern HalloPetra-API liefert)
@@ -130,7 +130,7 @@ css: |-
## AP-5: P19 Plattform-Admin (Mo bis Mi 22. bis 24.06.)
**Was:** Die Betriebs-Konsole auf `admin.hivegrid.de`. **Harte Auflagen (Scope-Schnitt 12.06.):** Admin-User bekommen KEINEN Tenant-Workspace · Host-Routing admin.hivegrid.de (Code routet bisher `/admin`-Pfad!) · Cookie-Domain-Trennung zu app.* · TOTP-Pflicht + MFA-Step-up (Boilerplate-Modell, keine getrennte Identität bis v2).
**Fertig heißt:** Tenant anlegen/verwalten + **Abo-Zusammenstellung** (Konnektoren je Tenant schalten + Stripe-Positionen) + Impersonation mit Begründungs-Pflicht + Queue-Monitoring.
**Fertig heißt:** Tenant anlegen/verwalten + **Abo-Zusammenstellung** (Konnektoren je Tenant schalten + Legacy-Provider-Positionen) + Impersonation mit Begründungs-Pflicht + Queue-Monitoring.
| Milestone | Inhalt |
|---|---|
@@ -216,13 +216,13 @@ css: |-
## AP-10: P20 Billing & Onboarding (Mi 08. bis Do 09.07.)
**Was:** Stripe mit dem **Abo-Baukasten-Recut**: Basis-Preis pro Nutzer + Feature-Module als Stripe-Positionen. KEINE Seat-Typen! Vorlage: `archive/hivegrid-cloud/app/config/pricing-plans.ts` (Core/Swarm/Colony, 14 Gates). Boilerplate-Billing ist HIGH-Coverage (14 Modelle, ~40 Stripe-Funktionen, `packages/entitlements`).
**Fertig heißt:** Admin stellt Tenant-Abo zusammen → Konnektoren schalten frei → Stripe rechnet ab · Trial → Frozen-Modus funktioniert · Demo-Daten-Knopf liefert 25 Datensätze.
**Was:** Legacy-Provider mit dem **Abo-Baukasten-Recut**: Basis-Preis pro Nutzer + Feature-Module als Legacy-Provider-Positionen. KEINE Seat-Typen! Vorlage: `archive/hivegrid-cloud/app/config/pricing-plans.ts` (Core/Swarm/Colony, 14 Gates). Boilerplate-Billing ist HIGH-Coverage (14 Modelle, ~40 Legacy-Provider-Funktionen, `packages/entitlements`).
**Fertig heißt:** Admin stellt Tenant-Abo zusammen → Konnektoren schalten frei → Legacy-Provider rechnet ab · Trial → Frozen-Modus funktioniert · Demo-Daten-Knopf liefert 25 Datensätze.
| Milestone | Inhalt |
|---|---|
| M030 hell-dunkel | Nur Hell/Dunkel-Toggle, flash-frei (next-themes aus Boilerplate; KEIN System-Modus) |
| M153 stripe | Stripe-Anbindung: Checkout, Webhooks, Portal-Embed; ersetzt den Subscription-Stub aus P2 |
| M153 legacyProvider | Legacy-Provider-Anbindung: Checkout, Webhooks, Portal-Embed; ersetzt den Subscription-Stub aus P2 |
| M154 abo-baukasten | Recut: Basis-Preis/Nutzer + Module als Positionen via Entitlements × Flag-Engine (Preise aus Kostenmatrix 25.06.) |
| M155 trial-readonly | 14-Tage-Trial ohne Karte, Dunning-Sequenz, Frozen = Read-Only-Middleware |
| M156 metering | Verbrauchs-Zähler (Automation-Läufe, AI-Tokens, Speicher) + Fair-Use-Drossel (Abrechnung v2) |
@@ -248,7 +248,7 @@ css: |-
| Milestone | Inhalt |
|---|---|
| Abo-UI (M154-Teil) | Self-Service-Baukasten: Module togglen, Preis-Vorschau, Stripe-Update live |
| Abo-UI (M154-Teil) | Self-Service-Baukasten: Module togglen, Preis-Vorschau, Legacy-Provider-Update live |
| M096 sync-strategie | OAuth-Postfach-Anbindung (Entra/Outlook zuerst; Gmail-Scopes laufen bei Google) |
| M097 inbox-view | Inbox mit Threading, Auto-Verknüpfung an Kontakt/Deal (Match-Confidence + Override) |
| M098 provider-versand | Mail-Versand aus HiveGrid über das verbundene Postfach (Compose, Reply, Vorlagen) |
@@ -38,7 +38,7 @@ css: |-
### AP-2: Pflege + Conversion (19. bis 28.06.)
SEO-Basics · Conversion-Feinschliff · Pricing-Seite vorbereiten (Zahlen kommen am 25.06. aus dem Partner-Gespräch) · Founding-Member-Sektion mit Haris (ab 30.06. live, mit Stripe Payment Link).
SEO-Basics · Conversion-Feinschliff · Pricing-Seite vorbereiten (Zahlen kommen am 25.06. aus dem Partner-Gespräch) · Founding-Member-Sektion mit Haris (ab 30.06. live, mit Mollie Payment Link).
### AP-3: Webinar-Funnel (Mo 29.06. bis Fr 03.07.)
@@ -49,7 +49,7 @@ Website geht Mi/Do 17./18. live → Lead-Sheet aufsetzen (bis die HiveGrid-Lead-
| Datum | Schritt |
|---|---|
| Do 25.06. | Preise kommen aus Raffaels Partner-Gespräch → **Founding-Member-Angebot strukturieren** (Early-Bird-Jahresdeal, begrenzte Plätze) → Noah-Abnahme |
| Fr 26.06. | Stripe Payment Link von Raffael |
| Fr 26.06. | Mollie Payment Link von Raffael |
| **Mo 30.06.** | [€] **Founding-Deal live**: Klick-Demos (CRM-Stand der Noah-Demo) + Abschluss via Payment Link. **Geld kann VOR dem Launch fließen** |
| ab Fr 03.07. | Echte Produkt-Demos (Kette Lead → Sales → Auftrag läuft E2E) |
@@ -74,7 +74,7 @@ Fr 24.07.: Trial-Follow-ups (14 Tage nach Launch-Onboardings) · ab ~20.07. neue
| Noah | Painpoint-Gespräch (dein Rohstoff für Texte + Pitch) · danach laufend Abnahmen | Sa/So 13./14.06. |
| Dejan | Website LIVE + Lead-Routing (jede Anfrage landet sofort bei dir) | Mi/Do 17./18.06. |
| David | Screens fürs Pitch-Deck | Fr 19.06. |
| Raffael | **Preise** (25.06.) · **Stripe Payment Link** für den Founding-Deal (26.06.) | siehe Daten |
| Raffael | **Preise** (25.06.) · **Mollie Payment Link** für den Founding-Deal (26.06.) | siehe Daten |
| Akeshan | Demo-Tenant auf Staging für deine Klick-Demos | ab Mo 30.06. |
| Dejan | Webinar-Landing für deine Einladungen | Fr 03.07. |
| Raffael | Live-Demo im Webinar (du moderierst, er zeigt) | 14./15.07. |
@@ -65,7 +65,7 @@ css: |-
| Akeshan | Service-Kerne aus P1 (Audit, notify, Flags, i18n): er baut ab Mi alles darauf | Di 16.06. |
| Akeshan | Apps-Engine-Vertrag (P5): seine P14/P15/P16-Anbindungen laufen darüber | So 21.06. |
| Akeshan | CRM-Entitäten (P6): Marketing-Segmente + Chat-Verknüpfungen brauchen Contacts | Mi 24.06. |
| Haris | Preise (25.06.) · Stripe Payment Link (26.06.) · Live-Demo im Webinar (14./15.07.) | siehe Daten |
| Haris | Preise (25.06.) · Mollie Payment Link (26.06.) · Live-Demo im Webinar (14./15.07.) | siehe Daten |
| Dejan | Rechtstexte vom Anwalt durchreichen · Preise für die Pricing-Seite | sobald da / 25.06. |
| David | Reconciliation-Feedback je Screen (passt jedes Feld zu einer Datenquelle?) | < 24 h je Screen |
| Noah | Demo-Führung an den Gate-Freitagen + KWP-E2E-Termin (24.07.) | Gate-Freitage |
@@ -75,7 +75,7 @@ css: |-
| Wann | Was |
|---|---|
| Fr 12.06. | KWP-Antwortmail (Callback-URLs) · bnWin 8.1 N3 an Noah · AVV + Rechtstexte an Anwalt |
| Mo 15.06. | Mailjet-DNS + Warm-up · Google-Calendar-OAuth · Stripe-Live · AirCall/PandaDoc-Sandbox · Hetzner-Projekt `HiveGrid` + Mistral-Key · Noah-Slots blocken |
| Mo 15.06. | Mailjet-DNS + Warm-up · Google-Calendar-OAuth · Legacy-Provider-Live · AirCall/PandaDoc-Sandbox · Hetzner-Projekt `HiveGrid` + Mistral-Key · Noah-Slots blocken |
| Do 25.06. | [€] Kostenmatrix/Preise mit Partner FIX (Blocker für Haris!) |
| Mo 29.06. | Web-Push-VAPID-Keys · Gmail-Restricted-Scope-Antrag · Microsoft-Entra-App (Google Play Console / FCM/APNs entfallen — PWA-only, AD-17) |
| Mo 13.07. | HalloPetra-Konto · Memberspot-Zugänge |
@@ -120,7 +120,7 @@ css: |-
## AP-3: P5 Apps-Engine (Sa/So 20./21.06.)
**Was:** DER Integrations-Vertrag. Alles Externe (AirCall, PandaDoc, Stripe, Google, später KWP) läuft über dieses eine Framework.
**Was:** DER Integrations-Vertrag. Alles Externe (AirCall, PandaDoc, Legacy-Provider, Google, später KWP) läuft über dieses eine Framework.
**Fertig heißt:** Eine DataConnection lässt sich per OAuth ODER API-Key herstellen, Status-Ampel + Klartext, Health-Check, Tokens verschlüsselt (DEK aus Coolify-Secret).
| Milestone | Inhalt |
@@ -96,7 +96,7 @@ Note: C-15 spans both api-contract and protocol categories.
Count: 7 topics
Topics: Projekt-Überblick und Ziel, Phase-0-Pflichten, Delivery-Sequenzierung, SHK-Branchen-Kontext, Boilerplate-Reuse-Struktur, KWP-Recut-Entscheidung, Spec-Status
Topics: Projekt-Überblick und Ziel, Phase-0-Pflichten, Delivery-Sequenzierung, SHK-Branchen-Kontext, HiveGrid-Workspace-Struktur, KWP-Recut-Entscheidung, Spec-Status
---
@@ -18,7 +18,7 @@
"WebSocket",
"SSE",
"KWP integration",
"Stripe",
"Legacy-Provider",
"Automation Editor",
"Mobile Offline",
"Customer Portal",
@@ -29,8 +29,9 @@
],
"cross_refs": [
"AGENTS.md",
"docs/boilerplate-mapping/_SUMMARY.md",
"docs/boilerplate-mapping/M19-kwp-integration.md",
".planning/ENTITY-MODEL.md",
".planning/PHASE-SOURCES.md",
"docs/milestones/M19-KWP-API-RECUT.md",
"docs/INTEGRATION-CONTRACTS.md",
"PLAN.md"
],
@@ -4,7 +4,7 @@
"confidence": "high",
"manifest_override": true,
"title": "Modul 28 — Subscriptions / Billing",
"summary": "Technical specification for HiveGrid subscription engine, Stripe billing integration, seat model, trial/dunning lifecycle, AI credits, and service metering.",
"summary": "Technical specification for HiveGrid subscription engine, Legacy-Provider billing integration, seat model, trial/dunning lifecycle, AI credits, and service metering.",
"scope": [
"TenantSubscription",
"SubscriptionProduct",
@@ -14,9 +14,9 @@
"TenantSubscriptionUsageRecord",
"CheckoutSessionStatus",
"Credit",
"Stripe",
"Stripe-Checkout",
"Stripe-Webhooks",
"Legacy-Provider",
"Payment-Checkout",
"Legacy-Provider-Webhooks",
"Seat-Modell",
"Trial-Phase",
"Dunning",
@@ -22,7 +22,7 @@
"KWP",
"Gmail",
"AirCall",
"Stripe",
"Legacy-Provider",
"PandaDoc",
"Marketplace"
],
@@ -14,7 +14,7 @@
"ZIP export",
"Privacy-Hub routes",
"Capacitor app privacy manifest",
"Stripe subscription",
"Legacy-Provider subscription",
"pgvector embeddings",
"Redis cache",
"Scaleway storage"
@@ -63,7 +63,7 @@ source: docs/ARCHITECTURE-DECISIONS.md (AD-4)
type: nfr
source: docs/ARCHITECTURE-DECISIONS.md (AD-5), _bmad-output/specs/M24-globale-suche.md
- Meilisearch (self-hosted): Keyword-Suche; BP-Paket @toolctrl/search weitgehend 1:1 übernommen
- Meilisearch (self-hosted): Keyword-Suche; BP-Paket @hivegrid/search weitgehend 1:1 übernommen
- pgvector: Semantische Suche; Embedding-Dimension 1024 (Scaleway EU-Endpoint)
- Fusion: RRF (Reciprocal Rank Fusion) kombiniert beide Ergebnislisten
- SearchIndexEntry muss tenant_id enthalten; Abfragen stets permission-aware (RLS + Permission-Filter)
@@ -96,16 +96,16 @@ source: docs/ARCHITECTURE-DECISIONS.md (AD-9)
---
## C-08 — Paket-Naming: @toolctrl/* bleibt, @hivegrid/* nur für neue Querschnittsdienste
## C-08 — Paket-Naming: @hivegrid/* ohne Alt-Aliase
type: protocol
source: docs/ARCHITECTURE-DECISIONS.md (AD-10), docs/INTEGRATION-CONTRACTS.md
- @toolctrl/*: Boilerplate-Pakete, unveränderter Name (upstream-mergebar)
- @hivegrid/*: einheitlicher Scope fuer alle Workspace-Pakete
- packages/ui-app (nicht ui-hivegrid): HiveGrid-UI-Shell
- apps/web/app/application/sidebar/: Sidebar-Konfiguration (nicht sidebar.config.ts)
- packages/search/: Such-Index / indexedEntities (nicht packages/db/src/search/indexed-entities.ts)
- @hivegrid/*: nur neue, genuin HiveGrid-eigene Pakete (z.B. @hivegrid/activity, @hivegrid/audit, @hivegrid/notifications)
- keine Alt-Scope-Package-Aliase, kein altes CLI-Binary, keine parallelen technischen Produkt-Contracts
---
@@ -134,7 +134,8 @@ Verbindliche URL-Schemata (Abweichungen erfordern Change-Request):
- Public-Forms: app.hivegrid.de/f/{tenant}/{slug}
- Public-Booking: app.hivegrid.de/b/{tenant}/{slug}
- Public-Help: app.hivegrid.de/help/{lang}/{kategorie}/{slug}
- Public-API: app.hivegrid.de/api/v1/{resource}
- Public-API: api.hivegrid.de/v1/{resource}
- MCP-Server: mcp.hivegrid.de
---
@@ -11,7 +11,7 @@ source: docs/ARCHITECTURE-DECISIONS.md
HiveGrid 2.0 ist eine SaaS-Plattform für SHK-Handwerksbetriebe (Sanitär, Heizung, Klima). Erstkunde: Geerkens GmbH. Das Produkt löst ab: HubSpot (CRM + Sales-Pipeline), Quiply (Team-Chat), Zapier/n8n (Automation), sowie mehrere Excel-Prozesse.
Die Codebase ist ein Git-Downstream der ToolCTRL-Boilerplate (upstream-Remote bleibt erhalten). Boilerplate deckt ~35–45 % der Anforderungen ab (Fundament high, Fachdomäne greenfield).
Die Codebase wird als bespoke HiveGrid-Anwendung weitergeführt. Historisches Fundament wird genutzt, produktfremde Altlasten werden entfernt.
USP-Säulen (Day-1-Launch): Automation-Engine (visuell, KI-gestützt) + KI-Layer (EU-only) + KWP-Integration (Kern-ERP Geerkens).
@@ -22,8 +22,8 @@ USP-Säulen (Day-1-Launch): Automation-Engine (visuell, KI-gestützt) + KI-Layer
source: docs/ARCHITECTURE-DECISIONS.md
Die folgenden Voraussetzungen sind vor dem ersten Fachmodul-Bau abzuarbeiten:
1. Boilerplate einziehen (Repo ist aktuell Docs-only, keine echte Codebase)
2. Naming-Drift in Docs beheben (AD-10)
1. Codebase lauffähig halten
2. Naming-Drift in Code und Docs beheben (AD-10)
3. EU-Sterilisation: GitHub/Google-OAuth-Reste entfernen
4. M00 aktivieren + 4 Querschnitts-Neubauten (logActivity, Search-Registry, Subframe-Adapter, Mass-Action-Schutz)
5. Fehlende Dependencies eintragen (PLAN.md teils falsch)
@@ -59,18 +59,18 @@ SHK-Seeds (beim Tenant-Anlegen): 6 Standard-Rollen, 2 Sales-Pipelines, Custom-Fe
---
## Topic: Boilerplate-Reuse-Struktur
## Topic: HiveGrid-Workspace-Struktur
source: docs/ARCHITECTURE-DECISIONS.md (AD-10), docs/INTEGRATION-CONTRACTS.md
Boilerplate-Pakete (unveränderter Name, upstream-mergebar):
Workspace-Pakete im `@hivegrid/*`-Scope:
- packages/ui-app (UI-Shell)
- packages/ui-subframe (Subframe-Adapter)
- packages/ui-mock (Mock-UI für Hot-Swap-Tests)
- @toolctrl/search (Meilisearch-Paket)
- @toolctrl/foundation (Basis-Utilities)
- @hivegrid/search (Meilisearch-Paket)
- @hivegrid/foundation (Basis-Utilities)
Neue HiveGrid-Querschnittsdienste (zu bauen, @hivegrid/*-Namespace):
HiveGrid-Querschnittsdienste:
- @hivegrid/activity (logActivity-Service + zentrale Activity-Tabelle)
- @hivegrid/audit (auditEvent-Service)
- @hivegrid/notifications (notify-Service)
@@ -67,7 +67,7 @@ status: Accepted (locked)
scope: Globale Suche, Search-Infrastruktur
source: docs/ARCHITECTURE-DECISIONS.md
Entscheidung: Keyword-Suche über selbst gehostetes Meilisearch (BP-`@toolctrl/search` weitgehend 1:1), semantische Suche über pgvector + EU-Embeddings (Scaleway 1024-dim, BP 1:1), kombiniert via RRF-Fusion. Embedding-Pipeline + Outbox + Indexer-Worker aus der BP übernehmen.
Entscheidung: Keyword-Suche über selbst gehostetes Meilisearch (BP-`@hivegrid/search` weitgehend 1:1), semantische Suche über pgvector + EU-Embeddings (Scaleway 1024-dim, BP 1:1), kombiniert via RRF-Fusion. Embedding-Pipeline + Outbox + Indexer-Worker aus der BP übernehmen.
Hinweis: Weicht bewusst von M056 "Postgres-FTS, kein Extra-Service" ab → Change-Request gegen M056 nötig.
Betrifft: M24 / M056–M058
@@ -120,16 +120,18 @@ Betrifft: M13 (bidirektional WebSocket); SSE-Pfad: M03, M22
---
## AD-10 — Naming-Konvention: `@toolctrl/*` behalten, Docs anpassen
## AD-10 — Naming-Konvention: HiveGrid-Scope ohne Alt-Aliase
status: Accepted (locked)
scope: Paket-Naming, Mono-Repo-Struktur, alle Module
source: docs/ARCHITECTURE-DECISIONS.md
Entscheidung: Boilerplate-Pakete behalten ihre Namen (`@toolctrl/*`, `packages/ui-app`) — kein flächiger Rename zu `@hivegrid/*`/`ui-hivegrid`.
- `@toolctrl/*` + `ui-app`/`ui-subframe`/`ui-mock` = geteilte Boilerplate-Pakete (mergebar mit upstream)
- `@hivegrid/*` = nur für genuin neue HiveGrid-Pakete (z.B. Activity-Paket, Domänen-Pakete)
- Docs: wo "ui-hivegrid"/"@hivegrid/foundation"/"sidebar.config.ts" steht, ist `ui-app`/`@toolctrl/foundation`/`apps/web/app/application/sidebar/` gemeint
Entscheidung: Alle Workspace-Pakete und technischen Produkt-Contracts nutzen HiveGrid-Namen:
`@hivegrid/*`, `hivegrid-cli`, `HIVEGRID_*`, `hivegrid_*`-Cookies, `x-hivegrid-*`-Header und
`hivegrid.*`-Queue-Namen. Es gibt keine Alt-Aliase.
- `packages/ui-app`, `packages/ui-subframe`, `packages/ui-mock` behalten ihre realen Pfade
- Estate-Slugs bleiben externe Infrastruktur (`git.tool-ctrl.de`, `toolctrl-labs`, `toolctrl-ci`, Plane-Workspace `toolctrl`)
- Docs: wo "ui-hivegrid", Alt-Scopes oder alte Sidebar-Pfade stehen, ist das Doku-Drift
Betrifft: Querschnitt (alle Module), Phase 0
@@ -178,10 +180,10 @@ Entscheidung: Geerkens startet weitgehend leer; KWP-Sync bringt Auftrags-/Stammd
## AD-15 — Pricing/Billing: Engine bauen, Preise geparkt
status: Accepted (locked)
scope: Stripe, Billing-Engine, Pricing
scope: Legacy-Provider, Billing-Engine, Pricing
source: docs/ARCHITECTURE-DECISIONS.md
Entscheidung: Stripe-Integration + Seat-/Plan-Engine + Verbrauchs-Metering werden technisch gebaut, die konkreten Preis-Stufen bleiben geparkt. Geerkens läuft auf manueller Sonderkondition/Trial. Self-Service-Checkout-Preise = eigene spätere Pricing-Runde.
Entscheidung: Legacy-Provider-Integration + Seat-/Plan-Engine + Verbrauchs-Metering werden technisch gebaut, die konkreten Preis-Stufen bleiben geparkt. Geerkens läuft auf manueller Sonderkondition/Trial. Self-Service-Checkout-Preise = eigene spätere Pricing-Runde.
---
@@ -231,8 +233,8 @@ Bewusst akzeptiert: längste Zeit bis zum ersten echten Nutzer-Feedback, höchst
source: docs/ARCHITECTURE-DECISIONS.md
1. Boilerplate als Code-Basis einziehen (Repo ist heute Docs-only)
2. Naming-Drift beheben: `@toolctrl/*` → Docs anpassen, Sidebar-Konvention korrigieren
1. Codebase lauffähig halten
2. Naming-Drift beheben: HiveGrid-Contracts in Code und Docs, Sidebar-Konvention korrigieren
3. EU-Sterilisation: GitHub/Google-OAuth-Reste + `User.githubId/googleId` entfernen
4. M00 aktivieren + 4 echte Neubauten: `logActivity()`/Activity-Stream, Search-Registry, Subframe-Adapter-Code, Mass-Action-Schutz
5. Fehlende Dependencies ergänzen: `signature_pad`, `browser-image-compression`, `react-grid-layout`, `@dnd-kit`, `libphonenumber`, `ics`/`ical-generator`, `btree_gist`/`EXCLUDE`-Constraint u.a.
@@ -914,8 +914,8 @@ source: _bmad-output/specs/M28-subscriptions-billing.md
TenantSubscription, SubscriptionProduct, SubscriptionPrice, SubscriptionFeature, TenantSubscriptionUsageRecord, Credit.
Acceptance criteria:
- TenantSubscription: tenant_id, stripe_customer_id, stripe_subscription_id, product_id, status (active|trialing|frozen|cancelled), trial_ends_at, current_period_start/end, seat_count
- Stripe-Webhook-Verarbeitung: checkout.session.completed, invoice.payment_succeeded, invoice.payment_failed, customer.subscription.deleted
- TenantSubscription: tenant_id, legacyProvider_customer_id, legacyProvider_subscription_id, product_id, status (active|trialing|frozen|cancelled), trial_ends_at, current_period_start/end, seat_count
- Legacy-Provider-Webhook-Verarbeitung: checkout.session.completed, invoice.payment_succeeded, invoice.payment_failed, customer.subscription.deleted
- Frozen-Modus: Zugriff eingeschränkt (Read-Only-Middleware), Warnungen in App
- AI-Credits: Credit-Konto pro Tenant, verknüpft mit Subscription-Plan
@@ -925,7 +925,7 @@ Trial-Phase, Dunning-Workflow, Setup-Fee.
Acceptance criteria:
- Trial: 14 Tage Default, keine Kreditkarte erforderlich
- Dunning: bei fehlgeschlagener Zahlung → Email-Sequenz (Tag 1, 3, 7), dann Frozen-Modus
- Setup-Fee: einmalige Gebühr bei Enterprise-Buchung (Stripe-Checkout-Session)
- Setup-Fee: einmalige Gebühr bei Enterprise-Buchung (Payment-Checkout-Session)
- Preise selbst geparkt (AD-15); Engine muss aber bereit sein
---
@@ -1026,7 +1026,7 @@ source: _bmad-output/specs/M33a-apps-engine.md
DataConnection, AppSyncRun, AppSyncRunLog, AppSyncItem, Adapter-Registry.
Acceptance criteria:
- DataConnection: tenant_id, app_key (gmail|outlook|aircall|kwp|stripe|pandadoc|...), status (connected|disconnected|error), credentials_encrypted (AD-8), config (JSONB), last_synced_at
- DataConnection: tenant_id, app_key (gmail|outlook|aircall|kwp|legacyProvider|pandadoc|...), status (connected|disconnected|error), credentials_encrypted (AD-8), config (JSONB), last_synced_at
- AppSyncRun: connection_id, status (running|completed|failed), started_at, finished_at, items_processed
- AppSyncItem: run_id, external_id, action (create|update|delete), payload_hash, status
- Adapter-Registry: app_key → Adapter-Klasse (type-safe Registry)
@@ -1036,7 +1036,7 @@ App-Connect-Flow, Sync-Mechanik, Connection-Health-Monitoring.
Acceptance criteria:
- OAuth2-Connect-Flow für Gmail, Outlook, AirCall, PandaDoc
- API-Key-Connect für KWP (type=windows_client), Stripe
- API-Key-Connect für KWP (type=windows_client), Legacy-Provider
- Sync-Intervalle: konfiguierbar per App (Push-Webhook bevorzugt, Pull als Fallback)
- Connection-Health: Heartbeat-Check, Auto-Reconnect-Versuch, Tenant-Admin-Alert bei Fehler
- Settings-UI: /settings/integrationen/{app_key}; Einstiegspunkt für alle Integrations
@@ -1087,7 +1087,7 @@ Tenant-Account-Löschung mit Crypto-Shredding.
Acceptance criteria:
- TenantDeletionRequest: tenant_id, requested_by_user_id, reason, scheduled_for (14-Tage-Frist)
- Crypto-Shredding: Per-Tenant-DEK löschen → alle verschlüsselten Daten unlesbar (AD-8)
- Stripe-Subscription canceln vor Löschung
- Legacy-Provider-Subscription canceln vor Löschung
- pgvector-Embeddings, Redis-Cache, Scaleway-Storage-Objekte alle entfernen
- AvvAcceptance + Subprocessor-Liste in Privacy-Hub-Route anzeigbar
@@ -1211,7 +1211,8 @@ Acceptance criteria:
- Public-Forms: app.hivegrid.de/f/{tenant}/{slug}
- Public-Booking: app.hivegrid.de/b/{tenant}/{slug}
- Public-Help: app.hivegrid.de/help/{lang}/{kategorie}/{slug}
- Public-API: app.hivegrid.de/api/v1/{resource}
- Public-API: api.hivegrid.de/v1/{resource}
- MCP-Server: mcp.hivegrid.de
### REQ-integration-activity-wiring
Jede schreibende Aktion mit Entitäts-Bezug muss logActivity() aufrufen.
@@ -0,0 +1,749 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HiveGrid · Pricing-Mix & Unit Economics</title>
<style>
:root{
--ink:#17202f; --muted:#667085; --bg:#f5f7fa; --card:#fff;
--line:#d9e0e8; --line2:#b8c4d2; --accent:#1f6f78; --accent2:#174f56;
--soft:#e5f4f4; --gold:#a66f13; --good:#168247; --bad:#bd3030;
--blue:#315da8; --violet:#7557b8; --orange:#cf6a2c; --slate:#94a3b8;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Inter,Arial,sans-serif;font-size:14px;line-height:1.45}
header{padding:26px 30px;color:#fff;background:linear-gradient(135deg,#132a36 0%,#1f6f78 62%,#7d5d1e 100%)}
header h1{margin:0;font-size:24px;line-height:1.15;letter-spacing:0}
header p{max-width:1120px;margin:7px 0 0;color:rgba(255,255,255,.82);font-size:13.5px}
.pill{display:inline-flex;align-items:center;padding:2px 9px;border-radius:999px;border:1px solid rgba(255,255,255,.28);background:rgba(255,255,255,.12);color:#fff;font-size:11px;font-weight:750;white-space:nowrap}
.wrap{display:grid;grid-template-columns:460px minmax(0,1fr);gap:20px;align-items:start;padding:22px 30px 30px}
.wrap>div{min-width:0}
@media(max-width:1120px){.wrap{grid-template-columns:1fr;padding:16px}}
.card{margin-bottom:18px;padding:18px 20px;background:var(--card);border:1px solid var(--line);border-radius:10px;box-shadow:0 1px 2px rgba(16,24,40,.04)}
@media(max-width:680px){.card{overflow-x:auto} table{min-width:560px}.mix-table{min-width:0}}
.card h2{margin:0 0 14px;padding-bottom:7px;border-bottom:2px solid var(--accent);font-size:15px;line-height:1.25}
.card h3{margin:18px 0 8px;color:var(--muted);font-size:12px;text-transform:uppercase;letter-spacing:.06em}
.lead{padding:12px 14px;margin:0 0 18px;border:1px solid var(--line);border-left:4px solid var(--accent);border-radius:8px;background:#fbfcfe;color:#263241}
table{width:100%;border-collapse:collapse;font-variant-numeric:tabular-nums}
th,td{padding:8px 9px;border-bottom:1px solid var(--line);text-align:left;vertical-align:middle}
th{background:#edf2f7;color:#111827;font-size:12px}
td.n,th.n{text-align:right;white-space:nowrap}
tr.sum td{border-top:2px solid var(--ink);background:#fbfcfe;font-weight:850}
.mix-table input[type=number]{width:76px;padding:5px 7px;border:1px solid var(--line2);border-radius:6px;background:#fff;color:var(--accent2);font-weight:750;text-align:right;font-variant-numeric:tabular-nums}
.mix-table td:first-child{font-weight:800}
.hint{color:var(--muted);font-size:12px}
label.row{display:block;margin:14px 0}
label.row .lab{display:flex;justify-content:space-between;gap:12px;margin-bottom:6px;font-weight:700}
label.row .val{color:var(--accent);font-variant-numeric:tabular-nums;white-space:nowrap}
input[type=range]{width:100%;accent-color:var(--accent)}
select{width:100%;padding:9px 10px;border:1px solid var(--line2);border-radius:8px;background:#fff;color:var(--ink);font-weight:700}
.note{margin-top:9px;color:var(--muted);font-size:12px}
.kpis{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:18px}
@media(max-width:1240px){.kpis{grid-template-columns:repeat(2,1fr)}}
@media(max-width:680px){.kpis{grid-template-columns:1fr}}
.kpi{min-height:104px;padding:14px 16px;border:1px solid var(--line);border-radius:10px;background:var(--card)}
.kpi .k{color:var(--muted);font-size:12px;font-weight:750}
.kpi .v{margin-top:5px;font-size:25px;line-height:1.1;font-weight:850;font-variant-numeric:tabular-nums}
.kpi .s{margin-top:4px;color:var(--muted);font-size:11.5px}
.grid2{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}
@media(max-width:760px){.grid2{grid-template-columns:1fr}}
.bar{display:flex;height:28px;margin:7px 0 5px;overflow:hidden;border:1px solid var(--line);border-radius:7px;background:#fff}
.bar span{display:flex;align-items:center;justify-content:center;min-width:0;height:100%;overflow:hidden;color:#fff;font-size:11px;font-weight:800;white-space:nowrap}
.legend{display:flex;flex-wrap:wrap;gap:10px 15px;margin-top:9px;font-size:12px}
.legend i{display:inline-block;width:11px;height:11px;margin-right:5px;border-radius:3px;vertical-align:middle}
.pos{color:var(--good)!important;font-weight:850}.neg{color:var(--bad)!important;font-weight:850}.warn{color:var(--gold)!important;font-weight:850}
.src{margin-top:8px;color:var(--muted);font-size:11.5px;line-height:1.65}.src a{color:var(--accent)}
details{margin-top:8px} summary{cursor:pointer;color:var(--accent);font-weight:800}
.payment-row{display:grid;grid-template-columns:88px 1fr 58px;gap:10px;align-items:center;margin:12px 0}
.payment-row strong{font-size:13px}
.payment-row output{font-weight:850;color:var(--accent);text-align:right;font-variant-numeric:tabular-nums}
.compact-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px}
.compact-grid label{display:block;color:var(--muted);font-size:12px;font-weight:750}
.compact-grid input,.compact-grid select{margin-top:5px}
.compact-grid input[type=number]{width:100%;padding:8px 10px;border:1px solid var(--line2);border-radius:8px;background:#fff;color:var(--ink);font-weight:750;font-variant-numeric:tabular-nums}
@media(max-width:520px){.compact-grid{grid-template-columns:1fr}}
.mode-panel{padding:12px;margin-top:10px;border:1px solid var(--line);border-radius:8px;background:#fbfcfe}
.mode-panel h3{margin-top:0}
.inline-result{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin-top:10px}
.inline-result div{padding:9px 10px;border:1px solid var(--line);border-radius:8px;background:#fff}
.inline-result span{display:block;color:var(--muted);font-size:11px;font-weight:750}
.inline-result b{display:block;margin-top:2px;font-size:15px;font-variant-numeric:tabular-nums}
@media(max-width:520px){.inline-result{grid-template-columns:1fr}}
.toggle{display:flex;align-items:center;gap:10px;margin-top:6px;cursor:pointer;user-select:none}
.toggle input{position:relative;width:38px;height:22px;appearance:none;border-radius:20px;background:var(--line2);cursor:pointer;transition:.15s}
.toggle input:checked{background:var(--good)}.toggle input:before{content:"";position:absolute;top:2px;left:2px;width:18px;height:18px;border-radius:50%;background:#fff;transition:.15s}.toggle input:checked:before{left:18px}
.seg{display:grid;grid-template-columns:repeat(3,1fr);overflow:hidden;border:1px solid var(--line2);border-radius:8px;background:#fff}
.seg button{min-height:38px;padding:8px 5px;border:0;border-right:1px solid var(--line);background:#fff;color:var(--muted);cursor:pointer;font-size:12px;font-weight:800}
.seg button:last-child{border-right:0}.seg button.on{background:var(--accent);color:#fff}.seg button:hover:not(.on){background:var(--soft);color:var(--accent)}
.role-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:14px}
.role-head h3{margin:0;color:var(--muted);font-size:12px;text-transform:uppercase;letter-spacing:.06em}
.salary-mode{width:auto;min-width:170px;padding:6px 8px;font-size:12px}
</style>
</head>
<body>
<header>
<h1>HiveGrid · Pricing-Mix & Unit Economics <span class="pill">Stand 07.07.2026</span></h1>
<p>Rechner für die betriebswirtschaftliche Planung: wie viele Starter-, Team- und Business-Betriebe, wie viele Extra-User je Paket, Add-on-Annahmen und Zahlungs-Mix über Mollie.</p>
</header>
<div class="wrap">
<div>
<div class="card">
<h2>Kundenmix</h2>
<h3>Modus</h3>
<select id="scaleMode">
<option value="manual">Kundenmix manuell eingeben</option>
<option value="target" selected>Aus Nutzerziel ableiten</option>
</select>
<div class="mode-panel" id="targetMixBox">
<h3>Nutzerziel</h3>
<div class="compact-grid">
<label>Nutzer gesamt
<input id="scaleUsers" type="number" min="100" max="500000" step="100" value="153000">
</label>
<label>Ø Nutzer je Betrieb
<input id="scaleAvgUsers" type="number" min="1" max="250" step="1" value="20">
</label>
<label>Abgeleitetes Paket
<select id="scalePlan">
<option value="team" selected>Team · 20 Nutzer inkl.</option>
<option value="starter">Starter · 7 Nutzer inkl.</option>
<option value="business">Business · 40 Nutzer inkl.</option>
</select>
</label>
</div>
<div class="inline-result" id="targetPreview"></div>
</div>
<div class="note" id="scaleModeNote">Im Zielmodus errechnet der Rechner Betriebe/Teams und Extra-User automatisch aus Nutzerziel, Durchschnittsgröße und Paket.</div>
<div class="mode-panel" id="manualMixBox">
<h3>Manueller Mix</h3>
<table class="mix-table">
<thead>
<tr>
<th>Paket</th>
<th class="n">Betriebe</th>
<th class="n">Ø extra Büro</th>
<th class="n">Ø extra Baustelle</th>
</tr>
</thead>
<tbody>
<tr>
<td>Starter<br><span class="hint">2 Büro + 5 Baustelle inkl.</span></td>
<td class="n"><input id="starterCount" type="number" min="0" step="1" value="25"></td>
<td class="n"><input id="starterExtraOffice" type="number" min="0" step="0.1" value="0.5"></td>
<td class="n"><input id="starterExtraSite" type="number" min="0" step="0.1" value="2"></td>
</tr>
<tr>
<td>Team<br><span class="hint">5 Büro + 15 Baustelle inkl.</span></td>
<td class="n"><input id="teamCount" type="number" min="0" step="1" value="12"></td>
<td class="n"><input id="teamExtraOffice" type="number" min="0" step="0.1" value="1"></td>
<td class="n"><input id="teamExtraSite" type="number" min="0" step="0.1" value="4"></td>
</tr>
<tr>
<td>Business<br><span class="hint">10 Büro + 30 Baustelle inkl.</span></td>
<td class="n"><input id="businessCount" type="number" min="0" step="1" value="3"></td>
<td class="n"><input id="businessExtraOffice" type="number" min="0" step="0.1" value="2"></td>
<td class="n"><input id="businessExtraSite" type="number" min="0" step="0.1" value="8"></td>
</tr>
</tbody>
</table>
<div class="note">Extra-User sind Durchschnittswerte pro Betrieb. 0 bedeutet: Paket wird ohne zusätzliche Nutzer verkauft.</div>
</div>
</div>
<div class="card">
<h2>Zahlungs-Mix · Mollie</h2>
<div class="payment-row">
<strong>SEPA</strong>
<input id="paySepa" type="range" min="0" max="100" step="1" value="70">
<output id="o_paySepa">70 %</output>
</div>
<div class="payment-row">
<strong>Visa/MC</strong>
<input id="payVisa" type="range" min="0" max="100" step="1" value="25">
<output id="o_payVisa">25 %</output>
</div>
<div class="payment-row">
<strong>Amex</strong>
<input id="payAmex" type="range" min="0" max="100" step="1" value="5">
<output id="o_payAmex">5 %</output>
</div>
<div class="note" id="paymentNote"></div>
</div>
<div class="card">
<h2>Add-ons</h2>
<h3>Email Marketing</h3>
<label class="row"><span class="lab">Anteil Betriebe mit Email Marketing <span class="val" id="o_emailAttach"></span></span>
<input type="range" id="emailAttach" min="0" max="100" step="5" value="35"></label>
<select id="emailTier">
<option value="s">S · bis 10.000 Mails · 59 €/Monat</option>
<option value="m" selected>M · 10.001-50.000 Mails · 149 €/Monat</option>
<option value="l">L · 50.001-100.000 Mails · 249 €/Monat</option>
<option value="xl">XL · 100.001-250.000 Mails · 499 €/Monat</option>
<option value="xxl">XXL · 250.001-500.000 Mails · 899 €/Monat</option>
</select>
<h3>Cloud-Speicher</h3>
<label class="row"><span class="lab">Anteil Betriebe mit Speicher-Add-on <span class="val" id="o_storageAttach"></span></span>
<input type="range" id="storageAttach" min="0" max="100" step="5" value="20"></label>
<select id="storageAddon">
<option value="100">+100 GB · 5 €/Monat</option>
<option value="500" selected>+500 GB · 15 €/Monat</option>
<option value="1000">+1 TB · 25 €/Monat</option>
<option value="5000">+5 TB · 99 €/Monat</option>
<option value="10000">+10 TB · 179 €/Monat</option>
</select>
<label class="row"><span class="lab">Ø Speichernutzung vom Pool <span class="val" id="o_storageUse"></span></span>
<input type="range" id="storageUse" min="10" max="100" step="5" value="60"></label>
<label class="row"><span class="lab">Egress je TB gespeichert <span class="val" id="o_egress"></span></span>
<input type="range" id="egressFactor" min="0" max="2" step="0.1" value="0.3"></label>
</div>
<div class="card">
<h2>Infrastruktur</h2>
<h3>Dynamisches Kapazitätsmodell</h3>
<div class="note">Rechnet aus den aktuell modellierten Nutzern automatisch die benötigte Kapazität. Die konkrete Serverliste steht rechts im Infra-Szenario.</div>
<label class="row"><span class="lab">Kapazitäts-Puffer <span class="val" id="o_scaleBuffer"></span></span>
<input id="scaleBuffer" type="range" min="10" max="80" step="5" value="30"></label>
<label class="row"><span class="lab">Mindest-Estate-/Ops-Anteil <span class="val" id="o_estateShare"></span></span>
<input type="range" id="estateShare" min="0" max="250" step="5" value="25"></label>
<div class="note" id="infraNote"></div>
</div>
<div class="card">
<h2>Personal</h2>
<label class="toggle"><input type="checkbox" id="persOn"> <span>Personal in GuV einrechnen</span></label>
<div id="persBox" style="display:none">
<label class="row"><span class="lab">Arbeitgeberaufschlag auf Brutto <span class="val" id="o_employerRate"></span></span>
<input type="range" id="employerRate" min="19" max="25" step="0.5" value="22"></label>
<div class="role-head"><h3>Entwicklung</h3><select id="devMode" class="salary-mode"><option value="gross" selected>Brutto angeben</option><option value="net">Netto-Ziel angeben</option><option value="cost">AG-Kosten direkt</option></select></div>
<label class="row"><span class="lab">Entwickler <span class="val" id="o_dev"></span></span>
<input type="range" id="dev" min="0" max="8" step="1" value="2"></label>
<label class="row"><span class="lab">Gehalt je Entwickler <span class="val" id="o_devpay"></span></span>
<input type="range" id="devpay" min="3000" max="10000" step="250" value="7500"></label>
<div class="note" id="devCostNote"></div>
<div class="role-head"><h3>Vertrieb</h3><select id="salesMode" class="salary-mode"><option value="gross" selected>Brutto angeben</option><option value="net">Netto-Ziel angeben</option><option value="cost">AG-Kosten direkt</option></select></div>
<label class="row"><span class="lab">Vertrieb <span class="val" id="o_sales"></span></span>
<input type="range" id="sales" min="0" max="6" step="1" value="1"></label>
<label class="row"><span class="lab">Gehalt je Vertrieb <span class="val" id="o_salespay"></span></span>
<input type="range" id="salespay" min="2500" max="8000" step="250" value="5000"></label>
<div class="note" id="salesCostNote"></div>
<div class="role-head"><h3>Support</h3><select id="suppMode" class="salary-mode"><option value="gross" selected>Brutto angeben</option><option value="net">Netto-Ziel angeben</option><option value="cost">AG-Kosten direkt</option></select></div>
<label class="row"><span class="lab">Support <span class="val" id="o_supp"></span></span>
<input type="range" id="supp" min="0" max="6" step="1" value="1"></label>
<label class="row"><span class="lab">Gehalt je Support <span class="val" id="o_supppay"></span></span>
<input type="range" id="supppay" min="2500" max="6000" step="250" value="4000"></label>
<div class="note" id="suppCostNote"></div>
<div class="note">Netto-Ziel ist eine Planungs-Schätzung für Deutschland 2026, Steuerklasse I, gesetzlich versichert, ohne Kirchensteuer. Für echte Payroll bitte Lohnabrechnung/Steuerberater verwenden.</div>
</div>
</div>
</div>
<div>
<div class="lead" id="summaryLead"></div>
<div class="kpis">
<div class="kpi"><div class="k">MRR</div><div class="v" id="kpi_mrr"></div><div class="s" id="kpi_arr"></div></div>
<div class="kpi"><div class="k">Betriebe / Nutzer</div><div class="v" id="kpi_tenants"></div><div class="s" id="kpi_users"></div></div>
<div class="kpi"><div class="k">Betriebskosten</div><div class="v" id="kpi_opex"></div><div class="s" id="kpi_opexpct"></div></div>
<div class="kpi"><div class="k" id="kpi_marginLabel">Rohmarge</div><div class="v" id="kpi_margin"></div><div class="s" id="kpi_marginpct"></div></div>
</div>
<div class="card">
<h2>Kundenmix & Core-Umsatz</h2>
<table>
<thead><tr><th>Paket</th><th class="n">Betriebe</th><th class="n">Nutzer gesamt</th><th class="n">Extra-User</th><th class="n">Core-MRR</th><th class="n">Ø/Betrieb</th></tr></thead>
<tbody id="planRows"></tbody>
</table>
<div class="note">Diese Tabelle zeigt den Paket- und Zusatznutzerumsatz. Email-Marketing und Speicher-Add-ons werden darunter separat ausgewiesen und sind im Gesamt-MRR enthalten.</div>
</div>
<div class="card">
<h2>Zahlungs-Mix & Mollie-Gebühren</h2>
<table>
<thead><tr><th>Zahlungsart</th><th class="n">Anteil</th><th class="n">Umsatzbasis</th><th class="n">Mollie-Gebühr</th><th class="n">Kosten / Monat</th></tr></thead>
<tbody id="paymentRows"></tbody>
</table>
<div class="note">Modellannahme: SEPA-Anteil nutzt den Jahres-SEPA-Preis und wird als eine jährliche SEPA-Lastschrift modelliert. Visa/MC und Amex nutzen den monatlichen Listenpreis.</div>
</div>
<div class="card">
<h2>Wohin geht 1 € Umsatz?</h2>
<div class="bar" id="splitbar"></div>
<div class="legend" id="splitlegend"></div>
</div>
<div class="card">
<h2>Betriebskosten im Detail</h2>
<table>
<thead><tr><th>Posten</th><th class="n">€ / Monat</th><th class="n">% Umsatz</th><th class="n">Cent je €</th></tr></thead>
<tbody id="opexRows"></tbody>
</table>
</div>
<div class="card">
<h2>Infra-Szenario</h2>
<table>
<thead><tr><th>Komponente</th><th>Rolle</th><th class="n">€ / Monat</th></tr></thead>
<tbody id="infraRows"></tbody>
</table>
<div class="note" id="infraScaleNote"></div>
</div>
<div class="grid2">
<div class="card">
<h2>Add-on-Ergebnis</h2>
<table><tbody id="addonRows"></tbody></table>
</div>
<div class="card">
<h2>Speicher-Modell</h2>
<table><tbody id="storageRows"></tbody></table>
</div>
</div>
<div class="card" id="persCard" style="display:none">
<h2>Mit Personal · GuV & Break-even</h2>
<table>
<thead><tr><th>Block</th><th class="n">€ / Monat</th><th class="n">% Umsatz</th></tr></thead>
<tbody id="persRows"></tbody>
</table>
<div class="note" id="breakeven"></div>
</div>
<div class="card">
<h2>Preislogik & Quellen</h2>
<details open>
<summary>Pricing-Basis</summary>
<div class="src">
Starter 199 €/Monat bzw. 179 € Jahres-SEPA, inkl. 2 Büro + 5 Baustelle, Zusatznutzer 59 €/25 €.
Team 499 €/Monat bzw. 449 € Jahres-SEPA, inkl. 5 Büro + 15 Baustelle, Zusatznutzer 39 €/19 €.
Business 999 €/Monat bzw. 899 € Jahres-SEPA, inkl. 10 Büro + 30 Baustelle, Zusatznutzer 35 €/15 €.
Bei Jahres-SEPA werden Zusatznutzer mit 10 % Vorteil modelliert.
</div>
</details>
<details open>
<summary>Mollie-Gebühren</summary>
<div class="src">
Mollie Pricing, geprüft am 07.07.2026: Visa/Mastercard EEA Consumer Cards 1,80 % + 0,25 €, American Express 2,90 % + 0,25 €, SEPA Direct Debit 0,35 € je erfolgreicher Transaktion. Commercial Cards können höher liegen. Quelle:
<a href="https://www.mollie.com/pricing">mollie.com/pricing</a>.
</div>
</details>
<details open>
<summary>Infra- und Personalkosten</summary>
<div class="src">
HiveGrid Prod läuft aktuell in einem separaten Hetzner-Account mit 5 VMs: control/web/api/db jeweils CX33 und worker+services CX43, plus 50 GB DB-Volume. Der Rechner nutzt jetzt ein dynamisches Kapazitätsmodell statt fixer Stufen: Web-Knoten ca. 10.000 Kapazitätsnutzer je CX43, API ca. 12.000 je CX43, Dedicated Worker ca. 25.000 je AX42, Service-Knoten ca. 40.000 je CX53, Redis ca. 50.000 je CPX42 und DB-Shards ca. 50.000 je Shard mit Primary+Standby. Der eingestellte Kapazitäts-Puffer wird auf die Zielnutzer aufgeschlagen. Hetzner-API-Preise für fsn1, geprüft am 07.07.2026: CX33 8,49 €, CX43 15,99 €, CX53 29,49 €, CPX42 69,49 €, CPX52 100,49 €, CPX62 129,99 €, LB11 7,49 €, LB21 21,49 €, LB31 42,99 €, DB-Volume 0,0572 €/GB/Monat. AX42 wird konservativ mit 97,30 €, AX102 mit 189 € als Planwert angesetzt; Dedicated-Angebote schwanken und müssen vor Kauf gegengeprüft werden.
Hetzner Object Storage wird mit 6,49 € Basispreis, ca. 1 TB Storage und 1 TB Egress inklusive, zusätzlichem Storage ca. 6,49 €/TB/Monat und zusätzlichem Egress 1 €/TB modelliert. Object Storage bleibt separat vom Compute-Szenario, weil der Verbrauch durch verkaufte Speicherpakete entsteht.
Personal: Arbeitgeberanteil ist als Zuschlag auf Brutto parametrisierbar, Default 22 %. Das orientiert sich an deutschen Arbeitgeber-Sozialabgaben von grob 21-23 % auf das Bruttogehalt. Netto-Umrechnung ist eine grobe Planungsrechnung, keine Payroll.
</div>
</details>
</div>
</div>
</div>
<script>
const PLANS = {
starter: { label:'Starter', monthly:199, sepa:179, incOffice:2, incSite:5, extraOffice:59, extraSite:25, storage:100 },
team: { label:'Team', monthly:499, sepa:449, incOffice:5, incSite:15, extraOffice:39, extraSite:19, storage:250 },
business: { label:'Business', monthly:999, sepa:899, incOffice:10, incSite:30, extraOffice:35, extraSite:15, storage:500 }
};
const EMAIL = {
s:{label:'Email Marketing S', volume:'bis 10.000', price:59, cost:25},
m:{label:'Email Marketing M', volume:'10.001-50.000', price:149, cost:50},
l:{label:'Email Marketing L', volume:'50.001-100.000', price:249, cost:95},
xl:{label:'Email Marketing XL', volume:'100.001-250.000', price:499, cost:230},
xxl:{label:'Email Marketing XXL', volume:'250.001-500.000', price:899, cost:430}
};
const STORAGE = {
100:{label:'+100 GB', gb:100, price:5},
500:{label:'+500 GB', gb:500, price:15},
1000:{label:'+1 TB', gb:1000, price:25},
5000:{label:'+5 TB', gb:5000, price:99},
10000:{label:'+10 TB', gb:10000, price:179}
};
const PRICE = {
cx33:8.49, cx43:15.99, cx53:29.49,
cpx22:19.49, cpx42:69.49, cpx52:100.49, cpx62:129.99,
lb11:7.49, lb21:21.49, lb31:42.99,
volumeGB:0.0572,
ax42:97.30, ax102:189
};
const CAPACITY = {
webCx43Users:10000,
apiCx43Users:12000,
workerAx42Users:25000,
workerBurstCx43Users:50000,
serviceCx53Users:40000,
redisCpx42Users:50000,
dbShardUsers:50000,
dbGbPerUser:0.03
};
const COL = {infra:'#168247', storage:'#a66f13', mailjet:'#7557b8', payment:'#cf6a2c', misc:'#94a3b8', dev:'#bd3030', sales:'#0f7b93', support:'#315da8', margin:'#168247'};
const $ = id => document.getElementById(id);
const eur = n => (n).toLocaleString('de-DE',{style:'currency',currency:'EUR',minimumFractionDigits:n<100&&n!==Math.round(n)?2:0,maximumFractionDigits:2});
const eur0 = n => Math.round(n).toLocaleString('de-DE',{style:'currency',currency:'EUR',maximumFractionDigits:0});
const pct = n => Number.isFinite(n) ? (n*100).toLocaleString('de-DE',{maximumFractionDigits:1})+' %' : '0 %';
const num = n => Math.round(n).toLocaleString('de-DE');
const gb = n => n>=1000 ? (n/1000).toLocaleString('de-DE',{maximumFractionDigits:1})+' TB' : Math.round(n).toLocaleString('de-DE')+' GB';
let lastPay = 'paySepa';
function val(id){ return +$(id).value || 0; }
function sepaExtra(v){ return v * 0.9; }
function planRevenueMonthly(p, eo, es){ return p.monthly + eo*p.extraOffice + es*p.extraSite; }
function planRevenueSepa(p, eo, es){ return p.sepa + eo*sepaExtra(p.extraOffice) + es*sepaExtra(p.extraSite); }
function ceilDiv(a,b){ return Math.ceil(a/b); }
function infraBreakdown(users){
const buffer = val('scaleBuffer') / 100;
const capacityUsers = Math.ceil(users * (1 + buffer));
const activeSessions = Math.ceil(capacityUsers * 0.08);
const components = [];
components.push(['Control · CX33', 'Coolify, Bastion, Traefik', PRICE.cx33]);
const webNodes = users <= 1000 ? 1 : Math.max(2, ceilDiv(capacityUsers, CAPACITY.webCx43Users));
const apiNodes = users <= 1000 ? 1 : Math.max(2, ceilDiv(capacityUsers, CAPACITY.apiCx43Users));
components.push([`Web · ${webNodes}x CX43`, `${num(CAPACITY.webCx43Users)} Nutzer Kapazität je Knoten`, webNodes * PRICE.cx43]);
components.push([`API · ${apiNodes}x CX43`, `${num(CAPACITY.apiCx43Users)} Nutzer Kapazität je Knoten`, apiNodes * PRICE.cx43]);
const serviceNodes = Math.max(1, ceilDiv(capacityUsers, CAPACITY.serviceCx53Users));
components.push([`Search/AV/Captcha · ${serviceNodes}x CX53`, 'Meilisearch, ClamAV, mCaptcha getrennt von Worker-Last', serviceNodes * PRICE.cx53]);
let redisNodes = 0;
if(capacityUsers > 1000) redisNodes = capacityUsers <= 20000 ? 1 : Math.max(3, ceilDiv(capacityUsers, CAPACITY.redisCpx42Users));
if(redisNodes > 0) components.push([`Redis · ${redisNodes}x CPX42`, 'Queues, Sessions, Cache; ab Scale als Cluster/Sentinel gedacht', redisNodes * PRICE.cpx42]);
if(capacityUsers <= 1000){
components.push(['DB · CPX42', 'Postgres, PgBouncer und Redis gemeinsam', PRICE.cpx42]);
}else if(capacityUsers <= 3000){
components.push(['DB · CPX62', 'Postgres + PgBouncer, Redis getrennt bei Bedarf', PRICE.cpx62]);
}else{
const dbShards = ceilDiv(capacityUsers, CAPACITY.dbShardUsers);
const dbNodes = dbShards * 2;
components.push([`DB · ${dbShards} Shards / ${dbNodes}x AX102`, 'je Shard Primary + Standby, Tenant-Sharding ab Scale', dbNodes * PRICE.ax102]);
}
const workerDedicated = capacityUsers <= 1000 ? 0 : Math.max(1, ceilDiv(capacityUsers, CAPACITY.workerAx42Users));
if(workerDedicated === 0) components.push(['Worker Light · CX43', 'Queues und Hintergrundjobs auf kleiner Stufe', PRICE.cx43]);
if(workerDedicated > 0) components.push([`Worker · ${workerDedicated}x AX42`, `${num(CAPACITY.workerAx42Users)} Nutzer Kapazität je Worker-Server`, workerDedicated * PRICE.ax42]);
const workerBurst = users > 3000 ? ceilDiv(capacityUsers, CAPACITY.workerBurstCx43Users) : 0;
if(workerBurst > 0) components.push([`Worker Burst · ${workerBurst}x CX43`, 'Import/Webhook/Plugin-Spitzen getrennt von Haupt-Workern', workerBurst * PRICE.cx43]);
const lbTargets = webNodes + apiNodes;
const lb = activeSessions > 20000 || lbTargets > 75 ? ['Load Balancer · LB31', `${num(activeSessions)} geplante aktive Sessions, ${lbTargets} Targets`, PRICE.lb31]
: activeSessions > 10000 || lbTargets > 25 ? ['Load Balancer · LB21', `${num(activeSessions)} geplante aktive Sessions, ${lbTargets} Targets`, PRICE.lb21]
: users > 500 ? ['Load Balancer · LB11', `${num(activeSessions)} geplante aktive Sessions, ${lbTargets} Targets`, PRICE.lb11]
: null;
if(lb) components.push(lb);
const dbVolumeGb = Math.max(100, Math.ceil(capacityUsers * CAPACITY.dbGbPerUser / 100) * 100);
components.push([`DB-Backup/Volume-Reserve · ${gb(dbVolumeGb)}`, `${Math.round(CAPACITY.dbGbPerUser * 1000)} MB DB-/Index-Reserve je Kapazitätsnutzer`, dbVolumeGb * PRICE.volumeGB]);
const minEstate = Math.ceil(capacityUsers / 10000) * 10;
const estate = Math.max(val('estateShare'), minEstate);
if(estate > 0) components.push(['Estate-/Ops-Anteil', 'anteilige Sentry/Grafana/Gitea/Monitoring-/Admin-Overheads', estate]);
const total = components.reduce((sum, component) => sum + component[2], 0);
return {
label: `${num(users)} Nutzerziel`,
cap: `${num(capacityUsers)} Nutzer Kapazität inkl. ${Math.round(buffer*100)} % Puffer`,
note: `Kapazitätsmodell: ${num(users)} Zielnutzer werden mit ${num(capacityUsers)} Kapazitätsnutzern geplant. Daraus folgen ${webNodes} Web-, ${apiNodes} API-, ${workerDedicated} Dedicated-Worker-Knoten, ${serviceNodes} Service-Knoten und ${activeSessions} geplante aktive Sessions.`,
components,
total,
capacityUsers,
activeSessions
};
}
function objectStorageCost(actualGB, egressFactor){
if(actualGB<=0) return 0;
const storageTB = actualGB/1000;
const egressTB = storageTB*egressFactor;
return 6.49 + Math.max(0, storageTB-1)*6.49 + Math.max(0, egressTB-1)*1;
}
function annualIncomeTaxApprox(taxable){
if(taxable <= 12348) return 0;
if(taxable <= 17799){
const x = taxable - 12348;
const avgRate = 0.14 + (0.24 - 0.14) * (x / (17799 - 12348)) / 2;
return x * avgRate;
}
if(taxable <= 69879){
const base = annualIncomeTaxApprox(17799);
const x = taxable - 17799;
const avgRate = 0.24 + (0.42 - 0.24) * (x / (69879 - 17799)) / 2;
return base + x * avgRate;
}
if(taxable <= 277826) return annualIncomeTaxApprox(69879) + (taxable - 69879) * 0.42;
return annualIncomeTaxApprox(277826) + (taxable - 277826) * 0.45;
}
function employeeSocialAnnual(grossMonthly){
const annual = grossMonthly * 12;
const healthBase = Math.min(annual, 69750);
const pensionBase = Math.min(annual, 96600);
return healthBase * (0.073 + 0.0145 + 0.018) + pensionBase * (0.093 + 0.013);
}
function netFromGross(grossMonthly){
const annual = grossMonthly * 12;
const social = employeeSocialAnnual(grossMonthly);
const taxable = Math.max(0, annual - social - 1230);
const tax = annualIncomeTaxApprox(taxable);
return (annual - social - tax) / 12;
}
function grossFromNet(targetNet){
let lo = targetNet, hi = targetNet * 3 + 2000;
for(let i=0;i<40;i++){
const mid = (lo + hi) / 2;
if(netFromGross(mid) < targetNet) lo = mid; else hi = mid;
}
return hi;
}
function salaryUnitCost(mode, amount, employerRate){
if(mode === 'cost') return { gross: amount / (1 + employerRate), net: netFromGross(amount / (1 + employerRate)), cost: amount };
const gross = mode === 'net' ? grossFromNet(amount) : amount;
return { gross, net: netFromGross(gross), cost: gross * (1 + employerRate) };
}
function roleCost(role, countId, payId, modeId, noteId, employerRate){
const count = val(countId);
const amount = val(payId);
const mode = $(modeId).value;
const unit = salaryUnitCost(mode, amount, employerRate);
$(noteId).textContent = `${mode === 'net' ? 'Netto-Ziel' : mode === 'gross' ? 'Brutto' : 'AG-Kosten'} ${eur(amount)} → ca. ${eur(unit.gross)} brutto, ${eur(unit.net)} netto, ${eur(unit.cost)} Arbeitgeberkosten je ${role}.`;
return { count, unit, total: count * unit.cost };
}
function syncPayment(changed){
const ids = ['paySepa','payVisa','payAmex'];
let changedVal = Math.max(0, Math.min(100, val(changed)));
$(changed).value = changedVal;
const others = ids.filter(id => id !== changed);
const remaining = 100 - changedVal;
const otherSum = others.reduce((s,id)=>s+val(id),0);
if(otherSum <= 0){
others.forEach(id => $(id).value = remaining / 2);
}else{
others.forEach(id => $(id).value = val(id) / otherSum * remaining);
}
}
function paymentMix(){
const total = val('paySepa') + val('payVisa') + val('payAmex') || 1;
return { sepa: val('paySepa')/total, visa: val('payVisa')/total, amex: val('payAmex')/total };
}
function planInputsFromMode(){
if($('scaleMode').value !== 'target'){
return [
['starter', val('starterCount'), val('starterExtraOffice'), val('starterExtraSite')],
['team', val('teamCount'), val('teamExtraOffice'), val('teamExtraSite')],
['business', val('businessCount'), val('businessExtraOffice'), val('businessExtraSite')]
];
}
const targetUsers = Math.max(1, val('scaleUsers'));
const avgUsersInput = Math.max(1, val('scaleAvgUsers'));
const planKey = $('scalePlan').value;
const p = PLANS[planKey];
const includedUsers = p.incOffice + p.incSite;
const avgUsers = Math.max(avgUsersInput, includedUsers);
const tenants = Math.ceil(targetUsers / avgUsers);
const extraUsers = Math.max(0, avgUsers - includedUsers);
const extraOffice = +(extraUsers * 0.25).toFixed(2);
const extraSite = +(extraUsers - extraOffice).toFixed(2);
const inputs = [['starter',0,0,0], ['team',0,0,0], ['business',0,0,0]];
const idx = planKey === 'starter' ? 0 : planKey === 'team' ? 1 : 2;
inputs[idx] = [planKey, tenants, extraOffice, extraSite];
return inputs;
}
function compute(){
const mix = paymentMix();
$('o_paySepa').textContent = pct(mix.sepa);
$('o_payVisa').textContent = pct(mix.visa);
$('o_payAmex').textContent = pct(mix.amex);
$('o_emailAttach').textContent = $('emailAttach').value+' %';
$('o_storageAttach').textContent = $('storageAttach').value+' %';
$('o_storageUse').textContent = $('storageUse').value+' %';
$('o_egress').textContent = (+$('egressFactor').value).toLocaleString('de-DE',{maximumFractionDigits:1})+' TB/TB';
$('o_scaleBuffer').textContent = $('scaleBuffer').value+' %';
$('o_estateShare').textContent = eur0(val('estateShare'));
$('o_employerRate').textContent = $('employerRate').value+' %';
const targetMode = $('scaleMode').value === 'target';
$('targetMixBox').style.display = targetMode ? 'block' : 'none';
$('manualMixBox').style.display = targetMode ? 'none' : 'block';
const targetUsers = Math.max(1, val('scaleUsers'));
const avgUsersInput = Math.max(1, val('scaleAvgUsers'));
const selectedPlan = PLANS[$('scalePlan').value];
const avgUsers = targetMode ? Math.max(avgUsersInput, selectedPlan.incOffice + selectedPlan.incSite) : avgUsersInput;
const targetTenants = Math.ceil(targetUsers / avgUsers);
const planInputs = planInputsFromMode();
const email = EMAIL[$('emailTier').value];
const storageAddon = STORAGE[$('storageAddon').value];
const emailAttach = val('emailAttach')/100;
const storageAttach = val('storageAttach')/100;
const storageUse = val('storageUse')/100;
const egressFactor = val('egressFactor');
let totalTenants = 0, totalUsers = 0, totalExtraUsers = 0, coreMRR = 0, monthlyListBasis = 0, sepaBasis = 0, storagePoolGB = 0;
const rows = [];
for(const [key,count,eo,es] of planInputs){
const p = PLANS[key];
const usersPerTenant = p.incOffice + p.incSite + eo + es;
const extraUsers = (eo + es) * count;
const monthlyTenant = planRevenueMonthly(p, eo, es);
const sepaTenant = planRevenueSepa(p, eo, es);
const weightedTenant = mix.sepa*sepaTenant + (mix.visa+mix.amex)*monthlyTenant;
const mrr = weightedTenant * count;
totalTenants += count;
totalUsers += usersPerTenant * count;
totalExtraUsers += extraUsers;
coreMRR += mrr;
monthlyListBasis += monthlyTenant * count;
sepaBasis += sepaTenant * count;
storagePoolGB += count * (p.storage + eo*20 + es*10);
rows.push({ key, p, count, users:usersPerTenant*count, extraUsers, mrr, avg: count ? mrr/count : 0 });
}
const emailTenants = totalTenants * emailAttach;
const storageTenants = totalTenants * storageAttach;
const emailRevenue = emailTenants * email.price;
const emailCost = emailTenants * email.cost;
const storageRevenue = storageTenants * storageAddon.price;
storagePoolGB += storageTenants * storageAddon.gb;
const MRR = coreMRR + emailRevenue + storageRevenue;
const avgAddonRevenue = totalTenants ? (emailRevenue + storageRevenue) / totalTenants : 0;
const sepaFee = totalTenants * mix.sepa * (0.35/12);
const visaFee = (monthlyListBasis / Math.max(totalTenants,1) + avgAddonRevenue) * totalTenants * mix.visa * 0.018 + totalTenants * mix.visa * 0.25;
const amexFee = (monthlyListBasis / Math.max(totalTenants,1) + avgAddonRevenue) * totalTenants * mix.amex * 0.029 + totalTenants * mix.amex * 0.25;
const paymentCost = sepaFee + visaFee + amexFee;
if(targetMode) totalUsers = targetUsers;
const actualStorageGB = storagePoolGB * storageUse;
const storageCost = objectStorageCost(actualStorageGB, egressFactor);
const infra = infraBreakdown(totalUsers);
const hosting = infra.total;
const misc = totalTenants * 0.75;
const opex = hosting + storageCost + emailCost + paymentCost + misc;
const gross = MRR - opex;
const derivedExtraUsers = targetMode ? Math.max(0, avgUsers - (selectedPlan.incOffice + selectedPlan.incSite)) : 0;
$('targetPreview').innerHTML = targetMode ? [
['Betriebe', num(targetTenants)],
['Paket', PLANS[$('scalePlan').value].label],
['Extra-User/Betrieb', derivedExtraUsers.toLocaleString('de-DE',{maximumFractionDigits:1})]
].map(([label,value]) => `<div><span>${label}</span><b>${value}</b></div>`).join('') : '';
$('scaleModeNote').innerHTML = targetMode
? `${num(targetUsers)} Nutzer bei Ø ${avgUsers.toLocaleString('de-DE',{maximumFractionDigits:1})} je Betrieb ergeben <b>${num(targetTenants)} ${selectedPlan.label}-Betriebe</b>.`
: `Manueller Modus aktiv: Betriebe und Extra-User kommen ausschließlich aus der Tabelle.`;
$('infraNote').innerHTML = `Für diese Rechnung werden <b>${infra.cap}</b> geplant. Compute/Infra: <b>${eur0(infra.total)}</b> vor Object Storage.`;
const persOn = $('persOn').checked;
$('persBox').style.display = persOn ? 'block' : 'none';
let personnel = 0, persParts = [];
if(persOn){
const employerRate = val('employerRate') / 100;
const dev = roleCost('Entwickler', 'dev', 'devpay', 'devMode', 'devCostNote', employerRate);
const sales = roleCost('Vertriebler', 'sales', 'salespay', 'salesMode', 'salesCostNote', employerRate);
const support = roleCost('Support', 'supp', 'supppay', 'suppMode', 'suppCostNote', employerRate);
personnel = dev.total + sales.total + support.total;
persParts = [['Entwicklung',dev.total,COL.dev],['Vertrieb',sales.total,COL.sales],['Support',support.total,COL.support]];
$('o_dev').textContent = val('dev'); $('o_devpay').textContent = eur0(val('devpay'));
$('o_sales').textContent = val('sales'); $('o_salespay').textContent = eur0(val('salespay'));
$('o_supp').textContent = val('supp'); $('o_supppay').textContent = eur0(val('supppay'));
}
const net = gross - personnel;
const visibleRows = rows.filter(r => r.count > 0);
const mixLabel = visibleRows.length ? visibleRows.map(r=>`${num(r.count)} ${r.p.label}`).join(' · ') : 'kein aktiver Paketmix';
$('summaryLead').innerHTML = `<strong>${num(totalTenants)} Betriebe</strong> (${mixLabel}) ergeben ${eur0(MRR)} MRR. Es sind ${num(totalUsers)} Nutzer modelliert, davon ${num(totalExtraUsers)} Extra-User.`;
$('kpi_mrr').textContent = eur0(MRR); $('kpi_arr').textContent = eur0(MRR*12)+' ARR';
$('kpi_tenants').textContent = num(totalTenants); $('kpi_users').textContent = `${num(totalUsers)} Nutzer · Ø ${totalTenants ? (totalUsers/totalTenants).toLocaleString('de-DE',{maximumFractionDigits:1}) : 0} je Betrieb`;
$('kpi_opex').textContent = eur0(opex); $('kpi_opexpct').textContent = pct(opex/MRR)+' vom Umsatz';
$('kpi_marginLabel').textContent = persOn ? 'Marge nach Personal' : 'Rohmarge';
$('kpi_margin').textContent = eur0(persOn ? net : gross); $('kpi_margin').className = 'v '+((persOn ? net : gross)>=0?'pos':'neg');
$('kpi_marginpct').textContent = pct((persOn ? net : gross)/MRR);
$('planRows').innerHTML = (visibleRows.length ? visibleRows : rows).map(r => `<tr><td><b>${r.p.label}</b></td><td class="n">${num(r.count)}</td><td class="n">${num(r.users)}</td><td class="n">${num(r.extraUsers)}</td><td class="n">${eur0(r.mrr)}</td><td class="n">${eur(r.avg)}</td></tr>`).join('')
+ `<tr class="sum"><td>Core gesamt</td><td class="n">${num(totalTenants)}</td><td class="n">${num(totalUsers)}</td><td class="n">${num(totalExtraUsers)}</td><td class="n">${eur0(coreMRR)}</td><td class="n">${eur(totalTenants?coreMRR/totalTenants:0)}</td></tr>`;
$('paymentRows').innerHTML = [
['SEPA Jahreszahlung', mix.sepa, sepaBasis*mix.sepa, '0,35 € / Jahr', sepaFee],
['Visa/Mastercard', mix.visa, monthlyListBasis*mix.visa, '1,80 % + 0,25 €', visaFee],
['American Express', mix.amex, monthlyListBasis*mix.amex, '2,90 % + 0,25 €', amexFee]
].map(r => `<tr><td>${r[0]}</td><td class="n">${pct(r[1])}</td><td class="n">${eur0(r[2])}</td><td class="n">${r[3]}</td><td class="n">${eur(r[4])}</td></tr>`).join('')
+ `<tr class="sum"><td>Payment gesamt</td><td class="n">100 %</td><td class="n">${eur0(MRR)}</td><td></td><td class="n">${eur(paymentCost)}</td></tr>`;
$('infraRows').innerHTML = infra.components.map(r => `<tr><td><b>${r[0]}</b></td><td>${r[1]}</td><td class="n">${eur(r[2])}</td></tr>`).join('')
+ `<tr class="sum"><td>Compute/Infra gesamt</td><td>${infra.cap}</td><td class="n">${eur0(hosting)}</td></tr>`;
$('infraScaleNote').innerHTML = `${infra.note} Object Storage kommt darunter separat dazu: aktuell ${eur(storageCost)} bei ${gb(actualStorageGB)} angenommener Nutzung.`;
const opexRows = [['Compute/Infra',hosting,COL.infra],['Object Storage',storageCost,COL.storage],['Mailjet',emailCost,COL.mailjet],['Mollie',paymentCost,COL.payment],['Monitoring / Kleinkram',misc,COL.misc]];
$('opexRows').innerHTML = opexRows.map(r => `<tr><td>${r[0]}</td><td class="n">${eur0(r[1])}</td><td class="n">${pct(r[1]/MRR)}</td><td class="n">${(r[1]/MRR*100).toLocaleString('de-DE',{maximumFractionDigits:2})}</td></tr>`).join('')
+ `<tr class="sum"><td>Betrieb gesamt</td><td class="n">${eur0(opex)}</td><td class="n">${pct(opex/MRR)}</td><td class="n">${(opex/MRR*100).toLocaleString('de-DE',{maximumFractionDigits:2})}</td></tr>`;
$('addonRows').innerHTML = [
['Email-Marketing Betriebe', num(emailTenants)],
['Email-Marketing Umsatz', eur0(emailRevenue)],
['Mailjet-Kosten', eur0(emailCost)],
['Email-Marketing Rohertrag', eur0(emailRevenue-emailCost)],
['Speicher-Add-on Betriebe', num(storageTenants)],
['Speicher-Add-on Umsatz', eur0(storageRevenue)]
].map(r=>`<tr><td>${r[0]}</td><td class="n">${r[1]}</td></tr>`).join('');
$('storageRows').innerHTML = [
['Verkaufter Speicherpool', gb(storagePoolGB)],
['Angenommene Nutzung', gb(actualStorageGB)],
['Object-Storage-Kosten', eur(storageCost)],
['Speicher-Add-on Umsatz', eur0(storageRevenue)],
['Speicher Rohertrag', eur0(storageRevenue-storageCost)]
].map(r=>`<tr><td>${r[0]}</td><td class="n">${r[1]}</td></tr>`).join('');
let split = opexRows.slice();
if(persOn) split = split.concat(persParts);
split.push([persOn?'Marge':'Rohmarge', Math.max(0, persOn?net:gross), COL.margin]);
const denom = split.reduce((s,p)=>s+p[1],0) || 1;
$('splitbar').innerHTML = split.filter(p=>p[1]>0).map(([label,value,color]) => {
const width = value/denom*100, cents = value/MRR*100;
return `<span style="width:${width}%;background:${color}" title="${label}: ${pct(value/MRR)}">${width>7?Math.round(cents)+'¢':''}</span>`;
}).join('');
$('splitlegend').innerHTML = split.filter(p=>p[1]>0).map(([label,value,color])=>`<span><i style="background:${color}"></i>${label} ${Math.round(value/MRR*100)}¢ (${pct(value/MRR)})</span>`).join('')
+ (persOn && net<0 ? ` <span class="neg">Verlust ${eur0(net)}</span>` : '');
$('persCard').style.display = persOn ? 'block' : 'none';
if(persOn){
$('persRows').innerHTML = [['Umsatz',MRR],['Betrieb',-opex],...persParts.map(p=>[p[0],-p[1]]),['Ergebnis',net]].map((r,i,arr)=>`<tr class="${i===arr.length-1?'sum':''}"><td>${i>0&&i<arr.length-1?'– ':''}${r[0]}</td><td class="n ${i===arr.length-1?(r[1]>=0?'pos':'neg'):''}">${eur0(r[1])}</td><td class="n">${pct(r[1]/MRR)}</td></tr>`).join('');
const variablePerTenant = totalTenants ? (opex-hosting)/totalTenants : 0;
const revenuePerTenant = totalTenants ? MRR/totalTenants : 0;
const contribution = revenuePerTenant - variablePerTenant;
const be = contribution>0 ? Math.ceil((personnel+hosting)/contribution) : Infinity;
$('breakeven').innerHTML = Number.isFinite(be) ? `<b>Break-even:</b> ca. <b>${be} Betriebe</b> bei ${eur(contribution)} Deckungsbeitrag je Betrieb. Aktuell ${num(totalTenants)} Betriebe: ${totalTenants>=be?`<span class="pos">${num(totalTenants-be)} über Break-even</span>`:`<span class="neg">${num(be-totalTenants)} fehlen</span>`}.` : 'Deckungsbeitrag ist negativ.';
}
$('paymentNote').innerHTML = `Mix wird automatisch auf 100 % gehalten. SEPA nutzt Jahres-SEPA-Preise, Karten nutzen Listenpreise. Aktueller Payment-Kostensatz: <b>${pct(paymentCost/MRR)}</b>.`;
}
function wire(){
document.querySelectorAll('input,select').forEach(el => {
el.addEventListener('input', e => {
if(['paySepa','payVisa','payAmex'].includes(e.target.id)){
lastPay = e.target.id;
syncPayment(lastPay);
}
compute();
});
});
$('persOn').addEventListener('change', compute);
}
syncPayment('paySepa');
wire();
compute();
</script>
</body>
</html>
@@ -60,7 +60,7 @@
<body>
<header>
<h1>HiveGrid · Kostenmodell &amp; Unit Economics <span class="pill">Stand 22.06.2026</span></h1>
<p>Reiner Betrieb (Hosting · Mailjet · Stripe), kein eigenes KI-Token-Risiko (MCP-Server = Kunde bringt eigenes Modell). Personal optional zuschaltbar. Alle Hebel live drehbar.</p>
<p>Reiner Betrieb (Hosting · Mailjet · Zahlung), kein eigenes KI-Token-Risiko (MCP-Server = Kunde bringt eigenes Modell). Personal optional zuschaltbar. Alle Hebel live drehbar.</p>
</header>
<div class="wrap">
@@ -89,11 +89,16 @@
<label class="row"><span class="lab">Mailjet-Preis je Mail <span class="val" id="o_mailrate"></span></span>
<input type="range" id="mailrate" min="0.0005" max="0.0015" step="0.0001" value="0.0008"></label>
<h3>Zahlungsart (Stripe)</h3>
<div class="seg" id="seg_pay">
<button data-pay="card" class="on">Karte · 1,5 % + 0,25 €</button>
<button data-pay="sepa">SEPA-Lastschrift · 0,8 % (max 5 €)</button>
<h3>Zahlung</h3>
<div class="seg" id="seg_prov">
<button data-prov="legacyProvider" class="on">Legacy-Provider</button>
<button data-prov="adyen">Adyen</button>
</div>
<div class="seg" id="seg_meth" style="margin-top:8px">
<button data-meth="card" class="on">Visa / Mastercard</button>
<button data-meth="sepa">SEPA-Lastschrift</button>
</div>
<div class="note" id="payNote"></div>
<h3>Infra-Stufe</h3>
<div class="seg" id="seg_infra">
@@ -176,9 +181,11 @@
Stufen (Engineering-Schätzung, vor Lasttest): Start 56 € (≤~1.000 Seats) · Wachstum 120 € (≤~2.500) · Skalierung 450 € (≤~6.000).
</div>
</details>
<details><summary>Stripe &amp; Mailjet</summary>
<details><summary>Zahlungsgebühren (Legacy-Provider vs. Adyen) &amp; Mailjet</summary>
<div class="src" style="margin-top:8px">
Stripe EU: Karte <b>1,5 % + 0,25 €</b> je Buchung · SEPA-Lastschrift <b>0,8 % gedeckelt 5 €</b> (für DE-Handwerk das natürliche Zahlungsmittel → halbiert die größte Betriebszeile).
<b>Legacy-Provider</b> (inkl. Billing 0,7 %): Karte 2,2 % + 0,25 € · SEPA 1,5 % + 0,30 €.
<b>Adyen</b> (Interchange++): Karte ~0,4 % + 0,11 € · SEPA ~0,12 € flat. Minimum ~120 € dokumentiert, praktisch <b>~1.000 €+/Monat</b> bei Direktvertrag; <b>keine native Subscription-Billing/Customer-Portal-Schicht</b> (selbst bauen oder Chargebee/Recurly dazu — oft wieder US). Lohnt erst ab ~300 k €/Monat Processing.
EEA-Standardkarten unterstellt; Firmen-Kreditkarten haben höhere, unregulierte Interchange (verteuert beide Anbieter).
Mailjet ~€0,0006–0,001/Mail je Aggregat-Volumen.
</div>
</details>
@@ -189,20 +196,25 @@
<a href="https://mailchimp.com/resources/email-marketing-benchmarks/">Mailchimp E-Mail-Benchmarks</a> ·
<a href="https://www.hetzner.com/cloud/">Hetzner Cloud Preise</a> ·
<a href="https://www.mailjet.com/pricing/">Mailjet Preise</a> ·
Stripe DE Standard-Gebühren.
Legacy-Provider DE Standard-Gebühren.
</div>
</div>
</div>
</div>
<script>
const COL = {hosting:'#3b82f6',mailjet:'#f5a623',stripe:'#8b5cf6',misc:'#94a3b8',dev:'#d23b3b',sales:'#0ea5e9',support:'#1f9d57',marge:'#16a34a'};
const COL = {hosting:'#3b82f6',mailjet:'#f5a623',legacyProvider:'#8b5cf6',misc:'#94a3b8',dev:'#d23b3b',sales:'#0ea5e9',support:'#1f9d57',marge:'#16a34a'};
const $ = id => document.getElementById(id);
const eur = n => '€'+(n).toLocaleString('de-DE',{minimumFractionDigits:n<100&&n!==Math.round(n)?2:0,maximumFractionDigits:2});
const eur0 = n => '€'+Math.round(n).toLocaleString('de-DE');
const pct = n => (n*100).toLocaleString('de-DE',{maximumFractionDigits:1})+' %';
let pay='card', infra='auto';
let prov='legacyProvider', meth='card', infra='auto';
const ADYEN_FLOOR=1000;
function feePerTenant(charge){
if(prov==='legacyProvider') return meth==='card' ? charge*0.022+0.25 : charge*0.015+0.30; // inkl. Billing 0,7 %
return meth==='card' ? charge*0.004+0.11 : 0.12; // Adyen IC++
}
function autoInfra(seatsTotal){
if(seatsTotal<=1000) return 56;
@@ -231,15 +243,21 @@ function compute(){
// mailjet
const mailjet = mails*tenants*mailrate;
// stripe (per tenant monthly charge)
// payment fees (per tenant charge → provider floor)
const charge = price*seats;
const stripePerT = pay==='card' ? charge*0.015+0.25 : Math.min(charge*0.008,5);
const stripe = stripePerT*tenants;
const feePerT = feePerTenant(charge);
let payfee = feePerT*tenants, floorOn=false;
if(prov==='adyen' && payfee<ADYEN_FLOOR){ payfee=ADYEN_FLOOR; floorOn=true; }
const rateTxt = prov==='legacyProvider'
? (meth==='card'?'2,2 % + 0,25 € (inkl. Billing 0,7 %)':'1,5 % + 0,30 € (inkl. Billing 0,7 %)')
: (meth==='card'?'~0,4 % + 0,11 € (IC++)':'~0,12 € flat (IC++)');
$('payNote').innerHTML = `${prov==='legacyProvider'?'Legacy-Provider':'Adyen'} · ${rateTxt} → ${eur(feePerT)}/Mandant`
+ (prov==='adyen' ? ` · Minimum ${eur0(ADYEN_FLOOR)}/Mo${floorOn?` <b>(greift — Nutzung wäre nur ${eur(feePerT*tenants)})</b>`:''}` : '');
// misc
const misc = tenants*0.5;
const opex = hosting+mailjet+stripe+misc;
const opex = hosting+mailjet+payfee+misc;
const gross = MRR-opex;
// KPIs
@@ -249,11 +267,13 @@ function compute(){
$('kpi_opexpct').textContent=pct(opex/MRR)+' vom Umsatz';
// opex table
const rows=[['Hosting',hosting,COL.hosting],['Mailjet',mailjet,COL.mailjet],['Stripe-Gebühren',stripe,COL.stripe],['Geo-IP / Kleinkram',misc,COL.misc]];
const rows=[['Hosting',hosting,COL.hosting],['Mailjet',mailjet,COL.mailjet],['Zahlungsgebühren',payfee,COL.legacyProvider],['Geo-IP / Kleinkram',misc,COL.misc]];
$('opexrows').innerHTML=rows.map(([l,v])=>`<tr><td>${l}</td><td class="n">${eur0(v)}</td><td class="n">${eur(v/tenants)}</td><td class="n">${pct(v/MRR)}</td><td class="n">${(v/MRR*100).toLocaleString('de-DE',{maximumFractionDigits:2})}</td></tr>`).join('')
+`<tr class="sum"><td>Betrieb gesamt</td><td class="n">${eur0(opex)}</td><td class="n">${eur(opex/tenants)}</td><td class="n">${pct(opex/MRR)}</td><td class="n">${(opex/MRR*100).toLocaleString('de-DE',{maximumFractionDigits:2})}</td></tr>`;
const biggest = rows.slice().sort((a,b)=>b[1]-a[1])[0][0];
$('opexnote').innerHTML=`Größter Betriebsposten: <b>${biggest}</b>. ${pay==='card'?'Tipp: SEPA-Lastschrift statt Karte halbiert die Stripe-Zeile.':'SEPA-Lastschrift aktiv → Stripe gedeckelt.'}`;
$('opexnote').innerHTML=`Größter Betriebsposten: <b>${biggest}</b>. `
+(meth==='card'?'Hebel 1: auf <b>SEPA-Lastschrift</b> wechseln. ':'')
+(prov==='legacyProvider'?'Hebel 2: <b>Legacy-Provider → Adyen</b> drückt die Zahlungszeile drastisch.':'Adyen aktiv → Zahlungszeile minimal.');
// personnel
const persOn=$('persOn').checked;
@@ -282,7 +302,7 @@ function compute(){
}
// split bar (cents per euro)
let parts=[['Hosting',hosting,COL.hosting],['Mailjet',mailjet,COL.mailjet],['Stripe',stripe,COL.stripe],['Kleinkram',misc,COL.misc]];
let parts=[['Hosting',hosting,COL.hosting],['Mailjet',mailjet,COL.mailjet],['Zahlung',payfee,COL.legacyProvider],['Kleinkram',misc,COL.misc]];
if(persOn) parts=parts.concat(persParts);
const margeVal=Math.max(net,0);
parts.push([persOn?'Marge':'Rohmarge',margeVal,COL.marge]);
@@ -301,7 +321,7 @@ function compute(){
$('persrows').innerHTML=blocks.map(([l,v])=>`<tr><td>${l}</td><td class="n">${eur0(v)}</td><td class="n">${pct(v/MRR)}</td></tr>`).join('')
+`<tr class="sum"><td>= Ergebnis</td><td class="n ${net>=0?'pos':'neg'}">${eur0(net)}</td><td class="n">${pct(net/MRR)}</td></tr>`;
// break-even: tenants where MRR - variableOpex*t - fixedHosting - personnel = 0
const contrib = charge - stripePerT - mails*mailrate - 0.5; // per tenant
const contrib = charge - feePerT - mails*mailrate - 0.5; // per tenant
const fixed = personnel + hosting;
const be = contrib>0 ? Math.ceil(fixed/contrib) : Infinity;
$('breakeven').innerHTML = isFinite(be)
@@ -314,7 +334,8 @@ function compute(){
['price','seats','tenants','mails','mailrate','persOn','dev','devpay','sales','salespay','supp','supppay']
.forEach(id=>$(id).addEventListener('input',compute));
$('persOn').addEventListener('change',()=>{$('persBox').style.display=$('persOn').checked?'block':'none';compute();});
document.querySelectorAll('#seg_pay button').forEach(b=>b.onclick=()=>{pay=b.dataset.pay;document.querySelectorAll('#seg_pay button').forEach(x=>x.classList.remove('on'));b.classList.add('on');compute();});
document.querySelectorAll('#seg_prov button').forEach(b=>b.onclick=()=>{prov=b.dataset.prov;document.querySelectorAll('#seg_prov button').forEach(x=>x.classList.remove('on'));b.classList.add('on');compute();});
document.querySelectorAll('#seg_meth button').forEach(b=>b.onclick=()=>{meth=b.dataset.meth;document.querySelectorAll('#seg_meth button').forEach(x=>x.classList.remove('on'));b.classList.add('on');compute();});
document.querySelectorAll('#seg_infra button').forEach(b=>b.onclick=()=>{infra=b.dataset.infra;document.querySelectorAll('#seg_infra button').forEach(x=>x.classList.remove('on'));b.classList.add('on');compute();});
document.querySelectorAll('.presets button').forEach(b=>b.onclick=()=>{
const m={klein:[20,5000],mittel:[40,10000],gross:[60,17500]}[b.dataset.size];
@@ -1,162 +0,0 @@
#!/usr/bin/env python3
"""
create_and_fix.py — Phase 2+3 der Review-Plane-Operation. IDEMPOTENT, default DRY-RUN, scharf: --apply
P2: neue David-Screen-Tickets anlegen (+ Cycle + Module David + Doc-Link) — nur was fehlt.
P3: Bündel auflösen (VD-2/3/4 rename→VD-P26/27/29; D-REST/VD-1/VD-5 cancel),
AK-P1c→R-P1c (rename+Owner+Module), Fixes (R-P8a Name, EXT-04 KWP-Nr, R-REFIT→Done).
"""
import json, os, re, sys, time, urllib.request, urllib.error
APPLY = "--apply" in sys.argv
TOK = {}
for line in open(os.path.expanduser("~/.cli-tokens/plane")):
line = line.strip()
if "=" in line and not line.startswith("#"):
k, v = line.split("=", 1); TOK[k.strip()] = v.strip()
BASE = TOK["PLANE_BASE_URL"].rstrip("/"); WS = TOK["PLANE_WORKSPACE"]; KEY = TOK["PLANE_API_KEY"]
PID = "35d0d968-8ab3-40cc-97b8-b81d917dc5df"
API = f"{BASE}/api/v1/workspaces/{WS}/projects/{PID}"
DOC = "https://git.tool-ctrl.de/toolctrl-labs/HiveGrid-Cloud/src/branch/main/.planning/ticket-docs/"
BRF = "https://git.tool-ctrl.de/toolctrl-labs/HiveGrid-Cloud/src/branch/main/.planning/designer-briefs/"
DAVID = "6c1fc63f-133d-4f40-86e4-7f658a6edcc7"; RAFFAEL = "8e34a88c-19c9-4565-b4f5-50415d623ce8"
bk = json.load(open(".planning/plane-sync/plane-backup-review.json"))
M = bk["meta"]
CYC = M["cycles"]; MOD = M["modules"]; ST = M["states"]; LB = M["labels"]
def A(cls, href, txt): return f'<a target="_blank" class="text-accent-secondary underline underline-offset-[3px] hover:text-accent-primary transition-colors cursor-pointer" href="{href}" rel="noopener noreferrer">{txt}</a>'
def http(method, path, body=None):
data = json.dumps(body).encode() if body is not None else None
r = urllib.request.Request(API + path, data=data, method=method)
r.add_header("X-API-Key", KEY)
if data: r.add_header("Content-Type", "application/json")
for _ in range(4):
try:
with urllib.request.urlopen(r, timeout=30) as resp:
raw = resp.read().decode(); return resp.status, (json.loads(raw) if raw else {})
except urllib.error.HTTPError as e:
if e.code == 429: time.sleep(5); continue
return e.code, {}
return 999, {}
def get_all(path):
out, c = [], None
while True:
st, d = http("GET", path + (("?per_page=100&cursor=" + c) if c else "?per_page=100"))
if st >= 400: break
if isinstance(d, list): out += d; break
out += d.get("results", [])
if d.get("next_page_results") and d.get("next_cursor"): c = d["next_cursor"]
else: break
return out
# aktueller Stand → Working-ID → issue
cur = get_all("/issues/")
wid2 = {re.split(r'\s*·\s*', i["name"])[0].strip(): i for i in cur}
def desc(wid, brief, blockedby, blocks, owner, fenster):
parts = ["<div><p><b>📚 Doku</b><br>",
A("", DOC + wid + ".md", f"📄 Ticket-Doku ({wid})"), "<br>"]
if brief: parts += [A("", BRF + brief + ".md", f"🎨 Designer-Brief {brief[:3]}"), "<br>"]
parts += [A("", "https://git.tool-ctrl.de/toolctrl-labs/HiveGrid-Cloud/src/branch/main/.planning/ENTITY-MODEL.md", "ENTITY-MODEL.md"), "</p>"]
parts += [f"<p><b>⛔ blocked-by:</b> {blockedby}<br><b>➡️ blocks:</b> {blocks}<br>",
f"<b>👤 Owner:</b> {owner} &nbsp;·&nbsp; <b>🗓 Fenster:</b> {fenster}</p>",
"<p><b>✅ Screens / Features / DoD:</b> siehe Ticket-Doku (oben).</p></div>"]
return "".join(parts)
# NEW: (wid, titel, brief, cycle, target, v2, blocks)
NEW = [
("D-P2","Screens P2 — Identität & Mandantentrennung","P02-identitaet-mandantentrennung","W1","2026-06-21",0,"R-P2a,R-P2b"),
("D-P3","Screens P3 — Querschnitts-Oberflächen","P03-querschnitts-oberflaechen","W2","2026-06-21",0,"AK-P3"),
("D-P4","Screens P4 — Files & Tasks","P04-files-tasks","W1","2026-06-19",0,"AK-P4a,AK-P4b"),
("D-P5","Screens P5 — Apps-Engine","P05-apps-engine-integrations-framework","W2","2026-06-21",0,"R-P5"),
("D-P7","Screens P7 — Lead-Inbox","P07-lead-inbox","W2","2026-06-26",0,"R-P7"),
("D-P8","Screens P8 — Projekt & Pipelines (Kern, ohne Kanban)","P08-projekt-pipelines-kern","W3","2026-06-29",0,"R-P8a,R-P8b"),
("D-P9","Screens P9 — Auftrags-Sub-Areas (ohne Plantafel/Abnahme)","P09-projekt-pipelines-auftrag","W3","2026-07-01",0,"R-P9a,R-P9b"),
("D-P12","Screens P12 — Automation (ohne Flow-Editor)","P12-automation-engine","W4","2026-07-07",0,"R-P12a,R-P12b"),
("D-P13","Screens P13 — KI-Layer (ex-D-REST)","P13-ki-layer","W4","2026-07-09",0,"R-P13"),
("D-P14","Screens P14 — Email & Marketing (ohne Kampagnen-Editor)","P14-email-marketing","W3","2026-06-29",0,"AK-P14a,AK-P14b"),
("D-P15","Screens P15 — Telefonie (ohne Chat)","P15-telefonie-chat","W3","2026-07-01",0,"AK-P15a"),
("D-P16","Screens P16 — Kalender/Signatur (ohne Form/Booking)","P16-kalender-booking-forms-signatur","W4","2026-07-07",0,"AK-P16a,AK-P16d"),
("D-P20","Screens P20 — Billing/Onboarding/Branding (ex-D-REST)","P20-billing-onboarding-branding","W4","2026-07-08",0,"AK-P20"),
("D-P21","Screens P21 — DSGVO/Launch (ex-D-REST)","P21-dsgvo-launch","W4","2026-07-09",0,"AK-P21"),
("VD-P22","Screens P22 — Public-API-Keys (ex-VD-5)","P22-public-api-webhooks","v2W1","2026-07-17",1,"VR-P22a,VR-P22b"),
("VD-P23","Screens P23 — Postfach-Inbox (ex-VD-1)","P23-postfach-sync","v2W1","2026-07-17",1,"VA-P23"),
("VD-P24","Screens P24 — Abo-Baukasten + Wizard (ex-VD-1)","P24-self-service-billing-wizard","v2W1","2026-07-15",1,"VA-P24a,VA-P24b"),
("VD-P25","Screens P25 — KI-Tiefe/HiveMind","P25-ki-tiefe-hivemind-rag","v2W1","2026-07-20",1,"VR-P25"),
("VD-P28","Screens P28 — Plattform-Admin-Tiefe (ex-VD-5)","P28-plattform-admin-tiefe","v2W3","2026-07-29",1,"VR-P28a,VA-P28b"),
("VD-P30","Screens P30 — Dashboard-Deluxe","P30-dashboard-deluxe-querschnitt","v2W3","2026-07-30",1,"VA-P30a,VA-P30b"),
("VD-P31","Screens P31 — DSGVO-Vollausbau","P31-dsgvo-vollausbau","v2W3","2026-07-31",1,"VR-P31"),
("VD-P32","Screens P32 — KWP-Setup (ex-VD-5)","P32-kwp-integration","v2W2","2026-07-22",1,"VR-P32a,VR-P32b,VR-P32c"),
]
CYK = {"W1":"W1 — Fundament & Identität","W2":"W2 — Plattform & CRM (Demo 1)","W3":"W3 — Kern-Kette & Kommunikation (Demo 2)","W4":"W4 — USP, Härtung & Launch","v2W1":"v2-W1 — Public-API/Postfach/Abo/KI-Start","v2W2":"v2-W2 — KWP/Portal/Mobile-Start","v2W3":"v2-W3 — Admin/Help/Dashboard/v2-Launch"}
created = skipped = 0
print(f"=== Phase 2 NEUE Tickets {'(SCHARF)' if APPLY else '(DRY-RUN)'} ===")
for wid, titel, brief, cyc, tgt, v2, blocks in NEW:
if wid in wid2:
skipped += 1; continue
name = f"{wid} · {titel}"
labels = [LB["typ:screen"]] + ([LB["v2"]] if v2 else [])
body = {"name": name, "description_html": desc(wid, brief, "D-D0a, D-D0b", blocks, "david", CYK[cyc].split(" — ")[0]),
"state": ST.get("Backlog"), "assignees": [DAVID], "labels": labels, "target_date": tgt}
if not APPLY:
print(f" + {wid:8} [{cyc}] → {titel[:40]}"); created += 1; continue
st, d = http("POST", "/issues/", body); time.sleep(1.1)
if st < 300 and d.get("id"):
iid = d["id"]
http("POST", f"/cycles/{CYC[CYK[cyc]]}/cycle-issues/", {"issues": [iid]}); time.sleep(1.0)
http("POST", f"/modules/{MOD['David (Screens)']}/module-issues/", {"issues": [iid]}); time.sleep(1.0)
print(f" ✓ angelegt {wid}"); created += 1
else:
print(f" ✗ {wid}: HTTP {st}")
print(f"angelegt: {created} | schon vorhanden (skip): {skipped}")
# ---- Phase 3 ----
def add_doclink(html, wid):
if f"ticket-docs/{wid}.md" in html: return html
link = A("", DOC + wid + ".md", f"📄 Ticket-Doku ({wid})") + "<br>"
return re.sub(r'(📚 Doku</(?:b|strong)><br\s*/?>)', lambda m: m.group(1) + link, html, count=1)
RENAME = [("VD-2","VD-P26","Screens P26 — Customer-Portal"),
("VD-3","VD-P27","Screens P27 — Mobile-PWA-Shell (PWA-only)"),
("VD-4","VD-P29","Screens P29 — Help-Center")]
CANCEL = [("D-REST","aufgelöst → D-P13/D-P20/D-P21"),
("VD-1","aufgelöst → VD-P23/VD-P24"),
("VD-5","aufgelöst → VD-P22/VD-P28/VD-P32")]
print(f"\n=== Phase 3 Rename/Cancel/Fix {'(SCHARF)' if APPLY else '(DRY-RUN)'} ===")
def pat(wid, body, note):
i = wid2.get(wid)
if not i: print(f" ⚠ {wid} nicht gefunden"); return
print(f" ~ {wid}: {note}")
if APPLY:
st, _ = http("PATCH", f"/issues/{i['id']}/", body); time.sleep(1.1)
if st >= 300: print(f" ✗ HTTP {st}")
for old, new, titel in RENAME:
i = wid2.get(old)
if i: pat(old, {"name": f"{new} · {titel}", "description_html": add_doclink(i["description_html"], new)}, f"rename → {new}")
for wid, note in CANCEL:
i = wid2.get(wid)
if i:
nm = i["name"] if "aufgelöst" in i["name"] else i["name"] + f" ({note})"
pat(wid, {"name": nm, "state": ST["Cancelled"]}, f"CANCEL ({note})")
# AK-P1c → R-P1c (rename + Owner Raffael + Module-Wechsel)
i = wid2.get("AK-P1c")
if i:
nm = "R-P1c · " + re.split(r'\s*·\s*', i["name"], maxsplit=1)[1] if "·" in i["name"] else "R-P1c · P1 Hot-Swap-Maschine + Walking-Skeleton + PWA"
pat("AK-P1c", {"name": nm, "assignees": [RAFFAEL], "description_html": add_doclink(i["description_html"].replace("AK-P1c","R-P1c"), "R-P1c")}, "→ R-P1c (Owner Raffael)")
if APPLY:
http("POST", f"/modules/{MOD['Raffael (Dev)']}/module-issues/", {"issues": [i["id"]]}); time.sleep(1.0)
http("DELETE", f"/modules/{MOD['Akeshan (Dev)']}/module-issues/{i['id']}/"); time.sleep(1.0)
# R-P8a Name-Fix (Deal→Projekt)
i = wid2.get("R-P8a")
if i and "Deal-CRUD" in i["name"]:
pat("R-P8a", {"name": i["name"].replace("Deal-CRUD", "Projekt-CRUD")}, "Name Deal-CRUD→Projekt-CRUD")
# EXT-04 KWP-Nummer
i = wid2.get("EXT-04")
if i and "v2-P10" in i["description_html"]:
pat("EXT-04", {"description_html": i["description_html"].replace("v2-P10", "P32 (v2)")}, "KWP-Nummer v2-P10→P32")
# R-REFIT → Done
i = wid2.get("R-REFIT")
if i and i["state"] != ST["Done"]:
pat("R-REFIT", {"state": ST["Done"]}, "→ Done (erledigt 16.06.)")
print("\n" + ("✅ Phase 2+3 scharf ausgeführt." if APPLY else "→ scharf: python3 .planning/plane-sync/create_and_fix.py --apply"))
@@ -13,6 +13,9 @@ Operationen:
- VA-P27a/b + VD-3 → Mobile-PWA-Ausbau umgewidmet (Expo/native/Store raus)
- VEXT-4, VJ-1 → v3-Backlog (aus Cycle entfernt, Label v2→v3)
- VD-6, EXT-13, VJ-3 → App-Store-/native-Teil entfernt, Rest bleibt
C) Board-States/Labels-Hygiene (2026-06-28): "In Review" gehört in Gruppe started
(nicht completed, sonst zählen wartende Tickets als erledigt); neuer State
"Blocked / On Hold" + Labels wartet-auf-screen / wartet-extern (idempotent angelegt).
Default = DRY-RUN (zeigt nur Diffs, schreibt nichts). Mit --apply scharf schalten.
"""
@@ -178,6 +181,42 @@ if V3 is None:
print("[würde Label 'v3' anlegen]")
V2 = labels.get("v2")
# ---------- C) Board-States/Labels sicherstellen (idempotent) ----------
# Workflow-Hygiene: "In Review" muss in Gruppe started liegen (nicht completed, sonst
# zaehlen wartende Tickets als erledigt). Neuer State "Blocked / On Hold" fuer "Code
# fertig, liegt bewusst" + Labels fuer den Blocker-Grund (typuebergreifend, damit Davids
# Screen-Board sauber bleibt). Siehe Operation C im Docstring.
print("\n--- Board-States/Labels ---")
WANT_LABELS = {"wartet-auf-screen": "#8B5CF6", "wartet-extern": "#0EA5E9"}
for lname, lcolor in WANT_LABELS.items():
if lname in labels:
print(f" ok (bereits): Label '{lname}'")
elif APPLY:
st, d = req("POST", "/labels/", {"name": lname, "color": lcolor})
if st < 400: labels[lname] = d["id"]; print(f" Label '{lname}' angelegt.")
else: print(f" Label '{lname}' FAIL:", st, d)
else:
print(f" [wuerde Label '{lname}' anlegen]")
states_by_name = {s["name"]: s for s in get_all("/states/")}
ir = states_by_name.get("In Review")
if ir and ir.get("group") != "started":
print(f" FIX: 'In Review' group {ir.get('group')} -> started")
if APPLY:
st, d = req("PATCH", f"/states/{ir['id']}/", {"group": "started", "sequence": 75000})
if st >= 400: print(" FAIL:", st, d)
elif ir:
print(" ok (bereits): 'In Review' in started")
if "Blocked / On Hold" in states_by_name:
print(" ok (bereits): State 'Blocked / On Hold'")
elif APPLY:
st, d = req("POST", "/states/", {"name": "Blocked / On Hold", "color": "#F59E0B",
"group": "started", "sequence": 80000})
if st < 400: print(" State 'Blocked / On Hold' angelegt.")
else: print(" State 'Blocked / On Hold' FAIL:", st, d)
else:
print(" [wuerde State 'Blocked / On Hold' anlegen]")
# Cycle-Namen fixen
print("\n--- Cycle-Namen ---")
cyc_by_name = {c["name"]: c for c in cycles}
@@ -1,54 +0,0 @@
#!/usr/bin/env python3
"""set_startdates.py — Nachzug: start_date für die 21 neuen Screen-Tickets (gen_dayplan ordnet nach start_date).
Idempotent. Default DRY-RUN, scharf: --apply"""
import json, os, re, sys, time, urllib.request, urllib.error
APPLY = "--apply" in sys.argv
TOK = {}
for line in open(os.path.expanduser("~/.cli-tokens/plane")):
line = line.strip()
if "=" in line and not line.startswith("#"):
k, v = line.split("=", 1); TOK[k.strip()] = v.strip()
BASE = TOK["PLANE_BASE_URL"].rstrip("/"); WS = TOK["PLANE_WORKSPACE"]; KEY = TOK["PLANE_API_KEY"]
PID = "35d0d968-8ab3-40cc-97b8-b81d917dc5df"
API = f"{BASE}/api/v1/workspaces/{WS}/projects/{PID}"
STARTS = {
"D-P2":"2026-06-20","D-P4":"2026-06-20","D-P3":"2026-06-20","D-P5":"2026-06-20","D-P7":"2026-06-25",
"D-P8":"2026-06-27","D-P14":"2026-06-27","D-P9":"2026-06-30","D-P15":"2026-06-30",
"D-P16":"2026-07-04","D-P12":"2026-07-06","D-P20":"2026-07-08","D-P13":"2026-07-09","D-P21":"2026-07-09",
"VD-P22":"2026-07-13","VD-P23":"2026-07-13","VD-P24":"2026-07-13","VD-P25":"2026-07-16",
"VD-P32":"2026-07-20","VD-P28":"2026-07-27","VD-P30":"2026-07-29","VD-P31":"2026-07-29",
}
def http(method, path, body=None):
data = json.dumps(body).encode() if body is not None else None
r = urllib.request.Request(API + path, data=data, method=method); r.add_header("X-API-Key", KEY)
if data: r.add_header("Content-Type", "application/json")
for _ in range(4):
try:
with urllib.request.urlopen(r, timeout=30) as resp:
raw = resp.read().decode(); return resp.status, (json.loads(raw) if raw else {})
except urllib.error.HTTPError as e:
if e.code == 429: time.sleep(5); continue
return e.code, {}
return 999, {}
def get_all(path):
out, c = [], None
while True:
st, d = http("GET", path + (("?per_page=100&cursor=" + c) if c else "?per_page=100"))
if st >= 400 or isinstance(d, list): out += d if isinstance(d, list) else []; break
out += d.get("results", [])
if d.get("next_page_results") and d.get("next_cursor"): c = d["next_cursor"]
else: break
return out
wid2 = {re.split(r'\s*·\s*', i["name"])[0].strip(): i for i in get_all("/issues/")}
print(f"=== start_date {'(SCHARF)' if APPLY else '(DRY-RUN)'} ===")
done = 0
for wid, sd in STARTS.items():
i = wid2.get(wid)
if not i: print(f" ⚠ {wid} fehlt"); continue
if (i.get("start_date") or "")[:10] == sd: continue
print(f" ~ {wid}: start_date = {sd}")
if APPLY:
st, _ = http("PATCH", f"/issues/{i['id']}/", {"start_date": sd}); time.sleep(1.1)
if st < 300: done += 1
else: print(f" ✗ HTTP {st}")
print(f"{'gesetzt: '+str(done) if APPLY else '→ scharf: --apply'}")
@@ -1,227 +0,0 @@
#!/usr/bin/env python3
"""
HiveGrid -> Plane Ticket-Sync (idempotent).
Liest Zugang aus ~/.cli-tokens/plane (env-Format).
ZIEL-PROJEKT ist HARDCODED auf HiveGrid (NICHT die PLANE_PROJECT-Variable,
die faelschlich auf PowerLinked zeigt).
Phase A: Cycles (W1-W4) + 7 Module + Labels + Modul "Externals" (12 Issues).
Phase B (spaeter): restliche Module-Issues + modueluebergreifende Relations.
Aufruf: python3 sync_plane.py
Re-Run-sicher: existierende Objekte werden per Name erkannt, nicht dupliziert.
"""
import json, os, sys, urllib.request, urllib.error
# ⚠ BOOTSTRAP-GUARD (seit 2026-06-16): Plane ist LIVE + manuell gescrubbt (Umlaute, PWA-Umbau,
# v3-Defers). Dieses Skript matcht Issues per VOLLNAMEN — ein Re-Run nach dem Scrub fände
# umbenannte Tickets NICHT wieder und legte DUPLIKATE an. Laufende Pflege -> scrub_plane.py
# (Working-ID-Match, idempotent). Bewusster Neu-Bootstrap nur mit --force-bootstrap.
if "--force-bootstrap" not in sys.argv:
raise SystemExit(
"✋ Bootstrap gesperrt: Plane ist live + gescrubbt — Re-Run würde Duplikate erzeugen.\n"
" Laufende Änderungen -> .planning/plane-sync/scrub_plane.py (idempotent).\n"
" Echter Neu-Bootstrap nur bewusst mit --force-bootstrap."
)
# ---- Zugang ----
TOK = {}
with open(os.path.expanduser("~/.cli-tokens/plane")) as f:
for line in f:
line = line.strip()
if "=" in line and not line.startswith("#"):
k, v = line.split("=", 1)
TOK[k.strip()] = v.strip()
BASE = TOK["PLANE_BASE_URL"].rstrip("/")
WS = TOK["PLANE_WORKSPACE"]
KEY = TOK["PLANE_API_KEY"]
# HARDCODED: HiveGrid-Projekt (NICHT PowerLinked!)
PID = "35d0d968-8ab3-40cc-97b8-b81d917dc5df"
API = f"{BASE}/api/v1/workspaces/{WS}/projects/{PID}"
GITEA = "https://git.tool-ctrl.de/toolctrl-labs/HiveGrid-Cloud/src/branch/main"
MEMBERS = {
"raffael": "8e34a88c-19c9-4565-b4f5-50415d623ce8",
"akeshan": "8bf43e86-dc8a-4963-b57a-aeb5f2cc450a",
"david": "6c1fc63f-133d-4f40-86e4-7f658a6edcc7",
"dejan": "a5883f5e-3038-453c-9401-39ab1f40be9e",
"haris": "48367c1f-20d2-44f1-8ae2-faf35aa2052a",
}
def req(method, path, body=None):
url = path if path.startswith("http") else API + path
data = json.dumps(body).encode() if body is not None else None
r = urllib.request.Request(url, data=data, method=method)
r.add_header("X-API-Key", KEY)
r.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(r, timeout=30) as resp:
raw = resp.read().decode()
return resp.status, (json.loads(raw) if raw else {})
except urllib.error.HTTPError as e:
return e.code, {"_error": e.read().decode()[:400]}
def get_all(path):
"""Cursor-paginierte Liste -> alle results."""
out, cursor = [], None
while True:
p = path + (("?cursor=" + cursor) if cursor else "")
st, d = req("GET", p)
if st >= 400:
print(" GET FAIL", path, st, d); break
if isinstance(d, list):
out += d; break
out += d.get("results", [])
if d.get("next_page_results") and d.get("next_cursor"):
cursor = d["next_cursor"]
else:
break
return out
# ---- States ----
states = {s["name"]: s["id"] for s in get_all("/states/")}
BACKLOG = states.get("Backlog")
print("States:", list(states.keys()), "| Backlog-id ok:", bool(BACKLOG))
# ---- Cycles (idempotent by name) ----
CYCLES = [
("W1 — Fundament & Identitaet", "2026-06-16", "2026-06-19"),
("W2 — Plattform & CRM (Demo 1)", "2026-06-20", "2026-06-26"),
("W3 — Kern-Kette & Kommunikation (Demo 2)", "2026-06-27", "2026-07-03"),
("W4 — USP, Haertung & Launch", "2026-07-04", "2026-07-10"),
]
existing_cycles = {c["name"]: c["id"] for c in get_all("/cycles/")}
cycle_id = {}
for name, start, end in CYCLES:
if name in existing_cycles:
cycle_id[name] = existing_cycles[name]; print("Cycle vorhanden:", name)
else:
st, d = req("POST", "/cycles/", {"name": name, "start_date": start, "end_date": end, "project_id": PID})
if st < 400: cycle_id[name] = d["id"]; print("Cycle angelegt:", name)
else: print("Cycle FAIL:", name, st, d)
W = {"W1": CYCLES[0][0], "W2": CYCLES[1][0], "W3": CYCLES[2][0], "W4": CYCLES[3][0]}
# ---- Module (idempotent by name) ----
MODULES = ["Externals", "Raffael (Dev)", "Akeshan (Dev)", "David (Screens)",
"Dejan (Website)", "Haris (Vertrieb)", "Noah (Validierung)"]
existing_mods = {m["name"]: m["id"] for m in get_all("/modules/")}
module_id = {}
for name in MODULES:
if name in existing_mods:
module_id[name] = existing_mods[name]; print("Modul vorhanden:", name)
else:
st, d = req("POST", "/modules/", {"name": name})
if st < 400: module_id[name] = d["id"]; print("Modul angelegt:", name)
else: print("Modul FAIL:", name, st, d)
# ---- Labels (idempotent by name) ----
LABELS = {
"typ:phase": "#2563eb", "typ:screen": "#db2777", "typ:external": "#d97706",
"typ:website": "#0891b2", "typ:vertrieb": "#16a34a", "typ:validierung": "#7c3aed",
"gate:W1": "#9ca3af", "gate:W2": "#6b7280", "gate:W3": "#4b5563", "gate:W4": "#374151",
}
existing_labels = {l["name"]: l["id"] for l in get_all("/labels/")}
label_id = {}
for name, color in LABELS.items():
if name in existing_labels:
label_id[name] = existing_labels[name]
else:
st, d = req("POST", "/labels/", {"name": name, "color": color})
if st < 400: label_id[name] = d["id"]; print("Label angelegt:", name)
else: print("Label FAIL:", name, st, d)
# ---- Helper: Doku-HTML + Issue-Description ----
def doku_link(path, section):
return f'<a href="{GITEA}/{path}">{path.split("/")[-1]} — {section}</a>'
def desc(doku, blocked_by, blocks, owner, window, dod):
lis = "".join(f"<li>{d}</li>" for d in dod)
return (f"<p><b>📚 Doku</b><br/>{doku}</p>"
f"<p><b>⛔ blocked-by:</b> {blocked_by or '—'}<br/>"
f"<b>➡️ blocks:</b> {blocks or '—'}<br/>"
f"<b>👤 Owner:</b> {owner} &nbsp;·&nbsp; <b>🗓 Fenster:</b> {window}</p>"
f"<p><b>✅ Definition of Done</b></p><ul>{lis}</ul>")
# ---- Issue anlegen (idempotent by exact name) ----
existing_issues = {i["name"]: i["id"] for i in get_all("/issues/")}
def ensure_issue(title, assignees, labels, start, target, description_html):
if title in existing_issues:
return existing_issues[title], False
body = {"name": title, "description_html": description_html,
"state": BACKLOG, "assignees": [MEMBERS[a] for a in assignees],
"labels": [label_id[l] for l in labels if l in label_id],
"start_date": start, "target_date": target}
st, d = req("POST", "/issues/", body)
if st >= 400:
print("Issue FAIL:", title, st, d); return None, False
existing_issues[title] = d["id"]
return d["id"], True
def link_issue(iid, cycle_key, module):
"""Cycle- + Modul-Zuordnung (idempotent; Plane M2M ignoriert Duplikate)."""
cn = W[cycle_key]
if cn in cycle_id:
st, d = req("POST", f"/cycles/{cycle_id[cn]}/cycle-issues/",
{"issues": [iid], "project": PID})
if st >= 400 and "already" not in str(d).lower():
print(" cycle-link FAIL", st, d)
if module in module_id:
st, d = req("POST", f"/modules/{module_id[module]}/module-issues/",
{"issues": [iid], "project": PID})
if st >= 400 and "already" not in str(d).lower():
print(" module-link FAIL", st, d)
EXT_DOC = ".planning/EXTERNALS-CHECKLISTE.md"
EXTERNALS = [
("EXT-02 · Mailjet Sende-Domain (SPF/DKIM/DMARC) + Warm-up", ["raffael"], "W1", "2026-06-16", "2026-06-16",
"A6/B1/D1", "—", "P2 (Einladungs-Mails), P14 (Marketing-Zustellbarkeit)",
["SPF/DKIM/DMARC gesetzt & verifiziert (Auto-Probe gruen)", "Domain-Warm-up gestartet", "AD-6 im ADR auf Accepted"]),
("EXT-03 · Anwalt: AVV + DSGVO-Auskunft + Website-Rechtstexte", ["dejan"], "W1", "2026-06-16", "2026-06-19",
"E6", "—", "Website-LIVE (Rechtstexte), P21 / Launch",
["AVV-Vorlage (Art. 28) beauftragt", "DSGVO-Auskunfts-Vorlage", "Impressum/Datenschutz fuer Website"]),
("EXT-04 · KWP: Callback-URLs melden + bnWin.net 8.1 N3", ["raffael"], "W1", "2026-06-16", "2026-06-17",
"A1", "N-KWP (Noah/Geerkens-IT)", "v2-P10 (KWP-Integration)",
["Beide Callback-URLs an KWP gemeldet (app + staging, PKCE)", "bnWin.net-Update 8.1 N3 bei Geerkens-IT angestossen"]),
("EXT-05 · Noah: 4 Gate-Freitags-Slots blocken", ["raffael"], "W1", "2026-06-16", "2026-06-16",
"E1", "—", "alle Noah-Demos (19./26.06., 03./10.07.)",
["4 Slots terminiert", "von Noah bestaetigt"]),
("EXT-06 · Hetzner-Projekt HiveGrid: S3+Token + DEK-Master-Key", ["raffael"], "W1", "2026-06-16", "2026-06-17",
"B5", "—", "P4 (Storage), P5/P17 (DEK), AK-Infra-Live",
["Hetzner-Projekt HiveGrid + S3-Bucket + Token", "DEK-Master-Key in Coolify-Secrets", "SCALEWAY_*-Env -> Hetzner-Endpoint"]),
("EXT-07 · Stripe-Konto + Live-Verifizierung", ["raffael"], "W1", "2026-06-16", "2026-06-26",
"A5", "—", "P20 (Billing-Go-Live)",
["Konto + Geschaeftsdaten + Bankkonto", "Test-Mode fuer Dev aktiv", "Live-Verifizierung eingereicht"]),
("EXT-08 · AirCall-Konto + Sandbox + Webhooks", ["raffael"], "W1", "2026-06-16", "2026-06-30",
"B2", "—", "P15 (Telefonie)",
["Konto + Sandbox", "API-Keys", "Webhooks konfiguriert"]),
("EXT-09 · PandaDoc-Sandbox (Provider-Wahl)", ["raffael"], "W1", "2026-06-16", "2026-07-03",
"B4/D4", "—", "P16 (Angebot/Signatur)",
["Provider gewaehlt (PandaDoc vs. DocuSign)", "Sandbox + API-Key"]),
("EXT-10 · Google-Cloud OAuth + Calendar-Scope-Antrag", ["raffael"], "W1", "2026-06-16", "2026-06-18",
"A2", "—", "P16 (Kalender, nur Google)",
["OAuth-App + nur Calendar-Scope", "Verifizierung beantragt", "Testmodus-Bruecke mit Workspace-Konten"]),
("EXT-11 · Mistral API-Key", ["raffael"], "W1", "2026-06-16", "2026-06-18",
"B6", "—", "P13 (KI-Layer)",
["La-Plateforme-Key in Coolify-Secrets"]),
("EXT-12 · Geo-IP + HIBP pruefen/anbinden", ["raffael"], "W1", "2026-06-16", "2026-06-19",
"B8/B9", "—", "P2 (Session-Standort + Breach-Check)",
["Geo-IP EU-Konformitaet geprueft (oder stub)", "HIBP k-Anonymity angebunden"]),
("EXT-13 · v2-Vorlauf-Externals (Play/FCM/Gmail-Scope/Entra)", ["raffael"], "W3", "2026-06-29", "2026-06-29",
"v2-Vorlauf", "—", "v2 (Mobile-App, Postfach-Sync)",
["Google Play Console", "FCM/APNs-Zertifikate", "Gmail-Restricted-Scope-Antrag (Wochen Vorlauf!)", "MS-Entra-App"]),
]
print("\n--- Externals-Issues ---")
for title, asg, cyc, start, target, sec, bby, blk, dod in EXTERNALS:
html = desc(doku_link(EXT_DOC, sec), bby, blk,
", ".join(asg), f"{cyc} (faellig {target})", dod)
labels = ["typ:external", f"gate:{cyc}"]
iid, created = ensure_issue(title, asg, labels, start, target, html)
if iid:
link_issue(iid, cyc, "Externals")
print(("angelegt:" if created else "verknuepft:"), title)
print("\nFERTIG (Phase A). Projekt-URL:",
f"{BASE}/{WS}/projects/{PID}/issues/")
@@ -1,407 +0,0 @@
#!/usr/bin/env python3
"""
HiveGrid -> Plane Ticket-Sync PHASE B (idempotent).
Legt die restlichen Issues (Raffael/Akeshan/David/Dejan/Haris/Noah + R-REFIT + EXT-14)
an und verdrahtet das JIT-Netz als klickbare Issue-Links (blocked-by).
Voraussetzung: Phase A (sync_plane.py) lief -> Module/Cycles/Labels/Externals existieren.
Re-Run-sicher: Issues per Name, Links per Titel-Prefix erkannt.
"""
import json, os, sys, time, urllib.request, urllib.error
# ⚠ BOOTSTRAP-GUARD (seit 2026-06-16): Plane ist LIVE + manuell gescrubbt (Umlaute, PWA-Umbau,
# v3-Defers). Dieses Skript matcht Issues per VOLLNAMEN — ein Re-Run nach dem Scrub fände
# umbenannte Tickets NICHT wieder und legte DUPLIKATE an. Laufende Pflege -> scrub_plane.py
# (Working-ID-Match, idempotent). Bewusster Neu-Bootstrap nur mit --force-bootstrap.
if "--force-bootstrap" not in sys.argv:
raise SystemExit(
"✋ Bootstrap gesperrt: Plane ist live + gescrubbt — Re-Run würde Duplikate erzeugen.\n"
" Laufende Änderungen -> .planning/plane-sync/scrub_plane.py (idempotent).\n"
" Echter Neu-Bootstrap nur bewusst mit --force-bootstrap."
)
TOK = {}
for line in open(os.path.expanduser("~/.cli-tokens/plane")):
line = line.strip()
if "=" in line and not line.startswith("#"):
k, v = line.split("=", 1); TOK[k.strip()] = v.strip()
BASE = TOK["PLANE_BASE_URL"].rstrip("/"); WS = TOK["PLANE_WORKSPACE"]; KEY = TOK["PLANE_API_KEY"]
PID = "35d0d968-8ab3-40cc-97b8-b81d917dc5df"
API = f"{BASE}/api/v1/workspaces/{WS}/projects/{PID}"
GITEA = "https://git.tool-ctrl.de/toolctrl-labs/HiveGrid-Cloud/src/branch/main"
M = {"raffael":"8e34a88c-19c9-4565-b4f5-50415d623ce8","akeshan":"8bf43e86-dc8a-4963-b57a-aeb5f2cc450a",
"david":"6c1fc63f-133d-4f40-86e4-7f658a6edcc7","dejan":"a5883f5e-3038-453c-9401-39ab1f40be9e",
"haris":"48367c1f-20d2-44f1-8ae2-faf35aa2052a"}
def _once(method, url, body):
r = urllib.request.Request(url, data=json.dumps(body).encode() if body is not None else None, method=method)
r.add_header("X-API-Key", KEY); r.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(r, timeout=30) as resp:
raw = resp.read().decode(); return resp.status, (json.loads(raw) if raw else {})
except urllib.error.HTTPError as e:
return e.code, {"_error": e.read().decode()[:300]}
def req(method, path, body=None):
"""Mit 429-Retry (Backoff) + Throttle, um Plane-Rate-Limit (60/min) zu respektieren."""
url = path if path.startswith("http") else API + path
st, d = None, None
for _ in range(8):
st, d = _once(method, url, body)
if st == 429:
time.sleep(25); continue
time.sleep(1.1) # Throttle: < 60 Requests/min
return st, d
return st, d
def get_all(path):
out, cursor = [], None
while True:
st, d = req("GET", path + (("?cursor=" + cursor) if cursor else ""))
if st >= 400: print("GET FAIL", path, st, d); break
if isinstance(d, list): out += d; break
out += d.get("results", [])
if d.get("next_page_results") and d.get("next_cursor"): cursor = d["next_cursor"]
else: break
return out
states = {s["name"]: s["id"] for s in get_all("/states/")}
BACKLOG = states["Backlog"]
cyc = {c["name"]: c["id"] for c in get_all("/cycles/")}
mod = {m["name"]: m["id"] for m in get_all("/modules/")}
lab = {l["name"]: l["id"] for l in get_all("/labels/")}
CY = {"W1":"W1 — Fundament & Identitaet","W2":"W2 — Plattform & CRM (Demo 1)",
"W3":"W3 — Kern-Kette & Kommunikation (Demo 2)","W4":"W4 — USP, Haertung & Launch"}
# Doku-Pfade
RM,PS,SM,EM,PB,TL,EX = (".planning/ROADMAP.md",".planning/PHASE-SOURCES.md",".planning/SCREEN-MAP.md",
".planning/ENTITY-MODEL.md",".planning/DESIGNER-FIRST-PLAYBOOK.md",".planning/TIMELINE.md",".planning/EXTERNALS-CHECKLISTE.md")
AD,IC = "docs/ARCHITECTURE-DECISIONS.md","docs/INTEGRATION-CONTRACTS.md"
def sp(x): return f"_bmad-output/specs/{x}"
def ho(p): return f".planning/handouts/{p}"
def link(path, sec): return f'<a href="{GITEA}/{path}">{path.split("/")[-1]} — {sec}</a>'
def desc(doks, bby, blk, owner, win, dod):
dl = "<br/>".join(link(p, s) for p, s in doks)
li = "".join(f"<li>{d}</li>" for d in dod)
return (f"<p><b>📚 Doku</b><br/>{dl}</p>"
f"<p><b>⛔ blocked-by:</b> {bby or '—'}<br/><b>➡️ blocks:</b> {blk or '—'}<br/>"
f"<b>👤 Owner:</b> {owner} &nbsp;·&nbsp; <b>🗓 Fenster:</b> {win}</p>"
f"<p><b>✅ Definition of Done</b></p><ul>{li}</ul>")
# Issue-Definition: (wid, title, [assignees], cycle, modul, typ, start, target, [doku], [blocked_by], blocks_text, [dod])
def I(wid,t,a,c,mo,ty,s,tg,dk,bb,bl,dod): return (wid,t,a,c,mo,ty,s,tg,dk,bb,bl,dod)
RA,AK,DA,DJ,HA,NO = "Raffael (Dev)","Akeshan (Dev)","David (Screens)","Dejan (Website)","Haris (Vertrieb)","Noah (Validierung)"
ISSUES = [
# ---- RAFFAEL ----
I("R-REFIT","R-REFIT · ER-Modell-Re-Fit P6–P9 (Requirements/Screen-Map/Phase-Sources)",["raffael"],"W1",RA,"typ:phase","2026-06-16","2026-06-21",
[(EM,"gelocktes Modell"),(PS,"Phase 6–9 Pivot-Note"),(SM,"§2–§4")],[],"R-P6, R-P7, R-P9a, D-CRM, D-KANBAN, D-ABN",
["REQUIREMENTS P6–9 gegen ENTITY-MODEL nachgezogen","SCREEN-MAP §2–§4 finalisiert (Lead→Projekt, ein Kanban, Rechnung-Sub-Entity)","PHASE-SOURCES P6–9 re-mapped"]),
I("R-P1a","R-P1a · P1 EU-Sterilisation + Contracts + URL-Schemata",["raffael"],"W1",RA,"typ:phase","2026-06-16","2026-06-17",
[(RM,"Phase 1"),(IC,"URL-Schemata"),(EX,"D3 GA/ConvertKit raus")],[],"R-P1b + gesamtes Fundament",
["GitHub/Google-OAuth-Reste + githubId/googleId raus","GA + ConvertKit entfernt (D3)","URL-Schemata (App/Admin/Portal/Forms/Booking/API) dokumentiert"]),
I("R-P1b","R-P1b · P1 Service-Kerne (Audit/notify/i18n/Flags) + Konnektor-Muster",["raffael"],"W1",RA,"typ:phase","2026-06-17","2026-06-18",
[(RM,"Phase 1"),(sp("M21-audit-log.md"),"Audit"),(sp("M32-feature-flags.md"),"Flags")],["R-P1a"],"R-P2a, AK-P3, alle Module (Konnektor)",
["AuditEvent SHA-256-Hash-Kette (default-on)","notify() Kanal-Abstraktion (In-App/Email, Push-Stub)","packages/i18n + verify:i18n","tenantHasFlag() + tenantHasModule()-Muster am Skeleton"]),
I("R-P2a","R-P2a · P2 Mandant + Auth (Email/PW/HIBP) + Sessions + TOTP",["raffael"],"W1",RA,"typ:phase","2026-06-18","2026-06-19",
[(RM,"Phase 2"),(sp("M27-auth-multi-tenancy.md"),"Auth")],["R-P1b","EXT-12"],"R-P2b",
["Self-Service-Signup + Auto-Slug (Reserved-Words)","Login Email/PW + HIBP + Brute-Force","TOTP-2FA","Sessions Postgres-SoT + Redis-Cache (AD-4)"]),
I("R-P2b","R-P2b · P2 RLS-Härtung (Gate-Beweis) + RBAC + User-Lifecycle",["raffael"],"W1",RA,"typ:phase","2026-06-19","2026-06-19",
[(RM,"Phase 2"),(AD,"AD-1 RLS")],["R-P2a"],"Gate W1 + alle Tenant-Daten-Module",
["RLS USING/WITH CHECK, SET LOCAL app.tenant_id, App-Rolle ohne BYPASSRLS","Cross-Tenant-Zugriff nachweisbar verhindert","6 SHK-Rollen geseedet","Einladung 48h / Deaktivierung / PII-Löschung"]),
I("R-P5","R-P5 · P5 Apps-Engine & Integrations-Framework",["raffael"],"W2",RA,"typ:phase","2026-06-20","2026-06-21",
[(RM,"Phase 5"),(sp("M33a-apps-engine.md"),"Apps-Engine")],["R-P2b"],"R-P6, AK-P14a, AK-P15a, AK-P16a, EXT-08/09/10",
["DataConnection OAuth2 + API-Key-Connect","type-safe Adapter-Registry, AppSyncRun/Item","Connection-Health, OAuth-Tokens per-Tenant-DEK (AD-8)"]),
I("R-P6","R-P6 · P6 CRM-Kern",["raffael"],"W2",RA,"typ:phase","2026-06-22","2026-06-24",
[(RM,"Phase 6"),(sp("M01-crm-kern.md"),"CRM"),(EM,"Kontaktmodell")],["R-P5","AK-P4b","R-REFIT"],"R-P7, AK-P14a, AK-P15a",
["Entitäten typisiert + RLS, Entity-Builder raus (AD-2)","Listen filter/sort/paginate/bulk/CSV serverseitig","Detail Inline-Edit + Activity-Stream + Custom-Felder","Live-Duplikat-Check + Lead→Projekt (AD-20)","Konnektor tenantHasModule('crm')"]),
I("R-P7","R-P7 · P7 Lead-Inbox",["raffael"],"W2",RA,"typ:phase","2026-06-25","2026-06-26",
[(RM,"Phase 7"),(sp("M02-lead-inbox.md"),"Lead-Inbox")],["R-P6","R-REFIT"],"R-P8a, AK-P16c (Form→Lead), Demo 1",
["Triage-Liste + Quick-Actions + Bulk + Realtime","Multi-Quellen-Auto-Anlage (Form/Email/AirCall)","Status-Workflow + DSGVO-Auto-Lösch-Cron","Anti-Spam/Quarantäne","lead.qualified-Event"]),
I("R-P8a","R-P8a · P8 Pipeline-Engine + Deal-CRUD + Auto-Verkaufsfall",["raffael"],"W3",RA,"typ:phase","2026-06-27","2026-06-28",
[(RM,"Phase 8"),(sp("M03-sales-pipeline.md"),"Sales"),(EM,"Projekt-Kern")],["R-P7"],"R-P8b",
["Pipeline/Stage/Deal + HiveNumberSeq, SHK-Pipelines geseedet","M049: lead.qualified → Auto-Verkaufsfall","Lost-Reason-CRUD"]),
I("R-P8b","R-P8b · P8 Konfigurierbarer Kanban + Forecast/Reporting",["raffael"],"W3",RA,"typ:phase","2026-06-28","2026-06-29",
[(RM,"Phase 8"),(sp("M03-sales-pipeline.md"),"Kanban/KPIs")],["R-P8a"],"R-P9a (deal.won), R-P11b (Aggregate)",
["Kanban D&D + Stage-Validation, SSE-Realtime","Forecast/KPIs/Stage-Conversion mit Aggregat-Cache","Field-Level-Permissions (Volumen)","deal.won-Event"]),
I("R-P9a","R-P9a · P9 Projekt-Pipeline + Sub-Areas + Rechnung (Sub-Entity v1)",["raffael"],"W3",RA,"typ:phase","2026-06-30","2026-07-01",
[(RM,"Phase 9"),(sp("M04-auftragspipeline.md"),"Auftrag"),(EM,"Projekt + Sub-Areas")],["R-P8b","R-REFIT"],"R-P9b",
["Auftrags-Welt = Pipeline-View auf Projekt-Kern (kein Won-Objekt)","Sub-Areas: Termine/Rechnungen/Checklisten/Tasks/Doku","Rechnung = neues Sub-Entity v1"]),
I("R-P9b","R-P9b · P9 Plantafel (offline) + Abnahme-Protokoll (offline)",["raffael"],"W3",RA,"typ:phase","2026-07-01","2026-07-02",
[(RM,"Phase 9"),(sp("M04-auftragspipeline.md"),"Plantafel/Abnahme")],["R-P9a","AK-P17b"],"Demo 2, R-P11a",
["Plantafel pro Monteur/Tag, offline-fähig, HR-Abwesenheiten sichtbar","Abnahme-Protokoll Touch-Signatur, offline + Sync","PDF + Auto-Email"]),
I("R-P11a","R-P11a · P11 Globale Suche (Meilisearch, Cmd+K)",["raffael"],"W4",RA,"typ:phase","2026-07-03","2026-07-04",
[(RM,"Phase 11"),(sp("M24-globale-suche.md"),"Suche")],["R-P9b","AK-INFRA2"],"R-P11b",
["Meilisearch permission-aware via SearchAdapter","Outbox-Index-Pattern, SearchIndexEntry/RecentSearch","Cmd+K debounced + Vollbild-View"]),
I("R-P11b","R-P11b · P11 Dashboard (3 Rollen-Layouts)",["raffael"],"W4",RA,"typ:phase","2026-07-04","2026-07-05",
[(RM,"Phase 11"),(sp("M20-dashboard.md"),"Dashboard")],["R-P11a","R-P8b"],"R-P12a",
["3 feste Rollen-Layouts (GF/Büro/Monteur) geseedet","Widgets aus Sales/Projekte/Tasks/HR/KI","Live-Update WebSocket/SSE + Aggregat-Cache"]),
I("R-P12a","R-P12a · P12 Automation-Engine + Trigger/Action-Registry + Guards",["raffael"],"W4",RA,"typ:phase","2026-07-06","2026-07-07",
[(RM,"Phase 12"),(sp("M17-automation-engine.md"),"Automation")],["R-P11b"],"R-P12b, R-P13 (KI-Actions)",
["Trigger-Library (CRM/Sales/Projekte/Email/Forms/Kalender/Zeit/Webhook)","Action-Library erweiterbar (KWP/KI docken nach)","Loop-Guard/Rate-Limit/Idempotenz/Metering","AutomationRule/Execution/StepExecution"]),
I("R-P12b","R-P12b · P12 Visueller Flow-Editor + Dry-Run + 24h-Undo + Sprach-Andock",["raffael"],"W4",RA,"typ:phase","2026-07-07","2026-07-08",
[(RM,"Phase 12"),(sp("M17-automation-engine.md"),"Flow-Editor")],["R-P12a","D-FLOW"],"R-P13",
["Graph-Editor aus Trigger/Action-Library","Dry-Run-Vorschau + Bestätigungs-Pflicht","24h-Undo-Fenster","Sprach-zu-Automation-Andock (nie Auto-Aktivierung)"]),
I("R-P13","R-P13 · P13 KI-Layer (Mistral-Gateway)",["raffael"],"W4",RA,"typ:phase","2026-07-09","2026-07-09",
[(RM,"Phase 13"),(sp("M18-ki-layer.md"),"KI")],["R-P12b","EXT-11"],"FREEZE Do 09.07.",
["EU-LLM-Gateway Mistral + AiCredits-Konto + harte Limits","Multi-Provider-Fallback-Interface","Promptfoo-Suite (Injection)","Sprach-zu-Automation exklusiv im Automation-Kontext"]),
# ---- AKESHAN ----
I("AK-P1c","AK-P1c · P1 Hot-Swap-Maschine + Walking-Skeleton + PWA",["akeshan"],"W1",AK,"typ:phase","2026-06-16","2026-06-17",
[(RM,"Phase 1"),(PB,"§1 Fundamente")],[],"alle Screen-Andock, Gate W1",
["VM-Konvention + Action-Callback + Subframe-Adapter","ui-mock + Visual-Regression-Gate + Hot-Swap-Drill","Walking-Skeleton-Route + PWA-Manifest"]),
I("AK-INFRA1","AK-INFRA1 · Infra-Live: Staging + Coolify-Apps + PG/Redis/PgBouncer",["akeshan"],"W1",AK,"typ:external","2026-06-16","2026-06-17",
[(EX,"C3/C4")],["EXT-06"],"CI/Gates, alle Deploys",
["staging-app/api auf staging-1 (öffentlich + gehärtet, noindex, HMAC)","Coolify-managed Postgres/Redis + PgBouncer"]),
I("AK-INFRA2","AK-INFRA2 · DNS + Wildcard-TLS + zentrale Dienste anbinden",["akeshan"],"W1",AK,"typ:external","2026-06-17","2026-06-18",
[(EX,"C1/C2/C5")],["AK-INFRA1"],"R-P2a (mCaptcha), AK-P4a (ClamAV), R-P11a (Meili)",
["Cloudflare *.hivegrid.de Wildcard-TLS + admin/staging-Subdomains","Meili-scoped-Key + mCaptcha + ClamAV per ENV anbinden"]),
I("AK-P4a","AK-P4a · P4 Files (chunked Upload, HEIC→JPG, ClamAV)",["akeshan"],"W1",AK,"typ:phase","2026-06-18","2026-06-19",
[(RM,"Phase 4"),(sp("M23-files.md"),"Files")],["AK-INFRA2","EXT-06"],"AK-P4b, R-P6 (File-Links)",
["chunked Upload (TUS) + RLS auf Storage-Pfaden (Hetzner-S3)","Preview PDF→PNG / Bild→WebP / HEIC→JPG (Pflicht!)","ClamAV-Scan, Permissions/Sharing, FileEntityLink"]),
I("AK-P4b","AK-P4b · P4 Tasks + polymorphe EntityLink-Tabelle",["akeshan"],"W1",AK,"typ:phase","2026-06-19","2026-06-19",
[(RM,"Phase 4"),(sp("M26-tasks.md"),"Tasks")],["AK-P4a"],"R-P6, Gate W1 (EntityLink)",
["Task-CRUD: Status/Priorität/Fälligkeit/Entity-Link/Watcher/Reminder","30-Tage-Auto-Archiv-Cron","polymorphe EntityLink-Tabelle (entity_type+entity_id)"]),
I("AK-P3","AK-P3 · P3 Querschnitts-Oberflächen (Audit/Notif/Sprache DE)",["akeshan"],"W2",AK,"typ:phase","2026-06-20","2026-06-21",
[(RM,"Phase 3"),(sp("M21-audit-log.md"),"Audit-UI"),(sp("M22-notifications.md"),"Notif-UI")],["R-P1b","R-P2b"],"—",
["Audit-Viewer permission-gegated + NDJSON/CSV-Export","Notification-Center + Kategorie-Toggles pro Kanal","Sprach-Settings DE (i18n-ready)"]),
I("AK-P19","AK-P19 · P19 Plattform-Admin (admin.hivegrid.de)",["akeshan"],"W2",AK,"typ:phase","2026-06-22","2026-06-24",
[(RM,"Phase 19"),(sp("M37-plattform-admin.md"),"Admin")],["R-P2b","AK-P3"],"AK-P20, AK-DEMO1",
["Host-Routing admin.hivegrid.de (NICHT /admin-Pfad!) + Cookie-Domain-Trennung","TOTP-Pflicht, Admin ohne Tenant-Workspace","Tenant-Verwaltung + Abo-Zusammenstellung (Konnektoren+Stripe)","Queue-Monitor + Support-Inbox + AdminAuditLog"]),
I("AK-P17a","AK-P17a · P17 HR-Kern (Stammdaten, Doku, Passwort-Safe)",["akeshan"],"W2",AK,"typ:phase","2026-06-25","2026-06-25",
[(RM,"Phase 17"),(sp("M14-hr-hub.md"),"HR")],["AK-P4b"],"AK-P17b",
["HR-Profile/Dokumente AES-256-GCM (KMS-Envelope, AD-8)","verschlüsselter Passwort-Safe mit Audit-Trail + Reveal"]),
I("AK-P17b","AK-P17b · P17 HR Urlaub/Krank + Surveys + Self-Service",["akeshan"],"W2",AK,"typ:phase","2026-06-26","2026-06-26",
[(RM,"Phase 17"),(sp("M14-hr-hub.md"),"Urlaub/Krank")],["AK-P17a"],"R-P9b (Abwesenheiten→Plantafel)",
["mehrstufiger Urlaubs-Approval-Workflow + Abwesenheits-Kalender","Krankmeldung + AU-Upload (verschlüsselt)","Surveys, Mitarbeiter-Self-Service (Web/PWA)"]),
I("AK-DEMO1","AK-DEMO1 · Demo-Tenant + SHK-Demo-Daten für Noah-Demo 1",["akeshan"],"W2",AK,"typ:external","2026-06-26","2026-06-26",
[(ho("akeshan/HANDOUT-AKESHAN.md"),"Demo-Setup")],["AK-P19","N-DATEN"],"N-DEMO1, H-DEMO",
["Demo-Tenant via Admin angelegt","SHK-Seeds eingespielt (echte Geerkens-Beispieldaten)","klickbarer CRM-Stand für Noah"]),
I("AK-P14a","AK-P14a · P14 Sende-Domain-Verifikation + BCC-Brücke + Tracking",["akeshan"],"W3",AK,"typ:phase","2026-06-27","2026-06-28",
[(RM,"Phase 14"),(sp("M09-email-inbox.md"),"Sende-Domain/Inbound")],["EXT-02","R-P6"],"AK-P14b",
["Sende-Domain DKIM/SPF/DMARC + Auto-Probe","BCC-Logging-Brücke (Mail am Kontakt, kein OAuth)","Email-Tracking + Delivery-Status + Bounces"]),
I("AK-P14b","AK-P14b · P14 Email-Marketing (Kampagnen + A/B)",["akeshan"],"W3",AK,"typ:phase","2026-06-28","2026-06-29",
[(RM,"Phase 14"),(sp("M10-email-marketing.md"),"Marketing")],["AK-P14a"],"—",
["Block-Editor + Segmente + Versand-Engine (BullMQ, Rate-Limit)","Double-Opt-In + Suppression + One-Click-Unsubscribe","Reporting + A/B-Varianten"]),
I("AK-P15a","AK-P15a · P15 Telefonie (AirCall)",["akeshan"],"W3",AK,"typ:phase","2026-06-30","2026-07-01",
[(RM,"Phase 15"),(sp("M12-telefonie.md"),"Telefonie")],["EXT-08","R-P5","R-P6"],"AK-P15b",
["AirCall-Adapter (Apps-Engine, OAuth verschlüsselt)","Click-to-Call + Inbound, unbekannte Nr → Lead","Anruf-Events als Automation-Trigger, Voicemail/Notizen"]),
I("AK-P15b","AK-P15b · P15 Team-Chat (Realtime)",["akeshan"],"W3",AK,"typ:phase","2026-07-01","2026-07-02",
[(RM,"Phase 15"),(sp("M13-team-chat.md"),"Chat")],["AK-P15a","AK-P4a"],"Demo 2",
["WebSocket + Presence/Typing/Read-Receipts (Redis-Pub/Sub, AD-9)","3 Channel-Typen (public/private/entity-linked)","@Mentions + File-Uploads + Threads/Reactions/Pins + Suche"]),
I("AK-P16a","AK-P16a · P16 Kalender-Sync (Google)",["akeshan"],"W4",AK,"typ:phase","2026-07-03","2026-07-04",
[(RM,"Phase 16"),(sp("M25-calendar-sync.md"),"Kalender")],["EXT-10","R-P5"],"AK-P16b",
["bidirektionaler Google-Sync (Auto-Token-Refresh, Webhooks)","HiveGrid-Termine + Konflikt-Log (Outlook → v2)"]),
I("AK-P16b","AK-P16b · P16 Booking (Slots + Verfügbarkeit + Storno)",["akeshan"],"W4",AK,"typ:phase","2026-07-04","2026-07-05",
[(RM,"Phase 16"),(sp("M06-booking.md"),"Booking")],["AK-P16a"],"—",
["öffentliche Booking-Seiten, Slots timezone-aware","Buchung → Lead + Activity + Bestätigungs-Email (iFrame)","Storno/Verschiebung/Re-Assign/No-Show (M129 voll)"]),
I("AK-P16c","AK-P16c · P16 Form-Builder (Editor + Submission + Actions)",["akeshan"],"W4",AK,"typ:phase","2026-07-05","2026-07-06",
[(RM,"Phase 16"),(sp("M05-form-builder.md"),"Forms")],["AK-INFRA2","R-P7"],"—",
["12 Feldtypen + Conditional-Logic + SHK-Templates","Embed URL/QR/iFrame","Post-Submit-Actions (Lead/Deal/Task/Webhook/Email/Automation) + ActionLog","Rate-Limit + mCaptcha + DSGVO-Pflicht"]),
I("AK-P16d","AK-P16d · P16 Angebot/Signatur (PandaDoc)",["akeshan"],"W4",AK,"typ:phase","2026-07-06","2026-07-07",
[(RM,"Phase 16"),(sp("M07-angebot-signatur.md"),"Signatur")],["EXT-09"],"—",
["PDF-Upload → Multi-Signer via PandaDoc (Apps-Engine)","Webhook-Verarbeitung + Status-Tracking pro Unterzeichner"]),
I("AK-P20","AK-P20 · P20 Billing (Stripe Abo-Baukasten) + Onboarding + Branding",["akeshan"],"W4",AK,"typ:phase","2026-07-08","2026-07-08",
[(RM,"Phase 20"),(sp("M28-subscriptions-billing.md"),"Billing")],["EXT-07","AK-P19"],"AK-P21",
["Stripe Abo-Baukasten: Basis/Nutzer + Module als Positionen (KEINE Seat-Typen!)","Trial + Dunning + Frozen-Modus (Read-Only-Middleware)","Auto-SHK-Provisionierung + Demo-Daten-Knopf","Hell/Dunkel-Toggle (SSR-Cookie)"]),
I("AK-P21","AK-P21 · P21 DSGVO (Export + Lösch-Workflow) + Feedback-Widget",["akeshan"],"W4",AK,"typ:phase","2026-07-09","2026-07-09",
[(RM,"Phase 21"),(sp("M35-dsgvo-export.md"),"DSGVO")],["AK-P20"],"FREEZE",
["DSGVO-ZIP-Export (72h-signed-URL)","Tenant-Löschung: Crypto-Shredding + Stripe-Cancel + Storage-Cleanup","Feedback-Widget → Admin-Support-Inbox + Minimal-FAQ"]),
# ---- DAVID ----
I("D-WEB","D-WEB · Website-Screens + Brand-Tokens",["david"],"W1",DA,"typ:screen","2026-06-16","2026-06-16",
[(SM,"Website"),(ho("david/HANDOUT-DAVID.md"),"Website")],[],"J-STRUKTUR, H-PITCH (Tokens)",
["Landing/Features/Pricing-Teaser/Demo-Formular","Brand-/Design-Tokens (Web + App)"]),
I("D-D0a","D-D0a · D0 Design-System + App-Shell (A1)",["david"],"W1",DA,"typ:screen","2026-06-16","2026-06-18",
[(SM,"§0.1 App-Shell"),(PB,"§1")],[],"ALLE Screens, H-PITCH",
["Tokens (Farben/Typo/Spacing)","Sidebar (Konnektor-Gating) + Topbar (Cmd+K, Bell, Tenant-Switch) + Breadcrumb","Status-Banner-System + Toast"]),
I("D-D0b","D-D0b · D0 Archetypen A2–A7 + Zustands-Kit + Primitives",["david"],"W1",DA,"typ:screen","2026-06-18","2026-06-20",
[(SM,"§0.2/0.3"),(PB,"§2 Archetypen")],["D-D0a"],"alle Compose-Screens",
["A2 List · A3 Detail · A4 Form (12 Typen) · A5 Modal/Wizard · A6 Settings/Connection · A7 Zustände","Primitives (Buttons/Badges/KPI/Charts/Signatur-Feld)"]),
I("D-FLOW","D-FLOW · Flow-/Node-Editor (Bespoke, Langläufer ab 18.06.)",["david"],"W3",DA,"typ:screen","2026-06-18","2026-07-03",
[(SM,"§5 Flow-Editor"),(PB,"§3 Rang 1")],["D-D0b"],"R-P12b",
["Graph-Canvas + Knoten-Katalog nach Modul","Dry-Run-Vorschau + Bestätigung","Sprach-Eingabe-Feld + Loop-Guard-Warnung"]),
I("D-CRM","D-CRM · CRM-Cluster-Screens",["david"],"W2",DA,"typ:screen","2026-06-22","2026-06-24",
[(SM,"§2"),(EM,"Kontaktmodell")],["D-D0b","R-REFIT"],"N-DEMO1 (Politur)",
["Kontakt/Firma Liste + Detail (A3-Muster)","Lead-Inbox + Lead→Projekt","Custom-Felder + Tags + Duplikat-Tool","§2 gegen ENTITY-MODEL finalisiert"]),
I("D-KANBAN","D-KANBAN · Pipeline-Kanban (Bespoke, konfigurierbar)",["david"],"W2",DA,"typ:screen","2026-06-24","2026-06-26",
[(SM,"§3 Kanban"),(EM,"Pipeline=View")],["D-D0b","R-REFIT"],"—",
["EIN konfigurierbares Board (Deal/Auftrag = Pipeline-Typen)","Won/Lost-Drop-Dialoge","Volumen permission-gegated"]),
I("D-FORM","D-FORM · Form-Builder-Canvas + öffentliches Formular (Bespoke)",["david"],"W2",DA,"typ:screen","2026-06-24","2026-06-26",
[(SM,"§3 Form-Builder"),(PB,"§3 Rang 2")],["D-D0b"],"—",
["Feld-Palette + Canvas D&D + Conditional-Logic-Editor","öffentliches Formular (mCaptcha, Tenant-Logo)"]),
I("D-KAMP","D-KAMP · Kampagnen-Editor + Segmente (Bespoke)",["david"],"W2",DA,"typ:screen","2026-06-25","2026-06-26",
[(SM,"§3 Kampagnen")],["D-D0b"],"—",
["Multi-Step (Basis→Inhalt→Empfänger→Test→Zeit→Bestätigung)","Ausschluss-Aufschlüsselung + Pflicht-Footer","Klick-Map + A/B-Vergleich + Segment-Builder"]),
I("D-HR","D-HR · HR-Compose-Screens",["david"],"W2",DA,"typ:screen","2026-06-25","2026-06-26",
[(SM,"§3 HR")],["D-D0b"],"—",
["Hub-Start + Announcements","Urlaub/Krank + Payslips + Passwort-Safe (Reveal) + Surveys"]),
I("D-ADMIN","D-ADMIN · Plattform-Admin-Compose",["david"],"W2",DA,"typ:screen","2026-06-26","2026-06-26",
[(SM,"§3 Plattform-Admin")],["D-D0b"],"—",
["Admin-Dashboard (Tenants/User/MRR) + Tenant-Liste/Detail","Abo-Zusammenstellung + Feature-Flags + Health/Queues + Support-Inbox"]),
I("D-PLAN","D-PLAN · Plantafel (Bespoke-Mobile, offline)",["david"],"W3",DA,"typ:screen","2026-06-29","2026-07-01",
[(SM,"§4 Plantafel"),(PB,"§3 Rang 3")],["D-D0b"],"—",
["Desktop-Grid (Mitarbeiter×Zeit) D&D + Konflikt-Indikatoren","Monteur-Mobil-Modus 'Meine Termine heute' offline"]),
I("D-ABN","D-ABN · Abnahme-Workflow (Bespoke-Mobile)",["david"],"W3",DA,"typ:screen","2026-06-29","2026-07-01",
[(SM,"§4 Abnahme")],["D-D0b","R-REFIT"],"—",
["SHK-Abnahme-Formular + Mängel-Repeater mit Foto","Touch-Unterschriften Kunde+Monteur, offline + Sync"]),
I("D-CHAT","D-CHAT · Chat-UI (Bespoke)",["david"],"W3",DA,"typ:screen","2026-06-30","2026-07-01",
[(SM,"§4 Chat")],["D-D0b"],"—",
["Channel-Index + Channel-Detail (Reactions/Threads/Read)","Composer (Markdown/Emoji/@Mention/Anhang) + Tipp-Indikator"]),
I("D-DASH","D-DASH · Dashboard (3 Rollen-Layouts) + Cmd+K",["david"],"W3",DA,"typ:screen","2026-07-01","2026-07-03",
[(SM,"§5 Dashboard/Cmd+K")],["D-D0b"],"—",
["3 feste Rollen-Layouts (GF/Büro/Monteur)","Cmd+K-Modal (Match-Highlight) + Vollbild-Such-View"]),
I("D-BOOK","D-BOOK · Booking-Page (Public, Polish) + Kalender-View",["david"],"W3",DA,"typ:screen","2026-07-02","2026-07-03",
[(SM,"§5 Booking/Kalender")],["D-D0b"],"—",
["öffentliche Booking-Page (Slot-Picker, .ics, höchster Polish)","Kalender Monats-/Wochen-Grid + Connect-Card"]),
I("D-REST","D-REST · KI/Telefonie/Signatur/Billing/DSGVO-Compose",["david"],"W4",DA,"typ:screen","2026-07-04","2026-07-06",
[(SM,"§6")],["D-D0b"],"—",
["KI-Settings/Credits, AirCall-Setup, Signatur-Wizard","Abo-Übersicht, Privacy-Hub, Feedback-Widget"]),
I("D-POLISH","D-POLISH · Politur extern sichtbarer Flächen + Visual-Reg-Baselines",["david"],"W4",DA,"typ:screen","2026-07-06","2026-07-09",
[(PB,"§6c CI-Gates")],["D-BOOK","D-DASH"],"FREEZE",
["Booking/Forms/Marketing-Mails poliert","Visual-Regression-Baselines final (vor Freeze)"]),
# ---- DEJAN ----
I("J-SETUP","J-SETUP · Domain/Hosting/Stack + Setup",["dejan"],"W1",DJ,"typ:website","2026-06-16","2026-06-16",
[(ho("dejan/HANDOUT-DEJAN.md"),"AP-1")],[],"J-STRUKTUR",
["Stack/Hosting/Domain geklärt","Repo-Setup + Seitengerüst"]),
I("J-STRUKTUR","J-STRUKTUR · Seitenstruktur + Davids Screens einbauen",["dejan"],"W1",DJ,"typ:website","2026-06-16","2026-06-17",
[(ho("dejan/HANDOUT-DEJAN.md"),"AP-1")],["J-SETUP","D-WEB"],"J-CONTENT",
["Seitenstruktur (Landing/Features/Pricing-Teaser/Demo-Formular/Webinar-Warteliste)","Davids Designs umgesetzt"]),
I("J-CONTENT","J-CONTENT · Content/Texte einbauen",["dejan"],"W1",DJ,"typ:website","2026-06-17","2026-06-17",
[(ho("dejan/HANDOUT-DEJAN.md"),"AP-1")],["J-STRUKTUR","H-TXT","N-TXTABN"],"J-LIVE",
["Texte (Haris, Noah-abgenommen) eingepflegt","Webinar-Plattform gewählt (EU-konform)"]),
I("J-LAW","J-LAW · Rechtstexte einbauen (Anwalt)",["dejan"],"W1",DJ,"typ:website","2026-06-17","2026-06-18",
[(EX,"E6")],["EXT-03"],"J-LIVE",
["Impressum/Datenschutz/AVV-Link eingebaut"]),
I("J-QA","J-QA · QA + Lead-Routing-Test",["dejan"],"W1",DJ,"typ:website","2026-06-17","2026-06-18",
[(ho("dejan/HANDOUT-DEJAN.md"),"AP-1")],["J-CONTENT"],"J-LIVE",
["responsive QA","Formular → Lead-Routing an Haris getestet"]),
I("J-LIVE","J-LIVE · WEBSITE LIVE",["dejan"],"W1",DJ,"typ:website","2026-06-18","2026-06-18",
[(TL,"Dejan / LIVE 17./18."),(ho("dejan/HANDOUT-DEJAN.md"),"AP-1")],["J-QA","J-LAW"],"H-INBOUND",
["live auf hivegrid.de","EU-konformes Analytics + Search Console"]),
I("J-SEO","J-SEO · SEO + Conversion + Pricing-Seite vorbereiten",["dejan"],"W2",DJ,"typ:website","2026-06-20","2026-06-26",
[(ho("dejan/HANDOUT-DEJAN.md"),"AP-2")],["J-LIVE"],"—",
["SEO-Basics + Conversion-Feinschliff","Pricing-Seite vorbereitet (Preise 25.06.) + Founding-Sektion + Payment-Link"]),
I("J-WEBINAR","J-WEBINAR · Webinar-Landing + Anmelde-Flow",["dejan"],"W3",DJ,"typ:website","2026-06-29","2026-07-03",
[(ho("dejan/HANDOUT-DEJAN.md"),"AP-3")],["J-LIVE"],"H-WEBINAR",
["Landing + Anmelde-Flow","Bestätigungs-/Erinnerungs-Mails"]),
I("J-WEBTECH","J-WEBTECH · Webinar-Technik (Plattform/Aufzeichnung)",["dejan"],"W4",DJ,"typ:website","2026-07-04","2026-07-10",
[(ho("dejan/HANDOUT-DEJAN.md"),"AP-5")],["J-WEBINAR"],"—",
["Plattform-Setup (Livestorm/Zoom) + Generalprobe","Aufzeichnung + Follow-up-Seite (Webinar 14./15.07.)"]),
I("J-LAUNCHUPD","J-LAUNCHUPD · Launch-Update Website",["dejan"],"W4",DJ,"typ:website","2026-07-06","2026-07-10",
[(ho("dejan/HANDOUT-DEJAN.md"),"AP-4")],["J-LIVE","J-SEO"],"—",
["finale Feature-Seiten + Pricing live","Launch-Ankündigung"]),
# ---- HARIS ----
I("H-LISTE","H-LISTE · Zielkundenliste SHK (KWP-Nutzer-Fokus)",["haris"],"W1",HA,"typ:vertrieb","2026-06-16","2026-06-17",
[(ho("haris/HANDOUT-HARIS.md"),"AP-1")],[],"H-CALLS",
["SHK-Zielkundenliste, Fokus KWP-Nutzer (A-Leads)"]),
I("H-PAIN","H-PAIN · Painpoint-Gespräch mit Noah",["haris"],"W1",HA,"typ:vertrieb","2026-06-16","2026-06-17",
[(ho("haris/HANDOUT-HARIS.md"),"AP-1")],["EXT-05"],"H-TXT, H-PITCH",
["Exklusiv-Gespräch SHK-Painpoints (HubSpot/Zettelwirtschaft)"]),
I("H-TXT","H-TXT · Website-Texte schreiben",["haris"],"W1",HA,"typ:vertrieb","2026-06-17","2026-06-17",
[(ho("haris/HANDOUT-HARIS.md"),"AP-1")],["H-PAIN","N-PAIN"],"N-TXTABN, J-CONTENT",
["Headline/Nutzenversprechen/Feature-Texte → an Dejan"]),
I("H-PITCH","H-PITCH · Pitch-Deck + Gesprächsskript",["haris"],"W1",HA,"typ:vertrieb","2026-06-18","2026-06-19",
[(ho("haris/HANDOUT-HARIS.md"),"AP-2")],["D-D0a"],"H-CALLS",
["Pitch-Deck mit D0/Auth-Screens","Gesprächsskript (Noah-Abnahme)"]),
I("H-INBOUND","H-INBOUND · Lead-Sheet + Inbound-Prozess (<24h)",["haris"],"W1",HA,"typ:vertrieb","2026-06-18","2026-06-19",
[(ho("haris/HANDOUT-HARIS.md"),"AP-2")],["J-LIVE"],"—",
["Lead-Sheet aufgesetzt","jede Anfrage < 24h beantwortet"]),
I("H-CALLS","H-CALLS · Erste Discovery-Calls",["haris"],"W2",HA,"typ:vertrieb","2026-06-23","2026-06-26",
[(ho("haris/HANDOUT-HARIS.md"),"AP-3")],["H-LISTE","H-PITCH"],"H-FOUND",
["Discovery/Qualifizierung (Pain, KWP-Nutzung, Team-Größe)"]),
I("H-FOUND","H-FOUND · Founding-Member-Angebot strukturieren",["haris"],"W2",HA,"typ:vertrieb","2026-06-25","2026-06-26",
[(ho("haris/HANDOUT-HARIS.md"),"AP-4")],["H-CALLS","EXT-14"],"H-PAYLINK",
["Early-Bird-Jahresdeal (Preise 25.06.!)","Noah-Abnahme"]),
I("H-PAYLINK","H-PAYLINK · Stripe Payment Link (Founding-Deal)",["haris"],"W2",HA,"typ:vertrieb","2026-06-26","2026-06-26",
[(ho("haris/HANDOUT-HARIS.md"),"AP-4")],["EXT-07","EXT-14"],"H-DEMO",
["Founding-Deal-Payment-Link live (Geld VOR Launch möglich)"]),
I("H-DEMO","H-DEMO · Klick-Demos + echte Produkt-Demos",["haris"],"W3",HA,"typ:vertrieb","2026-06-30","2026-07-03",
[(ho("haris/HANDOUT-HARIS.md"),"AP-5")],["AK-DEMO1"],"H-FOLLOW",
["Klick-Demos (Demo-1-Stand) ab 30.06.","echte Demos (Kette E2E) ab 03.07."]),
I("H-WEBINAR","H-WEBINAR · Webinar-Einladungen + Moderation",["haris"],"W4",HA,"typ:vertrieb","2026-07-06","2026-07-10",
[(ho("haris/HANDOUT-HARIS.md"),"AP-5")],["J-WEBINAR"],"—",
["Einladungen raus (kleine Liste, Spam-Risiko)","Webinar Nr.1 moderieren (14./15.07.)"]),
I("H-FOLLOW","H-FOLLOW · Trial-Follow-ups + Bilanz",["haris"],"W4",HA,"typ:vertrieb","2026-07-09","2026-07-10",
[(ho("haris/HANDOUT-HARIS.md"),"AP-6")],["H-DEMO"],"—",
["Trial-Follow-ups (14 Tage nach Onboarding)","Bilanz: erste Gelder ✓"]),
# ---- NOAH (Assignees Haris + Dejan) ----
I("N-KWP","N-KWP · KWP-Mail freigeben + bnWin.net 8.1 N3 anstoßen",["haris","dejan"],"W1",NO,"typ:validierung","2026-06-16","2026-06-17",
[(EX,"A1"),(ho("noah/HANDOUT-NOAH.md"),"KWP")],[],"EXT-04",
["KWP-Mail-Antwort freigegeben/weitergeleitet","bnWin.net-Update 8.1 N3 bei Geerkens-IT angestoßen"]),
I("N-PAIN","N-PAIN · Painpoint-Input liefern (mit Haris)",["haris","dejan"],"W1",NO,"typ:validierung","2026-06-16","2026-06-17",
[(ho("noah/HANDOUT-NOAH.md"),"Painpoint")],[],"H-TXT",
["SHK-Alltag-Painpoints im Gespräch geliefert"]),
I("N-TXTABN","N-TXTABN · Website-Texte abnehmen",["haris","dejan"],"W1",NO,"typ:validierung","2026-06-17","2026-06-17",
[(ho("noah/HANDOUT-NOAH.md"),"Abnahme")],["H-TXT"],"J-CONTENT",
["Website-Texte abgenommen (alle Außen-Texte = Noah)"]),
I("N-SLOTS","N-SLOTS · 4 Gate-Freitags-Slots bestätigen",["haris","dejan"],"W1",NO,"typ:validierung","2026-06-16","2026-06-16",
[(ho("noah/HANDOUT-NOAH.md"),"Slots")],["EXT-05"],"N-DEMO1, N-DEMO2, N-GONOGO",
["Slots 19./26.06. + 03./10.07. bestätigt"]),
I("N-DATEN","N-DATEN · SHK-Beispieldaten liefern",["haris","dejan"],"W2",NO,"typ:validierung","2026-06-20","2026-06-26",
[(EX,"E3"),(ho("noah/HANDOUT-NOAH.md"),"Daten")],[],"AK-DEMO1",
["echte Pipelines + Custom-Felder + Rollen geliefert (für Seeds)"]),
I("N-LAGER","N-LAGER · Lagerist-Frage beantworten (M070-Scope)",["haris","dejan"],"W2",NO,"typ:validierung","2026-06-20","2026-06-26",
[(EX,"E2")],[],"—",
["Antwort: gehört Lagerist-Arbeitsplatz zum Validierungs-Versprechen? (entscheidet M070-Dünnung)"]),
I("N-DEMO1","N-DEMO1 · Demo 1 abnehmen (CRM klickbar)",["haris","dejan"],"W2",NO,"typ:validierung","2026-06-26","2026-06-26",
[(TL,"Gate W2 / Demo 1")],["AK-DEMO1","R-P7","D-CRM","N-SLOTS"],"—",
["CRM klickbar validiert (Geerkens-Praxisbrille)"]),
I("N-DEMO2","N-DEMO2 · Demo 2 abnehmen (Kette E2E)",["haris","dejan"],"W3",NO,"typ:validierung","2026-07-03","2026-07-03",
[(TL,"Gate W3 / Demo 2")],["R-P9b","N-SLOTS"],"—",
["Kette Lead→Sales→Auftrag E2E validiert"]),
I("N-GONOGO","N-GONOGO · Voll-Walkthrough + Go/No-Go",["haris","dejan"],"W4",NO,"typ:validierung","2026-07-10","2026-07-10",
[(TL,"Gate W4 / Launch")],["R-P13","AK-P21","N-SLOTS"],"[LAUNCH]",
["Voll-Walkthrough + Go/No-Go-Entscheidung Big-Bang-Launch"]),
# ---- ergänztes External ----
I("EXT-14","EXT-14 · Kostenmatrix/Preise mit Partner fixieren",["raffael"],"W2","Externals","typ:external","2026-06-24","2026-06-25",
[(EX,"AD-15 / Preise"),(TL,"Risiko 1 — härtester Business-Blocker")],[],"H-FOUND, H-PAYLINK, J-SEO (Pricing-Seite)",
["Kostenmatrix mit Partner durchgerechnet","Preise/Tier-Zuschnitt fixiert (Abo-Baukasten)","an Haris übergeben"]),
]
# ---- Anlegen ----
existing = {i["name"]: i["id"] for i in get_all("/issues/")}
wid2id = {}
for i in get_all("/issues/"):
nm = i["name"]
if " · " in nm: wid2id[nm.split(" · ", 1)[0]] = i["id"]
created = 0
for (wid, title, asg, cy, mo, ty, start, target, doks, bby, blk, dod) in ISSUES:
if title in existing:
iid = existing[title]
else:
win = f"{cy} (faellig {target})"
html = desc(doks, ", ".join(bby) if bby else "—", blk, ", ".join(asg), win, dod)
body = {"name": title, "description_html": html, "state": BACKLOG,
"assignees": [M[a] for a in asg], "labels": [lab[ty], lab[f"gate:{cy}"]],
"start_date": start, "target_date": target}
st, d = req("POST", "/issues/", body)
if st >= 400:
print("ISSUE FAIL:", wid, st, d); continue
iid = d["id"]; existing[title] = iid; created += 1
wid2id[wid] = iid
cn = CY[cy]
if cn in cyc: req("POST", f"/cycles/{cyc[cn]}/cycle-issues/", {"issues": [iid], "project": PID})
if mo in mod: req("POST", f"/modules/{mod[mo]}/module-issues/", {"issues": [iid], "project": PID})
print(f"Issues: {created} neu angelegt, {len(ISSUES)-created} bereits vorhanden.")
# ---- Blocker-Links (JIT-Netz) ----
print("\n--- Blocker-Links ---")
title_by_wid = {wid: t for (wid, t, *_ ) in ISSUES}
linkn = 0
for (wid, title, asg, cy, mo, ty, start, target, doks, bby, blk, dod) in ISSUES:
if not bby: continue
iid = wid2id.get(wid)
if not iid: continue
have = {l.get("title","") for l in get_all(f"/issues/{iid}/links/")}
for b in bby:
bid = wid2id.get(b)
if not bid:
print(f" ! Blocker {b} (von {wid}) nicht gefunden"); continue
lt = f"⛔ blocked-by: {b}"
if any(x.startswith(lt) for x in have): continue
url = f"{BASE}/{WS}/projects/{PID}/issues/{bid}/"
st, d = req("POST", f"/issues/{iid}/links/", {"title": lt, "url": url})
if st < 400: linkn += 1
else: print(" link FAIL", wid, "->", b, st, d)
print(f"Blocker-Links: {linkn} neu gesetzt.")
print("\nFERTIG (Phase B).", f"{BASE}/{WS}/projects/{PID}/")
@@ -1,250 +0,0 @@
#!/usr/bin/env python3
"""
HiveGrid -> Plane Ticket-Sync v2 (idempotent).
Legt die 38 v2-Tickets an: Cycles v2-W1..W3 + Label v2/block-2 + Issues über 7 Module
+ JIT-Blocker-Links (inkl. v1->v2-Andockpunkte, die v1-Tickets existieren bereits).
Doku-Permalinks zeigen ins HiveGrid-Gitea-Repo (wie in v1).
Re-Run-sicher: Issues per Name, Links per Titel-Prefix, Cycles/Labels per Name erkannt.
"""
import json, os, sys, time, urllib.request, urllib.error
# ⚠ BOOTSTRAP-GUARD (seit 2026-06-16): Plane ist LIVE + manuell gescrubbt (Umlaute, PWA-Umbau,
# v3-Defers). Dieses Skript matcht Issues per VOLLNAMEN — ein Re-Run nach dem Scrub fände
# umbenannte Tickets NICHT wieder und legte DUPLIKATE an. Laufende Pflege -> scrub_plane.py
# (Working-ID-Match, idempotent). Bewusster Neu-Bootstrap nur mit --force-bootstrap.
if "--force-bootstrap" not in sys.argv:
raise SystemExit(
"✋ Bootstrap gesperrt: Plane ist live + gescrubbt — Re-Run würde Duplikate erzeugen.\n"
" Laufende Änderungen -> .planning/plane-sync/scrub_plane.py (idempotent).\n"
" Echter Neu-Bootstrap nur bewusst mit --force-bootstrap."
)
TOK = {}
for line in open(os.path.expanduser("~/.cli-tokens/plane")):
line = line.strip()
if "=" in line and not line.startswith("#"):
k, v = line.split("=", 1); TOK[k.strip()] = v.strip()
BASE = TOK["PLANE_BASE_URL"].rstrip("/"); WS = TOK["PLANE_WORKSPACE"]; KEY = TOK["PLANE_API_KEY"]
PID = "35d0d968-8ab3-40cc-97b8-b81d917dc5df"
API = f"{BASE}/api/v1/workspaces/{WS}/projects/{PID}"
GITEA = "https://git.tool-ctrl.de/toolctrl-labs/HiveGrid-Cloud/src/branch/main"
M = {"raffael":"8e34a88c-19c9-4565-b4f5-50415d623ce8","akeshan":"8bf43e86-dc8a-4963-b57a-aeb5f2cc450a",
"david":"6c1fc63f-133d-4f40-86e4-7f658a6edcc7","dejan":"a5883f5e-3038-453c-9401-39ab1f40be9e",
"haris":"48367c1f-20d2-44f1-8ae2-faf35aa2052a"}
def _once(method, url, body):
r = urllib.request.Request(url, data=json.dumps(body).encode() if body is not None else None, method=method)
r.add_header("X-API-Key", KEY); r.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(r, timeout=30) as resp:
raw = resp.read().decode(); return resp.status, (json.loads(raw) if raw else {})
except urllib.error.HTTPError as e:
return e.code, {"_error": e.read().decode()[:300]}
def req(method, path, body=None):
url = path if path.startswith("http") else API + path
st, d = 0, {}
for _ in range(8):
st, d = _once(method, url, body)
if st == 429:
time.sleep(25); continue
time.sleep(1.1)
return st, d
return st, d
def get_all(path):
out, cursor = [], None
while True:
st, d = req("GET", path + (("?cursor=" + cursor) if cursor else ""))
if st >= 400: print("GET FAIL", path, st, d); break
if isinstance(d, list): out += d; break
out += d.get("results", [])
if d.get("next_page_results") and d.get("next_cursor"): cursor = d["next_cursor"]
else: break
return out
states = {s["name"]: s["id"] for s in get_all("/states/")}
BACKLOG = states["Backlog"]
# ---- v2-Cycles (idempotent) ----
CYCLES = [
("v2-W1 — Public-API/Postfach/Abo/KI-Start", "2026-07-13", "2026-07-19"),
("v2-W2 — KWP/Portal/Mobile-Start", "2026-07-20", "2026-07-26"),
("v2-W3 — Admin/Help/Dashboard/v2-Launch", "2026-07-27", "2026-07-31"),
]
existing_cycles = {c["name"]: c["id"] for c in get_all("/cycles/")}
cyc = dict(existing_cycles)
for name, start, end in CYCLES:
if name not in cyc:
st, d = req("POST", "/cycles/", {"name": name, "start_date": start, "end_date": end, "project_id": PID})
if st < 400: cyc[name] = d["id"]; print("Cycle angelegt:", name)
else: print("Cycle FAIL:", name, st, d)
W = {"v2-W1": CYCLES[0][0], "v2-W2": CYCLES[1][0], "v2-W3": CYCLES[2][0]}
# ---- Module + Labels (idempotent; Module existieren aus v1) ----
mod = {m["name"]: m["id"] for m in get_all("/modules/")}
lab = {l["name"]: l["id"] for l in get_all("/labels/")}
for name, color in {"v2": "#0d9488", "block-2": "#b91c1c"}.items():
if name not in lab:
st, d = req("POST", "/labels/", {"name": name, "color": color})
if st < 400: lab[name] = d["id"]; print("Label angelegt:", name)
# ---- Doku-Pfade ----
RM, RV2, EX, TL = ".planning/ROADMAP.md", ".planning/ROADMAP-V2.md", ".planning/EXTERNALS-CHECKLISTE.md", ".planning/TIMELINE.md"
RECUT = "docs/milestones/M19-KWP-API-RECUT.md"
def sp(x): return f"_bmad-output/specs/{x}"
def ho(p): return f".planning/handouts/{p}"
def link(path, sec): return f'<a href="{GITEA}/{path}">{path.split("/")[-1]} — {sec}</a>'
def desc(doks, bby, blk, owner, win, dod):
dl = "<br/>".join(link(p, s) for p, s in doks)
li = "".join(f"<li>{d}</li>" for d in dod)
return (f"<p><b>📚 Doku</b><br/>{dl}</p>"
f"<p><b>⛔ blocked-by:</b> {bby or '—'}<br/><b>➡️ blocks:</b> {blk or '—'}<br/>"
f"<b>👤 Owner:</b> {owner} &nbsp;·&nbsp; <b>🗓 Fenster:</b> {win}</p>"
f"<p><b>✅ Definition of Done</b></p><ul>{li}</ul>")
RA, AK, DA, DJ, HA, NO = "Raffael (Dev)", "Akeshan (Dev)", "David (Screens)", "Dejan (Website)", "Haris (Vertrieb)", "Noah (Validierung)"
def I(wid, t, a, c, mo, ty, extra, s, tg, dk, bb, bl, dod): return (wid, t, a, c, mo, ty, extra, s, tg, dk, bb, bl, dod)
ISSUES = [
# ---- EXTERNALS ----
I("VEXT-1","VEXT-1 · KWP-API-Zugang nutzen + Recut-Detailplanung gegen API-Doku",["raffael"],"v2-W2","Externals","typ:external",["block-2"],"2026-07-20","2026-07-20",
[(RM,"Phase 32"),(RECUT,"Recut bindend")],"—","VR-P32a, VN-1",["KWP-API-Zugang aktiv (Auth/Endpoints/Webhook-Support geklärt)","Recut-Detailplanung pro Milestone gegen echte Doku","kwpsync.exe NICHT bauen"]),
I("VEXT-2","VEXT-2 · HalloPetra-Spec erheben (API-Vertrag)",["raffael"],None,"Externals","typ:external",["block-2"],"2026-07-27","2026-07-29",
[(RM,"Phase 33")],"—","VR-P33",["HalloPetra-API-Vertrag erhoben (Auth/Webhook-Events/Recording/Call-Lifecycle)","auf TelephonyProviderInterface gemappt"]),
I("VEXT-3","VEXT-3 · Memberspot/Boobora-Spec erheben (HR-Drittanbieter)",["akeshan"],None,"Externals","typ:external",["block-2"],"2026-07-27","2026-07-29",
[(RM,"Phase 34")],"—","VA-P34",["Drittanbieter-API-Vertrag erhoben (Sync-Richtung/Datenkategorien)","auf Apps-Engine-Adapter gemappt"]),
I("VEXT-4","VEXT-4 · App-Store-Einreichung Apple + Google (TestFlight/interner Track)",["akeshan"],"v2-W3","Externals","typ:external",[],"2026-07-28","2026-07-28",
[(RM,"Phase 27"),(TL,"v2-Externals")],"VA-P27b","VJ-3 (Badges)",["Build eingereicht Apple + Google","TestFlight/interner Track sofort nutzbar","Privacy-Manifest + Demo-Account beigelegt"]),
I("VEXT-5","VEXT-5 · Gmail-Restricted-Scope-Verifizierung nachhalten",["raffael"],"v2-W1","Externals","typ:external",[],"2026-07-13","2026-07-19",
[(EX,"A2")],"—","VA-P23 (Gmail-Breite)",["Google-OAuth-App-Verification verfolgt (Antrag lief v1 EXT-13)","Outlook + Gmail-Testmodus als Brücke"]),
# ---- RAFFAEL ----
I("VR-P22a","VR-P22a · Public-API: OpenAPI + Read-Endpoints (M161)",["raffael"],"v2-W1",RA,"typ:phase",["v2"],"2026-07-13","2026-07-15",
[(RM,"Phase 22"),(sp("M34-public-api.md"),"Read-API")],"R-P2b (ApiKey)","VR-P22b",["GET contacts/companies/deals/projects/tasks/leads cursor-paginiert + Filter/Sort/Sparse-Fieldsets","read_only-Scope lässt sensible Felder aus (nicht 403)","OpenAPI-3.1 auto-generiert + Swagger-UI"]),
I("VR-P22b","VR-P22b · Public-API: API-Key-Auth/Scopes/Rate-Limit (M162/M027) + Webhooks (M163)",["raffael"],"v2-W1",RA,"typ:phase",["v2"],"2026-07-15","2026-07-17",
[(RM,"Phase 22"),(sp("M34-public-api.md"),"Auth/Webhooks")],"VR-P22a, R-P12a (Automation external_event)","—",["API-Key-Auth + Scopes + Rate-Limit (1k/h Key, 10k/h Tenant) + X-RateLimit-Header","Incoming-Lead-Webhooks (HMAC/Token) + Auto-Match","Ausgehende WebhookSubscriptions HMAC-signiert, Retry/Backoff, Auto-Disable nach 10 Fehlern"]),
I("VR-P25","VR-P25 · KI-Tiefe: HiveMind/RAG + Whisper + pgvector-Hybrid (M087)",["raffael"],"v2-W1",RA,"typ:phase",["v2"],"2026-07-18","2026-07-20",
[(RM,"Phase 25"),(sp("M18-ki-layer.md"),"RAG/HiveMind")],"R-P13 (Gateway), R-P11a (Outbox/Suche)","VA-P24b (KI-Coach), VA-P29 (RAG-Korpus retro)",["Embedding-Pipeline → TenantEmbedding (pgvector), Permission-Pre-Filter vor Vector-Search","Hybride globale Suche (Volltext+Vektor RRF) + Whisper-EU async","HiveMind-Agent mit Action-Boundary (keine Schreib-Aktion ohne Bestätigung)"]),
I("VR-P32a","VR-P32a · KWP: Connector + Health + Push-Queue (M089/090/091)",["raffael"],"v2-W2",RA,"typ:phase",["v2","block-2"],"2026-07-20","2026-07-22",
[(RM,"Phase 32"),(sp("M19-kwp-integration.md"),"KWP"),(RECUT,"Recut bindend")],"R-P5 (Apps-Engine M33a), VEXT-1","VR-P32b",["KWP-Connector hinterm M33a-Adapter-Interface, Creds per-Tenant-DEK","Connection-Health-Probe (generisch, statt Heartbeat)","Push-Queue via Cloud-Outbox + Rate-Limited-Replay"]),
I("VR-P32b","VR-P32b · KWP: Contract-Sentinel + Konflikt 'KWP gewinnt' (M092/093)",["raffael"],"v2-W2",RA,"typ:phase",["v2","block-2"],"2026-07-22","2026-07-24",
[(RM,"Phase 32"),(RECUT,"Contract-Sentinel")],"VR-P32a","VR-P32c",["API-Contract-Sentinel (zod-Validierung + Versions-Check) pausiert Sync bei Drift + Admin-Alarm","Konflikt 'KWP gewinnt' (LWW + Audit-Restore über rolledBackData)","Optimistic Locking HiveGrid-seitig"]),
I("VR-P32c","VR-P32c · KWP: Plantafel-Sync + Doku-Archiv + Task-ERP-Sync (M094/095/074)",["raffael"],"v2-W2",RA,"typ:phase",["v2","block-2"],"2026-07-24","2026-07-26",
[(RM,"Phase 32"),(RECUT,"Sync-Familie")],"VR-P32b, R-P9b (Plantafel/Auftrag)","VN-2 (E2E 24.)",["Plantafel-Sync bidirektional über die API","Dokumenten-Archiv-Sync KWP→@toolctrl/storage","Task-ERP-Sync (M074), idempotent über AppSyncItem; E2E mit Geerkens Fr 24.07."]),
I("VR-P28a","VR-P28a · AdminUser-Welt + AdminRBAC (M038/039)",["raffael"],"v2-W3",RA,"typ:phase",["v2"],"2026-07-27","2026-07-29",
[(RM,"Phase 28"),(sp("M37-plattform-admin.md"),"AdminUser/RBAC")],"AK-P19 (Admin-Welt-Fundament)","VR-P31, VA-P28b",["Eigene AdminUser-Klasse, kein BYPASSRLS, eigene Cookies SameSite=Strict","AdminRBAC + 2FA-Pflicht + Self-Permission-Schutz (letzter super_admin)","AdminAuditLog cross-tenant mit Reason-Pflicht + Impersonate mit Reason+Banner"]),
I("VR-P33","VR-P33 · Telefonie-Erweiterung HalloPetra (M105)",["raffael"],None,RA,"typ:phase",["v2","block-2"],"2026-07-29","2026-07-31",
[(RM,"Phase 33"),(sp("M12-telefonie.md"),"Provider-Pattern")],"AK-P15a (TelephonyProviderInterface), VEXT-2","—",["HalloPetra-Adapter implementiert TelephonyProviderInterface ohne Kern-Umbau","Webhook-Auto-Match auf Contact/Lead/Deal + Activity + Automation-Trigger","⚠ Vorlauf-Spec VEXT-2 nötig (HalloPetra in keiner Spec)"]),
I("VR-P31","VR-P31 · DSGVO-Vollausbau: AVV/Subprozessoren/Auskunft (M166)",["raffael"],"v2-W3",RA,"typ:phase",["v2"],"2026-07-29","2026-07-31",
[(RM,"Phase 31"),(sp("M35-dsgvo-export.md"),"DSGVO")],"AK-P21 (DSGVO-Fundament), VR-P28a (Admin-Legal)","—",["AVV-Versionierung + dynamisches PDF + elektronische Akzeptanz + Re-Akzeptanz-Frist (14 Tage → Read-Only)","Subprozessoren-Liste + Drittland-Warnung + 14-Tage-Widerspruchs-Workflow","User/Tenant-Voll-Export (ZIP, async, 7-Tage-Window) + Lösch-Workflow (Grace + Hard-Delete-Cron)"]),
# ---- AKESHAN ----
I("VA-P24a","VA-P24a · Self-Service-Abo-Baukasten-UI (M28-Delta)",["akeshan"],"v2-W1",AK,"typ:phase",["v2"],"2026-07-13","2026-07-15",
[(RM,"Phase 24"),(sp("M28-subscriptions-billing.md"),"Block P Self-Service")],"AK-P20 (Billing-Fundament)","VA-P24b",["Self-Service-Trial ohne Vertriebs-Freigabe (Zahlungsmethode-Pflicht)","Modul-Toggle + Preis-Vorschau (Abo-Baukasten)","App-Store-konform (kein Pricing-UI in Mobile, Web-Flow)"]),
I("VA-P23","VA-P23 · Postfach-Sync: Gmail/Outlook OAuth + Inbox + Threading + Versand (M096-098)",["akeshan"],"v2-W1",AK,"typ:phase",["v2"],"2026-07-13","2026-07-17",
[(RM,"Phase 23"),(sp("M09-email-inbox.md"),"OAuth-Sync/Inbox")],"R-P5 (Apps-Engine), R-P6 (CRM-Match), AK-P14a (Sende-Domain)","—",["OAuth-Connect Gmail (Pub/Sub) + Outlook (Graph + 3-Tage-Renewal-Worker)","Push-Sync + Auto-Match (From/Subject/1-Deal) mit manuellem Override","Inbox-Liste + Thread-Detail (sicherer HTML-Render) + Reply/Forward; read_own/read_all"]),
I("VA-P24b","VA-P24b · Onboarding-Wizard-UI (M157)",["akeshan"],"v2-W1",AK,"typ:phase",["v2"],"2026-07-18","2026-07-20",
[(RM,"Phase 24"),(sp("M29-onboarding.md"),"Wizard")],"VA-P24a, VR-P25 (KI-Coach retro)","—",["Wizard resumebar, Step-Validierung, Rückwärts-Block nach irreversiblen Actions","SHK-Branchen-Template deklarativ geseedet (weitere Branchen 'coming soon')","Erste-Schritte-Checkliste + Demo-Daten-Bulk-Löschung"]),
I("VA-P26a","VA-P26a · Customer-Portal: Auth/Magic-Link + Projekt-Sicht + Status-Mails (M141/142) ⭐",["akeshan"],"v2-W2",AK,"typ:phase",["v2"],"2026-07-20","2026-07-21",
[(RM,"Phase 26"),(sp("M15-customer-portal.md"),"Portal")],"AK-P4a (FileEntityLink), R-P6 (CRM), R-P9a (Project)","VA-P26b",["Eigene PortalUser-Klasse getrennt (Cookies/Routes), Magic-Link primär","Nur eigene Projekte/konfigurierte Stages sichtbar, fehlend → 404","Auto-Status-Mails bei project.stage_changed (Debounce/Bedingungen)"]),
I("VA-P26b","VA-P26b · Customer-Portal: Upload/Reklamation + Subunternehmer/Skalierung/DSGVO (M143-146) ⭐",["akeshan"],"v2-W2",AK,"typ:phase",["v2"],"2026-07-21","2026-07-22",
[(RM,"Phase 26"),(sp("M15-customer-portal.md"),"Portal-Skalierung")],"VA-P26a","VJ-3",["Upload (Kamera/Multi-File) → Files + Activity + PL-Notification","Reklamations-Form + Subunternehmer-Zugang + Skalierungs-Sicht (Service-Inbox)","Tenant-Admin-Konfig (Sichtbarkeit/Templates) + DSGVO-Lösch/Export für PortalUser"]),
I("VA-P27a","VA-P27a · Mobile-App (Expo): Setup + Build + Native-Features Push/Bio/Kamera (M167/168)",["akeshan"],"v2-W2",AK,"typ:phase",["v2"],"2026-07-23","2026-07-25",
[(RM,"Phase 27"),(sp("M36-mobile-app.md"),"Expo-Recut")],"R-P9b (OfflineSyncQueue), AK-P21 (notify-Push)","VA-P27b",["Expo-Setup + EAS-Build-Pipeline","Pre-Cache beim Login + Offline-Write-Queue (Foto/Note/Task/Checklist/Acceptance/Chat)","Push (Apple-Pre-Prompt) + Biometrie (Keychain/Keystore, 2FA bleibt) + Kamera"]),
I("VA-P27b","VA-P27b · Mobile-App: Tab-Bar/Routes + Offline-Sync + Store-Compliance (M169/170/171)",["akeshan"],"v2-W3",AK,"typ:phase",["v2"],"2026-07-25","2026-07-27",
[(RM,"Phase 27"),(sp("M36-mobile-app.md"),"Offline/Compliance")],"VA-P27a","VEXT-4 (Einreichung)",["Native Tab-Bar (Baustelle/Büro) + Mobile-Routes","Sync-on-Reconnect idempotent (lokale UUID) + Konflikt-Resolution","Store-Compliance: kein Pricing-UI (Build-Flag), Account-Löschung, Privacy-Manifest, Deeplinks + Force-Update"]),
I("VA-P29","VA-P29 · Help-Center + Help-Desk (M172-175)",["akeshan"],"v2-W3",AK,"typ:phase",["v2"],"2026-07-28","2026-07-30",
[(RM,"Phase 29"),(sp("M38-help-center.md"),"Help-Center"),(sp("M39-help-desk.md"),"Help-Desk")],"AK-P21 (Feedback-Widget), AK-P19 (Admin)","VJ-2, VR-P25 (RAG-Korpus retro)",["Markdown-Artikel (Frontmatter) + Build-Index + öffentliche Routen ohne Auth + SEO/Suche","Deep-Link Route→Artikel (Hilfe-Icon pro Route)","Feedback-Widget → Feedback + Auto-Kontext; Admin-Feedback-Liste/Detail + Audit"]),
I("VA-P30a","VA-P30a · Dashboard-Deluxe: Custom-Widgets + Export (M149/150)",["akeshan"],"v2-W3",AK,"typ:phase",["v2"],"2026-07-29","2026-07-30",
[(RM,"Phase 30"),(sp("M20-dashboard.md"),"Custom-Widgets/Export")],"R-P11b (Dashboard/Widget-Registry)","—",["Layout anpassbar (Drag/Resize, permission-gegateter Widget-Catalog)","Drilldown pro Kachel + CSV-Export + Report-Versand","Realtime-Updates mit Opt-Out"]),
I("VA-P30b","VA-P30b · Querschnitt: Notif-Bündelung/Quiet-Hours (M117) + Sprach-Wechsler/EN (M029)",["akeshan"],"v2-W3",AK,"typ:phase",["v2"],"2026-07-30","2026-07-31",
[(RM,"Phase 30"),(sp("M22-notifications.md"),"Bündelung"),(sp("M30-i18n.md"),"Wechsler/EN")],"AK-P3 (Notif-Center), R-P1b (i18n-Library)","—",["Notif-Bündelung (5-Min-Window → 1 Push/Mail mit count) + Quiet-Hours (Urgent-Override)","Pro-User-Präferenzen Event×Kanal","Sprach-Wechsler + EN-Locale (DeepL DE→EN mit Review) + Empfänger-Sprach-Auflösung"]),
I("VA-P28b","VA-P28b · Flag-Massen-Statistik (M033) + interne Lead-Pipeline (M040)",["akeshan"],"v2-W3",AK,"typ:phase",["v2"],"2026-07-29","2026-07-31",
[(RM,"Phase 28"),(sp("M32-feature-flags.md"),"Massen-Statistik")],"AK-P19 (Admin), VR-P28a (AdminAudit)","—",["Flag-Massen-Statistik (Effektiv-Auswertung X aktiv/Y nicht + Live-Filter-Vorschau + Bulk-Override)","Deterministische Auswertung (Override > Filter > Default) + Audit","Interne Lead-Pipeline (HiveGrid-eigene Verkäufe)"]),
I("VA-P34","VA-P34 · HR-Drittanbieter-Onboarding Memberspot/Boobora (M138)",["akeshan"],None,AK,"typ:phase",["v2","block-2"],"2026-07-29","2026-07-31",
[(RM,"Phase 34"),(sp("M14-hr-hub.md"),"HR-Onboarding")],"R-P5 (Apps-Engine), AK-P17a (HR-Hub), VEXT-3","—",["HR-Onboarding-Checklisten pro neuem MA (verlinkte Forms/Tasks/Reminders)","Drittanbieter als DataConnection via Apps-Engine (gleicher Mechanismus wie KWP)","⚠ Vorlauf-Spec VEXT-3 nötig (Memberspot/Boobora in keiner Spec)"]),
# ---- DAVID ----
I("VD-1","VD-1 · Screens: Abo-Baukasten-UI + Wizard + Postfach-Inbox",["david"],"v2-W1",DA,"typ:screen",["v2"],"2026-07-13","2026-07-16",
[(RV2,"P23/P24"),(sp("M28-subscriptions-billing.md"),"Self-Service")],"D-D0b (Archetypen)","VA-P24a, VA-P24b, VA-P23",["Abo-Baukasten-UI (Modul-Toggle + Preis-Vorschau) + Wizard-Steps + Postfach-Inbox; 4 Zustände + Reconciliation"]),
I("VD-2","VD-2 · Screens: Portal-Shell (extern sichtbar, eigener Polish)",["david"],"v2-W1",DA,"typ:screen",["v2"],"2026-07-17","2026-07-19",
[(RV2,"P26"),(sp("M15-customer-portal.md"),"Portal-UI")],"D-D0b","VA-P26a, VA-P26b",["Portal-Shell mit eigener Design-Sprache (höchster Polish), Endkunden-Flächen + 4 Zustände"]),
I("VD-3","VD-3 · Screens: Mobile-Shell Expo (Touch, Tab-Bar, Offline-Zustände)",["david"],"v2-W2",DA,"typ:screen",["v2"],"2026-07-20","2026-07-22",
[(RV2,"P27"),(sp("M36-mobile-app.md"),"Mobile-UI")],"D-D0b","VA-P27a, VA-P27b",["Mobile-Shell: native Tab-Bar, Touch-Paradigma, Offline-Zustände, Sync-Queue/Konflikt-View"]),
I("VD-4","VD-4 · Screens: Help-Center (Public-Content-Site)",["david"],"v2-W2",DA,"typ:screen",["v2"],"2026-07-23","2026-07-27",
[(RV2,"P29"),(sp("M38-help-center.md"),"Help-UI")],"D-D0b","VA-P29",["Help-Home/Kategorie/Artikel-Detail/Suche; SEO-Layout"]),
I("VD-5","VD-5 · Screens: Public-API-Keys + Admin-Tiefe + KWP-Setup",["david"],"v2-W2",DA,"typ:screen",["v2"],"2026-07-17","2026-07-22",
[(RV2,"P22/P28/P32")],"D-D0b","VR-P22b, VR-P28a, VR-P32a",["API-Key-/Webhook-Verwaltung + AdminUser/Flag-Statistik + KWP-Setup-Wizard (OAuth/Sync-Vorschau)"]),
I("VD-6","VD-6 · Screens: App-Store-Assets/Screenshots + v2-Politur + Visual-Reg",["david"],"v2-W3",DA,"typ:screen",["v2"],"2026-07-28","2026-07-31",
[(TL,"David v2")],"VD-2, VD-3","VJ-1",["App-Store-Screenshots/Assets (mit Dejan) + v2-Politur extern + Visual-Reg-Baselines"]),
# ---- DEJAN ----
I("VJ-1","VJ-1 · App-Store-Metadaten (Beschreibung/Keywords/Screenshots/Datenschutz)",["dejan"],"v2-W2",DJ,"typ:website",["v2"],"2026-07-20","2026-07-27",
[(ho("dejan/HANDOUT-DEJAN.md"),"App-Store")],"VD-6","VEXT-4",["Store-Listings Apple + Google (Beschreibung/Keywords/Screenshots/Datenschutz-Angaben) einreichbar"]),
I("VJ-2","VJ-2 · Help-Center-Inhalte einpflegen",["dejan"],"v2-W3",DJ,"typ:website",["v2"],"2026-07-28","2026-07-28",
[(ho("dejan/HANDOUT-DEJAN.md"),"Help-Inhalte")],"VA-P29","—",["Help-Center-Artikel (Markdown/Git-PRs) eingepflegt"]),
I("VJ-3","VJ-3 · Website-Update: Portal/Mobile/KWP als Features live + Store-Badges",["dejan"],"v2-W3",DJ,"typ:website",["v2"],"2026-07-31","2026-07-31",
[(ho("dejan/HANDOUT-DEJAN.md"),"Launch-Update")],"VA-P26b, VA-P27b, VR-P32c","—",["Feature-Seiten Portal/Mobile/KWP live + Store-Badges (sobald Freigabe)"]),
# ---- NOAH (Assignees Haris + Dejan) ----
I("VN-1","VN-1 · KWP-Test-Instanz/Testdaten bereitstellen",["haris","dejan"],"v2-W2",NO,"typ:validierung",["v2","block-2"],"2026-07-20","2026-07-23",
[(ho("noah/HANDOUT-NOAH.md"),"KWP-Verprobung")],"VEXT-1","VN-2",["KWP-Test-Instanz + Testdaten bei Geerkens bereit (ab 20.07.)"]),
I("VN-2","VN-2 · KWP-Sync-E2E-Verprobung mit Geerkens",["haris","dejan"],"v2-W2",NO,"typ:validierung",["v2","block-2"],"2026-07-24","2026-07-24",
[(TL,"KWP-E2E 24.07.")],"VR-P32c, VN-1","VH-1",["KWP-Sync-E2E mit echten Geerkens-Testdaten verprobt (Fr 24.07.)"]),
# ---- HARIS ----
I("VH-1","VH-1 · KWP-Sync als Verkaufsargument + weitere Onboardings/Conversions",["haris"],"v2-W2",HA,"typ:vertrieb",["v2"],"2026-07-24","2026-07-31",
[(ho("haris/HANDOUT-HARIS.md"),"AP-6")],"VN-2","—",["KWP-Sync-Referenz (Geerkens) als A-Lead-Argument","weitere Onboardings + Trial-Conversions Richtung 'erste Gelder ≤ Ende Juli'"]),
]
# ---- Anlegen ----
existing = {i["name"]: i["id"] for i in get_all("/issues/")}
wid2id = {}
for i in get_all("/issues/"):
if " · " in i["name"]: wid2id[i["name"].split(" · ", 1)[0]] = i["id"]
created = 0
for (wid, title, asg, cy, mo, ty, extra, start, target, doks, bb, bl, dod) in ISSUES:
new = title not in existing
if new:
win = (f"{cy} (faellig {target})" if cy else f"Backlog/agil (Trigger, Ziel {target})")
html = desc(doks, bb, bl, ", ".join(asg), win, dod)
labels = [lab[ty], lab["v2"]] + [lab[x] for x in extra if x in lab and x != "v2"]
body = {"name": title, "description_html": html, "state": BACKLOG,
"assignees": [M[a] for a in asg], "labels": list(dict.fromkeys(labels)),
"start_date": start, "target_date": target}
st, d = req("POST", "/issues/", body)
if st >= 400: print("ISSUE FAIL:", wid, st, d); continue
iid = d["id"]; existing[title] = iid; created += 1
else:
iid = existing[title]
wid2id[wid] = iid
if cy and W[cy] in cyc:
req("POST", f"/cycles/{cyc[W[cy]]}/cycle-issues/", {"issues": [iid], "project": PID})
if mo in mod:
req("POST", f"/modules/{mod[mo]}/module-issues/", {"issues": [iid], "project": PID})
print(("neu:" if new else "vorhanden:"), wid)
print(f"\nIssues: {created} neu, {len(ISSUES)-created} vorhanden.")
# ---- Blocker-Links (JIT-Netz; blocked_by aus den ⛔-Feldern, Working-IDs extrahiert) ----
print("\n--- Blocker-Links ---")
import re
def parse_bby(s):
# extrahiert Working-IDs am Zeilenanfang/komma-getrennt: "R-P2b (ApiKey), VEXT-1" -> ["R-P2b","VEXT-1"]
ids = re.findall(r'\b(?:V?(?:R|AK|A|D|J|H|N|EXT)[-A-Za-z0-9]*)\b', s)
out = []
for x in ids:
if x in wid2id and x not in out: out.append(x)
return out
linkn = 0
for (wid, title, asg, cy, mo, ty, extra, start, target, doks, bb, bl, dod) in ISSUES:
if not bb or bb == "—": continue
iid = wid2id.get(wid)
if not iid: continue
have = {l.get("title","") for l in get_all(f"/issues/{iid}/links/")}
for b in parse_bby(bb):
bid = wid2id.get(b)
if not bid: continue
lt = f"⛔ blocked-by: {b}"
if any(x.startswith(lt) for x in have): continue
url = f"{BASE}/{WS}/projects/{PID}/issues/{bid}/"
st, d = req("POST", f"/issues/{iid}/links/", {"title": lt, "url": url})
if st < 400: linkn += 1
else: print(" link FAIL", wid, "->", b, st, d)
print(f"Blocker-Links: {linkn} neu gesetzt.")
print("\nFERTIG (v2).", f"{BASE}/{WS}/projects/{PID}/")
@@ -47,6 +47,43 @@ Das **v1-Fundament der Email-Welt** (Postfach-Sync ist v2 → P23): pro Mandant
- `designer-briefs/P14-email-marketing.md` (Sende-Domain-Card, BCC-Toggle, Suppression)
- `_bmad-output/specs/M09-email-inbox.md` (nur Sende-Domain/Inbound/BCC — Rest v2)
## Umsetzung (Akeshan)
### 2026-06-29 — Backend-Fundament (S1–S5), ~45 % DoD
Branch `feat/AK-P14a` (noch nicht gepusht). Strategie laut Plan: Backend-Fundament jetzt, Kontakt-abhängige Verdrahtung + finale Subframe-UI zurückgestellt (R-P6/R-P7/David offen).
**Gebaut + verifiziert (alle Typechecks grün, 35 neue Unit-Tests grün):**
- **S1 Schema** (`prisma/schema/email.prisma`, additiv, `prisma db push`-bereit, keine Migration): neu `TenantSendingDomain` (RLS, DKIM-Privatkey als per-Tenant-Envelope-Ciphertext AD-8, Soft-Delete, `verificationStatus`, `firstProbeAt`/`lastProbedAt`, `mailjetDnsId`), `EmailSuppressionList` (`@@unique([tenantId,emailAddress])`, `reason`), `EmailTemplate` (`isDefault`/`isDemoData`). Erweitert: `TenantInboundAddress` (`purpose`/`localPart`/`fullAddress`/`enabled`), `EmailSender` (`isDefault`/`isDemoData`/`sendingDomainId`), `Email` (`purpose`/`deliveryStatus`/`relatedKontaktId`/`relatedProjektId` als bare Scalars — FK erst R-P6). RLS: 3 neue Modelle in `TENANT_SCOPED_MODELS` + `prisma/rls/email-isolation.sql` (deferred-cutover wie hr). `prisma validate` + Registry-Test grün.
- **S2 DNS-Auto-Probe** (`apps/worker/src/jobs/email-domain-probe-cron.ts` + Handler in `handlers.ts` + Registrierung `index.ts`, Payload-Literal in `queue/payloads.ts`): 5-Min-Cron, DKIM/SPF/DMARC-Lookup (injizierbarer Resolver), Status `pending→verifying→verified|failed`, 7-Tage-Klartext-Key, `notify` bei verified/failed, Write auto-auditiert (M001). 9 Unit-Tests.
- **S3 Bounce/Complaint → Suppression**: `packages/db/src/email-suppression.server.ts` (idempotenter Upsert + `isEmailSuppressed`-Pre-Send-Guard) + `apps/api/src/webhooks/mailjet-suppression.ts` (reiner Signal-Parser, Tenant via Mailjet-`Payload`/`CustomID`), additiv in den bestehenden Mailjet-Webhook gehängt (optionaler Dep → keine Regression). 13 Unit-Tests.
- **S4 Tracking-Endpoints**: `packages/db/src/email-tracking.server.ts` (`anonymizeTrackingIp` sha256/trunc, `safeRedirectTarget` Open-Redirect-Schutz, `recordEmailOpen`/`recordEmailClick`) + öffentliche Routes `e.o.$outboundEmailId` (1×1-GIF) / `e.c.$outboundEmailId` (302-Redirect). `ipHash` additiv auf Open/Click-Modellen. 8 Unit-Tests.
- **S5 Provider-Abstraktion**: `packages/mail/src/mailjet-dns.ts` (`createMailjetDnsProvisioner`: registerDomain/getRecords/check via Mailjet `/v3/REST`, Basic-Auth, injizierbarer fetch, AD-6, kein US-Kern). 5 Unit-Tests.
**Teilweise (S6) / offen:**
- **S6 (angefangen)** — `buildSendingDomainVM` (`packages/view-models/src/email/sending-domain.vm.ts`, 5 Tests grün) ✅. Offen: Domain-Service (`emailDomainService.server.ts`, wiret S1+S5+withTenantRLS), reachable Route + Plain-UI + i18n + Smoke. **Befund:** Modul `email-marketing` existiert bereits inkl. Shell `app.$tenant/email-marketing.tsx` (Gate `requireTenantProductAccess(featureKeys.emailMarketing)`) + Sidebar-Eintrag (`AppSidebar.tsx`, `hasFeatureAccess("email-marketing")`) + `email-marketing/senders.tsx`. → Sende-Domain kommt als gated Tab in dieses Modul; finale Route `/settings/integrations/email` ist Davids UI-Entscheidung (Dev-Nachzug).
- **S8 (revidiert)** — Konnektor/Sidebar **existiert bereits** über `featureKeys.emailMarketing` ("email-marketing"); **kein** Eingriff in `@hivegrid/entitlements` nötig (Ticket-`tenantHasModule('email')` = das vorhandene `email-marketing`-Feature). Offen: tenant-seitige Permissions `email.domain.view`/`.manage` in 4 Registries (`DefaultPermissions.ts`, `permissions-seed.server.ts` Array+Rollen-Map, `roleDisplay.ts` scope→`emailDomain`, i18n scopes/actions inkl. `manage`) + i18n-Parität (4 Locales, roleDisplay-Test + verify:i18n als Gates).
- **S7** BCC-/Inbound→Activity-am-Kontakt + Lead-Anlage — **blockiert R-P6 (`Kontakt`) / R-P7 (tenant-Lead)**; Empfang/Persistenz machbar, Kontakt-Resolver-Write bleibt markierter Stub.
### 2026-06-29 (Update) — S6 + S8 umgesetzt
- **S6** `emailDomainService.server.ts` (Context+Permission-Gate 404, getSendingDomainState, createSendingDomain via Mailjet-Provisioner S5, setBccLogging, pure normalizeDomainInput/bccAddressFor) + Route `app.$tenant/email-marketing/sending-domain.tsx` (Loader→VM, Actions create-domain/toggle-bcc) + Plain-UI (DNS-Karten+Copy, Status-Ampel, 7-Tage-Klartext, BCC-Toggle, Empty-State, data-ui-id) + Tab im Modul-Shell. **Reachable** über das gated `email-marketing`-Modul.
- **S8** Permissions `email.domain.view/.manage` in 4 Registries (DefaultPermissions, permissions-seed Array+Rollen-Map, roleDisplay scope `emailDomain`, i18n en/de) + Domain-Screen-i18n (en/de). Konnektor = vorhandenes Feature `email-marketing` (kein entitlements-Eingriff). roleDisplay-Test (7) + verify:i18n grün, web/db typecheck grün.
### 2026-06-29 (Merge) — main in Branch + Email-Phasen-Migration (AD-21)
`main` in `feat/AK-P14a` gemergt (R-P6 typisierte Kontakte, R-P6B/C, AD-2-Präzisierung). Einzige Konflikte im generierten Prisma-Client → via `pnpm db:generate` aus dem gemergten Schema neu erzeugt (enthält beide Seiten). Kein neuer Entity-Builder-Bezug (AD-2 + Raffaels R-P14C-Ownership eingehalten).
Der CI-Docker-Smoke (`migrate deploy` gegen leere DB + Seed) schlug fehl (`P2022: TenantInboundAddress.createdAt does not exist`) — die Email-Schema-Änderungen lagen per AD-21 nur als `db push` vor, ohne Migration. Migration **`20260629120000_p14a_email_foundation`** erzeugt (AD-21 „beim Merge"): schema-zu-schema-Diff (merge-base vs. Branch), auf das Email-Delta getrimmt (3 Enums + 3 neue Tabellen + Spalten auf TenantInboundAddress/Email/EmailSender/OutboundEmail* + Indizes), Forward-only mit Inverse-SQL-Kommentar (DoD 28), RLS deferred (Artefakt `prisma/rls/email-isolation.sql`, Muster wie AK-P4a/hr). Verifiziert: `migrate deploy` auf frischer DB grün, Seed läuft komplett durch (`rolesAndPermissions` + entitlements `success`), `prisma validate` grün.
### DoD-Stand (aktualisiert)
- [x] Sende-Domain DKIM/SPF/DMARC + 5-Min-Probe + Ampel + 7-Tage-Klartext — Backend (S1+S2) + reachable Plain-UI (S6) ✅ (Subframe-Politur = Dev-Nachzug nach David)
- [~] BCC-Logging-Brücke — Toggle + BCC-Adresse-UI ✅ (S6); Mail→Activity-am-Kontakt **blockiert R-P6**
- [~] Tenant-Inbound → Lead/Activity — Schema ✅; Verdrahtung **blockiert R-P6/R-P7**
- [x] Email-Tracking + Open/Click (IP anon) + Bounce → Suppression — ✅ (S3+S4; Delivery-Status via Send-Pfad = AK-P14b)
- [x] SMTP-Provider-Abstraktion (Mailjet, AD-6) — ✅ (S5)
- [x] Konnektor `tenantHasModule` (Sidebar/Routes/Actions gated) — ✅ (S8, Feature `email-marketing` + email.domain-Permissions)
- [x] RLS auf allen neuen Tenant-Tabellen; `is_demo_data` auf seedbaren Sendern/Templates — ✅ (S1, RLS deferred-cutover-Artefakt wie hr)
**Stand: 5/7 DoD voll + 2 partiell ≈ ~71 % done.** Selbst-machbar offen (klein): Suppression-Listen-UI im Senders-Screen, e2e-Smoke (braucht laufende Stack+Seed). Extern blockiert: BCC/Inbound→Activity (S7, R-P6/R-P7), finale Subframe-UI (Davids D-P14/D-KAMP-Delta).
## Dev-Nachzug (nach Davids `D-KAMP`/`D-P14`-Delta)
- [ ] Spec/ENTITY-MODEL an Davids UI-Entscheidungen angepasst
- [ ] REQUIREMENTS/PHASE-SOURCES nachgezogen
@@ -49,6 +49,66 @@ Das **vollständige DSGVO-konforme Email-Marketing** (bleibt VOLL inkl. A/B): ei
- `designer-briefs/P14-email-marketing.md` (Kampagnen-Editor BESPOKE, Segmente, Unsubscribe-Page)
- `_bmad-output/specs/M10-email-marketing.md`
## Umsetzung (Akeshan)
### 2026-06-29 — Typisierte Backend-Foundation (S1–S7), ~78 % DoD
Branch `feat/AK-P14b` (von `feat/AK-P14a`, noch nicht gepusht). Plan-Entscheidung (User, 2026-06-29):
**typisierter Pfad** (neuer Code liest R-P6 `Kontakt`, KEIN neuer Entity-Builder-Konsument, AD-2) +
**Backend-Foundation jetzt**, bespoke Editor/Unsubscribe-Page + Lead/Projekt-Segment-Dimensionen + e2e/Visual
zurückgestellt. Der bestehende Entity-Builder-Kampagnen-Pfad (`CrmService`/`EntityViewsApi`/
`OutboundEmail.contactRowId`) bleibt **unangetastet** (gehört R-P14C).
**Gebaut + verifiziert (46 neue Unit-Tests grün, alle Typechecks 0 Fehler, verify:i18n exit 0):**
- **S1 Schema** (`prisma/schema/email.prisma`, additiv, `db push`, keine Migration — AD-21): neu `RecipientSegment`
(+`SegmentSnapshotMember`), `CampaignVariant` (A/B), `KontaktMarketingConsent` (in email.prisma, **nicht**
crm.prisma), `Unsubscribe`; `Campaign` (+`recipientSegmentId`/`scheduledAt`/`timezone`/`previewText`/
`footerHtml`/`isDemoData`/`archivedAt`), `OutboundEmail` (+`kontaktId`/`campaignVariantId`/`unsubscribeToken`/
`trackingToken`). Bare `tenantId`/`kontaktId`-Scalars. RLS-Artefakt `prisma/rls/email-isolation.sql` +
`TENANT_SCOPED_MODELS`-Registry nachgezogen. `models.test.ts` (11) grün.
- **S2 Segment-Engine** (typisiert, Kontakt-only): pure `segmentFilter.ts` (Validierung Tiefen-/Node-Cap,
Lookup-Collection, JSONB→`Prisma.KontaktWhereInput`) + `SegmentService.server.ts` (Lookup-Auflösung Tag/
Custom-Feld, Auto-Exclusion archiviert/keine-Email/Unsubscribe/Suppression/kein-Double-OptIn als
non-overlapping Breakdown, Live-Count+Sample, Snapshot). 13 Tests.
- **S3 Versand-Engine**: pure `personalization.ts` (Token inkl. `cf.<key>`, idempotente Footer-Injektion) +
`campaignSendPlan.ts` (A/B per d'Hondt interleaved, Throttle→BullMQ-`delay`, timezone-`startAt`) +
`CampaignSendService.server.ts` (Audience→harter Checklisten-Gate→Suppression-Re-Check→OutboundEmail-Row→
ein `mail.transactional`-Job je Empfänger mit List-Unsubscribe-Header; kein neuer Worker-Queue). 14 Tests.
- **S4 Double-Opt-In + Ein-Klick-Abmeldung**: `marketingToken.server.ts` (HMAC unsub/optin, 5 Tests) +
`ConsentService.server.ts` (startDoubleOptIn/confirmOptIn/unsubscribeKontakt, idempotent) + public Routes
`optin.$token` + `unsubscribe.$token` (erweitert um Marketing-Token, RFC-8058 One-Click). i18n `marketing.optin.*`.
- **S5 VMs** (`packages/view-models/src/email/`, pure): `presend-checklist.vm` (kanonischer Checklisten-Kern,
von der Send-Engine importiert), `campaign-detail.vm` (KPI%/Klick-Map/Pro-Empfänger/A-B), `campaign-list.vm`,
`segment.vm`. 7 Tests.
- **S6 Permissions**: `emailMarketing.view/.manage` in 4 Registries (permissions-seed Array+Rollen-Map,
DefaultPermissions, roleDisplay-Scope, i18n en/de). roleDisplay.test (7) grün.
- **S7 Segmente-Route** (`app.$tenant/email-marketing/segments.tsx`, reachable, gated, i18n en/de, Empty-State):
Liste + Erstellen aus AND/OR-Filter + Live-Preview gegen typisierte Kontakte. Tab im Modul-Shell.
**Proof:** 46/46 neue Unit-Tests grün; `@hivegrid/web`+`@hivegrid/db`+`@hivegrid/view-models`+`@hivegrid/cli`
typecheck 0 Fehler; `prisma validate` + `db push` (lokal) grün; `verify:i18n` exit 0 (Parität en/de);
hardcoded-strings auf Baseline; lefthook pre-commit je Commit grün. 7 Commits (S1–S7) auf `feat/AK-P14b`.
### DoD-Stand
- [~] Block-/Rich-Text-Editor + Tokens (inkl. Custom-Felder) + Pflicht-Footer + Desktop/Mobile-Vorschau —
Token-/Footer-Logik ✅ (pure, getestet); Rich-Text-Editor + Vorschau-UI **blockiert (David D-KAMP)**.
- [x] Segmente: verschachtelter AND/OR-Baum, Live-Auflösung, Auto-Exclusion, dynamisch/statisch — ✅
(Kontakt-Dimensionen). Lead/Projekt-Dimension **blockiert (R-P7/R-P8 offen)**.
- [x] Versand-Engine (BullMQ, Rate-Limit/Throttling, timezone-Scheduling) — ✅ (Throttle via per-Empfänger-
`delay`-Spreizung; worker-seitiger Limiter unverändert).
- [~] Double-Opt-In + Suppression-Check vor Versand + One-Click-Unsubscribe + List-Unsubscribe-Header — ✅
Kern; volle Form-Widget-Verdrahtung (Submission→Opt-In-Mail-Trigger) **teilweise** (Service da, Auto-Trigger offen).
- [x] Pflicht-Checkliste (Domain/Unsub/Impressum/Subject/>0/Daily-Cap) + Block + Fix-Link — ✅ Logik + harter
Server-Gate; Checklisten-**UI** Teil des bespoke Editors (blockiert David).
- [x] Reporting (Open/Click/Bounce/Abmeldung %, Klick-Map, Pro-Empfänger) + A/B-Varianten — ✅ VMs + A/B-Schema/
-Versand; Reporting-**UI** blockiert (David).
- [x] Konnektor `tenantHasModule('email')` + RLS + `is_demo_data` — ✅ (Feature `email-marketing` + RLS-Artefakt
+ `emailMarketing.*`-Permissions).
**Stand: 5/7 DoD voll + 2 partiell ≈ ~78 % done.** Extern blockiert: bespoke Kampagnen-Editor + Reporting-UI +
Unsubscribe-Page-Branding (Davids `D-KAMP`/`D-P14`-Delta), Lead/Projekt-Segmentdimension (R-P7/R-P8), e2e-Smoke
(braucht Stack+Seed) + Visual-Regression (braucht finale UI), Form-Widget→Opt-In-Auto-Trigger. Selbst-machbar
offen: Form-Widget-Trigger verdrahten (klein). **Nicht** anfassen: Alt-Pfad-Migration (R-P14C, Raffael).
## Dev-Nachzug (nach Davids `D-KAMP`/`D-P14`-Delta)
- [ ] Spec/ENTITY-MODEL an Davids UI-Entscheidungen angepasst
- [ ] REQUIREMENTS/PHASE-SOURCES nachgezogen
@@ -15,7 +15,7 @@
- **Konflikt-Risiko:** gering — additive Tabellen; `apps.prisma` + `crm.prisma` nur lesend für Relationen.
## Was wir bauen
Den **AirCall-Adapter** als Referenz-Implementierung hinter dem `TelephonyProviderInterface` (HalloPetra dockt v2 an → P33): **Click-to-Call** und **Inbound-Anrufe** über eine Apps-Engine-`DataConnection` (verschlüsselte OAuth-Tokens, Webhook-Secret). Unbekannte Nummern legen automatisch einen **Lead** an, bekannte werden auf Kontakt/Lead/Projekt **gematcht** (Hard/Soft mit Confidence + Override). Anrufe landen im **Activity-Stream** (in/out/verpasst), nach dem Anruf gibt es einen **Markdown-Notiz-Editor** (Whisper-Diktat ist v2) und eine Recording-Anzeige. **Anruf-Events** stehen als Automation-Trigger bereit (verpasster Anruf → Rückruf-Aufgabe, verdrahtet in P12).
Den **AirCall-Adapter** als Referenz-Implementierung hinter dem `TelephonyProviderInterface` (HalloPetra dockt v2 an → P33): **Click-to-Call** und **Inbound-Anrufe** über eine Apps-Engine-`DataConnection` (verschlüsselte OAuth-Tokens, Webhook-Secret). Unbekannte Nummern legen automatisch einen **Lead** an, bekannte werden auf Kontakt/Lead/Projekt **gematcht** (Hard/Soft mit Confidence + Override). Anrufe landen im **Activity-Stream** (in/out/verpasst), nach dem Anruf gibt es einen **Markdown-Notiz-Editor** und eine Recording-Anzeige. **Whisper-Diktat entfällt nach AD-26.** **Anruf-Events** stehen als Automation-Trigger bereit (verpasster Anruf → Rückruf-Aufgabe, verdrahtet in P12).
## Benötigte Screens (Backend bedient — Design liefert `D-CHAT` + `D-P15`)
| Screen [Route] | Kern-Inhalt |
@@ -11,11 +11,11 @@
## Abhängigkeiten & Parallelität
- **Blocked by:** `AK-P15a` (P15-Vorlauf), `AK-P4a` (Files — Chat-Anhänge via FileEntityLink)
- **Blocks:** **Demo 2** (Noah-Demo #2, Gate W3)
- **Läuft parallel zu:** Raffaels Kern-Kette. Eigene `prisma/schema/chat.prisma` (AD-21); entity-linked Channels referenzieren Projekt/Deal aus dem CRM-Kern (R-P6/P8) read-only. **Sync-Punkt:** ProjektMitglied-Sync braucht den Projekt-Kern (P8/P9).
- **Läuft parallel zu:** Raffaels Kern-Kette. Eigene `prisma/schema/chat.prisma` (AD-21); entity-linked Channels referenzieren Projekt aus dem CRM-Kern (R-P6/P8) read-only. **Sync-Punkt:** ProjektMitglied-Sync braucht den Projekt-Kern (P8/P9).
- **Konflikt-Risiko:** keines auf Schema-Ebene; `crm.prisma`/`files.prisma` nur lesend.
## Was wir bauen
Den **Real-Time-Team-Chat (Bespoke, AD-9)**: WebSocket-Messaging mit **Presence, Typing-Indicator, Read-Receipts** über Redis-Pub/Sub (Scale-Out), drei **Channel-Typen** (public/private Group, DM, entity-linked Projekt/Deal mit ProjektMitglied-Sync), **@Mentions** (User/Projekt/Deal/Kontakt/Firma) mit Autocomplete, **Threads, Reactions, Pins** (max 50), **File-Uploads** (via Files), Link-Previews, **Mark-as-Task** und **Cross-Channel-Suche**. Mobile-First für Monteure (vereinfachte View, Foto-Direct-Upload, Push). Ein **Admin-Audit-Modus** (`read_all_channels`) erfordert Begründungs-Pflicht je Session + DM-Audit-Banner und wird audit-geloggt.
Den **Real-Time-Team-Chat (Bespoke, AD-9)**: WebSocket-Messaging mit **Presence, Typing-Indicator, Read-Receipts** über Redis-Pub/Sub (Scale-Out), drei **Channel-Typen** (public/private Group, DM, entity-linked Projekt mit ProjektMitglied-Sync), **@Mentions** (User/Projekt/Kontakt/Firma) mit Autocomplete, **Threads, Reactions, Pins** (max 50), **File-Uploads** (via Files), Link-Previews, **Mark-as-Task** und **Cross-Channel-Suche**. Mobile-First für Monteure (vereinfachte View, Foto-Direct-Upload, Push). Ein **Admin-Audit-Modus** (`read_all_channels`) erfordert Begründungs-Pflicht je Session + DM-Audit-Banner und wird audit-geloggt.
## Benötigte Screens (Backend bedient — Design liefert `D-CHAT` + `D-P15`)
| Screen [Route] | Kern-Inhalt |
@@ -29,10 +29,10 @@ Den **Real-Time-Team-Chat (Bespoke, AD-9)**: WebSocket-Messaging mit **Presence,
## Definition of Done
- [ ] **WebSocket-Real-Time**: Presence, Typing, Read-Receipts, Redis-Pub/Sub (AD-9), Optimistic-UI, Reconnect
- [ ] **3 Channel-Typen**: public/private Group, DM, entity-linked (Projekt/Deal) mit **ProjektMitglied-Sync**
- [ ] **3 Channel-Typen**: public/private Group, DM, entity-linked (Projekt) mit **ProjektMitglied-Sync**
- [ ] **@Mentions** mit Autocomplete + **Threads** + **Reactions** + **Pins** (max 50) + **File-Uploads** (Files) + Link-Previews + **Mark-as-Task**
- [ ] Channel-Index (Filter/Suche/Pin/Mute/Archiv + Channel-Browser) + **Cross-Channel-Suche** + Mentions-Aggregat
- [ ] Entity-linked Channel erscheint im Projekt-/Deal-Detail (P8/P9)
- [ ] Entity-linked Channel erscheint im Projekt-Detail (P8/P9)
- [ ] **Admin-Audit** (`read_all_channels`): Begründungs-Pflicht je Session + DM-Audit-Banner, audit-geloggt
- [ ] **Mobile-First-Variante** für Monteure (vereinfachte View, Foto-Direct-Upload, Push)
- [ ] Konnektor `tenantHasModule('chat')`; RLS auf allen Tenant-Tabellen
@@ -6,7 +6,7 @@
| **Owner** | Akeshan |
| **Phase** | P16 — Kalender, Booking, Forms & Signatur |
| **Cycle/Fenster** | W4 — USP, Härtung & Launch · fällig 2026-07-04 · Gate W4 |
| **Status** | geplant |
| **Status** | in Arbeit (~83 % DoD, Backend+Routen+Sync+Worker+Write-Pfad fertig; Rest David D-P16/D-BOOK + Staging-DB) |
## Abhängigkeiten & Parallelität
- **Blocked by:** `EXT-10` (Google-Cloud-Projekt + OAuth-Consent für Calendar-API), `R-P5` (Apps-Engine — Kalender läuft als `DataConnection`)
@@ -25,12 +25,12 @@ Den **bidirektionalen Kalender-Sync mit Google** (Outlook-Adapter → v2, Interf
| Kalender-Verbindungs-Detail [`/settings/integrations/calendar/:id`] | gewählte Kalender, Sync-Status, Konflikt-Liste (letzte 50, Klartext + Resolution) |
## Definition of Done
- [ ] **Bidirektionaler Google-Sync** (CalendarAdapter, Auto-Token-Refresh, Webhook-Subscriptions); Outlook → v2 (Interface provider-agnostisch)
- [ ] HiveGrid-Termine (Booking/Projekt/Plantafel) gehen **extern**, externe Events kommen **read-only** für Verfügbarkeit
- [ ] **Connection pro User**; externe Event-Inhalte nie an andere User leaken (nur „belegt von X bis Y")
- [ ] **Last-Write-Wins-Konflikt** mit Klartext-Notification + Konflikt-Log (letzte 50)
- [x] **Bidirektionaler Google-Sync** (CalendarAdapter, Auto-Token-Refresh, Webhook-Subscriptions); Outlook → v2 (Interface provider-agnostisch)
- [x] HiveGrid-Termine (Booking/Projekt/Plantafel) gehen **extern**, externe Events kommen **read-only** für Verfügbarkeit
- [x] **Connection pro User**; externe Event-Inhalte nie an andere User leaken (nur „belegt von X bis Y")
- [x] **Last-Write-Wins-Konflikt** mit Klartext-Notification + Konflikt-Log (letzte 50)
- [ ] Kalender-Ansicht mit Quell-Markern (HiveGrid + extern)
- [ ] Konnektor `tenantHasModule('calendar')`; RLS auf allen Tenant-Tabellen; OAuth verschlüsselt (AD-8, via Apps-Engine)
- [x] Konnektor `tenantHasModule('calendar')`; RLS auf allen Tenant-Tabellen; OAuth verschlüsselt (AD-8, via Apps-Engine)
## Technische Bausteine
- **Entities** (eigene `prisma/schema/calendar.prisma`): `CalendarConnection` (provider google, oauth_refresh_token **verschlüsselt**, status — **pro User**), `CalendarEvent` (source internal/external_synced, starts/ends_at, is_busy, internal_entity_link polymorph), `CalendarEventMapping` (internal↔external_id), `CalendarSyncCursor` (cursor_token), `CalendarWebhookEvent` (payload). `apps.prisma` nur lesend (DataConnection-Bezug, AD-21).
@@ -50,3 +50,73 @@ Den **bidirektionalen Kalender-Sync mit Google** (Outlook-Adapter → v2, Interf
- [ ] Spec/ENTITY-MODEL an Davids UI-Entscheidungen angepasst
- [ ] REQUIREMENTS/PHASE-SOURCES nachgezogen
- [ ] Plane-Ticket `AK-P16a` aktualisiert
## Umsetzungs-Notiz (2026-06-30, Akeshan) — ~70 % DoD
Branch `feat/AK-P16a`, 6 lokale Commits (`ca0a410` → `4ac8cb07`), nichts gepusht,
Plane-Status unverändert. Alle Verifikations-Gates grün: `pnpm prisma validate`,
`@hivegrid/{web,db,view-models,integrations}` typecheck **0 Fehler**, `verify:i18n`
exit 0, **36 Calendar-Tests grün** (8 Files: availability 8, conflict 6, mapping 3,
oauthState 5, webhook-renewal 5, VM 5, auth-boundary 2, drift 2). lefthook pre-commit
je Commit grün.
**DoD-Stand:**
- [x] **Connection pro User + Leak-Schutz (DoD 3)** — `calendar.prisma` per-User,
`availability.ts` exponiert nur Busy-Intervalle (getestet), Owner-Check im Guard.
- [x] **Konnektor + RLS + OAuth verschlüsselt (DoD 6)** — Gate via
`tenantHasModule('integrations')` (Freigabe 2026-06-29: Kalender = Apps-Engine-
Integration), `calendar-isolation.sql` (deferred cutover), Token-Seal per-Tenant-DEK
(AD-8).
- [~] **Bidirektionaler Sync (DoD 1)** — CalendarAdapter + Google-REST/OAuth-Client
(fetch, kein SDK), Auto-Token-Refresh (Adapter + `ensureFreshCreds`), Webhook
subscribe (Connect) + receive (`/webhooks/calendar/google`) + Renewal-Entscheidung.
**Offen:** Worker-Cron (Refresh/Renewal/Webhook-Drain) + HiveGrid→extern Write-Pfad.
- [~] **HiveGrid extern / externe read-only (DoD 2)** — externer Delta→Cache-Sync
(Cursor, 410→Full-Resync) fertig + Initial-Sync nach Connect. **Offen:** Write-Pfad
HiveGrid→Google (`CalendarWriteService`). **Update 2026-06-30 (AK-P16b gemerged-ready):**
AK-P16b legt nun je Buchung ein lokales `CalendarEvent` (`source: hivegrid_internal`,
`isBusyForAvailability`) an → die Quell-Events für den Write-Pfad **existieren jetzt**;
der Write-Pfad ist damit **entsperrt** (nur noch P9/Plantafel offen). Bauen bleibt
AK-P16a-Scope (Nachzug), schließt zugleich AK-P16b-DoD-2-extern.
- [~] **Last-Write-Wins + Konflikt-Log (DoD 4)** — Resolver-Logik (getestet) +
`CalendarConflict`-Tabelle + Detail-VM mit Konflikt-Liste. **Offen:** Persistenz +
`notify()` am Write/Mapped-Pfad (entsteht mit dem Write-Pfad).
- [~] **Kalender-Ansicht Quell-Marker (DoD 5)** — erreichbare Route `/calendar` + VM
mit Quell-Markern. **Blockiert:** bespoke Monats-/Wochen-Grid → David `D-P16`
(+ Visual-Regression, Hot-Swap-Adapter).
**Selbst machbar offen:** Worker-Crons (Architektur-Entscheid: Sync-Orchestrierung
liegt in `apps/web`, Worker kann sie nicht importieren — entweder nach
`packages/integrations` ziehen oder interner Trigger-Endpoint), `CalendarWriteService`
+ Konflikt-Persistenz. **Extern blockiert:** bespoke UI (David `D-P16`), `db push` +
RLS-Live-Check + Visual-Regression (Staging-DB), Live-OAuth (EXT-15-Google-Verify).
**Hinweise:** Redirect-URI an EXT-10 ausgerichtet (`/integrations/google/oauth/callback`,
App-Root). Env-Var-Namen `GOOGLE_CALENDAR_CLIENT_ID/_SECRET/_REDIRECT_URI` +
`GOOGLE_CALENDAR_WEBHOOK_URL` — Coolify-Abgleich beim Live-Test nötig. Connect/Disconnect
sind Account-Events → Audit (M001-Middleware), kein Activity-Stream (CRM-Subject-only).
## Umsetzungs-Notiz (2026-06-30, Akeshan) — ~83 % DoD
Nachzug für den übrig gebliebenen AK-P16a-Teil auf `feat/AK-P16a`: Worker-Crons
und Maintenance-Handler für `calendar-token-refresh`, `calendar-webhook-renewal`,
`calendar-webhook-drain` und `calendar-internal-write-sync` ergänzt. Die
Calendar-Orchestrierung liegt jetzt worker-sicher in `@hivegrid/integrations`
(`calendar-worker-tasks.server`) und importiert keine `apps/web`-Module. Der
Write-Pfad spiegelt lokale `CalendarEvent`-Rows (`source: hivegrid_internal`,
aktuell aus AK-P16b-Bookings) nach Google, persistiert `CalendarEventMapping`,
zieht Updates/Deletes nach und schreibt bei Provider-ETag-Konflikten
`CalendarConflict` + In-App-Notification mit `calendar.conflict.*`.
**DoD-Stand:** 5/6 DoD = ~83 % done. Selbst machbarer Backend-Rest ist erledigt.
Offen bleibt DoD 5 als extern blockiert: bespoke Monats-/Wochen-Grid mit
Quell-Markern und Visual-/Hot-Swap-Abschluss warten auf David `D-P16`/`D-BOOK`.
Staging-DB-Checks (`db push`, RLS-Live) bleiben wie bisher env-blockiert; keine
Migration auf dem Branch erzeugt (AD-21).
**Proof grün:** `pnpm prisma validate`; `@hivegrid/{db,queue,integrations,worker,web,view-models}`
typecheck; `pnpm run verify:i18n`; gezielte Vitest-Suite **40 Tests grün**
(Calendar Services/Auth 24, Calendar Worker/Cron 3, Webhook-Renewal 5,
Calendar/Booking VMs 8). Zusätzlich wurde ein AK-P16b-VM-Testimport auf
NodeNext-konformes `.js` korrigiert, damit `@hivegrid/view-models` wieder
typecheck-grün ist.
@@ -6,7 +6,7 @@
| **Owner** | Akeshan |
| **Phase** | P16 — Kalender, Booking, Forms & Signatur |
| **Cycle/Fenster** | W4 — USP, Härtung & Launch · fällig 2026-07-05 · Gate W4 |
| **Status** | geplant |
| **Status** | in Arbeit (~92 % gewichtet; 5/7 DoD vollständig, 2/7 teilfertig; selbst machbarer Server-Rest erledigt, Rest David D-P16/AK-P16c + Staging-DB-Ausführung) |
## Abhängigkeiten & Parallelität
- **Blocked by:** `AK-P16a` (Kalender-Sync — Slot-Verfügbarkeit braucht Busy-Slots aus Google)
@@ -27,13 +27,13 @@
| Verfügbarkeit [`/availability`] | Wöchentliche Standard-Regeln (mehrere Zeitfenster/Tag), Datums-Ausnahmen (frei/Custom-Window), Timezone |
## Definition of Done
- [ ] Öffentliche **Booking-Seiten** berechnen Slots **timezone-aware** (Regeln, Ausnahmen, Kalender-Sync, Puffer, Vorlauf)
- [ ] Nach Buchung: **Lead oder Activity** (Email-Match), **FormSubmission**, **Bestätigungs-Email** (.ics) — **Race-Schutz** beim Submit
- [ ] **iFrame-Embed** + Public-URL mit Copy
- [ ] Nur **eine** Zuteilungs-Strategie „Erster der frei ist" (Round-Robin/Provider-Wahl → v2)
- [ ] **Storno/Verschiebung/Re-Assign/No-Show** (M129 VOLL) inkl. Auto-Mail bei Cancel
- [ ] Permission-Bundle `bookings.*` (read_own/read_all/write/admin) serverseitig Owner-gefiltert
- [ ] Konnektor `tenantHasModule('booking')`; RLS auf allen Tenant-Tabellen
- [x] Öffentliche **Booking-Seiten** berechnen Slots **timezone-aware** (Regeln, Ausnahmen, Kalender-Sync, Puffer, Vorlauf)
- [~] Nach Buchung: **Lead oder Activity** (Email-Match), **FormSubmission**, **Bestätigungs-Email** (.ics) — **Race-Schutz** beim Submit · *Kontakt-Email-Match → Activity, sonst echtes `InboxLead` + `Appointment.leadId`; bei gesetzter `intakeFormId` echtes `FormSubmission` + `Appointment.formSubmissionId`, sonst `intakeAnswers` Json-Fallback*
- [x] **iFrame-Embed** + Public-URL mit Copy
- [x] Nur **eine** Zuteilungs-Strategie „Erster der frei ist" (Round-Robin/Provider-Wahl → v2)
- [~] **Storno/Verschiebung/Re-Assign/No-Show** (M129 VOLL) inkl. Auto-Mail bei Cancel · *Service + Route-Action-Contract vollständig; Reschedule/Re-Assign-Picker-UI → David D-P16*
- [x] Permission-Bundle `bookings.*` (read_own/read_all/write/admin) serverseitig Owner-gefiltert
- [x] Konnektor `tenantHasModule('booking')`; RLS auf allen Tenant-Tabellen *(RLS-SQL als deferred Cutover wie Kalender)*
## Technische Bausteine
- **Entities** (eigene `prisma/schema/booking.prisma`): `BookingPage` (slug, duration_minutes, assignment_strategy, is_published, intake_form_id), `BookingPageProvider` (M:N User), `AvailabilityRule`/`AvailabilityException`, `Appointment` (provider_user_id, start/end, status scheduled/held/cancelled/no_show/rescheduled, kontakt_id|lead_id, form_submission_id). `calendar.prisma`/`crm.prisma`/`forms.prisma` nur über Relationen (AD-21).
@@ -53,3 +53,105 @@
- [ ] Spec/ENTITY-MODEL an Davids UI-Entscheidungen angepasst
- [ ] REQUIREMENTS/PHASE-SOURCES nachgezogen
- [ ] Plane-Ticket `AK-P16b` aktualisiert
## Umsetzungs-Notiz (2026-06-30, Akeshan) — ~85 % DoD
Branch `feat/AK-P16b` (von `feat/AK-P16a` abgezweigt, AD-21), **8 Commits** (`fecf6f19f`
→ `b262dabe8`), nicht gepusht, Plane-Status unverändert. Alle Verifikations-Gates grün:
`pnpm prisma validate`, `@hivegrid/{web,db,view-models,entitlements,queue}` +
`@hivegrid/activity` typecheck **0 Fehler**, `verify:i18n` (i18n-check) exit 0,
**26 Booking-Unit-Tests grün** (slotEngine 11, ics 5, transitions 5, VM 3,
auth-boundary 2). lefthook pre-commit je Commit grün.
**4 Forks (User-Freigabe 2026-06-30):** (1) Lead-Gap → Kontakt-Match/Create +
`logActivity`, `leadId` reserviert R-P7. (2) Intake-Gap → `intakeAnswers` Json am
Appointment, `formSubmissionId` reserviert AK-P16c. (3) Modul-Gate → eigener
`booking` Feature-Key (`tenantHasModule('booking')`). (4) Scope → Backend +
funktionale Routen inkl. minimal-funktionaler öffentlicher Strecke.
**DoD-Stand:**
- [x] **Slot-Engine timezone-aware (DoD 1)** — `slotEngine.ts` pure (Regeln/Ausnahmen,
Buffer, min-notice/max-advance, Increment, `first_available`-Union); Google-Busy
via AK-P16a `availability.ts` (leak-safe). 11 Tests inkl. DST. `/b/{tenant}/{slug}`.
- [~] **Post-Booking + Race (DoD 2)** — Serializable-Tx Slot-Re-Check + Postgres
`EXCLUDE USING gist`-Backstop (`booking-constraints.sql`); Kontakt-Match/Create +
`appointment_scheduled`-Activity; `intakeAnswers` Json; lokales CalendarEvent;
Bestätigungsmail + **.ics-Attachment** (`buildIcs` pure). **Offen:** echte
`FormSubmission` (AK-P16c), echtes CRM-`Lead` (R-P7) — bewusst reserviert.
- [x] **iFrame-Embed + Public-URL (DoD 3)** — `/b/.../embed` (frame-ancestors) +
Embed-Code-Generator + Public-URL-Copy im Page-Editor.
- [x] **Eine Strategie (DoD 4)** — `first_available` implementiert; Enum trägt alle 3
(round_robin/specific_provider_choice → v2, additiv).
- [~] **Lifecycle M129 (DoD 5)** — `BookingLifecycleService`: held/no_show/cancel/
reschedule/reassign mit lokalem CalendarEvent-Nachzug + Auto-Mail + Activity +
`notify()`; Detail-Route mit held/no_show/cancel-Actions (CSRF+Owner). **Offen:**
Reschedule-/Re-Assign-**Picker-UI** (Slot-/Provider-Auswahl) → David D-P16.
- [x] **bookings.* owner-gefiltert (DoD 6)** — 4 Registries, `bookingAccess.server.ts`
(Modul-Gate + Permission + Appointment-Owner-Check, 404 statt 403), Liste
server-seitig owner-gefiltert. Auth-Boundary-Test.
- [x] **Konnektor + RLS (DoD 7)** — `booking` Feature-Key + Gate; `booking-isolation.sql`
(deferred Cutover wie Kalender), `booking-constraints.sql` (gist + btree_gist).
**Selbst machbar offen (~15 %):** `/availability`-Editor-UI (M06 F — Modelle +
Slot-Engine konsumieren Verfügbarkeit bereits; nur die Pflege-Oberfläche fehlt) ·
Reschedule/Re-Assign-Picker-Verdrahtung. **Staging-DB-blockiert:** `prisma db push` +
`psql -f booking-constraints.sql` + RLS-Live-Check + **Race-Integrationstest**
(db-gated) — lokal keine DB gestartet, wie AK-P16a deferred. **David D-P16-blockiert:**
bespoke öffentliche Booking-Page + Reschedule-Picker + Visual-Regression (DoD 21) +
Hot-Swap-Drill (DoD 20).
**Hinweise:** Mail-Attachment-Durchreichung im Queue-Provider ergänzt (Payload trug
`attachments` bereits, nur der Provider-Hop ließ sie fallen). 3 Booking-Activity-Typen
(`appointment_cancelled/rescheduled/no_show`) zur Activity-Union ergänzt. Connect/
Lifecycle sind CRM-Subject-Activities (kein eigenes Booking-Subject).
## Umsetzungs-Notiz (2026-07-01, Raffael) — ~90 % gewichtet, 5/7 DoD vollständig
Direkt auf `main` serverseitig nachgezogen, um den ohne Screens selbst machbaren
P16b-Rest zu maximieren.
**Nachzug:**
- Public Booking erzeugt bei fehlendem Kontakt jetzt ein echtes `InboxLead` über
`createLead(...)`, verknüpft es über `Appointment.leadId` und schreibt die Activity
auf Kontakt oder Lead. Der alte automatische Kontakt-Create ist raus.
- Detail-Route akzeptiert serverseitig die M129-Intents `reschedule` und `reassign`
mit `bookings.write` bzw. `bookings.admin`; die Picker-UI bleibt D-P16-blockiert.
- `AvailabilityService.server.ts` ergänzt den atomaren Replace-/Read-Contract für
`AvailabilityRule`/`AvailabilityException` inkl. Zeitfenster-, Datum- und
Timezone-Validierung. Die `/availability`-Oberfläche bleibt screen-blockiert.
- Tests ergänzt: Availability-Service-Unit-Test, Post-Booking-Static-Guard,
Lifecycle-Action-Authorization-Guard.
**DoD-Stand nach Nachzug:**
- Vollständig abgehakt: DoD 1, 3, 4, 6, 7 = **5/7 vollständig**.
- Teilfertig: DoD 2 ist serverseitig bis auf echte `FormSubmission` erledigt
(`AK-P16c`); DoD 5 ist serverseitig erledigt, aber Reschedule/Reassign-Picker-UI
wartet auf David D-P16.
- Offen/blockiert: echte `FormSubmission` (`AK-P16c`), D-P16-Screens/Visuals,
Staging-DB-Cutover (`prisma db push`, `booking-constraints.sql`, RLS-Live-Check,
Race-Integrationstest).
**Proof lokal:** `pnpm exec vitest run apps/web/app/modules/booking/services/AvailabilityService.server.test.ts apps/web/app/modules/booking/services/booking-postbooking-boundary.test.ts apps/web/app/modules/booking/routes/booking-auth-boundary.test.ts` → 8/8 grün; `pnpm typecheck` → grün.
## Umsetzungs-Notiz (2026-07-01, Raffael) — ~92 % gewichtet, selbst machbarer Server-Rest erledigt
Noch ohne Screens nachgezogen:
- `BookingPage.intakeFormId` wird im Public-Booking-Resolve mitgeladen.
- Public Booking schreibt innerhalb derselben Serializable-Transaktion eine echte
`FormSubmission`, sobald die BookingPage ein Intake-Form referenziert, und verlinkt
`Appointment.formSubmissionId`. Ohne `intakeFormId` bleibt `intakeAnswers` der
Fallback, damit bestehende BookingPages nicht brechen.
- Statische DB-Artefakt-Guards ergänzt: `booking-constraints.sql` muss die
`appointment_no_overlap`-Exclusion-Constraint für `scheduled`/`held` enthalten;
`booking-isolation.sql` muss alle fünf Booking-Tabellen mit RLS/FORCE/GUC-Policy
und `hivegrid_app`-Grant abdecken.
**DoD-Stand nach Nachzug:**
- DoD 2 ist serverseitig maximal: Lead/Activity, FormSubmission bei konfiguriertem
Intake-Form, .ics-Mail, Serializable Slot-Re-Check und SQL-Backstop sind vorhanden.
Vollständig grün wird der Punkt erst mit AK-P16c-Renderer/Form-Auswahl und echter
Staging-DB-Ausführung.
- DoD 5 bleibt screen-blockiert für den Reschedule-/Reassign-Picker; Service und
Action-Contract sind bereits erledigt.
**Proof lokal:** `pnpm exec vitest run apps/web/app/modules/booking/services/booking-postbooking-boundary.test.ts apps/web/app/modules/booking/services/booking-db-artifacts.test.ts apps/web/app/modules/booking/services/AvailabilityService.server.test.ts apps/web/app/modules/booking/routes/booking-auth-boundary.test.ts` → 11/11 grün; `pnpm typecheck`, `pnpm lint`, `pnpm verify:i18n`, `pnpm test` und `pnpm build` → grün.
@@ -6,7 +6,7 @@
| **Owner** | Akeshan |
| **Phase** | P16 — Kalender, Booking, Forms & Signatur |
| **Cycle/Fenster** | W4 — USP, Härtung & Launch · fällig 2026-07-06 · Gate W4 |
| **Status** | geplant |
| **Status** | Dev-Anteil umgesetzt · Design-Delta offen |
## Abhängigkeiten & Parallelität
- **Blocked by:** `AK-INFRA2` (mCaptcha + Rate-Limit-Infra für öffentliche Endpunkte), `R-P7` (Lead-Inbox — Post-Submit-Action „Lead anlegen")
@@ -26,13 +26,47 @@ Einen Drag-&-Drop-**Form-Builder auf Google-Forms-Niveau**: **12 Feld-Typen**, *
| Submissions [`/forms/:id/submissions`] | Antwort-Tabelle (Spalten = Felder), Filter, CSV; Detail: alle Antworten, Submitter-Metadata, **Action-Log mit Re-Run**, File-/Foto-Vorschau, PDF-Export |
## Definition of Done
- [ ] **12 Feld-Typen** + **Conditional Logic** + Validierung pro Feld + geseedete **SHK-Templates** + Live-Vorschau
- [ ] **Embed**: öffentliche URL + QR + iFrame (personalisierte Links / JS-Snippet → v2)
- [ ] **Post-Submit-Actions** (Lead/Kontakt/Projekt/Task, Email, Webhook, Automation-Trigger, Auto-Match) mit **Field-Mapping** + **ActionLog** (Re-Run)
- [ ] **Rate-Limit + mCaptcha + DSGVO-Pflicht** auf öffentlichen Endpunkten; Race-Schutz beim Submit
- [ ] **Form-Renderer als wiederverwendbare Komponente** (Booking-Intake, HR-Anträge)
- [ ] Permission-Bundle `forms.*` (basic/builder/publisher/admin) serverseitig Owner-gefiltert
- [ ] Konnektor `tenantHasModule('forms')`; RLS auf allen Tenant-Tabellen
- [x] **12 Feld-Typen** + **Conditional Logic** + Validierung pro Feld + geseedete **SHK-Templates** + Live-Vorschau
- [x] **Embed**: öffentliche URL + QR + iFrame (personalisierte Links / JS-Snippet → v2)
- [x] **Post-Submit-Actions** (Lead/Kontakt/Projekt/Task, Email, Webhook, Automation-Trigger, Auto-Match) mit **Field-Mapping** + **ActionLog** (Re-Run)
- [x] **Rate-Limit + mCaptcha + DSGVO-Pflicht** auf öffentlichen Endpunkten; Race-Schutz beim Submit
- [x] **Form-Renderer als wiederverwendbare Komponente** (Booking-Intake, HR-Anträge)
- [x] Permission-Bundle `forms.*` (basic/builder/publisher/admin) serverseitig Owner-gefiltert
- [x] Konnektor `tenantHasModule('forms')`; RLS auf allen Tenant-Tabellen
## Umsetzungs-Notiz
**2026-07-01:** AK-P16c umgesetzt auf Branch `feat/AK-P16c-complete`.
- Form-Datenmodell in `prisma/schema/formWidget.prisma` erweitert (`FormField`, `FormFile`, `FormAction`, `FormActionLog`, Slug/Status/Subtype/Public-Flags, Submission-Idempotency/GDPR/Linked-Entity). Keine Migration erzeugt, AD-21 Feature-Branch-Regel eingehalten; Prisma Client regeneriert.
- Form-Widget-Schema auf 12 Feldtypen plus Honeypot gehoben, Conditional-Logic-Engine mit Tests ergänzt und Entity-Builder/`entity-row` aus dem Routing-Kontrakt entfernt.
- Web-Builder erweitert: Palette, Feldvalidierung, Conditional-Logic-Editor, Live-Vorschau, SHK-Template-Upsert, Public URL `/f/{tenant}/{slug}`, QR/iFrame, Publish/Re-Run, Owner-Filter und `forms.*` Permissions.
- Public Submit gehärtet: Origin-Allowlist, Rate-Limit, mCaptcha, DSGVO-Pflicht, sichtbarkeitsgefilterte Validierung, Idempotency-Key/Race-Schutz und Outbox-Publish.
- Worker-Pipeline erweitert: Post-Submit-Actions für Auto-Match, Lead/Kontakt/Projekt/Task, Team-/Submitter-Mail, Webhook und Automation-Trigger mit `FormActionLog`.
- Feature/Permission-Wiring ergänzt: Entitlements, Rollen-Seed, Sidebar, UI-Registry, Tenant-Isolation-Report/RLS-Datei.
**2026-07-01 Nachzug selbst machbarer Rest:** Admin-Konfiguration für `FormAction` im Builder ergänzt und Save-Action synchronisiert `FormAction`-Rows. Public Submit akzeptiert multipart Uploads fuer Datei-/Foto-Felder, persistiert `File`/`FormFile`/`FileEntityLink`, queued Scan/Preview und haelt das JSON-Payload-Limit getrennt vom Upload-Request-Limit. Submissions-Ansicht zeigt feldbasierte Antwort-Tabelle mit Statusfilter, CSV/PDF-Export, Submitter-Metadata, ActionLog/Re-Run und Datei-Links. Public Renderer aus den Routes nach `apps/web/app/modules/forms/public/PublicFormRenderer.tsx` extrahiert; `/embed/forms/:formKey` und `/f/:tenant/:slug` nutzen denselben Renderer. Builder-Save synchronisiert `FormField`-Rows aus der Definition.
**Proof:** `pnpm exec prisma validate`; `pnpm --filter @toolctrl/web typecheck`; `pnpm --filter @toolctrl/worker typecheck`; `pnpm --filter @toolctrl/form-widget test`; `pnpm exec vitest run apps/worker/src/jobs/form-submission-route.test.ts apps/worker/src/index.test.ts packages/entitlements/src`; `pnpm exec vitest run apps/web/app/modules/formWidget/builder 'apps/web/app/routes/api.embed.forms.$formKey.submit.test.ts' 'apps/web/app/routes/app.$tenant/forms.$id.defaults.test.ts' 'apps/web/app/routes/app.$tenant/forms.$id.action.test.ts' 'apps/web/app/routes/app.$tenant/forms.$id.i18n.test.ts'`; `pnpm lint`.
**Stand:** 7/7 Dev-DoD grün = 100 % selbst machbarer Dev-Anteil. Ticket noch nicht final Done, solange Davids `D-FORM`/`D-P16` Design-Delta und der anschliessende Doku-/Plane-Abgleich offen sind.
**Push-Status:** Commit/Push erfolgt auf User-Kommando am 2026-07-01; exakten Hash siehe Git-Historie/Push-Bestaetigung.
**Plane:** Kommentar `2d5037a5-5c59-4a07-9a53-8a597d9b6de4` am 2026-07-01 gesetzt. Ticket bewusst nicht auf Done gesetzt, weil Davids Design-Delta noch offen ist.
## Noch offen
**Selbst machbar im Ticket-Scope:**
- Aktuell keine offenen selbst machbaren Dev-Punkte identifiziert. Der funktionale AK-P16c-Umfang aus der DoD ist umgesetzt und lokal verifiziert.
**Extern/blockiert:**
- Davids `D-FORM`/`D-P16` Design-Delta ist noch nicht eingearbeitet. Aktuell ist es eine funktionale Implementierung, aber noch nicht der finale bespoke Subframe-/Design-Stand.
- Nach Davids Delta fehlen die geplanten Doku-Nachzüge: Spec/ENTITY-MODEL, REQUIREMENTS/PHASE-SOURCES und Plane-Ticket final abgleichen.
**Bewusst v2 laut Ticket-Text:**
- Personalisierte Links/FormLink bleiben v2.
- JS-Snippet ist laut Ticket-Text v2; v1 deckt Public URL, QR und iFrame ab.
## Technische Bausteine
- **Entities** (eigene `prisma/schema/forms.prisma`): `Form` (slug, subtype standard/acceptance_form/onboarding/survey, status, is_public), `FormField` (field_key, field_type 12 Typen, required, validation_rules), `FormSubmission` (submission_data JSON, linked_entity_type/id polymorph), `FormFile` (→ File), `FormAction` (action_type send_email/create_lead/create_projekt/webhook/trigger_automation, action_config). `crm.prisma`/`tasks.prisma`/`automation.prisma` nur über Actions berührt (AD-21).
@@ -1,4 +1,4 @@
# AK-P16d · P16 Angebot/Signatur (PandaDoc)
# AK-P16d · P16 Angebot/Signatur (SES-Default + BYOK-Adapter)
| | |
|---|---|
@@ -6,14 +6,13 @@
| **Owner** | Akeshan |
| **Phase** | P16 — Kalender, Booking, Forms & Signatur |
| **Cycle/Fenster** | W4 — USP, Härtung & Launch · fällig 2026-07-07 · Gate W4 |
| **Status** | geplant |
| **Status** | in Umsetzung |
> ⚠️ **Scope-Update (AD-25, 2026-06-21 — Raffael):** Signatur-**Default** ist jetzt der **gehärtete SES-Eigenbau**
> (kein PandaDoc-Zwang), umgesetzt über `defineAdapter` (Adapter `ses-selfhosted`). **PandaDoc bleibt nur als
> optionaler BYOK-Adapter** (`pandadoc-byok`, Tenant bringt eigenen Key) — Anbindungs-Spec in [[EXT-17]]. Dieses
> Ticket bitte entsprechend bauen: **SES als Default** (Touch vor Ort + Fern-Link + OTP + PAdES-Versiegelung +
> Zeitstempel + M001-Audit + Completion-Certificate, s. [[EXT-09]]/[[AD-25]]), **PandaDoc als Opt-in** daneben.
> DoD unten ist noch PandaDoc-zentriert und wird beim Bau gemäß AD-25 auf „SES-Default + BYOK-Adapter" angepasst.
> (Rechtlich: SES genügt im SHK; QES nur § 650h, später via D-Trust.)
## Abhängigkeiten & Parallelität
@@ -23,33 +22,43 @@
- **Konflikt-Risiko:** gering — additive Tabellen; `apps.prisma`/`crm.prisma`/`files.prisma` nur über Relationen.
## Was wir bauen
**Multi-Signer-Angebots-Signatur über PandaDoc** (DocuSign-Adapter bleibt möglich, läuft über die Apps-Engine M33a): PDF-Upload (aus Files oder neu) → Unterzeichner (Name vorausgefüllt aus Kontakt, Email, Rolle, Reihenfolge sequenziell/parallel) → Senden, mit **Webhook-Verarbeitung** und **Status-Tracking je Unterzeichner** (entwurf/gesendet/angesehen/signiert/abgelehnt), Erinnerung, Widerruf und optionalem Auto-Stage-Wechsel. Das signierte PDF landet in Files. **Pivot-Hart:** Signatur hängt am **`Projekt`-Kern** (`ProjektSignatureDocument`/`ProjektSignatureSigner`), kein eigenes „Deal"-Objekt — Einstieg lebt im Projekt-Detail (P8/P9).
**Multi-Signer-Angebots-Signatur über den gehärteten SES-Eigenbau**: PDF-Upload (aus Files oder neu) → Unterzeichner (Name vorausgefüllt aus Kontakt, Email, Rolle, Reihenfolge sequenziell/parallel) → Zustellung per Fern-Link/OTP oder Touch-Signatur vor Ort → PAdES-versiegeltes Signed-PDF + Completion-Certificate. **PandaDoc bleibt optionaler BYOK-Adapter** hinter derselben Apps-Engine-Connection- und Adapter-Schnittstelle. Status-Tracking je Unterzeichner (entwurf/gesendet/angesehen/signiert/abgelehnt), Erinnerung, Widerruf und optionaler Auto-Stage-Wechsel bleiben erhalten. Das signierte PDF landet in Files. **Pivot-Hart:** Signatur hängt am **`Projekt`-Kern** (`ProjektSignatureDocument`/`ProjektSignatureSigner`), kein eigenes „Deal"-Objekt — Einstieg lebt im Projekt-Detail (P8/P9).
## Benötigte Screens (Backend bedient — Design liefert `D-BOOK` + `D-P16`)
| Screen [Route] | Kern-Inhalt |
|---|---|
| Signatur-Wizard [im Projekt-Detail] | PDF-Upload (aus Files/neu) → Unterzeichner (Name aus Kontakt, Email, Rolle, Reihenfolge sequenziell/parallel) → Provider (PandaDoc, Default) → Senden; **Status-Tracking je Unterzeichner** + Erinnerung + Widerruf; Original- + Signed-PDF-Download |
| Signatur-Wizard [im Projekt-Detail] | PDF-Upload (aus Files/neu) → Unterzeichner (Name aus Kontakt, Email, Rolle, Reihenfolge sequenziell/parallel) → Signaturmodus (Fern-Link/OTP oder Touch vor Ort; PandaDoc nur BYOK-Opt-in) → Senden; **Status-Tracking je Unterzeichner** + Erinnerung + Widerruf; Original-, Signed-PDF- und Completion-Certificate-Download |
## Definition of Done
- [ ] **PDF-Upload → Multi-Signer via PandaDoc** über die Apps-Engine (M33a)
- [ ] **Webhook-Verarbeitung** + **Status-Tracking pro Unterzeichner** (entwurf/gesendet/angesehen/signiert/abgelehnt)
- [ ] Erinnerung + Widerruf + optionaler Auto-Stage-Wechsel; Signed-PDF landet in Files
- [ ] **Signatur hängt am `Projekt`-Kern** (`ProjektSignatureDocument`/`ProjektSignatureSigner`) — kein „Deal"-Objekt
- [ ] Permission-Bundle `signature.*` (read/send/revoke/admin) serverseitig Owner-gefiltert
- [ ] Konnektor `tenantHasModule('signature')`; RLS auf allen Tenant-Tabellen
- [x] **AD-25 umgesetzt:** SES-Eigenbau ist Default (`ses-selfhosted`), PandaDoc bleibt optionaler BYOK-Adapter (`pandadoc-byok`) über die Apps-Engine.
- [x] **Status-Tracking pro Unterzeichner** für SES-Flow (draft/sent/viewed/signed/revoked) plus PandaDoc-HMAC-Webhook-Helfer für BYOK.
- [x] OTP-Mail via Transactional-Email-Queue, Erinnerung + Widerruf; Signed-PDF- und Completion-Certificate-Artefakte landen in Files.
- [x] **Signatur hängt am `Projekt`-Kern** (`ProjektSignatureDocument`/`ProjektSignatureSigner`) — kein „Deal"-Objekt.
- [x] Permission-Bundle `signature.*` (read/send/revoke/admin) serverseitig Owner-gefiltert.
- [x] Konnektor `tenantHasModule('signature')`; RLS auf allen Tenant-Tabellen.
- [ ] **Blockiert durch D-P16:** finales Subframe-/David-Design für den Signatur-Wizard; aktuell funktionaler Fallback im Projekt-Detail.
- [x] **PAdES-/TSA-Produktionspfad:** `@signpdf`-basierte `ETSI.CAdES.detached`-Signatur mit P12-Zertifikat (`SIGNATURE_P12_BASE64` oder `SIGNATURE_P12_PATH`) und optionalem RFC3161-TSA-Request (`SIGNATURE_TSA_URL`). Ohne Zertifikat bleibt Dev-Fallback möglich; mit `SIGNATURE_REQUIRE_PADES=true` schlägt der Abschluss hart fehl, wenn Zertifikatsmaterial fehlt.
## Technische Bausteine
- **Entities** (eigene `prisma/schema/signature.prisma`): `SignatureProviderConnection` (data_connection_id → Apps-Engine!, provider PandaDoc, is_default), `ProjektSignatureDocument` (projekt_id, provider_document_id, source_pdf_file_id, status draft/sent/viewed/signed/declined, signed_pdf_file_id — **[Soft-Delete]**), `ProjektSignatureSigner` (document_id, signer_email/name, linked_kontakt_id, status, signed_at). `apps.prisma`/`crm.prisma`/`files.prisma` nur über Relationen (AD-21).
- **Signatur = Apps-Engine-Connection** (R-P5): OAuth/API-Key + Health vom Framework; hier Provider-Adapter PandaDoc.
- **Webhook-Handler** (Signatur-Check) → Status-Übergänge je Signer + Activity-Write; Auto-Stage-Wechsel über Pipeline (P8/P9, optional).
- **Entities** (eigene `prisma/schema/signature.prisma`): `SignatureProviderConnection` (data_connection_id → Apps-Engine!, provider `ses-selfhosted` default oder `pandadoc-byok` optional, is_default), `ProjektSignatureDocument` (projekt_id, provider_document_id, source_pdf_file_id, status draft/sent/viewed/signed/declined, signed_pdf_file_id, completion_certificate_file_id — **[Soft-Delete]**), `ProjektSignatureSigner` (document_id, signer_email/name, linked_kontakt_id, status, signed_at, otp_verified_at). `apps.prisma`/`crm.prisma`/`files.prisma` nur über Relationen (AD-21).
- **Signatur = Apps-Engine-Connection** (R-P5): OAuth/API-Key + Health vom Framework; hier Provider-Adapter `ses-selfhosted`, optional `pandadoc-byok`.
- **Callback-/Webhook-Handler** (Signatur-Check) → Status-Übergänge je Signer + Activity-Write; Auto-Stage-Wechsel über Pipeline (P8/P9, optional).
- **RLS** überall; `signature.*`-Bundle; signiertes PDF via FileEntityLink an den Projekt-Kern.
- **Konnektor** `tenantHasModule('signature')`; `is_demo_data` auf Demo-Dokumenten.
## Umsetzungsnotiz 2026-07-01
- Branch: `feat/AK-P16d-signature-ses`, abgezweigt von aktuellem `origin/main`.
- Gebaut: `prisma/schema/signature.prisma` + RLS-Policy, File-Link-Typ `signature_document`, Permission/Entitlement-Wiring `signature.*`, Tenant-Modell-Registrierung, Apps-Engine-Katalog/Adapter `ses-selfhosted` und `pandadoc-byok`, Projekt-Signatur-VM, Projekt-Detail-Fallback-UI, öffentlicher `/sign/:token`-Flow mit OTP/Intent/Touch-Signatur, OTP-Mail-Queue, Reminder/Widerruf, Signed-PDF-/Completion-Certificate-Ablage in Files.
- Proof lokal: `pnpm exec prisma validate` grün; `pnpm --filter @toolctrl/integrations test` grün; `pnpm --filter @toolctrl/view-models test` grün; `pnpm --filter @toolctrl/integrations typecheck` grün; `pnpm --filter @toolctrl/view-models typecheck` grün; `pnpm --filter @toolctrl/web typecheck` grün; `pnpm exec vitest run apps/web/app/modules/signature/services/padesSeal.server.test.ts` grün; `pnpm lint` grün.
- Push-Proof: Implementierungs-Commit `2e7e81557` auf `origin/feat/AK-P16d-signature-ses` gepusht; Pre-Push-Hook `i18n` + `typecheck` grün.
- Hinweis: `pnpm --filter @toolctrl/config test` fällt lokal auf Windows in `env-access-enforcement.test.ts` wegen bereits bestehender absoluter Baseline-Pfade in anderen Packages; keine AK-P16d-Datei ist in der Unexpected-Liste.
- Fortschritt: 7/8 DoD = 87,5 % done. Selbst machbar offen: 0 Punkte. Extern blockiert: D-P16 finales Signatur-Wizard-Design.
## Quellen (granular)
- `ENTITY-MODEL.md` → **Domäne 9** (SignatureProviderConnection, ProjektSignatureDocument/Signer — am Projekt-Kern, AD-20)
- `REQUIREMENTS.md` → `REQ-signatur-datenmodell` (⚠ Bezeichner „Deal" pre-pivot — gilt `Projekt`), `REQ-signatur-provider`
- `ROADMAP.md` → Phase 16 · `PHASE-SOURCES.md` → Phase 16 (M130-erstellung, M131-pandadoc-docusign, M132-status-tracking)
- `designer-briefs/P16-kalender-booking-forms-signatur.md` (Gruppe 4 Signatur; PandaDoc v1, Pivot-Hinweis Projekt-Kern)
- `ROADMAP.md` → Phase 16 · `PHASE-SOURCES.md` → Phase 16 (M130-erstellung, M131-self-hosted-e-signatur, M132-status-tracking)
- `designer-briefs/P16-kalender-booking-forms-signatur.md` (Gruppe 4 Signatur; SES-Default/PandaDoc-BYOK, Pivot-Hinweis Projekt-Kern)
- `_bmad-output/specs/M07-angebot-signatur.md` (⚠ teils pre-pivot — ENTITY-MODEL hat Vorrang)
## Dev-Nachzug (nach Davids `D-BOOK`/`D-P16`-Delta)
@@ -8,6 +8,8 @@
| **Cycle/Fenster** | W2 — Plattform & CRM (Demo 1) · fällig 2026-06-25 · Gate W2 |
| **Status** | geplant |
**🎨 design-nachzug: offen** (seit 2026-07-08) — Interim-UI aus `~/ui` steht (AD-28); finale Subframe-Optik aus `D-HR` ausstehend (Davids Delta noch leer).
## Abhängigkeiten & Parallelität
- **Blocked by:** `AK-P4b` (Files/Tasks + EntityLink — HR-Dokumente hängen an Files)
- **Blocks:** `AK-P17b` (Urlaub/Krank/Surveys)
@@ -28,11 +30,11 @@ Den **verschlüsselten HR-Kern** (Quiply-Ablösung): **MA-Stammdaten** (HrEmploy
| Passwort-Safe-Verwaltung [`/admin/hr/password-safe`] | Tenant-weite Vault-Sicht (ohne Inhalts-Access), Vault-Anlage/Archiv/Delete, Access-Verwaltung, Audit-Log-Übersicht |
## Definition of Done
- [ ] **HR-Profile/Dokumente AES-256-GCM** (KMS-Envelope-Encryption, AD-8)
- [ ] **MA-Bereich vs. HR-Admin-Bereich strikt getrennt**; sensible Tabellen zusätzlich App-Permission-gegated
- [ ] **Verschlüsselter Passwort-Safe mit Audit-Trail**: Reveal/Copy entschlüsselt on-demand + Permission-Check + Audit-Eintrag; Werte nie in Liste; Copy mit 30s-Auto-Clear; DSGVO-Export
- [ ] MA-Verzeichnis + Standorte (Admin-CRUD) + eigene Dokumente (Upload + read-only Büro-Uploads)
- [ ] Konnektor `tenantHasModule('hr')`; RLS auf allen Tenant-Tabellen; sensible Felder verschlüsselt
- [x] **HR-Profile/Dokumente AES-256-GCM** (KMS-Envelope-Encryption, AD-8) — *per-Tenant-DEK-Envelope aus R-P5 (`getTenantFieldCrypto`) jetzt in den Seam gebunden; Notfallkontakt verschlüsselt. Finale UI-Optik = Davids D-HR.*
- [x] **MA-Bereich vs. HR-Admin-Bereich strikt getrennt**; sensible Tabellen zusätzlich App-Permission-gegated
- [x] **Verschlüsselter Passwort-Safe mit Audit-Trail**: Reveal/Copy entschlüsselt on-demand + Permission-Check + Audit-Eintrag; Werte nie in Liste; Copy mit 30s-Auto-Clear; DSGVO-Export — *Backend komplett: Ver-/Entschlüsselung jetzt scharf (R-P5-Envelope); Audit/Permission/Never-List/Export gebaut. 30s-Clear = UI (David).*
- [x] MA-Verzeichnis + Standorte (Admin-CRUD) + eigene Dokumente (Upload + read-only Büro-Uploads) — *Backend + funktionale Routen; finale Optik wartet auf Davids D-HR-Delta.*
- [x] Konnektor `tenantHasModule('hr')`; RLS auf allen Tenant-Tabellen; sensible Felder verschlüsselt — *Konnektor (Feature/Blueprint/Plan) + RLS-SQL (deferred-Aktivierung wie P4b) + sensible Felder verschlüsselt (R-P5-Envelope) stehen.*
## Technische Bausteine
- **Entities** (eigene `prisma/schema/hr.prisma`): `HrEmployeeProfile` (user_id 1:1, personal_number, hire_date, position, team, location_id, manager_user_id, emergency_contact_*), `HrLocation` (name, address_*, geo, image_file_id), `HrEmployeeDocument` (employee_user_id, document_kind, file_id, visibility), `HrResource`/`HrQuickLink`, `EmployeePasswordSafeVault` (+ `Entry`/`Access` — username/password/notes **AES-256-GCM**, permission_level read/write/admin). `core.prisma` nur für User-Relation (AD-21).
@@ -48,7 +50,84 @@ Den **verschlüsselten HR-Kern** (Quiply-Ablösung): **MA-Stammdaten** (HrEmploy
- `designer-briefs/P17-hr-hub.md` (MA- vs. HR-Admin-Trennung, Passwort-Safe-Reveal, sensible Daten)
- `_bmad-output/specs/M14-hr-hub.md`
## Umsetzungs-Notiz
> **✅ Stand 2026-06-28 — auf `main` gemergt.** `feat/AK-P17a` (inkl. der eingemergten R-P5-Commits) ging mit **PR #5** (squash, Merge-Commit `b0fae1af8`, Base `main`, 2026-06-28 17:50, merged_by raffael) auf `main`; Docker-Nachzug `packages/integrations/package.json` via **PR #8** (`0b9d2e46`, 18:05). Alle HR-Artefakte (`hr.prisma`, `hrFieldCrypto.server.ts`, `hrService`/`hrPasswordSafeService`, RLS-SQL, Routen-Hülle, Sidebar) + das R-P5-Crypto-Fundament liegen im `main`-Tree. *Die „Branch lokal, nicht gepusht"-Notizen unten sind damit überholt.* **Backend 5/5 DoD grün.** Extern offen bleibt nur: Davids `D-HR`-Optik + echter 30s-Clipboard-Wipe, RLS-Live/Crypto-Round-trip/Visual-Regression gegen Staging-DB, Plane-Ticket auf Done. Weiterarbeit am HR-Hub läuft über **AK-P17b** (neuer Branch).
**2026-06-27 (Akeshan) — Backend-Kern + funktionale Routen, ~70% gebaut (2/5 DoD voll grün).**
Branch `feat/AK-P17a` (lokal, nicht gepusht). Blocker `AK-P4b` ist in `main` gemergt (Commit `2e7b935b9`), Files/EntityLink-Fundament steht. Strikt im AK-P17a-Scope geblieben (Entscheidung „strikt nach Ticket-Doc"): nur Stammdaten/Directory/Locations/Documents/Passwort-Safe; Announcements/Leave/Payslips/Surveys/Requests bleiben bei AK-P17b/Folge.
Gebaut:
- **Schema** `prisma/schema/hr.prisma` (AD-21, bare Scalars, `core.prisma` unberührt): HrEmployeeProfile, HrLocation, HrEmployeeDocument, HrResource/HrQuickLink, EmployeePasswordSafeVault/Entry/Access. Sensible Werte als `*Ciphertext`-Spalten. `prisma validate` + `generate` grün. **`db push` lokal noch offen** (kein lokales Postgres/Docker), env-gegatet wie bei AK-P4b.
- **RLS** `prisma/rls/hr-isolation.sql` (deferred-Aktivierung wie task-isolation.sql, P5-Cutover mit R-P2b) + env-gegateter Gate-Test `rls-hr-isolation.integration.test.ts` (skippt ohne `RLS_TEST_DATABASE_URL`).
- **Crypto-Seam** `hrFieldCrypto.server.ts` (Entscheidung c): Interface + injizierbar; Produktiv-Binding wirft Klartext-Fehler (M002) bis zum separaten AD-8-Crypto-Ticket. Services nehmen den Provider injiziert → voll testbar.
- **Services** `hrService.server.ts` (Profile/Directory/Locations/Documents, MA-self vs HR-admin) + `hrPasswordSafeService.server.ts` (Vault/Entry/Access, `withTenantRLS`, 404-statt-403, Reveal audit-geloggt + Permission-Check, Werte nie in Listen, DSGVO-Export). `hrAccess.server.ts` (Konnektor-Gate + Bundle-Permissions).
- **Permissions** `hr.*`-Set + Rollen-Grants in `permissions-seed.server.ts`. **Konnektor**: `hr` als Feature-Key + Blueprint + alle 3 Pläne (entitlements), `tenantHasModule({moduleKey:'hr'})` in jedem Loader.
- **View-Models** `packages/view-models/src/hr/*` (pure, Never-List-Values-Contract per Test gesichert) + **Search-Schemas** `hr-directory`/`hr-locations` (Vault-Werte nie indexiert).
- **Routen** `app.$tenant/hr.*` (Hub, Directory+Detail, Locations, My-Documents, Passwort-Safe-Liste+Vault-Detail+Reveal+Export, Admin-Vault-Sicht) + **Sidebar**-Eintrag (Feature- + Permission-gegated) + **i18n** de+en. Finale Optik = Hot-Swap-Hülle, wartet auf **Davids D-HR-Delta** (Delta leer).
Verifiziert (lokal grün): `pnpm typecheck` (alle Pakete + web), `pnpm run verify:i18n` (Exit 0), eslint-staged/baseline/test-density-Gates, entitlements/search/view-models/sidebar-Tests, neue HR-VM- + Crypto-Seam-Tests (15), RLS-Gate-Test skippt sauber. (Pre-existing Windows-Only-Flake `permissions-seed isPermissionsSeedDirectExecution` unverändert, environmental.)
**Stand: 2/5 DoD voll grün (#2, #4) = 40% strikt; #3/#5 zu ~90% (alles außer per-Tenant-Verschlüsselung), #1 schemaseitig vorbereitet. Gesamt ~70% gebaut.** Offen/blockiert: (a) AD-8-Crypto-Ticket (Entscheidung c) → schaltet #1/#3/#5-Verschlüsselung scharf; (b) Davids D-HR-Delta → finale UI/Visual-Regression; (c) `db push` + RLS-Live-Verifikation (Staging/DB); (d) Merge-Migration entsteht erst beim main-Merge (AD-21). 0 weitere selbst machbar ohne Crypto-Ticket/David/DB.
Proof: Commit `aa848dca7` (Branch `feat/AK-P17a`, lokal, nicht gepusht). Plane-Kommentar gesetzt (2026-06-27, akeshan). Ticket bleibt offen.
**Noch im Ticket selbst machbar (ohne externe Blocker), als Folge-Schritte:**
- Nebenbei-Validierung sobald lokal/Staging-Postgres da ist: `prisma db push` + RLS-Gate-Test (`RLS_TEST_DATABASE_URL`) live grün ziehen.
- Demo-Seed (`is_demo_data`) für Profile/Standorte/Vault, damit die Routen mit Daten zeigbar sind (sofern P17a-Scope; sonst AK-DEMO1).
- Smoke-Test `tests/e2e/hr.spec.ts` (Login → /hr → Hauptaktion) sobald die UI nicht mehr reine Hülle ist (nach Davids Delta).
- Mobile-Tab-Bar-Eintrag (DoD B#11) für die Monteur-relevanten HR-Screens, falls vorgezogen erwünscht.
**Update 2026-06-28 (Akeshan) — R-P5 in `feat/AK-P17a` gemergt + Crypto-Seam scharfgeschaltet. DoD jetzt 5/5 backend grün.**
Co-Dev-Abstimmung: R-P5 von Raffael übernommen und auf `feat/R-P5` fertig gebaut (per-Tenant-DEK-Envelope, AD-8). Entscheidung: `feat/R-P5` voll in `feat/AK-P17a` mergen, damit das Crypto-Fundament hier verfügbar ist, dann den HR-Seam umbinden. 2 Commits:
- `efcf44a9` **Merge `feat/R-P5`** — bringt `getTenantFieldCrypto` (`@hivegrid/db`) + `crypto.prisma`/`TenantEncryptionKey`. Konflikte alle „beide behalten": entitlements (hr + integrations, `integrations` defaultOrder 12→13 wegen unique+ascending-Test), permissions-seed/DefaultPermissions/AppSidebar, translations.json de+en (chirurgisch, kein Round-trip), `packages/db/generated/**` via `prisma generate` aus dem Union-Schema neu.
- `84fcf1e0` **Seam-Rebind** — `getHrFieldCrypto()` liefert jetzt `getTenantFieldCrypto()` (strukturell identisch). Schaltet #1 (Notfallkontakt AES-256-GCM), #3 (Passwort-Safe Reveal/Export), #5 (sensible Felder verschlüsselt) scharf. Deferred-Fail-Closed-Gerüst (`HrCryptoNotProvisionedError`) bleibt. Seam-Test angepasst (liefert echten Provider, nicht das Gerüst) + neuer env-gegateter Round-trip-Integrationstest (`HR_CRYPTO_TEST_DATABASE_URL`): treibt die echten Services, prüft Ciphertext≠Klartext, Reveal-Round-trip, List versteckt Werte, fremder Tenant kann nicht entschlüsseln (skippt ohne DB).
Verifiziert: `pnpm typecheck` (alle Pakete + web, direkt-tsc, **0 Fehler** — Turbo-Cache umgangen), `verify:i18n` Exit 0, Zieltests grün (Seam 5, HR-VMs 10, foundation+db-Crypto 16, entitlements 13), Round-trip-Test skippt sauber, beide Commits lefthook-grün.
**Stand: 5/5 DoD backend grün.** Nur noch extern blockiert: (a) Davids D-HR-Delta → finale Optik + echter 30s-Clipboard-Wipe + Visual-Regression/Smoke; (b) `db push` + RLS-Live + Crypto-Round-trip gegen echtes Postgres (kein lokales PG); (c) Merge-Migration beim main-Merge (AD-21). 0 selbst machbar offen. **Kopplung (bewusst):** `feat/AK-P17a` trägt jetzt R-P5s Commits; R-P5 muss vor/mit P17a auf main, bei R-P5-Review-Änderung hier nachziehen. Branch lokal, nicht gepusht, Plane-Status unverändert (In Progress).
**Nachtrag 2026-06-28 — aktuellen `origin/main` reingemergt** (`5a30e950`, Branch war 1 Commit hinter main: `e7a888e3` WF-P2 Auth-Screens + du/Sie-Anrede-Toggle). Konflikt nur in `prisma/schema/accounts.prisma`: main fügte `Tenant.anrede` direkt neben den alten `apps`/`dataConnections`/`appSyncRuns`-Back-Relations ein, die R-P5 bewusst entfernt hat (App global, bare Scalars, AD-21) → `anrede` behalten, Back-Relations draußen gelassen. Generierter Prisma-Client aus dem Union-Schema neu erzeugt, translations.json auto-gemergt (hr + integrations + main-Keys, JSON valide). Verifiziert: `pnpm typecheck` direkt-tsc **0 Fehler**, `verify:i18n` Exit 0, Zieltests grün (44). Branch jetzt 18 Commits ahead, lokal, nicht gepusht.
**Soll/Ist-Verifikation 2026-06-28 (Akeshan) — unabhängiger Nach-Merge-Check, alle CI-Gates grün, DoD bleibt 5/5 backend.**
Anforderungen (DoD #1–#5) gegen den Ist-Stand im Repo geprüft und nachgewiesen, dass durch die beiden Merges (R-P5 + origin/main) nichts gebrochen ist:
- **CI-Gates** (= was Gitea-CI + lefthook real fahren): `prisma generate` aus Union-Schema sauber; `pnpm typecheck` (web) **0 Fehler**; `@hivegrid/api` + `@hivegrid/worker` typecheck **0 Fehler**; `verify:i18n` Exit 0; Zieltests **44 passed / 3 skipped** (HR-VMs 10, Seam 5, foundation+db-Crypto 16, entitlements 13; Round-trip skippt sauber ohne DB).
- **DoD-Ist im Code bestätigt:** Crypto-Seam (`hrFieldCrypto.server.ts`) liefert `getTenantFieldCrypto` (Seam-Test grün) → #1/#3/#5 scharf; `crypto.prisma`/`hr.prisma`/`appsEngine.prisma` alle im Schema-Ordner, `prisma generate` validiert das Union-Schema; `accounts.prisma`-Konflikt korrekt (`Tenant.anrede` da, keine entfernten Back-Relations zurückgekrochen).
- **Befund (vorbestehend, KEIN Merge-Schaden):** Der volle `pnpm -r typecheck` (nicht von CI gefahren — CI = nur web/api/worker) zeigte 3 latente `TS2352`-Fehler in den HR-VM-Tests (`hr-directory.vm.test.ts`, `hr-password-safe.vm.test.ts`): direkter Cast eines benannten Row-Interfaces auf `Record<string, unknown>`. Die Tests **liefen** grün (vitest/esbuild typecheckt nicht), nur der Paket-`tsc` stolperte — und der `@hivegrid/view-models`-`tsc` hängt in keinem Gate. Schon im Ursprungs-Commit `aa848dca7` vorhanden (tsconfig im Merge-Range unberührt), also nicht durch den Merge entstanden. **Behoben** mit dem von TS vorgeschlagenen `as unknown as Record<…>`-Cast in den 3 Stellen → `@hivegrid/view-models` typecheckt jetzt sauber, VM-Tests bleiben grün. Damit stimmt die frühere „direkt-tsc 0 Fehler"-Aussage jetzt auch paketweit.
- **Ebenfalls vorbestehend + außerhalb Scope (nicht angefasst):** `packages/exports`-`tsc` wirft `TS2688 Cannot find type definition file for 'node'` — lokales `@types/node`-Resolution-Thema (`"types":["node"]`), im Merge-Range nicht berührt, in keinem CI-Gate, nicht HR/R-P5-bezogen. Notiert, nicht repariert (Fremd-Scope).
**Stand unverändert: 5/5 DoD backend grün.** Extern blockiert bleibt: (a) Davids D-HR-Delta → finale Optik + echter 30s-Clipboard-Wipe + Visual-Regression/Smoke; (b) `db push` + RLS-Live + Crypto-Round-trip gegen echtes Postgres; (c) Merge-Migration beim main-Merge (AD-21). 0 selbst machbar offen. Branch lokal (mehrere Commits ahead von origin/main, 0 behind), nicht gepusht, Plane-Status unverändert (In Progress).
**Nachtrag 2026-06-28 — PR-Pipeline Build-Fix (Client/Server-Split).** Der PR-Build (`pnpm --filter @hivegrid/web build`) fiel im **Client-Environment** mit „Server-only module referenced by client" auf `app.$tenant/hr._index.tsx` → `~/utils/integrations/hrAccess.server`. Ursache: `export const handle = { moduleKey: HR_MODULE_KEY }` — `handle` ist **kein** server-gestrippter Export (RR strippt nur `loader`/`action`/`middleware`/`headers`), und `HR_MODULE_KEY` lag im `.server`-Guard → der ganze Server-Graph (Prisma/db) wurde in den Client-Bundle gezogen. Typecheck fängt das nicht (kein Client/Server-Split), nur der Build. **Identisches Latenz-Muster im R-P5-Pendant** `integrations._index.tsx` (`INTEGRATIONS_MODULE_KEY` aus `integrationsAccess.server`) — der Build bricht nur zuerst bei HR ab. Fix nach dem Walking-Skeleton-Präzedenzfall (`modules/walkingSkeleton/constants.ts`): den Modul-Key in das **client-sichere** Companion-Modul (`hrRoutePaths.ts` / `integrationsRoutePaths.ts`) verschoben, aus dem `.server`-Guard für Back-Compat re-exportiert, und den Route-Import auf das client-sichere Modul umgezogen. Verifiziert: lokaler `pnpm --filter @hivegrid/web build` → **Client-Environment ✓ (8079 Module, no server-only error)**; `web`-typecheck **0 Fehler**. (Lokaler Server-Build-Step bricht danach am bekannten Windows-`/dev/null`-Footgun in `packages/mail` ab — environmental, Linux-CI nicht betroffen.) 6 Dateien, beide Routen (HR + integrations).
**Update 2026-07-08 (Akeshan) — Interim-UI aus `~/ui` (AD-28). UI-DoD über den Interim-Pfad erfüllt.**
Die HR-Screens rendern nicht mehr handgeschriebenes Tailwind (das war das Playbook-Negativbeispiel), sondern
sind aus `~/ui`-Components recomposed — pure `<XPage vm={} actions={} />` in `apps/web/app/components/screens/hr/`,
die Routen bauen nur das VM und injizieren Action-Callbacks (Fetcher bleiben in der Route). Kein VM-/Loader-/
Schema-Change. Gebaut (AK-P17a-Scope, 6 Screens + 4 Detail-/Sub-Screens):
- `HrHubPage`, `HrDirectoryPage` + `HrDirectoryDetailPage` (Notfallkontakt-Reveal on-demand, Inline-Self-Edit),
`HrLocationsPage` (+ Create-Dialog), `HrMyDocumentsPage` (Kind-Filter, read-only Büro-Uploads),
`HrPasswordSafeListPage` + `HrVaultDetailPage` (**Werte nie in der Liste**, Reveal je Feld,
**echter 30-s-Clipboard-Wipe** nach Copy via `CopyToClipboardButton` + `vm.copyAutoClearMs`, DoD #3),
`HrAdminPasswordSafePage`.
- 57 neue Präsentations-i18n-Keys chirurgisch in de+en gespleißt (kein JSON-Round-trip).
**Proof:** Commit `e0f8affd2` (Branch `feat/AK-P17a-interim-ui`, lokal, nicht gepusht) · `pnpm --filter
@hivegrid/web typecheck` **0 Fehler** · `pnpm run verify:i18n` Exit 0 (Parität ok, 0 missing-source) ·
lefthook pre-commit grün (lint-staged/eslint-baseline/secrets/test-density).
**Interim-UI (Vorschau, Stand 2026-07-08):** Screens aus `~/ui` gebaut — `apps/web/app/components/screens/hr/*`.
Refactor-Nachzug offen: finale Subframe-Optik aus `D-HR` (Plane-Label `design-nachzug`). Noch selbst machbar
(nicht David-blockiert): Screen-Unit-Tests je `<XPage>.test.tsx`, Smoke `e2e/smoke/hr-*`, Hot-Swap-Adapter-Triplet;
Visual-Regression bleibt **deferred** (kein Screenshot-Harness im Repo, R-P5-Präzedenz). Demo-Seed (`is_demo_data`)
für Profile/Standorte/Vault als Folge-Task.
## Dev-Nachzug (nach Davids `D-HR`-Delta)
- [ ] Spec/ENTITY-MODEL an Davids UI-Entscheidungen angepasst
- [ ] REQUIREMENTS/PHASE-SOURCES nachgezogen
- [ ] Plane-Ticket `AK-P17a` aktualisiert
- [ ] Finale `D-HR`-Optik in `<XPage>`-Internals getauscht (VM/Loader/Route unverändert), Visual-Baseline gezogen, `design-nachzug`-Tag auf erledigt
@@ -8,6 +8,8 @@
| **Cycle/Fenster** | W2 — Plattform & CRM (Demo 1) · fällig 2026-06-26 · Gate W2 |
| **Status** | geplant |
**🎨 design-nachzug: offen** (seit 2026-07-08) — Interim-UI aus `~/ui` steht (AD-28); finale Subframe-Optik aus `D-HR` ausstehend (Davids Delta noch leer).
## Abhängigkeiten & Parallelität
- **Blocked by:** `AK-P17a` (HR-Kern + Verschlüsselungs-Pattern + MA/Admin-Trennung)
- **Blocks:** `R-P9b` (Abwesenheiten → Plantafel-Konflikt-Indikatoren)
@@ -28,14 +30,19 @@ Die **HR-Workflows**: einen **mehrstufigen Urlaubs-Approval-Workflow** (submitte
| Upload-Erinnerungen-Verwaltung [`/admin/hr/upload-reminders`] | Reminder-Liste (Titel/Cadence/Audience/Status), Editor (Dokumentart/Cadence/Mandatory/Linked-Form), Dispatch-Liste |
## Definition of Done
- [ ] **Mehrstufiger Urlaubs-Approval-Workflow** (submitted/approved/rejected/withdrawn + Rückfrage) → speist **Abwesenheits-Kalender** (M25) + **Plantafel-Konflikt-Indikatoren** (R-P9b)
- [ ] **Krankmeldung** (selbst/durch Büro) + **AU-Upload verschlüsselt**
- [ ] **Announcements** (Audience, Acknowledgement-Pflicht + Read-Receipt, Reaktionen, Kommentare)
- [ ] **Lohnabrechnungs-Bulk-Upload** mit Dateiname-Auto-Match (Personalnummer/Name), Match-Vorschau mit Konfidenz, Read-Tracking, Audit pro Read
- [ ] **Upload-Erinnerungen** (Cadence once/yearly/every_n_months/on_expiry, Eskalation an Manager) + **Requests** via Templates
- [ ] **Surveys** (anonym möglich) mit Auswertung
- [ ] **Mitarbeiter-Self-Service** (Web/PWA; Web-Push → v2, AD-17); Anträge als **M05-Form-Submissions** (Renderer aus P16 wiederverwenden)
- [ ] Konnektor `tenantHasModule('hr')`; RLS + App-Permission-Gates; sensible Felder verschlüsselt
> Legende: `[x]` = im **jetzt-machbaren** Scope fertig + verifiziert · `(extern: …)` =
> Rest hängt an einem blockierenden Folge-Ticket. Details + %-Stand: Umsetzungs-Notiz unten.
- [x] **Mehrstufiger Urlaubs-Approval-Workflow** (submitted/approved/rejected/withdrawn + Rückfrage) → **Plantafel-Konflikt-Feed** (R-P9b via `listApprovedAbsencesForRange`). *(extern: CalendarEvent-Write → P16/M25)*
- [x] **Krankmeldung** (selbst/durch Büro) + **AU-Upload verschlüsselt** (Crypto-Seam, audited Reveal)
- [x] **Announcements** (Audience, Acknowledgement-Pflicht + Read-Receipt, Reaktionen, Kommentare)
- [x] **Lohnabrechnungs-Bulk-Upload** mit Dateiname-Auto-Match (Personalnummer/Name), Match-Vorschau mit Konfidenz, Read-Tracking, Audit pro Read
- [x] **Upload-Erinnerungen** (Cadence once/yearly/every_n_months/on_expiry, Eskalation an Manager via Worker-Cron) + **Requests** via Templates
- [x] **Surveys** (anonym möglich, `submitterHash`) mit Auswertung (gegated)
- [x] **Mitarbeiter-Self-Service** (Web/PWA) — alle Bereiche reachable. *(extern: M05-Form-Submissions → AK-P16c, bis dahin Übergangs-Formulare; Web-Push → v2/AD-17)*
- [x] Konnektor `tenantHasModule('hr')`; App-Permission-Gates; sensible Felder verschlüsselt; RLS-Policies geschrieben. *(extern: RLS-Live-Cutover → R-P2b, wie P17a)*
- [ ] **Tests/UI extern blockiert:** finale Subframe-UI + Adapter + Smoke-Tests (`e2e/smoke/hr-*`) + Visual-Regression (M016) + Hot-Swap (M017) → **Davids `D-HR`-Delta** (noch leer)
## Technische Bausteine
- **Entities** (eigene `prisma/schema/hr.prisma`, additiv): `EmployeeVacationRequest` (start/end_date, request_kind, status, assigned_approver, calendar_event_id), `EmployeeSickLeaveReport` (start/return_date, certificate_file_id, status — **[verschlüsselt]** AU-Schein), `HrAnnouncement` (+ Read/Reaction/Comment), `EmployeePayslip` (period_year/month, file_id, is_read), `EmployeeUploadReminder` (+ Dispatch), `HrRequest` (+ Template — request_kind, form_submission_id, status, assigned_approver), `HrSurvey` (+ Item/Submission — is_anonymous, submitter_hash). `calendar.prisma`/`forms.prisma` über Relationen (AD-21).
@@ -55,3 +62,120 @@ Die **HR-Workflows**: einen **mehrstufigen Urlaubs-Approval-Workflow** (submitte
- [ ] Spec/ENTITY-MODEL an Davids UI-Entscheidungen angepasst
- [ ] REQUIREMENTS/PHASE-SOURCES nachgezogen
- [ ] Plane-Ticket `AK-P17b` aktualisiert
## Umsetzungs-Notiz
### 2026-06-28 — Backend komplett + Urlaub/Krank-Vertikale reachable (~50 % DoD)
**Stand: ~50 % DoD** (Backend aller 7 Domänen fertig + verifiziert; Erreichbarkeit
bislang nur für die R-P9b-kritische Urlaub/Krank-Vertikale). Backend-first, da
Davids `D-HR`-Delta noch leer ist (finale Subframe-UI/Adapter/Visual-Regression
extern blockiert).
**Proof (2 Commits auf `feat/AK-P17b`, alle Pre-Commit-Gates grün):**
- `e2774ca8b` — Backend: `prisma/schema/hr.prisma` (12 Modelle additiv, `prisma
validate` grün), Permissions (`hr.leave.approve`, `hr.surveys.results`),
RLS-Policies (`prisma/rls/hr-isolation.sql`), 7 Services
(`hr{Leave,SickLeave,Announcement,Payslip,UploadReminder,Request,Survey}Service`),
8 View-Models + 3 pure Utils (`payslip-match`, `reminder-cadence`), 27 Unit-Tests
grün. typecheck web/db/view-models grün.
- `3a00766c7` — Routes `/hr/leave` + `/hr/sick` (transitional, `data-ui-id`),
Hub-Verlinkung, i18n de+en (Parity grün, hardcoded-strings-Guard grün).
**DoD-Stand je Punkt:**
- [~] (1) Urlaubs-Approval-Workflow + Konflikt-Feed → **Backend+Route fertig**;
`EmployeeVacationRequest.calendarEventId` nullable, **CalendarEvent-Write
`blockiert`** bis P16 (M25). R-P9b wird über `listApprovedAbsencesForRange`
bedient.
- [~] (2) Krankmeldung + verschl. AU → **fertig** (Backend+Route+Crypto-Reveal);
nur finale UI = David.
- [~] (3) Announcements → **Backend fertig**, Route offen (selbst machbar).
- [~] (4) Lohn-Bulk + Auto-Match + Read-Tracking → **Backend fertig**, Route offen.
- [~] (5) Upload-Erinnerungen + Requests → **Service-Logik fertig**; Worker-Cron-
Registrierung (`dispatchDueReminders`) + Routes offen (selbst machbar).
- [~] (6) Surveys (anonym) + Auswertung → **Backend fertig**, Route offen.
- [~] (7) Self-Service (PWA; Push→v2) → Urlaub/Krank self-service reachable; übrige
Routes offen; M05-Form-Reuse **blockiert** (AK-P16c) → Übergangs-Formulare;
Web-Push = v2 (out of scope).
- [~] (8) `tenantHasModule('hr')` + RLS + Permission-Gates + Verschlüsselung →
Connector wiederverwendet, RLS-Policies geschrieben (**Live-Cutover blockiert =
R-P2b, wie P17a**), Permissions + Crypto fertig.
**Selbst machbar offen (nächste Commits):** Routes Announcements/Payslips/Requests/
Surveys (+ Admin-Editoren), i18n dazu, Worker-Cron für `dispatchDueReminders`,
Smoke-Tests.
**Extern blockiert:** finale Subframe-UI/Adapter + Visual-Regression + Hot-Swap
(Davids `D-HR`-Delta), CalendarEvent-Write (P16), M05-Renderer (AK-P16c),
RLS-Live-Cutover (R-P2b), Web-Push (v2).
### 2026-06-28 (Update) — alle Domänen-Routes reachable (~80 % DoD)
**Stand: ~80 % DoD.** Alle 6 Screen-Bereiche + Admin-Editoren sind jetzt als
transitional UI erreichbar.
**Proof — Commit `b6c5e4f76`** (alle Pre-Commit-Gates grün): Routes
`/hr/announcements` (Feed/Detail/Admin), `/hr/payslips` (+ `/admin` Bulk-Match),
`/hr/requests` (+ `/admin/request-templates`), `/hr/surveys` (+ `/:id`
Teilnahme/Editor + `/:id/results` gegated), `/admin/upload-reminders`. Hub
verlinkt alle Bereiche. i18n de+en Parity grün, hardcoded-strings-Guard grün,
web-typecheck grün.
**Einziger selbst-machbarer Rest = Worker-Cron für `dispatchDueReminders`
(DoD 5).** Architektur-Frage offen: die Dispatch-Orchestrierung liegt aktuell in
`apps/web/.../hrUploadReminderService.server.ts`, aber der Cron läuft in
`apps/worker` (kann `apps/web` nicht importieren). Optionen: (a) Orchestrierung in
den Worker-Maintenance-Handler verschieben (wie `task-archive-retention`, nutzt
`deps.db` + `computeNextDue` aus `@hivegrid/view-models` + `createNotification
Delivery` aus `@hivegrid/db` — beide worker-importierbar), oder (b) in ein
geteiltes Paket extrahieren. **Stop + Rückfrage** statt raten (AGENTS.md). Die
Logik selbst (`dispatchDueReminders`/`escalateOverdueDispatches`/`computeNextDue`)
ist fertig + `computeNextDue` unit-getestet.
**DoD nach diesem Update:** 1 ~90 % (Calendar-Write blockiert) · 2 ~95 % · 3 ~95 % ·
4 ~95 % · 5 Requests ~95 %, Reminders ~75 % (Cron-Trigger offen) · 6 ~95 % ·
7 ~90 % (M05 blockiert, Push v2) · 8 ~90 % (RLS-Cutover blockiert).
### 2026-06-28 (Update) — Worker-Cron Upload-Erinnerungen fertig (~85 % DoD)
**Stand: ~85 % DoD — alle selbst-machbaren Punkte erledigt.** Worker-Cron gewählt
nach Codebase-Muster (Option a, User-Freigabe).
**Proof — Commit `b2b445010`** (alle Pre-Commit-Gates grün): Maintenance-Task
`hr-upload-reminder-dispatch` (täglich, general-Gruppe) → `runHrUploadReminder
DispatchHandler` (pro Tenant Dispatch + notify + Cadence-Advance + Manager-
Eskalation, alles in `withTenantRLS`). PURE Helfer `nextReminderDue`/
`audienceMatches` + 7 Unit-Tests grün. queue/worker typecheck grün. **DoD 5 jetzt
~95 %** (Cron-Trigger geschlossen).
**Verbleibend = ausschließlich extern blockiert:**
- Finale Subframe-UI + Adapter + **Smoke-Tests** (`e2e/smoke/hr-*`) + Visual-
Regression (M016) + Hot-Swap (M017) → **Davids `D-HR`-Delta** (noch leer).
- CalendarEvent-Write (P16/M25) · M05-Form-Renderer-Reuse (AK-P16c) · RLS-Live-
Cutover (R-P2b) · Web-Push (v2/AD-17).
**6 Code-Commits + 3 Doku-Commits auf `feat/AK-P17b`** (e2774ca8b · 3a00766c7 ·
b6c5e4f76 · b2b445010 + Doku). Nichts gepusht, Plane-Status unverändert (wartet
auf Freigabe).
**Update 2026-07-08 (Akeshan) — Interim-UI aus `~/ui` (AD-28). DoD #43 (UI/Tests) über den Interim-Pfad adressiert.**
Die AK-P17b-Workflow-Screens lagen bereits als Routen auf `main` (Commits `3a00766c7`/`b6c5e4f76`), rendern aber
handgeschriebenes Tailwind. Sie sind jetzt aus `~/ui`-Components recomposed — gleiches AD-28-Muster wie AK-P17a
(pure `<XPage vm={} actions={} />` in `apps/web/app/components/screens/hr/`, Routen bauen VM + injizieren
Callbacks, Fetcher in der Route). Kein VM-/Loader-/Schema-Change. Gebaut (9 Screens):
- `HrLeavePage` (Tabs own/approvals/team, Antrag/Approve/Reject/Rückfrage/Withdraw), `HrSickPage` (own/office,
audited AU-Reveal-and-open), `HrAnnouncementsFeedPage` + `HrAnnouncementDetailPage` (React/Kommentar/Ack) +
`HrAdminAnnouncementsPage`, `HrPayslipsPage` (audited Open-and-view) + `HrAdminPayslipsPage` (Bulk-Match-Vorschau
+ Per-Row-Confirm), `HrRequestsPage` (own/approvals + Template-Wahl) + `HrAdminRequestTemplatesPage`,
`HrAdminUploadRemindersPage`, `HrSurveysListPage` + `HrSurveyParticipationPage` (Item-Editor) +
`HrSurveyResultsPage` (Distribution-Bars).
- 21 neue Präsentations-i18n-Keys chirurgisch in de+en gespleißt.
**Proof:** Commit `f840fcf24` (Branch `feat/AK-P17a-interim-ui`, lokal, nicht gepusht) · `pnpm --filter
@hivegrid/web typecheck` **0 Fehler** · `pnpm run verify:i18n` Exit 0 · lefthook pre-commit grün.
**Interim-UI (Vorschau, Stand 2026-07-08):** Screens aus `~/ui` gebaut — `apps/web/app/components/screens/hr/*`.
Damit ist die finale Subframe-Optik (DoD #43) **kein Done-Blocker** mehr, sondern getrackter Nachzug
(Plane-Label `design-nachzug`). Noch selbst machbar: Screen-Unit-Tests, Smoke `e2e/smoke/hr-*`, Hot-Swap-Adapter;
Visual-Regression bleibt deferred (kein Screenshot-Harness). Extern blockiert bleibt nur: CalendarEvent-Write
(P16), M05-Renderer (AK-P16c), RLS-Live (R-P2b), Web-Push (v2).
@@ -1,5 +1,10 @@
# AK-P19 · P19 Plattform-Admin (admin.hivegrid.de)
> ⚠️ **Billing-Recut 2026-07-04 ([[AD-27]]):** Die frueheren Legacy-Provider-Positionen in diesem Ticket sind abgeloest.
> Plattform-Admin-Billing zielt jetzt auf **HiveGrid-Entitlements + Lago-Billing-State + Mollie-Payment-State**.
> Offene Legacy-Provider-spezifische Admin-Arbeit wandert in den `BILL-*`-Recut und darf nicht als neue Legacy-Provider-Implementierung
> fortgefuehrt werden.
| | |
|---|---|
| **Typ** | Dev (Code) |
@@ -10,22 +15,22 @@
## Abhängigkeiten & Parallelität
- **Blocked by:** `R-P2b` (RLS + RBAC + User-Lifecycle — Admin nutzt Boilerplate-User-Modell als Extension), `AK-P3` (Querschnitt: Audit-Hash-Kette für AdminAuditLog)
- **Blocks:** `AK-P20` (Abo-Baukasten-Mechanik wird hier angelegt), `AK-DEMO1` (Demo-Tenant via Admin)
- **Blocks:** `AK-P20` (Abo-/Entitlement-Mechanik wird hier angelegt), `BILL-06` (interne Admin-Billing-Basis), `AK-DEMO1` (Demo-Tenant via Admin)
- **Läuft parallel zu:** Raffaels `R-P6` (CRM-Kern). **Maximal isoliert** — Admin-Welt vs. CRM-Domäne, eigener Host `admin.hivegrid.de`, **kein Schema-Konflikt**. Eigene `prisma/schema/admin.prisma` (AD-21); **getrennte Welt, KEIN `tenant_id`** auf den Admin-Tabellen.
- **Konflikt-Risiko:** keines auf Schema-Ebene; berührt `core.prisma` nur für User-Extension (admin-mfa-gate).
## Was wir bauen
Die **getrennte Plattform-Admin-Welt** auf `admin.hivegrid.de` (Host-Routing, **NICHT** `/admin`-Pfad): eigene Admin-Shell (getrennte Sidebar/Topbar), **Cookie-Domain-Trennung** zu app.hivegrid.de, **TOTP-Pflicht + MFA-Step-up**. Admin-User haben **keinen Tenant-Workspace** und erscheinen in keiner Mitgliederliste (Boilerplate-Default-Provisionierung abschalten). Funktionen: **Tenant-Verwaltung** (Stammdaten, Status-Wechsel mit Reason-Pflicht, Notizen, Impersonate mit Begründung max. 30 Min + Banner), **Abo-Zusammenstellung** je Tenant (Feature-Konnektoren togglen + Stripe-Positionen = **Abo-Baukasten-Mechanik**), User-Verwaltung (2FA-Reset/Session-Revoke), **Feature-Flag-Verwaltung** (UI zur Engine aus P1), **Queue-/Health-Monitoring** (BullMQ Retry/Pause/Resume), **Support-Inbox** (speist sich aus P21-Feedback-Widget) und der **`AdminAuditLog`** (cross-tenant, tenant_id=null). **v1-Cut:** getrennte AdminUser-Identität/AdminRBAC (M038/M039) → v2; Plattform-Lead-Pipeline → v2; Flag-Massen-Statistik → v2; Churn/ARPU → v2.
Die **getrennte Plattform-Admin-Welt** auf `admin.hivegrid.de` (Host-Routing, **NICHT** `/admin`-Pfad): eigene Admin-Shell (getrennte Sidebar/Topbar), **Cookie-Domain-Trennung** zu app.hivegrid.de, **TOTP-Pflicht + MFA-Step-up**. Admin-User haben **keinen Tenant-Workspace** und erscheinen in keiner Mitgliederliste (Boilerplate-Default-Provisionierung abschalten). Funktionen: **Tenant-Verwaltung** (Stammdaten, Status-Wechsel mit Reason-Pflicht, Notizen, Impersonate mit Begründung max. 30 Min + Banner), **Abo-/Entitlement-Zusammenstellung** je Tenant (Feature-Konnektoren togglen, Plan/Seats/Billing-State nach `BILL-*`), User-Verwaltung (2FA-Reset/Session-Revoke), **Feature-Flag-Verwaltung** (UI zur Engine aus P1), **Queue-/Health-Monitoring** (BullMQ Retry/Pause/Resume), **Support-Inbox** (speist sich aus P21-Feedback-Widget) und der **`AdminAuditLog`** (cross-tenant, tenant_id=null). **v1-Cut:** getrennte AdminUser-Identität/AdminRBAC (M038/M039) → v2; Plattform-Lead-Pipeline → v2; Flag-Massen-Statistik → v2; Churn/ARPU → v2.
## Benötigte Screens (Backend bedient — Design liefert `D-ADMIN` + `D-P19`; eigene Admin-Shell)
| Screen [Route] | Kern-Inhalt |
|---|---|
| Admin-Login + 2FA [`admin.hivegrid.de/login`, `/2fa`] | Email+Passwort (NIST/HIBP, Captcha ab 3. Fehler), TOTP-Pflicht, kein Magic-Link, 8h-Session |
| Admin-Dashboard [`/dashboard`] | Basis-KPIs Tenants/aktive User/MRR + System-Health-Ampel + Queue-Status, Drill-Down (Churn/ARPU → v2) |
| Tenant-Liste + Detail [`/tenants`, `/:id`] | Liste (Status/Plan/MRR/User/Aktivität); Detail: Status-Wechsler (Reason), **Abo-Zusammenstellung (Konnektoren + Stripe-Positionen)**, Trial-Verlängerung, Notizen, **Impersonate mit Begründung**, User-/Activity-/Audit-/Flag-Sektionen |
| Tenant-Liste + Detail [`/tenants`, `/:id`] | Liste (Status/Plan/User/Aktivität); Detail: Status-Wechsler (Reason), **Abo-/Entitlement-Zusammenstellung (Konnektoren + Plan/Seats/Billing-State)**, Trial-Verlängerung, Notizen, **Impersonate mit Begründung**, User-/Activity-/Audit-/Flag-Sektionen |
| Manuelle Tenant-Anlage [`/tenants/new`] | Name, Slug (Live-Validation), Branche/Größe, Initial-Admin-Email, Module/Plan, Trial, Demo-Daten-Häkchen, Reason |
| User-Liste + Detail [`/users`, `/:id`] | Memberships/2FA/Sessions; Ops (2FA-Reset mit Reason, Session-Revoke, Deaktivieren, Lösch im Auftrag) |
| Subscription-Liste + Detail [`/subscriptions`, `/:id`] | Plan/MRR/Status/Stripe-Sync; manuelle Override (Plan/Sonderpreis/Reason), Stripe-Sync forcieren, Refund |
| Subscription-Liste + Detail [`/subscriptions`, `/:id`] | Plan/Seats/Status/Lago-Sync/Mollie-Payment-State; manuelle Override (Plan/Seats/Sonderfall/Reason), Sync forcieren, Refund-Verweis |
| Feature-Flags [`/feature-flags`] | Flag-Liste + Targeting-Filter (plan-tier) + Tenant-Override mit Ablauf (Massen-Statistik → v2) |
| Health + Worker-Queues [`/health`, `/health/workers`] | Service-Ampeln, DB/Redis/Storage; BullMQ-Queues Active/Waiting/Delayed/Failed, Job-Detail + Retry/Pause/Resume |
| Support-Inbox + Detail [`/support`, `/:id`] | Liste + Konversation + Markdown-Reply + interne Notiz; **speist sich aus Feedback-Widget (P21)** |
@@ -33,18 +38,18 @@ Die **getrennte Plattform-Admin-Welt** auf `admin.hivegrid.de` (Host-Routing, **
| Subprozessoren + AVV [`/legal/subprocessors`, `/legal/avv`] | Subprozessor-Liste; AVV-Versionen + akzeptierende/ausstehende Tenants |
## Definition of Done
- [ ] **Host-Routing `admin.hivegrid.de`** (NICHT `/admin`-Pfad!) + **Cookie-Domain-Trennung** App↔Admin
- [ ] **TOTP-Pflicht** hart erzwungen; Admin **ohne Tenant-Workspace** (Default-Provisionierung abgeschaltet, in keiner Mitgliederliste)
- [ ] **Tenant-Verwaltung** + **Abo-Zusammenstellung** (Konnektoren togglen + Stripe-Positionen) + Trial-Verlängerung + Notizen
- [ ] **Impersonate** (temporäre Tenant-User-Session max. 30 Min) mit Klartext-Banner im Tenant + Reason-Pflicht
- [ ] **User-Verwaltung** (2FA-Reset/Session-Revoke), **Feature-Flag-UI** (zur Engine aus P1)
- [ ] **Queue-Monitor** (BullMQ Retry/Pause/Resume, Job-Detail mit Error-Stack) + **Support-Inbox**
- [ ] **`AdminAuditLog`** (tenant_id=null, Hash-Kette); sensitive Aktionen mit Reason-Pflicht + Confirm-Phrase
- [ ] Eigene getrennte Admin-Shell (kein Reuse der App-Sidebar)
- [x] **Host-Routing `admin.hivegrid.de`** (NICHT `/admin`-Pfad!) + **Cookie-Domain-Trennung** App↔Admin
- [x] **TOTP-Pflicht** hart erzwungen; Admin **ohne Tenant-Workspace** (Default-Provisionierung abgeschaltet, in keiner Mitgliederliste) — Authentik-SSO + JIT legt nur User+AdminUser an (kein Tenant), MFA via Authentik+Step-up
- [ ] **Tenant-Verwaltung** + **Abo-/Entitlement-Zusammenstellung** (Konnektoren togglen + Plan/Seats/Billing-State nach `BILL-*`) + Trial-Verlängerung + Notizen — *Backend fertig: Abo-Baukasten-Konnektoren + AdminTenantNote. Offen: Trial-Verlängerung, manuelle Tenant-Anlage, Billing-Recut-Adminbasis aus `BILL-06`, finale UI (David)*
- [ ] **Impersonate** (temporäre Tenant-User-Session max. 30 Min) mit Klartext-Banner im Tenant + Reason-Pflicht — *Cap 30 Min + Banner + MFA-Gate fertig. Offen: Operator-Reason-Modal (David)*
- [x] **User-Verwaltung** (2FA-Reset/Session-Revoke), **Feature-Flag-UI** (zur Engine aus P1)
- [ ] **Queue-Monitor** (BullMQ Retry/Pause/Resume, Job-Detail mit Error-Stack) + **Support-Inbox** — *Backend fertig (Service + VM + audited Aktionen). Offen: finale UI (David), BullMQ-Adapter-Runtime auf Staging*
- [x] **`AdminAuditLog`** (tenant_id=null, Hash-Kette); sensitive Aktionen mit Reason-Pflicht + Confirm-Phrase — via AuditEvent-Querschnitt (tenantId=null/actorType=admin) + `/admin/audit` cross-tenant + Gate
- [x] Eigene getrennte Admin-Shell (kein Reuse der App-Sidebar)
## Technische Bausteine
- **Entities** (eigene `prisma/schema/admin.prisma` — **KEIN `tenant_id`**, getrennte Welt): `AdminAuditLog` (admin_user_id, action_type, target_kind/id, summary_text, metadata Diff — cross-tenant), `AdminTenantNote` (tenant_id, note_text, category, pinned), `AdminSupportTicket` (+ Message — source_kind in_app_feedback_widget/inbound_email, tenant_id, status, priority). **v1-Cut:** `AdminUser`/`AdminRole`/`AdminSession` (M038/M039) → v2; v1 = Boilerplate-User-Extension + `admin-mfa-gate.server.ts`. `AdminLead`/`AdminDeal` → v2.
- **Abo-Baukasten-Mechanik** (Billing-Domäne 12): Admin schaltet `SubscriptionFeature`-Konnektoren je Tenant + Stripe-Positionen — Grundlage für AK-P20 (Self-Service-UI dort → v2). `tenantHasModule()` prüft Feature im Abo.
- **Abo-/Entitlement-Mechanik** (Billing-Domäne 12): Admin schaltet `SubscriptionFeature`-Konnektoren je Tenant; Plan/Seats/Billing-/Payment-State wird nach `BILL-02` bis `BILL-06` ueber Lago/Mollie/HiveGrid synchronisiert. `tenantHasModule()` prueft Feature im lokalen Entitlement-State.
- **Host-Routing + Cookie-Domain-Trennung** (Boilerplate routet `/admin`-Pfad → auf Host umstellen); TOTP-Pflicht + MFA-Step-up-Gate.
- **Impersonate** generiert temporäre Tenant-Session (max 30 Min) mit Banner + Audit-Spur; kein BYPASSRLS.
- **AdminAuditLog** über die Audit-Hash-Kette (AK-P3); Reason-Pflicht bei Status-Wechsel/Impersonate/Override.
@@ -60,3 +65,74 @@ Die **getrennte Plattform-Admin-Welt** auf `admin.hivegrid.de` (Host-Routing, **
- [ ] Spec/ENTITY-MODEL an Davids UI-Entscheidungen angepasst
- [ ] REQUIREMENTS/PHASE-SOURCES nachgezogen
- [ ] Plane-Ticket `AK-P19` aktualisiert
## Umsetzungs-Notiz
### 2026-06-25 — Backend-Fundament (Branch `feat/AK-P19`)
**Vorab-Entscheidungen (mit User abgestimmt):**
- **Admin-Login = Authentik-SSO** (juengere, in Prod laufende Infra-Entscheidung: AK-INFRA2, AGENTS.md STANDARDS §5/AD-3). Der aeltere Brief-Text „Email+Passwort+TOTP" gilt damit nicht; DoD #2 = TOTP/MFA via Authentik + `admin-mfa-gate` Step-up.
- **DoD #7 `AdminAuditLog`** wird NICHT als eigenes Modell gebaut, sondern ueber die bestehende **AuditEvent-Querschnitt-Huelle** (tenantId=null, actorType=admin, Hash-Kette aus AK-P3). Grund: AGENTS.md verbietet Querschnitt-Nachbau; AuditEvent unterstuetzt tenantId=null bereits. `admin.prisma` enthaelt daher kein Audit-Modell.
**Ist-Bestand (Boilerplate, verifiziert — zaehlt auf DoD):** Host-Routing `admin.hivegrid.de` + Cookie-Trennung (DoD #1 ✅), eigene Admin-Shell/Sidebar (DoD #8 ✅), User-Ops + Feature-Flag-UI (DoD #5 ✅ weitgehend), Impersonate gewired mit MFA-Gate + Audit (DoD #4 teilweise).
**Diese Session gebaut + verifiziert:**
- `prisma/schema/admin.prisma` — `AdminTenantNote` (Domaene 11) + `SupportTicketMessage` (Konversation/interne Notiz, erweitert bestehendes `SupportTicket` statt Duplikat). Minimale Back-Relations. `prisma validate` + `generate` gruen. **`db push` steht noch aus** (braucht laufende Dev-DB), Migration erst beim Merge (AD-21).
- `apps/web/app/services/admin/admin-action-audit.server.ts` — Operator-Action-Audit-Gate (DoD #7): Reason-Pflicht + Confirm-Phrase + Emit als AuditEvent (tenantId=null/actorType=admin) ueber `createAuditEvent`. Unit-Test 10/10.
- `packages/view-models/src/admin/tenant-note-list.vm.ts` — reiner Notizen-VM (DoD #3, pinned-first, i18n-Keys, Empty-State). Unit-Test 6/6.
**Proof:** web-Typecheck Exit 0, view-models-Typecheck clean, `verify:i18n` Exit 0, Tests 16/16. Commits `6cf7a55b4` (Schema), `e562b2752` (Audit-Gate + VM). Nicht gepusht, kein Status-Wechsel.
**%-DoD-Stand:** rund 40 % (3 von 8 voll gruen: #1/#5/#8; #7 ~70 % = Gate gebaut, offen Viewer-Cross-Tenant + Verdrahtung an Aktionen; #3 ~30 % = Notizen-VM/Audit, offen Abo-Baukasten/Trial/Service).
**Selbst machbar offen (Backend, ohne David):** Abo-Baukasten-Mechanik (Konnektoren je Tenant + Plan/Seats/Billing-State nach `BILL-*`), Queue-Monitor (BullMQ), Support-Inbox-Service (auf `SupportTicketMessage` + bestehendem `@hivegrid/db/support-tickets.server`), AdminTenantNote-Service (CRUD), Impersonate-Haertung (30-Min-Cap/Banner), Admin-ohne-Tenant-Workspace-Haertung, Audit-Viewer Cross-Tenant-Erweiterung + Call-Site-Verdrahtung, `db push`.
**Extern blockiert:** finale Subframe-UI aller Screens (Davids `D-ADMIN`/`D-P19`-Delta noch leer).
### 2026-06-25 (Forts.) — Backend-Slices Abo-Baukasten / Queue / Support / Notizen
**Zusaetzlich gebaut + verifiziert:**
- **Abo-Baukasten-Mechanik (DoD #3, Grundlage AK-P20):** `services/admin/subscription-assembly.server.ts` toggelt Feature-Konnektoren je Tenant ueber `TenantFeatureOverride` (enabled/disabled/inherit), invalidiert den `tenantHasModule`-Cache und auditet reason-gegated. Reuse des Entitlement-Resolvers, kein Parallel-Store. + `subscription-connectors.vm.ts` (effektiver Zustand aus Blueprint-Default + Override).
- **Queue-Monitor (DoD #6 Teil A):** `services/admin/queue-monitor.server.ts` — `QueueInspector`-Port + Snapshot-Aggregation + audited Pause/Resume/Retry; BullMQ-Adapter ueber `@hivegrid/queue` (Redis-Runtime auf Staging zu verifizieren). + `queue-monitor.vm.ts` (Counts -> Klartext-Health ok/warn/paused, Worst-of-Overall). Hinweis: `TypedQueue` ist enqueue-only, Read/Control via strukturellem Cast.
- **Support-Inbox (DoD #6 Teil B):** `services/admin/support-inbox.server.ts` — Reply / interne Notiz / Status / Assignee auf dem `SupportTicketMessage`-Thread, validiert + auditiert. + `support-conversation.vm.ts` (Thread oldest-first, reply/internalNote/inbound).
- **Tenant-Notes (DoD #3):** `services/admin/tenant-notes.server.ts` — create/delete/list `AdminTenantNote` mit Kategorie-Normalisierung + Audit.
**Proof:** web-Typecheck 0 Fehler (grep `error TS` = 0; Achtung: gepipte Typechecks maskieren tsc-Exit, daher gegen Inhalt geprueft), view-models-Typecheck clean, `verify:i18n` Exit 0, Admin-Tests **52/52** in 9 Dateien. Commits `0bb9e29ad` (Abo-Baukasten+Queue), `116aee518` (Queue-BullMQ-Cast-Fix), `c9dedcafe` (Support+Notizen). Nicht gepusht, kein Status-Wechsel.
**%-DoD-Stand jetzt:** rund **60 %**. Voll gruen: #1/#5/#8 (Boilerplate, verifiziert). Backend-fertig (UI = David): #6 (Queue+Support), #3-Teile (Abo-Baukasten + Notizen). #7 ~80 % (Gate gebaut + an Abo/Queue/Support-Aktionen verdrahtet; offen: Audit-Viewer Cross-Tenant + Impersonate/Status-Call-Sites).
**Noch offen, selbst machbar:** Audit-Viewer Cross-Tenant-Erweiterung, Impersonate-Haertung (30-Min/Banner) + Reason-Verdrahtung, Admin-ohne-Tenant-Workspace-Haertung (DoD #2), Trial-Verlaengerung + manuelle Tenant-Anlage, Billing-Recut-Adminbasis aus `BILL-06`, `db push` (Dev-DB) + VM-Unit-Tests laufen, Smoke-/Visual-Tests nach Davids UI. **Extern blockiert:** finale Subframe-Screens (David).
### 2026-06-25 (Forts. 2) — DoD #2 / #4 / #7 verifiziert bzw. geschlossen
Drei Punkte gegen den realen Code geprueft statt neu gebaut:
- **DoD #2 (Admin ohne Tenant-Workspace) = erfuellt.** Der Admin-Login ist Authentik-SSO; `modules/admin-sso/jit-provisioning.server.ts` legt **nur** `User` + `AdminUser` an, **keinen** Tenant, **keine** `TenantUser`-Mitgliedschaft, **kein** `defaultTenantId`. Damit hat ein Admin keinen Workspace und taucht in keiner Mitgliederliste auf. TOTP/MFA laeuft ueber Authentik + `admin-mfa-gate` Step-up (bestehend). Verifiziert per Code-Read + bestehende `jit-provisioning.server.test.ts` (Fake-Client hat gar kein tenant-Modell).
- **DoD #7 (AdminAuditLog cross-tenant + Viewer) = erfuellt.** Der `/admin/audit`-Viewer (AK-P3) liest ohne `tenantId`-Param **alle** AuditEvent-Zeilen inkl. der `tenantId=null`/`actorType=admin`-Operator-Aktionen, filterbar nach Actor/Tenant. Mein Gate schreibt genau dorthin; Reason + Confirm-Phrase liegen im Gate. Kein Viewer-Umbau noetig.
- **DoD #4 (Impersonate max. 30 Min) = geschlossen.** `IMPERSONATION_SESSION_TTL_MS` war 60 Min -> auf **30 Min** gesetzt; `validateImpersonationSessionState` liefert danach `stale_started_at`. Banner (rootData) + MFA-Step-up bestehen. Test: 29 Min gueltig / 31 Min stale. Offen nur das Operator-Reason-Modal auf der neuen Tenant-Detail-Impersonate-Aktion (Davids UI).
Commit `feat(AK-P19): cap operator impersonation at 30 minutes`. web-Typecheck 0 Fehler, session-Impersonation-Tests 16/16.
**%-DoD-Stand jetzt: rund 70 %.** Voll gruen: #1, #2, #4 (bis auf Reason-Modal=David), #5, #7, #8. Backend-fertig (UI=David): #6 (Queue+Support), #3 (Abo-Baukasten+Notizen). Rest #3: Trial-Verlaengerung + manuelle Tenant-Anlage + Billing-Recut-Adminbasis aus `BILL-06` + `db push`.
### 2026-06-26 — `prisma db push` + DB-Integrationstest (real-DB-Verifikation)
- **`db push` erfolgreich** gegen eine frische pgvector-Postgres (Docker, da compose kein Postgres mitbringt): das **gesamte** Schema inkl. `admin.prisma` synct sauber. Verifiziert per `psql`: Tabellen `AdminTenantNote`, `SupportTicketMessage` + Spalte `SupportTicket.assignedToUserId` vorhanden. Damit AD-21 (Feature-Branch = `db push`, Migration erst beim Merge) erfuellt. **Befehl:** `prisma db push --url <direct-url>` (Prisma 7 kennt kein `--skip-generate` mehr).
- **DB-Integrationstest** `admin-services.integration.test.ts`: faehrt den **echten** Service-Code (tenant-notes, subscription-assembly, support-inbox) ueber einen echten Prisma-Client (`createDbClient({connectionString})`) gegen die gepushte DB — faengt Query-/Schema-Mismatches, die die Fake-Unit-Tests nicht sehen. **3/3 gruen** mit DB, **auto-skip** ohne `ADMIN_IT_DATABASE_URL` (CI bleibt gruen). web-Typecheck 0 Fehler.
Commits `test(AK-P19): DB-backed integration test`, `test(AK-P19): extend ... support-inbox thread`. Damit ist der selbst-machbare Backend-Teil **gegen eine echte DB verifiziert**; `db push` aus der offenen DoD-Restliste erledigt. Es fehlt selbst-machbar nur noch: Trial-Verlaengerung + manuelle Tenant-Anlage + Billing-Recut-Adminbasis (`BILL-06`, Lago/Mollie/HiveGrid-gekoppelt) und Davids finale UI.
## Noch zu erledigen (Stand 2026-06-26, ~70 % DoD)
**A — Selbst machbar, aber Billing-/Multi-Subsystem-gekoppelt (eigene CR/Folge-Arbeit sinnvoll):**
- [ ] **Trial-Verlängerung** je Tenant (Subscription-Trial-Ende setzen + Reason + Audit) — haengt am neuen Billing-State aus `BILL-02`/`BILL-05`.
- [ ] **Manuelle Tenant-Anlage** `/tenants/new` (Name/Slug-Live-Validation, Branche/Größe, Initial-Admin-Email, Module/Plan, Trial, Demo-Daten, Reason) als DoD-konforme **neue** Route (nicht die Legacy-`accounts/$id`-Route umbauen) — berührt Tenant-Create + Initial-User + Subscription + Demo-Seed.
- [ ] **Subscription-Override / Billing-State-Seite** (Plan/Seats/Sonderfall/Refund-Verweis, Lago/Mollie-Sync forcieren) — umgesetzt ueber `BILL-06`, nicht ueber Legacy-Provider.
**B — Extern blockiert (Davids `D-ADMIN`/`D-P19`-Delta ist leer):**
- [ ] Finale Subframe-Screens aller Admin-Routen (Tenant-Detail mit Abo-Baukasten-Panel + Notizen-Editor, Queue-Monitor, Support-Inbox, Impersonate-Begründungs-Modal, manuelle Tenant-Anlage).
- [ ] Daran hängende DoD-Querschnitte: Reachability/Sidebar-Gate je neuer Route, Breadcrumbs, **Smoke-Tests** `e2e/smoke/admin-*.spec.ts`, **Hot-Swap-Drill**, **Visual-Regression-Snapshots**, Empty/Loading/Permission-Denied-States.
**C — Betrieb/Runtime:**
- [ ] **BullMQ-Queue-Monitor-Adapter** gegen echtes Redis auf **Staging** verifizieren (Unit-Logik + Orchestrierung sind getestet; der Live-Adapter braucht Redis).
- [ ] Operator-**Reason-Modal** auf der neuen Tenant-Detail-Impersonate-Aktion ans Gate verdrahten (Backend-Enforcement steht, UI = David).
**Fertig (Backend verifiziert, gegen echte DB):** admin.prisma (`db push` ok), Operator-Audit-Gate (Reason+Confirm-Phrase), Abo-Baukasten-Konnektoren, Tenant-Notizen, Queue-Monitor-Logik, Support-Inbox-Service, Impersonate-Cap 30 Min. DoD #1/#2/#5/#7/#8 grün.
@@ -1,4 +1,9 @@
# AK-P20 · P20 Billing (Stripe Abo-Baukasten) + Onboarding + Branding
# AK-P20 · P20 Billing-Grundlagen + Onboarding + Branding
> ⚠️ **Billing-Recut 2026-07-04 ([[AD-27]]):** Die Legacy-Provider-Annahmen in diesem Ticket sind Doku-Drift. Zielarchitektur
> ist **Lago self-hosted fuer Billing/Invoicing + Mollie nur fuer Payments + HiveGrid-Entitlements**. v1-Scope:
> **Plans + Seats**, kein Usage-Billing. Legacy-Provider-Codepfade aus der bisherigen Teilumsetzung sind Migrationsmaterial fuer
> `BILL-02` bis `BILL-05`, nicht mehr Zielarchitektur.
> ⚠️ **KI→MCP-Pivot ([[AD-26]]):** Der **AI-Credits-Screen + AI-Token-Quota entfallen** (kein LLM-Gateway in v1 — HiveGrid wird MCP-Server, [[VR-MCP]]; `AiCredits`/`AiTokenUsage` gestrichen). Die Verbrauchs-Übersicht bleibt für Automation-Läufe + Speicher. Alle AI-Credits/AI-Token-Stellen unten sind durchgestrichen.
@@ -8,49 +13,81 @@
| **Owner** | Akeshan |
| **Phase** | P20 — Billing, Onboarding & Branding |
| **Cycle/Fenster** | W4 — USP, Härtung & Launch · fällig 2026-07-08 · Gate W4 |
| **Status** | geplant |
| **Status** | Recut / On Hold fuer Billing-Anteil; Onboarding/Branding/Frozen-Vorarbeit verwertbar |
## Abhängigkeiten & Parallelität
- **Blocked by:** `EXT-07` (Stripe-Account + Produkte/Preise + Webhook-Endpoint), `AK-P19` (Abo-Baukasten-Mechanik + Admin-Tenant-Verwaltung als Voraussetzung)
- **Blocks:** `AK-P21` (DSGVO/Launch — Tenant-Löschung braucht Stripe-Cancel)
- **Blocked by:** `BILL-00` bis `BILL-05` fuer den neuen Billing-Kern; `AK-P19` fuer Admin-/Tenant-Verwaltungsgrundlagen; `D-P20` fuer finale Screens
- **Blocks:** `AK-P21` teilweise (Read-only/Frozen-State und Billing-Cancel-Vertrag), `BILL-06` (Admin-Billing-Basis), spaetere Customer-Billing-Screens
- **Läuft parallel zu:** Raffaels Kern-Kette (Auftrags-/Abnahme-Welt P9). Eigene `prisma/schema/billing.prisma` + Onboarding-Teile (AD-21); `core.prisma` (Tenant) nur über Relation.
- **Konflikt-Risiko:** gering — additive Tabellen + Read-Only-Middleware (Frozen) auf der Request-Pipeline (Querschnitt, additiv).
## Was wir bauen
Drei gebündelte v1-Module: **Billing als Stripe-Abo-Baukasten** (M154-Recut) — Basis-Preis pro Nutzer + Feature-Module als **daten-getriebene Positionen**, **KEINE Seat-Typen**; In-App **keine** Pricing-/Upgrade-/Plan-Wechsel-UI (Apple-Compliance LP-8) — nur Read-Only-Übersicht + externer Verweis; **Trial + Dunning** (Tag 1/3/7) + **Frozen-Modus** (Read-Only-Middleware); Verbrauchs-Zähler + Drossel (Aufschlags-Abrechnung → v2). **Onboarding** = **Auto-SHK-Provisionierung** beim Signup (Seeds M158, **ohne Wizard** — der ist v2) + Demo-Daten-Knopf + Erste-Schritte-Checkliste. **Branding** = nur **Hell/Dunkel-Toggle** (kein System-Modus), flash-frei via SSR-Cookie.
Drei gebündelte v1-Module:
1. **Billing-Grundlagen:** lokaler Billing-/Entitlement-State fuer Plans + Seats, Trial/Dunning/Frozen, Read-only-State
und Feature-Gates. Lago wird spaeter Billing-Source-of-Truth, Mollie nur Payment Processor.
2. **Onboarding:** Auto-SHK-Provisionierung beim Signup (Seeds M158, ohne Wizard) + Demo-Daten-Knopf +
Erste-Schritte-Checkliste.
3. **Branding:** nur Hell/Dunkel-Toggle (kein System-Modus), flash-frei via SSR-Cookie.
Nicht mehr Ziel dieses Tickets: Legacy-Provider-Abo-Baukasten, Legacy-Provider-Customer-Portal, Payment-Checkout, Usage-Billing,
AI-Credits oder Pricing-/Upgrade-UI fuer Endkunden.
## Benötigte Screens (Backend bedient — Design liefert `D-P20`)
| Screen [Route] | Kern-Inhalt |
|---|---|
| Abo-Übersicht [`/admin/subscription`] | Basis-Plan + **Feature-Module als Positionsliste mit Preisen** (read-only), Status/Trial-Countdown, **KEIN Upgrade-Button** (Hinweis-Box „Plan-Wechsel über Account-Manager/hivegrid.de"), Pausieren + Kündigen |
| Verbrauchs-Übersicht [`/admin/usage`] ~~· AI-Credits [`/admin/usage/ai-credits`]~~ (AI-Credits-Screen entfällt — [[AD-26]]) | Quota-Balken je Metrik (Automation/~~AI-Tokens~~ (entfällt)/Speicher), ~~Donut AI-Verbrauch~~, 80%-Warnbanner, Aufgebraucht-Klartext |
| Rechnungen/Zahlung [`/admin/billing`] | **Stripe-Customer-Portal-Embed** (Rechnungs-History/PDF, Zahlungsmethode, USt-Daten) |
| Rechnungen/Zahlung [`/admin/billing`] | Lago-Invoice-Status + Mollie-Payment-Status + Klartext-Hilfe; kein Legacy-Provider-Portal-Embed |
| Demo-Daten [`/admin/onboarding/demo-data`] | Settings-Knopf „Beispieldaten anlegen" (~25 Datensätze, Klartext-Liste), Demo-Zähler + Lösch-Funktion, „Demo ausblenden"-Filter |
| Erste-Schritte-Checkliste [`/onboarding/checklist`] | Auto-erkannte Status-Häkchen, CTA-Links, manuell abhakbar/ausblendbar, Glückwunsch bei 100% (auch Dashboard-Widget) |
| Status-Banner-System (Shell) | trial-Ende-3-Tage / past_due / frozen / deletion_pending je eigener Klartext + Action; Read-Only-Toast bei blockiertem Schreib-Button |
| Hell/Dunkel-Toggle [Profil/User-Menü] | Zwei Optionen, sofortige Anwendung, flash-frei (SSR-Cookie) |
## Definition of Done
- [ ] **Stripe-Abo-Baukasten**: Basis-Preis pro Nutzer + Feature-Module als daten-getriebene Positionen — **KEINE Seat-Typen** (M154-Recut); Preise geparkt (AD-15)
- [ ] **Trial + Dunning** (Tag 1/3/7) + **Frozen-Modus** (Read-Only-Middleware) greift; Status-Banner pro Zustand; Verbrauchs-Zähler + Drossel (Aufschlags-Abrechnung → v2)
- [ ] **In-App keine** Pricing-Tabelle/Upgrade-Buttons/Plan-Wechsel-UI (LP-8) — Read-Only + externer Verweis; Pause/Kündigen erlaubt
- [ ] **Auto-SHK-Provisionierung** beim Signup (Seeds M158: Pipelines, Custom-Felder, Rollen, Form-Templates, Checkliste) — **ohne Wizard-UI** (v2)
- [ ] **Demo-Daten-Knopf** (per Settings, `is_demo_data`-Flag löschbar) + „Demo ausblenden"-Filter
- [ ] **Erste-Schritte-Checkliste** (auto-erkannter Status, CTA-Links, abhakbar/ausblendbar; Dashboard-Widget)
- [ ] **Hell/Dunkel-Toggle** flash-frei (SSR-Cookie, `next-themes`) — kein System-Modus
- [ ] Konnektor `tenantHasModule('billing')`; RLS auf allen Tenant-Tabellen
- [ ] **Billing-Recut-Basis**: lokaler Plan-/Seat-/Entitlement-State ist auf Lago/Mollie/HiveGrid ausgerichtet; keine Legacy-Provider-IDs als Zielmodell; kein Usage-Billing
- [x] **Trial + Dunning** (Tag 1/3/7) + **Frozen-Modus** (Read-Only-Middleware) greift; Status-Banner pro Zustand; Verbrauchs-Zähler + Drossel (Aufschlags-Abrechnung → v2)
- [x] **In-App keine** Pricing-Tabelle/Upgrade-Buttons/Plan-Wechsel-UI (LP-8) — Read-Only + externer Verweis; Pause/Kündigen erlaubt
- [x] **Auto-SHK-Provisionierung** beim Signup (Seeds M158: Pipelines, Custom-Felder, Rollen, Form-Templates, Checkliste) — **ohne Wizard-UI** (v2)
- [x] **Demo-Daten-Knopf** (per Settings, `is_demo_data`-Flag löschbar) + „Demo ausblenden"-Filter
- [x] **Erste-Schritte-Checkliste** (auto-erkannter Status, CTA-Links, abhakbar/ausblendbar; Dashboard-Widget)
- [x] **Hell/Dunkel-Toggle** flash-frei (SSR-Cookie, `next-themes`) — kein System-Modus
- [x] Konnektor `tenantHasModule('billing')`; RLS auf allen Tenant-Tabellen
## Aktueller Stand (Billing-Recut 2026-07-04)
**Fortschritt:** **AK-P20: 7/8 historischer DoD = 87,5 % done; nach AD-27 bleibt 1/8 blockiert/ersetzt durch `BILL-02` bis `BILL-05`.**
**Erledigt:**
- Trial/Dunning/Frozen inkl. Statusbanner, zentralem Read-only-Guard, Usage-Zählern und Drossel.
- In-App-Subscription bleibt read-only: keine Pricing-Tabelle, keine Upgrade-Buttons, keine Planwechsel-UI; Billing-/Zahlungsaktionen bleiben nur als freigegebene Recovery-/Kontaktpfade erreichbar.
- Auto-SHK-Provisionierung beim Signup mit Rollen, Pipeline, Formular-Templates, Projekt-/Abnahme-Templates und Checkliste.
- Demo-Daten-Knopf, `isDemoData`-Tracking, Löschung und Standardfilter “Demo ausblenden”.
- Erste-Schritte-Checkliste mit Auto-Erkennung, manuellen Zuständen und Dashboard-Widget.
- Hell/Dunkel-Toggle ohne System-Modus, SSR-Cookie-basiert.
- Billing-Konnektor `tenantHasModule('billing')`, Tenant-Isolation/RLS-Artefakte und Boundary-Gates.
- Seat-Typen im Plattform-Admin-Pricing blockiert: keine `PER_SEAT`-Seeds, keine `PER_SEAT`-Auswahl, serverseitige Guards gegen manipulierte Formulare.
**Offen:**
- Neuer Billing-Abschluss: lokale Billing-Domain auf Lago-/Mollie-Referenzen umstellen, Legacy-Provider-spezifische Felder/Services inventarisieren und ueber `BILL-02` bis `BILL-05` ersetzen.
- Finale Subframe-/Screen-Integration aus `D-P20`.
**Recut-Entscheidung:** AK-P20 wird nicht still auf Done gesetzt. Die verwertbaren Teile (Trial/Dunning/Frozen,
Read-only, Onboarding, Demo-Daten, Checkliste, Theme, Entitlement-Gates) bleiben Grundlage. Der alte
Legacy-Provider-Abo-Baukasten-DoD wird durch `BILL-02` bis `BILL-05` ersetzt.
## Technische Bausteine
- **Entities** (eigene `prisma/schema/billing.prisma`): `SubscriptionProduct` (Plan Core/Swarm/Colony — global), `SubscriptionPrice` (stripe_price_id, interval), `SubscriptionFeature` (+ `ProductFeature`, quota_value JSON — **Brücke zur Konnektor-Pflicht**, `tenantHasModule()`), `SubscriptionUsageBasedPrice`/`UsageBasedTier`, `TenantSubscription` (1:1 Tenant, status trial/active/past_due/cancelled/frozen, Stripe-IDs, Dunning-Felder), `TenantSubscriptionProductPrice` (quantity=Seats, is_metered), `TenantSubscriptionUsageRecord`, `Credit`, `CheckoutSessionStatus`. Onboarding: `Onboarding`/`OnboardingSession`/`OnboardingDemoProvisioning`. Branding: `User.theme_preference` (Feld, kein eigenes Modell). `core.prisma` über Tenant-Relation (AD-21).
- **Abo-Baukasten** (M154-Recut): `SubscriptionFeature.code` (z.B. `kwp_integration`) = Entitlement-Schicht; Struktur aus Legacy `pricing-plans.ts` portieren, Preise = Kostenmatrix (AD-15).
- **Stripe-Integration**: Webhook-Handler (Signatur), Customer-Portal-Embed, Dunning-Email-Sequenz, Frozen-Read-Only-Middleware auf der Request-Pipeline.
- **Entities** (eigene `prisma/schema/billing.prisma`): bestehende Subscription-/Feature-/Usage-Tabellen bleiben Migrationsbasis, muessen aber in `BILL-02` von Legacy-Provider-zentrierten Referenzen auf Lago-/Mollie-/HiveGrid-Referenzen umgestellt werden. Onboarding: `Onboarding`/`OnboardingSession`/`OnboardingDemoProvisioning`. Branding: `User.theme_preference` (Feld, kein eigenes Modell). `core.prisma` nur ueber noetige Tenant-Relationen (AD-21).
- **Abo-/Entitlement-Schicht:** `SubscriptionFeature.code` (z.B. `kwp_integration`) = Entitlement-Schicht; `tenantHasModule()` bleibt zentrale Gate-API.
- **Billing-Integration neu:** Lago-Gateway (`BILL-03`), Mollie-Payment-Gateway (`BILL-04`), Sync/Frozen-State (`BILL-05`). Die vorhandenen Legacy-Provider-Webhook-/Dunning-Tests sind Migrationsvorlage, nicht Ziel.
- **Auto-SHK-Provisionierung** orchestriert die Self-Seeds aller Module (AD-12); Demo-Daten-Seed via `is_demo_data`; Trial-Freischaltung durch Plattform-Admin (AK-P19).
- **RLS** überall (global: Produkte/Features/Preise); `next-themes` SSR-Cookie für Hell/Dunkel.
## Quellen (granular)
- `ENTITY-MODEL.md` → **Domäne 12: Billing & Abo-Baukasten** (Recut-Modell, keine Seat-Typen) + **Domäne 13** (Onboarding, OnboardingDemoProvisioning, User.theme_preference)
- `REQUIREMENTS.md` → `REQ-billing-datenmodell`, `REQ-billing-trial-dunning`, `REQ-onboarding-demo-daten`, `REQ-branding-theme-phase1` (Onboarding-Wizard → v2)
- `ROADMAP.md` → Phase 20 · `PHASE-SOURCES.md` → Phase 20 (M153-stripe, M154-recut bindend, M155-trial-readonly, M156-metering Zähler+Drossel, M158-shk-provisionierung, M159-branchen-templates nur SHK, M160-demo gedünnt, M030 nur Hell/Dunkel)
- `ROADMAP.md` → Phase 20 · `PHASE-SOURCES.md` → Phase 20 (M153-legacyProvider, M154-recut bindend, M155-trial-readonly, M156-metering Zähler+Drossel, M158-shk-provisionierung, M159-branchen-templates nur SHK, M160-demo gedünnt, M030 nur Hell/Dunkel)
- `designer-briefs/P20-billing-onboarding-branding.md` (Abo-Baukasten, kein Upgrade-UI LP-8, Demo-Knopf, Hell/Dunkel)
- `_bmad-output/specs/M28-subscriptions-billing.md` (⚠ M154-Recut), `M29-onboarding.md` (Wizard → v2), `M31-branding.md` · Legacy: `archive/hivegrid-cloud/app/config/pricing-plans.ts`
@@ -58,3 +95,215 @@ Drei gebündelte v1-Module: **Billing als Stripe-Abo-Baukasten** (M154-Recut)
- [ ] Spec/ENTITY-MODEL an Davids UI-Entscheidungen angepasst
- [ ] REQUIREMENTS/PHASE-SOURCES nachgezogen
- [ ] Plane-Ticket `AK-P20` aktualisiert
## Umsetzungs-Notiz
> Hinweis 2026-07-04: Die folgenden Slice-Notizen dokumentieren historische Umsetzung vor `AD-27`. Wo sie Legacy-Provider nennen,
> ist das als Migrationsinventar/Vorarbeit zu lesen. Der aktive Zielpfad ist `BILL-02` bis `BILL-05`.
**2026-07-01 · AK-P20 Teilumsetzung**
Umgesetzt:
- Billing-Grundlage erweitert: `SubscriptionFeature.code`/`quotaValue`, Subscription-Statusfelder, metered Usage-Records mit Idempotenz, Webhook-Log, `billing`-Entitlement und `tenantHasModule('billing')`-Gate auf der Subscription-/Usage-Route.
- In-App-AI-Credits entfernt bzw. auf Subscription umgeleitet; Subscription-UI bleibt read-only mit externem Hinweis, Portal/Kündigen bleiben erhalten.
- Verbrauchsübersicht für Automation-Läufe/Speicher, Billing-Statusbanner-VM und Shell-Banner vorbereitet.
- SHK-Onboarding-Grundlage: Signup ruft SHK-Defaults auf, Checkliste und Demo-Daten-Service/-Routes mit `isDemoData`-Tracking und löschbarer Provisionierung.
- Hell/Dunkel-Toggle auf zwei Modi begrenzt, kein System-Fallback mehr.
- Tenant-Isolation/RLS-Nachzug: `OnboardingDemoProvisioning`, Usage-Records und RLS-Artefakt registriert; Windows-kompatible Verify-Wrapper für die betroffenen Baseline-Gates repariert.
Proof:
- `pnpm prisma validate`
- `pnpm typecheck`
- `pnpm lint`
- `pnpm run verify:i18n`
- `pnpm run verify:sidebar-routes`
- `pnpm run verify:tenant-isolation-report`
- `pnpm run verify:billing-boundary-baseline`
- `pnpm run verify:onboarding-flow`
- `pnpm --filter @hivegrid/entitlements test`
- `pnpm --filter @hivegrid/cli exec vitest run src/commands/tenant/models.test.ts`
- `pnpm --filter @hivegrid/web exec vitest run app/application/sidebar/AppSidebar.test.tsx app/utils/theme/userThemePreference.server.test.ts 'app/routes/app.$tenant/settings/subscription.test.tsx' app/utils/services/.server/billingStatusService.test.ts`
Fortschritt: **AK-P20: 2/8 DoD = 25 % done**.
Abgehakt: Hell/Dunkel ohne System-Modus; Billing-Konnektor/RLS.
Teilweise umgesetzt, aber noch nicht DoD-grün: Legacy-Provider-Abo-Baukasten-Grundschema, Trial/Frozen-Statusdaten + Banner, read-only Subscription-Oberfläche, Auto-SHK-Provisionierung, Demo-Daten-Knopf, Erste-Schritte-Checkliste.
Offen/blockiert: `EXT-07` Legacy-Provider-Account/Produkte/Webhook, `AK-P19` Admin-Abo-Baukasten/Trial-Freischaltung, `D-P20` finale Screens/Subframe-Delta; außerdem selbst machbar offen sind vollständige Dunning-Sequenz Tag 1/3/7, Frozen-Read-Only-Middleware, Demo-ausblenden-Filter, Checklist-Auto-Erkennung und Dashboard-Widget.
**2026-07-01 · AK-P20 Slice 1 Nachzug: Legacy-Provider-Webhooks + Dunning/Frozen**
Umgesetzt:
- Legacy-Provider-Webhook-Einstieg `/webhooks/legacyProvider` mit Signaturprüfung über `MOLLIE_API_KEY` und idempotentem `Legacy-ProviderWebhookLog`.
- Legacy-Provider-Status-Sync für `customer.subscription.updated/deleted`, `invoice.payment_failed`, `invoice.paid` und `invoice.payment_succeeded`; Subscription-Periodenfelder werden aus Legacy-Provider übernommen.
- Dunning-Entscheidung Tag 1/3/7 plus Freeze nach ausgeschöpfter Mahnstufe; `pastDueSince` bleibt bei wiederholten Legacy-Provider-Fehlerereignissen stabil.
- Dunning-Email-Sequenz erzeugt Billing-Notifications über den bestehenden Notification-Delivery-Boundary (`category=billing`, `channel=EMAIL`).
- Billing-Read-Only-State zentral auslesbar und erste Tenant-Shell-Schreibaktionen blockieren im Frozen/Read-only-Status mit 423.
- Tests für Dunning-Entscheidung, Dunning-Cycle, Legacy-Provider-Webhook-Route, Legacy-Provider-Signaturkonstruktion, Status-Read-only-State und Tenant-Action-Block ergänzt.
Proof:
- `pnpm --filter @hivegrid/web exec vitest run app/utils/services/.server/billingStatusService.test.ts app/utils/services/.server/billingDunningService.test.ts app/routes/webhooks.legacyProvider.test.ts app/utils/legacyProvider.server.test.ts 'app/routes/app.$tenant.test.tsx'`
- `pnpm prisma validate`
- `pnpm typecheck`
- `pnpm lint`
- `pnpm run verify:i18n`
- `pnpm run verify:sidebar-routes`
- `pnpm run verify:billing-boundary-baseline`
Fortschritt: **AK-P20: 2/8 DoD = 25 % done**.
Abgehakt bleibt: Hell/Dunkel ohne System-Modus; Billing-Konnektor/RLS.
Teilweise umgesetzt, aber noch nicht DoD-grün: Legacy-Provider-Abo-Baukasten-Grundschema; Trial/Dunning/Frozen mit Statusdaten, Webhook-Sync und erster Read-only-Blockade; read-only Subscription-Oberfläche; Auto-SHK-Provisionierung; Demo-Daten-Knopf; Erste-Schritte-Checkliste.
Selbst machbar offen: Frozen-Read-Only noch route-übergreifend härtbar, Verbrauchs-Drossel, Demo-ausblenden-Filter, Checklist-Auto-Erkennung und Dashboard-Widget.
Blockiert/offen durch externes Delta: finale Subframe-/Screen-Integration aus `D-P20`. `EXT-07` ist laut Plane erledigt; `AK-P19` ist formal auf On-Hold, die für AK-P20 genutzten Grundlagen liegen aber bereits auf `main`.
**2026-07-01 · AK-P20 Slice 2 Nachzug: Usage-Drossel + First-Steps-Auto-Erkennung**
Umgesetzt:
- Verbrauchs-Drossel: `recordBillingUsage()` prueft Quotas vor dem Schreiben, wirft `BillingUsageQuotaExceededError` und behandelt `idempotencyKey` jetzt wirklich idempotent.
- Usage-Summary liefert pro Metrik `quota`, `remaining`, `exceeded` und `percentUsed`; `/settings/usage` zeigt Limit, Restmenge und ausgeschöpfte Limits an.
- Erste-Schritte-Checkliste erkennt Status automatisch aus echten Tenant-Daten: Profil, Kontakte, Projekte und aktives Trial/Abo. Erfüllte Schritte werden in `OnboardingSessionStep.completedAt` persistiert; vollständige Sessions werden auf `completed` gesetzt.
- Dashboard-Widget für die Erste-Schritte-Checkliste ergänzt, inklusive Fortschritt und Link zur Checklist-Route.
Proof:
- `pnpm --filter @hivegrid/web exec vitest run app/utils/services/.server/billingStatusService.test.ts app/utils/services/.server/billingDunningService.test.ts app/routes/webhooks.legacyProvider.test.ts app/utils/legacyProvider.server.test.ts 'app/routes/app.$tenant.test.tsx'`
- `pnpm typecheck`
- `pnpm lint`
- `pnpm run verify:i18n`
- `pnpm run verify:billing-boundary-baseline`
Hinweis Testabdeckung:
- Der isolierte direkte Lauf von `app/routes/app.$tenant/dashboard.test.tsx` bleibt ein bestehender Mixed-Environment-Testfall: Loader-Imports sind server-only, Render-Assertions brauchen jsdom. Die Dashboard-Änderung ist via Typecheck abgedeckt; die fokussierten grünen Tests decken die Billing-/Webhook-Regressionen ab.
Fortschritt: **AK-P20: 3/8 DoD = 37,5 % done**.
Abgehakt: Hell/Dunkel ohne System-Modus; Billing-Konnektor/RLS; Erste-Schritte-Checkliste.
Teilweise umgesetzt, aber noch nicht DoD-grün: Legacy-Provider-Abo-Baukasten-Grundschema; Trial/Dunning/Frozen mit Webhook-Sync, Dunning-Notifications, Usage-Drossel und erster Read-only-Blockade; read-only Subscription-Oberfläche; Auto-SHK-Provisionierung; Demo-Daten-Knopf.
Selbst machbar offen: Frozen-Read-Only noch route-übergreifend härtbar und Demo-ausblenden-Filter.
**2026-07-01 · AK-P20 Slice 3 Nachzug: Demo-Daten in Listen standardmäßig ausblenden**
Umgesetzt:
- Demo-Daten werden in Projektlisten, Projekt-Board, Aufgabenliste und Kontaktliste standardmäßig serverseitig ausgeblendet (`isDemoData=false`).
- Explizites Anzeigen läuft einheitlich über `demo=show`; Projekte/Aufgaben bieten einen Toggle-Link, Kontakte nutzen den bestehenden Filter-Submit.
- Kontaktlisten-Query serialisiert und parst den Demo-Filter stabil, inklusive Unit-Test.
- i18n-Keys für „Demo-Daten anzeigen/ausblenden" in Deutsch und Englisch ergänzt.
Proof:
- `pnpm --filter @hivegrid/web exec vitest run app/utils/services/.server/billingStatusService.test.ts app/utils/services/.server/billingDunningService.test.ts app/routes/webhooks.legacyProvider.test.ts app/utils/legacyProvider.server.test.ts 'app/routes/app.$tenant.test.tsx' app/utils/integrations/crm/kontaktListQuery.test.ts`
- `pnpm typecheck`
- `pnpm lint`
- `pnpm run verify:i18n`
- `pnpm run verify:billing-boundary-baseline`
- `pnpm run verify:onboarding-flow`
Fortschritt: **AK-P20: 4/8 DoD = 50 % done**.
Abgehakt: Hell/Dunkel ohne System-Modus; Billing-Konnektor/RLS; Erste-Schritte-Checkliste; Demo-Daten-Knopf plus Demo-ausblenden-Filter.
Teilweise umgesetzt, aber noch nicht DoD-grün: Legacy-Provider-Abo-Baukasten-Grundschema; Trial/Dunning/Frozen mit Webhook-Sync, Dunning-Notifications, Usage-Drossel und erster Read-only-Blockade; read-only Subscription-Oberfläche; Auto-SHK-Provisionierung.
Selbst machbar offen: Frozen-Read-Only noch route-übergreifend härtbar. Der zentrale Guard existiert aktuell nicht als Request-/Action-Middleware; ein flächiger Route-Patch hätte hohen Blast-Radius und sollte als eigener, gezielter Nachzug umgesetzt werden.
Blockiert/offen durch externes Delta: finale Subframe-/Screen-Integration aus `D-P20`.
**2026-07-02 · AK-P20 Slice 5 Nachzug: Frozen-ReadOnly zentral gehärtet**
Umgesetzt:
- Mutierende Tenant-Requests werden im zentralen Permission-Boundary `verifyUserHasPermission()` gegen den Billing-ReadOnly-State geprüft.
- `past_due`, `frozen` und `cancelled` blockieren permission-gated Tenant-Schreibaktionen mit HTTP 423 und dem Klartext-Key `billing:status.readOnly`.
- Billing-Recovery bleibt erlaubt: `/settings/subscription` ist als Ausnahme freigegeben, damit Customer-Portal/Zahlungs- und Kündigungsaktionen erreichbar bleiben.
- Bestehende Tenant-Shell-Actions (Feedback/Support) bleiben zusätzlich durch den bisherigen ReadOnly-Check geschützt.
Proof:
- `pnpm --filter @hivegrid/web exec vitest run 'app/routes/app.$tenant.test.tsx' app/utils/helpers/.server/PermissionsService.billing-readonly.test.ts`
- `pnpm typecheck`
- `pnpm lint`
- `pnpm run verify:i18n`
- `pnpm run verify:billing-boundary-baseline`
Fortschritt: **AK-P20: 6/8 DoD = 75 % done**.
Abgehakt: Hell/Dunkel ohne System-Modus; Billing-Konnektor/RLS; Erste-Schritte-Checkliste; Demo-Daten-Knopf plus Demo-ausblenden-Filter; Auto-SHK-Provisionierung; Trial/Dunning/Frozen/Usage-Drossel.
Teilweise umgesetzt, aber noch nicht DoD-grün: Legacy-Provider-Abo-Baukasten-Grundschema; read-only Subscription-Oberfläche ohne Pricing-/Upgrade-/Plan-Wechsel-UI.
Selbst machbar offen: AD-26-Cleanup von verbliebenen AI-Credits-/Credits-Entitlement-Resten prüfen und, falls scope-konform, bereinigen.
Blockiert/offen durch externes Delta: finale Subframe-/Screen-Integration aus `D-P20`.
**2026-07-02 · AK-P20 Slice 4 Nachzug: Auto-SHK-Provisionierung vervollständigt**
Umgesetzt:
- `provisionShkTenantDefaults()` orchestriert jetzt alle vorhandenen SHK-Seed-Bausteine: Rollen, Default-Sales-Pipeline, Custom-Field-Template-Seed, Abnahmeformular-Template, Projekt-Template, Erste-Schritte-Checkliste.
- Die bisher direkt in der Forms-Route liegenden SHK-Form-Templates wurden in `modules/forms/services/shkFormTemplates.server.ts` extrahiert und werden beim Signup automatisch mit lokalisierten Labels angelegt.
- Der bestehende Forms-Admin-Button nutzt denselben Seed-Service weiter; kein zweiter Seed-Pfad.
- Hinweis: Die Custom-Field-Template-Liste bleibt bewusst leer, weil der bestehende Code sie bis zum SHK-Domain-Workshop nicht mit erfundenen Feldern füllt. Der Provisioning-Hook ist verdrahtet.
Proof:
- `pnpm --filter @hivegrid/web exec vitest run app/modules/forms/services/shkFormTemplates.server.test.ts app/utils/services/shkRoles.server.test.ts app/utils/services/authService.transactional-email.test.ts`
- `pnpm typecheck`
- `pnpm lint`
- `pnpm run verify:i18n`
- `pnpm run verify:onboarding-flow`
Fortschritt: **AK-P20: 5/8 DoD = 62,5 % done**.
Abgehakt: Hell/Dunkel ohne System-Modus; Billing-Konnektor/RLS; Erste-Schritte-Checkliste; Demo-Daten-Knopf plus Demo-ausblenden-Filter; Auto-SHK-Provisionierung.
Teilweise umgesetzt, aber noch nicht DoD-grün: Legacy-Provider-Abo-Baukasten-Grundschema; Trial/Dunning/Frozen mit Webhook-Sync, Dunning-Notifications, Usage-Drossel und erster Read-only-Blockade; read-only Subscription-Oberfläche.
Selbst machbar offen: Frozen-Read-Only noch route-übergreifend härtbar; AD-26-Cleanup von verbliebenen AI-Credits-/Credits-Entitlement-Resten.
Blockiert/offen durch externes Delta: finale Subframe-/Screen-Integration aus `D-P20`.
**2026-07-02 · AK-P20 Slice 6 Nachzug: In-App-Planwechsel entfernt**
Umgesetzt:
- Die Legacy-Route `/subscribe/:tenant` rendert keine Pricing-/Planwechsel-UI mehr und startet keine Payment-Checkout-Session mehr; GET/POST fallen auf die read-only Subscription-Übersicht zurück.
- Interne Subscription-required-Links zeigen direkt auf `/settings/subscription?subscription_required=1` statt auf die alte Checkout-Seite.
- Header-/ProFeature-Einstiege verlinken nur noch die Subscription-Übersicht; Customer-Portal, Zahlungsmethoden und Kündigen bleiben in `/settings/subscription` erhalten.
- Billing-Boundary-Test ergänzt: Legacy-Subscribe muss frei von Checkout-Writes, `PlansGrouped` und direkten Billing-Imports bleiben.
Proof:
- `pnpm --filter @hivegrid/web exec vitest run 'app/routes/subscribe.$tenant.test.tsx' app/utils/app/SubscriptionUrlUtils.test.ts`
- `pnpm typecheck`
- `pnpm lint`
- `pnpm run verify:i18n`
- `pnpm exec vitest run packages/db/src/subscriptions.server.test.ts packages/db/src/subscriptions-boundary.test.ts apps/web/app/utils/services/.server/subscriptionService.test.ts`
- `pnpm run verify:billing-boundary-baseline`
Fortschritt: **AK-P20: 7/8 DoD = 87,5 % done**.
Abgehakt: Hell/Dunkel ohne System-Modus; Billing-Konnektor/RLS; Erste-Schritte-Checkliste; Demo-Daten-Knopf plus Demo-ausblenden-Filter; Auto-SHK-Provisionierung; Trial/Dunning/Frozen/Usage-Drossel; read-only Subscription-Oberfläche ohne Pricing-/Upgrade-/Plan-Wechsel-UI.
Offen nach Recut: Der alte Legacy-Provider-Abo-Baukasten-Abschluss wird nicht weitergebaut. Der saubere Abschluss laeuft ueber
`BILL-02` bis `BILL-05`: lokale Plan-/Seat-/Entitlement-Domain, Lago-Gateway, Mollie-Gateway und Billing-Sync.
Blockiert/offen durch externes Delta: finale Subframe-/Screen-Integration aus `D-P20`.
**2026-07-02 · AK-P20 Slice 7 Nachzug: Seat-Typen im Admin-Pricing blockiert**
Umgesetzt:
- Die Standard-Plan-Seeds erzeugen keine `PER_SEAT`-Pläne mehr.
- Die Plattform-Admin-Pricing-Auswahl nutzt eine zentrale HiveGrid-Pricing-Policy ohne `PER_SEAT`.
- `pricing.new`, `pricing.edit` und `create-all-plans` lehnen manipulierte `PER_SEAT`-Formulare serverseitig mit `shared.invalidForm` ab.
- Test ergänzt: Policy und File-Seeds bleiben frei von Seat-Type-Plänen.
Proof:
- `pnpm --filter @hivegrid/web exec vitest run app/application/pricing/pricingModelPolicy.test.ts`
- `pnpm typecheck`
- `pnpm lint`
- `pnpm run verify:i18n`
- `pnpm run verify:billing-boundary-baseline`
Fortschritt: **AK-P20: 7/8 DoD = 87,5 % done**.
Weiterhin offen nach Recut: Der alte Legacy-Provider-Abo-Baukasten-DoD ist durch `AD-27` ersetzt. Keine weitere
Legacy-Provider-Admin-Positionen-Arbeit in AK-P20; neue Billing-Arbeit ueber `BILL-02` bis `BILL-05`.
@@ -1,47 +1,51 @@
# AK-P21 · P21 DSGVO (Export + Lösch-Workflow) + Feedback-Widget
> ⚠️ **Billing-Recut 2026-07-04 ([[AD-27]]):** Tenant-Löschung storniert kuenftig den Billing-State ueber die
> Lago/Mollie/HiveGrid-Billing-Gateways aus `BILL-*`, nicht ueber Legacy-Provider. Historische Legacy-Provider-Cancel-Erwaehnungen sind
> als alter Adaptervertrag zu lesen und duerfen nicht als neue Legacy-Provider-Dependency weitergebaut werden.
| | |
|---|---|
| **Typ** | Dev (Code) |
| **Owner** | Akeshan |
| **Phase** | P21 — DSGVO & Launch-Reife |
| **Cycle/Fenster** | W4 — USP, Härtung & Launch · fällig 2026-07-09 · **Gate W4 (FREEZE)** |
| **Status** | geplant |
| **Status** | Blocked / On Hold, Teilumsetzung Backend/Fundament |
## Abhängigkeiten & Parallelität
- **Blocked by:** `AK-P20` (Billing — Tenant-Löschung ruft Stripe-Cancel; Feedback-Widget speist Admin-Support-Inbox aus AK-P19)
- **Blocked by:** `AK-P20`/`BILL-05` (Billing-Read-only/Frozen-State und Billing-Cancel-Vertrag; Feedback-Widget speist Admin-Support-Inbox aus AK-P19), `D-P21` (finale Privacy-/FAQ-/Feedback-Screens)
- **Blocks:** **FREEZE** (letzte v1-Phase, macht den Big-Bang-Launch AD-19 auslösbar)
- **Läuft parallel zu:** Raffaels Härtung/Launch-Tickets. Eigene `prisma/schema/gdpr.prisma` (AD-21); Feedback-Widget schreibt in `AdminSupportTicket` (AK-P19, generische Quelle); Export liest tenant-weit über RLS.
- **Konflikt-Risiko:** gering — additive Tabellen + asynchrone BullMQ-Jobs; Crypto-Shredding berührt per-Tenant-DEK (AD-8), keine Schema-Änderung an Fach-Modulen.
## Was wir bauen
Den **DSGVO-Lebenszyklus + Launch-Reife** (letzte v1-Phase): tenant- und user-weiter **DSGVO-ZIP-Export** (asynchroner BullMQ-Job mit Progress, **72h-signed-URL**, Rate-Limit), den **Tenant-Lösch-Workflow** (**Crypto-Shredding** per-Tenant-DEK, **Stripe-Cancel**, Storage/Cache/Index-Bereinigung, 30-Tage-Grace mit Read-Only + Abbruch, **Slug als Confirm-Phrase**), die **AVV-/Subprozessoren-Sichtbarkeit** im Privacy-Hub, ein **In-App-Feedback-Widget** (speist die Admin-Support-Inbox aus AK-P19) und eine **Minimal-FAQ**-Seite. **DSGVO-Auskunft = manuelles Playbook v1** (Suche → Kontakt-Detail → CSV → Anwalts-Vorlage → Frist-Task +25 Tage), **kein** Auskunfts-Wizard. PWA-only (AD-17): alle Screens mobil tauglich, kein nativer Client.
Den **DSGVO-Lebenszyklus + Launch-Reife** (letzte v1-Phase): tenant- und user-weiter **DSGVO-ZIP-Export** (asynchroner BullMQ-Job mit Progress, **72h-signed-URL**, Rate-Limit), den **Tenant-Lösch-Workflow** (**Crypto-Shredding** per-Tenant-DEK, **Billing-Cancel ueber Lago/Mollie/HiveGrid-Gateway**, Storage/Cache/Index-Bereinigung, 30-Tage-Grace mit Read-Only + Abbruch, **Slug als Confirm-Phrase**), die **AVV-/Subprozessoren-Sichtbarkeit** im Privacy-Hub, ein **In-App-Feedback-Widget** (speist die Admin-Support-Inbox aus AK-P19) und eine **Minimal-FAQ**-Seite. **DSGVO-Auskunft = manuelles Playbook v1** (Suche → Kontakt-Detail → CSV → Anwalts-Vorlage → Frist-Task +25 Tage), **kein** Auskunfts-Wizard. PWA-only (AD-17): alle Screens mobil tauglich, kein nativer Client.
## Benötigte Screens (Backend bedient — Design liefert `D-P21`)
| Screen [Route] | Kern-Inhalt |
|---|---|
| Privacy-Hub [`/admin/privacy`] | Übersicht Export/Löschung/Subprozessoren/AVV/Privacy-Manifest-Link, Einstieg in Sub-Bereiche |
| Tenant-Daten-Export [`/admin/privacy/export`, `/new`, `/:jobId`] | Job-Liste mit Status/Progress/Download (72h-Link); Anstoßen mit Klartext (was, Größe/Dauer, 7-Tage-Window) |
| Tenant-Löschung [`/admin/privacy/deletion`, `/:requestId`] | Multi-Step-Wizard: Erklärung (30-Tage-Grace, Read-Only, Hard-Delete) → Export-Angebot → Stripe-Hinweis (Pro-rata) → Passwort+2FA → **Slug als Confirm-Phrase**; Grace-Countdown + Abbruch |
| Tenant-Löschung [`/admin/privacy/deletion`, `/:requestId`] | Multi-Step-Wizard: Erklärung (30-Tage-Grace, Read-Only, Hard-Delete) → Export-Angebot → Billing-/Zahlungs-Hinweis → Passwort+2FA → **Slug als Confirm-Phrase**; Grace-Countdown + Abbruch |
| Subprozessoren [`/admin/privacy/subprocessors`] · AVV [`/admin/privacy/avv`] | Subprozessor-Tabelle (Land/EU-Marker/DPA-Link/Export); AVV-PDF-Download + Akzeptanz-History + Re-Akzeptanz-Modal |
| User-Daten-Export [`/settings/account/data-export`, `/:jobId`] · Account-Löschung [`/settings/account/delete`] | User-Self-Export (signed-URL, Rate-Limit); Account-Lösch-Wizard (Passwort+2FA, 24h-Reverse-Window, M27 D.3) |
| Feedback-Widget [global, klein] | Schwebender Button → Mini-Form (Bug/Feature/Frage, Text, Screenshot-Häkchen, Rate-Limit) → **Admin-Support-Inbox (AK-P19)** |
| Minimal-FAQ [`/help`] | Statische FAQ (gruppierte Fragen/Antworten, Markdown), Suchfeld optional (kein Help-Center-Ausbau → v2) |
## Definition of Done
- [ ] **DSGVO-ZIP-Export** (User-Self **und** Tenant-Full): asynchroner BullMQ-Job mit Progress, **72h-signed-URL**, Rate-Limit (max. 3 User-Self/24h, max. 1 Tenant-Full aktiv); README + manifest.json im ZIP
- [ ] **Tenant-Löschung**: **Crypto-Shredding** (per-Tenant-DEK) + **Stripe-Cancel** + Storage/Cache/Index-Bereinigung (AD-8); 30-Tage-Grace Read-Only + Abbruch; **Slug als Confirm-Phrase**; Stripe-Cancel erst beim Hard-Delete-Cron
- [ ] **AVV-Download** + Akzeptanz-History + Re-Akzeptanz (14-Tage-Frist, danach Read-Only-Sperre); **Subprozessoren-Liste** mit Drittland-Klartext (v1 EU-only)
- [ ] **DSGVO-Auskunft als dokumentiertes Playbook** (Suche → Kontakt-Detail → CSV → Anwalts-Vorlage → Frist-Task +25 Tage) — kein automatisches Tooling
- [ ] **In-App-Feedback-Widget** → speist Admin-Support-Inbox (AK-P19, generische Quelle)
- [ ] **Minimal-FAQ**-Seite (statisch)
- [ ] Alle Screens **mobil tauglich** (PWA/responsive, AD-17); kein nativer Client; PWA-Installierbarkeit/Web-Push → v2
- [ ] Permissions: `privacy.user_export.self` (implizit jeder User), `privacy.tenant_export`/`privacy.tenant_deletion`/`privacy.avv.*`/`privacy.subprocessors.read` (über `tenant.admin`)
- [ ] **DSGVO-ZIP-Export** (User-Self **und** Tenant-Full): asynchroner BullMQ-Job mit Progress, **72h-signed-URL**, Rate-Limit (max. 3 User-Self/24h, max. 1 Tenant-Full aktiv); README + manifest.json im ZIP — *teilweise 2026-07-02: `GdprExportJob` + Service-Regeln fuer Rate-Limit, Status/Progress und 72h-Expiry gebaut; ZIP-Builder + Worker-Runner mit README/manifest und Handler-Branch fuer `gdpr_user_self`/`gdpr_tenant_full`; Self-Service-Export legt Job an und enqueue't; Worker-Bootstrap + konservative Daten-Bundle-Loader fuer User/Tenant/Audit/Notifications gebaut. Offen: signierte Download-Route/E-Mail und breiteres Fachmodul-Inventar.*
- [ ] **Tenant-Löschung**: **Crypto-Shredding** (per-Tenant-DEK) + **Billing-Cancel ueber Lago/Mollie/HiveGrid-Gateway** + Storage/Cache/Index-Bereinigung (AD-8); 30-Tage-Grace Read-Only + Abbruch; **Slug als Confirm-Phrase**; Billing-Cancel erst beim Hard-Delete-Cron — *teilweise 2026-07-02: `TenantDeletionRequest` + Service-Regeln fuer Slug-Confirm, 30-Tage-Grace, Abbruch und Cron-Liste gebaut; getesteter Hard-Delete-Orchestrierungsvertrag fuer Billing-Cancel, Storage, Search, Crypto-Shredding und Tenant-Row-Delete. Offen: produktive Bootstrap-Anbindung/Cron, AK-P20-/BILL-05-Read-Only-Hook, echte Adapter-Implementierungen.*
- [ ] **AVV-Download** + Akzeptanz-History + Re-Akzeptanz (14-Tage-Frist, danach Read-Only-Sperre); **Subprozessoren-Liste** mit Drittland-Klartext (v1 EU-only) — *teilweise 2026-07-02: `AvvAcceptance`/`Subprocessor` + Service-Lese-/Akzeptanzpfad gebaut; PDF-Download/Re-Akzeptanz-Read-Only/finale Screens offen.*
- [x] **DSGVO-Auskunft als dokumentiertes Playbook** (Suche → Kontakt-Detail → CSV → Anwalts-Vorlage → Frist-Task +25 Tage) — kein automatisches Tooling
- [x] **In-App-Feedback-Widget** → speist Admin-Support-Inbox (AK-P19, generische Quelle)
- [x] **Minimal-FAQ**-Seite (statisch)
- [ ] Alle Screens **mobil tauglich** (PWA/responsive, AD-17); kein nativer Client; PWA-Installierbarkeit/Web-Push → v2 — *blockiert: finale Screen-Integration wartet auf `D-P21`; statische FAQ ist responsive.*
- [x] Permissions: `privacy.user_export.self` (implizit jeder User), `privacy.tenant_export`/`privacy.tenant_deletion`/`privacy.avv.*`/`privacy.subprocessors.read` (über `tenant.admin`)
## Technische Bausteine
- **Entities** (eigene `prisma/schema/gdpr.prisma`): `GdprExportJob` (scope user_self/tenant_full, status, result_file_id ZIP, result_expires_at), `TenantDeletionRequest` (reason, grace_period_ends_at +30d, status — Slug-Confirm), `AvvAcceptance` (avv_version, accepted_by, pdf_file_id, signer_name), `Subprocessor` (name, country, is_eu_eea, data_categories, dpa_url — global). Feedback schreibt in `AdminSupportTicket` (AK-P19). `core.prisma` über Tenant-Relation (AD-21).
- **Export-Worker** (BullMQ): tenant-weiter/user-weiter Sammel-Export → ZIP (S3-kompatibel, Hetzner) mit 72h-signed-URL + README/manifest.json; Rate-Limit.
- **Crypto-Shredding** (AD-8): per-Tenant-DEK löschen → Daten kryptographisch unlesbar; Stripe-Cancel + Storage/Cache/Index-Cleanup beim Hard-Delete-Cron; 30-Tage-Grace Read-Only-Middleware (Reuse aus AK-P20 Frozen-Modus).
- **Crypto-Shredding** (AD-8): per-Tenant-DEK löschen → Daten kryptographisch unlesbar; Billing-Cancel ueber Lago/Mollie/HiveGrid-Gateway + Storage/Cache/Index-Cleanup beim Hard-Delete-Cron; 30-Tage-Grace Read-Only-Middleware (Reuse aus AK-P20/BILL-05 Frozen-Modus).
- **Feedback-Widget**: Rate-Limit + Screenshot via Files → `AdminSupportTicket` (source_kind in_app_feedback_widget).
- **RLS** auf allen Tenant-Tabellen (Subprocessor global); `is_demo_data` n/a (kein Seed-Bedarf).
@@ -56,3 +60,53 @@ Den **DSGVO-Lebenszyklus + Launch-Reife** (letzte v1-Phase): tenant- und user-we
- [ ] Spec/ENTITY-MODEL an Davids UI-Entscheidungen angepasst
- [ ] REQUIREMENTS/PHASE-SOURCES nachgezogen
- [ ] Plane-Ticket `AK-P21` aktualisiert
## Umsetzungsnotiz
### 2026-07-02 — Teilumsetzung Backend/Fundament
Proof:
- Prisma/Isolation: `prisma/schema/gdpr.prisma`, `prisma/rls/gdpr-isolation.sql`, Tenant-Snapshot-Registry und generierter Prisma-Client.
- Services: `@hivegrid/db/privacy-gdpr.server` fuer Export-Limits, 72h-Expiry, Tenant-Deletion-Grace/Cancel, AVV-Akzeptanz und Subprozessoren; `createWidgetSupportTicket` fuer Widget → Support-Inbox.
- UI/Doku: statische `/help`-FAQ, `docs/compliance/dsgvo-auskunft-playbook.md`, GDPR-Doku-Verweis.
- Permissions: `privacy.user_export.self`, `privacy.tenant_export`, `privacy.tenant_deletion`, `privacy.avv.read`, `privacy.avv.accept`, `privacy.subprocessors.read` in Seed, DefaultPermissions, Rollenanzeige und i18n.
Verifikation:
- `pnpm typecheck` gruen
- `pnpm lint` gruen
- `pnpm exec prisma validate` gruen
- `pnpm run verify:tenant-isolation-report` gruen
- `pnpm run verify:security-gdpr-docs` gruen
- Fokus-Tests: 55/55 gruen (`privacy-gdpr`, `support-tickets`, `Feedback.server`, `roleDisplay`, `permissions-seed`, Tenant-Models, routes/i18n/disabled-plugin-links)
- Permission-Matrix-Einzeltests: 22/22 gruen; Wrapper `pnpm run verify:permission-matrix` scheitert auf Windows mit `spawn pnpm ENOENT`.
- Breites `pnpm test` timed auf Windows nach 302s ohne sichtbare Assertion-Failures aus; Ausgabe nur bekannte Stderr-Faelle (Session-Cookie-Warnung, Admin-SSO db-down Mock, Notification Redis Mock, i18next-Warnung).
DoD-Stand: **4/8 = 50 % done**. Offen: ZIP-Worker/README/manifest/Download, Hard-Delete-Cron inkl. Crypto-Shredding/Billing-Cancel/Storage/Cache/Search, AVV-PDF/Re-Akzeptanz-Read-Only/Subprozessor-Screen, finale mobile Screens aus `D-P21`.
### 2026-07-02 — Nachzug selbst machbarer Worker-/Queue-Aspekte
Proof:
- Export: `packages/exports/src/zip.ts` erzeugt ZIPs mit README/manifest/data-Dateien; `apps/worker/src/jobs/privacy-gdpr-export.ts` baut GDPR-Export-Artefakte; `apps/worker/src/handlers.ts` routet `gdpr_user_self`/`gdpr_tenant_full` in den GDPR-Statuspfad.
- Web-Queue: `apps/web/app/utils/privacy/gdprExportQueue.server.ts`; `/app/:tenant/settings/privacy` legt bei Self-Service-Export jetzt `GdprExportJob` an und enqueue't den Export.
- Tenant-Löschung: `apps/worker/src/jobs/privacy-tenant-deletion.ts` definiert und testet die Hard-Delete-Reihenfolge Billing-Cancel → Storage-Prefix → Search-Dokumente → Crypto-Shredding → Tenant-Rows.
- Nachzug: `apps/worker/src/jobs/privacy-gdpr-export-bootstrap.ts` bootstrapped den GDPR-Export-Service produktiv mit S3-`PutObjectCommand` und konservativen User-Self/Tenant-Full-Bundle-Loadern (User/Tenant/TenantUser/AuditEvent/NotificationPreference/NotificationDelivery).
Verifikation:
- `pnpm typecheck` gruen
- `pnpm --filter @hivegrid/exports typecheck` gruen
- `pnpm --filter @hivegrid/worker typecheck` gruen
- `pnpm lint` gruen
- `pnpm exec prisma validate` gruen
- `pnpm run verify:tenant-isolation-report` gruen
- `pnpm run verify:security-gdpr-docs` gruen
- Fokus-Tests: 141/141 gruen (AK-P21 DB/Web/Worker/Queue/ZIP/Feedback/Permissions/i18n)
- Nachzug-Fokus: 97/97 gruen (`privacy-gdpr`, GDPR-Export-Bootstrap/-Runner, Tenant-Deletion-Runner, Handler, Web-Queue)
- Permission-Matrix-Einzeltests: 22/22 gruen; Wrapper bleibt auf Windows mit `spawn pnpm ENOENT` ein bekanntes lokales Skriptproblem.
- `git diff --check -- . ':(exclude)packages/db/generated/prisma/client/**'` gruen.
DoD-Stand: **4/8 = 50 % done**. Selbst machbarer Backend-/Worker-Nachzug ist erledigt; offen bleiben signed Download/E-Mail, breiteres Fachmodul-Inventar fuer Tenant-Full, produktive Tenant-Deletion-Bootstrap-Anbindung/Read-Only-Hook, AVV-PDF/Re-Akzeptanz-Read-Only/Subprozessor-Screen und finale mobile Screens aus `D-P21`.
Repo/Plane:
- Branch `ak-p21-gdpr-launch-foundation` gepusht.
- Implementierungs-Commit `4a02238a6` (`feat: add AK-P21 GDPR launch foundation`).
- Plane-Kommentar `4a56b8c9-6254-4987-bd3d-2b78945b4786` am 2026-07-02 gesetzt; Ticket bleibt bewusst `Blocked / On Hold`, nicht `Done`.
@@ -6,7 +6,9 @@
| **Owner** | Akeshan |
| **Phase** | P3 — Querschnitts-Oberflächen |
| **Cycle/Fenster** | W2 — Plattform & CRM (Demo 1) · fällig 2026-06-21 · Gate W2 (Noah-Demo #1) |
| **Status** | geplant |
| **Status** | umgesetzt (2026-07-07) — Dev-DoD 7/7 = 100 %, wartet auf Owner-Freigabe für Done |
**🎨 design-nachzug: offen** (seit 2026-07-07) — Interim-UI aus `~/ui` steht; finale Subframe-Optik aus `D-P3` ausstehend.
## Abhängigkeiten & Parallelität
- **Blocked by:** `R-P1b` (Service-Kerne: Audit-Hash-Kette, `notify()`, i18n-Engine), `R-P2b` (RLS + Permissions für `audit.read`)
@@ -18,23 +20,30 @@
Die **Bedien-Oberflächen** der in P1 gebauten Plattformdienste: **Audit-Viewer** (gefilterter, permission-gegateter Trail mit deutschem Summary-Text, Actor-Kind-Marker, Diff-Detail-Drawer, NDJSON/CSV-Export ins Hetzner-Object-Storage mit Zweck-Pflichtfeld), **Notification-Center** (In-App via SSE, Read-Status, Deeplink, Tabs Alle/Ungelesen, Bulk-Clear) + **Notification-Settings** (Event-Typen je Modul, In-App/Email-Toggle, Master „alle Emails aus"), sowie die **Sprach-Settings** (v1 DE-only-Hinweis; alle Strings durch `t()`, `verify:i18n` grün).
## Benötigte Screens (Backend bedient — Design liefert `D-P3` aus `D-D0a`/`D-D0b`)
> **Routen-Notation (Stand 2026-07-07, host-aware):** Die Route-Convention mountet `routes/admin/*` zusätzlich
> als **Admin-Host-Alias ohne `/admin`-Präfix** und `app/:tenant/*` auf dem App-Host **ohne `/app`-Präfix**.
> Kanonisch erreichbar sind die Flächen also unter `admin.<domain>/audit` bzw. `<app>/<tenant>/notifications`;
> die pfadbasierten Formen (`/admin/audit`, `/app/:tenant/…`) existieren weiterhin. Angegeben ist unten die
> kanonische URL + das Route-Modul.
| Screen [Route] | Kern-Inhalt | Liefert (Design) |
|---|---|---|
| Audit-Viewer [`/admin/audit`] | Zeit/Actor (+Kind-Marker)/Aktion/Subjekt/DE-Summary · Filter (Zeitraum/Actor/Kategorie/Subjekt-Typ) · Diff-Detail-Drawer · Export-Button | `D-P3` (A2+Drawer) |
| Audit-Export-Jobs [`/admin/audit/exports` +`/new` +`/:id`] | Job-Liste · Anlage (Filter, Format, Zweck) · Status + signed-URL-Download | `D-P3` (A2) |
| Notification-Center [Topbar-Drawer + `/notifications`] | Titel/Body/Actor/Zeit/Read-Status, Tabs Alle/Ungelesen, Bulk-Clear, Bounce-Klartext | `D-P3` (A2) |
| Notification-Settings [`/settings/notifications`] | Event-Typen je Modul, In-App/Email-Toggle + Priority, Master „alle Emails aus" | `D-P3` (A6) |
| Audit-Viewer [Admin-Host `/audit` · Modul `routes/admin/audit.tsx`] | Zeit/Actor (+Kind-Marker)/Aktion/Subjekt/DE-Summary · Filter (Zeitraum/Actor/Kategorie/Subjekt-Typ) · Diff-Detail-Drawer · Export-Button | `D-P3` (A2+Drawer) |
| Audit-Export [Admin-Host `/audit-export` · Modul `routes/admin/audit-export.tsx`] — *v1 synchron statt Job-Liste (`/admin/audit/exports*` = v2, s. Vorab-Entscheidungen)* | Anlage (Filter, Format, Zweck-Pflichtfeld) · signed-URL-Download · CSV/PDF-Fallback | `D-P3` (A2) |
| Notification-Center [Topbar-Drawer + App-Host `/:tenant/notifications` · Modul `routes/app.$tenant/notifications.tsx`] | Titel/Body/Actor/Zeit/Read-Status, Tabs Alle/Ungelesen, Bulk-Clear, Bounce-Klartext | `D-P3` (A2) |
| Notification-Settings [App-Host `/:tenant/settings/notifications` · Modul `routes/app.$tenant/settings/notifications.tsx`] | Event-Typen je Modul, In-App/Email-Toggle + Priority, Master „alle Emails aus" | `D-P3` (A6) |
*Eingebettet (kein eigener Screen): Per-Subject-Audit-Drawer in jeder Detail-View, Per-User-Audit-Drawer in `/admin/users/:id`, DE-Sprach-Hinweis im Profil (P2). Davids P3-Screens werden über `D-P3` (Compose aus D0) geliefert.*
## Definition of Done
- [ ] **Audit-Viewer** permission-gegated (`audit.read`), server-seitige Filter, DE-Summary-Text, Actor-Kind-Marker
- [ ] **Diff-Detail-Drawer** (Feld-für-Feld Vorher/Nachher; Maskierung bei sensiblen Werten)
- [ ] **NDJSON/CSV-Export** ins Hetzner-Object-Storage + Zweck-Pflichtfeld + Export-Job-Status/Download; jeder Export selbst auditiert (`audit.exported`); fixe Default-Retention + Cron-Auto-Löschung
- [ ] **Notification-Center** (SSE, Read-Status, Deeplink, Tabs, Bulk-Clear, Bounce-Klartext)
- [ ] **Notification-Settings** (Event-Typen je Modul, In-App/Email-Toggle, Master-Toggle)
- [ ] **Sprach-Settings DE** — alle Strings durch `t()`, vollständige `de`-Locale, `verify:i18n` grün; `Contact.language`-Feld vorhanden (Outbound ab v2)
- [ ] *(→ v2)* Quiet-Hours/Bündelung/Push-Kanal/Retention-Editor/Legal-Hold/Saved-Views/Sprach-Wechsler ausgeschlossen
- [x] **Audit-Viewer** permission-gegated (`audit.read`), server-seitige Filter, DE-Summary-Text, Actor-Kind-Marker — *AP-1* (+ Interim-Screen *AP-6*)
- [x] **Diff-Detail-Drawer** (Feld-für-Feld Vorher/Nachher; Maskierung bei sensiblen Werten) — *AP-6*: `beforeDiff`/`afterDiff`/`summaryText` additiv an `AuditEvent` (Owner-Freigabe 2026-07-06), echter `~/ui`-Drawer mit Feld-für-Feld-Diff, maskierte Felder als „Wert geändert"; abgeleitete Summary bleibt Fallback für Alt-Zeilen. *Abgrenzung:* flächige Diff-Befüllung (Auto-Audit-Middleware) + Hash-Chain-Bindung der Diffs bleiben R-P1b/Raffael (Vermerk in `R-P1b.md`).
- [x] **NDJSON/CSV-Export** ins Hetzner-Object-Storage + Zweck-Pflichtfeld + Export-Job-Status/Download; jeder Export selbst auditiert (`audit.exported`); fixe Default-Retention + Cron-Auto-Löschung — *AP-2* (+ Interim-Screen *AP-6*)
- [x] **Notification-Center** (SSE, Read-Status, Deeplink, Tabs, Bulk-Clear, Bounce-Klartext) — *AP-3* + *AP-6*: Topbar-Drawer (echter `~/ui`-Drawer), Live-Unseen-Count via SSE, Deeplink-Kontrakt `payloadMetadata.deeplink` (validiert, app-relativ) als Interim-UI (AD-28) — nicht mehr David-blockiert.
- [x] **Notification-Settings** (Event-Typen je Modul, In-App/Email-Toggle, Master-Toggle) — *AP-4* (+ Interim-Screen *AP-6*)
- [x] **Sprach-Settings DE** — alle Strings durch `t()`, vollständige `de`-Locale, `verify:i18n` grün (*AP-5*); `Kontakt.language`-Feld ergänzt (*AP-6*, entsperrt durch CRM-P6-Merge; nullable, kein v1-Editor, Vermerk in `R-P6.md`)
- [x] *(→ v2)* Quiet-Hours/Bündelung/Push-Kanal/Retention-Editor/Legal-Hold/Saved-Views/Sprach-Wechsler ausgeschlossen
## Technische Bausteine
- **Keine neuen Kern-Entities** — liest/bedient die in `R-P1b` angelegten `AuditEntry`/`Notification`/`NotificationPreference`. Neu nur `AuditExportJob` ist **v2** → in v1 fixe Default-Retention statt Job-Editor (Boilerplate liefert Retention-Cron + PDF-Export). Eigene `prisma/schema/*.prisma` falls Felder ergänzt (AD-21).
@@ -51,6 +60,138 @@ Die **Bedien-Oberflächen** der in P1 gebauten Plattformdienste: **Audit-Viewer*
- `_bmad-output/specs/M21-audit-log.md`, `M22-notifications.md`, `M30-i18n.md` (Oberflächen-Teile)
## Dev-Nachzug (nach Davids `D-P3`-Delta)
- [ ] Audit-/Notif-Oberflächen an Davids UI angepasst
**Interim-UI (Vorschau, Stand 2026-07-07):** Alle vier Flächen aus `~/ui` gebaut (AD-28) —
`apps/web/app/components/screens/audit/{AuditListPage,AuditExportPage}.tsx`,
`apps/web/app/components/screens/notifications/{NotificationCenterPage,NotificationSettingsPage,NotificationsDrawer}.tsx`.
Proof: Smoke `tests/e2e/{audit,notifications}.spec.ts`, Hot-Swap-Adapter `auditList`/`notificationCenter`
(`packages/ui-app/src/adapters/*.hot-swap.test.ts`) grün; Visual-Baseline deferred wie R-P2a/AK-P4b
(keine Snapshot-Harness — Interim-Baseline wird beim David-Nachzug gezogen).
Refactor-Nachzug offen: finale Subframe-Optik aus `D-P3` (Plane-Label `design-nachzug`).
- [x] Interim-UI aus `~/ui` gebaut (AD-28, 2026-07-07) — VM als Prop, kein handgeschriebenes Tailwind für Spacing/Höhen
- [ ] Audit-/Notif-Oberflächen an Davids finaler `D-P3`-Optik angepasst (Subframe-Swap hinter der stabilen VM/Loader-Grenze; `design-nachzug: offen`). Nicht Done-blockierend (AD-28). Beim Swap: Interim-Visual-Baseline neu ziehen.
- [ ] Admin-Topbar-Drawer: der SSE-Stream ist tenant-scoped (`app.$tenant/notifications/stream`), die Admin-Topbar behält den Link — Drawer für Admin erst mit einem admin-scoped Stream (Nachzug, nicht DoD-relevant).
- [ ] REQUIREMENTS/PHASE-SOURCES nachgezogen
- [ ] Plane-Ticket `AK-P3` aktualisiert
- [x] Plane-Ticket `AK-P3` aktualisiert (2026-07-07: Abschluss-Kommentar + Label `design-nachzug` gesetzt; Branch gepusht. Status-Wechsel macht der Owner.)
## Vorab-Entscheidungen (Owner-bestätigt 2026-06-24, `/planning` = SSoT)
- **Audit-Export v1 = synchron** (Zweck-Pflichtfeld + Object-Storage + signed-URL + `audit.exported` + Retention-Cron). **`AuditExportJob`-Entity + `/admin/audit/exports`-Job-Liste = v2** (Technische-Bausteine schlagen die Screen-Tabelle).
- **Permission-Key:** bestehende `admin.auditTrails.view` (Admin) / `app.settings.auditTrails.view` (Tenant) wiederverwenden. DoD-Wortlaut `audit.read` ist damit der **Alias** auf `admin.auditTrails.view` (kein Key-Duplikat).
## Umsetzungsstand (Claude/Akeshan) — Branch `feat/AK-P3`
### AP-1 · Audit-Viewer `/admin/audit` — 2026-06-24
**Gebaut (Commit `2bee1ecaa`):**
- `packages/db/src/audit-events-query.server.ts` — `listAuditEvents` (paginiert, server-seitige Filter, neueste zuerst, nutzt vorhandene Indizes) + Subpath-Export.
- `packages/view-models/src/audit/audit-list.vm.ts` — reiner Builder mit **Actor-Kind-Marker** (`deriveAuditActorKind`) + **abgeleiteter DE-Summary** (`deriveAuditSummaryKey` → i18n-Key + Params). VM-Unit-Test 5/5.
- `apps/web/app/routes/admin/audit.tsx` — Route auf **`AuditEvent`** (nicht Legacy `Log`), gegated `admin.auditTrails.view`; Filter Zeitraum/Actor/Aktion/Subjekt-Typ/Ergebnis; Detail-Disclosure rendert die (write-time sanitisierte) `metadata` mit Maskierung; Pagination.
- Reachability: AdminSidebar-Eintrag + UI-Registry-ID `adminAudit`, Permission-gegated; i18n-Keys `audit.*` in `settings.json` (de+en) + `app.sidebar.audit` (translations.json de+en).
**DoD-Stand nach AP-1:**
- DoD 1 (Viewer permission-gegated, server-Filter, DE-Summary, Actor-Kind-Marker): **done**.
- DoD 2 (Diff-Detail-Drawer): **teilweise** — Metadata-Disclosure + Maskierung erledigt; **strukturiertes Feld-für-Feld Vorher/Nachher blockiert** (`AuditEvent` ohne `before/after_diff`-Spalten → Coordination-Item mit R-P1b/Raffael, AD-21 core.prisma; siehe Plan §5.2). Persistente `summary_text` ebenso (LOCKED ENTITY-MODEL vs. reale Tabelle).
- DoD 3-6: offen (AP-2..AP-5). DoD 7 (v2-Ausschlüsse): erfüllt (nicht gebaut).
- **%-Done: ~1,5 / 7 DoD ≈ 21 %** · selbst machbar offen: AP-2..AP-5 + Querschnitt/Tests · blockiert: strukturierter Diff/persist. Summary (R-P1b), Davids `D-P3`-Screens, `Kontakt.language` (P6+).
**Proof:** view-models + db + web Typecheck grün · `verify:i18n` Exit 0 · VM-Test 5/5 · pre-commit-Hooks (eslint-baseline/lint-staged/secrets) grün · Node 22.15 (Typecheck/i18n unkritisch).
### AP-2 · Audit-Export ins Object-Storage — 2026-06-24
**Gebaut (Commit `8b65a9135`):**
- `apps/web/app/utils/audit/auditExportStorage.server.ts` — `runAuditExportToStorage`: baut CSV/NDJSON, lädt via `putBufferAsFile` nach `audit-exports/<scope>/...`, gibt kurzlebige **signed-URL** zurück, schreibt **`audit.exported`** mit **Zweck (Pflicht)**. Typisierte Fehler (kein Zweck / kein Storage).
- `audit-export.helpers.server.ts` — `renderAuditExportNdjson` (+ Test 2/2).
- `apps/web/app/routes/admin/audit-export.tsx` — Zweck-Pflichtfeld + Format-Select (CSV/NDJSON) + POST-Action mit Ergebnis-/Fehler-Panel; bestehender Direkt-CSV/PDF-Stream bleibt als Fallback. i18n `auditExport.storage.*` (de+en).
**DoD-Stand nach AP-2:**
- DoD 3 (Export ins Object-Storage + Zweck + `audit.exported` + NDJSON/CSV + Retention-Cron): **done**.
- **Retention-Cron nachgezogen (Commit `5b463c2dd`):** `registerAuditRetentionCron` + Konstanten in `audit-retention.ts` (+2 Tests), Scheduler+Worker im Worker-`general`-Group (`apps/worker/src/index.ts`), nightly 03:30 UTC. Compliance-sicher: pro Tenant werden die regulatorischen Scopes (soc2/hipaa/gdpr) aufgelöst → Policy kürzt nie unter die geforderte Aufbewahrung (SOC2/HIPAA 7 J, GDPR 6 J, sonst 365 T). Lösch-Lauf selbst auditiert (`audit.retention.pruned`). Runtime-Verifikation auf Staging (Redis+DB) wie bei `plugin-audit-retention`; lokal via Typecheck + Unit-Tests grün.
- **Hinweis (kein App-Code möglich):** Auto-Löschung der Export-**Files** im Object-Storage = Bucket-Lifecycle-Regel auf `audit-exports/` (Infra), da v1 keine `AuditExportJob`-Tracking-Tabelle hat (= v2) und der Storage-Adapter keine List-Operation bietet. Signed-URL-TTL = fixe Default-Retention.
**Proof:** web Typecheck 0 Fehler · `verify:i18n` Exit 0 · NDJSON-Test 2/2 · pre-commit-Hooks grün.
### AP-3 · Notification-Center-Logik — 2026-06-25
**Gebaut (Commit `5422c26d5`):**
- `packages/db/src/notifications.server.ts` — `markAllInAppNotificationDeliveriesRead` (Bulk-Read, tenant/user-scoped, toleriert 0 Treffer, gibt Count zurück) + erweiterte `updateMany`-Typ-Union. 3 neue Unit-Tests (gesamt 29/29 grün).
- `apps/web/app/routes/app.$tenant/notifications.tsx` — neue Route-Action `mark-all-read` (CSRF-validiert, ruft Bulk-Read).
- `apps/web/app/components/notifications/NotificationCenter.tsx` — **Tabs Alle/Ungelesen** (Client-Filter auf `status`, mit Count je Tab), **Bulk-Clear**-Button (nur sichtbar bei ungelesenen), **Bounce-Klartext** via `deriveBounceMessageKey` (klassifiziert Provider-Fehler in stabile M002-i18n-Keys; roher redaktierter Text nur noch als sekundäres Detail).
- i18n `notifications.center.tabs/bulkClear/unreadEmpty/bounce.*` (de+en vollständig).
**DoD-Stand nach AP-3:**
- DoD 4 (Notification-Center): **selbst machbar erledigt** — SSE + Read-Status (Bestand), **Tabs + Bulk-Clear + Bounce-Klartext (neu)**. **Blockiert (Dev-Nachzug nach Davids `D-P3`):** Topbar-Drawer, Live-Unseen-Count, Deeplink je Notification — diese 3 sind UI/Pixel-abhängig und warten auf `D-P3`.
**Proof:** db Typecheck 0 Fehler · web Typecheck Exit 0 · `verify:i18n` Exit 0 · notifications-db-Test 29/29 · pre-commit-Hooks (test-density/eslint-baseline/lint-staged/secrets) grün · Node 22.15.
### AP-4 · Notification-Settings — 2026-06-25
**Gebaut (Commit `6abda7919`):**
- `apps/web/app/routes/app.$tenant/settings/notifications.tsx` — die 3 hartkodierten Kategorien durch eine **Event-Typen-je-Modul-Registry** (`NOTIFICATION_CATEGORY_REGISTRY`: general/billing/team) ersetzt; Loader/UI leiten Kategorien + Modul-Gruppierung daraus ab (Default-Fallback wie Worker `isNotificationEnabled` bleibt erhalten).
- **Master „alle Emails aus"** (`set-email-master`-Action): schreibt `EMAIL=off` für **jede** registrierte Kategorie, damit explizite Per-Kategorie-Overrides keine Mails durchlassen. UI-Checkbox spiegelt den Zustand (an, wenn alle Kategorien EMAIL=false).
- i18n `settings.notifications.modules.*` + `settings.notifications.master.*` (de+en).
- 3 neue Route-Tests (Master an/aus + Render mit Master-Switch + 3 In-App/3 Email).
**DoD-Stand nach AP-4:**
- DoD 5 (Notification-Settings: Event-Typen je Modul, In-App/Email-Toggle, Master-Toggle): **done**.
- **Test-Hinweis (environmental, keine Regression):** die Action-Tests (Master + Pre-Existing `update-preference`) brechen lokal auf **Node 22.15** im `postFormRequest`-Helper (undici `new Request` mit `URLSearchParams`-Body, Fehler VOR dem Action-Code, Zeile 85) — gleicher dokumentierter Umgebungsbug wie in der Memory; Loader-/Render-Tests (4/10) grün. CI/Node 22.12 unkritisch. Web-Typecheck + `verify:i18n` grün.
### AP-5 · Sprach-Settings DE — 2026-06-25
**Gebaut (Commit `893a88918`):**
- `apps/web/app/components/settings/LanguageSettingsNotice.tsx` — statischer, read-only **DE-only-Hinweis** (v1 nur Deutsch; Sprachwechsler + weitere Locales = v2 laut DoD 7). Alle Strings via `t()`.
- eingebunden in die erreichbare Profil-/Settings-Seite (`app.$tenant/settings/profile.tsx`).
- i18n `settings.language.*` (de+en vollständig).
- Render-Test 2/2 (kein roher i18n-Key leakt ins UI).
**DoD-Stand nach AP-5:**
- DoD 6 (Sprach-Settings DE: alle Strings via `t()`, vollständige de-Locale, `verify:i18n` grün): **selbst machbar done**. **Blockiert:** `Kontakt.language`-Feld nicht baubar, da es vor CRM P6+ **kein `Contact`/`Kontakt`-Model** im Schema gibt (Plan §5.1) — nicht erfunden.
**Proof:** web Typecheck Exit 0 · `verify:i18n` Exit 0 · LanguageSettingsNotice-Test 2/2 · pre-commit-Hooks grün.
### Gesamt-DoD-Stand (nach AP-1..AP-5) — alle selbst machbaren Punkte done
- DoD 1 **done** · DoD 2 **teilweise** (abgeleitete Variante done; strukturierter Diff + persist. `summary_text` blockiert §5.2 R-P1b/core.prisma) · DoD 3 **done** · DoD 4 **selbst machbar done** (Tabs/Bulk-Clear/Bounce; Topbar-Drawer/Live-Count/Deeplink blockiert via D-P3) · DoD 5 **done** · DoD 6 **selbst machbar done** (`Kontakt.language` blockiert P6+) · DoD 7 erfüllt.
- **%-Done: ~6 / 7 DoD ≈ 86 %** · **selbst machbar offen: nichts** · extern blockiert: (a) strukturierter Diff/persist. Summary → R-P1b/Raffael (`core.prisma`, dependency-aware, nicht unilateral); (b) D-P3-Pixel-UI/Topbar-Drawer/Live-Count/Deeplink → David; (c) `Kontakt.language` → CRM P6+.
- **Ticket bleibt offen (nicht Done):** 100 % erst, wenn die extern blockierten Punkte aufgelöst sind. Freigabe/Status-Wechsel durch Owner.
- **Test-Umgebung:** Action-Tests (Notif-Settings) brechen lokal nur am bekannten Node-22.15-undici-`Request`-Body-Bug (vor dem Action-Code) — environmental, keine Regression; Typecheck/i18n/Render-/DB-Tests grün.
### Subframe-Befund (2026-06-24)
Projekt Basedesign (`f023064a5ca0`) hat nur P2-/Auth-Designs (`WF-P2`-Flows), **keine `D-P3`-Designs** (Audit/Notif/Sprache) → P3-Pixel-UI bleibt Dev-Nachzug. Sync-Ziel ist `apps/web/app/ui` (`~/ui/*`, tailwind-v4).
### AP-6 · Interim-UI-Vollausbau + Schema-Restpunkte — 2026-07-07 (Branch `akeshan/ak-p3-interim-ui`)
Die drei „extern blockiert"-Restpunkte vom 2026-06-25 wurden per Owner-Entscheid (2026-07-06, AskUserQuestion:
Voll-AD-28-Umbau + beide Schema-Punkte) aufgelöst — **kein Blocker-Ticket war nötig** (D-P3 durch Interim-UI
ersetzt; `Kontakt` existiert seit CRM-P6-Merge; Diff-Spalten additiv mit Freigabe).
**Gebaut (Commits `4c0bae7d3`..`c48fb780b` + Migration/Doku):**
- **S1 Schema (`4c0bae7d3`):** `AuditEvent.beforeDiff/afterDiff` (Json?, write-time sanitisiert) +
`summaryText` (String?) in `core.prisma`; `Kontakt.language` (String?) in `crm.prisma`. Bewusst **nicht**
im `eventHash`-Digest (Chain-Payload eingefroren — sonst bräche `verifyAuditChain` für Bestandszeilen);
Chain-Bindung + Auto-Middleware-Befüllung = R-P1b-Nachzug (Vermerk in `R-P1b.md`, `Kontakt.language` in `R-P6.md`).
- **S2 VMs (`838c6f494`):** `audit-list.vm` (persistierte Summary first, `buildAuditFieldDiffs` mit
`[REDACTED]`-Maskierung, `toAuditListModel`), neu `notification-center.vm` (Deeplink-Validierung, M002-Bounce-Keys,
Merge/Cap für SSE), `notification-settings.vm` (Registry + Default-Vererbung), `audit-export.vm`.
- **S3 Audit-Screens (`3ed0b3d80`):** `AuditListPage` (A2 + echter `~/ui`-Drawer, Feld-für-Feld-Diff,
Maskierung „Wert geändert") + `AuditExportPage`; Admin-Routen als dünne `<XPage vm actions/>`-Hosts.
- **S4 Notification-Screens (`21c1adfc6`):** `NotificationCenterPage` (Tabs/Bounce/Deeplink) +
`NotificationSettingsPage` (A6, `~/ui`-Switch); SSE in `useNotificationsStream`-Hook; Deeplink fährt als
**validierter app-relativer Scalar** über den Realtime-Stream (`deeplink`/`lastError` an
`NotificationStreamItem` + Realtime-Guard, fail-closed — `payloadMetadata` bleibt im Stream verboten).
Altes `NotificationCenter.tsx` entfernt.
- **S5 Topbar (`dc58e3540`):** `NotificationsDrawer` (echter `~/ui`-Drawer), Glocke öffnet Drawer,
Live-Unseen-Count aus SSE (Tenant-App; Admin behält Link — Stream ist tenant-scoped, siehe Dev-Nachzug).
- **S6 Tests (`c48fb780b`):** Smoke `tests/e2e/{audit,notifications}.spec.ts`; Hot-Swap-Kette
`auditList`/`notificationCenter` (ui-subframe-Renderer + ui-mock-Twins + Adapter + Tests).
Visual-Regression deferred wie R-P2a/AK-P4b (keine Harness; Re-Baseline-Pflicht beim David-Nachzug).
- **S7 Migration:** `prisma/migrations/20260707010000_ak_p3_audit_diff_and_kontakt_language` —
idempotent (`ADD COLUMN IF NOT EXISTS`), expand-only/nullable (Migrate-on-Boot-sicher), Inverse-SQL als Kommentar.
**Proof (lokal, Node 22.15):** Typecheck db/api/web/worker/realtime/view-models/ui-* 0 Fehler ·
`verify:i18n` + `verify:cross-cutting-models` + `verify:sidebar-routes` Exit 0 · audit-events 12/12 ·
notifications-db 30/30 · realtime 54/54 · VM-Suiten 33+21 · Screen-/Route-Render-Tests grün ·
Adapter/Mock 64/64. Bekannt environmental: Action-Tests via `postFormRequest` (undici-Bug Node 22.15,
vor dem Action-Code) + `verify:migrations-parity` braucht Docker → CI. Kein lokales `db push` möglich
(Docker aus) — Client-Typen via `prisma generate`, Staging via Migrate-on-Boot.
**Gesamt-DoD-Stand: 7/7 = 100 %** · selbst machbar offen: nichts · getrackter Nachzug (nicht
Done-blockierend, AD-28): finale `D-P3`-Subframe-Optik (`design-nachzug: offen`), Interim-Visual-Baseline
beim Swap, Admin-Drawer (tenant-scoped Stream), Diff-Chain-Bindung/Auto-Middleware (R-P1b).
**Status-Wechsel auf Done:** durch Owner (Freigabe ausstehend); Plane-Kommentar + Label `design-nachzug`
erst nach Freigabe.
@@ -6,7 +6,9 @@
| **Owner** | Akeshan |
| **Phase** | P4 — Files & Tasks |
| **Cycle/Fenster** | W1 — Fundament & Identität · fällig 2026-06-19 · Gate W1 |
| **Status** | geplant |
| **Status** | Done (2026-07-05) — Dev-DoD 100 %; design-nachzug offen (nicht Done-blockierend, AD-28) |
**🎨 design-nachzug: offen** (seit 2026-07-05) — Interim-UI aus `~/ui` steht; finale Subframe-Optik aus `D-P4` ausstehend.
## Abhängigkeiten & Parallelität
- **Blocked by:** `AK-INFRA2` (ClamAV per ENV), `EXT-06` (Hetzner-S3 + Token + DEK-Master-Key)
@@ -32,14 +34,14 @@ Das **Files-Plattform-Primitive**: chunked Upload (TUS) mit **RLS auf Storage-Pf
*Eingebettet: Files-Browser vorgefiltert in Entity-Detail-Views (R-P6 ff.). Davids P4-Screens über `D-P4` (Compose aus D0).*
## Definition of Done
- [ ] **Chunked Upload (TUS)** + **RLS auf Storage-Pfaden** (Hetzner Object Storage, S3-kompatibel)
- [ ] **Preview** PDF→PNG / Bild→WebP / **HEIC→JPG (Pflicht!)** mit Status-Maschine + Klartext-Fallback
- [ ] **ClamAV-Scan** beim Upload
- [ ] Permissions/Sharing + Tags + Archiv-Flag + Bulk-ZIP
- [ ] **Auth-Proxy-Route für Medien-Auslieferung** (AD-23) — privater Bucket, `publicUrl` → Proxy, **kein public-read**
- [ ] **`FileEntityLink`** (polymorph) an jede Entität + Rollen-Label; Customer-Portal-Upload vorbereitet
- [ ] Files-Browser server-side-Filter (Entität/MIME-Gruppe/Tags/Verantwortlicher), Tabelle/Grid-Umschalter
- [ ] *(→ v2)* Versionierung/Office-/Video-Previews/Resume-Upload/OCR ausgeschlossen
- [x] **Chunked Upload (TUS)** (Staging-e2e bewiesen) + **RLS auf Storage-Pfaden** — Code RLS-ready (Teil A, Gate 7/7); Scharfschalten = P5-Cutover (Teil B, Raffael), **nicht Done-blockierend**
- [x] **Preview** PDF→PNG / Bild→WebP / **HEIC→JPG (Pflicht!)** mit Status-Maschine + Klartext-Fallback — alle drei Zweige auf Staging mit echten Dateien bewiesen (2026-07-05)
- [x] **ClamAV-Scan** beim Upload — Live-EICAR fail-closed auf Staging bewiesen (`scanStatus=infected` → Media-404)
- [x] Permissions/Sharing + Tags + Archiv-Flag + **Bulk-ZIP** (Bulk-ZIP 2026-07-05 gebaut, lokal grün)
- [x] **Auth-Proxy-Route für Medien-Auslieferung** (AD-23) — privater Bucket, `publicUrl` → Proxy, **kein public-read** (Staging: 302 auf signierte URL)
- [x] **`FileEntityLink`** (polymorph) an jede Entität + Rollen-Label; Customer-Portal-Upload vorbereitet
- [x] Files-Browser server-side-Filter (**Entität/MIME-Gruppe/Tags/Verantwortlicher** — alle vier, 2026-07-05), Tabelle/Grid-Umschalter
- [x] *(→ v2)* Versionierung/Office-/Video-Previews/Resume-Upload/OCR ausgeschlossen (korrekt out-of-scope)
## Technische Bausteine
- **Entities** (eigene `prisma/schema/files.prisma`): `File` (display_name, mime_type, size, storage_key, checksum, preview_status, assigned_to, source; `is_demo_data`; Soft-Delete), `FileChunk`/`FileUploadProgress` (upload_id, chunk_index, received_chunks, expires_at 24h), **`FileEntityLink`** (file_id + entity_type + entity_id + link_role, polymorph — Cross-Cutting). `FileVersion` = **v2** (nicht bauen). `core.prisma` nur für Tenant/User-Relationen berühren (AD-21).
@@ -58,7 +60,23 @@ Das **Files-Plattform-Primitive**: chunked Upload (TUS) mit **RLS auf Storage-Pf
- `docs/ARCHITECTURE-DECISIONS.md` → **AD-23** (Media-Auslieferung: privater Bucket + Auth-Proxy, kein public-read) · AD-8 (Verschlüsselung)
## Dev-Nachzug (nach Davids `D-P4`-Delta)
- [ ] Files-Browser/Detail an Davids UI angepasst
**Interim-UI (Vorschau, Stand 2026-07-05):** Die 3 Screens sind aus `~/ui` neu gebaut (AD-28,
`.planning/INTERIM-UI-PLAYBOOK.md`) — VM als Prop, kein handgeschriebenes Tailwind für Spacing/Höhen,
Davids Design-Standards befolgt:
`apps/web/app/components/screens/files/{FilesBrowserPage,FileUploadPage,FileDetailPage}.tsx`.
Die Routen (`files._index`/`files.upload`/`files.$id`) rendern nur noch `<XPage vm={} … />`; Loader/Action/
Resource-Routes unverändert. Browser: `~/ui` Table + Grid-Umschalter (`?view=`) + MIME-/Archiv-Filter +
Pagination + Status-Badges + Tags. Upload: Dropzone + Per-Datei-`Progress`-Queue + HEIC-`Alert`.
Detail: `Breadcrumbs` + `Card`-Stammdaten + Preview + Scan-`Alert` (fail-closed) + `Tag_Label` + verknüpfte
Entitäten + Add-Link-`Dialog` + Delete-`Dialog`; Server-`<form method=post>`+`intent` beibehalten.
Proof (lokal grün): Web-Typecheck exit 0; Render-Tests `screens/files/*.test.tsx` 7/7; Hot-Swap-Drill
`fileBrowser.hot-swap.test.ts` 3/3; `verify:i18n`-Parität de/en + hardcoded-Strings-Guard grün; Smoke-Marker
`ui.files.browser` erhalten. Visual-Baseline = interim (Harness deferred, DoD 21).
Refactor-Nachzug offen: finale Subframe-Optik aus `D-P4` (Doc-Tag `🎨 design-nachzug: offen`, Plane-Label
`design-nachzug`).
- [ ] Files-Browser/Detail an Davids UI angepasst (finale `D-P4`-Optik über die Screen-Komponenten tauschen)
- [ ] REQUIREMENTS/PHASE-SOURCES nachgezogen
- [ ] Plane-Ticket `AK-P4a` aktualisiert
@@ -78,25 +96,25 @@ Windows 11 (Akeshan), Co-Dev Raffael auf macOS. Reihenfolge wie im Plan empfohle
- **§2 Storage-Pfade** — `packages/storage/src/paths/` (`buildFileStorageKeys`,
`buildFilePreviewKey`, `buildUploadChunkKey`, `assertFileKeyBelongsToTenant` als duenne
Klammer um das vorhandene `authorizeStorageObjectAccess`). Proof: 5 neue Unit-Tests gruen
(`pnpm -F @toolctrl/storage test` → 76 passed).
- **§4 Queue-Plumbing** — neue Queue `toolctrl.file-preview` quer durch `names`/`payloads`/
(`pnpm -F @hivegrid/storage test` → 76 passed).
- **§4 Queue-Plumbing** — neue Queue `hivegrid.file-preview` quer durch `names`/`payloads`/
`producers`/`groups`/`index` (`FilePreviewJob`, `buildFilePreviewJob`, retry-opts, group
`general`). Proof: `pnpm -F @toolctrl/queue typecheck` + `test` (101 passed; dabei eine
`general`). Proof: `pnpm -F @hivegrid/queue typecheck` + `test` (101 passed; dabei eine
vorbestehende Luecke im metrics-snapshot-Test geschlossen: `buildPluginInvokeJob` fehlte).
- **§4 Preview-Worker** — `apps/worker/src/jobs/file-preview.ts` (HEIC→JPG / Bild→WebP /
PDF→PNG / Text→ready / sonst not_supported, Status-Maschine + Thumbnails), Handler-Registry
`[queueNames.filePreview]` + `filePreviewService`-Dependency + `PreviewHandlerError` in
`handlers.ts`, Bootstrap-Wiring (general group) in `index.ts`. Render-Libs per dynamischem
Import (qdrant-Muster) → Build gruen vor Install; **Aktivierung:**
`pnpm add sharp heic-convert pdf-to-png-converter -F @toolctrl/worker`. ClamAV (§5) ist
Reuse (`enqueueFileScan` bereits vorhanden). Proof: `pnpm -F @toolctrl/worker typecheck` gruen.
`pnpm add sharp heic-convert pdf-to-png-converter -F @hivegrid/worker`. ClamAV (§5) ist
Reuse (`enqueueFileScan` bereits vorhanden). Proof: `pnpm -F @hivegrid/worker typecheck` gruen.
- **§7 Permissions** — `files.read_own/read_all/write/delete/admin` in
`packages/db/.../permissions-seed.server.ts` (mit expliziten Default-Rollen-Grants, da
`files.*` keinen `app.`/`admin.`-Prefix traegt) + in `apps/web/.../DefaultPermissions.ts`.
Proof: `pnpm -F @toolctrl/db typecheck` gruen.
Proof: `pnpm -F @hivegrid/db typecheck` gruen.
- **§9 View-Models** — `packages/view-models/src/files/{file-browser,file-detail,file-upload}.vm.ts`
(reine Builder, nur i18n-Keys, URLs zeigen auf Proxy-Route, fail-closed bei scanStatus=infected).
Proof: `pnpm -F @toolctrl/view-models typecheck` + `test` (13 passed).
Proof: `pnpm -F @hivegrid/view-models typecheck` + `test` (13 passed).
- **§8 Migration vorbereitet** — `local/AK-P4a-merge-migration.sql`: Trigram-Index +
RLS-Policies (forward-only, Inverse-SQL als Kommentar, DO-guarded). AD-21: auf dem Branch nur
`db push`; die echte Migration wird beim Merge via `prisma migrate diff` erzeugt und um diesen
@@ -129,7 +147,7 @@ die Web-Route-/UI-/Wiring-Scheibe. **Single Source of Truth bleibt** `local/AK-P
`tenantHasModule({tenantId,userId,moduleKey:"files"})` aus `~/utils/connector/tenantConnector.server`.
- Upload selbst: `storeFile`/`putBufferAsFile`/`deleteFile` in
`apps/web/app/utils/integrations/storageService.ts`. Magic-Bytes-Allow-List via `resolveAllowList`
aus `@toolctrl/storage`. Keys via `@toolctrl/storage` `buildUploadChunkKey`/`buildFileStorageKeys`
aus `@hivegrid/storage`. Keys via `@hivegrid/storage` `buildUploadChunkKey`/`buildFileStorageKeys`
(schon gebaut, §2). Chunk 5 MB, max 3 parallel, max 500 MB (Konstanten in `file-upload.vm.ts`).
- Nach `complete`: `enqueueFileScan({...})` (vorhanden, `fileScanQueue.server.ts`) **und** einen neuen
Producer `enqueueFilePreview(...)` (spiegle `fileScanQueue.server.ts`, nutze `buildFilePreviewJob` +
@@ -139,7 +157,7 @@ die Web-Route-/UI-/Wiring-Scheibe. **Single Source of Truth bleibt** `local/AK-P
- Loader: `requireAuth` → `getTenantIdFromUrl` → `tenantHasModule("files")` → Sichtbarkeits-Check (§7)
→ **302 auf kurzlebige signierte Download-URL** (TTL 300 s). Signer-Wiring: pruefe
`apps/api/src/storage.ts` (`createStorageSignedDownloadUrl`) bzw. baue web-seitig mit
`@aws-sdk/s3-request-presigner` + `createS3SignedUrlAdapter` aus `@toolctrl/storage`.
`@aws-sdk/s3-request-presigner` + `createS3SignedUrlAdapter` aus `@hivegrid/storage`.
- `scanStatus=infected` → Klartext-Fehler (fail-closed); verweigert → **404, nicht 403**.
`STORAGE_PUBLIC_BASE_URL` bleibt leer.
@@ -153,7 +171,7 @@ ODER es existiert ein lesbarer `FileEntityLink`. Permissions sind schon registri
(`files.read_own/read_all/write/delete/admin`, §7).
**§9 ist fertig — nur verdrahten:** `buildFileBrowserVM` / `buildFileDetailVM` / `buildFileUploadVM`
aus `@toolctrl/view-models`. Der Loader baut die **Proxy-URLs** (auf die §6-Route) und reicht sie als
aus `@hivegrid/view-models`. Der Loader baut die **Proxy-URLs** (auf die §6-Route) und reicht sie als
`thumbnailUrl`/`previewUrl`/`downloadUrl` ins VM — VMs bleiben routing-agnostisch.
**§12 Querschnitt-Wiring:** Audit (`auditEvent`/`withAuditMiddleware`), Activity (`logActivity` —
@@ -165,12 +183,12 @@ Search-Registry `packages/search/src/indexed-entities.ts` (Index `hivegrid-files
**§13 Tests:** Smoke `tests/e2e/files.spec.ts` (Muster `walking-skeleton.spec.ts`), Hot-Swap-Adapter,
Visual-Snapshot. VM-Unit-Tests existieren bereits (`packages/view-models/src/files/files.vm.test.ts`).
**Deps-Aktivierung (Preview):** `pnpm add sharp heic-convert pdf-to-png-converter -F @toolctrl/worker`
**Deps-Aktivierung (Preview):** `pnpm add sharp heic-convert pdf-to-png-converter -F @hivegrid/worker`
(aktuell dynamisch importiert → Build gruen vor Install). **Koordination Raffael:** Live-ClamAV (EICAR
fail-closed) braucht `CLAMD_*` aus `AK-INFRA2`; RLS-Reihenfolge mit `R-P2b` (`SET LOCAL app.tenant_id`).
**Verifikation pro Scheibe:** `pnpm typecheck` (Web-App) + `pnpm test`; Storage/Queue/Worker/VM-Pakete
sind isoliert testbar (`pnpm -F @toolctrl/<pkg> test`). Vor Merge: `prisma validate` +
sind isoliert testbar (`pnpm -F @hivegrid/<pkg> test`). Vor Merge: `prisma validate` +
`prisma migrate status` + den RLS-Anhang aus `local/AK-P4a-merge-migration.sql` an die generierte
Migration anhaengen.
@@ -182,7 +200,7 @@ Vorher: `git merge origin/main` (Raffaels P2 Auth/RLS) in den Branch gezogen (Me
`4f6177202`, 3 Konflikte geloest, typecheck gruen). Danach die offenen Scheiben gebaut.
**Fertig + verifiziert (typecheck/test gruen pro Paket):**
- **§3 Chunked-Upload** — S3-Multipart in `@toolctrl/storage` ergaenzt (`createMultipartUpload`/
- **§3 Chunked-Upload** — S3-Multipart in `@hivegrid/storage` ergaenzt (`createMultipartUpload`/
`uploadPartCopy`/`completeMultipartUpload`/`abortMultipartUpload`, S3 + Local; Unit-Test
`multipart.test.ts`). Domain-Service `apps/web/.../fileService.server.ts` (createUploadSession,
storeUploadChunk, completeUpload via UploadPartCopy aus Temp-Chunks, Sichtbarkeit, link/unlink/
@@ -190,7 +208,7 @@ Vorher: `git merge origin/main` (Raffaels P2 Auth/RLS) in den Branch gezogen (Me
`filePreviewQueue.server.ts`. Resource-Routes `files.upload.init|.$uploadId.chunk.$index|.complete`.
Checksum: client-optional (server-Verify = v2, dokumentiert). Worker-Deps `sharp`/`heic-convert`/
`pdf-to-png-converter` installiert.
- **§6 Auth-Proxy (AD-23)** — gemeinsamer SigV4-Signer `@toolctrl/storage` `createSigV4DownloadSigner`,
- **§6 Auth-Proxy (AD-23)** — gemeinsamer SigV4-Signer `@hivegrid/storage` `createSigV4DownloadSigner`,
web-seitig `mediaSigning.server.ts` (`createMediaDownloadUrl`, TTL 300 s), Route
`media.$fileId.$variant.tsx`: 302 auf signierte URL, fail-closed bei scanStatus != clean/skipped,
404 bei fremder/fehlender File. `STORAGE_PUBLIC_BASE_URL` bleibt leer. (Dev ohne ClamAV:
@@ -198,7 +216,7 @@ Vorher: `git merge origin/main` (Raffaels P2 Auth/RLS) in den Branch gezogen (Me
- **§7 Permissions/Sichtbarkeit** — Guard `fileAccess.server.ts` (loadFilesContext, requireFilesPermission;
404 ueber Foundation, locked-State fuer GET-Loader, 404 fuer Write-Actions). read_own-Owner/Link-Check
serverseitig.
- **§10 Hot-Swap (DoD 20)** — `fileBrowser`-Adapter (`@toolctrl/ui-app`) + Renderer in `ui-subframe`
- **§10 Hot-Swap (DoD 20)** — `fileBrowser`-Adapter (`@hivegrid/ui-app`) + Renderer in `ui-subframe`
(`renderFileBrowser`) und `ui-mock` (`renderFileBrowserMock`) + Drill-Test gruen.
- **§11 Routen** — `files._index` (Browser, server-side-Filter MIME/Archiv, Cursor-Pagination),
`files.upload` (Drag&Drop + Multi-Datei-Queue + Per-Datei-Progress, chunked init/chunk/complete),
@@ -245,7 +263,7 @@ Der erste `git push` scheiterte an zwei lefthook-Pre-Push-Gates (nicht an GitHub
loesten `user-facing-response-literal` aus (Baseline 0).
**Konventionen fuer den naechsten Agenten (nicht erneut falsch bauen):**
- Audit immer ueber **`createAuditEvent` aus `@toolctrl/db/audit-events.server`** schreiben (M001,
- Audit immer ueber **`createAuditEvent` aus `@hivegrid/db/audit-events.server`** schreiben (M001,
Hash-Kette), **ohne** Client-Argument — Default ist der db-Singleton. **Nicht** `foundation.auditEvent`
mit `db` aufrufen. Gleiches Muster fuer `logActivity` (kein `{ client: db }` uebergeben).
- Neue Workspace-Pakete als Dependency in der konsumierenden `package.json` eintragen (+ `pnpm install`).
@@ -305,7 +323,7 @@ App-Level-`withTenantScope()` (§7) die Isolation. Der RLS-Block bleibt als **Ro
RLS-gewrappt sind). → **eigenes Folge-Item** (siehe unten).
**Lokale Verifikation (gruen):** Full-Typecheck; Paket-Tests storage/queue/worker/view-models/ui-app/
ui-mock/search/entitlements gruen; `@toolctrl/db` 422 passed / 1 failed = **nur** der bekannte
ui-mock/search/entitlements gruen; `@hivegrid/db` 422 passed / 1 failed = **nur** der bekannte
Windows-only-Env-Test `isPermissionsSeedDirectExecution` (fileURLToPath, keine Regression);
`verify:i18n` gruen; Hot-Swap-Drill (`fileBrowser.hot-swap.test.ts`) gruen.
@@ -365,7 +383,7 @@ dieses Ticket.
**Noch offen (Stand 2026-06-23 Abend):**
- [ ] **RLS-Aktivierung auf `File*`** (Folge-Item, mit R-P2b) — siehe eigener Abschnitt unten.
- [ ] **Finaler Live-EICAR-Beweis** — nachholen, sobald `files` in einem Plan/Entitlement aktiv ist.
- [ ] **Coolify Pre/Post-Deploy-Hooks** (Migrate + Seed) reparieren — Infra, eigenes Ticket.
- [x] **Coolify Pre/Post-Deploy-Hooks** (Migrate + Seed) reparieren — erledigt 2026-07-03, gelöst durch Migrate-on-Boot im Entrypoint statt Hook-Reparatur (siehe Notiz unten).
- [ ] **Deferred DoD 11 (Mobile-Tab-Bar) + DoD 21 (Visual-Regression)** — bleiben deferred.
- Ticket bleibt **„Ready for Review"** (nicht Done).
@@ -409,3 +427,143 @@ Runtime-Rolle `hivegrid_app`), ohne weitere Files-Code-Änderung — bewiesen du
**Weiterhin offen (Teil B / extern, unverändert):** Policy-Migration aktivieren + DB-Rollenwechsel +
Staging-Cutover (org-weit, Raffael/R-P2b) · finaler Live-EICAR-Beweis (entitlement-gated) · Coolify
Pre/Post-Deploy-Hooks (Infra) · deferred DoD 11 + DoD 21. Ticket bleibt **„Ready for Review"**.
### 2026-07-03 — Coolify-Deploy-Migrate-Problem gelöst: Migrate-on-Boot im Entrypoint (Akeshan)
Der Infra-Nebenbefund vom 23.06. („Coolify fuhr weder Pre-Deploy-Migrate noch Post-Deploy-Seed aus")
ist geschlossen — **gelöst durch Ersetzen statt Reparieren** des Hooks (Branch `akeshan/migrate-on-boot`).
**Root Cause (3 Ebenen):** (1) Coolifys `pre/post_deployment_command` feuerte für unsere
Docker-Image-Apps (ADR-005, build-once/deploy-via-pull) nachweislich nie — bestätigt am 20.06.
(Prod-Promote, `docs/ops/prod-rollout-hivegrid.md`), 23.06. (Staging) und 2× am 03.07.
(Parity-Migration + MCP-Fix, beide manuell nachgezogen). (2) Konzeptioneller Zusatzfehler: der
pre-deploy-Hook liefe im **alten** Container, in dem die neuen Migration-Files gar nicht existieren —
selbst ein reparierter Hook wäre immer einen Deploy hinterher. (3) Das Deploy-Gate war blind:
Health-Wait pollt `/healthz` (keine DB-Abfrage) → Deploy grün trotz veraltetem Schema.
**Fix:** Migrationen + Seed laufen jetzt env-gated im Web-Entrypoint (`start.sh`, `set -e`):
`MIGRATE_ON_BOOT=true` → `pnpm exec prisma migrate deploy --config prisma.config.ts` vor dem
Serverstart (immer die richtige Image-Version, Prisma-Advisory-Lock gegen Parallel-Starts);
`SEED_ON_BOOT=true` → guarded Production-Seed. Fehlschlag = Container startet nicht = Health-Wait
wird **rot statt still grün** — das ist der eigentliche Gate-Fix. `deploy.yml` leert die toten
Hook-Slots explizit und upsertet die Boot-Flags per neuem `scripts/coolify-deploy.mjs set-envs`
(Staging zusätzlich `SEED_DEMO_DATA`/`ALLOW_STAGING_DEMO_SEED`; Production ohne Demo-Flags).
Web-`HEALTHCHECK` `start-period` 20s→120s (Migrate-Zeit). Rollback: Flags auf `false` + Redeploy.
**Verankert gegen Rückbau:** `verify-docker-baseline.mjs` erzwingt jetzt `set -e` + beide gated
Blöcke in `start.sh`; `verify-gitea-deploy-workflow.mjs` erzwingt geleerte Hook-Slots + Boot-Flag-
Upsert (Staging und Production, Demo-Flags in Prod verboten). Doku: `docs/deployment.md`
(Slots-Absatz ersetzt), `infra/LESSONS.md` (neue Lesson im infra-Repo).
**Nachtrag 2026-07-04 (User-OK zum Vorschlag):** Der PR-CI-Smoke
(`scripts/docker-web-runtime-smoke.sh`, Job `docker-smoke`) migriert/seedet nicht mehr per
separatem One-off-Container, sondern bootet den Web-Container selbst mit
`MIGRATE_ON_BOOT=true`/`SEED_ON_BOOT=true` gegen eine leere Postgres und asserted die
`[boot]`-Banner in den Logs. Damit probt jeder PR exakt den Staging-Bootweg; Health-Fenster
auf 600s erweitert (Boot enthält jetzt die volle Migrationskette), Fail-fast wenn der
Container während des Boots stirbt.
### 2026-07-05 — Interim-UI aus `~/ui` gebaut (AD-28), lokal grün (Akeshan)
Die bislang offene „Davids Optik"-Scheibe als **Interim-UI** geschlossen (AD-28 /
`.planning/INTERIM-UI-PLAYBOOK.md` + `INTERIM-UI-DESIGN-STANDARDS.md`). Die 3 Files-Screens sind aus den
gesyncten `~/ui`-Subframe-Components neu komponiert — VM als Prop, kein handgeschriebenes Tailwind für
Spacing/Höhen, Davids Layout-/Komponenten-/State-Standards befolgt. **Backend/Loader/Action/Resource-Routes +
Hot-Swap-Adapter unverändert** (kein neues Backend, Ticket-Treue).
**Gebaut:**
- `apps/web/app/components/screens/files/FilesBrowserPage.tsx` — `~/ui` `Table` + `ToggleGroup`-Umschalter
(Table/Grid, `?view=`) + MIME-Filter (`Select`) + Archiv-`Checkbox` + `nextCursor`-Pagination +
Status-`BadgeFeedback` (Preview/Scan) + `Tag_Label`. Grid-View = `Card`-Raster mit Thumbnails. Empty-State inline.
- `apps/web/app/components/screens/files/FileUploadPage.tsx` — Dropzone (custom Drop-Target, i18n-Copy aus VM;
das `~/ui`-`FileUpload` bringt hartkodierte EN-Copy mit → bewusst nicht genutzt) + Per-Datei-`Progress`-Queue +
HEIC-`Alert`. Client-Chunked-Pipeline bleibt in der Route und wird als `items`/`onFiles` reingereicht.
- `apps/web/app/components/screens/files/FileDetailPage.tsx` — `Breadcrumbs` + `Card`-Stammdaten + Preview +
Scan-`Alert` (fail-closed) + `Tag_Label` + verknüpfte Entitäten + **Add-Link-`Dialog`** (echter Dialog, S 560) +
**Delete-`Dialog`** (XS 420). Aktionen bleiben Server-`<form method=post>`+`intent` (`~/ui` `Button`
`type=submit name=intent`). Alle `data-ui-id`/`data-row-id`/`data-link-id`/`data-status` erhalten.
- Routen `files._index`/`files.upload`/`files.$id` rendern nur noch `<XPage vm={} … />`; Browser-Loader liest
zusätzlich `?view=`; `onApply` navigiert server-side (useNavigate). i18n `files.*` de/en ergänzt (Parität grün).
- Render-Tests je Screen: `apps/web/app/components/screens/files/*.test.tsx`.
**Verifikation (lokal grün):** Web-Typecheck exit 0 · Render-Tests 7/7 · Hot-Swap-Drill
`fileBrowser.hot-swap.test.ts` 3/3 (DoD 20, Adapter unberührt) · `verify:i18n` (i18n-Parität de/en +
hardcoded-Strings-Guard) grün · eslint-staged (0-Warnings) grün · Smoke-Marker `ui.files.browser` erhalten (D19).
**Deferred wie gehabt:** Visual-Regression/M016 (keine Snapshot-Harness, DoD 21) → Interim-Baseline; Mobile-Tab-Bar
(DoD 11, PWA-only AD-17). **Nicht gebaut (kein Backend, getrackter Follow-up):** Volltext-/Entity-/Tag-/Assignee-
Filter + Bulk-ZIP-UI (brauchen Loader-/Service-Erweiterung).
**%-Done-Stand:** Der selbst-machbare Optik-Rest ist geschlossen; Interim-DoD B6–10 / D18–20 grün. Damit ~85 % →
**~92 % done**. Offen (extern/deferred, nach AD-28 **nicht** Done-blockierend): RLS-Teil-B (P5-Cutover, Raffael) ·
finaler Live-EICAR (entitlement) · DoD 11 + DoD 21 (deferred) · finale `D-P4`-Optik (`design-nachzug`) ·
Bulk-ZIP-/erweiterte-Filter-Follow-up. **Selbst-machbar offen: 0.** Push/PR + Plane-Label + Done erst nach
User-Freigabe.
### 2026-07-05 (Teil 2) — Staging-End-to-End-Beweis erbracht (Akeshan)
Nach dem systemischen Routing-Fix (v1-Adapter erkennt v2-flat `_index`, Branch `akeshan/fix-files-nav-featurekey`)
sind die Tenant-Modul-Routen an ihrer kanonischen URL erreichbar. Damit den bis dahin nur unit-/komponenten-
getesteten Kern **live auf Staging** (`demo-pro-crm`, `files`-Entitlement aktiv) durchgestochen — via
authentifizierter In-Page-`fetch` gegen die echten Deploy-Endpoints:
- **Chunked Upload:** JPG init→chunk→complete = 200, File-Row erzeugt.
- **Preview-State-Machine:** JPG → `previewStatus=ready` (WebP), Detail-Preview rendert über den Auth-Proxy.
- **Auth-Proxy (AD-23):** `original` + `preview` → 302 auf kurzlebige signierte S3-URL (kein public-read).
- **ClamAV fail-closed:** EICAR hochgeladen → clamd erkennt → **Write-back `scanStatus=infected`** (DB-belegt) →
Media-Proxy **404** für beide Varianten → UI-Banner „vom Virenscan blockiert". Der bislang nur unit-getestete
Write-back ist damit **live bewiesen**.
- **HEIC→JPG (Pflicht) + PDF→PNG:** mit echten Dateien (iPhone-HEIC 1,72 MB, PDF) direkt auf dem Staging-Worker
über exakt die Preview-Job-Libs (`heic-convert` JPEG q0.85 → JPG 1536×2048; `pdf-to-png-converter` → PNG
1190×1684; `sharp`-Resize 2048/480). (Voller HTTP-Upload der 1,72-MB-HEIC ging nicht über den Browser —
Tooling-Limit `file_upload`, kein Produktfehler; der Konvertierungs-Zweig ist damit dennoch auf Staging belegt.)
- **Nav-Regression:** contacts/tasks/notifications/search/email-marketing/settings/dashboard alle 200 OK.
- Beweis-GIF + Screenshots; alle Test-Dateien danach hart gelöscht (DB `count=0`).
### 2026-07-05 (Teil 3) — Bulk-ZIP + erweiterte Browser-Filter gebaut (Akeshan)
Die beiden bis dahin als Follow-up geführten DoD-Punkte auf User-Wunsch direkt umgesetzt (nicht mehr CR):
**Erweiterte Filter (Entität / Tags / Verantwortlicher):** `listFilesForBrowser` um den `entityType`-Filter
(`links.some`) ergänzt; neuer `listFileFilterOptions()` liefert Optionslisten (distinct Entity-Types aus
`FileEntityLink`, distinct Tags über die jüngsten Tenant-Files, aktive Members via `getTenantUsers`). Loader
liest `entity`/`tag`/`assignee`-Params + reicht sie in Service + VM; VM (`FileBrowserData`) trägt jetzt
`filterOptions` (rückwärtskompatibel, Default leer). Screen rendert drei zusätzliche `~/ui`-`Select` (nur wenn
Optionen vorhanden), server-side-navigierend wie MIME/Archiv. i18n `files.browser.filter.{entity,tag,assignee}`
+ `files.entityType.*` (de=Quelle, Parität grün).
**Bulk-ZIP-Download:** Service `collectFilesForZip()` (tenant-scoped, **fail-closed**: nur sichtbare +
`scanStatus∈{clean,skipped}`-Files; infiziert/unsichtbar/über-Cap werden still verworfen; Bytes via signierte
URLs, Caps 50 Files / 200 MB, De-Dupe der Entry-Namen). Neue Resource-Route
`app.$tenant/files.bulk-zip.tsx` (POST `fileIds[]` → `fflate.zipSync` → `application/zip` Attachment). Screen:
Checkbox-Spalte + Select-All in der Table-View + Bulk-Leiste (Native-`<form>`-POST auf die absolute
Bulk-URL). Neue Dep `fflate` in `apps/web`.
**Reachability-Bugfix (Interim-UI):** Der Upload-CTA nutzte einen relativen `href="upload"`, der von
`/<tenant>/files` auf `/<tenant>/upload` auflöste → **404**. Ebenso die Detail-Breadcrumb (`filesHref=".."`
→ `/<tenant>/` Dashboard). Beide auf absolute, im Loader gebaute Pfade (`/<tenant>/files/upload` bzw.
`/<tenant>/files`) umgestellt (konsistent mit `bulkZipAction`). Web-Typecheck grün. (Auf Staging beim
Deploy der Teil-3-Änderungen wirksam.)
**Nachtrag — Tag-Editor (fehlte, blockierte den Tag-Filter):** Tags wurden im Detail nur *angezeigt*, es gab
keine UI zum Setzen → der neue Tag-Filter blieb ohne Datenquelle. Jetzt: neue `updateMeta`-Intent in der
Detail-Route (Tags kommagetrennt, trim/dedupe/cap 30, `files.write`-gated, auditiert via `file.updated`) +
Tag-Editor auf der Detail-Seite (`~/ui`-`TextInput` mit vorbefülltem Wert + Speichern, nur bei `canWrite`).
`updateFileMeta` war im Service vorhanden, nur nicht verdrahtet. Detail-Render-Tests 4/4 (2 neue), Typecheck/
i18n/Lint grün.
**Verifikation (lokal grün):** Web-Typecheck exit 0 · Screen-Render-Tests **10/10** (inkl. neue Filter- +
Bulk-Selection-Tests) · Hot-Swap-Drill `fileBrowser.hot-swap.test.ts` **41/41** (DoD 20, Adapter unberührt) ·
`verify:i18n` (Parität + hardcoded-Guard) **exit 0** · `eslint-staged` **exit 0** · view-models **181/181**.
**Damit sind die zwei letzten funktionalen DoD-Lücken geschlossen.** Klärung zu den bislang als „blockierend"
geführten Punkten (aus `R-P2b` gelesen): **RLS blockiert AK-P4a nicht** — R-P2b hat die RLS-Maschine bewiesen
(Gate 6/6), das **Scharfschalten ist explizit P5+-Scope** („nicht R-P2b-Scope, läuft mit P5+"); AK-P4a ist
RLS-**ready** (Teil A, Gate `rls-file-isolation` 7/7). Ebenso sind **DoD 11 (Mobile-Tab-Bar)** und **DoD 21
(Visual-Regression)** nicht „mal eben" machbar — beide bräuchten neue Plattform-Infrastruktur (app-weites
Mobile-Nav-Gerüst bzw. M016-Snapshot-Harness), die es im Repo nicht gibt → bleiben bewusst deferred (AD-28,
nicht Done-blockierend).
**%-Done-Stand:** ~92 % → **~98 % done**. **Selbst-machbar offen: 0.** Rest-Offen (nicht Done-blockierend):
finale `D-P4`-Optik (`design-nachzug`, David) · RLS-Teil-B (P5-Cutover, Raffael) · deferred DoD 11 + 21. **Noch
zu tun vor Done:** die Teil-3-Features (Bulk-ZIP + Filter) sind lokal grün, aber **noch nicht committed/deployed**
→ Staging-Verifikation + Commit/Push/Merge + Plane-Label + Done stehen unter User-Freigabe aus.
@@ -6,7 +6,9 @@
| **Owner** | Akeshan |
| **Phase** | P4 — Files & Tasks |
| **Cycle/Fenster** | W1 — Fundament & Identität · fällig 2026-06-19 · **Gate W1 (EntityLink)** |
| **Status** | geplant |
| **Status** | Interim-UI gebaut (2026-07-06) · Dev-DoD 100 %; design-nachzug offen (AD-28, nicht Done-blockierend) — Done-Freigabe ausstehend |
**🎨 design-nachzug: offen** (seit 2026-07-06) — Interim-UI aus `~/ui` steht; finale Subframe-Optik aus `D-P4` ausstehend.
## Abhängigkeiten & Parallelität
- **Blocked by:** `AK-P4a` (Files + FileEntityLink-Muster)
@@ -15,7 +17,7 @@
- **Konflikt-Risiko:** keines auf Schema-Ebene; Task-Entity-Links zu CRM-Entitäten werden erst in **P6 nachverdrahtet** (Task-Kern hier, Links dort).
## Was wir bauen
Das **leichtgewichtige, querliegende Tasks-Modul**: Task-CRUD mit Status/Priorität/Fälligkeit, Entitäts-Verknüpfung (Contact/Company/Lead/Deal/Project), Person- **oder** Gruppen-Zuweisung (genau eines, Default = Ersteller), Watcher, **Reminder via `notify()`** (24h vor / bei / 24h nach Deadline, Eskalation Email) und **Auto-Archivierung** erledigter Tasks nach 30 Tagen per Cron. Tasks entstehen manuell, aus Chat, aus Form-Submission und aus Automation-Actions. Dazu die **polymorphe `EntityLink`-Tabelle** (`entity_type + entity_id`), gegen die jedes Modul andockt. Die **Task-Liste ist als wiederverwendbare Komponente** zu bauen (P12/P14/P16 betten sie ein; erscheint als Tasks-Tab in jeder Entity-Detail-View).
Das **leichtgewichtige, querliegende Tasks-Modul**: Task-CRUD mit Status/Priorität/Fälligkeit, Entitäts-Verknüpfung (Contact/Company/Lead/Projekt), Person- **oder** Gruppen-Zuweisung (genau eines, Default = Ersteller), Watcher, **Reminder via `notify()`** (24h vor / bei / 24h nach Deadline, Eskalation Email) und **Auto-Archivierung** erledigter Tasks nach 30 Tagen per Cron. Tasks entstehen manuell, aus Chat, aus Form-Submission und aus Automation-Actions. Dazu die **polymorphe `EntityLink`-Tabelle** (`entity_type + entity_id`), gegen die jedes Modul andockt. Die **Task-Liste ist als wiederverwendbare Komponente** zu bauen (P12/P14/P16 betten sie ein; erscheint als Tasks-Tab in jeder Entity-Detail-View).
## Benötigte Screens (Backend bedient — Design liefert `D-P4` aus `D-D0a`/`D-D0b`)
| Screen [Route] | Kern-Inhalt | Liefert (Design) |
@@ -50,9 +52,14 @@ Das **leichtgewichtige, querliegende Tasks-Modul**: Task-CRUD mit Status/Priorit
- `_bmad-output/specs/M26-tasks.md`
## Dev-Nachzug (nach Davids `D-P4`-Delta)
- [ ] Task-Liste/-Detail an Davids UI angepasst (wiederverwendbare Komponente)
- [ ] REQUIREMENTS/PHASE-SOURCES nachgezogen
- [ ] Plane-Ticket `AK-P4b` aktualisiert
- [x] **Interim-UI aus `~/ui` gebaut (AD-28, 2026-07-06)** — die drei Screens (Liste/Detail/Neu) sind aus
`~/ui`-Components komponiert (`apps/web/app/components/screens/tasks/`), VM als Prop, kein
handgeschriebenes Tailwind (Ausnahme: native `<input type="date">` via `DateField`, da `~/ui` keine
form-fähige Date-Variante hat — mit Design-Tokens gestyled). Routen rendern nur noch `<XPage vm={} … />`.
- [ ] Task-Liste/-Detail an Davids **finaler** `D-P4`-Optik angepasst (Subframe-Swap hinter der stabilen
VM/Loader-Grenze; `design-nachzug: offen`, Plane-Label `design-nachzug`). **Nicht Done-blockierend (AD-28).**
- [x] REQUIREMENTS nachgezogen (`REQ-tasks-datenmodell` + `REQ-tasks-reminder-archivierung` = Done); PHASE-SOURCES braucht keinen Status-Nachzug (reines Spec-Mapping, M071/M072/M073 bereits Phase 4).
- [x] Plane-Ticket `AK-P4b` aktualisiert (Kommentar + Label `design-nachzug` gesetzt, Status auf Done).
---
@@ -236,3 +243,46 @@ DoD 21 Visual-Regression (keine Snapshot-Harness) · Davids `D-P4`-Optik · Push
gegen echte Postgres verifiziert. Selbst-machbarer Rest auf dem Branch: **0** (alles Weitere ist
extern/infra blockiert oder Reviewer-Freigabe). Gesamt **~95 %** — Ticket bleibt in **„Ready for
Review"** bis Staging-Verifikation + Reviewer-Freigabe der deferrten Punkte.
---
### 2026-07-06 — Interim-UI aus `~/ui` gebaut (AD-28), Baseline-Tailwind abgelöst (Akeshan, Branch `akeshan/ak-p4b-interim-ui`)
Der Code-Kern (8/8 DoD) lag seit Juni auf `main` (Commit `4e3d12607` + Merge-Migration
`20260624120000_p4b_tasks_entitylinks`); offen war nur die UI-Optik. Plane stand auf „Blocked / On Hold"
(blocked-by `D-P4`) — das ist der reine Design-Wait, den AD-28 auflöst. Wie bei AK-P4a (Commit `33f0d01e5`,
05.07.) die drei Tasks-Screens als **Interim-UI aus `~/ui`** neu gebaut, hinter der stabilen VM/Loader-Grenze.
Backend/Loader/Action/VMs/Worker-Crons/Wiring **unverändert**.
**Gebaut:**
- `apps/web/app/components/screens/tasks/`: `TasksListPage.tsx`, `TaskDetailPage.tsx`, `TaskNewPage.tsx`
(+ je `.test.tsx`) und `DateField.tsx` (native Date-Input-Helfer). Komponiert aus `~/ui`-Components
(`Table`, `Select`, `ToggleGroup`, `Checkbox`, `BadgeFeedback`, `Tag_Label`, `Breadcrumbs`, `Card`,
`Dialog`, `Button`, `IconButton`, `TextInput`, `TextArea`, `EmptyState`). VM/`data` als Prop,
kein `useLoaderData` im Screen, kein handgeschriebenes Tailwind für Spacing/Höhen (Ausnahme `DateField`,
dokumentiert: `~/ui` hat keine form-fähige Date-Variante; mit Design-Tokens gestyled, Höhe 36/Radius 4).
- Routen `tasks.{_index,$id,new}.tsx` rendern jetzt nur noch `<XPage vm={} actions={} />`. List-Route:
`onApply` → `useNavigate({search})` (server-side Preset/Filter), plus `nextHref` im Loader (Cursor +
aktive Filter erhalten). Neu-Route: Creator-Default-Assignee über Sentinel `__creator__` (das `~/ui`
`Select` verbietet leere Option-Values), im Action zurück auf „kein expliziter Assignee" gemappt.
- Detail-Löschen als echter `~/ui`-`Dialog` (Größe XS 420), nicht als Card-Fake (David-Standard §6).
- i18n: neue `tasks.list.filter.*`, `tasks.list.pagination.next`, `tasks.detail.section.overview`,
`tasks.detail.field.created`, `tasks.detail.delete.*`, `tasks.detail.dialog.cancel` in de **und** en
(chirurgisch gespliced, kein JSON-Round-Trip; Parität grün).
**Verifikation (lokal grün):** `@hivegrid/web` typecheck exit 0 · Screen-Render-Tests **12/12**
(`screens/tasks/*.test.tsx`) · Hot-Swap-Drill `taskList.hot-swap.test.ts` **3/3** · `verify:i18n` exit 0 ·
`verify:sidebar-routes` 27/27 · `verify:cross-cutting-models` 178/178 · eslint-staged 0 Warnings.
Marker `ui.tasks.list`/`.detail`/`.new` + `data-row-id`/`data-watcher-id`/`data-overdue` erhalten (E2E
`tests/e2e/tasks.spec.ts` bleibt gültig).
**Offen / getrackter Nachzug:**
- **Finale `D-P4`-Subframe-Optik** (David) — `design-nachzug: offen` (Doc-Tag + Plane-Label), Swap hinter
der VM/Loader-Grenze, **nicht Done-blockierend** (AD-28).
- Visual-Regression (DoD 21): Interim-Baseline (Harness deferred, Re-Baseline beim David-Swap).
- Unverändert extern/deferred: Live-Staging Reminder/Archiv (Redis+notify) · RLS-Teil-B (P5-Cutover) ·
Mobile-Tab-Bar (PWA-only AD-17).
**%-Done-Stand:** 8/8 DoD-Code-Punkte grün **+** AD-28-Interim-UI-Konformität hergestellt (Screens aus
`~/ui`, Tests grün). Selbst-machbarer Rest: **0**. Gesamt **~97 %** — nur noch extern/Reviewer-Freigabe +
Davids finale Optik (getrackter `design-nachzug`) offen.
@@ -0,0 +1,86 @@
# BILL-00 · Billing-Recut: Legacy-Provider-Annahmen durch Lago/Mollie ersetzen
| | |
|---|---|
| **Typ** | Dev/Docs |
| **Owner** | Raffael |
| **Phase** | Billing-Recut nach AD-27 |
| **Cycle/Fenster** | zuerst im Billing-Track |
| **Status** | Umgesetzt / bereit fuer Review |
## Abhaengigkeiten & Parallelitaet
- **Blocked by:** AD-27 entschieden.
- **Blocks:** alle weiteren `BILL-*`-Tickets.
- **Parallelitaet:** keine Produktcode-Implementierung; Doku-/Planungs-Recut.
## Was wir bauen
Die bestehenden Legacy-Provider-basierten Billing-Docs wurden dependency-aware auf die neue Zielarchitektur gezogen:
Lago fuer Billing/Invoicing, Mollie nur fuer Payments, HiveGrid fuer Entitlements. Vorhandene Arbeit aus `AK-P20`
wird nicht verworfen, sondern als wiederverwendbare Billing-/Feature-Gate-/Read-only-Grundlage markiert.
## Definition of Done
- [x] `AK-P20` beschreibt nicht mehr Legacy-Provider als Zielsystem, sondern Lago/Mollie-Recut.
- [x] `D-P20`, `VA-P24a`, `VD-P24`, `AK-P19`, `AK-P21` sind an AD-27 angepasst oder mit klarem Recut-Hinweis versehen.
- [x] `EXT-07`/`EXT-16` sind nicht mehr Legacy-Provider-Blocker; Mollie-Test-/Live-Setup ist als neue Setup-Arbeit beschrieben.
- [x] `H-PAYLINK` ist als alter Legacy-Provider-Vertriebsweg ersetzt durch Mollie-/manuellen Zahlungsweg; `H-FOUND` verweist nicht mehr auf Mollie Payment Link.
- [x] Offene Legacy-Provider-Codepfade sind inventarisiert: behalten, entfernen, migrieren oder deaktivieren.
- [x] DoD-Stand der betroffenen Tickets bleibt nachvollziehbar; nichts wurde still auf Done gesetzt.
**Fortschritt:** `BILL-00`: 6/6 DoD = 100 % done. Ticket kann nach Review/Plane-Kommentar weitergeschoben werden.
## Code-Inventar fuer `BILL-02` bis `BILL-05`
**Migrieren auf Lago/Mollie/HiveGrid-Billing:**
- Core-Subscription-Schema: Legacy-Provider-zentrierte Felder in `prisma/schema/subscriptions.prisma` (`legacyProviderId`,
`legacyProviderCustomerId`, `legacyProviderSubscriptionId`, `legacyProviderSubscriptionItemId`, `Legacy-ProviderWebhookLog`).
- DB-Service-Schicht: Legacy-Provider-spezifische Helfer und Typen in `packages/db/src/subscriptions.server.ts`.
- Web-Billing-Adapter: `apps/web/app/utils/legacyProvider.server.ts`, `apps/web/app/routes/webhooks.legacyProvider.tsx`,
`apps/api/src/billing/legacyProvider-webhook-route.ts` und die zugehoerigen Tests als Migrationsvorlage.
- Dunning/Frozen-Vorarbeit: `billingDunningService` und `billingStatusService` behalten fachlich, aber Provider-
Kopplung auf generische Billing-/Payment-Events umbauen.
- Admin-/Settings-Routen: Subscription-/Invoice-/PaymentMethod-Komponenten und Admin-Subscription-Routen, die heute
Legacy-Provider-Typen oder Legacy-Provider-IDs anzeigen.
- Mail-Templates: Invoice/Subscription-Erfolgs-/Fehlertexte von Legacy-Provider-Bezug auf generische Billing-/Payment-Texte
umstellen.
**Deaktivieren oder spaeter separat entscheiden:**
- Portal/Sites-Legacy-Provider-Connect-Code in der Plugin-/Portal-Altlast. Das ist nicht HiveGrid-v1-Core-Billing und darf
nicht in den neuen Billing-Pfad gezogen werden.
- Legacy-Provider-Metering-/Usage-Dokumente und Ops-Runbooks. v1 hat kein Usage-Billing.
**Behalten als harmlose Beispiele/Test-Fixtures, bis ein eigener Cleanup folgt:**
- Plugin-Runtime-Testdaten mit `legacyProvider_*` als generischem Secret-Key-Muster.
- Alte Migrationshistorie, solange sie fuer bestehende DB-Reproduzierbarkeit noetig ist.
## Betroffene Planungsdocs
- `AK-P20`: alter Legacy-Provider-Abschluss ersetzt durch `BILL-02` bis `BILL-05`; AK-P20 bleibt Vorarbeit fuer Trial, Frozen,
Onboarding, Demo-Daten, Checkliste, Theme und Entitlement-Gates.
- `D-P20`: v1-Screens zeigen Plan/Seats, Lago-Invoices, Mollie-Payment-Status, kein Legacy-Provider-Portal.
- `VA-P24a`/`VD-P24`: v2-Self-Service baut auf Lago/Mollie, nicht auf Legacy-Provider-Reuse.
- `AK-P19`: Admin-Abo-Zusammenstellung = Entitlements + Billing-State; Legacy-Provider-Positionen ersetzt durch `BILL-06`.
- `AK-P21`: Tenant-Löschung nutzt generischen Billing-Cancel-Vertrag, nicht Legacy-Provider-Cancel.
- `EXT-07`/`EXT-16`: Mollie-Test-/Live-Setup + Lago-Staging/Prod statt Legacy-Provider.
- `H-PAYLINK`/`H-FOUND`: Founding-Deal-Zahlungsweg ohne Legacy-Provider.
## Technische Bausteine
- Keine Produktcode-Aenderung in `BILL-00`.
- Ergebnis ist ein sauberer Planungsstand fuer `BILL-01` bis `BILL-08`.
## Quellen
- `docs/ARCHITECTURE-DECISIONS.md` AD-15, AD-27
- `.planning/ticket-docs/BILL-README.md`
- `.planning/ticket-docs/AK-P20.md`
- `.planning/ticket-docs/D-P20.md`
- `.planning/ticket-docs/VA-P24a.md`, `.planning/ticket-docs/VD-P24.md`
- `.planning/ticket-docs/EXT-07.md`, `.planning/ticket-docs/EXT-16.md`, `.planning/ticket-docs/H-PAYLINK.md`
## Umsetzungsnotiz
2026-07-04: Doku-Recut umgesetzt. `git diff --check` ist der passende Proof fuer dieses Docs-only-Ticket; Produktcode
wurde bewusst nicht geaendert. Plane-Status/Kommentar stehen noch aus.
@@ -0,0 +1,70 @@
# BILL-01 · Lago-Staging sauber betreiben
| | |
|---|---|
| **Typ** | Setup/Infra |
| **Owner** | Raffael |
| **Phase** | Billing-Recut nach AD-27 |
| **Cycle/Fenster** | nach `BILL-00` |
| **Status** | Umgesetzt / bereit fuer Review |
## Abhaengigkeiten & Parallelitaet
- **Blocked by:** `BILL-00` fuer endgueltige Doku-Namen; Pilot kann parallel validiert werden.
- **Blocks:** `BILL-03`, `BILL-07`.
## Was wir bauen
Eine saubere Lago-Staging-Instanz als Coolify-Service im Estate. Sie ist fuer mehrere HiveGrid-Staging-Flows nutzbar,
aber nicht mit Production gemischt.
## Aktueller Stand
- Coolify-Projekt: `node1-estate`
- Resource: `lago-staging`
- Resource UUID: `kh7l869ianltiioqgmm45kur`
- Server: `node1`
- Oeffentlicher Operator-Zugriff: `https://staging-lago.hivegrid.de` via Lago-Login
- Lokaler SSH-Tunnel aktuell moeglich:
- Frontend `http://127.0.0.1:8188`
- API `http://127.0.0.1:3108`
- Verifiziert 2026-07-04: API `GET /health` liefert `Success`; Frontend liefert `200 OK`.
## Definition of Done
- [x] Lago laeuft als Coolify-Resource, nicht als manueller Raw-Compose-Stack.
- [x] API, Frontend, DB, Redis, Worker, Billing-Worker und PDF-Service starten.
- [x] Alte manuelle Pilot-Instanz entfernt.
- [x] Zugriffsmodell festgelegt: Public Operator-UI via Lago-Login; API intern ueber Mesh, SSH-Tunnel als Fallback.
- [x] Backup-Konzept fuer Lago-Postgres dokumentiert und Restore-Pfad definiert.
- [x] SMTP/Mail-Verhalten fuer Staging entschieden: standardmaessig deaktiviert, Mailjet-Testconfig nur explizit fuer `BILL-07`.
- [x] Dateispeicher fuer Invoice-PDFs entschieden: lokales Coolify-Volume fuer Staging-Pilot, Production spaeter separat.
- [x] Runbook fuer Start/Stop/Upgrade/Restore in `docs/ops/lago-staging.md` dokumentiert.
**Fortschritt:** `BILL-01`: 8/8 DoD = 100 % done.
## Technische Bausteine
- Coolify Docker-Compose-Resource mit `getlago/api:v1.27.1`, `getlago/front:v1.27.1`, Postgres 15, Redis 7.
- Keine Public-Domain ohne Auth.
- Keine Production-Daten.
- Runbook: `docs/ops/lago-staging.md`.
## Quellen
- `docs/ARCHITECTURE-DECISIONS.md` AD-27
- Estate-Infra `../infra/runbooks/migrate-node1-to-coolify.md`
- Lago Self-hosting Docs
## Umsetzungsnotiz
2026-07-04: Staging-Betrieb dokumentiert. Backup- und Restore-Pfad fuer Lago-Postgres, SMTP-Entscheidung,
Invoice-PDF-Storage-Entscheidung, Upgrade/Rollback und Testdaten-Konvention stehen in `docs/ops/lago-staging.md`.
2026-07-05: Zugriffsmodell aktualisiert. Die Operator-UI ist unter `https://staging-lago.hivegrid.de` erreichbar
und durch Lago-Login geschuetzt; die HiveGrid-zu-Lago-API bleibt intern ueber `http://10.42.0.4:3108`. Invoice-PDFs
liegen im Hetzner-Bucket `hivegrid-staging-lago`; Kunden-Downloads laufen ueber HiveGrid.
Proof:
- `docs/ops/lago-staging.md`
- `git diff --check`
Loaded 100 of 3781 files, more files were not shown because too many files have changed in this diff. Show more